Compare commits

...

11 Commits

Author SHA1 Message Date
VinPropane fb5137c39a
Merge pull request #141 from ViPro-Technologies/NEO-102-injectable-encounter-reward-registries-di
NEO-102: Injectable encounter/reward registries + DI
2026-05-31 00:50:52 -04:00
VinPropane ca0b1f8e30 NEO-102: tidy review doc documentation-checked row after follow-up 2026-05-31 00:40:39 -04:00
VinPropane 5229c17598 NEO-102: address code review — sync module register + README id lookup note 2026-05-31 00:40:37 -04:00
VinPropane fa31154d4c NEO-102: Add code review for encounter/reward registries + DI. 2026-05-31 00:39:18 -04:00
VinPropane 734876cde9 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.
2026-05-31 00:38:38 -04:00
VinPropane 93eca821d7 NEO-102: add implementation plan for encounter/reward registries + DI 2026-05-31 00:33:45 -04:00
VinPropane d7c2684488
Merge pull request #140 from ViPro-Technologies/NEO-101-encounter-reward-catalog-load-fail-fast
NEO-101: Fail-fast encounter and reward-table catalog load at startup
2026-05-31 00:15:20 -04:00
VinPropane 21749e7977 NEO-101: Address code review — schema tests, module snapshot, host boot.
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.
2026-05-31 00:08:41 -04:00
VinPropane 5e26143fbd NEO-101: Add code review for encounter/reward catalog load. 2026-05-31 00:08:01 -04:00
VinPropane f921095fb0 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.
2026-05-31 00:05:57 -04:00
VinPropane 06c58998cc 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.
2026-05-31 00:03:00 -04:00
34 changed files with 2644 additions and 9 deletions

View File

@ -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) 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
@ -78,6 +78,12 @@ 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 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
- Master plan: [`neon_sprawl_vision.plan.md`](../../../neon_sprawl_vision.plan.md) — Epic 5.

File diff suppressed because one or more lines are too long

View File

@ -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-92NEO-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). **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

View File

@ -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, 22 AAA tests; plan [NEO-101-implementation-plan.md](NEO-101-implementation-plan.md).
### E5M3-03 — Injectable encounter/reward registries + DI
@ -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).
---

View File

