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.pull/93/head
parent
2de699b7bd
commit
0384b63046
|
|
@ -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");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
meta {
|
||||||
|
name: resource-node-catalog
|
||||||
|
}
|
||||||
|
|
@ -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.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.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 |
|
| 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) |
|
| 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) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -100,8 +100,10 @@ Working backlog for **Epic 3 — Slice 2** ([gather nodes and outputs](../decomp
|
||||||
|
|
||||||
**Acceptance criteria**
|
**Acceptance criteria**
|
||||||
|
|
||||||
- [ ] Server refuses boot when resource-node catalog invalid (mirror E3.M3 / E2.M1 catalog behavior).
|
- [x] Server refuses boot when resource-node catalog invalid (mirror E3.M3 / E2.M1 catalog behavior).
|
||||||
- [ ] Catalog resolves `ResourceNodeDef` and yield row by `nodeDefId`.
|
- [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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,10 +41,10 @@
|
||||||
|
|
||||||
## Acceptance criteria checklist
|
## 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`).
|
- [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`).
|
||||||
- [ ] `ResourceNodeCatalog` resolves node def and yield row by `nodeDefId` (e.g. `prototype_resource_node_alpha` → lens + `maxGathers` + yield quantity).
|
- [x] `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.
|
- [x] 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] Success log includes **node count**, **yield count**, **catalog directory**, and **file counts** (Information).
|
||||||
|
|
||||||
## Technical approach
|
## Technical approach
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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<string> 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<string>() == 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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<InvalidOperationException>(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<ResourceNodeCatalog>();
|
||||||
|
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<Program>().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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
using Microsoft.AspNetCore.Hosting;
|
using Microsoft.AspNetCore.Hosting;
|
||||||
using Microsoft.AspNetCore.Mvc.Testing;
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using NeonSprawl.Server.Game.Gathering;
|
||||||
using NeonSprawl.Server.Game.Items;
|
using NeonSprawl.Server.Game.Items;
|
||||||
using NeonSprawl.Server.Game.Mastery;
|
using NeonSprawl.Server.Game.Mastery;
|
||||||
using NeonSprawl.Server.Game.Skills;
|
using NeonSprawl.Server.Game.Skills;
|
||||||
|
|
@ -31,9 +32,13 @@ public sealed class PostgresWebApplicationFactory : WebApplicationFactory<Progra
|
||||||
var itemsDir = ItemCatalogPathResolution.TryDiscoverItemsDirectory(AppContext.BaseDirectory)
|
var itemsDir = ItemCatalogPathResolution.TryDiscoverItemsDirectory(AppContext.BaseDirectory)
|
||||||
?? throw new InvalidOperationException(
|
?? throw new InvalidOperationException(
|
||||||
"Could not discover repo content/items from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
"Could not discover repo content/items from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||||
|
var resourceNodesDir = ResourceNodeCatalogPathResolution.TryDiscoverResourceNodesDirectory(AppContext.BaseDirectory)
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
"Could not discover repo content/resource-nodes from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||||
builder.UseSetting("Content:SkillsDirectory", skillsDir);
|
builder.UseSetting("Content:SkillsDirectory", skillsDir);
|
||||||
builder.UseSetting("Content:MasteryDirectory", masteryDir);
|
builder.UseSetting("Content:MasteryDirectory", masteryDir);
|
||||||
builder.UseSetting("Content:ItemsDirectory", itemsDir);
|
builder.UseSetting("Content:ItemsDirectory", itemsDir);
|
||||||
|
builder.UseSetting("Content:ResourceNodesDirectory", resourceNodesDir);
|
||||||
|
|
||||||
builder.ConfigureAppConfiguration((_, config) =>
|
builder.ConfigureAppConfiguration((_, config) =>
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using Microsoft.Extensions.Time.Testing;
|
using Microsoft.Extensions.Time.Testing;
|
||||||
using NeonSprawl.Server.Game.AbilityInput;
|
using NeonSprawl.Server.Game.AbilityInput;
|
||||||
|
using NeonSprawl.Server.Game.Gathering;
|
||||||
using NeonSprawl.Server.Game.Items;
|
using NeonSprawl.Server.Game.Items;
|
||||||
using NeonSprawl.Server.Game.PositionState;
|
using NeonSprawl.Server.Game.PositionState;
|
||||||
using NeonSprawl.Server.Game.Mastery;
|
using NeonSprawl.Server.Game.Mastery;
|
||||||
|
|
@ -34,9 +35,13 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
|
||||||
var itemsDir = ItemCatalogPathResolution.TryDiscoverItemsDirectory(AppContext.BaseDirectory)
|
var itemsDir = ItemCatalogPathResolution.TryDiscoverItemsDirectory(AppContext.BaseDirectory)
|
||||||
?? throw new InvalidOperationException(
|
?? throw new InvalidOperationException(
|
||||||
"Could not discover repo content/items from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
"Could not discover repo content/items from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||||
|
var resourceNodesDir = ResourceNodeCatalogPathResolution.TryDiscoverResourceNodesDirectory(AppContext.BaseDirectory)
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
"Could not discover repo content/resource-nodes from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
|
||||||
builder.UseSetting("Content:SkillsDirectory", skillsDir);
|
builder.UseSetting("Content:SkillsDirectory", skillsDir);
|
||||||
builder.UseSetting("Content:MasteryDirectory", masteryDir);
|
builder.UseSetting("Content:MasteryDirectory", masteryDir);
|
||||||
builder.UseSetting("Content:ItemsDirectory", itemsDir);
|
builder.UseSetting("Content:ItemsDirectory", itemsDir);
|
||||||
|
builder.UseSetting("Content:ResourceNodesDirectory", resourceNodesDir);
|
||||||
builder.UseSetting("Game:EnableMasteryFixtureApi", "true");
|
builder.UseSetting("Game:EnableMasteryFixtureApi", "true");
|
||||||
|
|
||||||
builder.ConfigureTestServices(services =>
|
builder.ConfigureTestServices(services =>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
using System.Collections.Frozen;
|
||||||
|
|
||||||
|
namespace NeonSprawl.Server.Game.Gathering;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Prototype Slice 2 roster gate (NEO-57), mirrored from <c>scripts/validate_content.py</c>
|
||||||
|
/// <c>PROTOTYPE_SLICE2_*</c> / <c>_prototype_slice2_*_gate</c>.
|
||||||
|
/// </summary>
|
||||||
|
public static class PrototypeSlice2ResourceNodeCatalogRules
|
||||||
|
{
|
||||||
|
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_SLICE2_NODE_IDS</c>.</summary>
|
||||||
|
public static readonly FrozenSet<string> ExpectedNodeDefIds = FrozenSet.ToFrozenSet(
|
||||||
|
[
|
||||||
|
"prototype_resource_node_alpha",
|
||||||
|
"prototype_subsurface_vein_beta",
|
||||||
|
"prototype_bio_mat_gamma",
|
||||||
|
"prototype_urban_bulk_delta",
|
||||||
|
],
|
||||||
|
StringComparer.Ordinal);
|
||||||
|
|
||||||
|
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_SLICE2_GATHER_LENSES</c>.</summary>
|
||||||
|
public static readonly FrozenSet<string> ExpectedGatherLenses = FrozenSet.ToFrozenSet(
|
||||||
|
["consumer_salvage", "subsurface", "bio", "urban_bulk"],
|
||||||
|
StringComparer.Ordinal);
|
||||||
|
|
||||||
|
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_SLICE2_YIELD_ITEM_IDS</c>.</summary>
|
||||||
|
public static readonly FrozenSet<string> ExpectedYieldItemIds = FrozenSet.ToFrozenSet(
|
||||||
|
["scrap_metal_bulk"],
|
||||||
|
StringComparer.Ordinal);
|
||||||
|
|
||||||
|
/// <summary>Returns a human-readable error if the Slice 2 node contract fails, otherwise <see langword="null"/>.</summary>
|
||||||
|
public static string? TryGetSlice2NodeGateError(
|
||||||
|
IReadOnlyDictionary<string, string> nodeDefIdToSourceFile,
|
||||||
|
IReadOnlyDictionary<string, string> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns a human-readable error if the Slice 2 yield contract fails, otherwise <see langword="null"/>.</summary>
|
||||||
|
public static string? TryGetSlice2YieldGateError(
|
||||||
|
IReadOnlySet<string> yieldNodeDefIds,
|
||||||
|
IReadOnlySet<string> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
|
||||||
|
namespace NeonSprawl.Server.Game.Gathering;
|
||||||
|
|
||||||
|
/// <summary>In-memory resource-node catalog loaded at startup (NEO-58). Prefer registry in NEO-59 for game lookups.</summary>
|
||||||
|
public sealed class ResourceNodeCatalog(
|
||||||
|
string resourceNodesDirectory,
|
||||||
|
IReadOnlyDictionary<string, ResourceNodeDefRow> nodesById,
|
||||||
|
IReadOnlyDictionary<string, ResourceYieldRow> yieldsByNodeDefId,
|
||||||
|
int nodeCatalogJsonFileCount,
|
||||||
|
int yieldCatalogJsonFileCount)
|
||||||
|
{
|
||||||
|
/// <summary>Absolute path to the directory enumerated for <c>*_resource_nodes.json</c> / <c>*_resource_yields.json</c>.</summary>
|
||||||
|
public string ResourceNodesDirectory { get; } = resourceNodesDirectory;
|
||||||
|
|
||||||
|
public IReadOnlyDictionary<string, ResourceNodeDefRow> NodesById { get; } =
|
||||||
|
new ReadOnlyDictionary<string, ResourceNodeDefRow>(new Dictionary<string, ResourceNodeDefRow>(nodesById, StringComparer.Ordinal));
|
||||||
|
|
||||||
|
public IReadOnlyDictionary<string, ResourceYieldRow> YieldsByNodeDefId { get; } =
|
||||||
|
new ReadOnlyDictionary<string, ResourceYieldRow>(new Dictionary<string, ResourceYieldRow>(yieldsByNodeDefId, StringComparer.Ordinal));
|
||||||
|
|
||||||
|
public int DistinctNodeCount => NodesById.Count;
|
||||||
|
|
||||||
|
public int DistinctYieldCount => YieldsByNodeDefId.Count;
|
||||||
|
|
||||||
|
/// <summary>Number of <c>*_resource_nodes.json</c> files under <see cref="ResourceNodesDirectory"/>.</summary>
|
||||||
|
public int NodeCatalogJsonFileCount { get; } = nodeCatalogJsonFileCount;
|
||||||
|
|
||||||
|
/// <summary>Number of <c>*_resource_yields.json</c> files under <see cref="ResourceNodesDirectory"/>.</summary>
|
||||||
|
public int YieldCatalogJsonFileCount { get; } = yieldCatalogJsonFileCount;
|
||||||
|
|
||||||
|
/// <summary>Resolves a node def by stable <paramref name="nodeDefId"/>.</summary>
|
||||||
|
public bool TryGetNode(string nodeDefId, out ResourceNodeDefRow? row) =>
|
||||||
|
NodesById.TryGetValue(nodeDefId, out row);
|
||||||
|
|
||||||
|
/// <summary>Resolves the fixed yield row for <paramref name="nodeDefId"/>.</summary>
|
||||||
|
public bool TryGetYield(string nodeDefId, out ResourceYieldRow? row) =>
|
||||||
|
YieldsByNodeDefId.TryGetValue(nodeDefId, out row);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
/// <summary>Loads and validates <c>content/resource-nodes/*.json</c> using the same rules as <c>scripts/validate_content.py</c> (NEO-58).</summary>
|
||||||
|
public static class ResourceNodeCatalogLoader
|
||||||
|
{
|
||||||
|
/// <summary>Loads catalogs from disk or throws <see cref="InvalidOperationException"/> with actionable messages.</summary>
|
||||||
|
public static ResourceNodeCatalog Load(
|
||||||
|
string resourceNodesDirectory,
|
||||||
|
string nodeSchemaPath,
|
||||||
|
string yieldSchemaPath,
|
||||||
|
IReadOnlySet<string> knownItemIds,
|
||||||
|
ILogger logger)
|
||||||
|
{
|
||||||
|
resourceNodesDirectory = Path.GetFullPath(resourceNodesDirectory);
|
||||||
|
nodeSchemaPath = Path.GetFullPath(nodeSchemaPath);
|
||||||
|
yieldSchemaPath = Path.GetFullPath(yieldSchemaPath);
|
||||||
|
|
||||||
|
var errors = new List<string>();
|
||||||
|
|
||||||
|
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<string, string>(StringComparer.Ordinal);
|
||||||
|
var idToGatherLens = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||||
|
var nodes = new Dictionary<string, ResourceNodeDefRow>(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<int>(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<string>();
|
||||||
|
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<string>();
|
||||||
|
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<string, string>(StringComparer.Ordinal);
|
||||||
|
var yieldItemIds = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
var yields = new Dictionary<string, ResourceYieldRow>(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<int>(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<string>();
|
||||||
|
var itemId = (rowObj["itemId"] as JsonValue)?.GetValue<string>();
|
||||||
|
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<string>();
|
||||||
|
var displayName = (rowObj["displayName"] as JsonValue)!.GetValue<string>();
|
||||||
|
var gatherLens = (rowObj["gatherLens"] as JsonValue)!.GetValue<string>();
|
||||||
|
var maxGathers = (rowObj["maxGathers"] as JsonValue)!.GetValue<int>();
|
||||||
|
return new ResourceNodeDefRow(nodeDefId, displayName, gatherLens, maxGathers);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ResourceYieldRow ParseYieldRow(JsonObject rowObj)
|
||||||
|
{
|
||||||
|
var nodeDefId = (rowObj["nodeDefId"] as JsonValue)!.GetValue<string>();
|
||||||
|
var itemId = (rowObj["itemId"] as JsonValue)!.GetValue<string>();
|
||||||
|
var quantity = (rowObj["quantity"] as JsonValue)!.GetValue<int>();
|
||||||
|
return new ResourceYieldRow(nodeDefId, itemId, quantity);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<string> CollectSchemaMessages(EvaluationResults eval, string filePath, string arrayName, int index)
|
||||||
|
{
|
||||||
|
var sink = new List<string>();
|
||||||
|
AppendSchemaMessages(eval, filePath, arrayName, index, sink);
|
||||||
|
return sink;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AppendSchemaMessages(
|
||||||
|
EvaluationResults r,
|
||||||
|
string filePath,
|
||||||
|
string arrayName,
|
||||||
|
int index,
|
||||||
|
List<string> 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<string> 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
namespace NeonSprawl.Server.Game.Gathering;
|
||||||
|
|
||||||
|
/// <summary>Resolves resource-node catalog paths for local dev, tests, and container layouts (NEO-58).</summary>
|
||||||
|
public static class ResourceNodeCatalogPathResolution
|
||||||
|
{
|
||||||
|
/// <summary>Walks <paramref name="startDirectory"/> and parents for an existing <c>content/resource-nodes</c> directory.</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves the resource-nodes catalog directory.
|
||||||
|
/// Empty <paramref name="configuredResourceNodesDirectory"/> triggers discovery from <see cref="AppContext.BaseDirectory"/>.
|
||||||
|
/// </summary>
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Resolves JSON Schema path for a single resource node row (Draft 2020-12).</summary>
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Resolves JSON Schema path for a single resource yield row (Draft 2020-12).</summary>
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
/// <summary>DI registration for the fail-fast resource-node catalog (NEO-58).</summary>
|
||||||
|
public static class ResourceNodeCatalogServiceCollectionExtensions
|
||||||
|
{
|
||||||
|
/// <summary>Binds <see cref="ContentPathsOptions"/> and registers <see cref="ResourceNodeCatalog"/> as a singleton.</summary>
|
||||||
|
public static IServiceCollection AddResourceNodeCatalog(this IServiceCollection services, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
services.AddOptions<ContentPathsOptions>()
|
||||||
|
.Bind(configuration.GetSection(ContentPathsOptions.SectionName));
|
||||||
|
|
||||||
|
services.AddSingleton<ResourceNodeCatalog>(sp =>
|
||||||
|
{
|
||||||
|
var hostEnv = sp.GetRequiredService<IHostEnvironment>();
|
||||||
|
var opts = sp.GetRequiredService<IOptions<ContentPathsOptions>>().Value;
|
||||||
|
var itemCatalog = sp.GetRequiredService<ItemDefinitionCatalog>();
|
||||||
|
var logger = sp.GetRequiredService<ILoggerFactory>()
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
namespace NeonSprawl.Server.Game.Gathering;
|
||||||
|
|
||||||
|
/// <summary>One validated <c>ResourceNodeDef</c> row from <c>content/resource-nodes/*_resource_nodes.json</c> (NEO-58).</summary>
|
||||||
|
public sealed record ResourceNodeDefRow(
|
||||||
|
string NodeDefId,
|
||||||
|
string DisplayName,
|
||||||
|
string GatherLens,
|
||||||
|
int MaxGathers);
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
namespace NeonSprawl.Server.Game.Gathering;
|
||||||
|
|
||||||
|
/// <summary>One validated yield row from <c>content/resource-nodes/*_resource_yields.json</c> (NEO-58).</summary>
|
||||||
|
public sealed record ResourceYieldRow(
|
||||||
|
string NodeDefId,
|
||||||
|
string ItemId,
|
||||||
|
int Quantity);
|
||||||
|
|
@ -40,4 +40,22 @@ public sealed class ContentPathsOptions
|
||||||
/// When unset, resolved as <c>{parent of items directory}/schemas/item-def.schema.json</c>.
|
/// When unset, resolved as <c>{parent of items directory}/schemas/item-def.schema.json</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string? ItemDefSchemaPath { get; set; }
|
public string? ItemDefSchemaPath { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional. Absolute path, or path relative to <see cref="Microsoft.Extensions.Hosting.IHostEnvironment.ContentRootPath"/>.
|
||||||
|
/// When unset, the host walks ancestors of <see cref="AppContext.BaseDirectory"/> for a <c>content/resource-nodes</c> directory.
|
||||||
|
/// </summary>
|
||||||
|
public string? ResourceNodesDirectory { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional override for <c>resource-node-def.schema.json</c>.
|
||||||
|
/// When unset, resolved as <c>{parent of resource-nodes directory}/schemas/resource-node-def.schema.json</c>.
|
||||||
|
/// </summary>
|
||||||
|
public string? ResourceNodeDefSchemaPath { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional override for <c>resource-yield-row.schema.json</c>.
|
||||||
|
/// When unset, resolved as <c>{parent of resource-nodes directory}/schemas/resource-yield-row.schema.json</c>.
|
||||||
|
/// </summary>
|
||||||
|
public string? ResourceYieldRowSchemaPath { get; set; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using NeonSprawl.Server.Game.AbilityInput;
|
using NeonSprawl.Server.Game.AbilityInput;
|
||||||
|
using NeonSprawl.Server.Game.Gathering;
|
||||||
using NeonSprawl.Server.Game.Interaction;
|
using NeonSprawl.Server.Game.Interaction;
|
||||||
using NeonSprawl.Server.Game.Items;
|
using NeonSprawl.Server.Game.Items;
|
||||||
using NeonSprawl.Server.Game.Mastery;
|
using NeonSprawl.Server.Game.Mastery;
|
||||||
|
|
@ -15,11 +16,13 @@ builder.Services.AddAbilityCooldownStore();
|
||||||
builder.Services.AddSingleton<IPlayerTargetLockStore, InMemoryPlayerTargetLockStore>();
|
builder.Services.AddSingleton<IPlayerTargetLockStore, InMemoryPlayerTargetLockStore>();
|
||||||
builder.Services.AddSkillDefinitionCatalog(builder.Configuration);
|
builder.Services.AddSkillDefinitionCatalog(builder.Configuration);
|
||||||
builder.Services.AddItemDefinitionCatalog(builder.Configuration);
|
builder.Services.AddItemDefinitionCatalog(builder.Configuration);
|
||||||
|
builder.Services.AddResourceNodeCatalog(builder.Configuration);
|
||||||
builder.Services.AddMasteryCatalog(builder.Configuration);
|
builder.Services.AddMasteryCatalog(builder.Configuration);
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
_ = app.Services.GetRequiredService<SkillDefinitionCatalog>();
|
_ = app.Services.GetRequiredService<SkillDefinitionCatalog>();
|
||||||
_ = app.Services.GetRequiredService<ItemDefinitionCatalog>();
|
_ = app.Services.GetRequiredService<ItemDefinitionCatalog>();
|
||||||
|
_ = app.Services.GetRequiredService<ResourceNodeCatalog>();
|
||||||
_ = app.Services.GetRequiredService<MasteryCatalog>();
|
_ = app.Services.GetRequiredService<MasteryCatalog>();
|
||||||
_ = app.Services.GetRequiredService<ISkillLevelCurve>();
|
_ = app.Services.GetRequiredService<ISkillLevelCurve>();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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).
|
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)
|
## 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/`.
|
**`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/`.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue