NEO-58: add implementation plan for resource-node catalog load

Kickoff plan for E3M1-02: fail-fast server loader mirroring NEO-51/NEO-57,
stacked on NEO-57, catalog-only DI until NEO-59 registry story.
pull/93/head
VinPropane 2026-05-24 13:58:44 -04:00
parent 8c1e0552b3
commit 2de699b7bd
1 changed files with 119 additions and 0 deletions

View File

@ -0,0 +1,119 @@
# NEO-58 — Implementation plan
## Story reference
| Field | Value |
|--------|--------|
| **Key** | NEO-58 |
| **Title** | E3.M1: Server resource-node catalog load (fail-fast) |
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-58/e3m1-server-resource-node-catalog-load-fail-fast |
| **Module** | [E3.M1 — ResourceNodeAndGatherLoop](../decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md) · Epic 3 Slice 2 · backlog **E3M1-02** |
| **Branch** | `NEO-58-e3m1-server-resource-node-catalog-load-fail-fast` (stacked on `NEO-57-e3m1-prototype-resourcenodedef-yield-schemas-ci`) |
| **Precursor** | [NEO-57](https://linear.app/neon-sprawl/issue/NEO-57) — `content/resource-nodes/` + CI gates (**on NEO-57 branch**; merge before or with stack) |
| **Pattern** | [NEO-51](https://linear.app/neon-sprawl/issue/NEO-51) — fail-fast catalog loader + `ContentPathsOptions` + eager `Program.cs` resolve; strict split before registry ticket ([NEO-59](https://linear.app/neon-sprawl/issue/NEO-59)) |
## Kickoff clarifications
| Topic | Question | Agent recommendation | Answer |
|--------|----------|----------------------|--------|
| **Branch base** | Stack on NEO-57 vs branch from `main`? | **Branch from NEO-57** — Linear `blockedBy` NEO-57; loader requires content + schemas from E3M1-01. | **User:** branch from NEO-57 (stacked PR). |
| **DI scope (E3M1-02 vs E3M1-03)** | Register `IResourceNodeDefinitionRegistry` now? | **`ResourceNodeCatalog` singleton only** — E3M1-03 ([NEO-59](https://linear.app/neon-sprawl/issue/NEO-59)) owns injectable registry; mirrors NEO-51 / NEO-52 split for items. | **User:** catalog singleton only. |
| **Item cross-check** | How to validate yield `itemId` at startup? | **Pass ids from `ItemDefinitionCatalog`** in the DI factory (items load first; no second disk read). | **User:** cross-check from `ItemDefinitionCatalog`. |
| **Runtime validation** | In-process C# vs subprocess `validate_content.py`? | **In-process C#** mirroring `scripts/validate_content.py` (NEO-51 / NEO-34 precedent). | **Adopted** — no separate question. |
| **Host boot tests** | Loader unit tests only vs also `WebApplicationFactory`? | **Both** — mirror `ItemDefinitionCatalogLoaderTests` (happy path, failure modes, `Host_ShouldResolveCatalogFromDi`, `Host_ShouldFailStartup_WhenCatalogDirectoryInvalid`). | **Adopted** — NEO-51 precedent. |
## Goal, scope, and out-of-scope
**Goal:** On host startup, load `content/resource-nodes/*_resource_nodes.json` and `*_resource_yields.json` with the same validation rules as CI ([NEO-57](NEO-57-implementation-plan.md)), build an in-memory **`ResourceNodeCatalog`**, cross-check yield **`itemId`** values against the already-loaded item catalog, and **refuse to listen** when any file is missing, malformed, or fails the prototype Slice 2 gate.
**In scope (from Linear + [E3M1-02](E3M1-prototype-backlog.md#e3m1-02--server-resource-node-catalog-load-fail-fast)):**
- Loader + catalog types under `server/NeonSprawl.Server/Game/Gathering/`.
- Configurable resource-nodes directory and row schema paths (`ContentPathsOptions` extension).
- CI-parity validation: `schemaVersion`, row schemas, duplicate `nodeDefId`, frozen four ids, four `gatherLens`, one yield per node, yield `nodeDefId` exists in nodes, yield `itemId` in item catalog + Slice 2 `scrap_metal_bulk`-only gate.
- DI registration of **`ResourceNodeCatalog`** singleton (factory depends on `ItemDefinitionCatalog`); eager resolve in `Program.cs` after item catalog.
- Unit + host startup tests (AAA).
**Out of scope (from Linear):**
- **`IResourceNodeDefinitionRegistry`** / `ResourceNodeDefinitionRegistry` (**NEO-59**, E3M1-03).
- Per-instance depletion, HTTP (`GET …/resource-node-definitions` is **NEO-60**), interact wiring, Bruno, client.
## Acceptance criteria checklist
- [ ] Server refuses boot when resource-node catalog invalid (missing dir, no `*_resource_nodes.json` / `*_resource_yields.json`, bad JSON, `schemaVersion` ≠ 1, schema violation, duplicate `nodeDefId`, Slice 2 gate failure, unknown yield `itemId`, orphan yield `nodeDefId`).
- [ ] `ResourceNodeCatalog` resolves node def and yield row by `nodeDefId` (e.g. `prototype_resource_node_alpha` → lens + `maxGathers` + yield quantity).
- [ ] Unit tests (AAA): loader happy path + failure modes; host resolves catalog on success; host fails startup on invalid catalog directory.
- [ ] Success log includes **node count**, **yield count**, **catalog directory**, and **file counts** (Information).
## Technical approach
1. **`Game/Gathering/` row DTOs** — `ResourceNodeDefRow` (`NodeDefId`, `DisplayName`, `GatherLens`, `MaxGathers`); `ResourceYieldRow` (`NodeDefId`, `ItemId`, `Quantity`).
2. **`ResourceNodeCatalogLoader.Load(resourceNodesDirectory, nodeSchemaPath, yieldSchemaPath, itemIds, logger)`** — two-pass load mirroring `validate_content.py`:
- Enumerate `*_resource_nodes.json` then `*_resource_yields.json` (top-level only), ordered by path.
- **Nodes pass:** per file `schemaVersion == 1`, top-level `nodes` array; per row validate `resource-node-def.schema.json`; track `nodeDefId`, `gatherLens`; reject duplicate `nodeDefId` across files.
- Run **`PrototypeSlice2ResourceNodeCatalogRules.TryGetSlice2NodeGateError`** (mirror `PROTOTYPE_SLICE2_NODE_IDS`, `PROTOTYPE_SLICE2_GATHER_LENSES`).
- **Yields pass:** per file `schemaVersion == 1`, top-level `yields` array; per row validate `resource-yield-row.schema.json`; reject duplicate yield `nodeDefId`; require yield `nodeDefId` in loaded nodes; require `itemId``itemIds`; run Slice 2 yield item gate (`scrap_metal_bulk` only); require exactly one yield per frozen `nodeDefId`.
- Throw **`InvalidOperationException`** with aggregated messages prefixed `Resource node catalog validation failed:`.
3. **`ResourceNodeCatalog`** — immutable snapshot: `ResourceNodesDirectory`, `NodesById`, `YieldsByNodeDefId`, counts; `TryGetNode(nodeDefId, out ResourceNodeDefRow?)`, `TryGetYield(nodeDefId, out ResourceYieldRow?)`.
4. **`ResourceNodeCatalogPathResolution`** — `TryDiscoverResourceNodesDirectory`, `ResolveResourceNodesDirectory`, `ResolveResourceNodeDefSchemaPath`, `ResolveResourceYieldRowSchemaPath` (parent `content/schemas/` layout when unset).
5. **`PrototypeSlice2ResourceNodeCatalogRules`** — frozen four `nodeDefId`s, four `gatherLens`, yield item allowlist; sync comments to `scripts/validate_content.py` `PROTOTYPE_SLICE2_*`.
6. **`ResourceNodeCatalogServiceCollectionExtensions.AddResourceNodeCatalog`** — bind `ContentPathsOptions`; register **`ResourceNodeCatalog`** singleton factory that resolves paths, gets `ItemDefinitionCatalog.ById.Keys` as `FrozenSet<string>`, calls loader.
7. **`Program.cs`** — `AddResourceNodeCatalog(configuration)` after `AddItemDefinitionCatalog`; after `Build()`, `GetRequiredService<ResourceNodeCatalog>()` alongside existing catalog eager resolves.
8. **Tests** — temp-dir fixtures copying repo schemas; valid four-node + four-yield JSON matching [prototype_resource_nodes.json](../../content/resource-nodes/prototype_resource_nodes.json); six-item item id set stub for cross-check; negative cases (duplicate id, missing yield, bad lens set, unknown `itemId`, invalid directory); `InMemoryWebApplicationFactory` / `WebApplicationFactory` host tests mirroring items.
## Files to add
| Path | Purpose |
|------|---------|
| `server/NeonSprawl.Server/Game/Gathering/ResourceNodeDefRow.cs` | Load-time node row DTO. |
| `server/NeonSprawl.Server/Game/Gathering/ResourceYieldRow.cs` | Load-time yield row DTO. |
| `server/NeonSprawl.Server/Game/Gathering/ResourceNodeCatalog.cs` | In-memory catalog + `TryGetNode` / `TryGetYield`. |
| `server/NeonSprawl.Server/Game/Gathering/ResourceNodeCatalogLoader.cs` | Disk I/O, schema validation, Slice 2 gates, item cross-check. |
| `server/NeonSprawl.Server/Game/Gathering/ResourceNodeCatalogPathResolution.cs` | Directory/schema discovery and config resolution. |
| `server/NeonSprawl.Server/Game/Gathering/PrototypeSlice2ResourceNodeCatalogRules.cs` | Frozen four ids / four lenses / yield item gate (sync comment to Python). |
| `server/NeonSprawl.Server/Game/Gathering/ResourceNodeCatalogServiceCollectionExtensions.cs` | `AddResourceNodeCatalog` DI registration. |
| `server/NeonSprawl.Server.Tests/Game/Gathering/ResourceNodeCatalogTestPaths.cs` | Repo `content/resource-nodes` + schema discovery for tests. |
| `server/NeonSprawl.Server.Tests/Game/Gathering/ResourceNodeCatalogLoaderTests.cs` | AAA loader + host startup tests. |
## Files to modify
| Path | Rationale |
|------|-----------|
| `server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs` | Add `ResourceNodesDirectory`, `ResourceNodeDefSchemaPath`, `ResourceYieldRowSchemaPath` under `Content` section. |
| `server/NeonSprawl.Server/Program.cs` | Register and eagerly resolve `ResourceNodeCatalog` at startup (after item catalog). |
| `server/README.md` | Document resource-node catalog load, config keys, fail-fast behavior, Slice 2 parity with CI. |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E3.M1 / NEO-58 server loader status when implementation completes. |
## Tests
| Item | Coverage |
|------|----------|
| `server/NeonSprawl.Server.Tests/Game/Gathering/ResourceNodeCatalogLoaderTests.cs` | **Add** — AAA: `Load` happy path with repo-shaped JSON; failures for missing dir/schema, bad `schemaVersion`, schema violations, duplicate `nodeDefId`, incomplete Slice 2 ids, lens set mismatch, orphan yield `nodeDefId`, unknown `itemId`, duplicate yield `nodeDefId`, missing yield for node; `Host_ShouldResolveCatalogFromDi`; `Host_ShouldFailStartup_WhenCatalogDirectoryInvalid`. |
| `server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs` | **Change** if needed — ensure resource-node content is discoverable in test host (same ancestor walk as items). |
| **CI / PR gate** | Existing `scripts/validate_content.py` remains content regression; server tests guard C#/Python drift for Slice 2 constants. |
No Bruno or client tests (out of scope).
## Open questions / risks
| Question / risk | Agent recommendation | Status |
|-----------------|----------------------|--------|
| **Stacked PR on NEO-57** | Merge NEO-57 first or land stack; rebase NEO-58 onto `main` after NEO-57 merges. | **adopted** |
| **C# vs Python drift** | Duplicate `PROTOTYPE_SLICE2_*` in `PrototypeSlice2ResourceNodeCatalogRules` with “keep in sync” comment (NEO-50 / NEO-57 pattern). | **adopted** |
| **Test factories without NEO-57 on main** | NEO-58 branch includes NEO-57 content via stack; after NEO-57 merges, rebase drops dependency. | **adopted** |
| **Registry consumers** | Gather engine / HTTP use `IResourceNodeDefinitionRegistry` in NEO-59+; this story only exposes `ResourceNodeCatalog` for boot + direct test lookup. | **adopted** |
## Decisions (kickoff)
- **Branch from NEO-57** for stacked PR until E3M1-01 merges.
- **`ResourceNodeCatalog` singleton only** — defer `IResourceNodeDefinitionRegistry` to NEO-59.
- **Yield `itemId` cross-check** uses `ItemDefinitionCatalog` keys passed into loader at DI registration time.
- **In-process validation** + **loader and host boot tests** per NEO-51 precedent.