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.
pull/163/head
VinPropane 2026-06-07 15:15:52 -04:00
parent 98a9602bb0
commit afcf0fba28
5 changed files with 247 additions and 2 deletions

View File

@ -5,6 +5,9 @@
"id": "prototype_quest_gather_intro", "id": "prototype_quest_gather_intro",
"displayName": "Intro: Salvage Run", "displayName": "Intro: Salvage Run",
"prerequisiteQuestIds": [], "prerequisiteQuestIds": [],
"completionRewardBundle": {
"skillXpGrants": [{ "skillId": "salvage", "amount": 25 }]
},
"steps": [ "steps": [
{ {
"id": "gather_intro_step_salvage", "id": "gather_intro_step_salvage",
@ -24,6 +27,9 @@
"id": "prototype_quest_refine_intro", "id": "prototype_quest_refine_intro",
"displayName": "Intro: Refine Stock", "displayName": "Intro: Refine Stock",
"prerequisiteQuestIds": ["prototype_quest_gather_intro"], "prerequisiteQuestIds": ["prototype_quest_gather_intro"],
"completionRewardBundle": {
"skillXpGrants": [{ "skillId": "refine", "amount": 25 }]
},
"steps": [ "steps": [
{ {
"id": "refine_intro_step_craft", "id": "refine_intro_step_craft",
@ -43,6 +49,9 @@
"id": "prototype_quest_combat_intro", "id": "prototype_quest_combat_intro",
"displayName": "Intro: Clear the Pocket", "displayName": "Intro: Clear the Pocket",
"prerequisiteQuestIds": [], "prerequisiteQuestIds": [],
"completionRewardBundle": {
"skillXpGrants": [{ "skillId": "salvage", "amount": 25 }]
},
"steps": [ "steps": [
{ {
"id": "combat_intro_step_encounter", "id": "combat_intro_step_encounter",
@ -65,6 +74,10 @@
"prototype_quest_refine_intro", "prototype_quest_refine_intro",
"prototype_quest_combat_intro" "prototype_quest_combat_intro"
], ],
"completionRewardBundle": {
"itemGrants": [{ "itemId": "survey_drone_kit", "quantity": 1 }],
"skillXpGrants": [{ "skillId": "salvage", "amount": 50 }]
},
"steps": [ "steps": [
{ {
"id": "chain_step_gather", "id": "chain_step_gather",

View File

@ -32,6 +32,10 @@
"$ref": "https://neon-sprawl.local/schemas/quest-step-def.json" "$ref": "https://neon-sprawl.local/schemas/quest-step-def.json"
}, },
"description": "Ordered steps; prototype Slice 1 uses sequential advance only." "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."
} }
} }
} }

View File

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

View File

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

View File

@ -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) - 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) - 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) - 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 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_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_STEP_DEF_SCHEMA = REPO_ROOT / "content/schemas/quest-step-def.schema.json"
QUEST_DEF_SCHEMA = REPO_ROOT / "content/schemas/quest-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" 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"
@ -170,6 +173,56 @@ PROTOTYPE_E7M1_QUEST_IDS = frozenset(
PROTOTYPE_E7M1_CHAIN_QUEST_ID = "prototype_quest_operator_chain" PROTOTYPE_E7M1_CHAIN_QUEST_ID = "prototype_quest_operator_chain"
PROTOTYPE_E7M1_CHAIN_TERMINAL_ITEM_ID = "contract_handoff_token" 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: 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."""
@ -842,15 +895,21 @@ def _prototype_e5m3_encounter_cross_ref_gate(id_to_row: dict[str, dict]) -> str
def _quest_def_validator() -> Draft202012Validator: 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")) 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")) 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")) 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( registry = Registry().with_resources(
[ [
(def_schema["$id"], Resource.from_contents(def_schema)), (def_schema["$id"], Resource.from_contents(def_schema)),
(step_schema["$id"], Resource.from_contents(step_schema)), (step_schema["$id"], Resource.from_contents(step_schema)),
(objective_schema["$id"], Resource.from_contents(objective_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) 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 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: def _prototype_slice4_gate(track_skill_ids: list[str]) -> str | None:
"""Return a human-readable error if Slice 4 contract fails, else None.""" """Return a human-readable error if Slice 4 contract fails, else None."""
if len(track_skill_ids) != 1: if len(track_skill_ids) != 1:
@ -1415,6 +1550,12 @@ def main() -> int:
if not QUEST_DEF_SCHEMA.is_file(): if not QUEST_DEF_SCHEMA.is_file():
print(f"error: missing schema {QUEST_DEF_SCHEMA}", file=sys.stderr) print(f"error: missing schema {QUEST_DEF_SCHEMA}", file=sys.stderr)
return 1 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_schema = json.loads(SKILL_SCHEMA.read_text(encoding="utf-8"))
skill_validator = Draft202012Validator(skill_schema) skill_validator = Draft202012Validator(skill_schema)
@ -1540,6 +1681,7 @@ def main() -> int:
seen_ids: dict[str, str] = {} seen_ids: dict[str, str] = {}
id_to_category: dict[str, str] = {} id_to_category: dict[str, str] = {}
skill_id_to_allowed_kinds: dict[str, list[str]] = {}
errors = 0 errors = 0
for path in skill_files: for path in skill_files:
@ -1579,6 +1721,11 @@ def main() -> int:
cat = row.get("category") cat = row.get("category")
if isinstance(cat, str): if isinstance(cat, str):
id_to_category[sid] = cat 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: for path in curve_files:
data = json.loads(path.read_text(encoding="utf-8")) data = json.loads(path.read_text(encoding="utf-8"))
@ -1831,6 +1978,24 @@ def main() -> int:
print(e7m1_chain_terminal_err, file=sys.stderr) print(e7m1_chain_terminal_err, file=sys.stderr)
return 1 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( print(
"content OK: " "content OK: "
f"{len(skill_files)} skill catalog file(s), " f"{len(skill_files)} skill catalog file(s), "