From 7ecc8746f5390c63715894608ebe18fa64e2ff21 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sat, 20 Jun 2026 19:18:58 -0400 Subject: [PATCH] NEO-144: validate contract templates in content CI Add roster, freeze, cross-ref, and band-1 economy cap gates for E7M4 prototype templates. --- scripts/validate_content.py | 320 ++++++++++++++++++++++++++++++++++++ 1 file changed, 320 insertions(+) diff --git a/scripts/validate_content.py b/scripts/validate_content.py index 2e48a60..f69ea29 100644 --- a/scripts/validate_content.py +++ b/scripts/validate_content.py @@ -18,6 +18,8 @@ Validates: optional completionRewardBundle via quest-reward-bundle.schema.json (NEO-124); optional factionGateRules + reputationGrants (NEO-133) - faction catalogs: content/factions/*_factions.json rows vs content/schemas/faction-def.schema.json (NEO-133) +- contract template catalogs: content/contracts/*_contract_templates.json rows vs + content/schemas/contract-template.schema.json (NEO-144); completionRewardBundle via quest-reward-bundle.schema.json """ from __future__ import annotations @@ -51,6 +53,8 @@ QUEST_SKILL_XP_GRANT_SCHEMA = REPO_ROOT / "content/schemas/quest-skill-xp-grant. FACTION_DEF_SCHEMA = REPO_ROOT / "content/schemas/faction-def.schema.json" FACTION_GATE_RULE_SCHEMA = REPO_ROOT / "content/schemas/faction-gate-rule.schema.json" REPUTATION_GRANT_ROW_SCHEMA = REPO_ROOT / "content/schemas/reputation-grant-row.schema.json" +CONTRACT_TEMPLATE_SCHEMA = REPO_ROOT / "content/schemas/contract-template.schema.json" +CONTRACT_SEED_SCHEMA = REPO_ROOT / "content/schemas/contract-seed.schema.json" SKILLS_DIR = REPO_ROOT / "content/skills" MASTERY_DIR = REPO_ROOT / "content/mastery" ITEMS_DIR = REPO_ROOT / "content/items" @@ -62,6 +66,7 @@ REWARD_TABLES_DIR = REPO_ROOT / "content/reward-tables" ENCOUNTERS_DIR = REPO_ROOT / "content/encounters" QUESTS_DIR = REPO_ROOT / "content/quests" FACTIONS_DIR = REPO_ROOT / "content/factions" +CONTRACTS_DIR = REPO_ROOT / "content/contracts" # Slice 1 prototype lock (NEO-33): exact ids + category coverage after schema passes. PROTOTYPE_SLICE1_SKILL_IDS = frozenset({"salvage", "refine", "intrusion"}) @@ -270,6 +275,33 @@ PROTOTYPE_E7M3_COMPLETION_BUNDLES: dict[str, dict] = { }, } +# Epic 7 Slice 4 prototype lock (NEO-144): one contract template + band-1 economy caps. +# Keep in sync with E7.M4 freeze table and future NEO-145 server loader. +PROTOTYPE_E7M4_TEMPLATE_IDS = frozenset({"prototype_contract_clear_combat_pocket"}) +PROTOTYPE_E7M4_TEMPLATE_FREEZE: dict[str, dict] = { + "prototype_contract_clear_combat_pocket": { + "displayName": "Clear Combat Pocket (Repeat)", + "zoneDifficultyBand": 1, + "objectiveKind": "encounter_clear", + "encounterTemplateId": "prototype_combat_pocket", + "minFactionStanding": { + "factionId": "prototype_faction_grid_operators", + "minStanding": 0, + }, + "completionRewardBundle": { + "itemGrants": [{"itemId": "scrap_metal_bulk", "quantity": 5}], + "skillXpGrants": [{"skillId": "salvage", "amount": 15}], + }, + }, +} +PROTOTYPE_E7M4_BAND_CAPS: dict[int, dict[str, int]] = { + 1: { + "maxItemQuantity": 10, + "maxSkillXpAmount": 25, + "maxReputationAmount": 10, + }, +} + def _normalize_completion_bundle(bundle: dict) -> dict: """Normalize grant rows for stable freeze-table comparison.""" @@ -1011,6 +1043,27 @@ def _quest_def_validator() -> Draft202012Validator: return Draft202012Validator(def_schema, registry=registry) +def _contract_template_validator() -> Draft202012Validator: + """Build a validator that resolves contract-template $ref to bundle and gate schemas.""" + template_schema = json.loads(CONTRACT_TEMPLATE_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")) + faction_gate_rule_schema = json.loads(FACTION_GATE_RULE_SCHEMA.read_text(encoding="utf-8")) + reputation_grant_row_schema = json.loads(REPUTATION_GRANT_ROW_SCHEMA.read_text(encoding="utf-8")) + registry = Registry().with_resources( + [ + (template_schema["$id"], Resource.from_contents(template_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)), + (faction_gate_rule_schema["$id"], Resource.from_contents(faction_gate_rule_schema)), + (reputation_grant_row_schema["$id"], Resource.from_contents(reputation_grant_row_schema)), + ] + ) + return Draft202012Validator(template_schema, registry=registry) + + def _validate_faction_catalogs( *, faction_files: list[Path], @@ -1143,6 +1196,55 @@ def _validate_quest_catalogs( return errors, seen_ids, id_to_row +def _validate_contract_catalogs( + *, + contract_files: list[Path], + contract_validator: Draft202012Validator, +) -> tuple[int, dict[str, str], dict[str, dict]]: + """Validate contract template 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 contract_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 + + contract_templates = data.get("contractTemplates") + if not isinstance(contract_templates, list): + print(f"error: {rel}: expected top-level 'contractTemplates' array", file=sys.stderr) + errors += 1 + continue + + for i, row in enumerate(contract_templates): + if not isinstance(row, dict): + print(f"error: {rel}: contractTemplates[{i}] must be an object", file=sys.stderr) + errors += 1 + continue + row_schema_errors = 0 + for err in sorted(contract_validator.iter_errors(row), key=lambda e: e.path): + loc = ".".join(str(p) for p in err.path) or "(root)" + print(f"error: {rel} contractTemplates[{i}] {loc}: {err.message}", file=sys.stderr) + row_schema_errors += 1 + errors += 1 + tid = row.get("id") + if isinstance(tid, str) and row_schema_errors == 0: + prev = seen_ids.get(tid) + if prev: + print(f"error: duplicate contract template id {tid!r} in {prev} and {rel}", file=sys.stderr) + errors += 1 + else: + seen_ids[tid] = rel + id_to_row[tid] = row + + return errors, seen_ids, id_to_row + + def _prototype_e7m3_quest_roster_gate(seen_ids: dict[str, str]) -> str | None: """Return a human-readable error if E7M3 five-quest roster fails, else None.""" ids = frozenset(seen_ids.keys()) @@ -1510,6 +1612,179 @@ def _prototype_e7m3_grid_contract_shape_gate(id_to_row: dict[str, dict]) -> str return None +def _prototype_e7m4_template_roster_gate(seen_ids: dict[str, str]) -> str | None: + """Return a human-readable error if E7M4 template roster fails, else None.""" + ids = frozenset(seen_ids.keys()) + if ids != PROTOTYPE_E7M4_TEMPLATE_IDS: + return ( + "error: prototype E7M4 expects exactly contract template ids " + f"{sorted(PROTOTYPE_E7M4_TEMPLATE_IDS)!r}, got {sorted(ids)!r}" + ) + return None + + +def _prototype_e7m4_template_freeze_gate(id_to_row: dict[str, dict]) -> str | None: + """Return a human-readable error if template rows diverge from E7M4 freeze table, else None.""" + for tid, expected in PROTOTYPE_E7M4_TEMPLATE_FREEZE.items(): + row = id_to_row.get(tid) + if not isinstance(row, dict): + return f"error: missing contract template {tid!r}" + for field in ( + "displayName", + "zoneDifficultyBand", + "objectiveKind", + "encounterTemplateId", + ): + if row.get(field) != expected[field]: + return ( + f"error: contract template {tid!r} {field} must be " + f"{expected[field]!r}, got {row.get(field)!r}" + ) + min_standing = row.get("minFactionStanding") + if min_standing != expected["minFactionStanding"]: + return ( + f"error: contract template {tid!r} minFactionStanding must be " + f"{expected['minFactionStanding']!r}, got {min_standing!r}" + ) + bundle = row.get("completionRewardBundle") + if not isinstance(bundle, dict): + return f"error: contract template {tid!r} must include completionRewardBundle object" + actual_bundle = _normalize_completion_bundle(bundle) + expected_bundle = _normalize_completion_bundle(expected["completionRewardBundle"]) + if actual_bundle != expected_bundle: + return ( + f"error: contract template {tid!r} completionRewardBundle must match E7M4 freeze table " + f"(expected {expected_bundle!r}, got {actual_bundle!r})" + ) + return None + + +def _prototype_e7m4_cross_ref_gate( + id_to_row: dict[str, dict], + encounter_seen_ids: dict[str, str], + faction_seen_ids: dict[str, str], + skill_id_to_allowed_kinds: dict[str, list[str]], +) -> str | None: + """Return a human-readable error if contract template refs fail cross-checks, else None.""" + for tid in sorted(PROTOTYPE_E7M4_TEMPLATE_IDS): + row = id_to_row.get(tid) + if not isinstance(row, dict): + continue + encounter_id = row.get("encounterTemplateId") + if isinstance(encounter_id, str) and encounter_id not in encounter_seen_ids: + return ( + f"error: contract template {tid!r} encounterTemplateId {encounter_id!r} " + "is not in the loaded encounter catalog" + ) + min_standing = row.get("minFactionStanding") + if isinstance(min_standing, dict): + faction_id = min_standing.get("factionId") + if isinstance(faction_id, str) and faction_id not in faction_seen_ids: + return ( + f"error: contract template {tid!r} minFactionStanding.factionId " + f"{faction_id!r} is not in the frozen prototype faction catalog" + ) + 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: contract template {tid!r} completionRewardBundle.itemGrants[{gi}]: " + f"itemId {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: contract template {tid!r} completionRewardBundle.skillXpGrants[{gi}]: " + f"skillId {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: contract template {tid!r} completionRewardBundle.skillXpGrants[{gi}]: " + f"skillId {skill_id!r} must allow sourceKind " + f"{PROTOTYPE_E7M2_MISSION_REWARD_SOURCE_KIND!r} in allowedXpSourceKinds " + f"(got {allowed_kinds!r})" + ) + reputation_grants = bundle.get("reputationGrants") + if isinstance(reputation_grants, list): + for gi, grant in enumerate(reputation_grants): + if not isinstance(grant, dict): + continue + faction_id = grant.get("factionId") + if isinstance(faction_id, str) and faction_id not in faction_seen_ids: + return ( + f"error: contract template {tid!r} completionRewardBundle.reputationGrants[{gi}]: " + f"factionId {faction_id!r} is not in the frozen prototype faction catalog" + ) + return None + + +def _prototype_e7m4_band_cap_gate(id_to_row: dict[str, dict]) -> str | None: + """Return a human-readable error if a template bundle exceeds band caps, else None.""" + for tid in sorted(PROTOTYPE_E7M4_TEMPLATE_IDS): + row = id_to_row.get(tid) + if not isinstance(row, dict): + continue + band = row.get("zoneDifficultyBand") + if not isinstance(band, int): + continue + caps = PROTOTYPE_E7M4_BAND_CAPS.get(band) + if caps is None: + return ( + f"error: contract template {tid!r} zoneDifficultyBand {band} has no " + "prototype economy cap policy" + ) + 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 + quantity = grant.get("quantity") + if isinstance(quantity, int) and quantity > caps["maxItemQuantity"]: + return ( + f"error: contract template {tid!r} completionRewardBundle.itemGrants[{gi}] " + f"quantity {quantity} exceeds band {band} cap {caps['maxItemQuantity']}" + ) + 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 + amount = grant.get("amount") + if isinstance(amount, int) and amount > caps["maxSkillXpAmount"]: + return ( + f"error: contract template {tid!r} completionRewardBundle.skillXpGrants[{gi}] " + f"amount {amount} exceeds band {band} cap {caps['maxSkillXpAmount']}" + ) + reputation_grants = bundle.get("reputationGrants") + if isinstance(reputation_grants, list): + for gi, grant in enumerate(reputation_grants): + if not isinstance(grant, dict): + continue + amount = grant.get("amount") + if isinstance(amount, int) and abs(amount) > caps["maxReputationAmount"]: + return ( + f"error: contract template {tid!r} completionRewardBundle.reputationGrants[{gi}] " + f"amount {amount} exceeds band {band} rep cap {caps['maxReputationAmount']}" + ) + 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: @@ -1891,6 +2166,7 @@ def main() -> int: encounter_schema = json.loads(ENCOUNTER_DEF_SCHEMA.read_text(encoding="utf-8")) encounter_validator = Draft202012Validator(encounter_schema) quest_validator = _quest_def_validator() + contract_validator = _contract_template_validator() faction_schema = json.loads(FACTION_DEF_SCHEMA.read_text(encoding="utf-8")) faction_validator = Draft202012Validator(faction_schema) @@ -2003,6 +2279,15 @@ def main() -> int: print(f"error: no *_factions.json files under {FACTIONS_DIR}", file=sys.stderr) return 1 + if not CONTRACTS_DIR.is_dir(): + print(f"error: missing directory {CONTRACTS_DIR}", file=sys.stderr) + return 1 + + contract_files = sorted(CONTRACTS_DIR.glob("*_contract_templates.json")) + if not contract_files: + print(f"error: no *_contract_templates.json files under {CONTRACTS_DIR}", file=sys.stderr) + return 1 + seen_ids: dict[str, str] = {} id_to_category: dict[str, str] = {} skill_id_to_allowed_kinds: dict[str, list[str]] = {} @@ -2297,6 +2582,39 @@ def main() -> int: print(e7m3_faction_band_err, file=sys.stderr) return 1 + contract_errors, contract_seen_ids, contract_id_to_row = _validate_contract_catalogs( + contract_files=contract_files, + contract_validator=contract_validator, + ) + if contract_errors: + print(f"content validation failed with {contract_errors} error(s)", file=sys.stderr) + return 1 + + e7m4_template_roster_err = _prototype_e7m4_template_roster_gate(contract_seen_ids) + if e7m4_template_roster_err: + print(e7m4_template_roster_err, file=sys.stderr) + return 1 + + e7m4_template_freeze_err = _prototype_e7m4_template_freeze_gate(contract_id_to_row) + if e7m4_template_freeze_err: + print(e7m4_template_freeze_err, file=sys.stderr) + return 1 + + e7m4_cross_ref_err = _prototype_e7m4_cross_ref_gate( + contract_id_to_row, + encounter_seen_ids, + faction_seen_ids, + skill_id_to_allowed_kinds, + ) + if e7m4_cross_ref_err: + print(e7m4_cross_ref_err, file=sys.stderr) + return 1 + + e7m4_band_cap_err = _prototype_e7m4_band_cap_gate(contract_id_to_row) + if e7m4_band_cap_err: + print(e7m4_band_cap_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, @@ -2375,6 +2693,7 @@ def main() -> int: f"{len(reward_table_files)} reward table catalog file(s), " f"{len(encounter_files)} encounter catalog file(s), " f"{len(faction_files)} faction catalog file(s), " + f"{len(contract_files)} contract template 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), " @@ -2385,6 +2704,7 @@ def main() -> int: f"{len(reward_table_seen_ids)} unique reward table id(s), " f"{len(encounter_seen_ids)} unique encounter id(s), " f"{len(faction_seen_ids)} unique faction id(s), " + f"{len(contract_seen_ids)} unique contract template id(s), " f"{len(quest_seen_ids)} unique quest id(s), " f"{len(track_skill_ids)} mastery track(s)" )