Merge pull request #93 from ViPro-Technologies/NEO-58-e3m1-server-resource-node-catalog-load-fail-fast
NEO-58: E3.M1 server resource-node catalog load (fail-fast)pull/94/head
commit
6748a848af
|
|
@ -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.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) |
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,120 @@
|
|||
# NEO-58 — Implementation plan
|
||||
|
||||
## Story reference
|
||||
|
||||
| Field | Value |
|
||||
|--------|--------|
|
||||
| **Key** | NEO-58 |
|
||||
| **Title** | E3.M1: Server resource-node catalog load (fail-fast) |
|
||||
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-58/e3m1-server-resource-node-catalog-load-fail-fast |
|
||||
| **Module** | [E3.M1 — ResourceNodeAndGatherLoop](../decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md) · Epic 3 Slice 2 · backlog **E3M1-02** |
|
||||
| **Branch** | `NEO-58-e3m1-server-resource-node-catalog-load-fail-fast` (rebased on `main` after NEO-57 merge PR #92) |
|
||||
| **Precursor** | [NEO-57](https://linear.app/neon-sprawl/issue/NEO-57) — `content/resource-nodes/` + CI gates (**merged** to `main` via PR #92) |
|
||||
| **Pattern** | [NEO-51](https://linear.app/neon-sprawl/issue/NEO-51) — fail-fast catalog loader + `ContentPathsOptions` + eager `Program.cs` resolve; strict split before registry ticket ([NEO-59](https://linear.app/neon-sprawl/issue/NEO-59)) |
|
||||
|
||||
## Kickoff clarifications
|
||||
|
||||
| Topic | Question | Agent recommendation | Answer |
|
||||
|--------|----------|----------------------|--------|
|
||||
| **Branch base** | Stack on NEO-57 vs branch from `main`? | **Branch from NEO-57** — Linear `blockedBy` NEO-57; loader requires content + schemas from E3M1-01. | **User:** branch from NEO-57 (stacked PR). |
|
||||
| **DI scope (E3M1-02 vs E3M1-03)** | Register `IResourceNodeDefinitionRegistry` now? | **`ResourceNodeCatalog` singleton only** — E3M1-03 ([NEO-59](https://linear.app/neon-sprawl/issue/NEO-59)) owns injectable registry; mirrors NEO-51 / NEO-52 split for items. | **User:** catalog singleton only. |
|
||||
| **Item cross-check** | How to validate yield `itemId` at startup? | **Pass ids from `ItemDefinitionCatalog`** in the DI factory (items load first; no second disk read). | **User:** cross-check from `ItemDefinitionCatalog`. |
|
||||
| **Runtime validation** | In-process C# vs subprocess `validate_content.py`? | **In-process C#** mirroring `scripts/validate_content.py` (NEO-51 / NEO-34 precedent). | **Adopted** — no separate question. |
|
||||
| **Host boot tests** | Loader unit tests only vs also `WebApplicationFactory`? | **Both** — mirror `ItemDefinitionCatalogLoaderTests` (happy path, failure modes, `Host_ShouldResolveCatalogFromDi`, `Host_ShouldFailStartup_WhenCatalogDirectoryInvalid`). | **Adopted** — NEO-51 precedent. |
|
||||
|
||||
## Goal, scope, and out-of-scope
|
||||
|
||||
**Goal:** On host startup, load `content/resource-nodes/*_resource_nodes.json` and `*_resource_yields.json` with the same validation rules as CI ([NEO-57](NEO-57-implementation-plan.md)), build an in-memory **`ResourceNodeCatalog`**, cross-check yield **`itemId`** values against the already-loaded item catalog, and **refuse to listen** when any file is missing, malformed, or fails the prototype Slice 2 gate.
|
||||
|
||||
**In scope (from Linear + [E3M1-02](E3M1-prototype-backlog.md#e3m1-02--server-resource-node-catalog-load-fail-fast)):**
|
||||
|
||||
- Loader + catalog types under `server/NeonSprawl.Server/Game/Gathering/`.
|
||||
- Configurable resource-nodes directory and row schema paths (`ContentPathsOptions` extension).
|
||||
- CI-parity validation: `schemaVersion`, row schemas, duplicate `nodeDefId`, frozen four ids, four `gatherLens`, one yield per node, yield `nodeDefId` exists in nodes, yield `itemId` in item catalog + Slice 2 `scrap_metal_bulk`-only gate.
|
||||
- DI registration of **`ResourceNodeCatalog`** singleton (factory depends on `ItemDefinitionCatalog`); eager resolve in `Program.cs` after item catalog.
|
||||
- Unit + host startup tests (AAA).
|
||||
|
||||
**Out of scope (from Linear):**
|
||||
|
||||
- **`IResourceNodeDefinitionRegistry`** / `ResourceNodeDefinitionRegistry` (**NEO-59**, E3M1-03).
|
||||
- Per-instance depletion, HTTP (`GET …/resource-node-definitions` is **NEO-60**), interact wiring, Bruno, client.
|
||||
|
||||
## Acceptance criteria checklist
|
||||
|
||||
- [x] Server refuses boot when resource-node catalog invalid (missing dir, no `*_resource_nodes.json` / `*_resource_yields.json`, bad JSON, `schemaVersion` ≠ 1, schema violation, duplicate `nodeDefId`, Slice 2 gate failure, unknown yield `itemId`, orphan yield `nodeDefId`).
|
||||
- [x] `ResourceNodeCatalog` resolves node def and yield row by `nodeDefId` (e.g. `prototype_resource_node_alpha` → lens + `maxGathers` + yield quantity).
|
||||
- [x] Unit tests (AAA): loader happy path + failure modes; host resolves catalog on success; host fails startup on invalid catalog directory.
|
||||
- [x] Success log includes **node count**, **yield count**, **catalog directory**, and **file counts** (Information).
|
||||
|
||||
## Technical approach
|
||||
|
||||
1. **`Game/Gathering/` row DTOs** — `ResourceNodeDefRow` (`NodeDefId`, `DisplayName`, `GatherLens`, `MaxGathers`); `ResourceYieldRow` (`NodeDefId`, `ItemId`, `Quantity`).
|
||||
|
||||
2. **`ResourceNodeCatalogLoader.Load(resourceNodesDirectory, nodeSchemaPath, yieldSchemaPath, itemIds, logger)`** — two-pass load mirroring `validate_content.py`:
|
||||
- Enumerate `*_resource_nodes.json` then `*_resource_yields.json` (top-level only), ordered by path.
|
||||
- **Nodes pass:** per file `schemaVersion == 1`, top-level `nodes` array; per row validate `resource-node-def.schema.json`; track `nodeDefId`, `gatherLens`; reject duplicate `nodeDefId` across files.
|
||||
- Run **`PrototypeSlice2ResourceNodeCatalogRules.TryGetSlice2NodeGateError`** (mirror `PROTOTYPE_SLICE2_NODE_IDS`, `PROTOTYPE_SLICE2_GATHER_LENSES`).
|
||||
- **Yields pass:** per file `schemaVersion == 1`, top-level `yields` array; per row validate `resource-yield-row.schema.json`; reject duplicate yield `nodeDefId`; require yield `nodeDefId` in loaded nodes; require `itemId` ∈ `itemIds`; run Slice 2 yield item gate (`scrap_metal_bulk` only); require exactly one yield per frozen `nodeDefId`.
|
||||
- Throw **`InvalidOperationException`** with aggregated messages prefixed `Resource node catalog validation failed:`.
|
||||
|
||||
3. **`ResourceNodeCatalog`** — immutable snapshot: `ResourceNodesDirectory`, `NodesById`, `YieldsByNodeDefId`, counts; `TryGetNode(nodeDefId, out ResourceNodeDefRow?)`, `TryGetYield(nodeDefId, out ResourceYieldRow?)`.
|
||||
|
||||
4. **`ResourceNodeCatalogPathResolution`** — `TryDiscoverResourceNodesDirectory`, `ResolveResourceNodesDirectory`, `ResolveResourceNodeDefSchemaPath`, `ResolveResourceYieldRowSchemaPath` (parent `content/schemas/` layout when unset).
|
||||
|
||||
5. **`PrototypeSlice2ResourceNodeCatalogRules`** — frozen four `nodeDefId`s, four `gatherLens`, yield item allowlist; sync comments to `scripts/validate_content.py` `PROTOTYPE_SLICE2_*`.
|
||||
|
||||
6. **`ResourceNodeCatalogServiceCollectionExtensions.AddResourceNodeCatalog`** — bind `ContentPathsOptions`; register **`ResourceNodeCatalog`** singleton factory that resolves paths, gets `ItemDefinitionCatalog.ById.Keys` as `FrozenSet<string>`, calls loader.
|
||||
|
||||
7. **`Program.cs`** — `AddResourceNodeCatalog(configuration)` after `AddItemDefinitionCatalog`; after `Build()`, `GetRequiredService<ResourceNodeCatalog>()` alongside existing catalog eager resolves.
|
||||
|
||||
8. **Tests** — temp-dir fixtures copying repo schemas; valid four-node + four-yield JSON matching [prototype_resource_nodes.json](../../content/resource-nodes/prototype_resource_nodes.json); six-item item id set stub for cross-check; negative cases (duplicate id, missing yield, bad lens set, unknown `itemId`, invalid directory); `InMemoryWebApplicationFactory` / `WebApplicationFactory` host tests mirroring items.
|
||||
|
||||
## Files to add
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `server/NeonSprawl.Server/Game/Gathering/ResourceNodeDefRow.cs` | Load-time node row DTO. |
|
||||
| `server/NeonSprawl.Server/Game/Gathering/ResourceYieldRow.cs` | Load-time yield row DTO. |
|
||||
| `server/NeonSprawl.Server/Game/Gathering/ResourceNodeCatalog.cs` | In-memory catalog + `TryGetNode` / `TryGetYield`. |
|
||||
| `server/NeonSprawl.Server/Game/Gathering/ResourceNodeCatalogLoader.cs` | Disk I/O, schema validation, Slice 2 gates, item cross-check. |
|
||||
| `server/NeonSprawl.Server/Game/Gathering/ResourceNodeCatalogPathResolution.cs` | Directory/schema discovery and config resolution. |
|
||||
| `server/NeonSprawl.Server/Game/Gathering/PrototypeSlice2ResourceNodeCatalogRules.cs` | Frozen four ids / four lenses / yield item gate (sync comment to Python). |
|
||||
| `server/NeonSprawl.Server/Game/Gathering/ResourceNodeCatalogServiceCollectionExtensions.cs` | `AddResourceNodeCatalog` DI registration. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Gathering/ResourceNodeCatalogTestPaths.cs` | Repo `content/resource-nodes` + schema discovery for tests. |
|
||||
| `server/NeonSprawl.Server.Tests/Game/Gathering/ResourceNodeCatalogLoaderTests.cs` | AAA loader + host startup tests. |
|
||||
|
||||
## Files to modify
|
||||
|
||||
| Path | Rationale |
|
||||
|------|-----------|
|
||||
| `server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs` | Add `ResourceNodesDirectory`, `ResourceNodeDefSchemaPath`, `ResourceYieldRowSchemaPath` under `Content` section. |
|
||||
| `server/NeonSprawl.Server/Program.cs` | Register and eagerly resolve `ResourceNodeCatalog` at startup (after item catalog). |
|
||||
| `server/README.md` | Document resource-node catalog load, config keys, fail-fast behavior, Slice 2 parity with CI. |
|
||||
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E3.M1 / NEO-58 server loader status when implementation completes. |
|
||||
|
||||
## Tests
|
||||
|
||||
| Item | Coverage |
|
||||
|------|----------|
|
||||
| `server/NeonSprawl.Server.Tests/Game/Gathering/ResourceNodeCatalogLoaderTests.cs` | **Add** — AAA: `Load` happy path with repo-shaped JSON; failures for missing dir/schema, bad `schemaVersion`, schema violations, duplicate `nodeDefId`, incomplete Slice 2 ids, lens set mismatch, orphan yield `nodeDefId`, unknown `itemId`, duplicate yield `nodeDefId`, missing yield for node, empty `*_resource_nodes.json` / `*_resource_yields.json` directories; `Host_ShouldResolveCatalogFromDi`; `Host_ShouldFailStartup_WhenCatalogDirectoryInvalid`. |
|
||||
| `server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs` | **Change** if needed — ensure resource-node content is discoverable in test host (same ancestor walk as items). |
|
||||
| **CI / PR gate** | Existing `scripts/validate_content.py` remains content regression; server tests guard C#/Python drift for Slice 2 constants. |
|
||||
| **Bruno smoke (optional)** | `bruno/neon-sprawl-server/resource-node-catalog/Health after resource node catalog load.bru` — `GET /health` after boot; no resource-node HTTP API this story (pre-commit hook when `Program.cs` changes). |
|
||||
|
||||
No client tests (out of scope).
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
| Question / risk | Agent recommendation | Status |
|
||||
|-----------------|----------------------|--------|
|
||||
| **Stacked PR on NEO-57** | NEO-57 merged (PR #92); NEO-58 rebased onto `main`. | **adopted** |
|
||||
| **C# vs Python drift** | Duplicate `PROTOTYPE_SLICE2_*` in `PrototypeSlice2ResourceNodeCatalogRules` with “keep in sync” comment (NEO-50 / NEO-57 pattern). | **adopted** |
|
||||
| **Test factories without NEO-57 on main** | NEO-58 branch includes NEO-57 content via stack; after NEO-57 merges, rebase drops dependency. | **adopted** |
|
||||
| **Registry consumers** | Gather engine / HTTP use `IResourceNodeDefinitionRegistry` in NEO-59+; this story only exposes `ResourceNodeCatalog` for boot + direct test lookup. | **adopted** |
|
||||
|
||||
## Decisions (kickoff)
|
||||
|
||||
- **Branch from NEO-57** for initial stacked PR; rebased onto `main` after NEO-57 merged (PR #92).
|
||||
- **`ResourceNodeCatalog` singleton only** — defer `IResourceNodeDefinitionRegistry` to NEO-59.
|
||||
- **Yield `itemId` cross-check** uses `ItemDefinitionCatalog` keys passed into loader at DI registration time.
|
||||
- **In-process validation** + **loader and host boot tests** per NEO-51 precedent.
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
# Code review — NEO-58 server resource-node catalog load (fail-fast)
|
||||
|
||||
**Date:** 2026-05-24
|
||||
**Scope:** Branch `NEO-58-e3m1-server-resource-node-catalog-load-fail-fast` · commits `2de699b`–`fdaf5b7` vs `main` (plan + implementation + rebase note)
|
||||
**Base:** `main`
|
||||
|
||||
## Verdict
|
||||
|
||||
**Approve with nits**
|
||||
|
||||
## Summary
|
||||
|
||||
NEO-58 adds fail-fast startup loading for `content/resource-nodes/*_resource_nodes.json` and `*_resource_yields.json` under `Game/Gathering/`, mirroring the NEO-51 item-catalog pattern: path discovery via `ContentPathsOptions`, in-process JSON Schema validation, duplicate `nodeDefId` detection, prototype Slice 2 gates (`PrototypeSlice2ResourceNodeCatalogRules` synced with `scripts/validate_content.py`), and yield `itemId` cross-check against `ItemDefinitionCatalog`. DI registers `ResourceNodeCatalog` after items; `Program.cs` eagerly resolves it at boot. Seventeen focused loader/host tests pass with full AAA. Docs (implementation plan, E3M1 backlog, alignment table, server README) match the landed behavior. Risk is low; main drift surface is duplicated Slice 2 constants in C# vs Python (documented, same as NEO-50/NEO-57).
|
||||
|
||||
## Documentation checked
|
||||
|
||||
| Document | Result |
|
||||
|----------|--------|
|
||||
| [`docs/plans/NEO-58-implementation-plan.md`](../plans/NEO-58-implementation-plan.md) | **Matches** — scope, acceptance checklist, technical approach, and file list align with the diff; checklist marked complete. |
|
||||
| [`docs/plans/E3M1-prototype-backlog.md`](../plans/E3M1-prototype-backlog.md) | **Matches** — E3M1-02 acceptance flipped with NEO-58 landed note. |
|
||||
| [`docs/decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md`](../decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md) | **Matches** — content contracts and Slice 2 freeze; server load is the planned E3M1-02 step (registry/depletion still open). |
|
||||
| [`docs/decomposition/modules/documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) | **Matches** — E3.M1 row notes NEO-58 server loader + README link. |
|
||||
| [`docs/plans/NEO-57-implementation-plan.md`](../plans/NEO-57-implementation-plan.md) | **Matches** — C# loader enforces the same gates CI added in NEO-57. |
|
||||
| [`docs/decomposition/modules/client_server_authority.md`](../decomposition/modules/client_server_authority.md) | **N/A** — no runtime gather authority change; startup validation only. |
|
||||
| [`docs/decomposition/modules/CT_M1_ContentValidationPipeline.md`](../decomposition/modules/CT_M1_ContentValidationPipeline.md) | **Matches** — PR gate remains `validate_content.py`; server tests guard C#/Python Slice 2 drift. |
|
||||
|
||||
Register/tracking: alignment table updated; no further register change required beyond this row.
|
||||
|
||||
## Blocking issues
|
||||
|
||||
None.
|
||||
|
||||
## Suggestions
|
||||
|
||||
1. ~~**Plan vs Bruno** — [`NEO-58-implementation-plan.md`](../plans/NEO-58-implementation-plan.md) lists Bruno as out of scope, but `bruno/neon-sprawl-server/resource-node-catalog/` adds a health smoke request. Either add a one-line note under **Tests** (“optional Bruno smoke”) or drop the folder to keep plan scope literal; the request itself is fine for manual boot verification.~~ **Done.** Plan **Tests** table now documents optional Bruno health smoke (pre-commit when `Program.cs` changes).
|
||||
|
||||
2. ~~**Explicit empty-catalog file test (optional)** — `Host_ShouldFailStartup_WhenCatalogDirectoryInvalid` covers an empty directory; a loader unit test asserting `no *_resource_nodes.json files` (and yields) would mirror item-catalog negative coverage and make failure messages grep-friendly in CI logs.~~ **Done.** Added `Load_ShouldThrow_WhenNoResourceNodesJsonFiles` and `Load_ShouldThrow_WhenNoResourceYieldsJsonFiles`.
|
||||
|
||||
## Nits
|
||||
|
||||
- Nit: Slice 2 lens gate checks **set** equality (same as Python/NEO-57); duplicate `gatherLens` on two nodes still fails via set mismatch — acceptable for prototype.
|
||||
|
||||
- ~~Nit: `Host_ShouldResolveCatalogFromDi_WhenStartupSucceeds` uses `/health` in **Act** (NEO-51 item-catalog host test does the same); strict AAA would move the HTTP call to **Arrange** and keep **Act** as `GetRequiredService<ResourceNodeCatalog>()` only.~~ **Done.** Host test is sync; **Act** is `GetRequiredService<ResourceNodeCatalog>()` only (`CreateClient()` in **Arrange** builds the host).
|
||||
|
||||
- Nit: No `docs/manual-qa/NEO-58.md` — consistent with plan (server boot-only, no user-facing HTTP for resource nodes); Bruno health folder is sufficient smoke.
|
||||
|
||||
- Nit: C#/Python constant drift remains a maintenance coupling; sync comments on `PrototypeSlice2ResourceNodeCatalogRules` are present (NEO-50/NEO-57 precedent).
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd server && dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj --filter "FullyQualifiedName~ResourceNodeCatalog"
|
||||
cd server && dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj
|
||||
python3 scripts/validate_content.py
|
||||
```
|
||||
|
||||
Manual: start server from repo root (content discovery); confirm Information log with node/yield counts; point `Content__ResourceNodesDirectory` at an empty dir and confirm process refuses boot with `Resource node catalog validation failed`.
|
||||
|
|
@ -0,0 +1,586 @@
|
|||
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_WhenNoResourceNodesJsonFiles()
|
||||
{
|
||||
// Arrange
|
||||
var (_, resourceNodesDir, nodeSchemaPath, yieldSchemaPath) = CreateTempContentLayout();
|
||||
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("no *_resource_nodes.json files", ioe.Message, StringComparison.Ordinal);
|
||||
Assert.Contains(resourceNodesDir, ioe.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldThrow_WhenNoResourceYieldsJsonFiles()
|
||||
{
|
||||
// Arrange
|
||||
var (_, resourceNodesDir, nodeSchemaPath, yieldSchemaPath) = CreateTempContentLayout();
|
||||
WriteNodeCatalog(resourceNodesDir, ValidPrototypeNodeCatalogJson);
|
||||
// Act
|
||||
var ex = Record.Exception(() => ResourceNodeCatalogLoader.Load(
|
||||
resourceNodesDir,
|
||||
nodeSchemaPath,
|
||||
yieldSchemaPath,
|
||||
ValidPrototypeItemIds,
|
||||
NullLogger.Instance));
|
||||
// Assert
|
||||
var ioe = Assert.IsType<InvalidOperationException>(ex);
|
||||
Assert.Contains("no *_resource_yields.json files", ioe.Message, StringComparison.Ordinal);
|
||||
Assert.Contains(resourceNodesDir, 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 void Host_ShouldResolveCatalogFromDi_WhenStartupSucceeds()
|
||||
{
|
||||
// Arrange
|
||||
using var factory = new InMemoryWebApplicationFactory();
|
||||
_ = factory.CreateClient();
|
||||
// Act
|
||||
var catalog = factory.Services.GetRequiredService<ResourceNodeCatalog>();
|
||||
// Assert
|
||||
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.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<Progra
|
|||
var itemsDir = ItemCatalogPathResolution.TryDiscoverItemsDirectory(AppContext.BaseDirectory)
|
||||
?? throw new InvalidOperationException(
|
||||
"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:MasteryDirectory", masteryDir);
|
||||
builder.UseSetting("Content:ItemsDirectory", itemsDir);
|
||||
builder.UseSetting("Content:ResourceNodesDirectory", resourceNodesDir);
|
||||
|
||||
builder.ConfigureAppConfiguration((_, config) =>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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<Progra
|
|||
var itemsDir = ItemCatalogPathResolution.TryDiscoverItemsDirectory(AppContext.BaseDirectory)
|
||||
?? throw new InvalidOperationException(
|
||||
"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:MasteryDirectory", masteryDir);
|
||||
builder.UseSetting("Content:ItemsDirectory", itemsDir);
|
||||
builder.UseSetting("Content:ResourceNodesDirectory", resourceNodesDir);
|
||||
builder.UseSetting("Game:EnableMasteryFixtureApi", "true");
|
||||
|
||||
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>.
|
||||
/// </summary>
|
||||
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.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<IPlayerTargetLockStore, InMemoryPlayerTargetLockStore>();
|
||||
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<SkillDefinitionCatalog>();
|
||||
_ = app.Services.GetRequiredService<ItemDefinitionCatalog>();
|
||||
_ = app.Services.GetRequiredService<ResourceNodeCatalog>();
|
||||
_ = app.Services.GetRequiredService<MasteryCatalog>();
|
||||
_ = 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).
|
||||
|
||||
## 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/`.
|
||||
|
|
|
|||
Loading…
Reference in New Issue