# 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 - [x] 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`). - [x] `ResourceNodeCatalog` resolves node def and yield row by `nodeDefId` (e.g. `prototype_resource_node_alpha` → lens + `maxGathers` + yield quantity). - [x] Unit tests (AAA): loader happy path + failure modes; host resolves catalog on success; host fails startup on invalid catalog directory. - [x] 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`, calls loader. 7. **`Program.cs`** — `AddResourceNodeCatalog(configuration)` after `AddItemDefinitionCatalog`; after `Build()`, `GetRequiredService()` 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.