From 93eca821d77df050d840e1da11f87c5a97b305b3 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 31 May 2026 00:33:45 -0400 Subject: [PATCH 1/5] NEO-102: add implementation plan for encounter/reward registries + DI --- docs/plans/NEO-102-implementation-plan.md | 114 ++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 docs/plans/NEO-102-implementation-plan.md diff --git a/docs/plans/NEO-102-implementation-plan.md b/docs/plans/NEO-102-implementation-plan.md new file mode 100644 index 0000000..0fa4804 --- /dev/null +++ b/docs/plans/NEO-102-implementation-plan.md @@ -0,0 +1,114 @@ +# NEO-102 — Implementation plan + +## Story reference + +| Field | Value | +|--------|--------| +| **Key** | NEO-102 | +| **Title** | E5M3-03: Injectable encounter/reward registries + DI | +| **Linear** | https://linear.app/neon-sprawl/issue/NEO-102/e5m3-03-injectable-encounterreward-registries-di | +| **Module** | [E5.M3 — EncounterAndRewardTables](../decomposition/modules/E5_M3_EncounterAndRewardTables.md) · Epic 5 Slice 3 · backlog **E5M3-03** | +| **Branch** | `NEO-102-injectable-encounter-reward-registries-di` | +| **Precursor** | [NEO-101](https://linear.app/neon-sprawl/issue/NEO-101) — fail-fast encounter + reward-table catalog load (**landed on `main`**) | +| **Pattern** | [NEO-52](NEO-52-implementation-plan.md) / [NEO-79](NEO-79-implementation-plan.md) — thin registry adapter over startup catalog + DI; strict split after NEO-101 loader | +| **Blocks** | [NEO-103](https://linear.app/neon-sprawl/issue/NEO-103) — GET `/game/world/encounter-definitions`; E5M3-05+ progress/completion engines | +| **Client counterpart** | None — server-only registry surface; 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 + +No clarifications needed — E5M3-03 backlog, [NEO-101 plan](NEO-101-implementation-plan.md) strict split, and NEO-52/79 registry precedent settle API surface, test scope, and DI wiring. User confirmed **Proceed — no clarifications needed** on kickoff. + +| Topic | Question | Agent recommendation | Answer | +|--------|----------|----------------------|--------| +| **Registry API surface** | Include `TryNormalizeKnown` + `GetDefinitionsInIdOrder` on both registries? | **Yes** — E5M3-03 backlog + `IAbilityDefinitionRegistry` (NEO-79) pattern. | **Adopted** | +| **Lookup naming** | `TryGetDefinition` vs domain-specific names? | **`TryGetDefinition`** — item/skill/ability/recipe precedent; catalogs keep `TryGetEncounter` / `TryGetRewardTable`. | **Adopted** | +| **Test scope** | Registry unit tests only, or also host DI resolve? | **Both** — mirror NEO-52/79; NEO-101 already covers catalog boot. | **Adopted** | +| **Program.cs eager resolve** | Eager-resolve registries at boot? | **Omit** — catalog singletons already eager-resolved (NEO-101); registry is thin adapter. | **Adopted** | +| **Call site migration** | Migrate existing consumers off catalog types? | **None this story** — no game code injects encounter/reward catalogs yet; HTTP is NEO-103. | **Adopted** | + +## Goal, scope, and out-of-scope + +**Goal:** Provide **`IEncounterDefinitionRegistry`** and **`IRewardTableDefinitionRegistry`** backed by the startup-loaded **`EncounterDefinitionCatalog`** and **`RewardTableDefinitionCatalog`**: resolve by stable id, enumerate definitions in id order, and expose **`TryNormalizeKnown`** for fail-closed id validation. Register both in DI so **NEO-103+** (HTTP, progress store, completion engine) depend on interfaces instead of catalog types. + +**In scope (from Linear + [E5M3-03](E5M3-prototype-backlog.md#e5m3-03--injectable-encounterreward-registries--di)):** + +- `EncounterDefinitionRegistry` + `RewardTableDefinitionRegistry` thin adapters over NEO-101 catalogs. +- DI registration in `AddEncounterAndRewardCatalogs`. +- Unit tests (AAA): known prototype id lookup + metadata; unknown id / null miss without throwing; `TryNormalizeKnown` trim/case/whitespace behavior; enumeration order; host resolves both registries from DI. + +**Out of scope (from Linear + backlog):** + +- HTTP routes ([NEO-103](https://linear.app/neon-sprawl/issue/NEO-103)). +- Progress store, completion grants, combat hooks (E5M3-05+). +- Changing loader, E5M3 gate, or catalog load semantics (NEO-101). +- Godot / client changes. + +## Acceptance criteria checklist + +- [ ] Registries resolve frozen encounter + reward table ids (`prototype_combat_pocket`, `prototype_combat_pocket_clear`). +- [ ] Unknown id normalization fails closed (`TryNormalizeKnown` returns false; no throw). + +## Technical approach + +1. **`IEncounterDefinitionRegistry`** — mirror [`IAbilityDefinitionRegistry`](../../server/NeonSprawl.Server/Game/Combat/IAbilityDefinitionRegistry.cs): + - `TryGetDefinition(string? encounterId, [NotNullWhen(true)] out EncounterDefRow? definition)` — null and unknown ids return false without throwing. + - `TryNormalizeKnown(string? rawEncounterId, [NotNullWhen(true)] out string normalized)` — trim + lowercase + catalog lookup; null/empty/whitespace return false. + - `GetDefinitionsInIdOrder()` — all rows ordered by `EncounterDefRow.Id` (ordinal). + - XML remarks: encounter progress/completion callers (NEO-104+) and HTTP read model (NEO-103) should use this interface, not `EncounterDefinitionCatalog`. + +2. **`IRewardTableDefinitionRegistry`** — same surface over `RewardTableDefRow` / `RewardTableDefinitionCatalog`. + +3. **`EncounterDefinitionRegistry`** — primary-constructor adapter delegating to `EncounterDefinitionCatalog.TryGetEncounter`; cache `GetDefinitionsInIdOrder` at construction (NEO-79 pattern). + +4. **`RewardTableDefinitionRegistry`** — same over `RewardTableDefinitionCatalog.TryGetRewardTable`. + +5. **DI** — extend [`AddEncounterAndRewardCatalogs`](../../server/NeonSprawl.Server/Game/Encounters/EncounterCatalogServiceCollectionExtensions.cs): + - `services.AddSingleton(sp => new EncounterDefinitionRegistry(sp.GetRequiredService()));` + - `services.AddSingleton(sp => new RewardTableDefinitionRegistry(sp.GetRequiredService()));` + - No change to `Program.cs` eager-resolve (catalogs only). + +6. **Comments / docs** — tighten catalog summaries (already reference NEO-102); update [server README](../../server/README.md) encounter + reward-table sections to document registries as preferred lookup surface (replace “land in NEO-102” placeholders). + +7. **Tests** — new `EncounterDefinitionRegistryTests.cs` and `RewardTableDefinitionRegistryTests.cs` mirroring [`AbilityDefinitionRegistryTests`](../../server/NeonSprawl.Server.Tests/Game/Combat/AbilityDefinitionRegistryTests.cs): + - In-memory catalog helper building registry from row dictionaries. + - Prototype fixture via repo catalogs or inline rows. + - `Host_ShouldResolveRegistriesFromDi_WhenStartupSucceeds` in one file (resolve both interfaces via `InMemoryWebApplicationFactory`, assert frozen ids + grant metadata). + +8. **Alignment doc** — update E5.M3 row in `documentation_and_implementation_alignment.md` when complete. + +## Files to add + +| Path | Purpose | +|------|---------| +| `server/NeonSprawl.Server/Game/Encounters/IEncounterDefinitionRegistry.cs` | Public contract: lookup, normalize, enumerate. | +| `server/NeonSprawl.Server/Game/Encounters/EncounterDefinitionRegistry.cs` | Singleton adapter over `EncounterDefinitionCatalog`. | +| `server/NeonSprawl.Server/Game/Encounters/IRewardTableDefinitionRegistry.cs` | Public contract: lookup, normalize, enumerate. | +| `server/NeonSprawl.Server/Game/Encounters/RewardTableDefinitionRegistry.cs` | Singleton adapter over `RewardTableDefinitionCatalog`. | +| `server/NeonSprawl.Server.Tests/Game/Encounters/EncounterDefinitionRegistryTests.cs` | AAA unit + host DI tests for encounter registry. | +| `server/NeonSprawl.Server.Tests/Game/Encounters/RewardTableDefinitionRegistryTests.cs` | AAA unit + host DI tests for reward-table registry. | +| `docs/plans/NEO-102-implementation-plan.md` | This plan. | + +## Files to modify + +| Path | Rationale | +|------|-----------| +| `server/NeonSprawl.Server/Game/Encounters/EncounterCatalogServiceCollectionExtensions.cs` | Register `IEncounterDefinitionRegistry` + `IRewardTableDefinitionRegistry` after catalog singletons. | +| `server/NeonSprawl.Server/Game/Encounters/EncounterDefinitionCatalog.cs` | Confirm summary points at `IEncounterDefinitionRegistry` (replace NEO-102 placeholder wording if needed). | +| `server/NeonSprawl.Server/Game/Encounters/RewardTableDefinitionCatalog.cs` | Confirm summary points at `IRewardTableDefinitionRegistry`. | +| `server/README.md` | Encounter + reward-table catalog sections: document registries as preferred lookup surface. | +| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E5.M3 row — note NEO-102 registries when complete. | + +## Tests + +| Test file | What it covers | +|-----------|----------------| +| `server/NeonSprawl.Server.Tests/Game/Encounters/EncounterDefinitionRegistryTests.cs` | **Unit:** `TryGetDefinition` returns true + expected metadata for `prototype_combat_pocket` (displayName, completionCriteriaKind, requiredNpcInstanceIds, rewardTableId); false for null/unknown. **Unit:** `TryNormalizeKnown` trim/case success; false for null/whitespace/unknown. **Unit:** `GetDefinitionsInIdOrder` ordinal order. **Host:** `InMemoryWebApplicationFactory` resolves `IEncounterDefinitionRegistry` with frozen encounter row. | +| `server/NeonSprawl.Server.Tests/Game/Encounters/RewardTableDefinitionRegistryTests.cs` | **Unit:** `TryGetDefinition` returns true + fixed grants for `prototype_combat_pocket_clear` (`scrap_metal_bulk` ×10, `contract_handoff_token` ×1); false for null/unknown. **Unit:** `TryNormalizeKnown` trim/case success; false for null/whitespace/unknown. **Unit:** `GetDefinitionsInIdOrder` ordinal order. **Host:** `InMemoryWebApplicationFactory` resolves `IRewardTableDefinitionRegistry` with frozen grant quantities. | + +## Open questions / risks + +| Question / risk | Agent recommendation | Status | +|-----------------|----------------------|--------| +| **Constants drift vs catalog load** | No runtime re-validation — registry delegates to NEO-101 fail-fast catalogs only. | **adopted** | +| **Duplicate host boot tests** | Keep NEO-101 catalog boot tests; add registry-specific DI resolve assertions (do not remove catalog coverage). | **adopted** | +| **NEO-103 HTTP projection** | NEO-103 should inject registries, not catalogs — document in README now. | **adopted** | From 734876cde908c03ff026c9bac2ae54ec8d879143 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 31 May 2026 00:38:38 -0400 Subject: [PATCH 2/5] NEO-102: add encounter and reward-table definition registries + DI Thin IEncounterDefinitionRegistry / IRewardTableDefinitionRegistry adapters over NEO-101 catalogs with TryNormalizeKnown, DI registration, and 18 AAA tests. --- .../modules/E5_M3_EncounterAndRewardTables.md | 6 +- ...umentation_and_implementation_alignment.md | 2 +- docs/plans/E5M3-prototype-backlog.md | 6 +- docs/plans/NEO-102-implementation-plan.md | 12 +- .../EncounterDefinitionRegistryTests.cs | 205 ++++++++++++++++++ .../RewardTableDefinitionRegistryTests.cs | 187 ++++++++++++++++ ...unterCatalogServiceCollectionExtensions.cs | 10 +- .../Encounters/EncounterDefinitionCatalog.cs | 2 +- .../Encounters/EncounterDefinitionRegistry.cs | 55 +++++ .../IEncounterDefinitionRegistry.cs | 23 ++ .../IRewardTableDefinitionRegistry.cs | 23 ++ .../RewardTableDefinitionCatalog.cs | 2 +- .../RewardTableDefinitionRegistry.cs | 55 +++++ server/README.md | 4 +- 14 files changed, 579 insertions(+), 13 deletions(-) create mode 100644 server/NeonSprawl.Server.Tests/Game/Encounters/EncounterDefinitionRegistryTests.cs create mode 100644 server/NeonSprawl.Server.Tests/Game/Encounters/RewardTableDefinitionRegistryTests.cs create mode 100644 server/NeonSprawl.Server/Game/Encounters/EncounterDefinitionRegistry.cs create mode 100644 server/NeonSprawl.Server/Game/Encounters/IEncounterDefinitionRegistry.cs create mode 100644 server/NeonSprawl.Server/Game/Encounters/IRewardTableDefinitionRegistry.cs create mode 100644 server/NeonSprawl.Server/Game/Encounters/RewardTableDefinitionRegistry.cs diff --git a/docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md b/docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md index 01ee904..dd6b3fa 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) 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) | +| **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) registries landed · **E5M3-04** [NEO-103](https://linear.app/neon-sprawl/issue/NEO-103) → **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 @@ -80,7 +80,9 @@ The **first shipped encounter + reward spine** is **frozen** for prototype tunin ## 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**. +**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 landed ([NEO-102](https://linear.app/neon-sprawl/issue/NEO-102)):** `IEncounterDefinitionRegistry` + `IRewardTableDefinitionRegistry` + DI in `AddEncounterAndRewardCatalogs`; game/HTTP callers should inject registries, not catalog types. Plan: [NEO-102 implementation plan](../../plans/NEO-102-implementation-plan.md). ## Source anchors diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md index 866fb59..f5b3c7f 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. **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 | +| 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). **E5M3-03 registries ([NEO-102](https://linear.app/neon-sprawl/issue/NEO-102)):** `IEncounterDefinitionRegistry` + `IRewardTableDefinitionRegistry` + DI. 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-103+) not started.** Upstream **E5.M2 Ready**, **E3.M3** inventory landed. | [NEO-102 plan](../../plans/NEO-102-implementation-plan.md), [E5_M3](E5_M3_EncounterAndRewardTables.md), label **`E5.M3`** on NEO-100–NEO-111 | --- diff --git a/docs/plans/E5M3-prototype-backlog.md b/docs/plans/E5M3-prototype-backlog.md index 1d9ebdb..139bf88 100644 --- a/docs/plans/E5M3-prototype-backlog.md +++ b/docs/plans/E5M3-prototype-backlog.md @@ -138,8 +138,10 @@ Working backlog for **Epic 5 — Slice 3** ([Epic 5 · Slice 3 — encounters an **Acceptance criteria** -- [ ] Registries resolve frozen encounter + reward table ids. -- [ ] Unknown id normalization fails closed. +- [x] Registries resolve frozen encounter + reward table ids. +- [x] Unknown id normalization fails closed. + +**Landed ([NEO-102](https://linear.app/neon-sprawl/issue/NEO-102)):** injectable encounter + reward-table registries + DI; 18 AAA tests; plan [NEO-102-implementation-plan.md](NEO-102-implementation-plan.md). --- diff --git a/docs/plans/NEO-102-implementation-plan.md b/docs/plans/NEO-102-implementation-plan.md index 0fa4804..1140c87 100644 --- a/docs/plans/NEO-102-implementation-plan.md +++ b/docs/plans/NEO-102-implementation-plan.md @@ -45,8 +45,16 @@ No clarifications needed — E5M3-03 backlog, [NEO-101 plan](NEO-101-implementat ## Acceptance criteria checklist -- [ ] Registries resolve frozen encounter + reward table ids (`prototype_combat_pocket`, `prototype_combat_pocket_clear`). -- [ ] Unknown id normalization fails closed (`TryNormalizeKnown` returns false; no throw). +- [x] Registries resolve frozen encounter + reward table ids (`prototype_combat_pocket`, `prototype_combat_pocket_clear`). +- [x] Unknown id normalization fails closed (`TryNormalizeKnown` returns false; no throw). + +## Implementation reconciliation (shipped) + +- **Interfaces:** `IEncounterDefinitionRegistry`, `IRewardTableDefinitionRegistry` with `TryGetDefinition`, `TryNormalizeKnown`, `GetDefinitionsInIdOrder`. +- **Adapters:** `EncounterDefinitionRegistry`, `RewardTableDefinitionRegistry` over NEO-101 catalogs (cached id-order lists). +- **DI:** `AddEncounterAndRewardCatalogs` registers both registries after catalog singletons; no `Program.cs` change. +- **Tests:** 18 AAA tests in `EncounterDefinitionRegistryTests` + `RewardTableDefinitionRegistryTests` (unit + host DI). +- **Docs:** `server/README.md` registry sections; alignment register E5.M3 row updated. ## Technical approach diff --git a/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterDefinitionRegistryTests.cs b/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterDefinitionRegistryTests.cs new file mode 100644 index 0000000..6a10470 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Encounters/EncounterDefinitionRegistryTests.cs @@ -0,0 +1,205 @@ +using Microsoft.Extensions.DependencyInjection; +using NeonSprawl.Server.Game.Encounters; +using NeonSprawl.Server.Tests; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Encounters; + +public class EncounterDefinitionRegistryTests +{ + private const string PrototypeCombatPocket = "prototype_combat_pocket"; + + private static EncounterDefinitionRegistry CreateRegistryFromRows(IReadOnlyDictionary byId) + { + var catalog = new EncounterDefinitionCatalog("/tmp/encounters", byId, catalogJsonFileCount: 1); + return new EncounterDefinitionRegistry(catalog); + } + + private static EncounterDefRow CreatePrototypeCombatPocketRow() => + new( + PrototypeCombatPocket, + "Prototype Combat Pocket", + "defeat_all_targets", + ["prototype_npc_melee", "prototype_npc_ranged", "prototype_npc_elite"], + "prototype_combat_pocket_clear"); + + [Fact] + public void TryGetDefinition_ShouldReturnTrueAndExpectedMetadata_WhenIdExists() + { + // Arrange + var rows = new Dictionary(StringComparer.Ordinal) + { + [PrototypeCombatPocket] = CreatePrototypeCombatPocketRow(), + }; + var registry = CreateRegistryFromRows(rows); + // Act + var found = registry.TryGetDefinition(PrototypeCombatPocket, out var def); + // Assert + Assert.True(found); + Assert.NotNull(def); + Assert.Equal("Prototype Combat Pocket", def!.DisplayName); + Assert.Equal("defeat_all_targets", def.CompletionCriteriaKind); + Assert.Equal("prototype_combat_pocket_clear", def.RewardTableId); + Assert.Equal(3, def.RequiredNpcInstanceIds.Count); + Assert.Contains("prototype_npc_melee", def.RequiredNpcInstanceIds); + } + + [Fact] + public void TryGetDefinition_ShouldReturnFalse_WhenEncounterIdIsNull() + { + // Arrange + var rows = new Dictionary(StringComparer.Ordinal) + { + [PrototypeCombatPocket] = CreatePrototypeCombatPocketRow(), + }; + var registry = CreateRegistryFromRows(rows); + // Act + var found = registry.TryGetDefinition(null, out var def); + // Assert + Assert.False(found); + Assert.Null(def); + } + + [Fact] + public void TryGetDefinition_ShouldReturnFalse_WhenIdUnknown() + { + // Arrange + var rows = new Dictionary(StringComparer.Ordinal) + { + [PrototypeCombatPocket] = CreatePrototypeCombatPocketRow(), + }; + var registry = CreateRegistryFromRows(rows); + // Act + var found = registry.TryGetDefinition("not_a_real_encounter", out var def); + // Assert + Assert.False(found); + Assert.Null(def); + } + + [Fact] + public void TryNormalizeKnown_ShouldReturnTrueAndLowercaseId_WhenInputHasWhitespaceAndMixedCase() + { + // Arrange + var rows = new Dictionary(StringComparer.Ordinal) + { + [PrototypeCombatPocket] = CreatePrototypeCombatPocketRow(), + }; + var registry = CreateRegistryFromRows(rows); + // Act + var found = registry.TryNormalizeKnown(" Prototype_Combat_Pocket ", out var normalized); + // Assert + Assert.True(found); + Assert.Equal(PrototypeCombatPocket, normalized); + } + + [Fact] + public void TryNormalizeKnown_ShouldReturnFalse_WhenInputIsNull() + { + // Arrange + var rows = new Dictionary(StringComparer.Ordinal) + { + [PrototypeCombatPocket] = CreatePrototypeCombatPocketRow(), + }; + var registry = CreateRegistryFromRows(rows); + // Act + var found = registry.TryNormalizeKnown(null, out var normalized); + // Assert + Assert.False(found); + Assert.Equal(string.Empty, normalized); + } + + [Fact] + public void TryNormalizeKnown_ShouldReturnFalse_WhenInputIsWhitespaceOnly() + { + // Arrange + var rows = new Dictionary(StringComparer.Ordinal) + { + [PrototypeCombatPocket] = CreatePrototypeCombatPocketRow(), + }; + var registry = CreateRegistryFromRows(rows); + // Act + var found = registry.TryNormalizeKnown(" ", out var normalized); + // Assert + Assert.False(found); + Assert.Equal(string.Empty, normalized); + } + + [Fact] + public void TryNormalizeKnown_ShouldReturnFalse_WhenIdNotInCatalog() + { + // Arrange + var rows = new Dictionary(StringComparer.Ordinal) + { + [PrototypeCombatPocket] = CreatePrototypeCombatPocketRow(), + }; + var registry = CreateRegistryFromRows(rows); + // Act + var found = registry.TryNormalizeKnown("prototype_unknown", out var normalized); + // Assert + Assert.False(found); + Assert.Equal("prototype_unknown", normalized); + } + + [Fact] + public void GetDefinitionsInIdOrder_ShouldListAllRowsOrderedById_WhenMultipleEncounters() + { + // Arrange + var rows = new Dictionary(StringComparer.Ordinal) + { + ["z_encounter"] = new EncounterDefRow( + "z_encounter", + "Z", + "defeat_all_targets", + ["prototype_npc_melee"], + "prototype_combat_pocket_clear"), + [PrototypeCombatPocket] = CreatePrototypeCombatPocketRow(), + ["a_encounter"] = new EncounterDefRow( + "a_encounter", + "A", + "defeat_all_targets", + ["prototype_npc_melee"], + "prototype_combat_pocket_clear"), + }; + var registry = CreateRegistryFromRows(rows); + // Act + var list = registry.GetDefinitionsInIdOrder(); + // Assert + Assert.Equal(3, list.Count); + Assert.Equal("a_encounter", list[0].Id); + Assert.Equal(PrototypeCombatPocket, list[1].Id); + Assert.Equal("z_encounter", list[2].Id); + } + + [Fact] + public async Task Host_ShouldResolveRegistriesFromDi_WhenStartupSucceeds() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + using var client = factory.CreateClient(); + _ = await client.GetAsync("/health"); + // Act + var encounterRegistry = factory.Services.GetRequiredService(); + var rewardRegistry = factory.Services.GetRequiredService(); + var encounterFound = encounterRegistry.TryGetDefinition(PrototypeCombatPocket, out var encounter); + var unknownEncounter = encounterRegistry.TryGetDefinition("not_a_real_encounter", out var missingEncounter); + var normalizedEncounter = encounterRegistry.TryNormalizeKnown(" Prototype_Combat_Pocket ", out var encounterId); + var rewardFound = rewardRegistry.TryGetDefinition("prototype_combat_pocket_clear", out var rewardTable); + var unknownReward = rewardRegistry.TryNormalizeKnown("prototype_unknown", out var unknownRewardId); + var encounterList = encounterRegistry.GetDefinitionsInIdOrder(); + // Assert + Assert.True(encounterFound); + Assert.NotNull(encounter); + Assert.Equal("prototype_combat_pocket_clear", encounter!.RewardTableId); + Assert.False(unknownEncounter); + Assert.Null(missingEncounter); + Assert.True(normalizedEncounter); + Assert.Equal(PrototypeCombatPocket, encounterId); + Assert.True(rewardFound); + Assert.NotNull(rewardTable); + Assert.Equal(10, rewardTable!.FixedGrants.First(g => g.ItemId == "scrap_metal_bulk").Quantity); + Assert.False(unknownReward); + Assert.Equal("prototype_unknown", unknownRewardId); + Assert.Single(encounterList); + Assert.Equal(PrototypeCombatPocket, encounterList[0].Id); + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/Encounters/RewardTableDefinitionRegistryTests.cs b/server/NeonSprawl.Server.Tests/Game/Encounters/RewardTableDefinitionRegistryTests.cs new file mode 100644 index 0000000..5dacc8a --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Encounters/RewardTableDefinitionRegistryTests.cs @@ -0,0 +1,187 @@ +using Microsoft.Extensions.DependencyInjection; +using NeonSprawl.Server.Game.Encounters; +using NeonSprawl.Server.Tests; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Encounters; + +public class RewardTableDefinitionRegistryTests +{ + private const string PrototypeCombatPocketClear = "prototype_combat_pocket_clear"; + + private static RewardTableDefinitionRegistry CreateRegistryFromRows(IReadOnlyDictionary byId) + { + var catalog = new RewardTableDefinitionCatalog("/tmp/reward-tables", byId, catalogJsonFileCount: 1); + return new RewardTableDefinitionRegistry(catalog); + } + + private static RewardTableDefRow CreatePrototypeCombatPocketClearRow() => + new( + PrototypeCombatPocketClear, + "Prototype Combat Pocket Clear", + [ + new RewardGrantRow("scrap_metal_bulk", 10), + new RewardGrantRow("contract_handoff_token", 1), + ]); + + [Fact] + public void TryGetDefinition_ShouldReturnTrueAndExpectedMetadata_WhenIdExists() + { + // Arrange + var rows = new Dictionary(StringComparer.Ordinal) + { + [PrototypeCombatPocketClear] = CreatePrototypeCombatPocketClearRow(), + }; + var registry = CreateRegistryFromRows(rows); + // Act + var found = registry.TryGetDefinition(PrototypeCombatPocketClear, out var def); + // Assert + Assert.True(found); + Assert.NotNull(def); + Assert.Equal("Prototype Combat Pocket Clear", def!.DisplayName); + Assert.Equal(10, def.FixedGrants.First(g => g.ItemId == "scrap_metal_bulk").Quantity); + Assert.Equal(1, def.FixedGrants.First(g => g.ItemId == "contract_handoff_token").Quantity); + } + + [Fact] + public void TryGetDefinition_ShouldReturnFalse_WhenRewardTableIdIsNull() + { + // Arrange + var rows = new Dictionary(StringComparer.Ordinal) + { + [PrototypeCombatPocketClear] = CreatePrototypeCombatPocketClearRow(), + }; + var registry = CreateRegistryFromRows(rows); + // Act + var found = registry.TryGetDefinition(null, out var def); + // Assert + Assert.False(found); + Assert.Null(def); + } + + [Fact] + public void TryGetDefinition_ShouldReturnFalse_WhenIdUnknown() + { + // Arrange + var rows = new Dictionary(StringComparer.Ordinal) + { + [PrototypeCombatPocketClear] = CreatePrototypeCombatPocketClearRow(), + }; + var registry = CreateRegistryFromRows(rows); + // Act + var found = registry.TryGetDefinition("not_a_real_reward_table", out var def); + // Assert + Assert.False(found); + Assert.Null(def); + } + + [Fact] + public void TryNormalizeKnown_ShouldReturnTrueAndLowercaseId_WhenInputHasWhitespaceAndMixedCase() + { + // Arrange + var rows = new Dictionary(StringComparer.Ordinal) + { + [PrototypeCombatPocketClear] = CreatePrototypeCombatPocketClearRow(), + }; + var registry = CreateRegistryFromRows(rows); + // Act + var found = registry.TryNormalizeKnown(" Prototype_Combat_Pocket_Clear ", out var normalized); + // Assert + Assert.True(found); + Assert.Equal(PrototypeCombatPocketClear, normalized); + } + + [Fact] + public void TryNormalizeKnown_ShouldReturnFalse_WhenInputIsNull() + { + // Arrange + var rows = new Dictionary(StringComparer.Ordinal) + { + [PrototypeCombatPocketClear] = CreatePrototypeCombatPocketClearRow(), + }; + var registry = CreateRegistryFromRows(rows); + // Act + var found = registry.TryNormalizeKnown(null, out var normalized); + // Assert + Assert.False(found); + Assert.Equal(string.Empty, normalized); + } + + [Fact] + public void TryNormalizeKnown_ShouldReturnFalse_WhenInputIsWhitespaceOnly() + { + // Arrange + var rows = new Dictionary(StringComparer.Ordinal) + { + [PrototypeCombatPocketClear] = CreatePrototypeCombatPocketClearRow(), + }; + var registry = CreateRegistryFromRows(rows); + // Act + var found = registry.TryNormalizeKnown(" ", out var normalized); + // Assert + Assert.False(found); + Assert.Equal(string.Empty, normalized); + } + + [Fact] + public void TryNormalizeKnown_ShouldReturnFalse_WhenIdNotInCatalog() + { + // Arrange + var rows = new Dictionary(StringComparer.Ordinal) + { + [PrototypeCombatPocketClear] = CreatePrototypeCombatPocketClearRow(), + }; + var registry = CreateRegistryFromRows(rows); + // Act + var found = registry.TryNormalizeKnown("prototype_unknown", out var normalized); + // Assert + Assert.False(found); + Assert.Equal("prototype_unknown", normalized); + } + + [Fact] + public void GetDefinitionsInIdOrder_ShouldListAllRowsOrderedById_WhenMultipleRewardTables() + { + // Arrange + var rows = new Dictionary(StringComparer.Ordinal) + { + ["z_table"] = new RewardTableDefRow("z_table", "Z", [new RewardGrantRow("scrap_metal_bulk", 1)]), + [PrototypeCombatPocketClear] = CreatePrototypeCombatPocketClearRow(), + ["a_table"] = new RewardTableDefRow("a_table", "A", [new RewardGrantRow("scrap_metal_bulk", 1)]), + }; + var registry = CreateRegistryFromRows(rows); + // Act + var list = registry.GetDefinitionsInIdOrder(); + // Assert + Assert.Equal(3, list.Count); + Assert.Equal("a_table", list[0].Id); + Assert.Equal(PrototypeCombatPocketClear, list[1].Id); + Assert.Equal("z_table", list[2].Id); + } + + [Fact] + public async Task Host_ShouldResolveRegistryFromDi_WhenStartupSucceeds() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + using var client = factory.CreateClient(); + _ = await client.GetAsync("/health"); + // Act + var registry = factory.Services.GetRequiredService(); + var found = registry.TryGetDefinition(PrototypeCombatPocketClear, out var rewardTable); + var unknown = registry.TryGetDefinition("not_a_real_reward_table", out var missing); + var normalized = registry.TryNormalizeKnown(" Prototype_Combat_Pocket_Clear ", out var rewardTableId); + var list = registry.GetDefinitionsInIdOrder(); + // Assert + Assert.True(found); + Assert.NotNull(rewardTable); + Assert.Equal(10, rewardTable!.FixedGrants.First(g => g.ItemId == "scrap_metal_bulk").Quantity); + Assert.Equal(1, rewardTable.FixedGrants.First(g => g.ItemId == "contract_handoff_token").Quantity); + Assert.False(unknown); + Assert.Null(missing); + Assert.True(normalized); + Assert.Equal(PrototypeCombatPocketClear, rewardTableId); + Assert.Single(list); + Assert.Equal(PrototypeCombatPocketClear, list[0].Id); + } +} diff --git a/server/NeonSprawl.Server/Game/Encounters/EncounterCatalogServiceCollectionExtensions.cs b/server/NeonSprawl.Server/Game/Encounters/EncounterCatalogServiceCollectionExtensions.cs index 39830d2..e396b1e 100644 --- a/server/NeonSprawl.Server/Game/Encounters/EncounterCatalogServiceCollectionExtensions.cs +++ b/server/NeonSprawl.Server/Game/Encounters/EncounterCatalogServiceCollectionExtensions.cs @@ -6,10 +6,10 @@ using NeonSprawl.Server.Game.Skills; namespace NeonSprawl.Server.Game.Encounters; -/// DI registration for the fail-fast encounter + reward-table catalogs (NEO-101). +/// DI registration for the fail-fast encounter + reward-table catalogs and registries (NEO-101, NEO-102). public static class EncounterCatalogServiceCollectionExtensions { - /// Binds and registers encounter + reward-table catalog singletons. + /// Binds and registers encounter + reward-table catalog singletons and registries. public static IServiceCollection AddEncounterAndRewardCatalogs(this IServiceCollection services, IConfiguration configuration) { services.AddOptions() @@ -70,6 +70,12 @@ public static class EncounterCatalogServiceCollectionExtensions logger); }); + services.AddSingleton(sp => + new EncounterDefinitionRegistry(sp.GetRequiredService())); + + services.AddSingleton(sp => + new RewardTableDefinitionRegistry(sp.GetRequiredService())); + return services; } } diff --git a/server/NeonSprawl.Server/Game/Encounters/EncounterDefinitionCatalog.cs b/server/NeonSprawl.Server/Game/Encounters/EncounterDefinitionCatalog.cs index 9e644c4..2511d39 100644 --- a/server/NeonSprawl.Server/Game/Encounters/EncounterDefinitionCatalog.cs +++ b/server/NeonSprawl.Server/Game/Encounters/EncounterDefinitionCatalog.cs @@ -2,7 +2,7 @@ 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). +/// In-memory encounter catalog loaded at startup (NEO-101). Game callers should use (NEO-102). public sealed class EncounterDefinitionCatalog( string encountersDirectory, IReadOnlyDictionary byId, diff --git a/server/NeonSprawl.Server/Game/Encounters/EncounterDefinitionRegistry.cs b/server/NeonSprawl.Server/Game/Encounters/EncounterDefinitionRegistry.cs new file mode 100644 index 0000000..ba911a2 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Encounters/EncounterDefinitionRegistry.cs @@ -0,0 +1,55 @@ +using System.Diagnostics.CodeAnalysis; + +namespace NeonSprawl.Server.Game.Encounters; + +/// Adapter over (NEO-102). +public sealed class EncounterDefinitionRegistry(EncounterDefinitionCatalog catalog) : IEncounterDefinitionRegistry +{ + private readonly IReadOnlyList _definitionsInIdOrder = BuildDefinitionsInIdOrder(catalog); + + /// + public bool TryGetDefinition(string? encounterId, [NotNullWhen(true)] out EncounterDefRow? definition) + { + if (encounterId is null) + { + definition = null; + return false; + } + + return catalog.TryGetEncounter(encounterId, out definition); + } + + /// + public bool TryNormalizeKnown(string? rawEncounterId, [NotNullWhen(true)] out string normalized) + { + if (rawEncounterId is null) + { + normalized = string.Empty; + return false; + } + + normalized = rawEncounterId.Trim().ToLowerInvariant(); + if (normalized.Length == 0) + { + normalized = string.Empty; + return false; + } + + return catalog.TryGetEncounter(normalized, out _); + } + + /// + public IReadOnlyList GetDefinitionsInIdOrder() => _definitionsInIdOrder; + + private static IReadOnlyList BuildDefinitionsInIdOrder(EncounterDefinitionCatalog catalog) + { + var ids = catalog.ById.Keys.OrderBy(k => k, StringComparer.Ordinal).ToArray(); + var list = new List(ids.Length); + foreach (var id in ids) + { + list.Add(catalog.ById[id]); + } + + return list; + } +} diff --git a/server/NeonSprawl.Server/Game/Encounters/IEncounterDefinitionRegistry.cs b/server/NeonSprawl.Server/Game/Encounters/IEncounterDefinitionRegistry.cs new file mode 100644 index 0000000..dac267e --- /dev/null +++ b/server/NeonSprawl.Server/Game/Encounters/IEncounterDefinitionRegistry.cs @@ -0,0 +1,23 @@ +using System.Diagnostics.CodeAnalysis; + +namespace NeonSprawl.Server.Game.Encounters; + +/// +/// Read-only access to validated entries loaded at startup (). +/// +/// +/// E5.M3 (encounter progress / completion): callers validating encounter ids and resolving completion metadata should depend on this interface +/// rather than so encounter defs stay centralized. +/// NEO-103: HTTP/read-model projections should depend on this interface rather than reaching into the catalog. +/// +public interface IEncounterDefinitionRegistry +{ + /// Attempts to resolve an encounter by stable id (see encounter-def.schema.json). Unknown ids and null return false without throwing. + bool TryGetDefinition(string? encounterId, [NotNullWhen(true)] out EncounterDefRow? definition); + + /// Trims and lowercases ; returns true when the normalized id exists in the loaded catalog. null, empty, and whitespace return false without throwing. + bool TryNormalizeKnown(string? rawEncounterId, [NotNullWhen(true)] out string normalized); + + /// Every loaded definition, ordered by (ordinal). + IReadOnlyList GetDefinitionsInIdOrder(); +} diff --git a/server/NeonSprawl.Server/Game/Encounters/IRewardTableDefinitionRegistry.cs b/server/NeonSprawl.Server/Game/Encounters/IRewardTableDefinitionRegistry.cs new file mode 100644 index 0000000..198c97b --- /dev/null +++ b/server/NeonSprawl.Server/Game/Encounters/IRewardTableDefinitionRegistry.cs @@ -0,0 +1,23 @@ +using System.Diagnostics.CodeAnalysis; + +namespace NeonSprawl.Server.Game.Encounters; + +/// +/// Read-only access to validated entries loaded at startup (). +/// +/// +/// E5.M3 (encounter completion / loot grants): callers validating reward-table ids and resolving fixed grants should depend on this interface +/// rather than so reward tables stay centralized. +/// NEO-103: HTTP/read-model projections should depend on this interface rather than reaching into the catalog. +/// +public interface IRewardTableDefinitionRegistry +{ + /// Attempts to resolve a reward table by stable id (see reward-table.schema.json). Unknown ids and null return false without throwing. + bool TryGetDefinition(string? rewardTableId, [NotNullWhen(true)] out RewardTableDefRow? definition); + + /// Trims and lowercases ; returns true when the normalized id exists in the loaded catalog. null, empty, and whitespace return false without throwing. + bool TryNormalizeKnown(string? rawRewardTableId, [NotNullWhen(true)] out string normalized); + + /// Every loaded definition, ordered by (ordinal). + IReadOnlyList GetDefinitionsInIdOrder(); +} diff --git a/server/NeonSprawl.Server/Game/Encounters/RewardTableDefinitionCatalog.cs b/server/NeonSprawl.Server/Game/Encounters/RewardTableDefinitionCatalog.cs index 10d10b4..70168f9 100644 --- a/server/NeonSprawl.Server/Game/Encounters/RewardTableDefinitionCatalog.cs +++ b/server/NeonSprawl.Server/Game/Encounters/RewardTableDefinitionCatalog.cs @@ -2,7 +2,7 @@ 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). +/// In-memory reward-table catalog loaded at startup (NEO-101). Game callers should use (NEO-102). public sealed class RewardTableDefinitionCatalog( string rewardTablesDirectory, IReadOnlyDictionary byId, diff --git a/server/NeonSprawl.Server/Game/Encounters/RewardTableDefinitionRegistry.cs b/server/NeonSprawl.Server/Game/Encounters/RewardTableDefinitionRegistry.cs new file mode 100644 index 0000000..b5d61dc --- /dev/null +++ b/server/NeonSprawl.Server/Game/Encounters/RewardTableDefinitionRegistry.cs @@ -0,0 +1,55 @@ +using System.Diagnostics.CodeAnalysis; + +namespace NeonSprawl.Server.Game.Encounters; + +/// Adapter over (NEO-102). +public sealed class RewardTableDefinitionRegistry(RewardTableDefinitionCatalog catalog) : IRewardTableDefinitionRegistry +{ + private readonly IReadOnlyList _definitionsInIdOrder = BuildDefinitionsInIdOrder(catalog); + + /// + public bool TryGetDefinition(string? rewardTableId, [NotNullWhen(true)] out RewardTableDefRow? definition) + { + if (rewardTableId is null) + { + definition = null; + return false; + } + + return catalog.TryGetRewardTable(rewardTableId, out definition); + } + + /// + public bool TryNormalizeKnown(string? rawRewardTableId, [NotNullWhen(true)] out string normalized) + { + if (rawRewardTableId is null) + { + normalized = string.Empty; + return false; + } + + normalized = rawRewardTableId.Trim().ToLowerInvariant(); + if (normalized.Length == 0) + { + normalized = string.Empty; + return false; + } + + return catalog.TryGetRewardTable(normalized, out _); + } + + /// + public IReadOnlyList GetDefinitionsInIdOrder() => _definitionsInIdOrder; + + private static IReadOnlyList BuildDefinitionsInIdOrder(RewardTableDefinitionCatalog catalog) + { + var ids = catalog.ById.Keys.OrderBy(k => k, StringComparer.Ordinal).ToArray(); + var list = new List(ids.Length); + foreach (var id in ids) + { + list.Add(catalog.ById[id]); + } + + return list; + } +} diff --git a/server/README.md b/server/README.md index c7d3b3d..d61a51e 100644 --- a/server/README.md +++ b/server/README.md @@ -118,7 +118,7 @@ On startup the host loads every **`*_reward_tables.json`** under the reward-tabl **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. +On success, **Information** logs include the resolved reward-tables directory path, distinct reward-table count, and catalog file count. Game code should use **`IRewardTableDefinitionRegistry`** for lookups (`TryGetDefinition`, `TryNormalizeKnown`, `GetDefinitionsInIdOrder`; NEO-102). The catalog singleton remains for fail-fast startup only; do not inject **`RewardTableDefinitionCatalog`** in new game code. ## Encounter catalog (`content/encounters`, NEO-101) @@ -131,7 +131,7 @@ On startup the host loads every **`*_encounters.json`** under the encounters dir **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. +On success, **Information** logs include the resolved encounters directory path, distinct encounter count, and catalog file count. Game code should use **`IEncounterDefinitionRegistry`** for lookups (`TryGetDefinition`, `TryNormalizeKnown`, `GetDefinitionsInIdOrder`; NEO-102). The catalog singleton remains for fail-fast startup only; do not inject **`EncounterDefinitionCatalog`** in new game code. ## NPC behavior definitions (NEO-90) From fa31154d4c9c53b78663e11169f27daaacbe7785 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 31 May 2026 00:39:18 -0400 Subject: [PATCH 3/5] NEO-102: Add code review for encounter/reward registries + DI. --- docs/reviews/2026-05-31-NEO-102.md | 54 ++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 docs/reviews/2026-05-31-NEO-102.md diff --git a/docs/reviews/2026-05-31-NEO-102.md b/docs/reviews/2026-05-31-NEO-102.md new file mode 100644 index 0000000..9b12574 --- /dev/null +++ b/docs/reviews/2026-05-31-NEO-102.md @@ -0,0 +1,54 @@ +# Code review — NEO-102 (E5M3-03) + +**Date:** 2026-05-31 +**Scope:** Branch `NEO-102-injectable-encounter-reward-registries-di` vs `d7c2684` (merge-base on `main`) — commits `93eca82` … `734876c` +**Base:** `d7c2684` (main at branch point, post NEO-101 merge) + +## Verdict + +**Approve with nits** + +## Summary + +NEO-102 adds thin **`IEncounterDefinitionRegistry`** and **`IRewardTableDefinitionRegistry`** adapters over the NEO-101 startup-loaded catalogs, mirroring the established NEO-79 **`AbilityDefinitionRegistry`** pattern: `TryGetDefinition`, `TryNormalizeKnown` (trim + lowercase + fail-closed lookup), and cached `GetDefinitionsInIdOrder`. DI registers both singletons in `AddEncounterAndRewardCatalogs` after catalog loaders; no `Program.cs` change (catalogs remain the eager boot gate). Eighteen AAA unit + host DI tests pass locally. Docs (implementation plan, alignment register, E5M3 backlog, E5.M3 module snapshot, `server/README.md`) are updated. Risk is low: server-only registry surface with no HTTP or game call-site migration yet (correctly deferred to NEO-103+). + +## Documentation checked + +| Path | Result | +|------|--------| +| `docs/plans/NEO-102-implementation-plan.md` | **Matches** — interfaces, adapters, DI, tests, README, alignment register; acceptance checklist checked; reconciliation section accurate. | +| `docs/plans/E5M3-prototype-backlog.md` (E5M3-03) | **Matches** — acceptance criteria checked; landed note cites plan and 18 tests. | +| `docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md` | **Matches** — Summary **Status** notes **NEO-102 registries landed**; **Implementation snapshot** paragraph added. | +| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E5.M3 row notes **NEO-102** registries; runtime NEO-103+ still outstanding. | +| `docs/decomposition/modules/module_dependency_register.md` | **Partially matches** — E5.M3 note still ends with **Runtime/engine (NEO-102+) not started.**; should cite NEO-102 registry landing (see Suggestion 1). | +| Full-stack epic decomposition | **N/A** — plan explicitly excludes client counterpart until NEO-110 / NEO-111. | +| `server/README.md` | **Matches** — encounter + reward-table sections document registries as preferred lookup surface. | + +## Blocking issues + +None. + +## Suggestions + +1. **Sync `module_dependency_register.md` E5.M3 note** — NEO-101 review added **NEO-101 landed** to the register note; this branch updates alignment + module pages but leaves the register saying **NEO-102+ not started**. Add **NEO-102 landed:** injectable **`IEncounterDefinitionRegistry`** + **`IRewardTableDefinitionRegistry`** + DI, mirroring the alignment register row, so the three tracking surfaces stay consistent. + +## Nits + +- Nit: `EncounterDefinitionRegistryTests.Host_ShouldResolveRegistriesFromDi_WhenStartupSucceeds` resolves **both** registries (per plan), while `RewardTableDefinitionRegistryTests.Host_ShouldResolveRegistryFromDi_WhenStartupSucceeds` repeats reward-table assertions. Harmless overlap with NEO-79’s single-registry host test; optional consolidation later. + +- Nit: `TryGetDefinition` is case-sensitive (delegates to ordinal catalog keys); only `TryNormalizeKnown` lowercases. Consistent with **`AbilityDefinitionRegistry`** — document for NEO-103 HTTP callers if they accept mixed-case query params. + +## Verification + +```bash +# NEO-102-focused (18 tests — all passed locally) +dotnet test NeonSprawl.sln --filter "FullyQualifiedName~EncounterDefinitionRegistry|FullyQualifiedName~RewardTableDefinitionRegistry" + +# Broader encounter slice (includes NEO-101 catalog tests) +dotnet test NeonSprawl.sln --filter "FullyQualifiedName~Encounters" + +# CI parity +python3 scripts/validate_content.py +``` + +**Local run:** 18/18 registry tests passed. From 5229c175986b0fcd32ad59f2bac3dd477d1183a4 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 31 May 2026 00:40:37 -0400 Subject: [PATCH 4/5] =?UTF-8?q?NEO-102:=20address=20code=20review=20?= =?UTF-8?q?=E2=80=94=20sync=20module=20register=20+=20README=20id=20lookup?= =?UTF-8?q?=20note?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/decomposition/modules/module_dependency_register.md | 2 +- docs/reviews/2026-05-31-NEO-102.md | 6 +++--- server/README.md | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/decomposition/modules/module_dependency_register.md b/docs/decomposition/modules/module_dependency_register.md index ae2a1ee..835a73d 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)). **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.** +**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). **NEO-102 landed:** injectable **`IEncounterDefinitionRegistry`** + **`IRewardTableDefinitionRegistry`** + DI ([NEO-102 plan](../../plans/NEO-102-implementation-plan.md)); [server README — Encounter + reward-table registries (NEO-102)](../../../server/README.md#reward-table-catalog-contentreward-tables-neo-101). **Runtime/engine (NEO-103+) not started.** ### Epic 6 — PvP Security diff --git a/docs/reviews/2026-05-31-NEO-102.md b/docs/reviews/2026-05-31-NEO-102.md index 9b12574..25f92dd 100644 --- a/docs/reviews/2026-05-31-NEO-102.md +++ b/docs/reviews/2026-05-31-NEO-102.md @@ -20,7 +20,7 @@ NEO-102 adds thin **`IEncounterDefinitionRegistry`** and **`IRewardTableDefiniti | `docs/plans/E5M3-prototype-backlog.md` (E5M3-03) | **Matches** — acceptance criteria checked; landed note cites plan and 18 tests. | | `docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md` | **Matches** — Summary **Status** notes **NEO-102 registries landed**; **Implementation snapshot** paragraph added. | | `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E5.M3 row notes **NEO-102** registries; runtime NEO-103+ still outstanding. | -| `docs/decomposition/modules/module_dependency_register.md` | **Partially matches** — E5.M3 note still ends with **Runtime/engine (NEO-102+) not started.**; should cite NEO-102 registry landing (see Suggestion 1). | +| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — E5.M3 note cites **NEO-102** registry landing (see Suggestion 1). ~~Partially matches — E5.M3 note still ends with **NEO-102+ not started.**~~ | | Full-stack epic decomposition | **N/A** — plan explicitly excludes client counterpart until NEO-110 / NEO-111. | | `server/README.md` | **Matches** — encounter + reward-table sections document registries as preferred lookup surface. | @@ -30,13 +30,13 @@ None. ## Suggestions -1. **Sync `module_dependency_register.md` E5.M3 note** — NEO-101 review added **NEO-101 landed** to the register note; this branch updates alignment + module pages but leaves the register saying **NEO-102+ not started**. Add **NEO-102 landed:** injectable **`IEncounterDefinitionRegistry`** + **`IRewardTableDefinitionRegistry`** + DI, mirroring the alignment register row, so the three tracking surfaces stay consistent. +1. ~~**Sync `module_dependency_register.md` E5.M3 note** — NEO-101 review added **NEO-101 landed** to the register note; this branch updates alignment + module pages but leaves the register saying **NEO-102+ not started**. Add **NEO-102 landed:** injectable **`IEncounterDefinitionRegistry`** + **`IRewardTableDefinitionRegistry`** + DI, mirroring the alignment register row, so the three tracking surfaces stay consistent.~~ **Done.** E5.M3 note updated; runtime NEO-103+ still outstanding. ## Nits - Nit: `EncounterDefinitionRegistryTests.Host_ShouldResolveRegistriesFromDi_WhenStartupSucceeds` resolves **both** registries (per plan), while `RewardTableDefinitionRegistryTests.Host_ShouldResolveRegistryFromDi_WhenStartupSucceeds` repeats reward-table assertions. Harmless overlap with NEO-79’s single-registry host test; optional consolidation later. -- Nit: `TryGetDefinition` is case-sensitive (delegates to ordinal catalog keys); only `TryNormalizeKnown` lowercases. Consistent with **`AbilityDefinitionRegistry`** — document for NEO-103 HTTP callers if they accept mixed-case query params. +- ~~Nit: `TryGetDefinition` is case-sensitive (delegates to ordinal catalog keys); only `TryNormalizeKnown` lowercases. Consistent with **`AbilityDefinitionRegistry`** — document for NEO-103 HTTP callers if they accept mixed-case query params.~~ **Done.** `server/README.md` encounter + reward-table sections note case-sensitive `TryGetDefinition` and wire validation via `TryNormalizeKnown`. ## Verification diff --git a/server/README.md b/server/README.md index d61a51e..5052fa1 100644 --- a/server/README.md +++ b/server/README.md @@ -118,7 +118,7 @@ On startup the host loads every **`*_reward_tables.json`** under the reward-tabl **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. Game code should use **`IRewardTableDefinitionRegistry`** for lookups (`TryGetDefinition`, `TryNormalizeKnown`, `GetDefinitionsInIdOrder`; NEO-102). The catalog singleton remains for fail-fast startup only; do not inject **`RewardTableDefinitionCatalog`** in new game code. +On success, **Information** logs include the resolved reward-tables directory path, distinct reward-table count, and catalog file count. Game code should use **`IRewardTableDefinitionRegistry`** for lookups (`TryGetDefinition`, `TryNormalizeKnown`, `GetDefinitionsInIdOrder`; NEO-102). The catalog singleton remains for fail-fast startup only; do not inject **`RewardTableDefinitionCatalog`** in new game code. **`TryGetDefinition`** is case-sensitive on catalog keys; HTTP and game callers validating wire ids should use **`TryNormalizeKnown`** (trim + lowercase + fail-closed lookup), same as ability/hotbar routes. ## Encounter catalog (`content/encounters`, NEO-101) @@ -131,7 +131,7 @@ On startup the host loads every **`*_encounters.json`** under the encounters dir **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. Game code should use **`IEncounterDefinitionRegistry`** for lookups (`TryGetDefinition`, `TryNormalizeKnown`, `GetDefinitionsInIdOrder`; NEO-102). The catalog singleton remains for fail-fast startup only; do not inject **`EncounterDefinitionCatalog`** in new game code. +On success, **Information** logs include the resolved encounters directory path, distinct encounter count, and catalog file count. Game code should use **`IEncounterDefinitionRegistry`** for lookups (`TryGetDefinition`, `TryNormalizeKnown`, `GetDefinitionsInIdOrder`; NEO-102). The catalog singleton remains for fail-fast startup only; do not inject **`EncounterDefinitionCatalog`** in new game code. **`TryGetDefinition`** is case-sensitive on catalog keys; HTTP and game callers validating wire ids should use **`TryNormalizeKnown`** (trim + lowercase + fail-closed lookup), same as ability/hotbar routes. ## NPC behavior definitions (NEO-90) From ca0b1f8e30aa9ef8c94b13fed38d9b72517eb3d3 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 31 May 2026 00:40:39 -0400 Subject: [PATCH 5/5] NEO-102: tidy review doc documentation-checked row after follow-up --- docs/reviews/2026-05-31-NEO-102.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reviews/2026-05-31-NEO-102.md b/docs/reviews/2026-05-31-NEO-102.md index 25f92dd..1475437 100644 --- a/docs/reviews/2026-05-31-NEO-102.md +++ b/docs/reviews/2026-05-31-NEO-102.md @@ -20,7 +20,7 @@ NEO-102 adds thin **`IEncounterDefinitionRegistry`** and **`IRewardTableDefiniti | `docs/plans/E5M3-prototype-backlog.md` (E5M3-03) | **Matches** — acceptance criteria checked; landed note cites plan and 18 tests. | | `docs/decomposition/modules/E5_M3_EncounterAndRewardTables.md` | **Matches** — Summary **Status** notes **NEO-102 registries landed**; **Implementation snapshot** paragraph added. | | `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E5.M3 row notes **NEO-102** registries; runtime NEO-103+ still outstanding. | -| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — E5.M3 note cites **NEO-102** registry landing (see Suggestion 1). ~~Partially matches — E5.M3 note still ends with **NEO-102+ not started.**~~ | +| `docs/decomposition/modules/module_dependency_register.md` | **Matches** — E5.M3 note cites **NEO-102** registry landing; runtime NEO-103+ still outstanding. | | Full-stack epic decomposition | **N/A** — plan explicitly excludes client counterpart until NEO-110 / NEO-111. | | `server/README.md` | **Matches** — encounter + reward-table sections document registries as preferred lookup surface. |