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

11 KiB

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 · Epic 3 Slice 2 · backlog E3M1-02
Branch NEO-58-e3m1-server-resource-node-catalog-load-fail-fast (rebased on main after NEO-57 merge PR #92)
Precursor NEO-57content/resource-nodes/ + CI gates (merged to main via PR #92)
Pattern NEO-51 — fail-fast catalog loader + ContentPathsOptions + eager Program.cs resolve; strict split before registry ticket (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) 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), 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):

  • 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 DTOsResourceNodeDefRow (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 itemIditemIds; 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. ResourceNodeCatalogPathResolutionTryDiscoverResourceNodesDirectory, ResolveResourceNodesDirectory, ResolveResourceNodeDefSchemaPath, ResolveResourceYieldRowSchemaPath (parent content/schemas/ layout when unset).

  5. PrototypeSlice2ResourceNodeCatalogRules — frozen four nodeDefIds, 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.csAddResourceNodeCatalog(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; 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, empty *_resource_nodes.json / *_resource_yields.json directories; 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.
Bruno smoke (optional) bruno/neon-sprawl-server/resource-node-catalog/Health after resource node catalog load.bruGET /health after boot; no resource-node HTTP API this story (pre-commit hook when Program.cs changes).

No client tests (out of scope).

Open questions / risks

Question / risk Agent recommendation Status
Stacked PR on NEO-57 NEO-57 merged (PR #92); NEO-58 rebased onto main. 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 initial stacked PR; rebased onto main after NEO-57 merged (PR #92).
  • 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.