@ -0,0 +1,146 @@
# 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<string> 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
- [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:** 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
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** |

View File

@ -0,0 +1,122 @@
# 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
- [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
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<IEncounterDefinitionRegistry>(sp => new EncounterDefinitionRegistry(sp.GetRequiredService<EncounterDefinitionCatalog>()));`
- `services.AddSingleton<IRewardTableDefinitionRegistry>(sp => new RewardTableDefinitionRegistry(sp.GetRequiredService<RewardTableDefinitionCatalog>()));`
- 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** |

View File

@ -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` | **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. |
| 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.~~ **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.~~ **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.~~ **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 (22 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:** 22/22 encounter-catalog tests passed. Full suite: 519 passed, 11 failed — all Postgres/docker-compose harness failures unrelated to this diff.

View File

@ -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` | **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. |
## 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.~~ **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-79s 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.~~ **Done.** `server/README.md` encounter + reward-table sections note case-sensitive `TryGetDefinition` and wire validation via `TryNormalizeKnown`.
## 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.

View File

@ -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<string> Slice1KnownItemIds { get; } =
[
"scrap_metal_bulk",
"refined_plate_stock",
"field_stim_mk0",
"survey_drone_kit",
"contract_handoff_token",
"prototype_armor_shell",
];
}

View File

@ -0,0 +1,367 @@
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<string> 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<string>? 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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(ex);
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<InvalidOperationException>(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()
{
// 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<InvalidOperationException>(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<RewardTableDefinitionCatalog>();
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<EncounterDefinitionCatalog>();
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_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<Program>().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()
{
// 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<Program>().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);
}
}
}

View File

@ -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<string, EncounterDefRow> 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<string, EncounterDefRow>(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<string, EncounterDefRow>(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<string, EncounterDefRow>(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<string, EncounterDefRow>(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<string, EncounterDefRow>(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<string, EncounterDefRow>(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<string, EncounterDefRow>(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<string, EncounterDefRow>(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<IEncounterDefinitionRegistry>();
var rewardRegistry = factory.Services.GetRequiredService<IRewardTableDefinitionRegistry>();
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);
}
}

View File

@ -0,0 +1,305 @@
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<string>? 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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(ex);
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<InvalidOperationException>(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()
{
// 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<InvalidOperationException>(ex);
Assert.Contains("missing schema file", ioe.Message, StringComparison.Ordinal);
Assert.Contains(missingSchema, ioe.Message, StringComparison.Ordinal);
}
}

View File

@ -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<string, RewardTableDefRow> 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<string, RewardTableDefRow>(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<string, RewardTableDefRow>(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<string, RewardTableDefRow>(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<string, RewardTableDefRow>(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<string, RewardTableDefRow>(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<string, RewardTableDefRow>(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<string, RewardTableDefRow>(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<string, RewardTableDefRow>(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<IRewardTableDefinitionRegistry>();
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);
}
}

View File

@ -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<Progra
var npcBehaviorsDir = NpcBehaviorCatalogPathResolution.TryDiscoverNpcBehaviorsDirectory(AppContext.BaseDirectory)
?? throw new InvalidOperationException(
"Could not discover repo content/npc-behaviors from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
var rewardTablesDir = RewardTableCatalogPathResolution.TryDiscoverRewardTablesDirectory(AppContext.BaseDirectory)
?? throw new InvalidOperationException(
"Could not discover repo content/reward-tables from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
var encountersDir = EncounterCatalogPathResolution.TryDiscoverEncountersDirectory(AppContext.BaseDirectory)
?? throw new InvalidOperationException(
"Could not discover repo content/encounters from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
builder.UseSetting("Content:SkillsDirectory", skillsDir);
builder.UseSetting("Content:MasteryDirectory", masteryDir);
builder.UseSetting("Content:ItemsDirectory", itemsDir);
@ -58,6 +65,8 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
builder.UseSetting("Content:RecipesDirectory", recipesDir);
builder.UseSetting("Content:AbilitiesDirectory", abilitiesDir);
builder.UseSetting("Content:NpcBehaviorsDirectory", npcBehaviorsDir);
builder.UseSetting("Content:RewardTablesDirectory", rewardTablesDir);
builder.UseSetting("Content:EncountersDirectory", encountersDir);
builder.UseSetting("Game:EnableMasteryFixtureApi", "true");
builder.ConfigureTestServices(services =>

View File

@ -0,0 +1,60 @@
namespace NeonSprawl.Server.Game.Encounters;
/// <summary>Resolves encounter catalog paths for local dev, tests, and container layouts (NEO-101).</summary>
public static class EncounterCatalogPathResolution
{
/// <summary>Walks <paramref name="startDirectory"/> and parents for an existing <c>content/encounters</c> directory.</summary>
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;
}
/// <summary>
/// Resolves the encounters catalog directory.
/// Empty <paramref name="configuredEncountersDirectory"/> triggers discovery from <see cref="AppContext.BaseDirectory"/>.
/// </summary>
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));
}
/// <summary>Resolves JSON Schema path for a single encounter row (Draft 2020-12).</summary>
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"));
}
}

View File

@ -0,0 +1,81 @@
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;
/// <summary>DI registration for the fail-fast encounter + reward-table catalogs and registries (NEO-101, NEO-102).</summary>
public static class EncounterCatalogServiceCollectionExtensions
{
/// <summary>Binds <see cref="ContentPathsOptions"/> and registers encounter + reward-table catalog singletons and registries.</summary>
public static IServiceCollection AddEncounterAndRewardCatalogs(this IServiceCollection services, IConfiguration configuration)
{
services.AddOptions<ContentPathsOptions>()
.Bind(configuration.GetSection(ContentPathsOptions.SectionName));
services.AddSingleton<RewardTableDefinitionCatalog>(sp =>
{
var hostEnv = sp.GetRequiredService<IHostEnvironment>();
var opts = sp.GetRequiredService<IOptions<ContentPathsOptions>>().Value;
var itemCatalog = sp.GetRequiredService<ItemDefinitionCatalog>();
var logger = sp.GetRequiredService<ILoggerFactory>()
.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<EncounterDefinitionCatalog>(sp =>
{
var hostEnv = sp.GetRequiredService<IHostEnvironment>();
var opts = sp.GetRequiredService<IOptions<ContentPathsOptions>>().Value;
var rewardTableCatalog = sp.GetRequiredService<RewardTableDefinitionCatalog>();
var logger = sp.GetRequiredService<ILoggerFactory>()
.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);
});
services.AddSingleton<IEncounterDefinitionRegistry>(sp =>
new EncounterDefinitionRegistry(sp.GetRequiredService<EncounterDefinitionCatalog>()));
services.AddSingleton<IRewardTableDefinitionRegistry>(sp =>
new RewardTableDefinitionRegistry(sp.GetRequiredService<RewardTableDefinitionCatalog>()));
return services;
}
}

View File

@ -0,0 +1,20 @@
namespace NeonSprawl.Server.Game.Encounters;
/// <summary>Load-time encounter row (NEO-101).</summary>
public sealed class EncounterDefRow(
string id,
string displayName,
string completionCriteriaKind,
IReadOnlyList<string> requiredNpcInstanceIds,
string rewardTableId)
{
public string Id { get; } = id;
public string DisplayName { get; } = displayName;
public string CompletionCriteriaKind { get; } = completionCriteriaKind;
public IReadOnlyList<string> RequiredNpcInstanceIds { get; } = requiredNpcInstanceIds;
public string RewardTableId { get; } = rewardTableId;
}

View File

@ -0,0 +1,25 @@
using System.Collections.ObjectModel;
namespace NeonSprawl.Server.Game.Encounters;
/// <summary>In-memory encounter catalog loaded at startup (NEO-101). Game callers should use <see cref="IEncounterDefinitionRegistry"/> (NEO-102).</summary>
public sealed class EncounterDefinitionCatalog(
string encountersDirectory,
IReadOnlyDictionary<string, EncounterDefRow> byId,
int catalogJsonFileCount)
{
/// <summary>Absolute path to the directory that was enumerated for <c>*_encounters.json</c> catalogs.</summary>
public string EncountersDirectory { get; } = encountersDirectory;
public IReadOnlyDictionary<string, EncounterDefRow> ById { get; } =
new ReadOnlyDictionary<string, EncounterDefRow>(new Dictionary<string, EncounterDefRow>(byId, StringComparer.Ordinal));
public int DistinctEncounterCount => ById.Count;
/// <summary>Number of <c>*_encounters.json</c> files under <see cref="EncountersDirectory"/>.</summary>
public int CatalogJsonFileCount { get; } = catalogJsonFileCount;
/// <summary>Resolves a catalog row by stable encounter <paramref name="id"/>.</summary>
public bool TryGetEncounter(string id, out EncounterDefRow? row) =>
ById.TryGetValue(id, out row);
}

View File

@ -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;
/// <summary>Loads and validates <c>content/encounters/*_encounters.json</c> using the same rules as <c>scripts/validate_content.py</c> (NEO-101).</summary>
public static class EncounterDefinitionCatalogLoader
{
/// <summary>Loads catalogs from disk or throws <see cref="InvalidOperationException"/> with actionable messages.</summary>
public static EncounterDefinitionCatalog Load(
string encountersDirectory,
string encounterDefSchemaPath,
IReadOnlySet<string> knownRewardTableIds,
ILogger logger)
{
encountersDirectory = Path.GetFullPath(encountersDirectory);
encounterDefSchemaPath = Path.GetFullPath(encounterDefSchemaPath);
var errors = new List<string>();
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<string, string>(StringComparer.Ordinal);
var rows = new Dictionary<string, EncounterDefRow>(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<int>(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<string>();
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<string>();
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<string>();
var displayName = (rowObj["displayName"] as JsonValue)!.GetValue<string>();
var completionCriteria = rowObj["completionCriteria"] as JsonObject
?? throw new InvalidOperationException("expected completionCriteria object");
var completionCriteriaKind = (completionCriteria["kind"] as JsonValue)!.GetValue<string>();
var requiredNpcInstanceIds = ParseStringArray(rowObj["requiredNpcInstanceIds"] as JsonArray);
var rewardTableId = (rowObj["rewardTableId"] as JsonValue)!.GetValue<string>();
return new EncounterDefRow(id, displayName, completionCriteriaKind, requiredNpcInstanceIds, rewardTableId);
}
private static IReadOnlyList<string> ParseStringArray(JsonArray? array)
{
if (array is null)
return [];
var values = new List<string>(array.Count);
foreach (var node in array)
{
if (node is JsonValue value && value.TryGetValue<string>(out var s))
values.Add(s);
}
return values;
}
private static List<string> CollectSchemaMessages(EvaluationResults eval, string filePath, int index)
{
var sink = new List<string>();
AppendSchemaMessages(eval, filePath, index, sink);
return sink;
}
private static void AppendSchemaMessages(EvaluationResults r, string filePath, int index, List<string> 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<string> 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());
}
}

View File

@ -0,0 +1,55 @@
using System.Diagnostics.CodeAnalysis;
namespace NeonSprawl.Server.Game.Encounters;
/// <summary>Adapter over <see cref="EncounterDefinitionCatalog"/> (NEO-102).</summary>
public sealed class EncounterDefinitionRegistry(EncounterDefinitionCatalog catalog) : IEncounterDefinitionRegistry
{
private readonly IReadOnlyList<EncounterDefRow> _definitionsInIdOrder = BuildDefinitionsInIdOrder(catalog);
/// <inheritdoc />
public bool TryGetDefinition(string? encounterId, [NotNullWhen(true)] out EncounterDefRow? definition)
{
if (encounterId is null)
{
definition = null;
return false;
}
return catalog.TryGetEncounter(encounterId, out definition);
}
/// <inheritdoc />
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 _);
}
/// <inheritdoc />
public IReadOnlyList<EncounterDefRow> GetDefinitionsInIdOrder() => _definitionsInIdOrder;
private static IReadOnlyList<EncounterDefRow> BuildDefinitionsInIdOrder(EncounterDefinitionCatalog catalog)
{
var ids = catalog.ById.Keys.OrderBy(k => k, StringComparer.Ordinal).ToArray();
var list = new List<EncounterDefRow>(ids.Length);
foreach (var id in ids)
{
list.Add(catalog.ById[id]);
}
return list;
}
}

View File

@ -0,0 +1,23 @@
using System.Diagnostics.CodeAnalysis;
namespace NeonSprawl.Server.Game.Encounters;
/// <summary>
/// Read-only access to validated <see cref="EncounterDefRow"/> entries loaded at startup (<see cref="EncounterDefinitionCatalog"/>).
/// </summary>
/// <remarks>
/// <para><b>E5.M3 (encounter progress / completion):</b> callers validating encounter ids and resolving completion metadata should depend on this interface
/// rather than <see cref="EncounterDefinitionCatalog"/> so encounter defs stay centralized.</para>
/// <para><b>NEO-103:</b> HTTP/read-model projections should depend on this interface rather than reaching into the catalog.</para>
/// </remarks>
public interface IEncounterDefinitionRegistry
{
/// <summary>Attempts to resolve an encounter by stable <c>id</c> (see <c>encounter-def.schema.json</c>). Unknown ids and <c>null</c> return <c>false</c> without throwing.</summary>
bool TryGetDefinition(string? encounterId, [NotNullWhen(true)] out EncounterDefRow? definition);
/// <summary>Trims and lowercases <paramref name="rawEncounterId"/>; returns <c>true</c> when the normalized id exists in the loaded catalog. <c>null</c>, empty, and whitespace return <c>false</c> without throwing.</summary>
bool TryNormalizeKnown(string? rawEncounterId, [NotNullWhen(true)] out string normalized);
/// <summary>Every loaded definition, ordered by <see cref="EncounterDefRow.Id"/> (ordinal).</summary>
IReadOnlyList<EncounterDefRow> GetDefinitionsInIdOrder();
}

View File

@ -0,0 +1,23 @@
using System.Diagnostics.CodeAnalysis;
namespace NeonSprawl.Server.Game.Encounters;
/// <summary>
/// Read-only access to validated <see cref="RewardTableDefRow"/> entries loaded at startup (<see cref="RewardTableDefinitionCatalog"/>).
/// </summary>
/// <remarks>
/// <para><b>E5.M3 (encounter completion / loot grants):</b> callers validating reward-table ids and resolving fixed grants should depend on this interface
/// rather than <see cref="RewardTableDefinitionCatalog"/> so reward tables stay centralized.</para>
/// <para><b>NEO-103:</b> HTTP/read-model projections should depend on this interface rather than reaching into the catalog.</para>
/// </remarks>
public interface IRewardTableDefinitionRegistry
{
/// <summary>Attempts to resolve a reward table by stable <c>id</c> (see <c>reward-table.schema.json</c>). Unknown ids and <c>null</c> return <c>false</c> without throwing.</summary>
bool TryGetDefinition(string? rewardTableId, [NotNullWhen(true)] out RewardTableDefRow? definition);
/// <summary>Trims and lowercases <paramref name="rawRewardTableId"/>; returns <c>true</c> when the normalized id exists in the loaded catalog. <c>null</c>, empty, and whitespace return <c>false</c> without throwing.</summary>
bool TryNormalizeKnown(string? rawRewardTableId, [NotNullWhen(true)] out string normalized);
/// <summary>Every loaded definition, ordered by <see cref="RewardTableDefRow.Id"/> (ordinal).</summary>
IReadOnlyList<RewardTableDefRow> GetDefinitionsInIdOrder();
}

View File

@ -0,0 +1,55 @@
using System.Collections.Frozen;
using NeonSprawl.Server.Game.Npc;
namespace NeonSprawl.Server.Game.Encounters;
/// <summary>
/// Prototype E5M3 encounter roster + cross-ref gates (NEO-101), mirrored from
/// <c>scripts/validate_content.py</c> <c>PROTOTYPE_E5M3_ENCOUNTER_IDS</c>,
/// <c>PROTOTYPE_E5M2_NPC_INSTANCE_IDS</c>, <c>_prototype_e5m3_encounter_gate</c>, and
/// <c>_prototype_e5m3_encounter_cross_ref_gate</c>.
/// </summary>
public static class PrototypeE5M3EncounterCatalogRules
{
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E5M3_ENCOUNTER_IDS</c>.</summary>
public static readonly FrozenSet<string> ExpectedEncounterIds = FrozenSet.ToFrozenSet(
["prototype_combat_pocket"],
StringComparer.Ordinal);
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E5M2_NPC_INSTANCE_IDS</c>.</summary>
public static FrozenSet<string> ExpectedNpcInstanceIds =>
PrototypeNpcRegistry.GetInstanceIdsInOrder().ToFrozenSet(StringComparer.Ordinal);
/// <summary>Returns a human-readable error if the E5M3 encounter contract fails, otherwise <see langword="null"/>.</summary>
public static string? TryGetE5M3GateError(IReadOnlyDictionary<string, string> 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;
}
/// <summary>Returns a human-readable error when NPC instance cross-refs fail, otherwise <see langword="null"/>.</summary>
public static string? TryGetNpcInstanceCrossRefError(IReadOnlyDictionary<string, EncounterDefRow> 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;
}
}

View File

@ -0,0 +1,63 @@
using System.Collections.Frozen;
namespace NeonSprawl.Server.Game.Encounters;
/// <summary>
/// Prototype E5M3 reward-table roster + grant quantity gates (NEO-101), mirrored from
/// <c>scripts/validate_content.py</c> <c>PROTOTYPE_E5M3_REWARD_TABLE_IDS</c>,
/// <c>PROTOTYPE_E5M3_REWARD_GRANTS</c>, <c>_prototype_e5m3_reward_table_gate</c>, and
/// <c>_prototype_e5m3_reward_grant_content_gate</c>.
/// </summary>
public static class PrototypeE5M3RewardTableCatalogRules
{
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E5M3_REWARD_TABLE_IDS</c>.</summary>
public static readonly FrozenSet<string> ExpectedRewardTableIds = FrozenSet.ToFrozenSet(
["prototype_combat_pocket_clear"],
StringComparer.Ordinal);
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_E5M3_REWARD_GRANTS</c>.</summary>
public static readonly FrozenDictionary<string, int> ExpectedGrantQuantities =
FrozenDictionary.ToFrozenDictionary(
new Dictionary<string, int>(StringComparer.Ordinal)
{
["scrap_metal_bulk"] = 10,
["contract_handoff_token"] = 1,
},
StringComparer.Ordinal);
/// <summary>Returns a human-readable error if the E5M3 reward-table contract fails, otherwise <see langword="null"/>.</summary>
public static string? TryGetE5M3GateError(IReadOnlyDictionary<string, string> 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;
}
/// <summary>Returns a human-readable error when frozen grant quantities fail, otherwise <see langword="null"/>.</summary>
public static string? TryGetGrantContentGateError(IReadOnlyDictionary<string, RewardTableDefRow> 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;
}
}

View File

@ -0,0 +1,4 @@
namespace NeonSprawl.Server.Game.Encounters;
/// <summary>A single deterministic item grant on a <see cref="RewardTableDefRow"/> (NEO-101).</summary>
public readonly record struct RewardGrantRow(string ItemId, int Quantity);

View File

@ -0,0 +1,78 @@
namespace NeonSprawl.Server.Game.Encounters;
/// <summary>Resolves reward-table catalog paths for local dev, tests, and container layouts (NEO-101).</summary>
public static class RewardTableCatalogPathResolution
{
/// <summary>Walks <paramref name="startDirectory"/> and parents for an existing <c>content/reward-tables</c> directory.</summary>
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;
}
/// <summary>
/// Resolves the reward-tables catalog directory.
/// Empty <paramref name="configuredRewardTablesDirectory"/> triggers discovery from <see cref="AppContext.BaseDirectory"/>.
/// </summary>
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));
}
/// <summary>Resolves JSON Schema path for a single reward-table row (Draft 2020-12).</summary>
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"));
}
/// <summary>Resolves JSON Schema path for a reward grant row referenced by reward-table schema (Draft 2020-12).</summary>
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"));
}
}

View File

@ -0,0 +1,14 @@
namespace NeonSprawl.Server.Game.Encounters;
/// <summary>Load-time reward table row (NEO-101).</summary>
public sealed class RewardTableDefRow(
string id,
string displayName,
IReadOnlyList<RewardGrantRow> fixedGrants)
{
public string Id { get; } = id;
public string DisplayName { get; } = displayName;
public IReadOnlyList<RewardGrantRow> FixedGrants { get; } = fixedGrants;
}

View File

@ -0,0 +1,25 @@
using System.Collections.ObjectModel;
namespace NeonSprawl.Server.Game.Encounters;
/// <summary>In-memory reward-table catalog loaded at startup (NEO-101). Game callers should use <see cref="IRewardTableDefinitionRegistry"/> (NEO-102).</summary>
public sealed class RewardTableDefinitionCatalog(
string rewardTablesDirectory,
IReadOnlyDictionary<string, RewardTableDefRow> byId,
int catalogJsonFileCount)
{
/// <summary>Absolute path to the directory that was enumerated for <c>*_reward_tables.json</c> catalogs.</summary>
public string RewardTablesDirectory { get; } = rewardTablesDirectory;
public IReadOnlyDictionary<string, RewardTableDefRow> ById { get; } =
new ReadOnlyDictionary<string, RewardTableDefRow>(new Dictionary<string, RewardTableDefRow>(byId, StringComparer.Ordinal));
public int DistinctRewardTableCount => ById.Count;
/// <summary>Number of <c>*_reward_tables.json</c> files under <see cref="RewardTablesDirectory"/>.</summary>
public int CatalogJsonFileCount { get; } = catalogJsonFileCount;
/// <summary>Resolves a catalog row by stable reward-table <paramref name="id"/>.</summary>
public bool TryGetRewardTable(string id, out RewardTableDefRow? row) =>
ById.TryGetValue(id, out row);
}

View File

@ -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;
/// <summary>Loads and validates <c>content/reward-tables/*_reward_tables.json</c> using the same rules as <c>scripts/validate_content.py</c> (NEO-101).</summary>
public static class RewardTableDefinitionCatalogLoader
{
/// <summary>Loads catalogs from disk or throws <see cref="InvalidOperationException"/> with actionable messages.</summary>
public static RewardTableDefinitionCatalog Load(
string rewardTablesDirectory,
string rewardTableSchemaPath,
string rewardGrantRowSchemaPath,
IReadOnlySet<string> knownItemIds,
ILogger logger)
{
rewardTablesDirectory = Path.GetFullPath(rewardTablesDirectory);
rewardTableSchemaPath = Path.GetFullPath(rewardTableSchemaPath);
rewardGrantRowSchemaPath = Path.GetFullPath(rewardGrantRowSchemaPath);
var errors = new List<string>();
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<string, string>(StringComparer.Ordinal);
var rows = new Dictionary<string, RewardTableDefRow>(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<int>(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<string>();
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<string> knownItemIds,
List<string> errors)
{
if (rowObj["fixedGrants"] is not JsonArray grantsArray)
return;
var seenGrantItemIds = new HashSet<string>(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<string>();
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<string>();
var displayName = (rowObj["displayName"] as JsonValue)!.GetValue<string>();
var fixedGrants = ParseGrantRows(rowObj["fixedGrants"] as JsonArray);
return new RewardTableDefRow(id, displayName, fixedGrants);
}
private static IReadOnlyList<RewardGrantRow> ParseGrantRows(JsonArray? array)
{
if (array is null)
return [];
var rows = new List<RewardGrantRow>(array.Count);
foreach (var node in array)
{
if (node is not JsonObject grantObj)
continue;
var itemId = (grantObj["itemId"] as JsonValue)!.GetValue<string>();
var quantity = (grantObj["quantity"] as JsonValue)!.GetValue<int>();
rows.Add(new RewardGrantRow(itemId, quantity));
}
return rows;
}
private static List<string> CollectSchemaMessages(EvaluationResults eval, string filePath, int index)
{
var sink = new List<string>();
AppendSchemaMessages(eval, filePath, index, sink);
return sink;
}
private static void AppendSchemaMessages(EvaluationResults r, string filePath, int index, List<string> 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<string> 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());
}
}

View File

@ -0,0 +1,55 @@
using System.Diagnostics.CodeAnalysis;
namespace NeonSprawl.Server.Game.Encounters;
/// <summary>Adapter over <see cref="RewardTableDefinitionCatalog"/> (NEO-102).</summary>
public sealed class RewardTableDefinitionRegistry(RewardTableDefinitionCatalog catalog) : IRewardTableDefinitionRegistry
{
private readonly IReadOnlyList<RewardTableDefRow> _definitionsInIdOrder = BuildDefinitionsInIdOrder(catalog);
/// <inheritdoc />
public bool TryGetDefinition(string? rewardTableId, [NotNullWhen(true)] out RewardTableDefRow? definition)
{
if (rewardTableId is null)
{
definition = null;
return false;
}
return catalog.TryGetRewardTable(rewardTableId, out definition);
}
/// <inheritdoc />
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 _);
}
/// <inheritdoc />
public IReadOnlyList<RewardTableDefRow> GetDefinitionsInIdOrder() => _definitionsInIdOrder;
private static IReadOnlyList<RewardTableDefRow> BuildDefinitionsInIdOrder(RewardTableDefinitionCatalog catalog)
{
var ids = catalog.ById.Keys.OrderBy(k => k, StringComparer.Ordinal).ToArray();
var list = new List<RewardTableDefRow>(ids.Length);
foreach (var id in ids)
{
list.Add(catalog.ById[id]);
}
return list;
}
}

View File

@ -100,4 +100,34 @@ public sealed class ContentPathsOptions
/// When unset, resolved as <c>{parent of npc-behaviors directory}/schemas/npc-behavior-def.schema.json</c>.
/// </summary>
public string? NpcBehaviorDefSchemaPath { get; set; }
/// <summary>
/// Optional. Absolute path, or path relative to <see cref="Microsoft.Extensions.Hosting.IHostEnvironment.ContentRootPath"/>.
/// When unset, the host walks ancestors of <see cref="AppContext.BaseDirectory"/> for a <c>content/reward-tables</c> directory.
/// </summary>
public string? RewardTablesDirectory { get; set; }
/// <summary>
/// Optional override for <c>reward-table.schema.json</c>.
/// When unset, resolved as <c>{parent of reward-tables directory}/schemas/reward-table.schema.json</c>.
/// </summary>
public string? RewardTableDefSchemaPath { get; set; }
/// <summary>
/// Optional override for <c>reward-grant-row.schema.json</c>.
/// When unset, resolved as <c>{parent of reward-tables directory}/schemas/reward-grant-row.schema.json</c>.
/// </summary>
public string? RewardGrantRowSchemaPath { get; set; }
/// <summary>
/// Optional. Absolute path, or path relative to <see cref="Microsoft.Extensions.Hosting.IHostEnvironment.ContentRootPath"/>.
/// When unset, the host walks ancestors of <see cref="AppContext.BaseDirectory"/> for a <c>content/encounters</c> directory.
/// </summary>
public string? EncountersDirectory { get; set; }
/// <summary>
/// Optional override for <c>encounter-def.schema.json</c>.
/// When unset, resolved as <c>{parent of encounters directory}/schemas/encounter-def.schema.json</c>.
/// </summary>
public string? EncounterDefSchemaPath { get; set; }
}

View File

@ -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<ResourceNodeCatalog>();
_ = app.Services.GetRequiredService<RecipeDefinitionCatalog>();
_ = app.Services.GetRequiredService<AbilityDefinitionCatalog>();
_ = app.Services.GetRequiredService<NpcBehaviorDefinitionCatalog>();
_ = app.Services.GetRequiredService<RewardTableDefinitionCatalog>();
_ = app.Services.GetRequiredService<EncounterDefinitionCatalog>();
_ = app.Services.GetRequiredService<MasteryCatalog>();
_ = app.Services.GetRequiredService<ISkillLevelCurve>();

View File

@ -17,7 +17,12 @@
"AbilitiesDirectory": "",
"AbilityDefSchemaPath": "",
"NpcBehaviorsDirectory": "",
"NpcBehaviorDefSchemaPath": ""
"NpcBehaviorDefSchemaPath": "",
"RewardTablesDirectory": "",
"RewardTableDefSchemaPath": "",
"RewardGrantRowSchemaPath": "",
"EncountersDirectory": "",
"EncounterDefSchemaPath": ""
},
"Game": {
"DevPlayerId": "dev-local-1",

View File

@ -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. 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)
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. 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)
**`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/`.