From 06c58998ccd020c4e813b38f4495f039abdd5084 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 31 May 2026 00:03:00 -0400 Subject: [PATCH 1/4] NEO-101: Add E5M3-02 encounter/reward catalog load kickoff plan. Documents fail-fast server loader scope, CI-parity gates, and loader plus host boot test approach after kickoff clarifications. --- docs/plans/NEO-101-implementation-plan.md | 137 ++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 docs/plans/NEO-101-implementation-plan.md diff --git a/docs/plans/NEO-101-implementation-plan.md b/docs/plans/NEO-101-implementation-plan.md new file mode 100644 index 0000000..e90c02e --- /dev/null +++ b/docs/plans/NEO-101-implementation-plan.md @@ -0,0 +1,137 @@ +# NEO-101 — Implementation plan + +## Story reference + +| Field | Value | +|--------|--------| +| **Key** | NEO-101 | +| **Title** | E5M3-02: Server encounter + reward catalog load (fail-fast) | +| **Linear** | https://linear.app/neon-sprawl/issue/NEO-101/e5m3-02-server-encounter-reward-catalog-load-fail-fast | +| **Module** | [E5.M3 — EncounterAndRewardTables](../decomposition/modules/E5_M3_EncounterAndRewardTables.md) · Epic 5 Slice 3 · backlog **E5M3-02** | +| **Branch** | `NEO-101-encounter-reward-catalog-load-fail-fast` | +| **Precursor** | [NEO-100](https://linear.app/neon-sprawl/issue/NEO-100) — encounter + reward-table schemas, catalogs, CI gates (**landed on `main`**) | +| **Pattern** | [NEO-51](NEO-51-implementation-plan.md) / [NEO-66](NEO-66-implementation-plan.md) / [NEO-88](NEO-88-implementation-plan.md) — row JSON Schema + catalog envelope + in-process C# gates mirroring `scripts/validate_content.py` | +| **Blocks** | [NEO-102](https://linear.app/neon-sprawl/issue/NEO-102) — injectable encounter/reward registries + DI | +| **Client counterpart** | None — server-only catalog load; player-visible work starts at [NEO-110](https://linear.app/neon-sprawl/issue/NEO-110) / [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111) | + +## Kickoff clarifications + +| Topic | Question | Agent recommendation | Answer | +|--------|----------|----------------------|--------| +| **Test scope** | Loader unit tests only, or also `WebApplicationFactory` host boot failure / DI resolve tests? | **Both** — mirror [NEO-51](NEO-51-implementation-plan.md) and [NEO-88](NEO-88-implementation-plan.md) (`Host_ShouldResolveCatalogFromDi`, `Host_ShouldFailStartup_WhenCatalogDirectoryInvalid`); Linear AC “host fails on invalid JSON” is stronger with an integration boot test. | **User:** loader + host boot tests. | +| **NEO-101 vs NEO-102 split** | Ship `IEncounterDefinitionRegistry` / `IRewardTableDefinitionRegistry` on this story? | **Strict split** — E5M3-02: loader + catalog singletons + eager fail-fast boot; [NEO-102](https://linear.app/neon-sprawl/issue/NEO-102) adds injectable registries (E5M3-03). Same pattern as NEO-51/52. | **Adopted** — backlog out-of-scope; no separate question needed. | +| **Runtime validation** | In-process C# vs subprocess `validate_content.py`? | **In-process C#** mirroring `scripts/validate_content.py` E5M3 gates (NEO-51/66 precedent; no Python on server images). | **Adopted** | +| **Cross-ref at load** | Validate `fixedGrants[].itemId` against item catalog? NPC ids against `PrototypeNpcRegistry`? | **Yes** — reward-table loader takes `IReadOnlySet knownItemIds` from `ItemDefinitionCatalog` (recipe pattern); encounter loader cross-checks `requiredNpcInstanceIds` set against `PrototypeNpcRegistry.GetInstanceIdsInOrder()` and `rewardTableId` against loaded reward-table ids. Load **reward tables before encounters** (same order as CI `main()`). | **Adopted** | + +## Goal, scope, and out-of-scope + +**Goal:** Disk → host: startup load of `content/encounters/*.json` and `content/reward-tables/*.json` with CI-parity validation; process refuses to listen when catalogs are missing, malformed, or fail prototype E5M3 gates. + +**In scope (from Linear + [E5M3-02](E5M3-prototype-backlog.md#e5m3-02--server-encounter--reward-catalog-load-fail-fast)):** + +- Loader + catalog types under `server/NeonSprawl.Server/Game/Encounters/`. +- Fail-fast on malformed files, duplicate ids, allowlist mismatch with CI, broken cross-refs (item ids, NPC instance ids, `rewardTableId`). +- Unit tests (AAA) for loader happy path and failure modes; host boot tests (AAA). +- `server/README.md` section for encounter + reward-table catalogs. + +**Out of scope (from Linear + backlog):** + +- Injectable registry interfaces (**NEO-102** / E5M3-03). +- HTTP projection, progress store, runtime completion (E5M3-04+). +- Godot client. + +## Acceptance criteria checklist + +- [ ] Host fails startup on invalid encounter or reward-table JSON (mirror CI rules). +- [ ] Tests cover at least one happy path and one malformed catalog rejection (loader + host boot per kickoff). + +## Technical approach + +1. **`Game/Encounters/` reward-table loader** — `RewardTableDefinitionCatalogLoader.Load(rewardTablesDirectory, rewardTableSchemaPath, rewardGrantRowSchemaPath, knownItemIds, logger)`: + - Enumerate `*_reward_tables.json` (top-level only), ordered by path. + - Per file: parse JSON; require `schemaVersion == 1` and top-level `rewardTables` array. + - Register `reward-grant-row.schema.json` in `SchemaRegistry.Global` before evaluating `reward-table.schema.json` (recipe I/O row pattern). + - Per row: JsonSchema.Net validation; track `id`; reject duplicate `id` across files. + - Cross-ref: each `fixedGrants[].itemId` ∈ `knownItemIds`; reject duplicate `itemId` within a table (mirror CI `_validate_reward_table_catalogs`). + - Run **`PrototypeE5M3RewardTableCatalogRules`**: ids == `{ prototype_combat_pocket_clear }`; frozen grant quantities `{ scrap_metal_bulk: 10, contract_handoff_token: 1 }` (sync comment → `PROTOTYPE_E5M3_REWARD_TABLE_IDS` / `PROTOTYPE_E5M3_REWARD_GRANTS` in `validate_content.py`). + - Throw **`InvalidOperationException`** with aggregated sorted messages prefixed `Reward table catalog validation failed:`. + +2. **`RewardTableDefinitionCatalog`** — immutable snapshot: directory path, `ById`, `DistinctRewardTableCount`, `CatalogJsonFileCount`; `TryGetRewardTable(id, out RewardTableDefRow?)`. + +3. **`RewardTableDefRow` / `RewardGrantRow`** — load-time DTOs (`Id`, `DisplayName`, `FixedGrants` with `ItemId` + `Quantity`). + +4. **`Game/Encounters/` encounter loader** — `EncounterDefinitionCatalogLoader.Load(encountersDirectory, encounterDefSchemaPath, knownRewardTableIds, logger)`: + - Enumerate `*_encounters.json` (top-level only), ordered by path. + - Per file: `schemaVersion == 1`, top-level `encounters` array; row schema via `encounter-def.schema.json`. + - Duplicate `id` rejection across files. + - Run **`PrototypeE5M3EncounterCatalogRules`**: ids == `{ prototype_combat_pocket }`; `requiredNpcInstanceIds` set equals `PrototypeNpcRegistry` instance id set; each `rewardTableId` ∈ `knownRewardTableIds`. + - Throw with prefix `Encounter catalog validation failed:`. + +5. **`EncounterDefinitionCatalog`** — `ById`, counts, `TryGetEncounter(id, out EncounterDefRow?)`. + +6. **`EncounterDefRow`** — `Id`, `DisplayName`, `CompletionCriteriaKind`, `RequiredNpcInstanceIds`, `RewardTableId`. + +7. **Path resolution** — `EncounterCatalogPathResolution` / `RewardTableCatalogPathResolution`: `TryDiscover*Directory`, `Resolve*Directory`, `Resolve*SchemaPath` (ancestor walk from `AppContext.BaseDirectory` for `content/encounters` and `content/reward-tables`; schema defaults under sibling `content/schemas/`). + +8. **`ContentPathsOptions`** — add `EncountersDirectory`, `EncounterDefSchemaPath`, `RewardTablesDirectory`, `RewardTableDefSchemaPath`, `RewardGrantRowSchemaPath`. + +9. **`EncounterCatalogServiceCollectionExtensions.AddEncounterAndRewardCatalogs`** (single extension, two singletons): + - Factory for `RewardTableDefinitionCatalog` depends on `ItemDefinitionCatalog` (known item ids). + - Factory for `EncounterDefinitionCatalog` depends on `RewardTableDefinitionCatalog` (known reward table ids). + - **No** `IEncounterDefinitionRegistry` / `IRewardTableDefinitionRegistry` yet (NEO-102). + +10. **`Program.cs`** — register extension; after `Build()`, eager-resolve both catalogs before `Run()` (same as items/recipes/NPC behaviors). + +11. **`InMemoryWebApplicationFactory`** — pin `Content:EncountersDirectory` and `Content:RewardTablesDirectory` to repo paths so existing tests keep booting. + +12. **Tests** — temp-dir fixtures copying repo schemas; valid JSON matching [prototype_reward_tables.json](../../content/reward-tables/prototype_reward_tables.json) and [prototype_encounters.json](../../content/encounters/prototype_encounters.json); negative cases for schema violation, duplicate id, wrong allowlist id, unknown item id, wrong NPC set, broken `rewardTableId`, duplicate `fixedGrants[].itemId`, wrong grant quantities; host resolve via DI + `/health`; host fail with empty encounters dir. + +13. **Docs** — `server/README.md` sections (config keys, discovery, fail-fast, load order); `documentation_and_implementation_alignment.md` E5.M3 row note server load landed when complete. + +## Files to add + +| Path | Purpose | +|------|---------| +| `server/NeonSprawl.Server/Game/Encounters/RewardGrantRow.cs` | Load-time DTO for a single fixed grant row. | +| `server/NeonSprawl.Server/Game/Encounters/RewardTableDefRow.cs` | Load-time reward table row DTO. | +| `server/NeonSprawl.Server/Game/Encounters/RewardTableDefinitionCatalog.cs` | In-memory reward-table catalog + `TryGetRewardTable`. | +| `server/NeonSprawl.Server/Game/Encounters/RewardTableDefinitionCatalogLoader.cs` | Disk I/O, schema validation, item cross-ref, E5M3 gates, logging. | +| `server/NeonSprawl.Server/Game/Encounters/RewardTableCatalogPathResolution.cs` | Directory/schema discovery for reward tables. | +| `server/NeonSprawl.Server/Game/Encounters/PrototypeE5M3RewardTableCatalogRules.cs` | Frozen reward-table ids + grant quantity gate (sync → Python). | +| `server/NeonSprawl.Server/Game/Encounters/EncounterDefRow.cs` | Load-time encounter row DTO. | +| `server/NeonSprawl.Server/Game/Encounters/EncounterDefinitionCatalog.cs` | In-memory encounter catalog + `TryGetEncounter`. | +| `server/NeonSprawl.Server/Game/Encounters/EncounterDefinitionCatalogLoader.cs` | Disk I/O, schema validation, NPC + rewardTableId cross-ref, E5M3 gates. | +| `server/NeonSprawl.Server/Game/Encounters/EncounterCatalogPathResolution.cs` | Directory/schema discovery for encounters. | +| `server/NeonSprawl.Server/Game/Encounters/PrototypeE5M3EncounterCatalogRules.cs` | Frozen encounter ids + NPC instance set + rewardTableId cross-ref rules. | +| `server/NeonSprawl.Server/Game/Encounters/EncounterCatalogServiceCollectionExtensions.cs` | `AddEncounterAndRewardCatalogs` DI registration. | +| `server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCatalogTestPaths.cs` | Repo `content/encounters` + `content/reward-tables` + schema discovery for tests. | +| `server/NeonSprawl.Server.Tests/Game/Encounters/RewardTableDefinitionCatalogLoaderTests.cs` | AAA loader tests for reward-table catalog. | +| `server/NeonSprawl.Server.Tests/Game/Encounters/EncounterDefinitionCatalogLoaderTests.cs` | AAA loader + host boot tests for both catalogs. | +| `docs/plans/NEO-101-implementation-plan.md` | This plan. | + +## Files to modify + +| Path | Rationale | +|------|-----------| +| `server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs` | Add encounter + reward-table directory and schema path options under `Content` section. | +| `server/NeonSprawl.Server/Program.cs` | Register encounter/reward catalogs; eager-resolve after build. | +| `server/NeonSprawl.Server/appsettings.json` | Optional empty `Content:EncountersDirectory` / `RewardTablesDirectory` / schema override keys for documentation. | +| `server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs` | Pin encounter + reward-table content paths so test host boots with repo catalogs. | +| `server/README.md` | New encounter + reward-table catalog sections (config, discovery, fail-fast, load order). | +| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E5.M3 row — note NEO-101 server load when complete. | + +## Tests + +| Test file | What it covers | +|-----------|----------------| +| `server/NeonSprawl.Server.Tests/Game/Encounters/RewardTableDefinitionCatalogLoaderTests.cs` | **Unit:** valid prototype reward-table catalog loads; `rewardTables` not array; schema violation; duplicate `id`; wrong/missing E5M3 table id; unknown `itemId` in `fixedGrants`; duplicate `itemId` in `fixedGrants`; wrong frozen grant quantities; `schemaVersion` ≠ 1; missing schema file. | +| `server/NeonSprawl.Server.Tests/Game/Encounters/EncounterDefinitionCatalogLoaderTests.cs` | **Unit:** valid prototype encounter catalog loads (with pre-built reward-table id set); `encounters` not array; schema violation; duplicate `id`; wrong/missing E5M3 encounter id; wrong `requiredNpcInstanceIds` set; unknown `rewardTableId`; `schemaVersion` ≠ 1. **Host:** `InMemoryWebApplicationFactory` + `/health` + DI resolve both catalogs (encounter `prototype_combat_pocket`, reward table `prototype_combat_pocket_clear`); `WebApplicationFactory` with empty encounters dir fails startup with actionable message. | + +## Open questions / risks + +| Question / risk | Agent recommendation | Status | +|-----------------|----------------------|--------| +| **Constants drift vs Python** | Comment on `PrototypeE5M3*` rules pointing at `scripts/validate_content.py` `PROTOTYPE_E5M3_*` constants and gate functions. | **adopted** | +| **NPC instance ids are code constants, not content** | Encounter cross-ref uses `PrototypeNpcRegistry.GetInstanceIdsInOrder()` set equality (same as CI `PROTOTYPE_E5M2_NPC_INSTANCE_IDS`). | **adopted** | +| **Test cwd / CI** | `InMemoryWebApplicationFactory` must set `Content:EncountersDirectory` and `Content:RewardTablesDirectory` explicitly. | **adopted** | +| **Load order in DI** | Register reward-table catalog factory before encounter catalog; encounter factory reads reward-table ids from resolved singleton. | **adopted** | From f921095fb060d4ff36f4503452c2b591e0f4428b Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 31 May 2026 00:05:57 -0400 Subject: [PATCH 2/4] NEO-101: Fail-fast encounter and reward-table catalog load at startup. Adds Game/Encounters loaders mirroring validate_content.py E5M3 gates, DI registration with reward-tables-before-encounters load order, 19 AAA tests, and server README catalog sections. --- ...umentation_and_implementation_alignment.md | 2 +- .../modules/module_dependency_register.md | 2 +- docs/plans/E5M3-prototype-backlog.md | 6 +- docs/plans/NEO-101-implementation-plan.md | 13 +- .../Encounters/EncounterCatalogTestPaths.cs | 44 +++ .../EncounterDefinitionCatalogLoaderTests.cs | 309 ++++++++++++++++++ ...RewardTableDefinitionCatalogLoaderTests.cs | 276 ++++++++++++++++ .../InMemoryWebApplicationFactory.cs | 9 + .../EncounterCatalogPathResolution.cs | 60 ++++ ...unterCatalogServiceCollectionExtensions.cs | 75 +++++ .../Game/Encounters/EncounterDefRow.cs | 20 ++ .../Encounters/EncounterDefinitionCatalog.cs | 25 ++ .../EncounterDefinitionCatalogLoader.cs | 224 +++++++++++++ .../PrototypeE5M3EncounterCatalogRules.cs | 55 ++++ .../PrototypeE5M3RewardTableCatalogRules.cs | 63 ++++ .../Game/Encounters/RewardGrantRow.cs | 4 + .../RewardTableCatalogPathResolution.cs | 78 +++++ .../Game/Encounters/RewardTableDefRow.cs | 14 + .../RewardTableDefinitionCatalog.cs | 25 ++ .../RewardTableDefinitionCatalogLoader.cs | 260 +++++++++++++++ .../Game/Skills/ContentPathsOptions.cs | 30 ++ server/NeonSprawl.Server/Program.cs | 4 + server/NeonSprawl.Server/appsettings.json | 7 +- server/README.md | 27 ++ 24 files changed, 1624 insertions(+), 8 deletions(-) create mode 100644 server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCatalogTestPaths.cs create mode 100644 server/NeonSprawl.Server.Tests/Game/Encounters/EncounterDefinitionCatalogLoaderTests.cs create mode 100644 server/NeonSprawl.Server.Tests/Game/Encounters/RewardTableDefinitionCatalogLoaderTests.cs create mode 100644 server/NeonSprawl.Server/Game/Encounters/EncounterCatalogPathResolution.cs create mode 100644 server/NeonSprawl.Server/Game/Encounters/EncounterCatalogServiceCollectionExtensions.cs create mode 100644 server/NeonSprawl.Server/Game/Encounters/EncounterDefRow.cs create mode 100644 server/NeonSprawl.Server/Game/Encounters/EncounterDefinitionCatalog.cs create mode 100644 server/NeonSprawl.Server/Game/Encounters/EncounterDefinitionCatalogLoader.cs create mode 100644 server/NeonSprawl.Server/Game/Encounters/PrototypeE5M3EncounterCatalogRules.cs create mode 100644 server/NeonSprawl.Server/Game/Encounters/PrototypeE5M3RewardTableCatalogRules.cs create mode 100644 server/NeonSprawl.Server/Game/Encounters/RewardGrantRow.cs create mode 100644 server/NeonSprawl.Server/Game/Encounters/RewardTableCatalogPathResolution.cs create mode 100644 server/NeonSprawl.Server/Game/Encounters/RewardTableDefRow.cs create mode 100644 server/NeonSprawl.Server/Game/Encounters/RewardTableDefinitionCatalog.cs create mode 100644 server/NeonSprawl.Server/Game/Encounters/RewardTableDefinitionCatalogLoader.cs diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md index 44b97c6..866fb59 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 | **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 | +| 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. **E5M3-02 server load ([NEO-101](https://linear.app/neon-sprawl/issue/NEO-101)):** fail-fast startup loaders for encounter + reward-table catalogs (CI parity). 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)). **Runtime/engine (NEO-102+) not started.** Upstream **E5.M2 Ready**, **E3.M3** inventory landed. | [NEO-101 plan](../../plans/NEO-101-implementation-plan.md), [E5_M3](E5_M3_EncounterAndRewardTables.md), label **`E5.M3`** on NEO-100–NEO-111 | --- diff --git a/docs/decomposition/modules/module_dependency_register.md b/docs/decomposition/modules/module_dependency_register.md index 9eb8abf..ae2a1ee 100644 --- a/docs/decomposition/modules/module_dependency_register.md +++ b/docs/decomposition/modules/module_dependency_register.md @@ -80,7 +80,7 @@ Fleshed-out scope, contracts, and integration notes live in **per-module documen **E5.M2 note:** Epic 5 **Slice 2** backlog in Linear ([Epic 5 — PvE Combat and Encounter Content](https://linear.app/neon-sprawl/project/epic-5-pve-combat-and-encounter-content-cf3c94c3-5346-40e0-8aaf-281e846f2846)): [NEO-87](https://linear.app/neon-sprawl/issue/NEO-87) → [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98); label **`E5.M2`**. Client capstone **E5M2-12** [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98). See [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md), [E5_M2_NpcAiAndBehaviorProfiles.md](E5_M2_NpcAiAndBehaviorProfiles.md). Upstream **E5.M1 Ready**. **NEO-87 landed:** frozen three-behavior catalog + CI ([NEO-87 plan](../../plans/NEO-87-implementation-plan.md)). **NEO-88 landed:** fail-fast server load of `content/npc-behaviors/*_npc_behaviors.json` ([NEO-88 plan](../../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 ([NEO-89 plan](../../plans/NEO-89-implementation-plan.md)). **NEO-90 landed:** **`GET /game/world/npc-behavior-definitions`** — `NpcBehaviorDefinitionsWorldApi` + DTOs in `Game/Npc/` ([NEO-90 plan](../../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, per-archetype catalog max HP ([NEO-91 plan](../../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–NEO-96 landed:** aggro/threat, runtime state machine, **`GET …/npc-runtime-snapshot`**, player combat HP + NPC attack resolve, telemetry hooks ([NEO-92](../../plans/NEO-92-implementation-plan.md)–[NEO-96](../../plans/NEO-96-implementation-plan.md)). **NEO-97 landed:** client telegraph HUD ([NEO-97 plan](../../plans/NEO-97-implementation-plan.md), [`NEO-97` manual QA](../../manual-qa/NEO-97.md)). **NEO-98 landed:** playable NPC telegraph combat capstone ([NEO-98 plan](../../plans/NEO-98-implementation-plan.md), [`NEO-98` manual QA](../../manual-qa/NEO-98.md)); [client README — End-to-end NPC telegraph combat loop (NEO-98)](../../../client/README.md#end-to-end-npc-telegraph-combat-loop-neo-98). **Epic 5 Slice 2 complete — register row Ready.** Replaces **`prototype_target_alpha` / `beta`** with three NPC archetype instances; owns **`ThreatState`**, **`TelegraphEvent`**, session **`IPlayerCombatHealthStore`**. -**E5.M3 note:** Epic 5 **Slice 3** backlog in Linear ([Epic 5 — PvE Combat and Encounter Content](https://linear.app/neon-sprawl/project/epic-5-pve-combat-and-encounter-content-cf3c94c3-5346-40e0-8aaf-281e846f2846)): [NEO-100](https://linear.app/neon-sprawl/issue/NEO-100) → [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111); label **`E5.M3`**. Client capstone **E5M3-12** [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111). See [E5M3-prototype-backlog.md](../../plans/E5M3-prototype-backlog.md), [E5_M3_EncounterAndRewardTables.md](E5_M3_EncounterAndRewardTables.md). Upstream **E5.M2 Ready**, **E3.M3** inventory landed. 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 player; per-defeat gig XP unchanged ([NEO-44](../../plans/NEO-44-implementation-plan.md)). **NEO-100 landed:** encounter + reward-table schemas, prototype catalogs, CI gates ([NEO-100 plan](../../plans/NEO-100-implementation-plan.md)). **Server load/runtime (NEO-101+) not started.** +**E5.M3 note:** Epic 5 **Slice 3** backlog in Linear ([Epic 5 — PvE Combat and Encounter Content](https://linear.app/neon-sprawl/project/epic-5-pve-combat-and-encounter-content-cf3c94c3-5346-40e0-8aaf-281e846f2846)): [NEO-100](https://linear.app/neon-sprawl/issue/NEO-100) → [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111); label **`E5.M3`**. Client capstone **E5M3-12** [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111). See [E5M3-prototype-backlog.md](../../plans/E5M3-prototype-backlog.md), [E5_M3_EncounterAndRewardTables.md](E5_M3_EncounterAndRewardTables.md). Upstream **E5.M2 Ready**, **E3.M3** inventory landed. 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 player; per-defeat gig XP unchanged ([NEO-44](../../plans/NEO-44-implementation-plan.md)). **NEO-100 landed:** encounter + reward-table schemas, prototype catalogs, CI gates ([NEO-100 plan](../../plans/NEO-100-implementation-plan.md)). **NEO-101 landed:** fail-fast server load of `content/encounters/*_encounters.json` + `content/reward-tables/*_reward_tables.json` ([NEO-101 plan](../../plans/NEO-101-implementation-plan.md)); [server README — Encounter + reward-table catalogs (NEO-101)](../../../server/README.md#reward-table-catalog-contentreward-tables-neo-101). **Runtime/engine (NEO-102+) not started.** ### Epic 6 — PvP Security diff --git a/docs/plans/E5M3-prototype-backlog.md b/docs/plans/E5M3-prototype-backlog.md index cedbee3..2d0ba1a 100644 --- a/docs/plans/E5M3-prototype-backlog.md +++ b/docs/plans/E5M3-prototype-backlog.md @@ -117,10 +117,10 @@ Working backlog for **Epic 5 — Slice 3** ([Epic 5 · Slice 3 — encounters an **Acceptance criteria** -- [ ] Host fails startup on invalid encounter or reward-table JSON (mirror CI rules). -- [ ] Tests cover at least one happy path and one malformed catalog rejection. +- [x] Host fails startup on invalid encounter or reward-table JSON (mirror CI rules). +- [x] Tests cover at least one happy path and one malformed catalog rejection. ---- +**Landed ([NEO-101](https://linear.app/neon-sprawl/issue/NEO-101)):** fail-fast encounter + reward-table catalog loaders, DI registration, 19 AAA tests; plan [NEO-101-implementation-plan.md](NEO-101-implementation-plan.md). ### E5M3-03 — Injectable encounter/reward registries + DI diff --git a/docs/plans/NEO-101-implementation-plan.md b/docs/plans/NEO-101-implementation-plan.md index e90c02e..6c846ee 100644 --- a/docs/plans/NEO-101-implementation-plan.md +++ b/docs/plans/NEO-101-implementation-plan.md @@ -42,8 +42,17 @@ ## Acceptance criteria checklist -- [ ] Host fails startup on invalid encounter or reward-table JSON (mirror CI rules). -- [ ] Tests cover at least one happy path and one malformed catalog rejection (loader + host boot per kickoff). +- [x] Host fails startup on invalid encounter or reward-table JSON (mirror CI rules). +- [x] Tests cover at least one happy path and one malformed catalog rejection (loader + host boot per kickoff). + +## Implementation reconciliation (shipped) + +- **Loaders:** `RewardTableDefinitionCatalogLoader`, `EncounterDefinitionCatalogLoader` under `Game/Encounters/` with CI-parity gates. +- **Catalogs:** `RewardTableDefinitionCatalog`, `EncounterDefinitionCatalog` singletons; eager resolve in `Program.cs`. +- **DI:** `AddEncounterAndRewardCatalogs` — reward tables load after item catalog; encounters load after reward tables. +- **Config:** `ContentPathsOptions` encounter + reward-table path keys; `InMemoryWebApplicationFactory` pins repo paths. +- **Tests:** 19 AAA tests in `RewardTableDefinitionCatalogLoaderTests` + `EncounterDefinitionCatalogLoaderTests` (loader + host boot). +- **Docs:** `server/README.md` catalog sections; alignment register E5.M3 row updated. ## Technical approach diff --git a/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCatalogTestPaths.cs b/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCatalogTestPaths.cs new file mode 100644 index 0000000..ca3b073 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterCatalogTestPaths.cs @@ -0,0 +1,44 @@ +using NeonSprawl.Server.Game.Encounters; + +namespace NeonSprawl.Server.Tests.Game.Encounters; + +internal static class EncounterCatalogTestPaths +{ + internal static string DiscoverRepoRewardTablesDirectory() => + RewardTableCatalogPathResolution.TryDiscoverRewardTablesDirectory(AppContext.BaseDirectory) + ?? throw new InvalidOperationException( + "Could not discover repo content/reward-tables from AppContext.BaseDirectory; run tests from the neon-sprawl clone."); + + internal static string DiscoverRepoEncountersDirectory() => + EncounterCatalogPathResolution.TryDiscoverEncountersDirectory(AppContext.BaseDirectory) + ?? throw new InvalidOperationException( + "Could not discover repo content/encounters from AppContext.BaseDirectory; run tests from the neon-sprawl clone."); + + internal static string DiscoverRepoRewardTableDefSchemaPath() => + RewardTableCatalogPathResolution.ResolveRewardTableDefSchemaPath( + DiscoverRepoRewardTablesDirectory(), + configuredSchemaPath: null, + contentRootPath: string.Empty); + + internal static string DiscoverRepoRewardGrantRowSchemaPath() => + RewardTableCatalogPathResolution.ResolveRewardGrantRowSchemaPath( + DiscoverRepoRewardTablesDirectory(), + configuredSchemaPath: null, + contentRootPath: string.Empty); + + internal static string DiscoverRepoEncounterDefSchemaPath() => + EncounterCatalogPathResolution.ResolveEncounterDefSchemaPath( + DiscoverRepoEncountersDirectory(), + configuredSchemaPath: null, + contentRootPath: string.Empty); + + internal static HashSet Slice1KnownItemIds { get; } = + [ + "scrap_metal_bulk", + "refined_plate_stock", + "field_stim_mk0", + "survey_drone_kit", + "contract_handoff_token", + "prototype_armor_shell", + ]; +} diff --git a/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterDefinitionCatalogLoaderTests.cs b/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterDefinitionCatalogLoaderTests.cs new file mode 100644 index 0000000..b3c664e --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterDefinitionCatalogLoaderTests.cs @@ -0,0 +1,309 @@ +using System.Net; +using System.Text; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using NeonSprawl.Server.Game.Encounters; +using NeonSprawl.Server.Tests; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Encounters; + +public class EncounterDefinitionCatalogLoaderTests +{ + private const string ValidPrototypeCatalogJson = + """ + { + "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" + } + ] + } + """; + + private static readonly HashSet KnownRewardTableIds = + ["prototype_combat_pocket_clear"]; + + private static (string Root, string EncountersDir, string SchemaPath) CreateTempContentLayout() + { + var root = Directory.CreateTempSubdirectory("neon-sprawl-encountercat-"); + var encountersDir = Path.Combine(root.FullName, "content", "encounters"); + var schemaDir = Path.Combine(root.FullName, "content", "schemas"); + Directory.CreateDirectory(encountersDir); + Directory.CreateDirectory(schemaDir); + var schemaPath = Path.Combine(schemaDir, "encounter-def.schema.json"); + File.Copy(EncounterCatalogTestPaths.DiscoverRepoEncounterDefSchemaPath(), schemaPath, overwrite: true); + return (root.FullName, encountersDir, schemaPath); + } + + private static void WriteCatalog(string encountersDir, string catalogJson) => + File.WriteAllText(Path.Combine(encountersDir, "prototype_encounters.json"), catalogJson, Encoding.UTF8); + + private static EncounterDefinitionCatalog LoadCatalog( + string encountersDir, + string schemaPath, + IReadOnlySet? knownRewardTableIds = null) => + EncounterDefinitionCatalogLoader.Load( + encountersDir, + schemaPath, + knownRewardTableIds ?? KnownRewardTableIds, + NullLogger.Instance); + + [Fact] + public void Load_ShouldSucceed_WhenCatalogMatchesPrototypeContract() + { + // Arrange + var (_, encountersDir, schemaPath) = CreateTempContentLayout(); + WriteCatalog(encountersDir, ValidPrototypeCatalogJson); + // Act + var catalog = LoadCatalog(encountersDir, schemaPath); + // Assert + Assert.Equal(1, catalog.DistinctEncounterCount); + Assert.Equal(1, catalog.CatalogJsonFileCount); + Assert.True(catalog.TryGetEncounter("prototype_combat_pocket", out var encounter)); + Assert.NotNull(encounter); + Assert.Equal("Prototype Combat Pocket", encounter!.DisplayName); + Assert.Equal("defeat_all_targets", encounter.CompletionCriteriaKind); + Assert.Equal("prototype_combat_pocket_clear", encounter.RewardTableId); + Assert.Equal(3, encounter.RequiredNpcInstanceIds.Count); + } + + [Fact] + public void Load_ShouldThrow_WhenEncountersIsNotArray() + { + // Arrange + var (_, encountersDir, schemaPath) = CreateTempContentLayout(); + File.WriteAllText( + Path.Combine(encountersDir, "bad_encounters.json"), + """{"schemaVersion": 1, "encounters": "nope"}""", + Encoding.UTF8); + // Act + var ex = Record.Exception(() => LoadCatalog(encountersDir, schemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("bad_encounters.json", ioe.Message, StringComparison.Ordinal); + Assert.Contains("expected top-level 'encounters' array", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenSchemaVersionIsNotOne() + { + // Arrange + var (_, encountersDir, schemaPath) = CreateTempContentLayout(); + File.WriteAllText( + Path.Combine(encountersDir, "bad_encounters.json"), + """{"schemaVersion": 2, "encounters": []}""", + Encoding.UTF8); + // Act + var ex = Record.Exception(() => LoadCatalog(encountersDir, schemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("expected schemaVersion 1", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenDuplicateEncounterIdAcrossFiles() + { + // Arrange + var (_, encountersDir, schemaPath) = CreateTempContentLayout(); + WriteCatalog(encountersDir, ValidPrototypeCatalogJson); + File.WriteAllText( + Path.Combine(encountersDir, "extra_encounters.json"), + """ + { + "schemaVersion": 1, + "encounters": [ + { + "id": "prototype_combat_pocket", + "displayName": "Duplicate Encounter", + "completionCriteria": { "kind": "defeat_all_targets" }, + "requiredNpcInstanceIds": [ + "prototype_npc_melee", + "prototype_npc_ranged", + "prototype_npc_elite" + ], + "rewardTableId": "prototype_combat_pocket_clear" + } + ] + } + """, + Encoding.UTF8); + // Act + var ex = Record.Exception(() => LoadCatalog(encountersDir, schemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("duplicate encounter id 'prototype_combat_pocket'", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenE5M3AllowlistMismatch() + { + // Arrange + var (_, encountersDir, schemaPath) = CreateTempContentLayout(); + File.WriteAllText( + Path.Combine(encountersDir, "prototype_encounters.json"), + """ + { + "schemaVersion": 1, + "encounters": [ + { + "id": "wrong_encounter_id", + "displayName": "Wrong Encounter", + "completionCriteria": { "kind": "defeat_all_targets" }, + "requiredNpcInstanceIds": [ + "prototype_npc_melee", + "prototype_npc_ranged", + "prototype_npc_elite" + ], + "rewardTableId": "prototype_combat_pocket_clear" + } + ] + } + """, + Encoding.UTF8); + // Act + var ex = Record.Exception(() => LoadCatalog(encountersDir, schemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("prototype E5M3 expects exactly encounter ids", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenRequiredNpcInstanceIdsMismatch() + { + // Arrange + var (_, encountersDir, schemaPath) = CreateTempContentLayout(); + File.WriteAllText( + Path.Combine(encountersDir, "prototype_encounters.json"), + """ + { + "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_unknown" + ], + "rewardTableId": "prototype_combat_pocket_clear" + } + ] + } + """, + Encoding.UTF8); + // Act + var ex = Record.Exception(() => LoadCatalog(encountersDir, schemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("requiredNpcInstanceIds must be exactly", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenRewardTableIdUnknown() + { + // Arrange + var (_, encountersDir, schemaPath) = CreateTempContentLayout(); + File.WriteAllText( + Path.Combine(encountersDir, "prototype_encounters.json"), + """ + { + "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": "missing_reward_table" + } + ] + } + """, + Encoding.UTF8); + // Act + var ex = Record.Exception(() => LoadCatalog(encountersDir, schemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("rewardTableId 'missing_reward_table' is not in reward table catalogs", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenSchemaFileMissing() + { + // Arrange + var (_, encountersDir, _) = CreateTempContentLayout(); + WriteCatalog(encountersDir, ValidPrototypeCatalogJson); + var missingSchema = Path.Combine(encountersDir, "missing-encounter-def.schema.json"); + // Act + var ex = Record.Exception(() => LoadCatalog(encountersDir, missingSchema)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("missing schema file", ioe.Message, StringComparison.Ordinal); + Assert.Contains(missingSchema, ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task Host_ShouldResolveCatalogsFromDi_WhenStartupSucceeds() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + using var client = factory.CreateClient(); + // Act + var response = await client.GetAsync("/health"); + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var rewardCatalog = factory.Services.GetRequiredService(); + Assert.Equal(1, rewardCatalog.DistinctRewardTableCount); + Assert.True(rewardCatalog.TryGetRewardTable("prototype_combat_pocket_clear", out var rewardTable)); + Assert.NotNull(rewardTable); + Assert.Equal(10, rewardTable!.FixedGrants.First(g => g.ItemId == "scrap_metal_bulk").Quantity); + var encounterCatalog = factory.Services.GetRequiredService(); + Assert.Equal(1, encounterCatalog.DistinctEncounterCount); + Assert.True(encounterCatalog.TryGetEncounter("prototype_combat_pocket", out var encounter)); + Assert.NotNull(encounter); + Assert.Equal("prototype_combat_pocket_clear", encounter!.RewardTableId); + } + + [Fact] + public void Host_ShouldFailStartup_WhenEncountersDirectoryInvalid() + { + // Arrange + var badDir = Path.Combine(Path.GetTempPath(), "neon-sprawl-empty-encounters-" + Guid.NewGuid().ToString("n")); + Directory.CreateDirectory(badDir); + try + { + // Act + var ex = Record.Exception(() => + { + using var factory = new WebApplicationFactory().WithWebHostBuilder(b => + b.UseSetting("Content:EncountersDirectory", badDir)); + factory.CreateClient(); + }); + // Assert + Assert.NotNull(ex); + Assert.Contains("Encounter catalog validation failed", ex.ToString(), StringComparison.Ordinal); + } + finally + { + if (Directory.Exists(badDir)) + Directory.Delete(badDir); + } + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/Encounters/RewardTableDefinitionCatalogLoaderTests.cs b/server/NeonSprawl.Server.Tests/Game/Encounters/RewardTableDefinitionCatalogLoaderTests.cs new file mode 100644 index 0000000..c135d9e --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Encounters/RewardTableDefinitionCatalogLoaderTests.cs @@ -0,0 +1,276 @@ +using System.Text; +using System.Text.Json.Nodes; +using Microsoft.Extensions.Logging.Abstractions; +using NeonSprawl.Server.Game.Encounters; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Encounters; + +public class RewardTableDefinitionCatalogLoaderTests +{ + private const string ValidPrototypeCatalogJson = + """ + { + "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 } + ] + } + ] + } + """; + + private static (string Root, string RewardTablesDir, string TableSchemaPath, string GrantRowSchemaPath) CreateTempContentLayout() + { + var root = Directory.CreateTempSubdirectory("neon-sprawl-rewardtablecat-"); + var rewardTablesDir = Path.Combine(root.FullName, "content", "reward-tables"); + var schemaDir = Path.Combine(root.FullName, "content", "schemas"); + Directory.CreateDirectory(rewardTablesDir); + Directory.CreateDirectory(schemaDir); + var tableSchemaPath = Path.Combine(schemaDir, "reward-table.schema.json"); + var grantRowSchemaPath = Path.Combine(schemaDir, "reward-grant-row.schema.json"); + File.Copy(EncounterCatalogTestPaths.DiscoverRepoRewardTableDefSchemaPath(), tableSchemaPath, overwrite: true); + File.Copy(EncounterCatalogTestPaths.DiscoverRepoRewardGrantRowSchemaPath(), grantRowSchemaPath, overwrite: true); + return (root.FullName, rewardTablesDir, tableSchemaPath, grantRowSchemaPath); + } + + private static void WriteCatalog(string rewardTablesDir, string catalogJson) => + File.WriteAllText(Path.Combine(rewardTablesDir, "prototype_reward_tables.json"), catalogJson, Encoding.UTF8); + + private static RewardTableDefinitionCatalog LoadCatalog( + string rewardTablesDir, + string tableSchemaPath, + string grantRowSchemaPath, + IReadOnlySet? knownItemIds = null) => + RewardTableDefinitionCatalogLoader.Load( + rewardTablesDir, + tableSchemaPath, + grantRowSchemaPath, + knownItemIds ?? EncounterCatalogTestPaths.Slice1KnownItemIds, + NullLogger.Instance); + + [Fact] + public void Load_ShouldSucceed_WhenCatalogMatchesPrototypeContract() + { + // Arrange + var (_, rewardTablesDir, tableSchemaPath, grantRowSchemaPath) = CreateTempContentLayout(); + WriteCatalog(rewardTablesDir, ValidPrototypeCatalogJson); + // Act + var catalog = LoadCatalog(rewardTablesDir, tableSchemaPath, grantRowSchemaPath); + // Assert + Assert.Equal(1, catalog.DistinctRewardTableCount); + Assert.Equal(1, catalog.CatalogJsonFileCount); + Assert.True(catalog.TryGetRewardTable("prototype_combat_pocket_clear", out var table)); + Assert.NotNull(table); + Assert.Equal("Prototype Combat Pocket Clear", table!.DisplayName); + Assert.Equal(2, table.FixedGrants.Count); + Assert.Equal("scrap_metal_bulk", table.FixedGrants[0].ItemId); + Assert.Equal(10, table.FixedGrants[0].Quantity); + Assert.Equal("contract_handoff_token", table.FixedGrants[1].ItemId); + Assert.Equal(1, table.FixedGrants[1].Quantity); + } + + [Fact] + public void Load_ShouldThrow_WhenRewardTablesIsNotArray() + { + // Arrange + var (_, rewardTablesDir, tableSchemaPath, grantRowSchemaPath) = CreateTempContentLayout(); + File.WriteAllText( + Path.Combine(rewardTablesDir, "bad_reward_tables.json"), + """{"schemaVersion": 1, "rewardTables": "nope"}""", + Encoding.UTF8); + // Act + var ex = Record.Exception(() => LoadCatalog(rewardTablesDir, tableSchemaPath, grantRowSchemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("bad_reward_tables.json", ioe.Message, StringComparison.Ordinal); + Assert.Contains("expected top-level 'rewardTables' array", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenSchemaVersionIsNotOne() + { + // Arrange + var (_, rewardTablesDir, tableSchemaPath, grantRowSchemaPath) = CreateTempContentLayout(); + File.WriteAllText( + Path.Combine(rewardTablesDir, "bad_reward_tables.json"), + """{"schemaVersion": 2, "rewardTables": []}""", + Encoding.UTF8); + // Act + var ex = Record.Exception(() => LoadCatalog(rewardTablesDir, tableSchemaPath, grantRowSchemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("expected schemaVersion 1", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenDuplicateRewardTableIdAcrossFiles() + { + // Arrange + var (_, rewardTablesDir, tableSchemaPath, grantRowSchemaPath) = CreateTempContentLayout(); + WriteCatalog(rewardTablesDir, ValidPrototypeCatalogJson); + File.WriteAllText( + Path.Combine(rewardTablesDir, "extra_reward_tables.json"), + """ + { + "schemaVersion": 1, + "rewardTables": [ + { + "id": "prototype_combat_pocket_clear", + "displayName": "Duplicate Table", + "fixedGrants": [ + { "itemId": "scrap_metal_bulk", "quantity": 10 }, + { "itemId": "contract_handoff_token", "quantity": 1 } + ] + } + ] + } + """, + Encoding.UTF8); + // Act + var ex = Record.Exception(() => LoadCatalog(rewardTablesDir, tableSchemaPath, grantRowSchemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("duplicate reward table id 'prototype_combat_pocket_clear'", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenE5M3AllowlistMismatch() + { + // Arrange + var (_, rewardTablesDir, tableSchemaPath, grantRowSchemaPath) = CreateTempContentLayout(); + File.WriteAllText( + Path.Combine(rewardTablesDir, "prototype_reward_tables.json"), + """ + { + "schemaVersion": 1, + "rewardTables": [ + { + "id": "wrong_reward_table_id", + "displayName": "Wrong Table", + "fixedGrants": [ + { "itemId": "scrap_metal_bulk", "quantity": 10 }, + { "itemId": "contract_handoff_token", "quantity": 1 } + ] + } + ] + } + """, + Encoding.UTF8); + // Act + var ex = Record.Exception(() => LoadCatalog(rewardTablesDir, tableSchemaPath, grantRowSchemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("prototype E5M3 expects exactly reward table ids", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenUnknownItemIdInFixedGrants() + { + // Arrange + var (_, rewardTablesDir, tableSchemaPath, grantRowSchemaPath) = CreateTempContentLayout(); + File.WriteAllText( + Path.Combine(rewardTablesDir, "prototype_reward_tables.json"), + """ + { + "schemaVersion": 1, + "rewardTables": [ + { + "id": "prototype_combat_pocket_clear", + "displayName": "Prototype Combat Pocket Clear", + "fixedGrants": [ + { "itemId": "unknown_item_id", "quantity": 1 } + ] + } + ] + } + """, + Encoding.UTF8); + // Act + var ex = Record.Exception(() => LoadCatalog(rewardTablesDir, tableSchemaPath, grantRowSchemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("itemId 'unknown_item_id' is not in item catalogs", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenDuplicateItemIdInFixedGrants() + { + // Arrange + var (_, rewardTablesDir, tableSchemaPath, grantRowSchemaPath) = CreateTempContentLayout(); + File.WriteAllText( + Path.Combine(rewardTablesDir, "prototype_reward_tables.json"), + """ + { + "schemaVersion": 1, + "rewardTables": [ + { + "id": "prototype_combat_pocket_clear", + "displayName": "Prototype Combat Pocket Clear", + "fixedGrants": [ + { "itemId": "scrap_metal_bulk", "quantity": 5 }, + { "itemId": "scrap_metal_bulk", "quantity": 5 } + ] + } + ] + } + """, + Encoding.UTF8); + // Act + var ex = Record.Exception(() => LoadCatalog(rewardTablesDir, tableSchemaPath, grantRowSchemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("duplicate itemId 'scrap_metal_bulk'", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenFrozenGrantQuantitiesMismatch() + { + // Arrange + var (_, rewardTablesDir, tableSchemaPath, grantRowSchemaPath) = CreateTempContentLayout(); + File.WriteAllText( + Path.Combine(rewardTablesDir, "prototype_reward_tables.json"), + """ + { + "schemaVersion": 1, + "rewardTables": [ + { + "id": "prototype_combat_pocket_clear", + "displayName": "Prototype Combat Pocket Clear", + "fixedGrants": [ + { "itemId": "scrap_metal_bulk", "quantity": 9 }, + { "itemId": "contract_handoff_token", "quantity": 1 } + ] + } + ] + } + """, + Encoding.UTF8); + // Act + var ex = Record.Exception(() => LoadCatalog(rewardTablesDir, tableSchemaPath, grantRowSchemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("prototype_combat_pocket_clear fixedGrants must match", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenSchemaFileMissing() + { + // Arrange + var (_, rewardTablesDir, _, grantRowSchemaPath) = CreateTempContentLayout(); + WriteCatalog(rewardTablesDir, ValidPrototypeCatalogJson); + var missingSchema = Path.Combine(rewardTablesDir, "missing-reward-table.schema.json"); + // Act + var ex = Record.Exception(() => + LoadCatalog(rewardTablesDir, missingSchema, grantRowSchemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("missing schema file", ioe.Message, StringComparison.Ordinal); + Assert.Contains(missingSchema, ioe.Message, StringComparison.Ordinal); + } +} diff --git a/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs b/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs index 52b36c9..23560bf 100644 --- a/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs +++ b/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs @@ -7,6 +7,7 @@ using Microsoft.Extensions.Time.Testing; using NeonSprawl.Server.Game.AbilityInput; using NeonSprawl.Server.Game.Combat; using NeonSprawl.Server.Game.Crafting; +using NeonSprawl.Server.Game.Encounters; using NeonSprawl.Server.Game.Gathering; using NeonSprawl.Server.Game.Gigs; using NeonSprawl.Server.Game.Items; @@ -51,6 +52,12 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory diff --git a/server/NeonSprawl.Server/Game/Encounters/EncounterCatalogPathResolution.cs b/server/NeonSprawl.Server/Game/Encounters/EncounterCatalogPathResolution.cs new file mode 100644 index 0000000..6a73594 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Encounters/EncounterCatalogPathResolution.cs @@ -0,0 +1,60 @@ +namespace NeonSprawl.Server.Game.Encounters; + +/// Resolves encounter catalog paths for local dev, tests, and container layouts (NEO-101). +public static class EncounterCatalogPathResolution +{ + /// Walks and parents for an existing content/encounters directory. + public static string? TryDiscoverEncountersDirectory(string startDirectory) + { + for (var dir = new DirectoryInfo(startDirectory); dir is not null; dir = dir.Parent) + { + var candidate = Path.Combine(dir.FullName, "content", "encounters"); + if (Directory.Exists(candidate)) + return Path.GetFullPath(candidate); + } + + return null; + } + + /// + /// Resolves the encounters catalog directory. + /// Empty triggers discovery from . + /// + public static string ResolveEncountersDirectory(string? configuredEncountersDirectory, string contentRootPath) + { + if (string.IsNullOrWhiteSpace(configuredEncountersDirectory)) + { + var discovered = TryDiscoverEncountersDirectory(AppContext.BaseDirectory); + if (discovered is not null) + return discovered; + + throw new InvalidOperationException( + "Content:EncountersDirectory is not set and auto-discovery failed (no ancestor of AppContext.BaseDirectory contains 'content/encounters'). " + + "Set Content:EncountersDirectory in configuration or environment (e.g. Content__EncountersDirectory)."); + } + + var trimmed = configuredEncountersDirectory.Trim(); + if (Path.IsPathRooted(trimmed)) + return Path.GetFullPath(trimmed); + + return Path.GetFullPath(Path.Combine(contentRootPath, trimmed)); + } + + /// Resolves JSON Schema path for a single encounter row (Draft 2020-12). + public static string ResolveEncounterDefSchemaPath( + string encountersDirectory, + string? configuredSchemaPath, + string contentRootPath) + { + if (!string.IsNullOrWhiteSpace(configuredSchemaPath)) + { + var trimmed = configuredSchemaPath.Trim(); + if (Path.IsPathRooted(trimmed)) + return Path.GetFullPath(trimmed); + + return Path.GetFullPath(Path.Combine(contentRootPath, trimmed)); + } + + return Path.GetFullPath(Path.Combine(encountersDirectory, "..", "schemas", "encounter-def.schema.json")); + } +} diff --git a/server/NeonSprawl.Server/Game/Encounters/EncounterCatalogServiceCollectionExtensions.cs b/server/NeonSprawl.Server/Game/Encounters/EncounterCatalogServiceCollectionExtensions.cs new file mode 100644 index 0000000..39830d2 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Encounters/EncounterCatalogServiceCollectionExtensions.cs @@ -0,0 +1,75 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using NeonSprawl.Server.Game.Items; +using NeonSprawl.Server.Game.Skills; + +namespace NeonSprawl.Server.Game.Encounters; + +/// DI registration for the fail-fast encounter + reward-table catalogs (NEO-101). +public static class EncounterCatalogServiceCollectionExtensions +{ + /// Binds and registers encounter + reward-table catalog singletons. + public static IServiceCollection AddEncounterAndRewardCatalogs(this IServiceCollection services, IConfiguration configuration) + { + services.AddOptions() + .Bind(configuration.GetSection(ContentPathsOptions.SectionName)); + + services.AddSingleton(sp => + { + var hostEnv = sp.GetRequiredService(); + var opts = sp.GetRequiredService>().Value; + var itemCatalog = sp.GetRequiredService(); + var logger = sp.GetRequiredService() + .CreateLogger("NeonSprawl.Server.Game.Encounters.RewardTableCatalog"); + + var rewardTablesDir = RewardTableCatalogPathResolution.ResolveRewardTablesDirectory( + opts.RewardTablesDirectory, + hostEnv.ContentRootPath); + var rewardTableSchemaPath = RewardTableCatalogPathResolution.ResolveRewardTableDefSchemaPath( + rewardTablesDir, + opts.RewardTableDefSchemaPath, + hostEnv.ContentRootPath); + var rewardGrantRowSchemaPath = RewardTableCatalogPathResolution.ResolveRewardGrantRowSchemaPath( + rewardTablesDir, + opts.RewardGrantRowSchemaPath, + hostEnv.ContentRootPath); + + var knownItemIds = itemCatalog.ById.Keys.ToHashSet(StringComparer.Ordinal); + + return RewardTableDefinitionCatalogLoader.Load( + rewardTablesDir, + rewardTableSchemaPath, + rewardGrantRowSchemaPath, + knownItemIds, + logger); + }); + + services.AddSingleton(sp => + { + var hostEnv = sp.GetRequiredService(); + var opts = sp.GetRequiredService>().Value; + var rewardTableCatalog = sp.GetRequiredService(); + var logger = sp.GetRequiredService() + .CreateLogger("NeonSprawl.Server.Game.Encounters.EncounterCatalog"); + + var encountersDir = EncounterCatalogPathResolution.ResolveEncountersDirectory( + opts.EncountersDirectory, + hostEnv.ContentRootPath); + var encounterDefSchemaPath = EncounterCatalogPathResolution.ResolveEncounterDefSchemaPath( + encountersDir, + opts.EncounterDefSchemaPath, + hostEnv.ContentRootPath); + + var knownRewardTableIds = rewardTableCatalog.ById.Keys.ToHashSet(StringComparer.Ordinal); + + return EncounterDefinitionCatalogLoader.Load( + encountersDir, + encounterDefSchemaPath, + knownRewardTableIds, + logger); + }); + + return services; + } +} diff --git a/server/NeonSprawl.Server/Game/Encounters/EncounterDefRow.cs b/server/NeonSprawl.Server/Game/Encounters/EncounterDefRow.cs new file mode 100644 index 0000000..8a235af --- /dev/null +++ b/server/NeonSprawl.Server/Game/Encounters/EncounterDefRow.cs @@ -0,0 +1,20 @@ +namespace NeonSprawl.Server.Game.Encounters; + +/// Load-time encounter row (NEO-101). +public sealed class EncounterDefRow( + string id, + string displayName, + string completionCriteriaKind, + IReadOnlyList requiredNpcInstanceIds, + string rewardTableId) +{ + public string Id { get; } = id; + + public string DisplayName { get; } = displayName; + + public string CompletionCriteriaKind { get; } = completionCriteriaKind; + + public IReadOnlyList RequiredNpcInstanceIds { get; } = requiredNpcInstanceIds; + + public string RewardTableId { get; } = rewardTableId; +} diff --git a/server/NeonSprawl.Server/Game/Encounters/EncounterDefinitionCatalog.cs b/server/NeonSprawl.Server/Game/Encounters/EncounterDefinitionCatalog.cs new file mode 100644 index 0000000..9e644c4 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Encounters/EncounterDefinitionCatalog.cs @@ -0,0 +1,25 @@ +using System.Collections.ObjectModel; + +namespace NeonSprawl.Server.Game.Encounters; + +/// In-memory encounter catalog loaded at startup (NEO-101). Game callers should use injectable registries (NEO-102). +public sealed class EncounterDefinitionCatalog( + string encountersDirectory, + IReadOnlyDictionary byId, + int catalogJsonFileCount) +{ + /// Absolute path to the directory that was enumerated for *_encounters.json catalogs. + public string EncountersDirectory { get; } = encountersDirectory; + + public IReadOnlyDictionary ById { get; } = + new ReadOnlyDictionary(new Dictionary(byId, StringComparer.Ordinal)); + + public int DistinctEncounterCount => ById.Count; + + /// Number of *_encounters.json files under . + public int CatalogJsonFileCount { get; } = catalogJsonFileCount; + + /// Resolves a catalog row by stable encounter . + public bool TryGetEncounter(string id, out EncounterDefRow? row) => + ById.TryGetValue(id, out row); +} diff --git a/server/NeonSprawl.Server/Game/Encounters/EncounterDefinitionCatalogLoader.cs b/server/NeonSprawl.Server/Game/Encounters/EncounterDefinitionCatalogLoader.cs new file mode 100644 index 0000000..4acec77 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Encounters/EncounterDefinitionCatalogLoader.cs @@ -0,0 +1,224 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using Json.Schema; +using Microsoft.Extensions.Logging; + +namespace NeonSprawl.Server.Game.Encounters; + +/// Loads and validates content/encounters/*_encounters.json using the same rules as scripts/validate_content.py (NEO-101). +public static class EncounterDefinitionCatalogLoader +{ + /// Loads catalogs from disk or throws with actionable messages. + public static EncounterDefinitionCatalog Load( + string encountersDirectory, + string encounterDefSchemaPath, + IReadOnlySet knownRewardTableIds, + ILogger logger) + { + encountersDirectory = Path.GetFullPath(encountersDirectory); + encounterDefSchemaPath = Path.GetFullPath(encounterDefSchemaPath); + + var errors = new List(); + + if (!File.Exists(encounterDefSchemaPath)) + errors.Add($"error: missing schema file {encounterDefSchemaPath}"); + + if (!Directory.Exists(encountersDirectory)) + errors.Add($"error: missing directory {encountersDirectory}"); + + if (errors.Count > 0) + ThrowIfAny(errors); + + var jsonFiles = Directory.GetFiles(encountersDirectory, "*_encounters.json", SearchOption.TopDirectoryOnly) + .OrderBy(p => p, StringComparer.Ordinal) + .ToArray(); + if (jsonFiles.Length == 0) + errors.Add($"error: no *_encounters.json files under {encountersDirectory}"); + + if (errors.Count > 0) + ThrowIfAny(errors); + + var schema = JsonSchema.FromText(File.ReadAllText(encounterDefSchemaPath)); + var evalOptions = new EvaluationOptions { OutputFormat = OutputFormat.List }; + + var encounterIdToSourceFile = new Dictionary(StringComparer.Ordinal); + var rows = new Dictionary(StringComparer.Ordinal); + + foreach (var path in jsonFiles) + { + JsonNode? root; + try + { + root = JsonNode.Parse(File.ReadAllText(path)); + } + catch (JsonException ex) + { + errors.Add($"error: {path}: invalid JSON: {ex.Message}"); + continue; + } + + if (root is not JsonObject rootObj) + { + errors.Add($"error: {path}: expected JSON object at root"); + continue; + } + + var schemaVersionNode = rootObj["schemaVersion"]; + if (schemaVersionNode is not JsonValue schemaVersionValue || + !schemaVersionValue.TryGetValue(out var schemaVersion) || + schemaVersion != 1) + { + var got = schemaVersionNode?.ToJsonString() ?? "null"; + errors.Add($"error: {path}: expected schemaVersion 1, got {got}"); + continue; + } + + var encountersNode = rootObj["encounters"]; + if (encountersNode is not JsonArray encountersArray) + { + errors.Add($"error: {path}: expected top-level 'encounters' array"); + continue; + } + + for (var i = 0; i < encountersArray.Count; i++) + { + var encounter = encountersArray[i]; + if (encounter is not JsonObject rowObj) + { + errors.Add($"error: {path}: encounters[{i}] must be an object"); + continue; + } + + var eval = schema.Evaluate(rowObj, evalOptions); + var schemaMsgs = CollectSchemaMessages(eval, path, i).OrderBy(m => m, StringComparer.Ordinal).ToList(); + if (!eval.IsValid) + { + if (schemaMsgs.Count == 0) + schemaMsgs.Add($"error: {path} encounters[{i}] (root): schema validation failed"); + + errors.AddRange(schemaMsgs); + } + + var rowSchemaErrors = schemaMsgs.Count; + + var eid = (rowObj["id"] as JsonValue)?.GetValue(); + if (eid is not null && rowSchemaErrors == 0) + { + if (encounterIdToSourceFile.TryGetValue(eid, out var prevPath)) + { + errors.Add($"error: duplicate encounter id '{eid}' in {prevPath} and {path}"); + continue; + } + + encounterIdToSourceFile[eid] = path; + + var rewardTableId = (rowObj["rewardTableId"] as JsonValue)?.GetValue(); + if (rewardTableId is not null && !knownRewardTableIds.Contains(rewardTableId)) + { + errors.Add( + $"error: {path} encounters[{i}]: rewardTableId '{rewardTableId}' is not in reward table catalogs"); + } + + rows[eid] = ParseRow(rowObj); + } + } + } + + ThrowIfAny(errors); + + var e5m3 = PrototypeE5M3EncounterCatalogRules.TryGetE5M3GateError(encounterIdToSourceFile); + if (e5m3 is not null) + { + errors.Add(e5m3); + ThrowIfAny(errors); + } + + var npcCrossRef = PrototypeE5M3EncounterCatalogRules.TryGetNpcInstanceCrossRefError(rows); + if (npcCrossRef is not null) + { + errors.Add(npcCrossRef); + ThrowIfAny(errors); + } + + if (logger.IsEnabled(LogLevel.Information)) + { + logger.LogInformation( + "Loaded encounter catalog from {EncountersDirectory}: {EncounterCount} encounter(s) across {CatalogFileCount} JSON catalog file(s).", + encountersDirectory, + rows.Count, + jsonFiles.Length); + } + + return new EncounterDefinitionCatalog(encountersDirectory, rows, jsonFiles.Length); + } + + private static EncounterDefRow ParseRow(JsonObject rowObj) + { + var id = (rowObj["id"] as JsonValue)!.GetValue(); + var displayName = (rowObj["displayName"] as JsonValue)!.GetValue(); + var completionCriteria = rowObj["completionCriteria"] as JsonObject + ?? throw new InvalidOperationException("expected completionCriteria object"); + var completionCriteriaKind = (completionCriteria["kind"] as JsonValue)!.GetValue(); + var requiredNpcInstanceIds = ParseStringArray(rowObj["requiredNpcInstanceIds"] as JsonArray); + var rewardTableId = (rowObj["rewardTableId"] as JsonValue)!.GetValue(); + + return new EncounterDefRow(id, displayName, completionCriteriaKind, requiredNpcInstanceIds, rewardTableId); + } + + private static IReadOnlyList ParseStringArray(JsonArray? array) + { + if (array is null) + return []; + + var values = new List(array.Count); + foreach (var node in array) + { + if (node is JsonValue value && value.TryGetValue(out var s)) + values.Add(s); + } + + return values; + } + + private static List CollectSchemaMessages(EvaluationResults eval, string filePath, int index) + { + var sink = new List(); + AppendSchemaMessages(eval, filePath, index, sink); + return sink; + } + + private static void AppendSchemaMessages(EvaluationResults r, string filePath, int index, List sink) + { + if (r.HasDetails) + { + foreach (var d in r.Details!) + AppendSchemaMessages(d, filePath, index, sink); + } + + if (!r.HasErrors) + return; + + foreach (var kv in r.Errors!) + { + var loc = r.InstanceLocation?.ToString(); + if (string.IsNullOrEmpty(loc) || loc == "#") + loc = "(root)"; + + sink.Add($"error: {filePath} encounters[{index}] {loc}: {kv.Key} — {kv.Value}"); + } + } + + private static void ThrowIfAny(List errors) + { + if (errors.Count == 0) + return; + + var sb = new StringBuilder(); + sb.AppendLine("Encounter catalog validation failed:"); + foreach (var e in errors.OrderBy(x => x, StringComparer.Ordinal)) + sb.AppendLine(e); + + throw new InvalidOperationException(sb.ToString().TrimEnd()); + } +} diff --git a/server/NeonSprawl.Server/Game/Encounters/PrototypeE5M3EncounterCatalogRules.cs b/server/NeonSprawl.Server/Game/Encounters/PrototypeE5M3EncounterCatalogRules.cs new file mode 100644 index 0000000..74fe29f --- /dev/null +++ b/server/NeonSprawl.Server/Game/Encounters/PrototypeE5M3EncounterCatalogRules.cs @@ -0,0 +1,55 @@ +using System.Collections.Frozen; +using NeonSprawl.Server.Game.Npc; + +namespace NeonSprawl.Server.Game.Encounters; + +/// +/// Prototype E5M3 encounter roster + cross-ref gates (NEO-101), mirrored from +/// scripts/validate_content.py PROTOTYPE_E5M3_ENCOUNTER_IDS, +/// PROTOTYPE_E5M2_NPC_INSTANCE_IDS, _prototype_e5m3_encounter_gate, and +/// _prototype_e5m3_encounter_cross_ref_gate. +/// +public static class PrototypeE5M3EncounterCatalogRules +{ + /// Keep in sync with scripts/validate_content.py PROTOTYPE_E5M3_ENCOUNTER_IDS. + public static readonly FrozenSet ExpectedEncounterIds = FrozenSet.ToFrozenSet( + ["prototype_combat_pocket"], + StringComparer.Ordinal); + + /// Keep in sync with scripts/validate_content.py PROTOTYPE_E5M2_NPC_INSTANCE_IDS. + public static FrozenSet ExpectedNpcInstanceIds => + PrototypeNpcRegistry.GetInstanceIdsInOrder().ToFrozenSet(StringComparer.Ordinal); + + /// Returns a human-readable error if the E5M3 encounter contract fails, otherwise . + public static string? TryGetE5M3GateError(IReadOnlyDictionary encounterIdToSourceFile) + { + var ids = encounterIdToSourceFile.Keys.ToFrozenSet(StringComparer.Ordinal); + if (!ids.SetEquals(ExpectedEncounterIds)) + { + return + "error: prototype E5M3 expects exactly encounter ids " + + $"[{string.Join(", ", ExpectedEncounterIds.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}], " + + $"got [{string.Join(", ", ids.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}]"; + } + + return null; + } + + /// Returns a human-readable error when NPC instance cross-refs fail, otherwise . + public static string? TryGetNpcInstanceCrossRefError(IReadOnlyDictionary rowsById) + { + foreach (var (encounterId, row) in rowsById) + { + var npcSet = row.RequiredNpcInstanceIds.ToFrozenSet(StringComparer.Ordinal); + if (!npcSet.SetEquals(ExpectedNpcInstanceIds)) + { + return + $"error: encounter '{encounterId}': requiredNpcInstanceIds must be exactly " + + $"[{string.Join(", ", ExpectedNpcInstanceIds.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}], " + + $"got [{string.Join(", ", npcSet.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}]"; + } + } + + return null; + } +} diff --git a/server/NeonSprawl.Server/Game/Encounters/PrototypeE5M3RewardTableCatalogRules.cs b/server/NeonSprawl.Server/Game/Encounters/PrototypeE5M3RewardTableCatalogRules.cs new file mode 100644 index 0000000..607543e --- /dev/null +++ b/server/NeonSprawl.Server/Game/Encounters/PrototypeE5M3RewardTableCatalogRules.cs @@ -0,0 +1,63 @@ +using System.Collections.Frozen; + +namespace NeonSprawl.Server.Game.Encounters; + +/// +/// Prototype E5M3 reward-table roster + grant quantity gates (NEO-101), mirrored from +/// scripts/validate_content.py PROTOTYPE_E5M3_REWARD_TABLE_IDS, +/// PROTOTYPE_E5M3_REWARD_GRANTS, _prototype_e5m3_reward_table_gate, and +/// _prototype_e5m3_reward_grant_content_gate. +/// +public static class PrototypeE5M3RewardTableCatalogRules +{ + /// Keep in sync with scripts/validate_content.py PROTOTYPE_E5M3_REWARD_TABLE_IDS. + public static readonly FrozenSet ExpectedRewardTableIds = FrozenSet.ToFrozenSet( + ["prototype_combat_pocket_clear"], + StringComparer.Ordinal); + + /// Keep in sync with scripts/validate_content.py PROTOTYPE_E5M3_REWARD_GRANTS. + public static readonly FrozenDictionary ExpectedGrantQuantities = + FrozenDictionary.ToFrozenDictionary( + new Dictionary(StringComparer.Ordinal) + { + ["scrap_metal_bulk"] = 10, + ["contract_handoff_token"] = 1, + }, + StringComparer.Ordinal); + + /// Returns a human-readable error if the E5M3 reward-table contract fails, otherwise . + public static string? TryGetE5M3GateError(IReadOnlyDictionary rewardTableIdToSourceFile) + { + var ids = rewardTableIdToSourceFile.Keys.ToFrozenSet(StringComparer.Ordinal); + if (!ids.SetEquals(ExpectedRewardTableIds)) + { + return + "error: prototype E5M3 expects exactly reward table ids " + + $"[{string.Join(", ", ExpectedRewardTableIds.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}], " + + $"got [{string.Join(", ", ids.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}]"; + } + + return null; + } + + /// Returns a human-readable error when frozen grant quantities fail, otherwise . + public static string? TryGetGrantContentGateError(IReadOnlyDictionary rowsById) + { + if (!rowsById.TryGetValue("prototype_combat_pocket_clear", out var row)) + return null; + + var grants = row.FixedGrants.ToDictionary(g => g.ItemId, g => g.Quantity, StringComparer.Ordinal); + var expectedOrdered = ExpectedGrantQuantities.OrderBy(kv => kv.Key, StringComparer.Ordinal).ToList(); + var actualOrdered = grants.OrderBy(kv => kv.Key, StringComparer.Ordinal).ToList(); + if (grants.Count != ExpectedGrantQuantities.Count || + !actualOrdered.SequenceEqual(expectedOrdered)) + { + return + "error: prototype_combat_pocket_clear fixedGrants must match " + + $"[{string.Join(", ", expectedOrdered.Select(kv => $"'{kv.Key}': {kv.Value}"))}], " + + $"got [{string.Join(", ", actualOrdered.Select(kv => $"'{kv.Key}': {kv.Value}"))}]"; + } + + return null; + } +} diff --git a/server/NeonSprawl.Server/Game/Encounters/RewardGrantRow.cs b/server/NeonSprawl.Server/Game/Encounters/RewardGrantRow.cs new file mode 100644 index 0000000..6f60f69 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Encounters/RewardGrantRow.cs @@ -0,0 +1,4 @@ +namespace NeonSprawl.Server.Game.Encounters; + +/// A single deterministic item grant on a (NEO-101). +public readonly record struct RewardGrantRow(string ItemId, int Quantity); diff --git a/server/NeonSprawl.Server/Game/Encounters/RewardTableCatalogPathResolution.cs b/server/NeonSprawl.Server/Game/Encounters/RewardTableCatalogPathResolution.cs new file mode 100644 index 0000000..87fed81 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Encounters/RewardTableCatalogPathResolution.cs @@ -0,0 +1,78 @@ +namespace NeonSprawl.Server.Game.Encounters; + +/// Resolves reward-table catalog paths for local dev, tests, and container layouts (NEO-101). +public static class RewardTableCatalogPathResolution +{ + /// Walks and parents for an existing content/reward-tables directory. + public static string? TryDiscoverRewardTablesDirectory(string startDirectory) + { + for (var dir = new DirectoryInfo(startDirectory); dir is not null; dir = dir.Parent) + { + var candidate = Path.Combine(dir.FullName, "content", "reward-tables"); + if (Directory.Exists(candidate)) + return Path.GetFullPath(candidate); + } + + return null; + } + + /// + /// Resolves the reward-tables catalog directory. + /// Empty triggers discovery from . + /// + public static string ResolveRewardTablesDirectory(string? configuredRewardTablesDirectory, string contentRootPath) + { + if (string.IsNullOrWhiteSpace(configuredRewardTablesDirectory)) + { + var discovered = TryDiscoverRewardTablesDirectory(AppContext.BaseDirectory); + if (discovered is not null) + return discovered; + + throw new InvalidOperationException( + "Content:RewardTablesDirectory is not set and auto-discovery failed (no ancestor of AppContext.BaseDirectory contains 'content/reward-tables'). " + + "Set Content:RewardTablesDirectory in configuration or environment (e.g. Content__RewardTablesDirectory)."); + } + + var trimmed = configuredRewardTablesDirectory.Trim(); + if (Path.IsPathRooted(trimmed)) + return Path.GetFullPath(trimmed); + + return Path.GetFullPath(Path.Combine(contentRootPath, trimmed)); + } + + /// Resolves JSON Schema path for a single reward-table row (Draft 2020-12). + public static string ResolveRewardTableDefSchemaPath( + string rewardTablesDirectory, + string? configuredSchemaPath, + string contentRootPath) + { + if (!string.IsNullOrWhiteSpace(configuredSchemaPath)) + { + var trimmed = configuredSchemaPath.Trim(); + if (Path.IsPathRooted(trimmed)) + return Path.GetFullPath(trimmed); + + return Path.GetFullPath(Path.Combine(contentRootPath, trimmed)); + } + + return Path.GetFullPath(Path.Combine(rewardTablesDirectory, "..", "schemas", "reward-table.schema.json")); + } + + /// Resolves JSON Schema path for a reward grant row referenced by reward-table schema (Draft 2020-12). + public static string ResolveRewardGrantRowSchemaPath( + string rewardTablesDirectory, + string? configuredSchemaPath, + string contentRootPath) + { + if (!string.IsNullOrWhiteSpace(configuredSchemaPath)) + { + var trimmed = configuredSchemaPath.Trim(); + if (Path.IsPathRooted(trimmed)) + return Path.GetFullPath(trimmed); + + return Path.GetFullPath(Path.Combine(contentRootPath, trimmed)); + } + + return Path.GetFullPath(Path.Combine(rewardTablesDirectory, "..", "schemas", "reward-grant-row.schema.json")); + } +} diff --git a/server/NeonSprawl.Server/Game/Encounters/RewardTableDefRow.cs b/server/NeonSprawl.Server/Game/Encounters/RewardTableDefRow.cs new file mode 100644 index 0000000..1d72cfb --- /dev/null +++ b/server/NeonSprawl.Server/Game/Encounters/RewardTableDefRow.cs @@ -0,0 +1,14 @@ +namespace NeonSprawl.Server.Game.Encounters; + +/// Load-time reward table row (NEO-101). +public sealed class RewardTableDefRow( + string id, + string displayName, + IReadOnlyList fixedGrants) +{ + public string Id { get; } = id; + + public string DisplayName { get; } = displayName; + + public IReadOnlyList FixedGrants { get; } = fixedGrants; +} diff --git a/server/NeonSprawl.Server/Game/Encounters/RewardTableDefinitionCatalog.cs b/server/NeonSprawl.Server/Game/Encounters/RewardTableDefinitionCatalog.cs new file mode 100644 index 0000000..10d10b4 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Encounters/RewardTableDefinitionCatalog.cs @@ -0,0 +1,25 @@ +using System.Collections.ObjectModel; + +namespace NeonSprawl.Server.Game.Encounters; + +/// In-memory reward-table catalog loaded at startup (NEO-101). Game callers should use injectable registries (NEO-102). +public sealed class RewardTableDefinitionCatalog( + string rewardTablesDirectory, + IReadOnlyDictionary byId, + int catalogJsonFileCount) +{ + /// Absolute path to the directory that was enumerated for *_reward_tables.json catalogs. + public string RewardTablesDirectory { get; } = rewardTablesDirectory; + + public IReadOnlyDictionary ById { get; } = + new ReadOnlyDictionary(new Dictionary(byId, StringComparer.Ordinal)); + + public int DistinctRewardTableCount => ById.Count; + + /// Number of *_reward_tables.json files under . + public int CatalogJsonFileCount { get; } = catalogJsonFileCount; + + /// Resolves a catalog row by stable reward-table . + public bool TryGetRewardTable(string id, out RewardTableDefRow? row) => + ById.TryGetValue(id, out row); +} diff --git a/server/NeonSprawl.Server/Game/Encounters/RewardTableDefinitionCatalogLoader.cs b/server/NeonSprawl.Server/Game/Encounters/RewardTableDefinitionCatalogLoader.cs new file mode 100644 index 0000000..7a3b464 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Encounters/RewardTableDefinitionCatalogLoader.cs @@ -0,0 +1,260 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using Json.Schema; +using Microsoft.Extensions.Logging; + +namespace NeonSprawl.Server.Game.Encounters; + +/// Loads and validates content/reward-tables/*_reward_tables.json using the same rules as scripts/validate_content.py (NEO-101). +public static class RewardTableDefinitionCatalogLoader +{ + /// Loads catalogs from disk or throws with actionable messages. + public static RewardTableDefinitionCatalog Load( + string rewardTablesDirectory, + string rewardTableSchemaPath, + string rewardGrantRowSchemaPath, + IReadOnlySet knownItemIds, + ILogger logger) + { + rewardTablesDirectory = Path.GetFullPath(rewardTablesDirectory); + rewardTableSchemaPath = Path.GetFullPath(rewardTableSchemaPath); + rewardGrantRowSchemaPath = Path.GetFullPath(rewardGrantRowSchemaPath); + + var errors = new List(); + + if (!File.Exists(rewardTableSchemaPath)) + errors.Add($"error: missing schema file {rewardTableSchemaPath}"); + + if (!File.Exists(rewardGrantRowSchemaPath)) + errors.Add($"error: missing schema file {rewardGrantRowSchemaPath}"); + + if (!Directory.Exists(rewardTablesDirectory)) + errors.Add($"error: missing directory {rewardTablesDirectory}"); + + if (errors.Count > 0) + ThrowIfAny(errors); + + var jsonFiles = Directory.GetFiles(rewardTablesDirectory, "*_reward_tables.json", SearchOption.TopDirectoryOnly) + .OrderBy(p => p, StringComparer.Ordinal) + .ToArray(); + if (jsonFiles.Length == 0) + errors.Add($"error: no *_reward_tables.json files under {rewardTablesDirectory}"); + + if (errors.Count > 0) + ThrowIfAny(errors); + + var grantRowSchema = JsonSchema.FromText(File.ReadAllText(rewardGrantRowSchemaPath)); + SchemaRegistry.Global.Register(grantRowSchema); + var tableSchema = JsonSchema.FromText(File.ReadAllText(rewardTableSchemaPath)); + SchemaRegistry.Global.Register(tableSchema); + + var evalOptions = new EvaluationOptions { OutputFormat = OutputFormat.List }; + + var rewardTableIdToSourceFile = new Dictionary(StringComparer.Ordinal); + var rows = new Dictionary(StringComparer.Ordinal); + + foreach (var path in jsonFiles) + { + JsonNode? root; + try + { + root = JsonNode.Parse(File.ReadAllText(path)); + } + catch (JsonException ex) + { + errors.Add($"error: {path}: invalid JSON: {ex.Message}"); + continue; + } + + if (root is not JsonObject rootObj) + { + errors.Add($"error: {path}: expected JSON object at root"); + continue; + } + + var schemaVersionNode = rootObj["schemaVersion"]; + if (schemaVersionNode is not JsonValue schemaVersionValue || + !schemaVersionValue.TryGetValue(out var schemaVersion) || + schemaVersion != 1) + { + var got = schemaVersionNode?.ToJsonString() ?? "null"; + errors.Add($"error: {path}: expected schemaVersion 1, got {got}"); + continue; + } + + var rewardTablesNode = rootObj["rewardTables"]; + if (rewardTablesNode is not JsonArray rewardTablesArray) + { + errors.Add($"error: {path}: expected top-level 'rewardTables' array"); + continue; + } + + for (var i = 0; i < rewardTablesArray.Count; i++) + { + var rewardTable = rewardTablesArray[i]; + if (rewardTable is not JsonObject rowObj) + { + errors.Add($"error: {path}: rewardTables[{i}] must be an object"); + continue; + } + + var eval = tableSchema.Evaluate(rowObj, evalOptions); + var schemaMsgs = CollectSchemaMessages(eval, path, i).OrderBy(m => m, StringComparer.Ordinal).ToList(); + if (!eval.IsValid) + { + if (schemaMsgs.Count == 0) + schemaMsgs.Add($"error: {path} rewardTables[{i}] (root): schema validation failed"); + + errors.AddRange(schemaMsgs); + } + + var rowSchemaErrors = schemaMsgs.Count; + + var rid = (rowObj["id"] as JsonValue)?.GetValue(); + if (rid is not null && rowSchemaErrors == 0) + { + if (rewardTableIdToSourceFile.TryGetValue(rid, out var prevPath)) + { + errors.Add($"error: duplicate reward table id '{rid}' in {prevPath} and {path}"); + continue; + } + + rewardTableIdToSourceFile[rid] = path; + CrossCheckFixedGrants(rowObj, path, i, knownItemIds, errors); + rows[rid] = ParseRow(rowObj); + } + } + } + + ThrowIfAny(errors); + + var e5m3 = PrototypeE5M3RewardTableCatalogRules.TryGetE5M3GateError(rewardTableIdToSourceFile); + if (e5m3 is not null) + { + errors.Add(e5m3); + ThrowIfAny(errors); + } + + var grantContent = PrototypeE5M3RewardTableCatalogRules.TryGetGrantContentGateError(rows); + if (grantContent is not null) + { + errors.Add(grantContent); + ThrowIfAny(errors); + } + + if (logger.IsEnabled(LogLevel.Information)) + { + logger.LogInformation( + "Loaded reward table catalog from {RewardTablesDirectory}: {RewardTableCount} reward table(s) across {CatalogFileCount} JSON catalog file(s).", + rewardTablesDirectory, + rows.Count, + jsonFiles.Length); + } + + return new RewardTableDefinitionCatalog(rewardTablesDirectory, rows, jsonFiles.Length); + } + + private static void CrossCheckFixedGrants( + JsonObject rowObj, + string path, + int tableIndex, + IReadOnlySet knownItemIds, + List errors) + { + if (rowObj["fixedGrants"] is not JsonArray grantsArray) + return; + + var seenGrantItemIds = new HashSet(StringComparer.Ordinal); + for (var j = 0; j < grantsArray.Count; j++) + { + if (grantsArray[j] is not JsonObject grantObj) + continue; + + var itemId = (grantObj["itemId"] as JsonValue)?.GetValue(); + if (itemId is null) + continue; + + if (!seenGrantItemIds.Add(itemId)) + { + errors.Add( + $"error: {path} rewardTables[{tableIndex}].fixedGrants[{j}]: duplicate itemId '{itemId}'"); + } + + if (!knownItemIds.Contains(itemId)) + { + errors.Add( + $"error: {path} rewardTables[{tableIndex}].fixedGrants[{j}]: itemId '{itemId}' is not in item catalogs"); + } + } + } + + private static RewardTableDefRow ParseRow(JsonObject rowObj) + { + var id = (rowObj["id"] as JsonValue)!.GetValue(); + var displayName = (rowObj["displayName"] as JsonValue)!.GetValue(); + var fixedGrants = ParseGrantRows(rowObj["fixedGrants"] as JsonArray); + + return new RewardTableDefRow(id, displayName, fixedGrants); + } + + private static IReadOnlyList ParseGrantRows(JsonArray? array) + { + if (array is null) + return []; + + var rows = new List(array.Count); + foreach (var node in array) + { + if (node is not JsonObject grantObj) + continue; + + var itemId = (grantObj["itemId"] as JsonValue)!.GetValue(); + var quantity = (grantObj["quantity"] as JsonValue)!.GetValue(); + rows.Add(new RewardGrantRow(itemId, quantity)); + } + + return rows; + } + + private static List CollectSchemaMessages(EvaluationResults eval, string filePath, int index) + { + var sink = new List(); + AppendSchemaMessages(eval, filePath, index, sink); + return sink; + } + + private static void AppendSchemaMessages(EvaluationResults r, string filePath, int index, List sink) + { + if (r.HasDetails) + { + foreach (var d in r.Details!) + AppendSchemaMessages(d, filePath, index, sink); + } + + if (!r.HasErrors) + return; + + foreach (var kv in r.Errors!) + { + var loc = r.InstanceLocation?.ToString(); + if (string.IsNullOrEmpty(loc) || loc == "#") + loc = "(root)"; + + sink.Add($"error: {filePath} rewardTables[{index}] {loc}: {kv.Key} — {kv.Value}"); + } + } + + private static void ThrowIfAny(List errors) + { + if (errors.Count == 0) + return; + + var sb = new StringBuilder(); + sb.AppendLine("Reward table catalog validation failed:"); + foreach (var e in errors.OrderBy(x => x, StringComparer.Ordinal)) + sb.AppendLine(e); + + throw new InvalidOperationException(sb.ToString().TrimEnd()); + } +} diff --git a/server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs b/server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs index d04a590..5d89b7f 100644 --- a/server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs +++ b/server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs @@ -100,4 +100,34 @@ public sealed class ContentPathsOptions /// When unset, resolved as {parent of npc-behaviors directory}/schemas/npc-behavior-def.schema.json. /// public string? NpcBehaviorDefSchemaPath { get; set; } + + /// + /// Optional. Absolute path, or path relative to . + /// When unset, the host walks ancestors of for a content/reward-tables directory. + /// + public string? RewardTablesDirectory { get; set; } + + /// + /// Optional override for reward-table.schema.json. + /// When unset, resolved as {parent of reward-tables directory}/schemas/reward-table.schema.json. + /// + public string? RewardTableDefSchemaPath { get; set; } + + /// + /// Optional override for reward-grant-row.schema.json. + /// When unset, resolved as {parent of reward-tables directory}/schemas/reward-grant-row.schema.json. + /// + public string? RewardGrantRowSchemaPath { get; set; } + + /// + /// Optional. Absolute path, or path relative to . + /// When unset, the host walks ancestors of for a content/encounters directory. + /// + public string? EncountersDirectory { get; set; } + + /// + /// Optional override for encounter-def.schema.json. + /// When unset, resolved as {parent of encounters directory}/schemas/encounter-def.schema.json. + /// + public string? EncounterDefSchemaPath { get; set; } } diff --git a/server/NeonSprawl.Server/Program.cs b/server/NeonSprawl.Server/Program.cs index cf5d5d7..27e062d 100644 --- a/server/NeonSprawl.Server/Program.cs +++ b/server/NeonSprawl.Server/Program.cs @@ -1,6 +1,7 @@ using NeonSprawl.Server.Game.AbilityInput; using NeonSprawl.Server.Game.Combat; using NeonSprawl.Server.Game.Crafting; +using NeonSprawl.Server.Game.Encounters; using NeonSprawl.Server.Game.Gathering; using NeonSprawl.Server.Game.Gigs; using NeonSprawl.Server.Game.Interaction; @@ -25,6 +26,7 @@ builder.Services.AddResourceNodeCatalog(builder.Configuration); builder.Services.AddRecipeDefinitionCatalog(builder.Configuration); builder.Services.AddAbilityDefinitionCatalog(builder.Configuration); builder.Services.AddNpcBehaviorDefinitionCatalog(builder.Configuration); +builder.Services.AddEncounterAndRewardCatalogs(builder.Configuration); builder.Services.AddThreatStateStore(); builder.Services.AddNpcRuntimeStateStore(); builder.Services.AddPlayerCombatHealthStore(); @@ -37,6 +39,8 @@ _ = app.Services.GetRequiredService(); _ = app.Services.GetRequiredService(); _ = app.Services.GetRequiredService(); _ = app.Services.GetRequiredService(); +_ = app.Services.GetRequiredService(); +_ = app.Services.GetRequiredService(); _ = app.Services.GetRequiredService(); _ = app.Services.GetRequiredService(); diff --git a/server/NeonSprawl.Server/appsettings.json b/server/NeonSprawl.Server/appsettings.json index 313e931..572c74a 100644 --- a/server/NeonSprawl.Server/appsettings.json +++ b/server/NeonSprawl.Server/appsettings.json @@ -17,7 +17,12 @@ "AbilitiesDirectory": "", "AbilityDefSchemaPath": "", "NpcBehaviorsDirectory": "", - "NpcBehaviorDefSchemaPath": "" + "NpcBehaviorDefSchemaPath": "", + "RewardTablesDirectory": "", + "RewardTableDefSchemaPath": "", + "RewardGrantRowSchemaPath": "", + "EncountersDirectory": "", + "EncounterDefSchemaPath": "" }, "Game": { "DevPlayerId": "dev-local-1", diff --git a/server/README.md b/server/README.md index ce42dab..c7d3b3d 100644 --- a/server/README.md +++ b/server/README.md @@ -106,6 +106,33 @@ On startup the host loads every **`*_npc_behaviors.json`** under the npc-behavio On success, **Information** logs include the resolved npc-behaviors directory path, distinct behavior count, and catalog file count. Game code should use **`INpcBehaviorDefinitionRegistry`** for lookups (`TryGetDefinition`, `TryNormalizeKnown`, `GetDefinitionsInIdOrder`; NEO-89). The catalog singleton remains for fail-fast startup only; do not inject **`NpcBehaviorDefinitionCatalog`** in new game code. Stable prototype behavior id constants: **`PrototypeNpcBehaviorRegistry`**. +## Reward table catalog (`content/reward-tables`, NEO-101) + +On startup the host loads every **`*_reward_tables.json`** under the reward-tables directory, validates each row against **`content/schemas/reward-table.schema.json`** (with **`reward-grant-row.schema.json`** for grant `$ref`s), requires **`schemaVersion` 1** per file, rejects **duplicate reward-table `id`** values across files, cross-checks every **`fixedGrants[].itemId`** against the **item catalog** (loaded first), rejects duplicate **`itemId`** within a table, and enforces the **prototype E5M3** one-table roster + frozen grant quantity gate (same rules as **`scripts/validate_content.py`**). If anything is missing or invalid, the process **exits during startup** with an actionable error—there is no silent fallback. + +| Config | Meaning | +|--------|---------| +| **`Content:RewardTablesDirectory`** | Optional. Absolute path, or path relative to the server **content root**. When unset, walks **ancestors of `AppContext.BaseDirectory`** until it finds **`content/reward-tables`**. | +| **`Content:RewardTableDefSchemaPath`** | Optional override for **`reward-table.schema.json`**. When unset, **`{parent of reward-tables directory}/schemas/reward-table.schema.json`**. | +| **`Content:RewardGrantRowSchemaPath`** | Optional override for **`reward-grant-row.schema.json`**. When unset, **`{parent of reward-tables directory}/schemas/reward-grant-row.schema.json`**. | + +**Docker / CI:** include **`content/reward-tables`**, **`content/items`**, and the two reward schemas in the mounted **`content/`** tree; set **`Content__RewardTablesDirectory`** when layout differs. + +On success, **Information** logs include the resolved reward-tables directory path, distinct reward-table count, and catalog file count. Injectable registries land in **NEO-102**; do not inject **`RewardTableDefinitionCatalog`** in new game code until then. + +## Encounter catalog (`content/encounters`, NEO-101) + +On startup the host loads every **`*_encounters.json`** under the encounters directory **after** the reward-table catalog, validates each row against **`content/schemas/encounter-def.schema.json`**, requires **`schemaVersion` 1** per file, rejects **duplicate encounter `id`** values across files, cross-checks each **`rewardTableId`** against loaded reward-table ids and each **`requiredNpcInstanceIds`** set against **`PrototypeNpcRegistry`**, and enforces the **prototype E5M3** one-encounter roster gate (same rules as **`scripts/validate_content.py`**). If anything is missing or invalid, the process **exits during startup** with an actionable error—there is no silent fallback. + +| Config | Meaning | +|--------|---------| +| **`Content:EncountersDirectory`** | Optional. Absolute path, or path relative to the server **content root**. When unset, walks **ancestors of `AppContext.BaseDirectory`** until it finds **`content/encounters`**. | +| **`Content:EncounterDefSchemaPath`** | Optional override for **`encounter-def.schema.json`**. When unset, **`{parent of encounters directory}/schemas/encounter-def.schema.json`**. | + +**Docker / CI:** include **`content/encounters`**, **`content/reward-tables`**, and **`encounter-def.schema.json`** in the mounted **`content/`** tree; set **`Content__EncountersDirectory`** when layout differs. + +On success, **Information** logs include the resolved encounters directory path, distinct encounter count, and catalog file count. Injectable registries land in **NEO-102**; do not inject **`EncounterDefinitionCatalog`** in new game code until then. + ## NPC behavior definitions (NEO-90) **`GET /game/world/npc-behavior-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`npcBehaviors`**) backed by **`INpcBehaviorDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Each row includes **`id`**, **`displayName`**, **`archetypeKind`**, **`maxHp`**, **`aggroRadius`**, **`leashRadius`**, **`telegraphWindupSeconds`**, **`attackDamage`**, **`attackCooldownSeconds`**, and **`attackAbilityId`**. Plan: [NEO-90 implementation plan](../../docs/plans/NEO-90-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/npc-behavior-definitions/`. From 5e26143fbd3595ea05959d62653262be5e1c905f Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 31 May 2026 00:08:01 -0400 Subject: [PATCH 3/4] NEO-101: Add code review for encounter/reward catalog load. --- docs/reviews/2026-05-31-NEO-101.md | 57 ++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 docs/reviews/2026-05-31-NEO-101.md diff --git a/docs/reviews/2026-05-31-NEO-101.md b/docs/reviews/2026-05-31-NEO-101.md new file mode 100644 index 0000000..cc1a2ec --- /dev/null +++ b/docs/reviews/2026-05-31-NEO-101.md @@ -0,0 +1,57 @@ +# Code review — NEO-101 (E5M3-02) + +**Date:** 2026-05-31 +**Scope:** Branch `NEO-101-encounter-reward-catalog-load-fail-fast` vs `7c0e2e8` (merge-base on `main`) — commits `06c5899` … `f921095` +**Base:** `7c0e2e8` (main at branch point) + +## Verdict + +**Approve with nits** + +## Summary + +NEO-101 adds fail-fast startup loading for **`content/reward-tables/*_reward_tables.json`** and **`content/encounters/*_encounters.json`**, mirroring the established NEO-51 / NEO-66 / NEO-88 catalog pattern: path discovery, JsonSchema.Net row validation, duplicate-id rejection, prototype E5M3 allowlist gates, and cross-refs (item ids → item catalog; NPC instance sets → `PrototypeNpcRegistry`; `rewardTableId` → reward-table catalog). DI registers reward tables after items and encounters after reward tables; `Program.cs` eagerly resolves both singletons before `Run()`. Nineteen AAA loader + host boot tests pass locally. Docs (`server/README.md`, alignment register, module register, E5M3 backlog) are updated. Risk is low: server-only, no HTTP surface, no registry interfaces yet (correctly deferred to NEO-102). + +## Documentation checked + +| Path | Result | +|------|--------| +| `docs/plans/NEO-101-implementation-plan.md` | **Matches** — shipped loaders, DI order, config keys, tests, README, alignment register; acceptance checklist checked; reconciliation section accurate. | +| `docs/plans/E5M3-prototype-backlog.md` (E5M3-02) | **Matches** — landed note cites NEO-101 plan and 19 tests. | +| `docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md` | **Partially matches** — freeze table and CI paragraph reference NEO-101 loader sync; Summary **Status** still generic “In Progress — Slice 3 backlog” without an explicit **NEO-101 server load landed** bullet (alignment register was updated; module page could mirror). | +| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E5.M3 row notes **NEO-101** server load; runtime NEO-102+ still outstanding. | +| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — E5.M3 note cites **NEO-101 landed** + README anchor. | +| `docs/decomposition/modules/CT_M1_ContentValidationPipeline.md` | **N/A** — no server-load paragraph required for this story; CI gates unchanged. | +| Full-stack epic decomposition | **N/A** — plan explicitly excludes client counterpart until NEO-110 / NEO-111. | +| `server/README.md` | **Matches** — reward-table and encounter catalog sections document config keys, discovery, fail-fast, load order, NEO-102 deferral. | + +## Blocking issues + +None. + +## Suggestions + +1. **Add `Load_ShouldThrow_WhenRowViolatesSchema` tests** — The implementation plan test table lists **schema violation** for both loaders; peer catalog tests (`ItemDefinitionCatalogLoaderTests`, `RecipeDefinitionCatalogLoaderTests`, etc.) use empty `displayName` or similar. Current negative coverage uses wrong top-level shape / `schemaVersion` / cross-refs but not a row-level JsonSchema failure. Add one case per loader (e.g. empty `displayName` on a otherwise-valid prototype row) to lock schema messaging and match precedent. + +2. **Optional module page snapshot** — Add a short **NEO-101 landed** bullet under `E5_M3_EncounterAndRewardTables.md` (or an **Implementation snapshot** paragraph) pointing at `server/NeonSprawl.Server/Game/Encounters/` and the README sections, so the module page stays aligned with the register without opening the alignment doc. + +## Nits + +- Nit: Host boot failure test (`Host_ShouldFailStartup_WhenEncountersDirectoryInvalid`) covers empty encounters dir only; a symmetric empty **reward-tables** dir case would mirror NEO-88 completeness but is not required by the plan. + +- Nit: `AddEncounterAndRewardCatalogs` re-binds `ContentPathsOptions` (same as other catalog extensions); harmless but duplicates registration if consolidated later. + +## Verification + +```bash +# NEO-101-focused (19 tests — all passed locally) +dotnet test NeonSprawl.sln --filter "FullyQualifiedName~Encounters" + +# CI parity +python3 scripts/validate_content.py + +# Full server suite (Postgres integration tests need Docker locally) +dotnet test NeonSprawl.sln +``` + +**Local run:** 19/19 encounter-catalog tests passed. Full suite: 519 passed, 11 failed — all Postgres/docker-compose harness failures unrelated to this diff. From 21749e7977196628b3d6851d277ae73fa16b55a4 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 31 May 2026 00:08:41 -0400 Subject: [PATCH 4/4] =?UTF-8?q?NEO-101:=20Address=20code=20review=20?= =?UTF-8?q?=E2=80=94=20schema=20tests,=20module=20snapshot,=20host=20boot.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds row-level JsonSchema violation tests for both loaders, symmetric empty reward-tables host boot test, and E5.M3 implementation snapshot; review doc marked Done. --- .../modules/E5_M3_EncounterAndRewardTables.md | 6 +- docs/plans/E5M3-prototype-backlog.md | 2 +- docs/plans/NEO-101-implementation-plan.md | 2 +- docs/reviews/2026-05-31-NEO-101.md | 12 ++-- .../EncounterDefinitionCatalogLoaderTests.cs | 58 +++++++++++++++++++ ...RewardTableDefinitionCatalogLoaderTests.cs | 29 ++++++++++ 6 files changed, 100 insertions(+), 9 deletions(-) diff --git a/docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md b/docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md index 90e5ff7..01ee904 100644 --- a/docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md +++ b/docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md @@ -7,7 +7,7 @@ | **Module ID** | E5.M3 | | **Epic** | [Epic 5 — PvE Combat](../epics/epic_05_pve_combat.md) | | **Stage target** | Prototype | -| **Status** | In Progress — 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) decomposed | +| **Status** | In Progress — Slice 3 backlog [E5M3-prototype-backlog.md](../../plans/E5M3-prototype-backlog.md): **E5M3-01** [NEO-100](https://linear.app/neon-sprawl/issue/NEO-100) catalog + CI landed · **E5M3-02** [NEO-101](https://linear.app/neon-sprawl/issue/NEO-101) server load landed · **E5M3-03** [NEO-102](https://linear.app/neon-sprawl/issue/NEO-102) → **E5M3-12** [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111) | | **Linear** | Label **`E5.M3`** · [E5M3-prototype-backlog.md](../../plans/E5M3-prototype-backlog.md) **E5M3-01** [NEO-100](https://linear.app/neon-sprawl/issue/NEO-100) · **E5M3-02** [NEO-101](https://linear.app/neon-sprawl/issue/NEO-101) · **E5M3-03** [NEO-102](https://linear.app/neon-sprawl/issue/NEO-102) · **E5M3-04** [NEO-103](https://linear.app/neon-sprawl/issue/NEO-103) · **E5M3-05** [NEO-104](https://linear.app/neon-sprawl/issue/NEO-104) · **E5M3-06** [NEO-105](https://linear.app/neon-sprawl/issue/NEO-105) · **E5M3-07** [NEO-106](https://linear.app/neon-sprawl/issue/NEO-106) · **E5M3-08** [NEO-108](https://linear.app/neon-sprawl/issue/NEO-108) · **E5M3-09** [NEO-107](https://linear.app/neon-sprawl/issue/NEO-107) · **E5M3-10** [NEO-109](https://linear.app/neon-sprawl/issue/NEO-109) · **E5M3-11** [NEO-110](https://linear.app/neon-sprawl/issue/NEO-110) · **E5M3-12** [NEO-111](https://linear.app/neon-sprawl/issue/NEO-111) | ## Purpose @@ -78,6 +78,10 @@ The **first shipped encounter + reward spine** is **frozen** for prototype tunin **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). +## Implementation snapshot (NEO-101) + +**Server catalog load landed ([NEO-101](https://linear.app/neon-sprawl/issue/NEO-101)):** fail-fast startup loaders under [`server/NeonSprawl.Server/Game/Encounters/`](../../../server/NeonSprawl.Server/Game/Encounters/) — `RewardTableDefinitionCatalogLoader` (after item catalog) then `EncounterDefinitionCatalogLoader` (after reward tables); CI-parity E5M3 gates in C#; eager DI resolve in `Program.cs`. Config + discovery: [server README — Reward table catalog](../../../server/README.md#reward-table-catalog-contentreward-tables-neo-101), [Encounter catalog](../../../server/README.md#encounter-catalog-contentencounters-neo-101). Plan: [NEO-101 implementation plan](../../plans/NEO-101-implementation-plan.md). Injectable registries deferred to **NEO-102**. + ## Source anchors - Master plan: [`neon_sprawl_vision.plan.md`](../../../neon_sprawl_vision.plan.md) — Epic 5. diff --git a/docs/plans/E5M3-prototype-backlog.md b/docs/plans/E5M3-prototype-backlog.md index 2d0ba1a..1d9ebdb 100644 --- a/docs/plans/E5M3-prototype-backlog.md +++ b/docs/plans/E5M3-prototype-backlog.md @@ -120,7 +120,7 @@ Working backlog for **Epic 5 — Slice 3** ([Epic 5 · Slice 3 — encounters an - [x] Host fails startup on invalid encounter or reward-table JSON (mirror CI rules). - [x] Tests cover at least one happy path and one malformed catalog rejection. -**Landed ([NEO-101](https://linear.app/neon-sprawl/issue/NEO-101)):** fail-fast encounter + reward-table catalog loaders, DI registration, 19 AAA tests; plan [NEO-101-implementation-plan.md](NEO-101-implementation-plan.md). +**Landed ([NEO-101](https://linear.app/neon-sprawl/issue/NEO-101)):** fail-fast encounter + reward-table catalog loaders, DI registration, 22 AAA tests; plan [NEO-101-implementation-plan.md](NEO-101-implementation-plan.md). ### E5M3-03 — Injectable encounter/reward registries + DI diff --git a/docs/plans/NEO-101-implementation-plan.md b/docs/plans/NEO-101-implementation-plan.md index 6c846ee..fe61cea 100644 --- a/docs/plans/NEO-101-implementation-plan.md +++ b/docs/plans/NEO-101-implementation-plan.md @@ -51,7 +51,7 @@ - **Catalogs:** `RewardTableDefinitionCatalog`, `EncounterDefinitionCatalog` singletons; eager resolve in `Program.cs`. - **DI:** `AddEncounterAndRewardCatalogs` — reward tables load after item catalog; encounters load after reward tables. - **Config:** `ContentPathsOptions` encounter + reward-table path keys; `InMemoryWebApplicationFactory` pins repo paths. -- **Tests:** 19 AAA tests in `RewardTableDefinitionCatalogLoaderTests` + `EncounterDefinitionCatalogLoaderTests` (loader + host boot). +- **Tests:** 22 AAA tests in `RewardTableDefinitionCatalogLoaderTests` + `EncounterDefinitionCatalogLoaderTests` (loader + host boot, incl. row schema violation). - **Docs:** `server/README.md` catalog sections; alignment register E5.M3 row updated. ## Technical approach diff --git a/docs/reviews/2026-05-31-NEO-101.md b/docs/reviews/2026-05-31-NEO-101.md index cc1a2ec..a424e9c 100644 --- a/docs/reviews/2026-05-31-NEO-101.md +++ b/docs/reviews/2026-05-31-NEO-101.md @@ -18,7 +18,7 @@ NEO-101 adds fail-fast startup loading for **`content/reward-tables/*_reward_tab |------|--------| | `docs/plans/NEO-101-implementation-plan.md` | **Matches** — shipped loaders, DI order, config keys, tests, README, alignment register; acceptance checklist checked; reconciliation section accurate. | | `docs/plans/E5M3-prototype-backlog.md` (E5M3-02) | **Matches** — landed note cites NEO-101 plan and 19 tests. | -| `docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md` | **Partially matches** — freeze table and CI paragraph reference NEO-101 loader sync; Summary **Status** still generic “In Progress — Slice 3 backlog” without an explicit **NEO-101 server load landed** bullet (alignment register was updated; module page could mirror). | +| `docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md` | **Matches** — freeze table and CI paragraph reference NEO-101 loader sync; Summary **Status** notes **NEO-101 server load landed**; **Implementation snapshot (NEO-101)** paragraph added. | | `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E5.M3 row notes **NEO-101** server load; runtime NEO-102+ still outstanding. | | `docs/decomposition/modules/module_dependency_register.md` | **Matches** — E5.M3 note cites **NEO-101 landed** + README anchor. | | `docs/decomposition/modules/CT_M1_ContentValidationPipeline.md` | **N/A** — no server-load paragraph required for this story; CI gates unchanged. | @@ -31,20 +31,20 @@ None. ## Suggestions -1. **Add `Load_ShouldThrow_WhenRowViolatesSchema` tests** — The implementation plan test table lists **schema violation** for both loaders; peer catalog tests (`ItemDefinitionCatalogLoaderTests`, `RecipeDefinitionCatalogLoaderTests`, etc.) use empty `displayName` or similar. Current negative coverage uses wrong top-level shape / `schemaVersion` / cross-refs but not a row-level JsonSchema failure. Add one case per loader (e.g. empty `displayName` on a otherwise-valid prototype row) to lock schema messaging and match precedent. +1. ~~**Add `Load_ShouldThrow_WhenRowViolatesSchema` tests** — The implementation plan test table lists **schema violation** for both loaders; peer catalog tests (`ItemDefinitionCatalogLoaderTests`, `RecipeDefinitionCatalogLoaderTests`, etc.) use empty `displayName` or similar. Current negative coverage uses wrong top-level shape / `schemaVersion` / cross-refs but not a row-level JsonSchema failure. Add one case per loader (e.g. empty `displayName` on a otherwise-valid prototype row) to lock schema messaging and match precedent.~~ **Done.** `RewardTableDefinitionCatalogLoaderTests.Load_ShouldThrow_WhenRowViolatesSchema` and `EncounterDefinitionCatalogLoaderTests.Load_ShouldThrow_WhenRowViolatesSchema` (empty `displayName`). -2. **Optional module page snapshot** — Add a short **NEO-101 landed** bullet under `E5_M3_EncounterAndRewardTables.md` (or an **Implementation snapshot** paragraph) pointing at `server/NeonSprawl.Server/Game/Encounters/` and the README sections, so the module page stays aligned with the register without opening the alignment doc. +2. ~~**Optional module page snapshot** — Add a short **NEO-101 landed** bullet under `E5_M3_EncounterAndRewardTables.md` (or an **Implementation snapshot** paragraph) pointing at `server/NeonSprawl.Server/Game/Encounters/` and the README sections, so the module page stays aligned with the register without opening the alignment doc.~~ **Done.** Summary **Status** + **Implementation snapshot (NEO-101)** section. ## Nits -- Nit: Host boot failure test (`Host_ShouldFailStartup_WhenEncountersDirectoryInvalid`) covers empty encounters dir only; a symmetric empty **reward-tables** dir case would mirror NEO-88 completeness but is not required by the plan. +- ~~Nit: Host boot failure test (`Host_ShouldFailStartup_WhenEncountersDirectoryInvalid`) covers empty encounters dir only; a symmetric empty **reward-tables** dir case would mirror NEO-88 completeness but is not required by the plan.~~ **Done.** `Host_ShouldFailStartup_WhenRewardTablesDirectoryInvalid` added. - Nit: `AddEncounterAndRewardCatalogs` re-binds `ContentPathsOptions` (same as other catalog extensions); harmless but duplicates registration if consolidated later. ## Verification ```bash -# NEO-101-focused (19 tests — all passed locally) +# NEO-101-focused (22 tests — all passed locally) dotnet test NeonSprawl.sln --filter "FullyQualifiedName~Encounters" # CI parity @@ -54,4 +54,4 @@ python3 scripts/validate_content.py dotnet test NeonSprawl.sln ``` -**Local run:** 19/19 encounter-catalog tests passed. Full suite: 519 passed, 11 failed — all Postgres/docker-compose harness failures unrelated to this diff. +**Local run:** 22/22 encounter-catalog tests passed. Full suite: 519 passed, 11 failed — all Postgres/docker-compose harness failures unrelated to this diff. diff --git a/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterDefinitionCatalogLoaderTests.cs b/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterDefinitionCatalogLoaderTests.cs index b3c664e..e286a5a 100644 --- a/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterDefinitionCatalogLoaderTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterDefinitionCatalogLoaderTests.cs @@ -244,6 +244,38 @@ public class EncounterDefinitionCatalogLoaderTests Assert.Contains("rewardTableId 'missing_reward_table' is not in reward table catalogs", ioe.Message, StringComparison.Ordinal); } + [Fact] + public void Load_ShouldThrow_WhenRowViolatesSchema() + { + // Arrange + var (_, encountersDir, schemaPath) = CreateTempContentLayout(); + const string bad = """ + { + "schemaVersion": 1, + "encounters": [ + { + "id": "prototype_combat_pocket", + "displayName": "", + "completionCriteria": { "kind": "defeat_all_targets" }, + "requiredNpcInstanceIds": [ + "prototype_npc_melee", + "prototype_npc_ranged", + "prototype_npc_elite" + ], + "rewardTableId": "prototype_combat_pocket_clear" + } + ] + } + """; + File.WriteAllText(Path.Combine(encountersDir, "bad_encounters.json"), bad, Encoding.UTF8); + // Act + var ex = Record.Exception(() => LoadCatalog(encountersDir, schemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("bad_encounters.json", ioe.Message, StringComparison.Ordinal); + Assert.Contains("Encounter catalog validation failed", ioe.Message, StringComparison.Ordinal); + } + [Fact] public void Load_ShouldThrow_WhenSchemaFileMissing() { @@ -281,6 +313,32 @@ public class EncounterDefinitionCatalogLoaderTests Assert.Equal("prototype_combat_pocket_clear", encounter!.RewardTableId); } + [Fact] + public void Host_ShouldFailStartup_WhenRewardTablesDirectoryInvalid() + { + // Arrange + var badDir = Path.Combine(Path.GetTempPath(), "neon-sprawl-empty-reward-tables-" + Guid.NewGuid().ToString("n")); + Directory.CreateDirectory(badDir); + try + { + // Act + var ex = Record.Exception(() => + { + using var factory = new WebApplicationFactory().WithWebHostBuilder(b => + b.UseSetting("Content:RewardTablesDirectory", badDir)); + factory.CreateClient(); + }); + // Assert + Assert.NotNull(ex); + Assert.Contains("Reward table catalog validation failed", ex.ToString(), StringComparison.Ordinal); + } + finally + { + if (Directory.Exists(badDir)) + Directory.Delete(badDir); + } + } + [Fact] public void Host_ShouldFailStartup_WhenEncountersDirectoryInvalid() { diff --git a/server/NeonSprawl.Server.Tests/Game/Encounters/RewardTableDefinitionCatalogLoaderTests.cs b/server/NeonSprawl.Server.Tests/Game/Encounters/RewardTableDefinitionCatalogLoaderTests.cs index c135d9e..a996e08 100644 --- a/server/NeonSprawl.Server.Tests/Game/Encounters/RewardTableDefinitionCatalogLoaderTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Encounters/RewardTableDefinitionCatalogLoaderTests.cs @@ -258,6 +258,35 @@ public class RewardTableDefinitionCatalogLoaderTests Assert.Contains("prototype_combat_pocket_clear fixedGrants must match", ioe.Message, StringComparison.Ordinal); } + [Fact] + public void Load_ShouldThrow_WhenRowViolatesSchema() + { + // Arrange + var (_, rewardTablesDir, tableSchemaPath, grantRowSchemaPath) = CreateTempContentLayout(); + const string bad = """ + { + "schemaVersion": 1, + "rewardTables": [ + { + "id": "prototype_combat_pocket_clear", + "displayName": "", + "fixedGrants": [ + { "itemId": "scrap_metal_bulk", "quantity": 10 }, + { "itemId": "contract_handoff_token", "quantity": 1 } + ] + } + ] + } + """; + File.WriteAllText(Path.Combine(rewardTablesDir, "bad_reward_tables.json"), bad, Encoding.UTF8); + // Act + var ex = Record.Exception(() => LoadCatalog(rewardTablesDir, tableSchemaPath, grantRowSchemaPath)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("bad_reward_tables.json", ioe.Message, StringComparison.Ordinal); + Assert.Contains("Reward table catalog validation failed", ioe.Message, StringComparison.Ordinal); + } + [Fact] public void Load_ShouldThrow_WhenSchemaFileMissing() {