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
parent
98a9602bb0
commit
afcf0fba28
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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), "
|
||||
|
|
|
|||
Loading…
Reference in New Issue