NEO-144: validate contract templates in content CI

Add roster, freeze, cross-ref, and band-1 economy cap gates for E7M4 prototype templates.
pull/184/head
VinPropane 2026-06-20 19:18:58 -04:00
parent 58d2c32152
commit 7ecc8746f5
1 changed files with 320 additions and 0 deletions

View File

@ -18,6 +18,8 @@ Validates:
optional completionRewardBundle via quest-reward-bundle.schema.json (NEO-124); optional completionRewardBundle via quest-reward-bundle.schema.json (NEO-124);
optional factionGateRules + reputationGrants (NEO-133) optional factionGateRules + reputationGrants (NEO-133)
- faction catalogs: content/factions/*_factions.json rows vs content/schemas/faction-def.schema.json (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 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_DEF_SCHEMA = REPO_ROOT / "content/schemas/faction-def.schema.json"
FACTION_GATE_RULE_SCHEMA = REPO_ROOT / "content/schemas/faction-gate-rule.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" 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" 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"
@ -62,6 +66,7 @@ REWARD_TABLES_DIR = REPO_ROOT / "content/reward-tables"
ENCOUNTERS_DIR = REPO_ROOT / "content/encounters" ENCOUNTERS_DIR = REPO_ROOT / "content/encounters"
QUESTS_DIR = REPO_ROOT / "content/quests" QUESTS_DIR = REPO_ROOT / "content/quests"
FACTIONS_DIR = REPO_ROOT / "content/factions" 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. # Slice 1 prototype lock (NEO-33): exact ids + category coverage after schema passes.
PROTOTYPE_SLICE1_SKILL_IDS = frozenset({"salvage", "refine", "intrusion"}) 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: def _normalize_completion_bundle(bundle: dict) -> dict:
"""Normalize grant rows for stable freeze-table comparison.""" """Normalize grant rows for stable freeze-table comparison."""
@ -1011,6 +1043,27 @@ def _quest_def_validator() -> Draft202012Validator:
return Draft202012Validator(def_schema, registry=registry) 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( def _validate_faction_catalogs(
*, *,
faction_files: list[Path], faction_files: list[Path],
@ -1143,6 +1196,55 @@ def _validate_quest_catalogs(
return errors, seen_ids, id_to_row 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: 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.""" """Return a human-readable error if E7M3 five-quest roster fails, else None."""
ids = frozenset(seen_ids.keys()) 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 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: 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:
@ -1891,6 +2166,7 @@ def main() -> int:
encounter_schema = json.loads(ENCOUNTER_DEF_SCHEMA.read_text(encoding="utf-8")) encounter_schema = json.loads(ENCOUNTER_DEF_SCHEMA.read_text(encoding="utf-8"))
encounter_validator = Draft202012Validator(encounter_schema) encounter_validator = Draft202012Validator(encounter_schema)
quest_validator = _quest_def_validator() quest_validator = _quest_def_validator()
contract_validator = _contract_template_validator()
faction_schema = json.loads(FACTION_DEF_SCHEMA.read_text(encoding="utf-8")) faction_schema = json.loads(FACTION_DEF_SCHEMA.read_text(encoding="utf-8"))
faction_validator = Draft202012Validator(faction_schema) 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) print(f"error: no *_factions.json files under {FACTIONS_DIR}", file=sys.stderr)
return 1 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] = {} 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]] = {} skill_id_to_allowed_kinds: dict[str, list[str]] = {}
@ -2297,6 +2582,39 @@ def main() -> int:
print(e7m3_faction_band_err, file=sys.stderr) print(e7m3_faction_band_err, file=sys.stderr)
return 1 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_errors, quest_seen_ids, quest_id_to_row = _validate_quest_catalogs(
quest_files=quest_files, quest_files=quest_files,
quest_validator=quest_validator, quest_validator=quest_validator,
@ -2375,6 +2693,7 @@ def main() -> int:
f"{len(reward_table_files)} reward table catalog file(s), " f"{len(reward_table_files)} reward table catalog file(s), "
f"{len(encounter_files)} encounter catalog file(s), " f"{len(encounter_files)} encounter catalog file(s), "
f"{len(faction_files)} faction 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(quest_files)} quest catalog file(s), "
f"{len(seen_ids)} unique skill id(s), " f"{len(seen_ids)} unique skill id(s), "
f"{len(item_seen_ids)} unique item 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(reward_table_seen_ids)} unique reward table id(s), "
f"{len(encounter_seen_ids)} unique encounter id(s), " f"{len(encounter_seen_ids)} unique encounter id(s), "
f"{len(faction_seen_ids)} unique faction 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(quest_seen_ids)} unique quest id(s), "
f"{len(track_skill_ids)} mastery track(s)" f"{len(track_skill_ids)} mastery track(s)"
) )