From 4c82283bbc325456ba6e9435efe16d0b72cf7d84 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 24 May 2026 16:21:44 -0400 Subject: [PATCH] NEO-65: Add prototype recipe catalog, schemas, and CI gates. Lock eight frozen RecipeDef rows with JSON Schema validation, duplicate-id rejection, Slice 3 allowlist, and cross-checks against item and skill catalogs. --- content/recipes/prototype_recipes.json | 87 +++++++++++ content/schemas/recipe-def.schema.json | 55 +++++++ content/schemas/recipe-io-row.schema.json | 21 +++ scripts/validate_content.py | 170 ++++++++++++++++++++++ 4 files changed, 333 insertions(+) create mode 100644 content/recipes/prototype_recipes.json create mode 100644 content/schemas/recipe-def.schema.json create mode 100644 content/schemas/recipe-io-row.schema.json diff --git a/content/recipes/prototype_recipes.json b/content/recipes/prototype_recipes.json new file mode 100644 index 0000000..9702ae7 --- /dev/null +++ b/content/recipes/prototype_recipes.json @@ -0,0 +1,87 @@ +{ + "schemaVersion": 1, + "recipes": [ + { + "id": "refine_scrap_standard", + "displayName": "Refine Scrap (Standard)", + "recipeKind": "process", + "requiredSkillId": "refine", + "inputs": [{ "itemId": "scrap_metal_bulk", "quantity": 5 }], + "outputs": [{ "itemId": "refined_plate_stock", "quantity": 1 }] + }, + { + "id": "refine_scrap_efficient", + "displayName": "Refine Scrap (Efficient)", + "recipeKind": "process", + "requiredSkillId": "refine", + "inputs": [{ "itemId": "scrap_metal_bulk", "quantity": 10 }], + "outputs": [{ "itemId": "refined_plate_stock", "quantity": 2 }] + }, + { + "id": "make_field_stim_mk0", + "displayName": "Make Field Stim Mk0", + "recipeKind": "make", + "requiredSkillId": "refine", + "inputs": [ + { "itemId": "refined_plate_stock", "quantity": 2 }, + { "itemId": "scrap_metal_bulk", "quantity": 1 } + ], + "outputs": [{ "itemId": "field_stim_mk0", "quantity": 1 }] + }, + { + "id": "make_field_stim_batch", + "displayName": "Make Field Stim (Batch)", + "recipeKind": "make", + "requiredSkillId": "refine", + "inputs": [ + { "itemId": "refined_plate_stock", "quantity": 5 }, + { "itemId": "scrap_metal_bulk", "quantity": 3 } + ], + "outputs": [{ "itemId": "field_stim_mk0", "quantity": 3 }] + }, + { + "id": "make_prototype_armor", + "displayName": "Make Prototype Armor Shell", + "recipeKind": "make", + "requiredSkillId": "refine", + "inputs": [ + { "itemId": "refined_plate_stock", "quantity": 8 }, + { "itemId": "scrap_metal_bulk", "quantity": 5 } + ], + "outputs": [{ "itemId": "prototype_armor_shell", "quantity": 1 }] + }, + { + "id": "make_armor_quick", + "displayName": "Make Armor (Quick)", + "recipeKind": "make", + "requiredSkillId": "refine", + "inputs": [ + { "itemId": "refined_plate_stock", "quantity": 4 }, + { "itemId": "scrap_metal_bulk", "quantity": 3 } + ], + "outputs": [{ "itemId": "prototype_armor_shell", "quantity": 1 }] + }, + { + "id": "make_survey_drone_kit", + "displayName": "Make Survey Drone Kit", + "recipeKind": "make", + "requiredSkillId": "refine", + "inputs": [ + { "itemId": "refined_plate_stock", "quantity": 3 }, + { "itemId": "scrap_metal_bulk", "quantity": 2 } + ], + "outputs": [{ "itemId": "survey_drone_kit", "quantity": 1 }] + }, + { + "id": "make_contract_token", + "displayName": "Make Contract Handoff Token", + "recipeKind": "make", + "requiredSkillId": "refine", + "inputs": [ + { "itemId": "refined_plate_stock", "quantity": 2 }, + { "itemId": "scrap_metal_bulk", "quantity": 1 } + ], + "outputs": [{ "itemId": "contract_handoff_token", "quantity": 1 }] + } + ] +} diff --git a/content/schemas/recipe-def.schema.json b/content/schemas/recipe-def.schema.json new file mode 100644 index 0000000..3592c37 --- /dev/null +++ b/content/schemas/recipe-def.schema.json @@ -0,0 +1,55 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://neon-sprawl.local/schemas/recipe-def.json", + "title": "RecipeDef", + "description": "Single recipe row for catalogs (e.g. content/recipes/*_recipes.json). IDs are stable forever—rename display in displayName only. See docs/decomposition/modules/E3_M2_RefinementAndRecipeExecution.md.", + "type": "object", + "additionalProperties": false, + "required": ["id", "displayName", "recipeKind", "requiredSkillId", "inputs", "outputs"], + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z][a-z0-9_]*$", + "description": "Immutable recipe key for craft HTTP, quests, telemetry, and catalog lookup." + }, + "displayName": { + "type": "string", + "minLength": 1, + "description": "Player-facing label; safe to change without migrating character data." + }, + "recipeKind": { + "type": "string", + "description": "process = refine/intermediate; make = finished good.", + "enum": ["process", "make"] + }, + "requiredSkillId": { + "type": "string", + "pattern": "^[a-z][a-z0-9_]*$", + "description": "SkillDef id gating craft execution (prototype Slice 3: refine on all rows)." + }, + "inputs": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "https://neon-sprawl.local/schemas/recipe-io-row.json" + } + }, + "outputs": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "https://neon-sprawl.local/schemas/recipe-io-row.json" + } + }, + "requiredSkillLevel": { + "type": "integer", + "minimum": 0, + "description": "Optional level gate stub (unused in prototype Slice 3 rows)." + }, + "stationTag": { + "type": "string", + "minLength": 1, + "description": "Optional station/bench requirement stub (unused in prototype Slice 3)." + } + } +} diff --git a/content/schemas/recipe-io-row.schema.json b/content/schemas/recipe-io-row.schema.json new file mode 100644 index 0000000..c2b9fb4 --- /dev/null +++ b/content/schemas/recipe-io-row.schema.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://neon-sprawl.local/schemas/recipe-io-row.json", + "title": "RecipeIoRow", + "description": "Single input or output row on a RecipeDef (content/recipes/*_recipes.json).", + "type": "object", + "additionalProperties": false, + "required": ["itemId", "quantity"], + "properties": { + "itemId": { + "type": "string", + "pattern": "^[a-z][a-z0-9_]*$", + "description": "Item def id from content/items (prototype Slice 3: frozen six-item catalog)." + }, + "quantity": { + "type": "integer", + "minimum": 1, + "description": "Units consumed (input) or granted (output) per craft execution." + } + } +} diff --git a/scripts/validate_content.py b/scripts/validate_content.py index b32abe8..c16f917 100644 --- a/scripts/validate_content.py +++ b/scripts/validate_content.py @@ -9,6 +9,7 @@ Validates: - item catalogs: content/items/*_items.json rows vs content/schemas/item-def.schema.json (NEO-50) - resource node catalogs: content/resource-nodes/*_resource_nodes.json vs resource-node-def.schema.json (NEO-57) - resource yield catalogs: content/resource-nodes/*_resource_yields.json vs resource-yield-row.schema.json (NEO-57) +- recipe catalogs: content/recipes/*_recipes.json rows vs content/schemas/recipe-def.schema.json (NEO-65) """ from __future__ import annotations @@ -17,6 +18,7 @@ import json import sys from pathlib import Path +import jsonschema from jsonschema import Draft202012Validator REPO_ROOT = Path(__file__).resolve().parent.parent @@ -26,10 +28,13 @@ MASTERY_SCHEMA = REPO_ROOT / "content/schemas/mastery-catalog.schema.json" ITEM_SCHEMA = REPO_ROOT / "content/schemas/item-def.schema.json" RESOURCE_NODE_SCHEMA = REPO_ROOT / "content/schemas/resource-node-def.schema.json" RESOURCE_YIELD_ROW_SCHEMA = REPO_ROOT / "content/schemas/resource-yield-row.schema.json" +RECIPE_IO_ROW_SCHEMA = REPO_ROOT / "content/schemas/recipe-io-row.schema.json" +RECIPE_SCHEMA = REPO_ROOT / "content/schemas/recipe-def.schema.json" SKILLS_DIR = REPO_ROOT / "content/skills" MASTERY_DIR = REPO_ROOT / "content/mastery" ITEMS_DIR = REPO_ROOT / "content/items" RESOURCE_NODES_DIR = REPO_ROOT / "content/resource-nodes" +RECIPES_DIR = REPO_ROOT / "content/recipes" # Slice 1 prototype lock (NEO-33): exact ids + category coverage after schema passes. PROTOTYPE_SLICE1_SKILL_IDS = frozenset({"salvage", "refine", "intrusion"}) @@ -67,6 +72,22 @@ PROTOTYPE_SLICE2_GATHER_LENSES = frozenset( ) PROTOTYPE_SLICE2_YIELD_ITEM_IDS = frozenset({"scrap_metal_bulk"}) +# Slice 3 prototype lock (NEO-65): exact recipe ids + recipeKind coverage after schema passes. +# Keep in sync with E3.M2 freeze table and future NEO-66 server loader. +PROTOTYPE_SLICE3_RECIPE_IDS = frozenset( + { + "refine_scrap_standard", + "refine_scrap_efficient", + "make_field_stim_mk0", + "make_field_stim_batch", + "make_prototype_armor", + "make_armor_quick", + "make_survey_drone_kit", + "make_contract_token", + } +) +PROTOTYPE_SLICE3_REFINE_SKILL_ID = "refine" + def _prototype_slice1_gate(seen_ids: dict[str, str], id_to_category: dict[str, str]) -> str | None: """Return a human-readable error if Slice 1 contract fails, else None.""" @@ -551,6 +572,122 @@ def _validate_resource_yield_catalogs( return errors, frozenset(seen_node_ids.keys()), frozenset(yield_item_ids) +def _recipe_def_validator() -> Draft202012Validator: + """Build a validator that resolves recipe-def $ref to recipe-io-row schema.""" + io_schema = json.loads(RECIPE_IO_ROW_SCHEMA.read_text(encoding="utf-8")) + def_schema = json.loads(RECIPE_SCHEMA.read_text(encoding="utf-8")) + schema_store = {io_schema["$id"]: io_schema, def_schema["$id"]: def_schema} + resolver = jsonschema.RefResolver.from_schema(def_schema, store=schema_store) + return Draft202012Validator(def_schema, resolver=resolver) + + +def _prototype_slice3_recipe_gate( + seen_ids: dict[str, str], + id_to_kind: dict[str, str], + id_to_skill: dict[str, str], +) -> str | None: + """Return a human-readable error if Slice 3 recipe contract fails, else None.""" + ids = frozenset(seen_ids.keys()) + if ids != PROTOTYPE_SLICE3_RECIPE_IDS: + return ( + "error: prototype Slice 3 expects exactly recipe ids " + f"{sorted(PROTOTYPE_SLICE3_RECIPE_IDS)!r}, got {sorted(ids)!r}" + ) + kinds = set(id_to_kind.values()) + if "process" not in kinds or "make" not in kinds: + return ( + "error: prototype Slice 3 requires at least one process and one make recipe; " + f"recipeKind values seen: {sorted(kinds)!r}" + ) + for rid, skill_id in id_to_skill.items(): + if skill_id != PROTOTYPE_SLICE3_REFINE_SKILL_ID: + return ( + f"error: {rid!r} requiredSkillId must be {PROTOTYPE_SLICE3_REFINE_SKILL_ID!r}, " + f"got {skill_id!r}" + ) + return None + + +def _validate_recipe_catalogs( + *, + recipe_files: list[Path], + recipe_validator: Draft202012Validator, + known_item_ids: frozenset[str], + known_skill_ids: frozenset[str], +) -> tuple[int, dict[str, str], dict[str, str], dict[str, str]]: + """Validate recipe JSON files. Returns (error_count, seen_ids, id_to_kind, id_to_skill).""" + errors = 0 + seen_ids: dict[str, str] = {} + id_to_kind: dict[str, str] = {} + id_to_skill: dict[str, str] = {} + + for path in recipe_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 + + recipes = data.get("recipes") + if not isinstance(recipes, list): + print(f"error: {rel}: expected top-level 'recipes' array", file=sys.stderr) + errors += 1 + continue + + for i, row in enumerate(recipes): + if not isinstance(row, dict): + print(f"error: {rel}: recipes[{i}] must be an object", file=sys.stderr) + errors += 1 + continue + row_schema_errors = 0 + for err in sorted(recipe_validator.iter_errors(row), key=lambda e: e.path): + loc = ".".join(str(p) for p in err.path) or "(root)" + print(f"error: {rel} recipes[{i}] {loc}: {err.message}", file=sys.stderr) + row_schema_errors += 1 + errors += 1 + rid = row.get("id") + if isinstance(rid, str) and row_schema_errors == 0: + prev = seen_ids.get(rid) + if prev: + print(f"error: duplicate recipe id {rid!r} in {prev} and {rel}", file=sys.stderr) + errors += 1 + else: + seen_ids[rid] = rel + recipe_kind = row.get("recipeKind") + if isinstance(recipe_kind, str): + id_to_kind[rid] = recipe_kind + skill_id = row.get("requiredSkillId") + if isinstance(skill_id, str): + id_to_skill[rid] = skill_id + if skill_id not in known_skill_ids: + print( + f"error: {rel} recipes[{i}]: requiredSkillId {skill_id!r} " + f"is not a known SkillDef id (known: {sorted(known_skill_ids)!r})", + file=sys.stderr, + ) + errors += 1 + + for io_key in ("inputs", "outputs"): + io_rows = row.get(io_key) + if not isinstance(io_rows, list): + continue + for j, io_row in enumerate(io_rows): + if not isinstance(io_row, dict): + continue + item_id = io_row.get("itemId") + if isinstance(item_id, str) and item_id not in known_item_ids: + print( + f"error: {rel} recipes[{i}].{io_key}[{j}]: itemId {item_id!r} " + "is not in item catalogs", + file=sys.stderr, + ) + errors += 1 + + return errors, seen_ids, id_to_kind, id_to_skill + + def main() -> int: if not SKILL_SCHEMA.is_file(): print(f"error: missing schema {SKILL_SCHEMA}", file=sys.stderr) @@ -570,6 +707,12 @@ def main() -> int: if not RESOURCE_YIELD_ROW_SCHEMA.is_file(): print(f"error: missing schema {RESOURCE_YIELD_ROW_SCHEMA}", file=sys.stderr) return 1 + if not RECIPE_IO_ROW_SCHEMA.is_file(): + print(f"error: missing schema {RECIPE_IO_ROW_SCHEMA}", file=sys.stderr) + return 1 + if not RECIPE_SCHEMA.is_file(): + print(f"error: missing schema {RECIPE_SCHEMA}", file=sys.stderr) + return 1 skill_schema = json.loads(SKILL_SCHEMA.read_text(encoding="utf-8")) skill_validator = Draft202012Validator(skill_schema) @@ -583,6 +726,7 @@ def main() -> int: resource_node_validator = Draft202012Validator(resource_node_schema) resource_yield_schema = json.loads(RESOURCE_YIELD_ROW_SCHEMA.read_text(encoding="utf-8")) resource_yield_validator = Draft202012Validator(resource_yield_schema) + recipe_validator = _recipe_def_validator() if not SKILLS_DIR.is_dir(): print(f"error: missing directory {SKILLS_DIR}", file=sys.stderr) @@ -630,6 +774,15 @@ def main() -> int: print(f"error: no *_resource_yields.json files under {RESOURCE_NODES_DIR}", file=sys.stderr) return 1 + if not RECIPES_DIR.is_dir(): + print(f"error: missing directory {RECIPES_DIR}", file=sys.stderr) + return 1 + + recipe_files = sorted(RECIPES_DIR.glob("*_recipes.json")) + if not recipe_files: + print(f"error: no *_recipes.json files under {RECIPES_DIR}", file=sys.stderr) + return 1 + seen_ids: dict[str, str] = {} id_to_category: dict[str, str] = {} errors = 0 @@ -803,6 +956,21 @@ def main() -> int: print(slice2_yield_err, file=sys.stderr) return 1 + recipe_errors, recipe_seen_ids, id_to_kind, id_to_skill = _validate_recipe_catalogs( + recipe_files=recipe_files, + recipe_validator=recipe_validator, + known_item_ids=frozenset(item_seen_ids.keys()), + known_skill_ids=frozenset(seen_ids.keys()), + ) + if recipe_errors: + print(f"content validation failed with {recipe_errors} error(s)", file=sys.stderr) + return 1 + + slice3_recipe_err = _prototype_slice3_recipe_gate(recipe_seen_ids, id_to_kind, id_to_skill) + if slice3_recipe_err: + print(slice3_recipe_err, file=sys.stderr) + return 1 + print( "content OK: " f"{len(skill_files)} skill catalog file(s), " @@ -811,9 +979,11 @@ def main() -> int: f"{len(item_files)} item catalog file(s), " f"{len(node_files)} resource node catalog file(s), " f"{len(yield_files)} resource yield catalog file(s), " + f"{len(recipe_files)} recipe catalog file(s), " f"{len(seen_ids)} unique skill id(s), " f"{len(item_seen_ids)} unique item id(s), " f"{len(node_seen_ids)} unique nodeDefId(s), " + f"{len(recipe_seen_ids)} unique recipe id(s), " f"{len(track_skill_ids)} mastery track(s)" ) return 0