From 923ae0511ce3552b548ae4db4f30b6b55f8a979f Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 17 May 2026 21:25:06 -0400 Subject: [PATCH] NEO-50: Add item-def schema, prototype catalog, and CI gates. --- content/items/prototype_items.json | 47 +++++++++ content/schemas/item-def.schema.json | 58 +++++++++++ scripts/validate_content.py | 144 +++++++++++++++++++++++++++ 3 files changed, 249 insertions(+) create mode 100644 content/items/prototype_items.json create mode 100644 content/schemas/item-def.schema.json diff --git a/content/items/prototype_items.json b/content/items/prototype_items.json new file mode 100644 index 0000000..7632f7c --- /dev/null +++ b/content/items/prototype_items.json @@ -0,0 +1,47 @@ +{ + "schemaVersion": 1, + "items": [ + { + "id": "scrap_metal_bulk", + "displayName": "Scrap Metal (Bulk)", + "prototypeRole": "material", + "stackMax": 999, + "inventorySlotKind": "bag" + }, + { + "id": "refined_plate_stock", + "displayName": "Refined Plate Stock", + "prototypeRole": "intermediate", + "stackMax": 999, + "inventorySlotKind": "bag" + }, + { + "id": "field_stim_mk0", + "displayName": "Field Stim Mk0", + "prototypeRole": "consumable", + "stackMax": 20, + "inventorySlotKind": "bag" + }, + { + "id": "survey_drone_kit", + "displayName": "Survey Drone Kit", + "prototypeRole": "utility", + "stackMax": 1, + "inventorySlotKind": "bag" + }, + { + "id": "contract_handoff_token", + "displayName": "Contract Handoff Token", + "prototypeRole": "quest_token", + "stackMax": 1, + "inventorySlotKind": "bag" + }, + { + "id": "prototype_armor_shell", + "displayName": "Prototype Armor Shell", + "prototypeRole": "equip_stub", + "stackMax": 1, + "inventorySlotKind": "equipment" + } + ] +} diff --git a/content/schemas/item-def.schema.json b/content/schemas/item-def.schema.json new file mode 100644 index 0000000..1c0e271 --- /dev/null +++ b/content/schemas/item-def.schema.json @@ -0,0 +1,58 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://neon-sprawl.local/schemas/item-def.json", + "title": "ItemDef", + "description": "Single item row for catalogs (e.g. content/items/*_items.json). IDs are stable forever—rename display in displayName only. See docs/decomposition/modules/E3_M3_ItemizationAndInventorySchema.md.", + "type": "object", + "additionalProperties": false, + "required": ["id", "displayName", "prototypeRole", "stackMax", "inventorySlotKind"], + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z][a-z0-9_]*$", + "description": "Immutable item key for saves, recipes, quests, telemetry, and inventory grants. Never change after content ships; add a new id if the item splits." + }, + "displayName": { + "type": "string", + "minLength": 1, + "description": "Player-facing label; safe to change without migrating character data." + }, + "prototypeRole": { + "type": "string", + "description": "Prototype Slice 1 archetype (material through equip_stub). CI requires exactly one row per role.", + "enum": [ + "material", + "intermediate", + "consumable", + "utility", + "quest_token", + "equip_stub" + ] + }, + "stackMax": { + "type": "integer", + "minimum": 1, + "description": "Maximum stack size per inventory slot for this item def." + }, + "inventorySlotKind": { + "type": "string", + "description": "Where the item may be stored: general bag vs equipment slot (prototype equip stub).", + "enum": ["bag", "equipment"] + }, + "rarity": { + "type": "string", + "description": "Optional rarity band (unused in prototype Slice 1 rows).", + "enum": ["common", "uncommon", "rare", "epic"] + }, + "bindPolicy": { + "type": "string", + "description": "Optional bind rule stub (unused in prototype Slice 1 rows).", + "enum": ["none", "bind_on_acquire", "bind_on_equip"] + }, + "durabilityMax": { + "type": "integer", + "minimum": 0, + "description": "Optional durability ceiling stub (unused in prototype Slice 1; no durability mutation in v1)." + } + } +} diff --git a/scripts/validate_content.py b/scripts/validate_content.py index 97b9db4..4b8de83 100644 --- a/scripts/validate_content.py +++ b/scripts/validate_content.py @@ -6,6 +6,7 @@ Validates: - level curves: content/skills/*_level_curve.json vs content/schemas/level-curve.schema.json - mastery catalogs: content/mastery/*_mastery.json vs content/schemas/mastery-catalog.schema.json (post-schema: tierIndex unique and sequential 1..N per track, aligned with server boot — NEO-46) +- item catalogs: content/items/*_items.json rows vs content/schemas/item-def.schema.json (NEO-50) """ from __future__ import annotations @@ -20,12 +21,29 @@ REPO_ROOT = Path(__file__).resolve().parent.parent SKILL_SCHEMA = REPO_ROOT / "content/schemas/skill-def.schema.json" LEVEL_CURVE_SCHEMA = REPO_ROOT / "content/schemas/level-curve.schema.json" MASTERY_SCHEMA = REPO_ROOT / "content/schemas/mastery-catalog.schema.json" +ITEM_SCHEMA = REPO_ROOT / "content/schemas/item-def.schema.json" SKILLS_DIR = REPO_ROOT / "content/skills" MASTERY_DIR = REPO_ROOT / "content/mastery" +ITEMS_DIR = REPO_ROOT / "content/items" # Slice 1 prototype lock (NEO-33): exact ids + category coverage after schema passes. PROTOTYPE_SLICE1_SKILL_IDS = frozenset({"salvage", "refine", "intrusion"}) +# Slice 1 prototype lock (NEO-50): exact item ids + prototypeRole coverage after schema passes. +PROTOTYPE_SLICE1_ITEM_IDS = frozenset( + { + "scrap_metal_bulk", + "refined_plate_stock", + "field_stim_mk0", + "survey_drone_kit", + "contract_handoff_token", + "prototype_armor_shell", + } +) +PROTOTYPE_SLICE1_ITEM_ROLES = frozenset( + {"material", "intermediate", "consumable", "utility", "quest_token", "equip_stub"} +) + # Slice 4 prototype lock (NEO-45): exactly one salvage track across all mastery files. PROTOTYPE_SLICE4_SALVAGE_SKILL_ID = "salvage" @@ -245,6 +263,101 @@ def _validate_mastery_catalogs( return errors, all_track_skill_ids +def _prototype_slice1_item_gate( + seen_ids: dict[str, str], + id_to_role: dict[str, str], + id_to_slot_kind: dict[str, str], + id_to_stack_max: dict[str, int], +) -> str | None: + """Return a human-readable error if Slice 1 item contract fails, else None.""" + ids = frozenset(seen_ids.keys()) + if ids != PROTOTYPE_SLICE1_ITEM_IDS: + return ( + "error: prototype Slice 1 expects exactly item ids " + f"{sorted(PROTOTYPE_SLICE1_ITEM_IDS)!r}, got {sorted(ids)!r}" + ) + roles = set(id_to_role.values()) + if roles != PROTOTYPE_SLICE1_ITEM_ROLES: + return ( + "error: prototype Slice 1 requires exactly one row per prototypeRole " + f"{sorted(PROTOTYPE_SLICE1_ITEM_ROLES)!r}, roles seen: {sorted(roles)!r}" + ) + if len(id_to_role) != len(roles): + return "error: prototype Slice 1 duplicate prototypeRole across item rows" + armor_id = "prototype_armor_shell" + if id_to_role.get(armor_id) == "equip_stub": + if id_to_slot_kind.get(armor_id) != "equipment": + return ( + f"error: {armor_id!r} (equip_stub) must use inventorySlotKind 'equipment', " + f"got {id_to_slot_kind.get(armor_id)!r}" + ) + if id_to_stack_max.get(armor_id) != 1: + return ( + f"error: {armor_id!r} (equip_stub) must use stackMax 1, " + f"got {id_to_stack_max.get(armor_id)!r}" + ) + return None + + +def _validate_item_catalogs( + *, + item_files: list[Path], + item_validator: Draft202012Validator, +) -> tuple[int, dict[str, str], dict[str, str], dict[str, str], dict[str, int]]: + """Validate item JSON files. Returns (error_count, seen_ids, id_to_role, id_to_slot_kind, id_to_stack_max).""" + errors = 0 + seen_ids: dict[str, str] = {} + id_to_role: dict[str, str] = {} + id_to_slot_kind: dict[str, str] = {} + id_to_stack_max: dict[str, int] = {} + + for path in item_files: + rel = str(path.relative_to(REPO_ROOT)) + data = json.loads(path.read_text(encoding="utf-8")) + schema_version = data.get("schemaVersion") + if schema_version != 1: + print(f"error: {rel}: expected schemaVersion 1, got {schema_version!r}", file=sys.stderr) + errors += 1 + continue + + items = data.get("items") + if not isinstance(items, list): + print(f"error: {rel}: expected top-level 'items' array", file=sys.stderr) + errors += 1 + continue + + for i, row in enumerate(items): + if not isinstance(row, dict): + print(f"error: {rel}: items[{i}] must be an object", file=sys.stderr) + errors += 1 + continue + row_schema_errors = 0 + for err in sorted(item_validator.iter_errors(row), key=lambda e: e.path): + loc = ".".join(str(p) for p in err.path) or "(root)" + print(f"error: {rel} items[{i}] {loc}: {err.message}", file=sys.stderr) + row_schema_errors += 1 + errors += 1 + iid = row.get("id") + if isinstance(iid, str) and row_schema_errors == 0: + prev = seen_ids.get(iid) + if prev: + print(f"error: duplicate item id {iid!r} in {prev} and {rel}", file=sys.stderr) + errors += 1 + else: + seen_ids[iid] = rel + role = row.get("prototypeRole") + if isinstance(role, str): + id_to_role[iid] = role + slot_kind = row.get("inventorySlotKind") + if isinstance(slot_kind, str): + id_to_slot_kind[iid] = slot_kind + stack_max = row.get("stackMax") + if isinstance(stack_max, int): + id_to_stack_max[iid] = stack_max + + return errors, seen_ids, id_to_role, id_to_slot_kind, id_to_stack_max + + def _prototype_slice4_gate(track_skill_ids: list[str]) -> str | None: """Return a human-readable error if Slice 4 contract fails, else None.""" if len(track_skill_ids) != 1: @@ -270,6 +383,9 @@ def main() -> int: if not MASTERY_SCHEMA.is_file(): print(f"error: missing schema {MASTERY_SCHEMA}", file=sys.stderr) return 1 + if not ITEM_SCHEMA.is_file(): + print(f"error: missing schema {ITEM_SCHEMA}", file=sys.stderr) + return 1 skill_schema = json.loads(SKILL_SCHEMA.read_text(encoding="utf-8")) skill_validator = Draft202012Validator(skill_schema) @@ -277,6 +393,8 @@ def main() -> int: level_curve_validator = Draft202012Validator(level_curve_schema) mastery_schema = json.loads(MASTERY_SCHEMA.read_text(encoding="utf-8")) mastery_validator = Draft202012Validator(mastery_schema) + item_schema = json.loads(ITEM_SCHEMA.read_text(encoding="utf-8")) + item_validator = Draft202012Validator(item_schema) if not SKILLS_DIR.is_dir(): print(f"error: missing directory {SKILLS_DIR}", file=sys.stderr) @@ -301,6 +419,15 @@ def main() -> int: print(f"error: no *_mastery.json files under {MASTERY_DIR}", file=sys.stderr) return 1 + if not ITEMS_DIR.is_dir(): + print(f"error: missing directory {ITEMS_DIR}", file=sys.stderr) + return 1 + + item_files = sorted(ITEMS_DIR.glob("*_items.json")) + if not item_files: + print(f"error: no *_items.json files under {ITEMS_DIR}", file=sys.stderr) + return 1 + seen_ids: dict[str, str] = {} id_to_category: dict[str, str] = {} errors = 0 @@ -431,12 +558,29 @@ def main() -> int: print(slice4_err, file=sys.stderr) return 1 + item_errors, item_seen_ids, id_to_role, id_to_slot_kind, id_to_stack_max = _validate_item_catalogs( + item_files=item_files, + item_validator=item_validator, + ) + if item_errors: + print(f"content validation failed with {item_errors} error(s)", file=sys.stderr) + return 1 + + slice1_item_err = _prototype_slice1_item_gate( + item_seen_ids, id_to_role, id_to_slot_kind, id_to_stack_max + ) + if slice1_item_err: + print(slice1_item_err, file=sys.stderr) + return 1 + print( "content OK: " f"{len(skill_files)} skill catalog file(s), " f"{len(curve_files)} level curve file(s), " f"{len(mastery_files)} mastery catalog file(s), " + f"{len(item_files)} item catalog file(s), " f"{len(seen_ids)} unique skill id(s), " + f"{len(item_seen_ids)} unique item id(s), " f"{len(track_skill_ids)} mastery track(s)" ) return 0