From ee5e1a0caf0c39b0275d5563d9af37bf93e5dc93 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sat, 30 May 2026 19:35:02 -0400 Subject: [PATCH] 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. --- content/encounters/prototype_encounters.json | 16 + .../prototype_reward_tables.json | 13 + content/schemas/encounter-def.schema.json | 54 ++++ content/schemas/reward-grant-row.schema.json | 21 ++ content/schemas/reward-table.schema.json | 28 ++ .../CT_M1_ContentValidationPipeline.md | 4 + .../modules/E5_M3_EncounterAndRewardTables.md | 2 + ...umentation_and_implementation_alignment.md | 2 +- docs/plans/E5M3-prototype-backlog.md | 8 +- docs/plans/NEO-100-implementation-plan.md | 13 +- scripts/validate_content.py | 292 ++++++++++++++++++ 11 files changed, 446 insertions(+), 7 deletions(-) create mode 100644 content/encounters/prototype_encounters.json create mode 100644 content/reward-tables/prototype_reward_tables.json create mode 100644 content/schemas/encounter-def.schema.json create mode 100644 content/schemas/reward-grant-row.schema.json create mode 100644 content/schemas/reward-table.schema.json diff --git a/content/encounters/prototype_encounters.json b/content/encounters/prototype_encounters.json new file mode 100644 index 0000000..5b6646f --- /dev/null +++ b/content/encounters/prototype_encounters.json @@ -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" + } + ] +} diff --git a/content/reward-tables/prototype_reward_tables.json b/content/reward-tables/prototype_reward_tables.json new file mode 100644 index 0000000..b6048fa --- /dev/null +++ b/content/reward-tables/prototype_reward_tables.json @@ -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 } + ] + } + ] +} diff --git a/content/schemas/encounter-def.schema.json b/content/schemas/encounter-def.schema.json new file mode 100644 index 0000000..3cfa79e --- /dev/null +++ b/content/schemas/encounter-def.schema.json @@ -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." + } + } +} diff --git a/content/schemas/reward-grant-row.schema.json b/content/schemas/reward-grant-row.schema.json new file mode 100644 index 0000000..edd75a9 --- /dev/null +++ b/content/schemas/reward-grant-row.schema.json @@ -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." + } + } +} diff --git a/content/schemas/reward-table.schema.json b/content/schemas/reward-table.schema.json new file mode 100644 index 0000000..b224c05 --- /dev/null +++ b/content/schemas/reward-table.schema.json @@ -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" + } + } + } +} diff --git a/docs/decomposition/modules/CT_M1_ContentValidationPipeline.md b/docs/decomposition/modules/CT_M1_ContentValidationPipeline.md index bb76187..950309b 100644 --- a/docs/decomposition/modules/CT_M1_ContentValidationPipeline.md +++ b/docs/decomposition/modules/CT_M1_ContentValidationPipeline.md @@ -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`). +**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. ## Source anchors diff --git a/docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md b/docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md index ce7f83e..d1258b3 100644 --- a/docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md +++ b/docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md @@ -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** | +**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). ## Source anchors diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md index 2ec6ffa..44b97c6 100644 --- a/docs/decomposition/modules/documentation_and_implementation_alignment.md +++ b/docs/decomposition/modules/documentation_and_implementation_alignment.md @@ -58,7 +58,7 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not | E3.M2 | Ready | **NEO-65 landed:** frozen prototype eight-recipe catalog in [`content/recipes/prototype_recipes.json`](../../../content/recipes/prototype_recipes.json); [`recipe-def.schema.json`](../../../content/schemas/recipe-def.schema.json) + [`recipe-io-row.schema.json`](../../../content/schemas/recipe-io-row.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) Slice 3 gates ([NEO-65](../../plans/NEO-65-implementation-plan.md)). **NEO-66 landed:** fail-fast server load of `content/recipes/*_recipes.json` at startup — `server/NeonSprawl.Server/Game/Crafting/` ([NEO-66](../../plans/NEO-66-implementation-plan.md)); [server README — Recipe catalog](../../../server/README.md#recipe-catalog-contentrecipes-neo-66). **NEO-67 landed:** injectable **`IRecipeDefinitionRegistry`** + lookup tests ([NEO-67](../../plans/NEO-67-implementation-plan.md)). **NEO-68 landed:** **`GET /game/world/recipe-definitions`** — `RecipeDefinitionsWorldApi` + DTOs in `Game/Crafting/` ([NEO-68](../../plans/NEO-68-implementation-plan.md), [`NEO-68` manual QA](../../manual-qa/NEO-68.md)); [server README — Recipe definitions (NEO-68)](../../../server/README.md#recipe-definitions-neo-68); Bruno `bruno/neon-sprawl-server/recipe-definitions/`. **NEO-69 landed:** **`CraftOperations.TryCraft`** + **`CraftResult`** — pre-flight inputs/outputs, inventory mutation, refine XP ([NEO-69](../../plans/NEO-69-implementation-plan.md)); [server README — Craft engine (NEO-69)](../../../server/README.md#craft-engine-neo-69-and-craft-http-neo-70). **NEO-70 landed:** **`POST /game/players/{id}/craft`** — `PlayerCraftApi` + wire **`CraftResponse`**; Bruno gather→refine→make spine ([NEO-70](../../plans/NEO-70-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/craft/`. **NEO-71 landed:** comment-only **`item_crafted`** / **`craft_failed`** telemetry hook sites in **`CraftOperations.TryCraft`** ([NEO-71](../../plans/NEO-71-implementation-plan.md)); [server README — Craft telemetry hooks (NEO-71)](../../../server/README.md#craft-telemetry-hooks-neo-71). **NEO-42 landed (prep):** **`RefineActivitySkillXpGrant`** / **`RefineSkillXpConstants`** — wired from craft engine success path. Epic 3 Slice 3 server backlog **E3M2-01**–**E3M2-07** complete. Upstream: E3.M1 **Ready**, E3.M3 inventory landed. **NEO-74 landed:** client craft UI — **`recipe_definitions_client.gd`**, **`craft_client.gd`**, **`craft_recipe_panel.gd`**, **`CraftFeedbackLabel`**, **refine** skill row ([NEO-74](../../plans/NEO-74-implementation-plan.md), [`NEO-74` manual QA](../../manual-qa/NEO-74.md)); `client/README.md` craft section. **NEO-75 landed:** capstone playable gather→refine→make loop — [`NEO-75` manual QA](../../manual-qa/NEO-75.md), **`prototype_economy_hud_section.gd`**; Epic 3 Slice 5 client complete. | [NEO-42](../../plans/NEO-42-implementation-plan.md), [NEO-65](../../plans/NEO-65-implementation-plan.md), [NEO-66](../../plans/NEO-66-implementation-plan.md), [NEO-67](../../plans/NEO-67-implementation-plan.md), [NEO-68](../../plans/NEO-68-implementation-plan.md), [NEO-69](../../plans/NEO-69-implementation-plan.md), [NEO-70](../../plans/NEO-70-implementation-plan.md), [NEO-71](../../plans/NEO-71-implementation-plan.md), [NEO-74](../../plans/NEO-74-implementation-plan.md), [NEO-75](../../plans/NEO-75-implementation-plan.md), [E3M2-prototype-backlog](../../plans/E3M2-prototype-backlog.md), [E3S5-client-prototype-backlog](../../plans/E3S5-client-prototype-backlog.md), [E3_M2](E3_M2_RefinementAndRecipeExecution.md) | | E5.M1 | Ready | **NEO-76 landed:** frozen prototype four-ability catalog in [`content/abilities/prototype_abilities.json`](../../../content/abilities/prototype_abilities.json); [`ability-def.schema.json`](../../../content/schemas/ability-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) E5M1 four-id gate ([NEO-76](../../plans/NEO-76-implementation-plan.md)). **NEO-77 landed:** fail-fast server load of `content/abilities/*_abilities.json` at startup — `server/NeonSprawl.Server/Game/Combat/` ([NEO-77](../../plans/NEO-77-implementation-plan.md)); [server README — Ability catalog](../../../server/README.md#ability-catalog-contentabilities-neo-77). **NEO-79 landed:** injectable **`IAbilityDefinitionRegistry`** + DI; hotbar/cast use **`TryNormalizeKnown`** ([NEO-79](../../plans/NEO-79-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/hotbar-loadout/` + `ability-cast/` unknown-ability deny smokes. **NEO-78 landed:** **`GET /game/world/ability-definitions`** — `AbilityDefinitionsWorldApi` + DTOs in `Game/Combat/` ([NEO-78](../../plans/NEO-78-implementation-plan.md)); [server README — Ability definitions (NEO-78)](../../../server/README.md#ability-definitions-neo-78); Bruno `bruno/neon-sprawl-server/ability-definitions/`. **NEO-80 landed:** **`ICombatEntityHealthStore`** / **`InMemoryCombatEntityHealthStore`** — lazy init (Slice 1 flat 100 HP; **superseded by [NEO-91](../../plans/NEO-91-implementation-plan.md)** per-archetype catalog max HP + three NPC instance ids); DI via **`AddCombatEntityHealthStore()`** ([NEO-80](../../plans/NEO-80-implementation-plan.md)); [server README — Combat entity health (NEO-80, NEO-91)](../../../server/README.md#combat-entity-health-neo-80-neo-91). **NEO-81 landed:** **`CombatOperations.TryResolve`** + **`CombatResult`** + **`CombatReasonCodes`** — defeated pre-check deny, catalog damage ([NEO-81](../../plans/NEO-81-implementation-plan.md)); [server README — Combat engine (NEO-81)](../../../server/README.md#combat-engine-neo-81). **NEO-82 landed:** **`AbilityCastApi`** → **`CombatOperations.TryResolve`**; nested wire **`combatResolution`** on accept; per-ability catalog cooldown; **`target_defeated`** cast deny ([NEO-82](../../plans/NEO-82-implementation-plan.md)); [server README — Ability cast (NEO-82)](../../../server/README.md#ability-cast-neo-31-neo-82); Bruno `bruno/neon-sprawl-server/ability-cast/` defeat spine. **NEO-83 landed:** **`GET /game/world/combat-targets`** — `CombatTargetsWorldApi` + DTOs; authoritative HP snapshot from **`ICombatEntityHealthStore`** ([NEO-83](../../plans/NEO-83-implementation-plan.md)); [server README — Combat targets snapshot (NEO-83)](../../../server/README.md#combat-targets-snapshot-neo-83); Bruno `bruno/neon-sprawl-server/combat-targets/`. **NEO-44 landed:** **`CombatDefeatGigXpGrant`** on cast **`targetDefeated`** — **25** gig XP to **`breach`** via **`IPlayerGigProgressionStore`** (V007); **`GET …/gig-progression`** ([NEO-44](../../plans/NEO-44-implementation-plan.md), [`NEO-44` manual QA](../../manual-qa/NEO-44.md)); [server README — Gig progression (NEO-44)](../../../server/README.md#gig-progression-snapshot-neo-44); Bruno `bruno/neon-sprawl-server/gig-progression/`. **NEO-84 landed:** comment-only **`ability_used`** / **`enemy_defeat`** telemetry hook sites in **`CombatOperations.TryResolve`**; reserved **`encounter_start`** / **`player_death`** ([NEO-84](../../plans/NEO-84-implementation-plan.md)); [server README — Combat telemetry hooks (NEO-84)](../../../server/README.md#combat-telemetry-hooks-neo-84). **NEO-85 landed:** client combat HUD — **`ability_cast_client.gd`** parses **`combatResolution`**; **`combat_targets_client.gd`**, **`CastFeedbackLabel`** resolution copy, **`CombatTargetHpLabel`**; event-driven GET refresh ([NEO-85](../../plans/NEO-85-implementation-plan.md), [`NEO-85` manual QA](../../manual-qa/NEO-85.md)); `client/README.md` combat HUD section. **NEO-86 landed:** playable combat capstone — **`gig_progression_client.gd`**, **`GigXpLabel`**, defeat-triggered gig GET refresh; capstone manual QA [`NEO-86`](../../manual-qa/NEO-86.md); `client/README.md` end-to-end combat loop section ([NEO-86](../../plans/NEO-86-implementation-plan.md)). **Epic 5 Slice 1 client capstone complete.** **Backlog decomposed:** Epic 5 Slice 1 — **E5M1-01**–**E5M1-12** landed. **Precursor landed:** E1.M4 cast funnel (NEO-28/31/32). | [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md), [E5_M1](E5_M1_CombatRulesEngine.md), [NEO-77](../../plans/NEO-77-implementation-plan.md), [NEO-79](../../plans/NEO-79-implementation-plan.md), [NEO-78](../../plans/NEO-78-implementation-plan.md), [NEO-80](../../plans/NEO-80-implementation-plan.md), [NEO-81](../../plans/NEO-81-implementation-plan.md), [NEO-82](../../plans/NEO-82-implementation-plan.md), [NEO-83](../../plans/NEO-83-implementation-plan.md), [NEO-44](../../plans/NEO-44-implementation-plan.md), [NEO-84](../../plans/NEO-84-implementation-plan.md), [NEO-85](../../plans/NEO-85-implementation-plan.md), [NEO-86](../../plans/NEO-86-implementation-plan.md), [E1M4-prototype-backlog.md](../../plans/E1M4-prototype-backlog.md) | | E5.M2 | Ready | **NEO-87 landed:** frozen prototype three-behavior catalog in [`content/npc-behaviors/prototype_npc_behaviors.json`](../../../content/npc-behaviors/prototype_npc_behaviors.json); [`npc-behavior-def.schema.json`](../../../content/schemas/npc-behavior-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) E5M2 three-id gate ([NEO-87](../../plans/NEO-87-implementation-plan.md)). **NEO-88 landed:** fail-fast server load of `content/npc-behaviors/*_npc_behaviors.json` at startup — `server/NeonSprawl.Server/Game/Npc/` ([NEO-88](../../plans/NEO-88-implementation-plan.md)); [server README — NPC behavior catalog](../../../server/README.md#npc-behavior-catalog-contentnpc-behaviors-neo-88). **NEO-89 landed:** injectable **`INpcBehaviorDefinitionRegistry`** + DI; **`PrototypeNpcBehaviorRegistry`** id constants ([NEO-89](../../plans/NEO-89-implementation-plan.md)). **NEO-90 landed:** **`GET /game/world/npc-behavior-definitions`** — `NpcBehaviorDefinitionsWorldApi` + DTOs in `Game/Npc/` ([NEO-90](../../plans/NEO-90-implementation-plan.md)); [server README — NPC behavior definitions (NEO-90)](../../../server/README.md#npc-behavior-definitions-neo-90); Bruno `bruno/neon-sprawl-server/npc-behavior-definitions/`. **NEO-91 landed:** **`PrototypeNpcRegistry`** + combat-target migration — three NPC instance ids replace alpha/beta; per-archetype max HP from behavior catalog ([NEO-91](../../plans/NEO-91-implementation-plan.md)); [server README — Combat entity health (NEO-91)](../../../server/README.md#combat-entity-health-neo-80-neo-91). **NEO-92 landed:** **`IThreatStateStore`** + **`AggroOperations`** — first-hit acquire on damaging cast, leash clear on move/cast; cast/move/fixture hooks ([NEO-92](../../plans/NEO-92-implementation-plan.md)); [server README — Threat / aggro state (NEO-92)](../../../server/README.md#threat--aggro-state-neo-92). **NEO-93 landed:** **`INpcRuntimeStateStore`** + **`NpcRuntimeOperations.AdvanceAll`** — catalog-driven behavior state machine, lazy tick advance with delta cap; dev fixture reset ([NEO-93](../../plans/NEO-93-implementation-plan.md)); [server README — NPC runtime behavior state (NEO-93)](../../../server/README.md#npc-runtime-behavior-state-neo-93). **NEO-94 landed:** **`GET /game/world/npc-runtime-snapshot`** — `NpcRuntimeSnapshotWorldApi` + DTOs; lazy **`AdvanceAll`** on poll; nested **`activeTelegraph`** with server-computed **`windupRemainingSeconds`** ([NEO-94](../../plans/NEO-94-implementation-plan.md)); [server README — NPC runtime snapshot (NEO-94)](../../../server/README.md#npc-runtime-snapshot-neo-94); Bruno `bruno/neon-sprawl-server/npc-runtime-snapshot/`. **NEO-95 landed:** **`IPlayerCombatHealthStore`** + **`NpcAttackOperations.TryResolveTelegraphComplete`** — telegraph resolve via behavior **`attackAbilityId`** + ability **`maxRange`** / **`baseDamage`** ([NEO-95](../../plans/NEO-95-implementation-plan.md), [NEO-98](../../plans/NEO-98-implementation-plan.md)); [server README — Session player combat HP (NEO-95)](../../../server/README.md#session-player-combat-hp-neo-95); Bruno `bruno/neon-sprawl-server/combat-health/`. **NEO-96 landed:** comment-only **`telegraph_fired`** + **`npc_state_transition`** hook sites in **`NpcRuntimeOperations.AdvanceAll`** ([NEO-96](../../plans/NEO-96-implementation-plan.md)); [server README — NPC runtime telemetry hooks (NEO-96)](../../../server/README.md#npc-runtime-telemetry-hooks-neo-96). **NEO-97 landed:** client telegraph HUD — **`npc_runtime_client.gd`**, **`player_combat_health_client.gd`**, **`npc_runtime_hud_state.gd`**, **`TelegraphLabel`** / **`NpcStateLabel`** / **`PlayerCombatHpLabel`**; combat-active ~1 Hz poll ([NEO-97](../../plans/NEO-97-implementation-plan.md), [`NEO-97` manual QA](../../manual-qa/NEO-97.md)); [client README — NPC runtime + telegraph HUD (NEO-97)](../../../client/README.md#npc-runtime--telegraph-hud-neo-97). **NEO-98 landed:** playable NPC telegraph combat capstone — [`NEO-98` manual QA](../../manual-qa/NEO-98.md); `client/README.md` end-to-end NPC telegraph combat loop section ([NEO-98](../../plans/NEO-98-implementation-plan.md)). **NEO-98 server integration:** defeat clears aggro/runtime (**`TryStopOnTargetDefeat`**); telegraph damage range-gated via [`prototype_npc_abilities.json`](../../../content/abilities/prototype_npc_abilities.json) + behavior **`attackAbilityId`**. **Epic 5 Slice 2 client capstone complete.** **Backlog decomposed:** Epic 5 Slice 2 — [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md) **E5M2-01** [NEO-87](https://linear.app/neon-sprawl/issue/NEO-87) → **E5M2-12** [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98) **landed**. Three archetypes (`prototype_melee_pressure`, `prototype_ranged_control`, `prototype_elite_mini_boss`); replaces alpha/beta dummies with **`prototype_npc_melee` / `ranged` / `elite`**. Owns **`ThreatState`**, **`TelegraphEvent`**, **`GET …/npc-runtime-snapshot`**, session **`IPlayerCombatHealthStore`**. Upstream **E5.M1 Ready**. | [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md), [E5_M2](E5_M2_NpcAiAndBehaviorProfiles.md), [NEO-87](../../plans/NEO-87-implementation-plan.md), [NEO-88](../../plans/NEO-88-implementation-plan.md), [NEO-89](../../plans/NEO-89-implementation-plan.md), [NEO-90](../../plans/NEO-90-implementation-plan.md), [NEO-91](../../plans/NEO-91-implementation-plan.md), [NEO-92](../../plans/NEO-92-implementation-plan.md), [NEO-93](../../plans/NEO-93-implementation-plan.md), [NEO-94](../../plans/NEO-94-implementation-plan.md), [NEO-95](../../plans/NEO-95-implementation-plan.md), [NEO-96](../../plans/NEO-96-implementation-plan.md), [NEO-97](../../plans/NEO-97-implementation-plan.md), [NEO-98](../../plans/NEO-98-implementation-plan.md), label **`E5.M2`** on NEO-87–NEO-98 | -| E5.M3 | In Progress | **Backlog decomposed:** Epic 5 Slice 3 — [E5M3-prototype-backlog.md](../../plans/E5M3-prototype-backlog.md) **E5M3-01** [NEO-100](https://linear.app/neon-sprawl/issue/NEO-100) → **E5M3-12** [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111). Prototype spine: one encounter **`prototype_combat_pocket`** (defeat all three E5.M2 NPC ids) → reward table **`prototype_combat_pocket_clear`** (**`scrap_metal_bulk` ×10**, **`contract_handoff_token` ×1**); idempotent completion; per-defeat gig XP unchanged ([NEO-44](../../plans/NEO-44-implementation-plan.md)). **Implementation not started.** Upstream **E5.M2 Ready**, **E3.M3** inventory landed. | [E5M3-prototype-backlog.md](../../plans/E5M3-prototype-backlog.md), [E5_M3](E5_M3_EncounterAndRewardTables.md), label **`E5.M3`** on NEO-100–NEO-111 | +| E5.M3 | In Progress | **E5M3-01 catalog landed ([NEO-100](https://linear.app/neon-sprawl/issue/NEO-100)):** encounter + reward-table schemas, `prototype_encounters.json`, `prototype_reward_tables.json`, CI gates. Epic 5 Slice 3 backlog [E5M3-prototype-backlog.md](../../plans/E5M3-prototype-backlog.md) **E5M3-01** [NEO-100](https://linear.app/neon-sprawl/issue/NEO-100) → **E5M3-12** [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111). Prototype spine: **`prototype_combat_pocket`** → **`prototype_combat_pocket_clear`**; idempotent completion; per-defeat gig XP unchanged ([NEO-44](../../plans/NEO-44-implementation-plan.md)). **Server load/runtime (NEO-101+) not started.** Upstream **E5.M2 Ready**, **E3.M3** inventory landed. | [NEO-100 plan](../../plans/NEO-100-implementation-plan.md), [E5_M3](E5_M3_EncounterAndRewardTables.md), label **`E5.M3`** on NEO-100–NEO-111 | --- diff --git a/docs/plans/E5M3-prototype-backlog.md b/docs/plans/E5M3-prototype-backlog.md index da301ba..cedbee3 100644 --- a/docs/plans/E5M3-prototype-backlog.md +++ b/docs/plans/E5M3-prototype-backlog.md @@ -92,9 +92,11 @@ Working backlog for **Epic 5 — Slice 3** ([Epic 5 · Slice 3 — encounters an **Acceptance criteria** -- [ ] PR gate validates encounter + reward-table JSON against schema. -- [ ] Exactly one prototype encounter id and one reward table id; duplicate `id` fails CI. -- [ ] Stable id list documented in module doc freeze box. +- [x] PR gate validates encounter + reward-table JSON against schema. +- [x] Exactly one prototype encounter id and one reward table id; duplicate `id` fails CI. +- [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). --- diff --git a/docs/plans/NEO-100-implementation-plan.md b/docs/plans/NEO-100-implementation-plan.md index 7b72b6d..566540f 100644 --- a/docs/plans/NEO-100-implementation-plan.md +++ b/docs/plans/NEO-100-implementation-plan.md @@ -42,9 +42,16 @@ ## Acceptance criteria checklist -- [ ] PR gate validates encounter + reward-table JSON against schema. -- [ ] Exactly one prototype encounter id and one reward table id; duplicate `id` fails CI. -- [ ] Stable id list documented in module doc freeze box. +- [x] PR gate validates encounter + reward-table JSON against schema. +- [x] Exactly one prototype encounter id and one reward table id; duplicate `id` fails CI. +- [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 diff --git a/scripts/validate_content.py b/scripts/validate_content.py index ceff4b0..4c8c8d0 100644 --- a/scripts/validate_content.py +++ b/scripts/validate_content.py @@ -12,6 +12,8 @@ Validates: - 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) - 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 @@ -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" ABILITY_SCHEMA = REPO_ROOT / "content/schemas/ability-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" MASTERY_DIR = REPO_ROOT / "content/mastery" ITEMS_DIR = REPO_ROOT / "content/items" @@ -41,6 +46,8 @@ RESOURCE_NODES_DIR = REPO_ROOT / "content/resource-nodes" RECIPES_DIR = REPO_ROOT / "content/recipes" ABILITIES_DIR = REPO_ROOT / "content/abilities" 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. 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: """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 +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: """Return a human-readable error if Slice 4 contract fails, else None.""" if len(track_skill_ids) != 1: @@ -918,6 +1138,15 @@ def main() -> int: if not NPC_BEHAVIOR_SCHEMA.is_file(): print(f"error: missing schema {NPC_BEHAVIOR_SCHEMA}", file=sys.stderr) 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_validator = Draft202012Validator(skill_schema) @@ -936,6 +1165,9 @@ def main() -> int: ability_validator = Draft202012Validator(ability_schema) npc_behavior_schema = json.loads(NPC_BEHAVIOR_SCHEMA.read_text(encoding="utf-8")) 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(): 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) 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] = {} id_to_category: dict[str, str] = {} errors = 0 @@ -1237,6 +1487,44 @@ def main() -> int: print(e5m2_attack_ability_err, file=sys.stderr) 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( "content OK: " 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(ability_files)} ability 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(item_seen_ids)} unique item id(s), " f"{len(node_seen_ids)} unique nodeDefId(s), " f"{len(recipe_seen_ids)} unique recipe id(s), " f"{len(ability_seen_ids)} unique ability 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)" ) return 0