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
|
- 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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue