NEO-100: Add encounter and reward-table catalogs with CI validation

Ship frozen prototype_combat_pocket spine (schemas, JSON catalogs, validate_content
gates) and update E5.M3, CT.M1, and alignment docs for E5M3-01 completion.
pull/139/head
VinPropane 2026-05-30 19:35:02 -04:00
parent 4f16c848db
commit ee5e1a0caf
11 changed files with 446 additions and 7 deletions

View File

@ -0,0 +1,16 @@
{
"schemaVersion": 1,
"encounters": [
{
"id": "prototype_combat_pocket",
"displayName": "Prototype Combat Pocket",
"completionCriteria": { "kind": "defeat_all_targets" },
"requiredNpcInstanceIds": [
"prototype_npc_melee",
"prototype_npc_ranged",
"prototype_npc_elite"
],
"rewardTableId": "prototype_combat_pocket_clear"
}
]
}

View File

@ -0,0 +1,13 @@
{
"schemaVersion": 1,
"rewardTables": [
{
"id": "prototype_combat_pocket_clear",
"displayName": "Prototype Combat Pocket Clear",
"fixedGrants": [
{ "itemId": "scrap_metal_bulk", "quantity": 10 },
{ "itemId": "contract_handoff_token", "quantity": 1 }
]
}
]
}

View File

@ -0,0 +1,54 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://neon-sprawl.local/schemas/encounter-def.json",
"title": "EncounterDef",
"description": "Single encounter template row for catalogs (e.g. content/encounters/*_encounters.json). IDs are stable forever—rename display in displayName only. See docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md.",
"type": "object",
"additionalProperties": false,
"required": [
"id",
"displayName",
"completionCriteria",
"requiredNpcInstanceIds",
"rewardTableId"
],
"properties": {
"id": {
"type": "string",
"pattern": "^[a-z][a-z0-9_]*$",
"description": "Immutable encounter key for progress tracking and completion events."
},
"displayName": {
"type": "string",
"minLength": 1,
"description": "Player-facing label; safe to change without migrating character data."
},
"completionCriteria": {
"type": "object",
"additionalProperties": false,
"required": ["kind"],
"properties": {
"kind": {
"type": "string",
"description": "Prototype Slice 3: defeat all listed NPC instances.",
"enum": ["defeat_all_targets"]
}
}
},
"requiredNpcInstanceIds": {
"type": "array",
"minItems": 3,
"maxItems": 3,
"items": {
"type": "string",
"pattern": "^[a-z][a-z0-9_]*$"
},
"description": "NPC instance ids from server PrototypeNpcRegistry (CI cross-ref set gate)."
},
"rewardTableId": {
"type": "string",
"pattern": "^[a-z][a-z0-9_]*$",
"description": "RewardTable id granted once on encounter complete."
}
}
}

View File

@ -0,0 +1,21 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://neon-sprawl.local/schemas/reward-grant-row.json",
"title": "RewardGrantRow",
"description": "Single fixed-grant row on a RewardTable (content/reward-tables/*_reward_tables.json).",
"type": "object",
"additionalProperties": false,
"required": ["itemId", "quantity"],
"properties": {
"itemId": {
"type": "string",
"pattern": "^[a-z][a-z0-9_]*$",
"description": "Item def id from content/items (prototype Slice 1: frozen six-item catalog)."
},
"quantity": {
"type": "integer",
"minimum": 1,
"description": "Units granted on encounter complete."
}
}
}

View File

@ -0,0 +1,28 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://neon-sprawl.local/schemas/reward-table.json",
"title": "RewardTable",
"description": "Single reward table row for catalogs (e.g. content/reward-tables/*_reward_tables.json). IDs are stable forever—rename display in displayName only. See docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md.",
"type": "object",
"additionalProperties": false,
"required": ["id", "displayName", "fixedGrants"],
"properties": {
"id": {
"type": "string",
"pattern": "^[a-z][a-z0-9_]*$",
"description": "Immutable reward table key for encounter binding and loot routing."
},
"displayName": {
"type": "string",
"minLength": 1,
"description": "Player-facing label; safe to change without migrating character data."
},
"fixedGrants": {
"type": "array",
"minItems": 1,
"items": {
"$ref": "https://neon-sprawl.local/schemas/reward-grant-row.json"
}
}
}
}

