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
parent
b4b8536081
commit
4c82283bbc
|
|
@ -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 }]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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)."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue