diff --git a/scripts/validate_content.py b/scripts/validate_content.py index 13d68fc..76f5de2 100644 --- a/scripts/validate_content.py +++ b/scripts/validate_content.py @@ -14,6 +14,7 @@ Validates: - npc behavior catalogs: content/npc-behaviors/*_npc_behaviors.json rows vs content/schemas/npc-behavior-def.schema.json (NEO-87) - reward table catalogs: content/reward-tables/*_reward_tables.json rows vs content/schemas/reward-table.schema.json (NEO-100) - encounter catalogs: content/encounters/*_encounters.json rows vs content/schemas/encounter-def.schema.json (NEO-100) +- quest catalogs: content/quests/*_quests.json rows vs content/schemas/quest-def.schema.json (NEO-112) """ from __future__ import annotations @@ -39,6 +40,9 @@ NPC_BEHAVIOR_SCHEMA = REPO_ROOT / "content/schemas/npc-behavior-def.schema.json" REWARD_GRANT_ROW_SCHEMA = REPO_ROOT / "content/schemas/reward-grant-row.schema.json" REWARD_TABLE_SCHEMA = REPO_ROOT / "content/schemas/reward-table.schema.json" ENCOUNTER_DEF_SCHEMA = REPO_ROOT / "content/schemas/encounter-def.schema.json" +QUEST_OBJECTIVE_DEF_SCHEMA = REPO_ROOT / "content/schemas/quest-objective-def.schema.json" +QUEST_STEP_DEF_SCHEMA = REPO_ROOT / "content/schemas/quest-step-def.schema.json" +QUEST_DEF_SCHEMA = REPO_ROOT / "content/schemas/quest-def.schema.json" SKILLS_DIR = REPO_ROOT / "content/skills" MASTERY_DIR = REPO_ROOT / "content/mastery" ITEMS_DIR = REPO_ROOT / "content/items" @@ -48,6 +52,7 @@ ABILITIES_DIR = REPO_ROOT / "content/abilities" NPC_BEHAVIORS_DIR = REPO_ROOT / "content/npc-behaviors" REWARD_TABLES_DIR = REPO_ROOT / "content/reward-tables" ENCOUNTERS_DIR = REPO_ROOT / "content/encounters" +QUESTS_DIR = REPO_ROOT / "content/quests" # Slice 1 prototype lock (NEO-33): exact ids + category coverage after schema passes. PROTOTYPE_SLICE1_SKILL_IDS = frozenset({"salvage", "refine", "intrusion"}) @@ -152,6 +157,19 @@ PROTOTYPE_E5M3_REWARD_GRANTS: dict[str, int] = { "contract_handoff_token": 1, } +# Epic 7 Slice 1 prototype lock (NEO-112): exact quest ids after schema passes. +# Keep in sync with E7.M1 freeze table and future NEO-113 server loader. +PROTOTYPE_E7M1_QUEST_IDS = frozenset( + { + "prototype_quest_gather_intro", + "prototype_quest_refine_intro", + "prototype_quest_combat_intro", + "prototype_quest_operator_chain", + } +) +PROTOTYPE_E7M1_CHAIN_QUEST_ID = "prototype_quest_operator_chain" +PROTOTYPE_E7M1_CHAIN_TERMINAL_ITEM_ID = "contract_handoff_token" + 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.""" @@ -823,6 +841,236 @@ def _prototype_e5m3_encounter_cross_ref_gate(id_to_row: dict[str, dict]) -> str return None +def _quest_def_validator() -> Draft202012Validator: + """Build a validator that resolves quest-def $ref to step and objective schemas.""" + objective_schema = json.loads(QUEST_OBJECTIVE_DEF_SCHEMA.read_text(encoding="utf-8")) + step_schema = json.loads(QUEST_STEP_DEF_SCHEMA.read_text(encoding="utf-8")) + def_schema = json.loads(QUEST_DEF_SCHEMA.read_text(encoding="utf-8")) + registry = Registry().with_resources( + [ + (def_schema["$id"], Resource.from_contents(def_schema)), + (step_schema["$id"], Resource.from_contents(step_schema)), + (objective_schema["$id"], Resource.from_contents(objective_schema)), + ] + ) + return Draft202012Validator(def_schema, registry=registry) + + +def _validate_quest_catalogs( + *, + quest_files: list[Path], + quest_validator: Draft202012Validator, +) -> tuple[int, dict[str, str], dict[str, dict]]: + """Validate quest JSON files. Returns (error_count, seen_ids, id_to_row).""" + errors = 0 + seen_ids: dict[str, str] = {} + id_to_row: dict[str, dict] = {} + + for path in quest_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 + + quests = data.get("quests") + if not isinstance(quests, list): + print(f"error: {rel}: expected top-level 'quests' array", file=sys.stderr) + errors += 1 + continue + + for i, row in enumerate(quests): + if not isinstance(row, dict): + print(f"error: {rel}: quests[{i}] must be an object", file=sys.stderr) + errors += 1 + continue + row_schema_errors = 0 + for err in sorted(quest_validator.iter_errors(row), key=lambda e: e.path): + loc = ".".join(str(p) for p in err.path) or "(root)" + print(f"error: {rel} quests[{i}] {loc}: {err.message}", file=sys.stderr) + row_schema_errors += 1 + errors += 1 + qid = row.get("id") + if isinstance(qid, str) and row_schema_errors == 0: + prev = seen_ids.get(qid) + if prev: + print(f"error: duplicate quest id {qid!r} in {prev} and {rel}", file=sys.stderr) + errors += 1 + else: + seen_ids[qid] = rel + id_to_row[qid] = row + + steps = row.get("steps") + if isinstance(steps, list): + step_ids_seen: set[str] = set() + for si, step in enumerate(steps): + if not isinstance(step, dict): + continue + sid = step.get("id") + if isinstance(sid, str): + if sid in step_ids_seen: + print( + f"error: {rel} quests[{i}] steps[{si}]: duplicate step id {sid!r}", + file=sys.stderr, + ) + errors += 1 + else: + step_ids_seen.add(sid) + objectives = step.get("objectives") + if isinstance(objectives, list): + obj_ids_seen: set[str] = set() + for oi, obj in enumerate(objectives): + if not isinstance(obj, dict): + continue + oid = obj.get("id") + if isinstance(oid, str): + if oid in obj_ids_seen: + print( + f"error: {rel} quests[{i}] steps[{si}] objectives[{oi}]: " + f"duplicate objective id {oid!r}", + file=sys.stderr, + ) + errors += 1 + else: + obj_ids_seen.add(oid) + + return errors, seen_ids, id_to_row + + +def _prototype_e7m1_quest_gate(seen_ids: dict[str, str]) -> str | None: + """Return a human-readable error if E7M1 quest contract fails, else None.""" + ids = frozenset(seen_ids.keys()) + if ids != PROTOTYPE_E7M1_QUEST_IDS: + return ( + "error: prototype E7M1 expects exactly quest ids " + f"{sorted(PROTOTYPE_E7M1_QUEST_IDS)!r}, got {sorted(ids)!r}" + ) + return None + + +def _prototype_e7m1_quest_prerequisite_gate(id_to_row: dict[str, dict]) -> str | None: + """Return a human-readable error if prerequisite ids are unknown or cyclic, else None.""" + known = frozenset(id_to_row.keys()) + for qid, row in id_to_row.items(): + prereqs = row.get("prerequisiteQuestIds") + if not isinstance(prereqs, list): + continue + for pid in prereqs: + if isinstance(pid, str) and pid not in known: + return ( + f"error: quest {qid!r}: prerequisiteQuestIds references unknown quest id {pid!r}" + ) + + visiting: set[str] = set() + visited: set[str] = set() + + def dfs(node: str) -> str | None: + if node in visiting: + return f"error: cyclic prerequisiteQuestIds detected involving quest {node!r}" + if node in visited: + return None + visiting.add(node) + row = id_to_row.get(node, {}) + prereqs = row.get("prerequisiteQuestIds", []) + if isinstance(prereqs, list): + for pid in prereqs: + if isinstance(pid, str): + err = dfs(pid) + if err: + return err + visiting.remove(node) + visited.add(node) + return None + + for qid in known: + err = dfs(qid) + if err: + return err + return None + + +def _prototype_e7m1_quest_cross_ref_gate(id_to_row: dict[str, dict]) -> str | None: + """Return a human-readable error if objective catalog cross-refs fail, else None.""" + for qid, row in id_to_row.items(): + steps = row.get("steps") + if not isinstance(steps, list): + continue + for si, step in enumerate(steps): + if not isinstance(step, dict): + continue + objectives = step.get("objectives") + if not isinstance(objectives, list): + continue + for oi, obj in enumerate(objectives): + if not isinstance(obj, dict): + continue + kind = obj.get("kind") + item_id = obj.get("itemId") + if isinstance(item_id, str) and kind in ("gather_item", "inventory_has_item"): + if item_id not in PROTOTYPE_SLICE1_ITEM_IDS: + return ( + f"error: quest {qid!r} steps[{si}] objectives[{oi}]: itemId {item_id!r} " + "is not in the frozen prototype item catalog" + ) + recipe_id = obj.get("recipeId") + if isinstance(recipe_id, str) and kind == "craft_recipe": + if recipe_id not in PROTOTYPE_SLICE3_RECIPE_IDS: + return ( + f"error: quest {qid!r} steps[{si}] objectives[{oi}]: recipeId {recipe_id!r} " + "is not in the frozen prototype recipe catalog" + ) + encounter_id = obj.get("encounterId") + if isinstance(encounter_id, str) and kind == "encounter_complete": + if encounter_id not in PROTOTYPE_E5M3_ENCOUNTER_IDS: + return ( + f"error: quest {qid!r} steps[{si}] objectives[{oi}]: encounterId " + f"{encounter_id!r} is not in the frozen prototype encounter catalog" + ) + return None + + +def _prototype_e7m1_chain_terminal_gate(id_to_row: dict[str, dict]) -> str | None: + """Return a human-readable error if operator chain terminal step fails, else None.""" + chain = id_to_row.get(PROTOTYPE_E7M1_CHAIN_QUEST_ID) + if not isinstance(chain, dict): + return f"error: missing chain quest {PROTOTYPE_E7M1_CHAIN_QUEST_ID!r}" + steps = chain.get("steps") + if not isinstance(steps, list) or len(steps) == 0: + return f"error: chain quest {PROTOTYPE_E7M1_CHAIN_QUEST_ID!r} must have at least one step" + last_step = steps[-1] + if not isinstance(last_step, dict): + return f"error: chain quest {PROTOTYPE_E7M1_CHAIN_QUEST_ID!r} terminal step must be an object" + objectives = last_step.get("objectives") + if not isinstance(objectives, list) or len(objectives) != 1: + return ( + f"error: chain quest {PROTOTYPE_E7M1_CHAIN_QUEST_ID!r} terminal step must have " + "exactly one objective" + ) + obj = objectives[0] + if not isinstance(obj, dict): + return ( + f"error: chain quest {PROTOTYPE_E7M1_CHAIN_QUEST_ID!r} terminal objective must be an object" + ) + if obj.get("kind") != "inventory_has_item": + return ( + f"error: chain quest {PROTOTYPE_E7M1_CHAIN_QUEST_ID!r} terminal objective kind must be " + "'inventory_has_item'" + ) + if obj.get("itemId") != PROTOTYPE_E7M1_CHAIN_TERMINAL_ITEM_ID: + return ( + f"error: chain quest {PROTOTYPE_E7M1_CHAIN_QUEST_ID!r} terminal itemId must be " + f"{PROTOTYPE_E7M1_CHAIN_TERMINAL_ITEM_ID!r}, got {obj.get('itemId')!r}" + ) + if obj.get("quantity") != 1: + return ( + f"error: chain quest {PROTOTYPE_E7M1_CHAIN_QUEST_ID!r} terminal quantity must be 1, " + f"got {obj.get('quantity')!r}" + ) + return None + + 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: @@ -1158,6 +1406,15 @@ def main() -> int: if not ENCOUNTER_DEF_SCHEMA.is_file(): print(f"error: missing schema {ENCOUNTER_DEF_SCHEMA}", file=sys.stderr) return 1 + if not QUEST_OBJECTIVE_DEF_SCHEMA.is_file(): + print(f"error: missing schema {QUEST_OBJECTIVE_DEF_SCHEMA}", file=sys.stderr) + return 1 + if not QUEST_STEP_DEF_SCHEMA.is_file(): + print(f"error: missing schema {QUEST_STEP_DEF_SCHEMA}", file=sys.stderr) + return 1 + if not QUEST_DEF_SCHEMA.is_file(): + print(f"error: missing schema {QUEST_DEF_SCHEMA}", file=sys.stderr) + return 1 skill_schema = json.loads(SKILL_SCHEMA.read_text(encoding="utf-8")) skill_validator = Draft202012Validator(skill_schema) @@ -1179,6 +1436,7 @@ def main() -> int: reward_table_validator = _reward_table_def_validator() encounter_schema = json.loads(ENCOUNTER_DEF_SCHEMA.read_text(encoding="utf-8")) encounter_validator = Draft202012Validator(encounter_schema) + quest_validator = _quest_def_validator() if not SKILLS_DIR.is_dir(): print(f"error: missing directory {SKILLS_DIR}", file=sys.stderr) @@ -1271,6 +1529,15 @@ def main() -> int: print(f"error: no *_encounters.json files under {ENCOUNTERS_DIR}", file=sys.stderr) return 1 + if not QUESTS_DIR.is_dir(): + print(f"error: missing directory {QUESTS_DIR}", file=sys.stderr) + return 1 + + quest_files = sorted(QUESTS_DIR.glob("*_quests.json")) + if not quest_files: + print(f"error: no *_quests.json files under {QUESTS_DIR}", file=sys.stderr) + return 1 + seen_ids: dict[str, str] = {} id_to_category: dict[str, str] = {} errors = 0 @@ -1536,6 +1803,34 @@ def main() -> int: print(e5m3_encounter_cross_ref_err, file=sys.stderr) return 1 + quest_errors, quest_seen_ids, quest_id_to_row = _validate_quest_catalogs( + quest_files=quest_files, + quest_validator=quest_validator, + ) + if quest_errors: + print(f"content validation failed with {quest_errors} error(s)", file=sys.stderr) + return 1 + + e7m1_quest_err = _prototype_e7m1_quest_gate(quest_seen_ids) + if e7m1_quest_err: + print(e7m1_quest_err, file=sys.stderr) + return 1 + + e7m1_prereq_err = _prototype_e7m1_quest_prerequisite_gate(quest_id_to_row) + if e7m1_prereq_err: + print(e7m1_prereq_err, file=sys.stderr) + return 1 + + e7m1_cross_ref_err = _prototype_e7m1_quest_cross_ref_gate(quest_id_to_row) + if e7m1_cross_ref_err: + print(e7m1_cross_ref_err, file=sys.stderr) + return 1 + + e7m1_chain_terminal_err = _prototype_e7m1_chain_terminal_gate(quest_id_to_row) + if e7m1_chain_terminal_err: + print(e7m1_chain_terminal_err, file=sys.stderr) + return 1 + print( "content OK: " f"{len(skill_files)} skill catalog file(s), " @@ -1549,6 +1844,7 @@ def main() -> int: f"{len(npc_behavior_files)} npc behavior catalog file(s), " f"{len(reward_table_files)} reward table catalog file(s), " f"{len(encounter_files)} encounter catalog file(s), " + f"{len(quest_files)} quest 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), " @@ -1557,6 +1853,7 @@ def main() -> int: f"{len(npc_behavior_seen_ids)} unique npc behavior id(s), " f"{len(reward_table_seen_ids)} unique reward table id(s), " f"{len(encounter_seen_ids)} unique encounter id(s), " + f"{len(quest_seen_ids)} unique quest id(s), " f"{len(track_skill_ids)} mastery track(s)" ) return 0