View File

@ -55,6 +55,10 @@ Content tooling **Slice 1** — schemas + CI; **Slice 4** — server parity hard
**NPC behavior catalogs ([NEO-87](https://linear.app/neon-sprawl/issue/NEO-87)):** the same script validates each row in `content/npc-behaviors/*_npc_behaviors.json` against [`content/schemas/npc-behavior-def.schema.json`](../../../content/schemas/npc-behavior-def.schema.json), rejects **duplicate `id`** across files, requires **`leashRadius` > `aggroRadius`** per row, and (E5 Slice 2) enforces the **frozen three-behavior** id set (`prototype_melee_pressure`, `prototype_ranged_control`, `prototype_elite_mini_boss`). **NPC behavior catalogs ([NEO-87](https://linear.app/neon-sprawl/issue/NEO-87)):** the same script validates each row in `content/npc-behaviors/*_npc_behaviors.json` against [`content/schemas/npc-behavior-def.schema.json`](../../../content/schemas/npc-behavior-def.schema.json), rejects **duplicate `id`** across files, requires **`leashRadius` > `aggroRadius`** per row, and (E5 Slice 2) enforces the **frozen three-behavior** id set (`prototype_melee_pressure`, `prototype_ranged_control`, `prototype_elite_mini_boss`).
**Reward table catalogs ([NEO-100](https://linear.app/neon-sprawl/issue/NEO-100)):** the same script validates each row in `content/reward-tables/*_reward_tables.json` against [`content/schemas/reward-table.schema.json`](../../../content/schemas/reward-table.schema.json) (grant rows via [`reward-grant-row.schema.json`](../../../content/schemas/reward-grant-row.schema.json)), rejects **duplicate `id`** across files, cross-checks every **`fixedGrants[].itemId`** against item catalogs, and (E5 Slice 3) enforces the **frozen one-table** id set (`prototype_combat_pocket_clear`) with fixed grant quantities.
**Encounter catalogs ([NEO-100](https://linear.app/neon-sprawl/issue/NEO-100)):** the same script validates each row in `content/encounters/*_encounters.json` against [`content/schemas/encounter-def.schema.json`](../../../content/schemas/encounter-def.schema.json), rejects **duplicate `id`** across files, cross-checks **`rewardTableId`** against reward table catalogs, and (E5 Slice 3) enforces the **frozen one-encounter** id set (`prototype_combat_pocket`) with **`requiredNpcInstanceIds`** matching the three E5.M2 NPC instance ids.
**Decomposition vocabulary:** the same workflow runs [`scripts/check_decomposition_language.py`](../../../scripts/check_decomposition_language.py) (stdlib only) over `docs/decomposition/**/*.md` to catch wording that treats **combat** as a leveled **`SkillDef`** line instead of **gig** progression (see script docstring for patterns). Adjust the script if a future doc needs a documented exception. **Decomposition vocabulary:** the same workflow runs [`scripts/check_decomposition_language.py`](../../../scripts/check_decomposition_language.py) (stdlib only) over `docs/decomposition/**/*.md` to catch wording that treats **combat** as a leveled **`SkillDef`** line instead of **gig** progression (see script docstring for patterns). Adjust the script if a future doc needs a documented exception.
## Source anchors ## Source anchors

View File

@ -74,6 +74,8 @@ The **first shipped encounter + reward spine** is **frozen** for prototype tunin
|------------------|---------------| |------------------|---------------|
| **`prototype_combat_pocket_clear`** | **`scrap_metal_bulk` ×10**, **`contract_handoff_token` ×1** | | **`prototype_combat_pocket_clear`** | **`scrap_metal_bulk` ×10**, **`contract_handoff_token` ×1** |
**CI enforcement (NEO-100):** `scripts/validate_content.py` requires **exactly** encounter id **`prototype_combat_pocket`** and reward table id **`prototype_combat_pocket_clear`**; **`requiredNpcInstanceIds`** set must match server **`PrototypeNpcRegistry`** (`prototype_npc_melee`, `prototype_npc_ranged`, `prototype_npc_elite`); **`fixedGrants`** item ids from frozen item catalog with quantities **`scrap_metal_bulk` ×10**, **`contract_handoff_token` ×1**; encounter **`rewardTableId`** cross-ref. Keep **`PROTOTYPE_E5M3_*`** and **`PROTOTYPE_E5M2_NPC_INSTANCE_IDS`** in sync with this table and [NEO-101](https://linear.app/neon-sprawl/issue/NEO-101) server loader. Plan: [NEO-100 implementation plan](../../plans/NEO-100-implementation-plan.md).
**Payout policy:** Per-defeat **gig XP** stays on [NEO-44](../../plans/NEO-44-implementation-plan.md). Encounter complete grants **loot + quest token** **once** per player per encounter id. Full **E7.M2** quest credit routing consumes **`EncounterCompleteEvent`** (E5M3-09 hook stub). **Payout policy:** Per-defeat **gig XP** stays on [NEO-44](../../plans/NEO-44-implementation-plan.md). Encounter complete grants **loot + quest token** **once** per player per encounter id. Full **E7.M2** quest credit routing consumes **`EncounterCompleteEvent`** (E5M3-09 hook stub).
## Source anchors ## Source anchors

File diff suppressed because one or more lines are too long

View File

@ -92,9 +92,11 @@ Working backlog for **Epic 5 — Slice 3** ([Epic 5 · Slice 3 — encounters an
**Acceptance criteria** **Acceptance criteria**
- [ ] PR gate validates encounter + reward-table JSON against schema. - [x] PR gate validates encounter + reward-table JSON against schema.
- [ ] Exactly one prototype encounter id and one reward table id; duplicate `id` fails CI. - [x] Exactly one prototype encounter id and one reward table id; duplicate `id` fails CI.
- [ ] Stable id list documented in module doc freeze box. - [x] Stable id list documented in module doc freeze box.
**Landed ([NEO-100](https://linear.app/neon-sprawl/issue/NEO-100)):** encounter + reward-table schemas, `prototype_encounters.json`, `prototype_reward_tables.json`, CI gates in `validate_content.py`; plan [NEO-100-implementation-plan.md](NEO-100-implementation-plan.md).
--- ---

View File

@ -42,9 +42,16 @@
## Acceptance criteria checklist ## Acceptance criteria checklist
- [ ] PR gate validates encounter + reward-table JSON against schema. - [x] PR gate validates encounter + reward-table JSON against schema.
- [ ] Exactly one prototype encounter id and one reward table id; duplicate `id` fails CI. - [x] Exactly one prototype encounter id and one reward table id; duplicate `id` fails CI.
- [ ] Stable id list documented in module doc freeze box. - [x] Stable id list documented in module doc freeze box.
## Implementation reconciliation (shipped)
- **Schemas:** [`encounter-def.schema.json`](../../content/schemas/encounter-def.schema.json), [`reward-table.schema.json`](../../content/schemas/reward-table.schema.json), [`reward-grant-row.schema.json`](../../content/schemas/reward-grant-row.schema.json).
- **Catalogs:** [`prototype_encounters.json`](../../content/encounters/prototype_encounters.json), [`prototype_reward_tables.json`](../../content/reward-tables/prototype_reward_tables.json).
- **CI:** `PROTOTYPE_E5M3_ENCOUNTER_IDS`, `PROTOTYPE_E5M3_REWARD_TABLE_IDS`, `PROTOTYPE_E5M2_NPC_INSTANCE_IDS`, `PROTOTYPE_E5M3_REWARD_GRANTS` gates in [`validate_content.py`](../../scripts/validate_content.py).
- **Docs:** E5.M3 CI enforcement line, CT.M1, alignment register, E5M3-01 backlog checkboxes.
## Technical approach ## Technical approach

View File

@ -12,6 +12,8 @@ Validates:
- recipe catalogs: content/recipes/*_recipes.json rows vs content/schemas/recipe-def.schema.json (NEO-65) - recipe catalogs: content/recipes/*_recipes.json rows vs content/schemas/recipe-def.schema.json (NEO-65)
- ability catalogs: content/abilities/*_abilities.json rows vs content/schemas/ability-def.schema.json (NEO-76) - ability catalogs: content/abilities/*_abilities.json rows vs content/schemas/ability-def.schema.json (NEO-76)
- 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)
- encounter catalogs: content/encounters/*_encounters.json rows vs content/schemas/encounter-def.schema.json (NEO-100)
""" """
from __future__ import annotations from __future__ import annotations
@ -34,6 +36,9 @@ RECIPE_IO_ROW_SCHEMA = REPO_ROOT / "content/schemas/recipe-io-row.schema.json"
RECIPE_SCHEMA = REPO_ROOT / "content/schemas/recipe-def.schema.json" RECIPE_SCHEMA = REPO_ROOT / "content/schemas/recipe-def.schema.json"
ABILITY_SCHEMA = REPO_ROOT / "content/schemas/ability-def.schema.json" ABILITY_SCHEMA = REPO_ROOT / "content/schemas/ability-def.schema.json"
NPC_BEHAVIOR_SCHEMA = REPO_ROOT / "content/schemas/npc-behavior-def.schema.json" NPC_BEHAVIOR_SCHEMA = REPO_ROOT / "content/schemas/npc-behavior-def.schema.json"
REWARD_GRANT_ROW_SCHEMA = REPO_ROOT / "content/schemas/reward-grant-row.schema.json"
REWARD_TABLE_SCHEMA = REPO_ROOT / "content/schemas/reward-table.schema.json"
ENCOUNTER_DEF_SCHEMA = REPO_ROOT / "content/schemas/encounter-def.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"
@ -41,6 +46,8 @@ RESOURCE_NODES_DIR = REPO_ROOT / "content/resource-nodes"
RECIPES_DIR = REPO_ROOT / "content/recipes" RECIPES_DIR = REPO_ROOT / "content/recipes"
ABILITIES_DIR = REPO_ROOT / "content/abilities" ABILITIES_DIR = REPO_ROOT / "content/abilities"
NPC_BEHAVIORS_DIR = REPO_ROOT / "content/npc-behaviors" NPC_BEHAVIORS_DIR = REPO_ROOT / "content/npc-behaviors"
REWARD_TABLES_DIR = REPO_ROOT / "content/reward-tables"
ENCOUNTERS_DIR = REPO_ROOT / "content/encounters"
# 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"})
@ -126,6 +133,25 @@ PROTOTYPE_E5M2_NPC_BEHAVIOR_IDS = frozenset(
} }
) )
# Epic 5 Slice 2 NPC instance ids (server PrototypeNpcRegistry); CI cross-ref only (NEO-100).
PROTOTYPE_E5M2_NPC_INSTANCE_IDS = frozenset(
{
"prototype_npc_melee",
"prototype_npc_ranged",
"prototype_npc_elite",
}
)
# Epic 5 Slice 3 prototype lock (NEO-100): exact encounter + reward table ids after schema passes.
PROTOTYPE_E5M3_ENCOUNTER_IDS = frozenset({"prototype_combat_pocket"})
PROTOTYPE_E5M3_REWARD_TABLE_IDS = frozenset({"prototype_combat_pocket_clear"})
# Frozen fixed grants for prototype_combat_pocket_clear (keep in sync with E5.M3 freeze table).
PROTOTYPE_E5M3_REWARD_GRANTS: dict[str, int] = {
"scrap_metal_bulk": 10,
"contract_handoff_token": 1,
}
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."""
@ -592,6 +618,200 @@ def _prototype_e5m2_npc_behavior_attack_ability_gate(
return None return None
def _reward_table_def_validator() -> Draft202012Validator:
"""Build a validator that resolves reward-table $ref to reward-grant-row schema."""
grant_schema = json.loads(REWARD_GRANT_ROW_SCHEMA.read_text(encoding="utf-8"))
table_schema = json.loads(REWARD_TABLE_SCHEMA.read_text(encoding="utf-8"))
registry = Registry().with_resources(
[
(table_schema["$id"], Resource.from_contents(table_schema)),
(grant_schema["$id"], Resource.from_contents(grant_schema)),
]
)
return Draft202012Validator(table_schema, registry=registry)
def _validate_reward_table_catalogs(
*,
reward_table_files: list[Path],
reward_table_validator: Draft202012Validator,
known_item_ids: frozenset[str],
) -> tuple[int, dict[str, str], dict[str, dict]]:
"""Validate reward table 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 reward_table_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
reward_tables = data.get("rewardTables")
if not isinstance(reward_tables, list):
print(f"error: {rel}: expected top-level 'rewardTables' array", file=sys.stderr)
errors += 1
continue
for i, row in enumerate(reward_tables):
if not isinstance(row, dict):
print(f"error: {rel}: rewardTables[{i}] must be an object", file=sys.stderr)
errors += 1
continue
row_schema_errors = 0
for err in sorted(reward_table_validator.iter_errors(row), key=lambda e: e.path):
loc = ".".join(str(p) for p in err.path) or "(root)"
print(f"error: {rel} rewardTables[{i}] {loc}: {err.message}", file=sys.stderr)
row_schema_errors += 1
errors += 1
rid = row.get("id")
if isinstance(rid, str) and row_schema_errors == 0:
prev = seen_ids.get(rid)
if prev:
print(f"error: duplicate reward table id {rid!r} in {prev} and {rel}", file=sys.stderr)
errors += 1
else:
seen_ids[rid] = rel
id_to_row[rid] = row
fixed_grants = row.get("fixedGrants")
if isinstance(fixed_grants, list) and row_schema_errors == 0:
for j, grant in enumerate(fixed_grants):
if not isinstance(grant, dict):
continue
item_id = grant.get("itemId")
if isinstance(item_id, str) and item_id not in known_item_ids:
print(
f"error: {rel} rewardTables[{i}].fixedGrants[{j}]: itemId {item_id!r} "
"is not in item catalogs",
file=sys.stderr,
)
errors += 1
return errors, seen_ids, id_to_row
def _prototype_e5m3_reward_table_gate(seen_ids: dict[str, str]) -> str | None:
"""Return a human-readable error if E5M3 reward table contract fails, else None."""
ids = frozenset(seen_ids.keys())
if ids != PROTOTYPE_E5M3_REWARD_TABLE_IDS:
return (
"error: prototype E5M3 expects exactly reward table ids "
f"{sorted(PROTOTYPE_E5M3_REWARD_TABLE_IDS)!r}, got {sorted(ids)!r}"
)
return None
def _prototype_e5m3_reward_grant_content_gate(id_to_row: dict[str, dict]) -> str | None:
"""Return a human-readable error if frozen grant quantities fail, else None."""
row = id_to_row.get("prototype_combat_pocket_clear")
if row is None:
return None
fixed_grants = row.get("fixedGrants")
if not isinstance(fixed_grants, list):
return None
grants: dict[str, int] = {}
for grant in fixed_grants:
if not isinstance(grant, dict):
continue
item_id = grant.get("itemId")
quantity = grant.get("quantity")
if isinstance(item_id, str) and isinstance(quantity, int):
grants[item_id] = quantity
if grants != PROTOTYPE_E5M3_REWARD_GRANTS:
return (
"error: prototype_combat_pocket_clear fixedGrants must match "
f"{PROTOTYPE_E5M3_REWARD_GRANTS!r}, got {grants!r}"
)
return None
def _validate_encounter_catalogs(
*,
encounter_files: list[Path],
encounter_validator: Draft202012Validator,
known_reward_table_ids: frozenset[str],
) -> tuple[int, dict[str, str], dict[str, dict]]:
"""Validate encounter 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 encounter_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
encounters = data.get("encounters")
if not isinstance(encounters, list):
print(f"error: {rel}: expected top-level 'encounters' array", file=sys.stderr)
errors += 1
continue
for i, row in enumerate(encounters):
if not isinstance(row, dict):
print(f"error: {rel}: encounters[{i}] must be an object", file=sys.stderr)
errors += 1
continue
row_schema_errors = 0
for err in sorted(encounter_validator.iter_errors(row), key=lambda e: e.path):
loc = ".".join(str(p) for p in err.path) or "(root)"
print(f"error: {rel} encounters[{i}] {loc}: {err.message}", file=sys.stderr)
row_schema_errors += 1
errors += 1
eid = row.get("id")
if isinstance(eid, str) and row_schema_errors == 0:
prev = seen_ids.get(eid)
if prev:
print(f"error: duplicate encounter id {eid!r} in {prev} and {rel}", file=sys.stderr)
errors += 1
else:
seen_ids[eid] = rel
id_to_row[eid] = row
reward_table_id = row.get("rewardTableId")
if isinstance(reward_table_id, str) and reward_table_id not in known_reward_table_ids:
print(
f"error: {rel} encounters[{i}]: rewardTableId {reward_table_id!r} "
"is not in reward table catalogs",
file=sys.stderr,
)
errors += 1
return errors, seen_ids, id_to_row
def _prototype_e5m3_encounter_gate(seen_ids: dict[str, str]) -> str | None:
"""Return a human-readable error if E5M3 encounter contract fails, else None."""
ids = frozenset(seen_ids.keys())
if ids != PROTOTYPE_E5M3_ENCOUNTER_IDS:
return (
"error: prototype E5M3 expects exactly encounter ids "
f"{sorted(PROTOTYPE_E5M3_ENCOUNTER_IDS)!r}, got {sorted(ids)!r}"
)
return None
def _prototype_e5m3_encounter_cross_ref_gate(id_to_row: dict[str, dict]) -> str | None:
"""Return a human-readable error if NPC instance or reward table cross-refs fail, else None."""
for eid, row in id_to_row.items():
npc_ids = row.get("requiredNpcInstanceIds")
if isinstance(npc_ids, list):
npc_set = frozenset(str(x) for x in npc_ids if isinstance(x, str))
if npc_set != PROTOTYPE_E5M2_NPC_INSTANCE_IDS:
return (
f"error: encounter {eid!r}: requiredNpcInstanceIds must be exactly "
f"{sorted(PROTOTYPE_E5M2_NPC_INSTANCE_IDS)!r}, got {sorted(npc_set)!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:
@ -918,6 +1138,15 @@ def main() -> int:
if not NPC_BEHAVIOR_SCHEMA.is_file(): if not NPC_BEHAVIOR_SCHEMA.is_file():
print(f"error: missing schema {NPC_BEHAVIOR_SCHEMA}", file=sys.stderr) print(f"error: missing schema {NPC_BEHAVIOR_SCHEMA}", file=sys.stderr)
return 1 return 1
if not REWARD_GRANT_ROW_SCHEMA.is_file():
print(f"error: missing schema {REWARD_GRANT_ROW_SCHEMA}", file=sys.stderr)
return 1
if not REWARD_TABLE_SCHEMA.is_file():
print(f"error: missing schema {REWARD_TABLE_SCHEMA}", file=sys.stderr)
return 1
if not ENCOUNTER_DEF_SCHEMA.is_file():
print(f"error: missing schema {ENCOUNTER_DEF_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)
@ -936,6 +1165,9 @@ def main() -> int:
ability_validator = Draft202012Validator(ability_schema) ability_validator = Draft202012Validator(ability_schema)
npc_behavior_schema = json.loads(NPC_BEHAVIOR_SCHEMA.read_text(encoding="utf-8")) npc_behavior_schema = json.loads(NPC_BEHAVIOR_SCHEMA.read_text(encoding="utf-8"))
npc_behavior_validator = Draft202012Validator(npc_behavior_schema) npc_behavior_validator = Draft202012Validator(npc_behavior_schema)
reward_table_validator = _reward_table_def_validator()
encounter_schema = json.loads(ENCOUNTER_DEF_SCHEMA.read_text(encoding="utf-8"))
encounter_validator = Draft202012Validator(encounter_schema)
if not SKILLS_DIR.is_dir(): if not SKILLS_DIR.is_dir():
print(f"error: missing directory {SKILLS_DIR}", file=sys.stderr) print(f"error: missing directory {SKILLS_DIR}", file=sys.stderr)
@ -1010,6 +1242,24 @@ def main() -> int:
print(f"error: no *_npc_behaviors.json files under {NPC_BEHAVIORS_DIR}", file=sys.stderr) print(f"error: no *_npc_behaviors.json files under {NPC_BEHAVIORS_DIR}", file=sys.stderr)
return 1 return 1
if not REWARD_TABLES_DIR.is_dir():
print(f"error: missing directory {REWARD_TABLES_DIR}", file=sys.stderr)
return 1
reward_table_files = sorted(REWARD_TABLES_DIR.glob("*_reward_tables.json"))
if not reward_table_files:
print(f"error: no *_reward_tables.json files under {REWARD_TABLES_DIR}", file=sys.stderr)
return 1
if not ENCOUNTERS_DIR.is_dir():
print(f"error: missing directory {ENCOUNTERS_DIR}", file=sys.stderr)
return 1
encounter_files = sorted(ENCOUNTERS_DIR.glob("*_encounters.json"))
if not encounter_files:
print(f"error: no *_encounters.json files under {ENCOUNTERS_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] = {}
errors = 0 errors = 0
@ -1237,6 +1487,44 @@ def main() -> int:
print(e5m2_attack_ability_err, file=sys.stderr) print(e5m2_attack_ability_err, file=sys.stderr)
return 1 return 1
reward_table_errors, reward_table_seen_ids, reward_table_id_to_row = _validate_reward_table_catalogs(
reward_table_files=reward_table_files,
reward_table_validator=reward_table_validator,
known_item_ids=frozenset(item_seen_ids.keys()),
)
if reward_table_errors:
print(f"content validation failed with {reward_table_errors} error(s)", file=sys.stderr)
return 1
e5m3_reward_table_err = _prototype_e5m3_reward_table_gate(reward_table_seen_ids)
if e5m3_reward_table_err:
print(e5m3_reward_table_err, file=sys.stderr)
return 1
e5m3_reward_grant_err = _prototype_e5m3_reward_grant_content_gate(reward_table_id_to_row)
if e5m3_reward_grant_err:
print(e5m3_reward_grant_err, file=sys.stderr)
return 1
encounter_errors, encounter_seen_ids, encounter_id_to_row = _validate_encounter_catalogs(
encounter_files=encounter_files,
encounter_validator=encounter_validator,
known_reward_table_ids=frozenset(reward_table_seen_ids.keys()),
)
if encounter_errors:
print(f"content validation failed with {encounter_errors} error(s)", file=sys.stderr)
return 1
e5m3_encounter_err = _prototype_e5m3_encounter_gate(encounter_seen_ids)
if e5m3_encounter_err:
print(e5m3_encounter_err, file=sys.stderr)
return 1
e5m3_encounter_cross_ref_err = _prototype_e5m3_encounter_cross_ref_gate(encounter_id_to_row)
if e5m3_encounter_cross_ref_err:
print(e5m3_encounter_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), "
@ -1248,12 +1536,16 @@ def main() -> int:
f"{len(recipe_files)} recipe catalog file(s), " f"{len(recipe_files)} recipe catalog file(s), "
f"{len(ability_files)} ability catalog file(s), " f"{len(ability_files)} ability catalog file(s), "
f"{len(npc_behavior_files)} npc behavior catalog file(s), " f"{len(npc_behavior_files)} npc behavior catalog file(s), "
f"{len(reward_table_files)} reward table catalog file(s), "
f"{len(encounter_files)} encounter 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), "
f"{len(node_seen_ids)} unique nodeDefId(s), " f"{len(node_seen_ids)} unique nodeDefId(s), "
f"{len(recipe_seen_ids)} unique recipe id(s), " f"{len(recipe_seen_ids)} unique recipe id(s), "
f"{len(ability_seen_ids)} unique ability id(s), " f"{len(ability_seen_ids)} unique ability id(s), "
f"{len(npc_behavior_seen_ids)} unique npc behavior id(s), " f"{len(npc_behavior_seen_ids)} unique npc behavior id(s), "
f"{len(reward_table_seen_ids)} unique reward table id(s), "
f"{len(encounter_seen_ids)} unique encounter id(s), "
f"{len(track_skill_ids)} mastery track(s)" f"{len(track_skill_ids)} mastery track(s)"
) )
return 0 return 0