From 2de699b7bdd4031794db94d39e364104cac4ed21 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 24 May 2026 13:58:44 -0400 Subject: [PATCH] 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. --- docs/plans/NEO-58-implementation-plan.md | 119 +++++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 docs/plans/NEO-58-implementation-plan.md diff --git a/docs/plans/NEO-58-implementation-plan.md b/docs/plans/NEO-58-implementation-plan.md new file mode 100644 index 0000000..ab0f076 --- /dev/null +++ b/docs/plans/NEO-58-implementation-plan.md @@ -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`, 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.