NEO-50: Add item-def schema, prototype catalog, and CI gates.
parent
6616bfbca5
commit
923ae0511c
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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)."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue