From 0384b63046373dd7e10583541baea0923041b51d Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 24 May 2026 14:01:09 -0400 Subject: [PATCH] NEO-58: add fail-fast resource-node catalog load at startup Loader, Slice 2 gates, DI registration, AAA tests, README and plan updates. Cross-checks yield itemIds against ItemDefinitionCatalog loaded first. --- ...ealth after resource node catalog load.bru | 25 + .../resource-node-catalog/folder.bru | 3 + ...umentation_and_implementation_alignment.md | 2 +- docs/plans/E3M1-prototype-backlog.md | 6 +- docs/plans/NEO-58-implementation-plan.md | 8 +- .../ResourceNodeCatalogLoaderTests.cs | 551 ++++++++++++++++++ .../Gathering/ResourceNodeCatalogTestPaths.cs | 23 + .../PostgresWebApplicationFactory.cs | 5 + .../InMemoryWebApplicationFactory.cs | 5 + ...PrototypeSlice2ResourceNodeCatalogRules.cs | 82 +++ .../Game/Gathering/ResourceNodeCatalog.cs | 39 ++ .../Gathering/ResourceNodeCatalogLoader.cs | 337 +++++++++++ .../ResourceNodeCatalogPathResolution.cs | 78 +++ ...eNodeCatalogServiceCollectionExtensions.cs | 50 ++ .../Game/Gathering/ResourceNodeDefRow.cs | 8 + .../Game/Gathering/ResourceYieldRow.cs | 7 + .../Game/Skills/ContentPathsOptions.cs | 18 + server/NeonSprawl.Server/Program.cs | 3 + server/README.md | 14 + 19 files changed, 1257 insertions(+), 7 deletions(-) create mode 100644 bruno/neon-sprawl-server/resource-node-catalog/Health after resource node catalog load.bru create mode 100644 bruno/neon-sprawl-server/resource-node-catalog/folder.bru create mode 100644 server/NeonSprawl.Server.Tests/Game/Gathering/ResourceNodeCatalogLoaderTests.cs create mode 100644 server/NeonSprawl.Server.Tests/Game/Gathering/ResourceNodeCatalogTestPaths.cs create mode 100644 server/NeonSprawl.Server/Game/Gathering/PrototypeSlice2ResourceNodeCatalogRules.cs create mode 100644 server/NeonSprawl.Server/Game/Gathering/ResourceNodeCatalog.cs create mode 100644 server/NeonSprawl.Server/Game/Gathering/ResourceNodeCatalogLoader.cs create mode 100644 server/NeonSprawl.Server/Game/Gathering/ResourceNodeCatalogPathResolution.cs create mode 100644 server/NeonSprawl.Server/Game/Gathering/ResourceNodeCatalogServiceCollectionExtensions.cs create mode 100644 server/NeonSprawl.Server/Game/Gathering/ResourceNodeDefRow.cs create mode 100644 server/NeonSprawl.Server/Game/Gathering/ResourceYieldRow.cs diff --git a/bruno/neon-sprawl-server/resource-node-catalog/Health after resource node catalog load.bru b/bruno/neon-sprawl-server/resource-node-catalog/Health after resource node catalog load.bru new file mode 100644 index 0000000..f44484a --- /dev/null +++ b/bruno/neon-sprawl-server/resource-node-catalog/Health after resource node catalog load.bru @@ -0,0 +1,25 @@ +meta { + name: GET health (resource-node catalog boot NEO-58) + type: http + seq: 1 +} + +get { + url: {{baseUrl}}/health + body: none + auth: none +} + +docs { + NEO-58 loads content/resource-nodes/*_resource_nodes.json and *_resource_yields.json at startup (fail-fast, Slice 2 gates, yield item cross-check against item catalog). No resource-node HTTP API in this story — use this request to confirm the host started after catalog validation. +} + +tests { + test("status 200", function () { + expect(res.getStatus()).to.equal(200); + }); + + test("service identity", function () { + expect(res.getBody().service).to.equal("NeonSprawl.Server"); + }); +} diff --git a/bruno/neon-sprawl-server/resource-node-catalog/folder.bru b/bruno/neon-sprawl-server/resource-node-catalog/folder.bru new file mode 100644 index 0000000..b49ceb1 --- /dev/null +++ b/bruno/neon-sprawl-server/resource-node-catalog/folder.bru @@ -0,0 +1,3 @@ +meta { + name: resource-node-catalog +} diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md index 8b0bbf0..42ba4df 100644 --- a/docs/decomposition/modules/documentation_and_implementation_alignment.md +++ b/docs/decomposition/modules/documentation_and_implementation_alignment.md @@ -53,7 +53,7 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not | E2.M1 | In Progress | **NEO-33 landed:** frozen prototype trio **`salvage`** / **`refine`** / **`intrusion`** in [`content/skills/prototype_skills.json`](../../../content/skills/prototype_skills.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) enforce schema, duplicate `id`, exact trio ids, and category coverage; designer note on **`allowedXpSourceKinds`** in [E2_M1](E2_M1_SkillDefinitionRegistry.md) + [`content/README.md`](../../../content/README.md). **NEO-34 landed:** fail-fast server load of `content/skills/*.json` at startup — `server/NeonSprawl.Server/Game/Skills/` ([NEO-34](../../plans/NEO-34-implementation-plan.md)); config + discovery in [server README — Skill catalog](../../../server/README.md#skill-catalog-contentskills-neo-34). **NEO-35 landed:** `ISkillDefinitionRegistry` / `SkillDefinitionRegistry` + DI ([NEO-35](../../plans/NEO-35-implementation-plan.md)). **NEO-36 landed:** versioned **`GET /game/world/skill-definitions`** ([NEO-36](../../plans/NEO-36-implementation-plan.md)) — `SkillDefinitionsWorldApi` + `SkillDefinitionsListDtos` in `server/NeonSprawl.Server/Game/Skills/`; Bruno `bruno/neon-sprawl-server/skill-definitions/`; manual QA [`NEO-36`](../../manual-qa/NEO-36.md). **E2.M2 consumer (NEO-38 landed):** grant path validates `skillId` + `sourceKind` vs catalog **`allowedXpSourceKinds`** on **`POST …/skill-progression`** ([NEO-38](../../plans/NEO-38-implementation-plan.md)); see [server README — Skill progression grant](../../../server/README.md#skill-progression-grant-neo-38) and [E2_M2](E2_M2_XpAwardAndLevelEngine.md). | [NEO-33](../../plans/NEO-33-implementation-plan.md), [NEO-34](../../plans/NEO-34-implementation-plan.md), [NEO-35](../../plans/NEO-35-implementation-plan.md), [NEO-36](../../plans/NEO-36-implementation-plan.md); [E2_M1](E2_M1_SkillDefinitionRegistry.md); [module_dependency_register.md](module_dependency_register.md) **E2.M1 note**; `server/NeonSprawl.Server/Game/Skills/`; [`docs/manual-qa/NEO-36.md`](../../manual-qa/NEO-36.md); [server README — Skill definitions (NEO-36)](../../../server/README.md#skill-definitions-neo-36) | | E2.M3 | In Progress | **NEO-45 landed:** prototype **`salvage`** mastery catalog + CI gates (see [NEO-45 plan](../../plans/NEO-45-implementation-plan.md)). **NEO-46 landed:** fail-fast server load under `server/NeonSprawl.Server/Game/Mastery/` — `MasteryCatalogLoader`, `IMasteryCatalogRegistry`, cross-check vs `ISkillDefinitionRegistry`, Slice 4 + **`tierIndex`** 1..N gate; see [NEO-46 plan](../../plans/NEO-46-implementation-plan.md). **NEO-47 landed:** `PerkUnlockEngine`, `IPlayerPerkStateStore` + **`V004`**, level-up hook in skill XP grants; see [NEO-47 plan](../../plans/NEO-47-implementation-plan.md). **NEO-49 landed:** comment-only **`perk_unlock`** telemetry hook site in [`PerkUnlockEngine.TryUnlockPerks`](../../../server/NeonSprawl.Server/Game/Mastery/PerkUnlockEngine.cs) ([NEO-49 plan](../../plans/NEO-49-implementation-plan.md), [`NEO-49` manual QA](../../manual-qa/NEO-49.md)); [server README — Perk unlock telemetry (NEO-49)](../../../server/README.md#perk-unlock-engine-and-telemetry-hooks-neo-47-neo-49). **NEO-48 landed:** **`GET`/`POST /game/players/{id}/perk-state`** — `PerkStateApi` + DTOs in `Game/Mastery/` ([NEO-48](../../plans/NEO-48-implementation-plan.md), [`NEO-48` manual QA](../../manual-qa/NEO-48.md)); [server README — Perk state (NEO-48)](../../../server/README.md#perk-state-neo-48); Bruno `bruno/neon-sprawl-server/perk-state/`. | [NEO-45](../../plans/NEO-45-implementation-plan.md), [NEO-46](../../plans/NEO-46-implementation-plan.md), [NEO-47](../../plans/NEO-47-implementation-plan.md), [NEO-48](../../plans/NEO-48-implementation-plan.md), [NEO-49](../../plans/NEO-49-implementation-plan.md), [E2M3-pre-production-backlog](../../plans/E2M3-pre-production-backlog.md), [E2_M3](E2_M3_MasteryAndPerkUnlocks.md) | | E2.M2 | In Progress | **NEO-37 landed:** versioned **`GET /game/players/{id}/skill-progression`** ([NEO-37](../../plans/NEO-37-implementation-plan.md)) — read model for every registered skill; known-player gate via `IPositionStateStore`; [server README — Skill progression snapshot (NEO-37)](../../../server/README.md#skill-progression-snapshot-neo-37); manual QA [`NEO-37`](../../manual-qa/NEO-37.md). **NEO-38 landed:** **`POST`** same path — grant apply, persistence (`IPlayerSkillProgressionStore`, `V003` migration), structured denies + **`levelUps`** ([NEO-38](../../plans/NEO-38-implementation-plan.md)); manual QA [`NEO-38`](../../manual-qa/NEO-38.md); Bruno `bruno/neon-sprawl-server/skill-progression/`; [server README — Skill progression grant (NEO-38)](../../../server/README.md#skill-progression-grant-neo-38). **NEO-39 landed:** data-driven `LevelCurve` content + schema + CI validation (`*_level_curve.json`) with fail-fast startup schema checks; progression GET/POST level resolution now uses `ISkillLevelCurve` content-backed thresholds ([NEO-39](../../plans/NEO-39-implementation-plan.md)); manual QA [`NEO-39`](../../manual-qa/NEO-39.md). **NEO-40 landed:** comment-only telemetry hook sites in [`SkillProgressionSnapshotApi.cs`](../../../server/NeonSprawl.Server/Game/Skills/SkillProgressionSnapshotApi.cs) on **`POST …/skill-progression`** for future **`xp_grant`** / **`level_up`** ([NEO-40](../../plans/NEO-40-implementation-plan.md), [`NEO-40` manual QA](../../manual-qa/NEO-40.md)). **NEO-41 landed:** gather prototype — **`POST …/interact`** with **`kind: resource_node`** grants **`salvage`** + **`activity`** (10 XP) via [`SkillProgressionGrantOperations`](../../../server/NeonSprawl.Server/Game/Skills/SkillProgressionGrantOperations.cs) ([NEO-41](../../plans/NEO-41-implementation-plan.md), [`NEO-41` manual QA](../../manual-qa/NEO-41.md)); [server README — Interaction](../../../server/README.md#interaction-neo-9). **NEO-42 landed (prep):** **`RefineActivitySkillXpGrant`** / **`RefineSkillXpConstants`** + deny-registry factory + tests — same NEO-38 grant stack; **E3.M2** must invoke on craft/refine success ([NEO-42](../../plans/NEO-42-implementation-plan.md), [`NEO-42` manual QA](../../manual-qa/NEO-42.md)); [server README — Craft / refine hook (NEO-42)](../../../server/README.md#craft--refine-hook--skill-xp-neo-42). **NEO-43 landed (prep):** **`MissionRewardSkillXpGrant`** / **`MissionRewardSkillXpConstants`** + deny-registry factory + tests — same NEO-38 grant stack with fixed **`sourceKind: mission_reward`**; **E7.M2** must invoke from quest hand-in ([NEO-43](../../plans/NEO-43-implementation-plan.md), [`NEO-43` manual QA](../../manual-qa/NEO-43.md)); [server README — Mission / quest reward (NEO-43)](../../../server/README.md#mission--quest-reward--skill-xp-neo-43). **Slice 3 still open:** [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44). See [epic_02 — E2.M2 + Slice 3](../epics/epic_02_skills_and_progression.md). | [NEO-37](../../plans/NEO-37-implementation-plan.md), [NEO-38](../../plans/NEO-38-implementation-plan.md), [NEO-39](../../plans/NEO-39-implementation-plan.md), [NEO-40](../../plans/NEO-40-implementation-plan.md), [NEO-41](../../plans/NEO-41-implementation-plan.md), [NEO-42](../../plans/NEO-42-implementation-plan.md), [NEO-43](../../plans/NEO-43-implementation-plan.md), [E2_M2](E2_M2_XpAwardAndLevelEngine.md); label **`E2.M2`** on NEO-37–NEO-41, NEO-42–NEO-44 | -| E3.M1 | In Progress | **NEO-41 landed (prototype):** `POST …/interact` success on **`resource_node`** (`prototype_resource_node_alpha`) applies **`salvage`** skill XP (**`sourceKind: activity`**, 10 XP) via shared NEO-38 grant operations. **NEO-57 landed:** frozen four-node catalog in [`content/resource-nodes/`](../../../content/resource-nodes/); [`resource-node-def.schema.json`](../../../content/schemas/resource-node-def.schema.json), [`resource-yield-row.schema.json`](../../../content/schemas/resource-yield-row.schema.json); CI gates in [`validate_content.py`](../../../scripts/validate_content.py). **Still planned:** server load, `GatherResult`, depletion, inventory grants per [E3_M1](E3_M1_ResourceNodeAndGatherLoop.md). | [NEO-41](../../plans/NEO-41-implementation-plan.md), [NEO-57](../../plans/NEO-57-implementation-plan.md), [E3_M1](E3_M1_ResourceNodeAndGatherLoop.md); `content/resource-nodes/`, `server/NeonSprawl.Server/Game/Interaction/` | +| E3.M1 | In Progress | **NEO-41 landed (prototype):** `POST …/interact` success on **`resource_node`** (`prototype_resource_node_alpha`) applies **`salvage`** skill XP (**`sourceKind: activity`**, 10 XP) via shared NEO-38 grant operations. **NEO-57 landed:** frozen four-node catalog in [`content/resource-nodes/`](../../../content/resource-nodes/); [`resource-node-def.schema.json`](../../../content/schemas/resource-node-def.schema.json), [`resource-yield-row.schema.json`](../../../content/schemas/resource-yield-row.schema.json); CI gates in [`validate_content.py`](../../../scripts/validate_content.py). **NEO-58 landed:** fail-fast server load of `content/resource-nodes/*` at startup — `server/NeonSprawl.Server/Game/Gathering/` ([NEO-58](../../plans/NEO-58-implementation-plan.md)); [server README — Resource-node catalog](../../../server/README.md#resource-node-catalog-contentresource-nodes-neo-58). **Still planned:** `IResourceNodeDefinitionRegistry`, `GatherResult`, depletion, inventory grants per [E3_M1](E3_M1_ResourceNodeAndGatherLoop.md). | [NEO-41](../../plans/NEO-41-implementation-plan.md), [NEO-57](../../plans/NEO-57-implementation-plan.md), [NEO-58](../../plans/NEO-58-implementation-plan.md), [E3_M1](E3_M1_ResourceNodeAndGatherLoop.md); `content/resource-nodes/`, `server/NeonSprawl.Server/Game/Gathering/`, `server/NeonSprawl.Server/Game/Interaction/` | | E3.M3 | In Progress | **NEO-50 landed:** frozen prototype six-item catalog in [`content/items/prototype_items.json`](../../../content/items/prototype_items.json); [`item-def.schema.json`](../../../content/schemas/item-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py). **NEO-51 landed:** fail-fast server load of `content/items/*_items.json` at startup — `server/NeonSprawl.Server/Game/Items/` ([NEO-51](../../plans/NEO-51-implementation-plan.md)); [server README — Item catalog](../../../server/README.md#item-catalog-contentitems-neo-51). **NEO-52 landed:** injectable **`IItemDefinitionRegistry`** + lookup tests ([NEO-52](../../plans/NEO-52-implementation-plan.md)). **NEO-53 landed:** **`GET /game/world/item-definitions`** — `ItemDefinitionsWorldApi` + DTOs in `Game/Items/` ([NEO-53](../../plans/NEO-53-implementation-plan.md), [`NEO-53` manual QA](../../manual-qa/NEO-53.md)); [server README — Item definitions (NEO-53)](../../../server/README.md#item-definitions-neo-53); Bruno `bruno/neon-sprawl-server/item-definitions/`. **NEO-54 landed:** **`IPlayerInventoryStore`** + **`PlayerInventoryOperations`** — 24 bag + 1 equipment slots, stack rules, `V005` migration ([NEO-54](../../plans/NEO-54-implementation-plan.md)). **NEO-55 landed:** **`GET`/`POST /game/players/{id}/inventory`** — `PlayerInventoryApi` + DTOs in `Game/Items/` ([NEO-55](../../plans/NEO-55-implementation-plan.md)); [server README — Player inventory (NEO-54 store, NEO-55 HTTP)](../../../server/README.md#player-inventory-neo-54-store-neo-55-http); Bruno `bruno/neon-sprawl-server/inventory/`. **NEO-56 landed:** comment-only **`item_created`** / **`inventory_transfer_denied`** telemetry hook sites in [`PlayerInventoryOperations`](../../../server/NeonSprawl.Server/Game/Items/PlayerInventoryOperations.cs) ([NEO-56](../../plans/NEO-56-implementation-plan.md), [`NEO-56` manual QA](../../manual-qa/NEO-56.md)); no E9.M1 ingest. | [NEO-50](../../plans/NEO-50-implementation-plan.md), [NEO-51](../../plans/NEO-51-implementation-plan.md), [NEO-52](../../plans/NEO-52-implementation-plan.md), [NEO-53](../../plans/NEO-53-implementation-plan.md), [NEO-54](../../plans/NEO-54-implementation-plan.md), [NEO-55](../../plans/NEO-55-implementation-plan.md), [NEO-56](../../plans/NEO-56-implementation-plan.md), [E3M3-prototype-backlog](../../plans/E3M3-prototype-backlog.md), [E3_M3](E3_M3_ItemizationAndInventorySchema.md) | --- diff --git a/docs/plans/E3M1-prototype-backlog.md b/docs/plans/E3M1-prototype-backlog.md index 0884101..e47ab46 100644 --- a/docs/plans/E3M1-prototype-backlog.md +++ b/docs/plans/E3M1-prototype-backlog.md @@ -100,8 +100,10 @@ Working backlog for **Epic 3 — Slice 2** ([gather nodes and outputs](../decomp **Acceptance criteria** -- [ ] Server refuses boot when resource-node catalog invalid (mirror E3.M3 / E2.M1 catalog behavior). -- [ ] Catalog resolves `ResourceNodeDef` and yield row by `nodeDefId`. +- [x] Server refuses boot when resource-node catalog invalid (mirror E3.M3 / E2.M1 catalog behavior). +- [x] Catalog resolves `ResourceNodeDef` and yield row by `nodeDefId`. + +**Landed:** [NEO-58](https://linear.app/neon-sprawl/issue/NEO-58) — server loader; registry DI is E3M1-03. --- diff --git a/docs/plans/NEO-58-implementation-plan.md b/docs/plans/NEO-58-implementation-plan.md index ab0f076..e83fb54 100644 --- a/docs/plans/NEO-58-implementation-plan.md +++ b/docs/plans/NEO-58-implementation-plan.md @@ -41,10 +41,10 @@ ## 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). +- [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 diff --git a/server/NeonSprawl.Server.Tests/Game/Gathering/ResourceNodeCatalogLoaderTests.cs b/server/NeonSprawl.Server.Tests/Game/Gathering/ResourceNodeCatalogLoaderTests.cs new file mode 100644 index 0000000..70951a9 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Gathering/ResourceNodeCatalogLoaderTests.cs @@ -0,0 +1,551 @@ +using System.Net; +using System.Text; +using System.Text.Json.Nodes; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using NeonSprawl.Server.Game.Gathering; +using NeonSprawl.Server.Game.Items; +using NeonSprawl.Server.Tests; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Gathering; + +public class ResourceNodeCatalogLoaderTests +{ + private static readonly HashSet ValidPrototypeItemIds = + [ + "scrap_metal_bulk", + "refined_plate_stock", + "field_stim_mk0", + "survey_drone_kit", + "contract_handoff_token", + "prototype_armor_shell", + ]; + + private const string ValidPrototypeNodeCatalogJson = + """ + { + "schemaVersion": 1, + "nodes": [ + { + "nodeDefId": "prototype_resource_node_alpha", + "displayName": "Prototype Salvage Heap", + "gatherLens": "consumer_salvage", + "maxGathers": 10 + }, + { + "nodeDefId": "prototype_subsurface_vein_beta", + "displayName": "Prototype Subsurface Vein", + "gatherLens": "subsurface", + "maxGathers": 10 + }, + { + "nodeDefId": "prototype_bio_mat_gamma", + "displayName": "Prototype Bio Mat", + "gatherLens": "bio", + "maxGathers": 10 + }, + { + "nodeDefId": "prototype_urban_bulk_delta", + "displayName": "Prototype Urban Bulk", + "gatherLens": "urban_bulk", + "maxGathers": 10 + } + ] + } + """; + + private const string ValidPrototypeYieldCatalogJson = + """ + { + "schemaVersion": 1, + "yields": [ + { + "nodeDefId": "prototype_resource_node_alpha", + "itemId": "scrap_metal_bulk", + "quantity": 1 + }, + { + "nodeDefId": "prototype_subsurface_vein_beta", + "itemId": "scrap_metal_bulk", + "quantity": 2 + }, + { + "nodeDefId": "prototype_bio_mat_gamma", + "itemId": "scrap_metal_bulk", + "quantity": 3 + }, + { + "nodeDefId": "prototype_urban_bulk_delta", + "itemId": "scrap_metal_bulk", + "quantity": 5 + } + ] + } + """; + + private static (string Root, string ResourceNodesDir, string NodeSchemaPath, string YieldSchemaPath) CreateTempContentLayout() + { + var root = Directory.CreateTempSubdirectory("neon-sprawl-resnodecat-"); + var resourceNodesDir = Path.Combine(root.FullName, "content", "resource-nodes"); + var schemaDir = Path.Combine(root.FullName, "content", "schemas"); + Directory.CreateDirectory(resourceNodesDir); + Directory.CreateDirectory(schemaDir); + var nodeSchemaPath = Path.Combine(schemaDir, "resource-node-def.schema.json"); + var yieldSchemaPath = Path.Combine(schemaDir, "resource-yield-row.schema.json"); + File.Copy(ResourceNodeCatalogTestPaths.DiscoverRepoResourceNodeDefSchemaPath(), nodeSchemaPath, overwrite: true); + File.Copy(ResourceNodeCatalogTestPaths.DiscoverRepoResourceYieldRowSchemaPath(), yieldSchemaPath, overwrite: true); + return (root.FullName, resourceNodesDir, nodeSchemaPath, yieldSchemaPath); + } + + private static void WriteNodeCatalog(string resourceNodesDir, string catalogJson) => + File.WriteAllText(Path.Combine(resourceNodesDir, "prototype_resource_nodes.json"), catalogJson, Encoding.UTF8); + + private static void WriteYieldCatalog(string resourceNodesDir, string catalogJson) => + File.WriteAllText(Path.Combine(resourceNodesDir, "prototype_resource_yields.json"), catalogJson, Encoding.UTF8); + + private static void WriteValidCatalogs(string resourceNodesDir) + { + WriteNodeCatalog(resourceNodesDir, ValidPrototypeNodeCatalogJson); + WriteYieldCatalog(resourceNodesDir, ValidPrototypeYieldCatalogJson); + } + + private static JsonObject GetNodeRow(JsonObject catalogRoot, string nodeDefId) + { + var nodes = catalogRoot["nodes"] as JsonArray + ?? throw new InvalidOperationException("expected nodes array"); + foreach (var node in nodes) + { + if (node is JsonObject row && row["nodeDefId"]?.GetValue() == nodeDefId) + return row; + } + + throw new InvalidOperationException($"nodeDefId not found: {nodeDefId}"); + } + + [Fact] + public void Load_ShouldSucceed_WhenCatalogMatchesPrototypeContract() + { + // Arrange + var (_, resourceNodesDir, nodeSchemaPath, yieldSchemaPath) = CreateTempContentLayout(); + WriteValidCatalogs(resourceNodesDir); + // Act + var catalog = ResourceNodeCatalogLoader.Load( + resourceNodesDir, + nodeSchemaPath, + yieldSchemaPath, + ValidPrototypeItemIds, + NullLogger.Instance); + // Assert + Assert.Equal(4, catalog.DistinctNodeCount); + Assert.Equal(4, catalog.DistinctYieldCount); + Assert.Equal(1, catalog.NodeCatalogJsonFileCount); + Assert.Equal(1, catalog.YieldCatalogJsonFileCount); + Assert.True(catalog.TryGetNode("prototype_resource_node_alpha", out var node)); + Assert.NotNull(node); + Assert.Equal("consumer_salvage", node!.GatherLens); + Assert.Equal(10, node.MaxGathers); + Assert.True(catalog.TryGetYield("prototype_resource_node_alpha", out var yield)); + Assert.NotNull(yield); + Assert.Equal("scrap_metal_bulk", yield!.ItemId); + Assert.Equal(1, yield.Quantity); + Assert.True(catalog.TryGetYield("prototype_urban_bulk_delta", out var deltaYield)); + Assert.Equal(5, deltaYield!.Quantity); + } + + [Fact] + public void Load_ShouldThrow_WhenNodesIsNotArray() + { + // Arrange + var (_, resourceNodesDir, nodeSchemaPath, yieldSchemaPath) = CreateTempContentLayout(); + WriteNodeCatalog(resourceNodesDir, """{"schemaVersion": 1, "nodes": "nope"}"""); + WriteYieldCatalog(resourceNodesDir, ValidPrototypeYieldCatalogJson); + // Act + var ex = Record.Exception(() => ResourceNodeCatalogLoader.Load( + resourceNodesDir, + nodeSchemaPath, + yieldSchemaPath, + ValidPrototypeItemIds, + NullLogger.Instance)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("expected top-level 'nodes' array", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenYieldsIsNotArray() + { + // Arrange + var (_, resourceNodesDir, nodeSchemaPath, yieldSchemaPath) = CreateTempContentLayout(); + WriteNodeCatalog(resourceNodesDir, ValidPrototypeNodeCatalogJson); + WriteYieldCatalog(resourceNodesDir, """{"schemaVersion": 1, "yields": "nope"}"""); + // Act + var ex = Record.Exception(() => ResourceNodeCatalogLoader.Load( + resourceNodesDir, + nodeSchemaPath, + yieldSchemaPath, + ValidPrototypeItemIds, + NullLogger.Instance)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("expected top-level 'yields' array", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenSchemaVersionIsNotOne() + { + // Arrange + var (_, resourceNodesDir, nodeSchemaPath, yieldSchemaPath) = CreateTempContentLayout(); + WriteNodeCatalog(resourceNodesDir, """{"schemaVersion": 2, "nodes": []}"""); + WriteYieldCatalog(resourceNodesDir, ValidPrototypeYieldCatalogJson); + // Act + var ex = Record.Exception(() => ResourceNodeCatalogLoader.Load( + resourceNodesDir, + nodeSchemaPath, + yieldSchemaPath, + ValidPrototypeItemIds, + NullLogger.Instance)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("expected schemaVersion 1", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenNodeRowViolatesSchema() + { + // Arrange + var (_, resourceNodesDir, nodeSchemaPath, yieldSchemaPath) = CreateTempContentLayout(); + const string bad = """ + { + "schemaVersion": 1, + "nodes": [ + { + "nodeDefId": "prototype_resource_node_alpha", + "displayName": "", + "gatherLens": "consumer_salvage", + "maxGathers": 10 + } + ] + } + """; + WriteNodeCatalog(resourceNodesDir, bad); + WriteYieldCatalog(resourceNodesDir, ValidPrototypeYieldCatalogJson); + // Act + var ex = Record.Exception(() => ResourceNodeCatalogLoader.Load( + resourceNodesDir, + nodeSchemaPath, + yieldSchemaPath, + ValidPrototypeItemIds, + NullLogger.Instance)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("Resource node catalog validation failed", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenDuplicateNodeDefIdAcrossFiles() + { + // Arrange + var (_, resourceNodesDir, nodeSchemaPath, yieldSchemaPath) = CreateTempContentLayout(); + const string singleNode = """ + { + "schemaVersion": 1, + "nodes": [ + { + "nodeDefId": "prototype_resource_node_alpha", + "displayName": "A", + "gatherLens": "consumer_salvage", + "maxGathers": 10 + } + ] + } + """; + File.WriteAllText(Path.Combine(resourceNodesDir, "a_resource_nodes.json"), singleNode, Encoding.UTF8); + File.WriteAllText(Path.Combine(resourceNodesDir, "b_resource_nodes.json"), singleNode, Encoding.UTF8); + WriteYieldCatalog(resourceNodesDir, ValidPrototypeYieldCatalogJson); + // Act + var ex = Record.Exception(() => ResourceNodeCatalogLoader.Load( + resourceNodesDir, + nodeSchemaPath, + yieldSchemaPath, + ValidPrototypeItemIds, + NullLogger.Instance)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("duplicate nodeDefId", ioe.Message, StringComparison.Ordinal); + Assert.Contains("prototype_resource_node_alpha", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenSlice2NodeIdsIncomplete() + { + // Arrange + var (_, resourceNodesDir, nodeSchemaPath, yieldSchemaPath) = CreateTempContentLayout(); + const string oneOnly = """ + { + "schemaVersion": 1, + "nodes": [ + { + "nodeDefId": "prototype_resource_node_alpha", + "displayName": "A", + "gatherLens": "consumer_salvage", + "maxGathers": 10 + } + ] + } + """; + WriteNodeCatalog(resourceNodesDir, oneOnly); + WriteYieldCatalog(resourceNodesDir, ValidPrototypeYieldCatalogJson); + // Act + var ex = Record.Exception(() => ResourceNodeCatalogLoader.Load( + resourceNodesDir, + nodeSchemaPath, + yieldSchemaPath, + ValidPrototypeItemIds, + NullLogger.Instance)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("prototype Slice 2 expects exactly nodeDefIds", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenSlice2GatherLensesDoNotMatch() + { + // Arrange + var (_, resourceNodesDir, nodeSchemaPath, yieldSchemaPath) = CreateTempContentLayout(); + var root = JsonNode.Parse(ValidPrototypeNodeCatalogJson) as JsonObject + ?? throw new InvalidOperationException("expected object root"); + GetNodeRow(root, "prototype_subsurface_vein_beta")["gatherLens"] = "consumer_salvage"; + WriteNodeCatalog(resourceNodesDir, root.ToJsonString()); + WriteYieldCatalog(resourceNodesDir, ValidPrototypeYieldCatalogJson); + // Act + var ex = Record.Exception(() => ResourceNodeCatalogLoader.Load( + resourceNodesDir, + nodeSchemaPath, + yieldSchemaPath, + ValidPrototypeItemIds, + NullLogger.Instance)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("requires exactly one row per gatherLens", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenYieldNodeDefIdNotInNodes() + { + // Arrange + var (_, resourceNodesDir, nodeSchemaPath, yieldSchemaPath) = CreateTempContentLayout(); + WriteValidCatalogs(resourceNodesDir); + var yieldRoot = JsonNode.Parse(ValidPrototypeYieldCatalogJson) as JsonObject + ?? throw new InvalidOperationException("expected object root"); + var yields = yieldRoot["yields"] as JsonArray ?? throw new InvalidOperationException("expected yields"); + yields.Add(JsonNode.Parse("""{"nodeDefId": "unknown_node_xyz", "itemId": "scrap_metal_bulk", "quantity": 1}""")); + WriteYieldCatalog(resourceNodesDir, yieldRoot.ToJsonString()); + // Act + var ex = Record.Exception(() => ResourceNodeCatalogLoader.Load( + resourceNodesDir, + nodeSchemaPath, + yieldSchemaPath, + ValidPrototypeItemIds, + NullLogger.Instance)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("unknown_node_xyz", ioe.Message, StringComparison.Ordinal); + Assert.Contains("not defined in resource node catalogs", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenYieldItemIdUnknown() + { + // Arrange + var (_, resourceNodesDir, nodeSchemaPath, yieldSchemaPath) = CreateTempContentLayout(); + WriteValidCatalogs(resourceNodesDir); + var yieldRoot = JsonNode.Parse(ValidPrototypeYieldCatalogJson) as JsonObject + ?? throw new InvalidOperationException("expected object root"); + var yields = yieldRoot["yields"] as JsonArray ?? throw new InvalidOperationException("expected yields"); + yields[0]!["itemId"] = "not_a_real_item"; + WriteYieldCatalog(resourceNodesDir, yieldRoot.ToJsonString()); + // Act + var ex = Record.Exception(() => ResourceNodeCatalogLoader.Load( + resourceNodesDir, + nodeSchemaPath, + yieldSchemaPath, + ValidPrototypeItemIds, + NullLogger.Instance)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("not_a_real_item", ioe.Message, StringComparison.Ordinal); + Assert.Contains("not in item catalogs", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenDuplicateYieldNodeDefId() + { + // Arrange + var (_, resourceNodesDir, nodeSchemaPath, yieldSchemaPath) = CreateTempContentLayout(); + WriteValidCatalogs(resourceNodesDir); + const string dupYield = """ + { + "schemaVersion": 1, + "yields": [ + { + "nodeDefId": "prototype_resource_node_alpha", + "itemId": "scrap_metal_bulk", + "quantity": 1 + } + ] + } + """; + File.WriteAllText(Path.Combine(resourceNodesDir, "a_resource_yields.json"), dupYield, Encoding.UTF8); + File.WriteAllText(Path.Combine(resourceNodesDir, "b_resource_yields.json"), dupYield, Encoding.UTF8); + // Act + var ex = Record.Exception(() => ResourceNodeCatalogLoader.Load( + resourceNodesDir, + nodeSchemaPath, + yieldSchemaPath, + ValidPrototypeItemIds, + NullLogger.Instance)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("duplicate yield nodeDefId", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenSlice2YieldRowsIncomplete() + { + // Arrange + var (_, resourceNodesDir, nodeSchemaPath, yieldSchemaPath) = CreateTempContentLayout(); + WriteValidCatalogs(resourceNodesDir); + const string oneYield = """ + { + "schemaVersion": 1, + "yields": [ + { + "nodeDefId": "prototype_resource_node_alpha", + "itemId": "scrap_metal_bulk", + "quantity": 1 + } + ] + } + """; + WriteYieldCatalog(resourceNodesDir, oneYield); + // Act + var ex = Record.Exception(() => ResourceNodeCatalogLoader.Load( + resourceNodesDir, + nodeSchemaPath, + yieldSchemaPath, + ValidPrototypeItemIds, + NullLogger.Instance)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("expects exactly one yield row per nodeDefId", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenSlice2YieldItemIdNotAllowed() + { + // Arrange + var (_, resourceNodesDir, nodeSchemaPath, yieldSchemaPath) = CreateTempContentLayout(); + WriteValidCatalogs(resourceNodesDir); + var yieldRoot = JsonNode.Parse(ValidPrototypeYieldCatalogJson) as JsonObject + ?? throw new InvalidOperationException("expected object root"); + var yields = yieldRoot["yields"] as JsonArray ?? throw new InvalidOperationException("expected yields"); + yields[0]!["itemId"] = "refined_plate_stock"; + WriteYieldCatalog(resourceNodesDir, yieldRoot.ToJsonString()); + // Act + var ex = Record.Exception(() => ResourceNodeCatalogLoader.Load( + resourceNodesDir, + nodeSchemaPath, + yieldSchemaPath, + ValidPrototypeItemIds, + NullLogger.Instance)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("expects yield itemIds", ioe.Message, StringComparison.Ordinal); + Assert.Contains("scrap_metal_bulk", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenResourceNodesDirectoryMissing() + { + // Arrange + var missingDir = Path.Combine(Path.GetTempPath(), "neon-sprawl-no-resnodes-" + Guid.NewGuid().ToString("n")); + var nodeSchemaPath = ResourceNodeCatalogTestPaths.DiscoverRepoResourceNodeDefSchemaPath(); + var yieldSchemaPath = ResourceNodeCatalogTestPaths.DiscoverRepoResourceYieldRowSchemaPath(); + // Act + var ex = Record.Exception(() => ResourceNodeCatalogLoader.Load( + missingDir, + nodeSchemaPath, + yieldSchemaPath, + ValidPrototypeItemIds, + NullLogger.Instance)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("missing directory", ioe.Message, StringComparison.Ordinal); + Assert.Contains(missingDir, ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public void Load_ShouldThrow_WhenNodeSchemaFileMissing() + { + // Arrange + var (_, resourceNodesDir, _, yieldSchemaPath) = CreateTempContentLayout(); + WriteValidCatalogs(resourceNodesDir); + var missingSchema = Path.Combine(resourceNodesDir, "missing-node.schema.json"); + // Act + var ex = Record.Exception(() => ResourceNodeCatalogLoader.Load( + resourceNodesDir, + missingSchema, + yieldSchemaPath, + ValidPrototypeItemIds, + NullLogger.Instance)); + // Assert + var ioe = Assert.IsType(ex); + Assert.Contains("missing schema file", ioe.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task Host_ShouldResolveCatalogFromDi_WhenStartupSucceeds() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + using var client = factory.CreateClient(); + // Act + var response = await client.GetAsync("/health"); + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var catalog = factory.Services.GetRequiredService(); + Assert.Equal(4, catalog.DistinctNodeCount); + Assert.True(catalog.TryGetNode("prototype_bio_mat_gamma", out var node)); + Assert.Equal("bio", node!.GatherLens); + Assert.True(catalog.TryGetYield("prototype_bio_mat_gamma", out var yield)); + Assert.Equal(3, yield!.Quantity); + } + + [Fact] + public void Host_ShouldFailStartup_WhenCatalogDirectoryInvalid() + { + // Arrange + var badDir = Path.Combine(Path.GetTempPath(), "neon-sprawl-empty-resnodes-" + Guid.NewGuid().ToString("n")); + Directory.CreateDirectory(badDir); + try + { + // Act + var ex = Record.Exception(() => + { + using var factory = new WebApplicationFactory().WithWebHostBuilder(b => + b.UseSetting("Content:ResourceNodesDirectory", badDir)); + factory.CreateClient(); + }); + // Assert + Assert.NotNull(ex); + Assert.Contains("Resource node catalog validation failed", ex.ToString(), StringComparison.Ordinal); + } + finally + { + if (Directory.Exists(badDir)) + Directory.Delete(badDir); + } + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/Gathering/ResourceNodeCatalogTestPaths.cs b/server/NeonSprawl.Server.Tests/Game/Gathering/ResourceNodeCatalogTestPaths.cs new file mode 100644 index 0000000..d7a40f9 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Gathering/ResourceNodeCatalogTestPaths.cs @@ -0,0 +1,23 @@ +using NeonSprawl.Server.Game.Gathering; + +namespace NeonSprawl.Server.Tests.Game.Gathering; + +internal static class ResourceNodeCatalogTestPaths +{ + internal static string DiscoverRepoResourceNodesDirectory() => + ResourceNodeCatalogPathResolution.TryDiscoverResourceNodesDirectory(AppContext.BaseDirectory) + ?? throw new InvalidOperationException( + "Could not discover repo content/resource-nodes from AppContext.BaseDirectory; run tests from the neon-sprawl clone."); + + internal static string DiscoverRepoResourceNodeDefSchemaPath() => + ResourceNodeCatalogPathResolution.ResolveResourceNodeDefSchemaPath( + DiscoverRepoResourceNodesDirectory(), + configuredSchemaPath: null, + contentRootPath: string.Empty); + + internal static string DiscoverRepoResourceYieldRowSchemaPath() => + ResourceNodeCatalogPathResolution.ResolveResourceYieldRowSchemaPath( + DiscoverRepoResourceNodesDirectory(), + configuredSchemaPath: null, + contentRootPath: string.Empty); +} diff --git a/server/NeonSprawl.Server.Tests/Game/PositionState/PostgresWebApplicationFactory.cs b/server/NeonSprawl.Server.Tests/Game/PositionState/PostgresWebApplicationFactory.cs index 462261d..4c13ca3 100644 --- a/server/NeonSprawl.Server.Tests/Game/PositionState/PostgresWebApplicationFactory.cs +++ b/server/NeonSprawl.Server.Tests/Game/PositionState/PostgresWebApplicationFactory.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.Configuration; +using NeonSprawl.Server.Game.Gathering; using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Mastery; using NeonSprawl.Server.Game.Skills; @@ -31,9 +32,13 @@ public sealed class PostgresWebApplicationFactory : WebApplicationFactory { diff --git a/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs b/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs index 51ccbfe..0129796 100644 --- a/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs +++ b/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Time.Testing; using NeonSprawl.Server.Game.AbilityInput; +using NeonSprawl.Server.Game.Gathering; using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.PositionState; using NeonSprawl.Server.Game.Mastery; @@ -34,9 +35,13 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory diff --git a/server/NeonSprawl.Server/Game/Gathering/PrototypeSlice2ResourceNodeCatalogRules.cs b/server/NeonSprawl.Server/Game/Gathering/PrototypeSlice2ResourceNodeCatalogRules.cs new file mode 100644 index 0000000..c61a3f8 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Gathering/PrototypeSlice2ResourceNodeCatalogRules.cs @@ -0,0 +1,82 @@ +using System.Collections.Frozen; + +namespace NeonSprawl.Server.Game.Gathering; + +/// +/// Prototype Slice 2 roster gate (NEO-57), mirrored from scripts/validate_content.py +/// PROTOTYPE_SLICE2_* / _prototype_slice2_*_gate. +/// +public static class PrototypeSlice2ResourceNodeCatalogRules +{ + /// Keep in sync with scripts/validate_content.py PROTOTYPE_SLICE2_NODE_IDS. + public static readonly FrozenSet ExpectedNodeDefIds = FrozenSet.ToFrozenSet( + [ + "prototype_resource_node_alpha", + "prototype_subsurface_vein_beta", + "prototype_bio_mat_gamma", + "prototype_urban_bulk_delta", + ], + StringComparer.Ordinal); + + /// Keep in sync with scripts/validate_content.py PROTOTYPE_SLICE2_GATHER_LENSES. + public static readonly FrozenSet ExpectedGatherLenses = FrozenSet.ToFrozenSet( + ["consumer_salvage", "subsurface", "bio", "urban_bulk"], + StringComparer.Ordinal); + + /// Keep in sync with scripts/validate_content.py PROTOTYPE_SLICE2_YIELD_ITEM_IDS. + public static readonly FrozenSet ExpectedYieldItemIds = FrozenSet.ToFrozenSet( + ["scrap_metal_bulk"], + StringComparer.Ordinal); + + /// Returns a human-readable error if the Slice 2 node contract fails, otherwise . + public static string? TryGetSlice2NodeGateError( + IReadOnlyDictionary nodeDefIdToSourceFile, + IReadOnlyDictionary idToGatherLens) + { + var ids = nodeDefIdToSourceFile.Keys.ToFrozenSet(StringComparer.Ordinal); + if (!ids.SetEquals(ExpectedNodeDefIds)) + { + return + "error: prototype Slice 2 expects exactly nodeDefIds " + + $"[{string.Join(", ", ExpectedNodeDefIds.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}], " + + $"got [{string.Join(", ", ids.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}]"; + } + + var lenses = idToGatherLens.Values.ToHashSet(StringComparer.Ordinal); + if (!lenses.SetEquals(ExpectedGatherLenses)) + { + return + "error: prototype Slice 2 requires exactly one row per gatherLens " + + $"[{string.Join(", ", ExpectedGatherLenses.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}], " + + $"lenses seen: [{string.Join(", ", lenses.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}]"; + } + + return null; + } + + /// Returns a human-readable error if the Slice 2 yield contract fails, otherwise . + public static string? TryGetSlice2YieldGateError( + IReadOnlySet yieldNodeDefIds, + IReadOnlySet yieldItemIds) + { + var yieldNodes = yieldNodeDefIds.ToFrozenSet(StringComparer.Ordinal); + if (!yieldNodes.SetEquals(ExpectedNodeDefIds)) + { + return + "error: prototype Slice 2 expects exactly one yield row per nodeDefId " + + $"[{string.Join(", ", ExpectedNodeDefIds.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}], " + + $"yield nodeDefIds: [{string.Join(", ", yieldNodes.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}]"; + } + + var items = yieldItemIds.ToFrozenSet(StringComparer.Ordinal); + if (!items.SetEquals(ExpectedYieldItemIds)) + { + return + "error: prototype Slice 2 expects yield itemIds " + + $"[{string.Join(", ", ExpectedYieldItemIds.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}] only, " + + $"got [{string.Join(", ", items.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}]"; + } + + return null; + } +} diff --git a/server/NeonSprawl.Server/Game/Gathering/ResourceNodeCatalog.cs b/server/NeonSprawl.Server/Game/Gathering/ResourceNodeCatalog.cs new file mode 100644 index 0000000..40558ad --- /dev/null +++ b/server/NeonSprawl.Server/Game/Gathering/ResourceNodeCatalog.cs @@ -0,0 +1,39 @@ +using System.Collections.ObjectModel; + +namespace NeonSprawl.Server.Game.Gathering; + +/// In-memory resource-node catalog loaded at startup (NEO-58). Prefer registry in NEO-59 for game lookups. +public sealed class ResourceNodeCatalog( + string resourceNodesDirectory, + IReadOnlyDictionary nodesById, + IReadOnlyDictionary yieldsByNodeDefId, + int nodeCatalogJsonFileCount, + int yieldCatalogJsonFileCount) +{ + /// Absolute path to the directory enumerated for *_resource_nodes.json / *_resource_yields.json. + public string ResourceNodesDirectory { get; } = resourceNodesDirectory; + + public IReadOnlyDictionary NodesById { get; } = + new ReadOnlyDictionary(new Dictionary(nodesById, StringComparer.Ordinal)); + + public IReadOnlyDictionary YieldsByNodeDefId { get; } = + new ReadOnlyDictionary(new Dictionary(yieldsByNodeDefId, StringComparer.Ordinal)); + + public int DistinctNodeCount => NodesById.Count; + + public int DistinctYieldCount => YieldsByNodeDefId.Count; + + /// Number of *_resource_nodes.json files under . + public int NodeCatalogJsonFileCount { get; } = nodeCatalogJsonFileCount; + + /// Number of *_resource_yields.json files under . + public int YieldCatalogJsonFileCount { get; } = yieldCatalogJsonFileCount; + + /// Resolves a node def by stable . + public bool TryGetNode(string nodeDefId, out ResourceNodeDefRow? row) => + NodesById.TryGetValue(nodeDefId, out row); + + /// Resolves the fixed yield row for . + public bool TryGetYield(string nodeDefId, out ResourceYieldRow? row) => + YieldsByNodeDefId.TryGetValue(nodeDefId, out row); +} diff --git a/server/NeonSprawl.Server/Game/Gathering/ResourceNodeCatalogLoader.cs b/server/NeonSprawl.Server/Game/Gathering/ResourceNodeCatalogLoader.cs new file mode 100644 index 0000000..1563904 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Gathering/ResourceNodeCatalogLoader.cs @@ -0,0 +1,337 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using Json.Schema; +using Microsoft.Extensions.Logging; + +namespace NeonSprawl.Server.Game.Gathering; + +/// Loads and validates content/resource-nodes/*.json using the same rules as scripts/validate_content.py (NEO-58). +public static class ResourceNodeCatalogLoader +{ + /// Loads catalogs from disk or throws with actionable messages. + public static ResourceNodeCatalog Load( + string resourceNodesDirectory, + string nodeSchemaPath, + string yieldSchemaPath, + IReadOnlySet knownItemIds, + ILogger logger) + { + resourceNodesDirectory = Path.GetFullPath(resourceNodesDirectory); + nodeSchemaPath = Path.GetFullPath(nodeSchemaPath); + yieldSchemaPath = Path.GetFullPath(yieldSchemaPath); + + var errors = new List(); + + if (!File.Exists(nodeSchemaPath)) + errors.Add($"error: missing schema file {nodeSchemaPath}"); + + if (!File.Exists(yieldSchemaPath)) + errors.Add($"error: missing schema file {yieldSchemaPath}"); + + if (!Directory.Exists(resourceNodesDirectory)) + errors.Add($"error: missing directory {resourceNodesDirectory}"); + + if (errors.Count > 0) + ThrowIfAny(errors); + + var nodeJsonFiles = Directory.GetFiles(resourceNodesDirectory, "*_resource_nodes.json", SearchOption.TopDirectoryOnly) + .OrderBy(p => p, StringComparer.Ordinal) + .ToArray(); + if (nodeJsonFiles.Length == 0) + errors.Add($"error: no *_resource_nodes.json files under {resourceNodesDirectory}"); + + var yieldJsonFiles = Directory.GetFiles(resourceNodesDirectory, "*_resource_yields.json", SearchOption.TopDirectoryOnly) + .OrderBy(p => p, StringComparer.Ordinal) + .ToArray(); + if (yieldJsonFiles.Length == 0) + errors.Add($"error: no *_resource_yields.json files under {resourceNodesDirectory}"); + + if (errors.Count > 0) + ThrowIfAny(errors); + + var nodeSchema = JsonSchema.FromText(File.ReadAllText(nodeSchemaPath)); + var yieldSchema = JsonSchema.FromText(File.ReadAllText(yieldSchemaPath)); + var evalOptions = new EvaluationOptions { OutputFormat = OutputFormat.List }; + + var nodeDefIdToSourceFile = new Dictionary(StringComparer.Ordinal); + var idToGatherLens = new Dictionary(StringComparer.Ordinal); + var nodes = new Dictionary(StringComparer.Ordinal); + + foreach (var path in nodeJsonFiles) + { + JsonNode? root; + try + { + root = JsonNode.Parse(File.ReadAllText(path)); + } + catch (JsonException ex) + { + errors.Add($"error: {path}: invalid JSON: {ex.Message}"); + continue; + } + + if (root is not JsonObject rootObj) + { + errors.Add($"error: {path}: expected JSON object at root"); + continue; + } + + var schemaVersionNode = rootObj["schemaVersion"]; + if (schemaVersionNode is not JsonValue schemaVersionValue || + !schemaVersionValue.TryGetValue(out var schemaVersion) || + schemaVersion != 1) + { + var got = schemaVersionNode?.ToJsonString() ?? "null"; + errors.Add($"error: {path}: expected schemaVersion 1, got {got}"); + continue; + } + + var nodesNode = rootObj["nodes"]; + if (nodesNode is not JsonArray nodesArray) + { + errors.Add($"error: {path}: expected top-level 'nodes' array"); + continue; + } + + for (var i = 0; i < nodesArray.Count; i++) + { + var row = nodesArray[i]; + if (row is not JsonObject rowObj) + { + errors.Add($"error: {path}: nodes[{i}] must be an object"); + continue; + } + + var eval = nodeSchema.Evaluate(rowObj, evalOptions); + var schemaMsgs = CollectSchemaMessages(eval, path, "nodes", i).OrderBy(m => m, StringComparer.Ordinal).ToList(); + if (!eval.IsValid) + { + if (schemaMsgs.Count == 0) + schemaMsgs.Add($"error: {path} nodes[{i}] (root): schema validation failed"); + + errors.AddRange(schemaMsgs); + } + + var rowSchemaErrors = schemaMsgs.Count; + + var nodeDefId = (rowObj["nodeDefId"] as JsonValue)?.GetValue(); + if (nodeDefId is not null && rowSchemaErrors == 0) + { + if (nodeDefIdToSourceFile.TryGetValue(nodeDefId, out var prevPath)) + { + errors.Add($"error: duplicate nodeDefId '{nodeDefId}' in {prevPath} and {path}"); + continue; + } + + nodeDefIdToSourceFile[nodeDefId] = path; + + var gatherLens = (rowObj["gatherLens"] as JsonValue)?.GetValue(); + if (gatherLens is not null) + idToGatherLens[nodeDefId] = gatherLens; + + nodes[nodeDefId] = ParseNodeRow(rowObj); + } + } + } + + ThrowIfAny(errors); + + var slice2Node = PrototypeSlice2ResourceNodeCatalogRules.TryGetSlice2NodeGateError( + nodeDefIdToSourceFile, + idToGatherLens); + if (slice2Node is not null) + { + errors.Add(slice2Node); + ThrowIfAny(errors); + } + + var knownNodeIds = nodeDefIdToSourceFile.Keys.ToHashSet(StringComparer.Ordinal); + var yieldNodeDefIdToSourceFile = new Dictionary(StringComparer.Ordinal); + var yieldItemIds = new HashSet(StringComparer.Ordinal); + var yields = new Dictionary(StringComparer.Ordinal); + + foreach (var path in yieldJsonFiles) + { + JsonNode? root; + try + { + root = JsonNode.Parse(File.ReadAllText(path)); + } + catch (JsonException ex) + { + errors.Add($"error: {path}: invalid JSON: {ex.Message}"); + continue; + } + + if (root is not JsonObject rootObj) + { + errors.Add($"error: {path}: expected JSON object at root"); + continue; + } + + var schemaVersionNode = rootObj["schemaVersion"]; + if (schemaVersionNode is not JsonValue schemaVersionValue || + !schemaVersionValue.TryGetValue(out var schemaVersion) || + schemaVersion != 1) + { + var got = schemaVersionNode?.ToJsonString() ?? "null"; + errors.Add($"error: {path}: expected schemaVersion 1, got {got}"); + continue; + } + + var yieldsNode = rootObj["yields"]; + if (yieldsNode is not JsonArray yieldsArray) + { + errors.Add($"error: {path}: expected top-level 'yields' array"); + continue; + } + + for (var i = 0; i < yieldsArray.Count; i++) + { + var row = yieldsArray[i]; + if (row is not JsonObject rowObj) + { + errors.Add($"error: {path}: yields[{i}] must be an object"); + continue; + } + + var eval = yieldSchema.Evaluate(rowObj, evalOptions); + var schemaMsgs = CollectSchemaMessages(eval, path, "yields", i).OrderBy(m => m, StringComparer.Ordinal).ToList(); + if (!eval.IsValid) + { + if (schemaMsgs.Count == 0) + schemaMsgs.Add($"error: {path} yields[{i}] (root): schema validation failed"); + + errors.AddRange(schemaMsgs); + } + + var rowSchemaErrors = schemaMsgs.Count; + + var nodeDefId = (rowObj["nodeDefId"] as JsonValue)?.GetValue(); + var itemId = (rowObj["itemId"] as JsonValue)?.GetValue(); + if (nodeDefId is not null && rowSchemaErrors == 0) + { + if (yieldNodeDefIdToSourceFile.TryGetValue(nodeDefId, out var prevPath)) + { + errors.Add($"error: duplicate yield nodeDefId '{nodeDefId}' in {prevPath} and {path}"); + continue; + } + + yieldNodeDefIdToSourceFile[nodeDefId] = path; + + if (!knownNodeIds.Contains(nodeDefId)) + { + errors.Add( + $"error: {path} yields[{i}]: nodeDefId '{nodeDefId}' is not defined in resource node catalogs"); + continue; + } + + yields[nodeDefId] = ParseYieldRow(rowObj); + } + + if (itemId is not null && rowSchemaErrors == 0) + { + yieldItemIds.Add(itemId); + if (!knownItemIds.Contains(itemId)) + { + errors.Add( + $"error: {path} yields[{i}]: itemId '{itemId}' is not in item catalogs"); + } + } + } + } + + ThrowIfAny(errors); + + var slice2Yield = PrototypeSlice2ResourceNodeCatalogRules.TryGetSlice2YieldGateError( + yieldNodeDefIdToSourceFile.Keys.ToHashSet(StringComparer.Ordinal), + yieldItemIds); + if (slice2Yield is not null) + { + errors.Add(slice2Yield); + ThrowIfAny(errors); + } + + if (logger.IsEnabled(LogLevel.Information)) + { + logger.LogInformation( + "Loaded resource-node catalog from {ResourceNodesDirectory}: {NodeCount} node(s) across {NodeFileCount} node catalog file(s), {YieldCount} yield row(s) across {YieldFileCount} yield catalog file(s).", + resourceNodesDirectory, + nodes.Count, + nodeJsonFiles.Length, + yields.Count, + yieldJsonFiles.Length); + } + + return new ResourceNodeCatalog( + resourceNodesDirectory, + nodes, + yields, + nodeJsonFiles.Length, + yieldJsonFiles.Length); + } + + private static ResourceNodeDefRow ParseNodeRow(JsonObject rowObj) + { + var nodeDefId = (rowObj["nodeDefId"] as JsonValue)!.GetValue(); + var displayName = (rowObj["displayName"] as JsonValue)!.GetValue(); + var gatherLens = (rowObj["gatherLens"] as JsonValue)!.GetValue(); + var maxGathers = (rowObj["maxGathers"] as JsonValue)!.GetValue(); + return new ResourceNodeDefRow(nodeDefId, displayName, gatherLens, maxGathers); + } + + private static ResourceYieldRow ParseYieldRow(JsonObject rowObj) + { + var nodeDefId = (rowObj["nodeDefId"] as JsonValue)!.GetValue(); + var itemId = (rowObj["itemId"] as JsonValue)!.GetValue(); + var quantity = (rowObj["quantity"] as JsonValue)!.GetValue(); + return new ResourceYieldRow(nodeDefId, itemId, quantity); + } + + private static List CollectSchemaMessages(EvaluationResults eval, string filePath, string arrayName, int index) + { + var sink = new List(); + AppendSchemaMessages(eval, filePath, arrayName, index, sink); + return sink; + } + + private static void AppendSchemaMessages( + EvaluationResults r, + string filePath, + string arrayName, + int index, + List sink) + { + if (r.HasDetails) + { + foreach (var d in r.Details!) + AppendSchemaMessages(d, filePath, arrayName, index, sink); + } + + if (!r.HasErrors) + return; + + foreach (var kv in r.Errors!) + { + var loc = r.InstanceLocation?.ToString(); + if (string.IsNullOrEmpty(loc) || loc == "#") + loc = "(root)"; + + sink.Add($"error: {filePath} {arrayName}[{index}] {loc}: {kv.Key} — {kv.Value}"); + } + } + + private static void ThrowIfAny(List errors) + { + if (errors.Count == 0) + return; + + var sb = new StringBuilder(); + sb.AppendLine("Resource node catalog validation failed:"); + foreach (var e in errors.OrderBy(x => x, StringComparer.Ordinal)) + sb.AppendLine(e); + + throw new InvalidOperationException(sb.ToString().TrimEnd()); + } +} diff --git a/server/NeonSprawl.Server/Game/Gathering/ResourceNodeCatalogPathResolution.cs b/server/NeonSprawl.Server/Game/Gathering/ResourceNodeCatalogPathResolution.cs new file mode 100644 index 0000000..5f06d11 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Gathering/ResourceNodeCatalogPathResolution.cs @@ -0,0 +1,78 @@ +namespace NeonSprawl.Server.Game.Gathering; + +/// Resolves resource-node catalog paths for local dev, tests, and container layouts (NEO-58). +public static class ResourceNodeCatalogPathResolution +{ + /// Walks and parents for an existing content/resource-nodes directory. + public static string? TryDiscoverResourceNodesDirectory(string startDirectory) + { + for (var dir = new DirectoryInfo(startDirectory); dir is not null; dir = dir.Parent) + { + var candidate = Path.Combine(dir.FullName, "content", "resource-nodes"); + if (Directory.Exists(candidate)) + return Path.GetFullPath(candidate); + } + + return null; + } + + /// + /// Resolves the resource-nodes catalog directory. + /// Empty triggers discovery from . + /// + public static string ResolveResourceNodesDirectory(string? configuredResourceNodesDirectory, string contentRootPath) + { + if (string.IsNullOrWhiteSpace(configuredResourceNodesDirectory)) + { + var discovered = TryDiscoverResourceNodesDirectory(AppContext.BaseDirectory); + if (discovered is not null) + return discovered; + + throw new InvalidOperationException( + "Content:ResourceNodesDirectory is not set and auto-discovery failed (no ancestor of AppContext.BaseDirectory contains 'content/resource-nodes'). " + + "Set Content:ResourceNodesDirectory in configuration or environment (e.g. Content__ResourceNodesDirectory)."); + } + + var trimmed = configuredResourceNodesDirectory.Trim(); + if (Path.IsPathRooted(trimmed)) + return Path.GetFullPath(trimmed); + + return Path.GetFullPath(Path.Combine(contentRootPath, trimmed)); + } + + /// Resolves JSON Schema path for a single resource node row (Draft 2020-12). + public static string ResolveResourceNodeDefSchemaPath( + string resourceNodesDirectory, + string? configuredSchemaPath, + string contentRootPath) + { + if (!string.IsNullOrWhiteSpace(configuredSchemaPath)) + { + var trimmed = configuredSchemaPath.Trim(); + if (Path.IsPathRooted(trimmed)) + return Path.GetFullPath(trimmed); + + return Path.GetFullPath(Path.Combine(contentRootPath, trimmed)); + } + + return Path.GetFullPath(Path.Combine(resourceNodesDirectory, "..", "schemas", "resource-node-def.schema.json")); + } + + /// Resolves JSON Schema path for a single resource yield row (Draft 2020-12). + public static string ResolveResourceYieldRowSchemaPath( + string resourceNodesDirectory, + string? configuredSchemaPath, + string contentRootPath) + { + if (!string.IsNullOrWhiteSpace(configuredSchemaPath)) + { + var trimmed = configuredSchemaPath.Trim(); + if (Path.IsPathRooted(trimmed)) + return Path.GetFullPath(trimmed); + + return Path.GetFullPath(Path.Combine(contentRootPath, trimmed)); + } + + return Path.GetFullPath(Path.Combine(resourceNodesDirectory, "..", "schemas", "resource-yield-row.schema.json")); + } +} diff --git a/server/NeonSprawl.Server/Game/Gathering/ResourceNodeCatalogServiceCollectionExtensions.cs b/server/NeonSprawl.Server/Game/Gathering/ResourceNodeCatalogServiceCollectionExtensions.cs new file mode 100644 index 0000000..5c5f47b --- /dev/null +++ b/server/NeonSprawl.Server/Game/Gathering/ResourceNodeCatalogServiceCollectionExtensions.cs @@ -0,0 +1,50 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using NeonSprawl.Server.Game.Items; +using NeonSprawl.Server.Game.Skills; + +namespace NeonSprawl.Server.Game.Gathering; + +/// DI registration for the fail-fast resource-node catalog (NEO-58). +public static class ResourceNodeCatalogServiceCollectionExtensions +{ + /// Binds and registers as a singleton. + public static IServiceCollection AddResourceNodeCatalog(this IServiceCollection services, IConfiguration configuration) + { + services.AddOptions() + .Bind(configuration.GetSection(ContentPathsOptions.SectionName)); + + services.AddSingleton(sp => + { + var hostEnv = sp.GetRequiredService(); + var opts = sp.GetRequiredService>().Value; + var itemCatalog = sp.GetRequiredService(); + var logger = sp.GetRequiredService() + .CreateLogger("NeonSprawl.Server.Game.Gathering.ResourceNodeCatalog"); + + var resourceNodesDir = ResourceNodeCatalogPathResolution.ResolveResourceNodesDirectory( + opts.ResourceNodesDirectory, + hostEnv.ContentRootPath); + var nodeSchemaPath = ResourceNodeCatalogPathResolution.ResolveResourceNodeDefSchemaPath( + resourceNodesDir, + opts.ResourceNodeDefSchemaPath, + hostEnv.ContentRootPath); + var yieldSchemaPath = ResourceNodeCatalogPathResolution.ResolveResourceYieldRowSchemaPath( + resourceNodesDir, + opts.ResourceYieldRowSchemaPath, + hostEnv.ContentRootPath); + + var knownItemIds = itemCatalog.ById.Keys.ToHashSet(StringComparer.Ordinal); + + return ResourceNodeCatalogLoader.Load( + resourceNodesDir, + nodeSchemaPath, + yieldSchemaPath, + knownItemIds, + logger); + }); + + return services; + } +} diff --git a/server/NeonSprawl.Server/Game/Gathering/ResourceNodeDefRow.cs b/server/NeonSprawl.Server/Game/Gathering/ResourceNodeDefRow.cs new file mode 100644 index 0000000..4275051 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Gathering/ResourceNodeDefRow.cs @@ -0,0 +1,8 @@ +namespace NeonSprawl.Server.Game.Gathering; + +/// One validated ResourceNodeDef row from content/resource-nodes/*_resource_nodes.json (NEO-58). +public sealed record ResourceNodeDefRow( + string NodeDefId, + string DisplayName, + string GatherLens, + int MaxGathers); diff --git a/server/NeonSprawl.Server/Game/Gathering/ResourceYieldRow.cs b/server/NeonSprawl.Server/Game/Gathering/ResourceYieldRow.cs new file mode 100644 index 0000000..daf5062 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Gathering/ResourceYieldRow.cs @@ -0,0 +1,7 @@ +namespace NeonSprawl.Server.Game.Gathering; + +/// One validated yield row from content/resource-nodes/*_resource_yields.json (NEO-58). +public sealed record ResourceYieldRow( + string NodeDefId, + string ItemId, + int Quantity); diff --git a/server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs b/server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs index 7debb9e..9a51b30 100644 --- a/server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs +++ b/server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs @@ -40,4 +40,22 @@ public sealed class ContentPathsOptions /// When unset, resolved as {parent of items directory}/schemas/item-def.schema.json. /// public string? ItemDefSchemaPath { get; set; } + + /// + /// Optional. Absolute path, or path relative to . + /// When unset, the host walks ancestors of for a content/resource-nodes directory. + /// + public string? ResourceNodesDirectory { get; set; } + + /// + /// Optional override for resource-node-def.schema.json. + /// When unset, resolved as {parent of resource-nodes directory}/schemas/resource-node-def.schema.json. + /// + public string? ResourceNodeDefSchemaPath { get; set; } + + /// + /// Optional override for resource-yield-row.schema.json. + /// When unset, resolved as {parent of resource-nodes directory}/schemas/resource-yield-row.schema.json. + /// + public string? ResourceYieldRowSchemaPath { get; set; } } diff --git a/server/NeonSprawl.Server/Program.cs b/server/NeonSprawl.Server/Program.cs index 77cda31..b5d83b6 100644 --- a/server/NeonSprawl.Server/Program.cs +++ b/server/NeonSprawl.Server/Program.cs @@ -1,4 +1,5 @@ using NeonSprawl.Server.Game.AbilityInput; +using NeonSprawl.Server.Game.Gathering; using NeonSprawl.Server.Game.Interaction; using NeonSprawl.Server.Game.Items; using NeonSprawl.Server.Game.Mastery; @@ -15,11 +16,13 @@ builder.Services.AddAbilityCooldownStore(); builder.Services.AddSingleton(); builder.Services.AddSkillDefinitionCatalog(builder.Configuration); builder.Services.AddItemDefinitionCatalog(builder.Configuration); +builder.Services.AddResourceNodeCatalog(builder.Configuration); builder.Services.AddMasteryCatalog(builder.Configuration); var app = builder.Build(); _ = app.Services.GetRequiredService(); _ = app.Services.GetRequiredService(); +_ = app.Services.GetRequiredService(); _ = app.Services.GetRequiredService(); _ = app.Services.GetRequiredService(); diff --git a/server/README.md b/server/README.md index 577c740..63547d4 100644 --- a/server/README.md +++ b/server/README.md @@ -50,6 +50,20 @@ On startup the host loads every **`*_items.json`** under the items directory, va On success, **Information** logs include the resolved items directory path, distinct item count, and catalog file count. Game code should use **`IItemDefinitionRegistry`** for lookups (NEO-52). +## Resource-node catalog (`content/resource-nodes`, NEO-58) + +On startup the host loads every **`*_resource_nodes.json`** and **`*_resource_yields.json`** under the resource-nodes directory, validates each row against **`content/schemas/resource-node-def.schema.json`** and **`resource-yield-row.schema.json`**, requires **`schemaVersion` 1** per file, rejects **duplicate `nodeDefId`** values across files, cross-checks yield **`itemId`** values against the **item catalog loaded first**, and enforces the **prototype Slice 2** roster gate (same rules as **`scripts/validate_content.py`**). If anything is missing or invalid, the process **exits during startup** with an actionable error—there is no silent fallback. + +| Config | Meaning | +|--------|---------| +| **`Content:ResourceNodesDirectory`** | Optional. Absolute path, or path relative to the server **content root**. When unset, walks **ancestors of `AppContext.BaseDirectory`** until it finds **`content/resource-nodes`**. | +| **`Content:ResourceNodeDefSchemaPath`** | Optional override for **`resource-node-def.schema.json`**. When unset, **`{parent of resource-nodes directory}/schemas/resource-node-def.schema.json`**. | +| **`Content:ResourceYieldRowSchemaPath`** | Optional override for **`resource-yield-row.schema.json`**. When unset, **`{parent of resource-nodes directory}/schemas/resource-yield-row.schema.json`**. | + +**Docker / CI:** include **`content/resource-nodes`**, **`content/items`**, and the two resource-node schemas in the mounted **`content/`** tree; set **`Content__ResourceNodesDirectory`** when layout differs. + +On success, **Information** logs include the resolved resource-nodes directory path, distinct node count, yield row count, and catalog file counts. Injectable **`IResourceNodeDefinitionRegistry`** lands in NEO-59. + ## Item definitions (NEO-53) **`GET /game/world/item-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`items`**) backed by **`IItemDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Plan: [NEO-53 implementation plan](../../docs/plans/NEO-53-implementation-plan.md); manual QA: [`docs/manual-qa/NEO-53.md`](../../docs/manual-qa/NEO-53.md); Bruno: `bruno/neon-sprawl-server/item-definitions/`.