neon-sprawl/docs/plans/NEO-101-implementation-plan.md

147 lines
14 KiB
Markdown

# 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** |