NEO-144: address review findings in contract CI gates

Use E7M3 bundle normalization for rep grants, smoke-test contract-seed schema,
and apply cross-ref/band-cap checks to all loaded template rows.
pull/184/head
VinPropane 2026-06-20 19:22:02 -04:00
parent fe9b706092
commit f7a014fe69
1 changed files with 29 additions and 5 deletions

View File

@ -20,6 +20,7 @@ Validates:
- 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 - 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 content/schemas/contract-template.schema.json (NEO-144); completionRewardBundle via quest-reward-bundle.schema.json
- contract-seed schema smoke: content/schemas/contract-seed.schema.json minimal fixture parse (NEO-144)
""" """
from __future__ import annotations from __future__ import annotations
@ -276,7 +277,8 @@ PROTOTYPE_E7M3_COMPLETION_BUNDLES: dict[str, dict] = {
} }
# Epic 7 Slice 4 prototype lock (NEO-144): one contract template + band-1 economy caps. # 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. # Keep in sync with E7.M4 freeze table, future NEO-145 server loader, and NEO-147
# ContractEconomyValidation (port PROTOTYPE_E7M4_BAND_CAPS keys to C# in E7M4-07).
PROTOTYPE_E7M4_TEMPLATE_IDS = frozenset({"prototype_contract_clear_combat_pocket"}) PROTOTYPE_E7M4_TEMPLATE_IDS = frozenset({"prototype_contract_clear_combat_pocket"})
PROTOTYPE_E7M4_TEMPLATE_FREEZE: dict[str, dict] = { PROTOTYPE_E7M4_TEMPLATE_FREEZE: dict[str, dict] = {
"prototype_contract_clear_combat_pocket": { "prototype_contract_clear_combat_pocket": {
@ -291,6 +293,7 @@ PROTOTYPE_E7M4_TEMPLATE_FREEZE: dict[str, dict] = {
"completionRewardBundle": { "completionRewardBundle": {
"itemGrants": [{"itemId": "scrap_metal_bulk", "quantity": 5}], "itemGrants": [{"itemId": "scrap_metal_bulk", "quantity": 5}],
"skillXpGrants": [{"skillId": "salvage", "amount": 15}], "skillXpGrants": [{"skillId": "salvage", "amount": 15}],
"reputationGrants": [],
}, },
}, },
} }
@ -1064,6 +1067,22 @@ def _contract_template_validator() -> Draft202012Validator:
return Draft202012Validator(template_schema, registry=registry) return Draft202012Validator(template_schema, registry=registry)
def _contract_seed_schema_smoke_gate() -> str | None:
"""Return a human-readable error if contract-seed.schema.json fails a minimal fixture parse, else None."""
seed_schema = json.loads(CONTRACT_SEED_SCHEMA.read_text(encoding="utf-8"))
validator = Draft202012Validator(seed_schema)
fixture = {
"playerId": "prototype_player",
"templateId": "prototype_contract_clear_combat_pocket",
"seedBucket": "2026-06-20",
}
errors = sorted(validator.iter_errors(fixture), key=lambda err: err.path)
if errors:
loc = ".".join(str(part) for part in errors[0].path) or "(root)"
return f"error: contract-seed schema smoke fixture {loc}: {errors[0].message}"
return None
def _validate_faction_catalogs( def _validate_faction_catalogs(
*, *,
faction_files: list[Path], faction_files: list[Path],
@ -1649,8 +1668,8 @@ def _prototype_e7m4_template_freeze_gate(id_to_row: dict[str, dict]) -> str | No
bundle = row.get("completionRewardBundle") bundle = row.get("completionRewardBundle")
if not isinstance(bundle, dict): if not isinstance(bundle, dict):
return f"error: contract template {tid!r} must include completionRewardBundle object" return f"error: contract template {tid!r} must include completionRewardBundle object"
actual_bundle = _normalize_completion_bundle(bundle) actual_bundle = _normalize_e7m3_completion_bundle(bundle)
expected_bundle = _normalize_completion_bundle(expected["completionRewardBundle"]) expected_bundle = _normalize_e7m3_completion_bundle(expected["completionRewardBundle"])
if actual_bundle != expected_bundle: if actual_bundle != expected_bundle:
return ( return (
f"error: contract template {tid!r} completionRewardBundle must match E7M4 freeze table " f"error: contract template {tid!r} completionRewardBundle must match E7M4 freeze table "
@ -1666,7 +1685,7 @@ def _prototype_e7m4_cross_ref_gate(
skill_id_to_allowed_kinds: dict[str, list[str]], skill_id_to_allowed_kinds: dict[str, list[str]],
) -> str | None: ) -> str | None:
"""Return a human-readable error if contract template refs fail cross-checks, else None.""" """Return a human-readable error if contract template refs fail cross-checks, else None."""
for tid in sorted(PROTOTYPE_E7M4_TEMPLATE_IDS): for tid in sorted(id_to_row.keys()):
row = id_to_row.get(tid) row = id_to_row.get(tid)
if not isinstance(row, dict): if not isinstance(row, dict):
continue continue
@ -1733,7 +1752,7 @@ def _prototype_e7m4_cross_ref_gate(
def _prototype_e7m4_band_cap_gate(id_to_row: dict[str, dict]) -> str | 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.""" """Return a human-readable error if a template bundle exceeds band caps, else None."""
for tid in sorted(PROTOTYPE_E7M4_TEMPLATE_IDS): for tid in sorted(id_to_row.keys()):
row = id_to_row.get(tid) row = id_to_row.get(tid)
if not isinstance(row, dict): if not isinstance(row, dict):
continue continue
@ -2590,6 +2609,11 @@ def main() -> int:
print(f"content validation failed with {contract_errors} error(s)", file=sys.stderr) print(f"content validation failed with {contract_errors} error(s)", file=sys.stderr)
return 1 return 1
contract_seed_smoke_err = _contract_seed_schema_smoke_gate()
if contract_seed_smoke_err:
print(contract_seed_smoke_err, file=sys.stderr)
return 1
e7m4_template_roster_err = _prototype_e7m4_template_roster_gate(contract_seen_ids) e7m4_template_roster_err = _prototype_e7m4_template_roster_gate(contract_seen_ids)
if e7m4_template_roster_err: if e7m4_template_roster_err:
print(e7m4_template_roster_err, file=sys.stderr) print(e7m4_template_roster_err, file=sys.stderr)