From afcf0fba284cb4b5e0ec8ac936c8af840a21763d Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 7 Jun 2026 15:15:52 -0400 Subject: [PATCH] NEO-124: Add quest completion reward bundle schemas, catalog, and CI gates. Extend quest defs with completionRewardBundle on four frozen quests; validate bundle freeze table and item/skill cross-refs in validate_content.py. --- content/quests/prototype_quests.json | 13 ++ content/schemas/quest-def.schema.json | 4 + .../schemas/quest-reward-bundle.schema.json | 42 +++++ .../schemas/quest-skill-xp-grant.schema.json | 21 +++ scripts/validate_content.py | 169 +++++++++++++++++- 5 files changed, 247 insertions(+), 2 deletions(-) create mode 100644 content/schemas/quest-reward-bundle.schema.json create mode 100644 content/schemas/quest-skill-xp-grant.schema.json diff --git a/content/quests/prototype_quests.json b/content/quests/prototype_quests.json index bdc3857..4ebae45 100644 --- a/content/quests/prototype_quests.json +++ b/content/quests/prototype_quests.json @@ -5,6 +5,9 @@ "id": "prototype_quest_gather_intro", "displayName": "Intro: Salvage Run", "prerequisiteQuestIds": [], + "completionRewardBundle": { + "skillXpGrants": [{ "skillId": "salvage", "amount": 25 }] + }, "steps": [ { "id": "gather_intro_step_salvage", @@ -24,6 +27,9 @@ "id": "prototype_quest_refine_intro", "displayName": "Intro: Refine Stock", "prerequisiteQuestIds": ["prototype_quest_gather_intro"], + "completionRewardBundle": { + "skillXpGrants": [{ "skillId": "refine", "amount": 25 }] + }, "steps": [ { "id": "refine_intro_step_craft", @@ -43,6 +49,9 @@ "id": "prototype_quest_combat_intro", "displayName": "Intro: Clear the Pocket", "prerequisiteQuestIds": [], + "completionRewardBundle": { + "skillXpGrants": [{ "skillId": "salvage", "amount": 25 }] + }, "steps": [ { "id": "combat_intro_step_encounter", @@ -65,6 +74,10 @@ "prototype_quest_refine_intro", "prototype_quest_combat_intro" ], + "completionRewardBundle": { + "itemGrants": [{ "itemId": "survey_drone_kit", "quantity": 1 }], + "skillXpGrants": [{ "skillId": "salvage", "amount": 50 }] + }, "steps": [ { "id": "chain_step_gather", diff --git a/content/schemas/quest-def.schema.json b/content/schemas/quest-def.schema.json index 557ca9d..30ccfbd 100644 --- a/content/schemas/quest-def.schema.json +++ b/content/schemas/quest-def.schema.json @@ -32,6 +32,10 @@ "$ref": "https://neon-sprawl.local/schemas/quest-step-def.json" }, "description": "Ordered steps; prototype Slice 1 uses sequential advance only." + }, + "completionRewardBundle": { + "$ref": "https://neon-sprawl.local/schemas/quest-reward-bundle.json", + "description": "Optional completion payout (E7.M2); required on all four prototype quest ids in CI." } } } diff --git a/content/schemas/quest-reward-bundle.schema.json b/content/schemas/quest-reward-bundle.schema.json new file mode 100644 index 0000000..ae2aa8d --- /dev/null +++ b/content/schemas/quest-reward-bundle.schema.json @@ -0,0 +1,42 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://neon-sprawl.local/schemas/quest-reward-bundle.json", + "title": "QuestRewardBundle", + "description": "Composite completion rewards on QuestDef.completionRewardBundle. Grant kinds v1: itemGrants + skillXpGrants only. See docs/decomposition/modules/E7_M2_RewardAndUnlockRouter.md.", + "type": "object", + "additionalProperties": false, + "properties": { + "itemGrants": { + "type": "array", + "items": { + "$ref": "https://neon-sprawl.local/schemas/reward-grant-row.json" + }, + "description": "Item payout rows; omit when empty (XP-only bundles)." + }, + "skillXpGrants": { + "type": "array", + "items": { + "$ref": "https://neon-sprawl.local/schemas/quest-skill-xp-grant.json" + }, + "description": "Skill XP rows applied via mission_reward on first-time completion." + } + }, + "anyOf": [ + { + "required": ["itemGrants"], + "properties": { + "itemGrants": { + "minItems": 1 + } + } + }, + { + "required": ["skillXpGrants"], + "properties": { + "skillXpGrants": { + "minItems": 1 + } + } + } + ] +} diff --git a/content/schemas/quest-skill-xp-grant.schema.json b/content/schemas/quest-skill-xp-grant.schema.json new file mode 100644 index 0000000..f73f9f3 --- /dev/null +++ b/content/schemas/quest-skill-xp-grant.schema.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://neon-sprawl.local/schemas/quest-skill-xp-grant.json", + "title": "QuestSkillXpGrant", + "description": "Single skill XP row on a quest completionRewardBundle. Apply path uses MissionRewardSkillXpGrant (sourceKind mission_reward) — see E7.M2.", + "type": "object", + "additionalProperties": false, + "required": ["skillId", "amount"], + "properties": { + "skillId": { + "type": "string", + "pattern": "^[a-z][a-z0-9_]*$", + "description": "Skill def id from content/skills (prototype Slice 1: frozen trio)." + }, + "amount": { + "type": "integer", + "minimum": 1, + "description": "Skill XP granted on first-time quest completion." + } + } +} diff --git a/scripts/validate_content.py b/scripts/validate_content.py index b1287b6..fd578de 100644 --- a/scripts/validate_content.py +++ b/scripts/validate_content.py @@ -14,7 +14,8 @@ 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) +- quest catalogs: content/quests/*_quests.json rows vs content/schemas/quest-def.schema.json (NEO-112); + optional completionRewardBundle via quest-reward-bundle.schema.json (NEO-124) """ from __future__ import annotations @@ -43,6 +44,8 @@ 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" +QUEST_REWARD_BUNDLE_SCHEMA = REPO_ROOT / "content/schemas/quest-reward-bundle.schema.json" +QUEST_SKILL_XP_GRANT_SCHEMA = REPO_ROOT / "content/schemas/quest-skill-xp-grant.schema.json" SKILLS_DIR = REPO_ROOT / "content/skills" MASTERY_DIR = REPO_ROOT / "content/mastery" ITEMS_DIR = REPO_ROOT / "content/items" @@ -170,6 +173,56 @@ PROTOTYPE_E7M1_QUEST_IDS = frozenset( PROTOTYPE_E7M1_CHAIN_QUEST_ID = "prototype_quest_operator_chain" PROTOTYPE_E7M1_CHAIN_TERMINAL_ITEM_ID = "contract_handoff_token" +# Epic 7 Slice 2 prototype lock (NEO-124): completionRewardBundle on four frozen quests. +# Keep in sync with E7.M2 freeze table and future NEO-125 server loader. +PROTOTYPE_E7M2_MISSION_REWARD_SOURCE_KIND = "mission_reward" +PROTOTYPE_E7M2_COMPLETION_BUNDLES: dict[str, dict] = { + "prototype_quest_gather_intro": { + "itemGrants": [], + "skillXpGrants": [{"skillId": "salvage", "amount": 25}], + }, + "prototype_quest_refine_intro": { + "itemGrants": [], + "skillXpGrants": [{"skillId": "refine", "amount": 25}], + }, + "prototype_quest_combat_intro": { + "itemGrants": [], + "skillXpGrants": [{"skillId": "salvage", "amount": 25}], + }, + "prototype_quest_operator_chain": { + "itemGrants": [{"itemId": "survey_drone_kit", "quantity": 1}], + "skillXpGrants": [{"skillId": "salvage", "amount": 50}], + }, +} + + +def _normalize_completion_bundle(bundle: dict) -> dict: + """Normalize grant rows for stable freeze-table comparison.""" + item_grants = bundle.get("itemGrants") + skill_xp_grants = bundle.get("skillXpGrants") + return { + "itemGrants": sorted( + [ + {"itemId": grant["itemId"], "quantity": grant["quantity"]} + for grant in (item_grants if isinstance(item_grants, list) else []) + if isinstance(grant, dict) + and isinstance(grant.get("itemId"), str) + and isinstance(grant.get("quantity"), int) + ], + key=lambda grant: (grant["itemId"], grant["quantity"]), + ), + "skillXpGrants": sorted( + [ + {"skillId": grant["skillId"], "amount": grant["amount"]} + for grant in (skill_xp_grants if isinstance(skill_xp_grants, list) else []) + if isinstance(grant, dict) + and isinstance(grant.get("skillId"), str) + and isinstance(grant.get("amount"), int) + ], + key=lambda grant: (grant["skillId"], grant["amount"]), + ), + } + 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.""" @@ -842,15 +895,21 @@ def _prototype_e5m3_encounter_cross_ref_gate(id_to_row: dict[str, dict]) -> str def _quest_def_validator() -> Draft202012Validator: - """Build a validator that resolves quest-def $ref to step and objective schemas.""" + """Build a validator that resolves quest-def $ref to step, objective, and bundle 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")) + bundle_schema = json.loads(QUEST_REWARD_BUNDLE_SCHEMA.read_text(encoding="utf-8")) + skill_xp_grant_schema = json.loads(QUEST_SKILL_XP_GRANT_SCHEMA.read_text(encoding="utf-8")) + grant_row_schema = json.loads(REWARD_GRANT_ROW_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)), + (bundle_schema["$id"], Resource.from_contents(bundle_schema)), + (skill_xp_grant_schema["$id"], Resource.from_contents(skill_xp_grant_schema)), + (grant_row_schema["$id"], Resource.from_contents(grant_row_schema)), ] ) return Draft202012Validator(def_schema, registry=registry) @@ -1071,6 +1130,82 @@ def _prototype_e7m1_chain_terminal_gate(id_to_row: dict[str, dict]) -> str | Non return None +def _prototype_e7m2_completion_bundle_presence_gate(id_to_row: dict[str, dict]) -> str | None: + """Return a human-readable error if a frozen quest lacks completionRewardBundle, else None.""" + for qid in sorted(PROTOTYPE_E7M1_QUEST_IDS): + row = id_to_row.get(qid) + if not isinstance(row, dict): + return f"error: missing quest {qid!r}" + bundle = row.get("completionRewardBundle") + if not isinstance(bundle, dict): + return f"error: quest {qid!r} must include completionRewardBundle object" + return None + + +def _prototype_e7m2_completion_bundle_freeze_gate(id_to_row: dict[str, dict]) -> str | None: + """Return a human-readable error if bundle contents diverge from E7M2 freeze table, else None.""" + for qid, expected in PROTOTYPE_E7M2_COMPLETION_BUNDLES.items(): + row = id_to_row.get(qid) + if not isinstance(row, dict): + return f"error: missing quest {qid!r}" + bundle = row.get("completionRewardBundle") + if not isinstance(bundle, dict): + return f"error: quest {qid!r} must include completionRewardBundle object" + actual = _normalize_completion_bundle(bundle) + expected_normalized = _normalize_completion_bundle(expected) + if actual != expected_normalized: + return ( + f"error: quest {qid!r} completionRewardBundle must match E7M2 freeze table " + f"(expected {expected_normalized!r}, got {actual!r})" + ) + return None + + +def _prototype_e7m2_completion_bundle_cross_ref_gate( + id_to_row: dict[str, dict], + skill_id_to_allowed_kinds: dict[str, list[str]], +) -> str | None: + """Return a human-readable error if bundle refs fail cross-checks, else None.""" + for qid in sorted(PROTOTYPE_E7M1_QUEST_IDS): + row = id_to_row.get(qid) + if not isinstance(row, dict): + continue + bundle = row.get("completionRewardBundle") + if not isinstance(bundle, dict): + continue + item_grants = bundle.get("itemGrants") + if isinstance(item_grants, list): + for gi, grant in enumerate(item_grants): + if not isinstance(grant, dict): + continue + item_id = grant.get("itemId") + if isinstance(item_id, str) and item_id not in PROTOTYPE_SLICE1_ITEM_IDS: + return ( + f"error: quest {qid!r} completionRewardBundle.itemGrants[{gi}]: itemId " + f"{item_id!r} is not in the frozen prototype item catalog" + ) + skill_xp_grants = bundle.get("skillXpGrants") + if isinstance(skill_xp_grants, list): + for gi, grant in enumerate(skill_xp_grants): + if not isinstance(grant, dict): + continue + skill_id = grant.get("skillId") + if isinstance(skill_id, str) and skill_id not in PROTOTYPE_SLICE1_SKILL_IDS: + return ( + f"error: quest {qid!r} completionRewardBundle.skillXpGrants[{gi}]: skillId " + f"{skill_id!r} is not in the frozen prototype skill catalog" + ) + allowed_kinds = skill_id_to_allowed_kinds.get(skill_id, []) + if PROTOTYPE_E7M2_MISSION_REWARD_SOURCE_KIND not in allowed_kinds: + return ( + f"error: quest {qid!r} completionRewardBundle.skillXpGrants[{gi}]: skillId " + f"{skill_id!r} must allow sourceKind " + f"{PROTOTYPE_E7M2_MISSION_REWARD_SOURCE_KIND!r} in allowedXpSourceKinds " + f"(got {allowed_kinds!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: @@ -1415,6 +1550,12 @@ def main() -> int: if not QUEST_DEF_SCHEMA.is_file(): print(f"error: missing schema {QUEST_DEF_SCHEMA}", file=sys.stderr) return 1 + if not QUEST_REWARD_BUNDLE_SCHEMA.is_file(): + print(f"error: missing schema {QUEST_REWARD_BUNDLE_SCHEMA}", file=sys.stderr) + return 1 + if not QUEST_SKILL_XP_GRANT_SCHEMA.is_file(): + print(f"error: missing schema {QUEST_SKILL_XP_GRANT_SCHEMA}", file=sys.stderr) + return 1 skill_schema = json.loads(SKILL_SCHEMA.read_text(encoding="utf-8")) skill_validator = Draft202012Validator(skill_schema) @@ -1540,6 +1681,7 @@ def main() -> int: seen_ids: dict[str, str] = {} id_to_category: dict[str, str] = {} + skill_id_to_allowed_kinds: dict[str, list[str]] = {} errors = 0 for path in skill_files: @@ -1579,6 +1721,11 @@ def main() -> int: cat = row.get("category") if isinstance(cat, str): id_to_category[sid] = cat + allowed_kinds = row.get("allowedXpSourceKinds") + if isinstance(allowed_kinds, list): + skill_id_to_allowed_kinds[sid] = [ + kind for kind in allowed_kinds if isinstance(kind, str) + ] for path in curve_files: data = json.loads(path.read_text(encoding="utf-8")) @@ -1831,6 +1978,24 @@ def main() -> int: print(e7m1_chain_terminal_err, file=sys.stderr) return 1 + e7m2_bundle_presence_err = _prototype_e7m2_completion_bundle_presence_gate(quest_id_to_row) + if e7m2_bundle_presence_err: + print(e7m2_bundle_presence_err, file=sys.stderr) + return 1 + + e7m2_bundle_freeze_err = _prototype_e7m2_completion_bundle_freeze_gate(quest_id_to_row) + if e7m2_bundle_freeze_err: + print(e7m2_bundle_freeze_err, file=sys.stderr) + return 1 + + e7m2_bundle_cross_ref_err = _prototype_e7m2_completion_bundle_cross_ref_gate( + quest_id_to_row, + skill_id_to_allowed_kinds, + ) + if e7m2_bundle_cross_ref_err: + print(e7m2_bundle_cross_ref_err, file=sys.stderr) + return 1 + print( "content OK: " f"{len(skill_files)} skill catalog file(s), "