NEO-50: Add item-def schema, prototype catalog, and CI gates.

pull/85/head
VinPropane 2026-05-17 21:25:06 -04:00
parent 6616bfbca5
commit 923ae0511c
3 changed files with 249 additions and 0 deletions

View File

@ -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"
}
]
}

View File

@ -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)."
}
}
}

View File

@ -6,6 +6,7 @@ Validates:
- level curves: content/skills/*_level_curve.json vs content/schemas/level-curve.schema.json - 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 - 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) (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 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" SKILL_SCHEMA = REPO_ROOT / "content/schemas/skill-def.schema.json"
LEVEL_CURVE_SCHEMA = REPO_ROOT / "content/schemas/level-curve.schema.json" LEVEL_CURVE_SCHEMA = REPO_ROOT / "content/schemas/level-curve.schema.json"
MASTERY_SCHEMA = REPO_ROOT / "content/schemas/mastery-catalog.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" SKILLS_DIR = REPO_ROOT / "content/skills"
MASTERY_DIR = REPO_ROOT / "content/mastery" 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. # Slice 1 prototype lock (NEO-33): exact ids + category coverage after schema passes.
PROTOTYPE_SLICE1_SKILL_IDS = frozenset({"salvage", "refine", "intrusion"}) 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. # Slice 4 prototype lock (NEO-45): exactly one salvage track across all mastery files.
PROTOTYPE_SLICE4_SALVAGE_SKILL_ID = "salvage" PROTOTYPE_SLICE4_SALVAGE_SKILL_ID = "salvage"
@ -245,6 +263,101 @@ def _validate_mastery_catalogs(
return errors, all_track_skill_ids 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: def _prototype_slice4_gate(track_skill_ids: list[str]) -> str | None:
"""Return a human-readable error if Slice 4 contract fails, else None.""" """Return a human-readable error if Slice 4 contract fails, else None."""
if len(track_skill_ids) != 1: if len(track_skill_ids) != 1:
@ -270,6 +383,9 @@ def main() -> int:
if not MASTERY_SCHEMA.is_file(): if not MASTERY_SCHEMA.is_file():
print(f"error: missing schema {MASTERY_SCHEMA}", file=sys.stderr) print(f"error: missing schema {MASTERY_SCHEMA}", file=sys.stderr)
return 1 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_schema = json.loads(SKILL_SCHEMA.read_text(encoding="utf-8"))
skill_validator = Draft202012Validator(skill_schema) skill_validator = Draft202012Validator(skill_schema)
@ -277,6 +393,8 @@ def main() -> int:
level_curve_validator = Draft202012Validator(level_curve_schema) level_curve_validator = Draft202012Validator(level_curve_schema)
mastery_schema = json.loads(MASTERY_SCHEMA.read_text(encoding="utf-8")) mastery_schema = json.loads(MASTERY_SCHEMA.read_text(encoding="utf-8"))
mastery_validator = Draft202012Validator(mastery_schema) 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(): if not SKILLS_DIR.is_dir():
print(f"error: missing directory {SKILLS_DIR}", file=sys.stderr) 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) print(f"error: no *_mastery.json files under {MASTERY_DIR}", file=sys.stderr)
return 1 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] = {} seen_ids: dict[str, str] = {}
id_to_category: dict[str, str] = {} id_to_category: dict[str, str] = {}
errors = 0 errors = 0
@ -431,12 +558,29 @@ def main() -> int:
print(slice4_err, file=sys.stderr) print(slice4_err, file=sys.stderr)
return 1 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( print(
"content OK: " "content OK: "
f"{len(skill_files)} skill catalog file(s), " f"{len(skill_files)} skill catalog file(s), "
f"{len(curve_files)} level curve file(s), " f"{len(curve_files)} level curve file(s), "
f"{len(mastery_files)} mastery catalog 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(seen_ids)} unique skill id(s), "
f"{len(item_seen_ids)} unique item id(s), "
f"{len(track_skill_ids)} mastery track(s)" f"{len(track_skill_ids)} mastery track(s)"
) )
return 0 return 0