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.
pull/99/head
VinPropane 2026-05-24 16:21:44 -04:00
parent b4b8536081
commit 4c82283bbc
4 changed files with 333 additions and 0 deletions

View File

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

View File

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

View File

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

View File

@ -9,6 +9,7 @@ Validates:
- item catalogs: content/items/*_items.json rows vs content/schemas/item-def.schema.json (NEO-50) - 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 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) - 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 from __future__ import annotations
@ -17,6 +18,7 @@ import json
import sys import sys
from pathlib import Path from pathlib import Path
import jsonschema
from jsonschema import Draft202012Validator from jsonschema import Draft202012Validator
REPO_ROOT = Path(__file__).resolve().parent.parent 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" ITEM_SCHEMA = REPO_ROOT / "content/schemas/item-def.schema.json"
RESOURCE_NODE_SCHEMA = REPO_ROOT / "content/schemas/resource-node-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" 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" SKILLS_DIR = REPO_ROOT / "content/skills"
MASTERY_DIR = REPO_ROOT / "content/mastery" MASTERY_DIR = REPO_ROOT / "content/mastery"
ITEMS_DIR = REPO_ROOT / "content/items" ITEMS_DIR = REPO_ROOT / "content/items"
RESOURCE_NODES_DIR = REPO_ROOT / "content/resource-nodes" 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. # 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"})
@ -67,6 +72,22 @@ PROTOTYPE_SLICE2_GATHER_LENSES = frozenset(
) )
PROTOTYPE_SLICE2_YIELD_ITEM_IDS = frozenset({"scrap_metal_bulk"}) 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: 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.""" """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) 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: def main() -> int:
if not SKILL_SCHEMA.is_file(): if not SKILL_SCHEMA.is_file():
print(f"error: missing schema {SKILL_SCHEMA}", file=sys.stderr) 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(): if not RESOURCE_YIELD_ROW_SCHEMA.is_file():
print(f"error: missing schema {RESOURCE_YIELD_ROW_SCHEMA}", file=sys.stderr) print(f"error: missing schema {RESOURCE_YIELD_ROW_SCHEMA}", file=sys.stderr)
return 1 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_schema = json.loads(SKILL_SCHEMA.read_text(encoding="utf-8"))
skill_validator = Draft202012Validator(skill_schema) skill_validator = Draft202012Validator(skill_schema)
@ -583,6 +726,7 @@ def main() -> int:
resource_node_validator = Draft202012Validator(resource_node_schema) resource_node_validator = Draft202012Validator(resource_node_schema)
resource_yield_schema = json.loads(RESOURCE_YIELD_ROW_SCHEMA.read_text(encoding="utf-8")) resource_yield_schema = json.loads(RESOURCE_YIELD_ROW_SCHEMA.read_text(encoding="utf-8"))
resource_yield_validator = Draft202012Validator(resource_yield_schema) resource_yield_validator = Draft202012Validator(resource_yield_schema)
recipe_validator = _recipe_def_validator()
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)
@ -630,6 +774,15 @@ def main() -> int:
print(f"error: no *_resource_yields.json files under {RESOURCE_NODES_DIR}", file=sys.stderr) print(f"error: no *_resource_yields.json files under {RESOURCE_NODES_DIR}", file=sys.stderr)
return 1 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] = {} seen_ids: dict[str, str] = {}
id_to_category: dict[str, str] = {} id_to_category: dict[str, str] = {}
errors = 0 errors = 0
@ -803,6 +956,21 @@ def main() -> int:
print(slice2_yield_err, file=sys.stderr) print(slice2_yield_err, file=sys.stderr)
return 1 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( print(
"content OK: " "content OK: "
f"{len(skill_files)} skill catalog file(s), " 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(item_files)} item catalog file(s), "
f"{len(node_files)} resource node catalog file(s), " f"{len(node_files)} resource node catalog file(s), "
f"{len(yield_files)} resource yield 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(seen_ids)} unique skill id(s), "
f"{len(item_seen_ids)} unique item id(s), " f"{len(item_seen_ids)} unique item id(s), "
f"{len(node_seen_ids)} unique nodeDefId(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)" f"{len(track_skill_ids)} mastery track(s)"
) )
return 0 return 0