Merge pull request #86 from ViPro-Technologies/NEO-51-server-item-catalog-load-fail-fast

NEO-51: Server loads item catalog at startup (fail-fast)
pull/87/head
VinPropane 2026-05-17 22:11:49 -04:00 committed by GitHub
commit da0562bdf4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 1043 additions and 6 deletions

View File

@ -43,6 +43,7 @@ When **blocking issues**, **suggestions**, or agreed **nits** from a saved **`do
- **What** was chosen (or rejected) and **why** in one short paragraph or bullets.
- **Outcomes** of verification (e.g. “`dotnet test` N passed; double-dispose safe on Npgsql 10”).
- If the user **explicitly** chose an option (e.g. “Option 2”), record that label so future readers do not re-litigate.
- For **open questions** still in the plan at kickoff or mid-story, keep the **agent recommendation** alongside each item (see [story-kickoff](story-kickoff.md) **Kickoff clarifications** and **Open questions / risks**); when resolved, move the outcome to **Decisions** and clear or update the open-question row.
## Agent behavior

View File

@ -11,7 +11,8 @@ When the user starts work on a **Linear issue** (e.g. issue key `NEO-5`, phrases
- During kickoff and while drafting the plan, **ask the user** about anything **unclear**, **underspecified**, or that **needs a decision** (scope, behavior, acceptance criteria, priorities, trade-offs, or what is out of scope). Do not guess or pick silent defaults when the issue, Linear notes, or repo context do not settle it.
- Use **concrete questions**, often with short options when helpful; group related questions so the user can answer in one pass.
- When the Cursor session supports it (**Agent** mode), prefer **`AskQuestion`** multiple-choice for blocking decisions (same pattern as [story-end](story-end.md) §4a for **Linear state** after “end story”) instead of only long prose lists.
- For **every** question or blocking decision presented to the user, state the **agents recommendation**: which option to pick (or what to do) and a **one-line rationale** tied to repo precedent, Linear AC, or decomposition docs. Do not list options without a stated preference unless there is genuinely no basis to recommend—in that case, say why.
- When the Cursor session supports it (**Agent** mode), prefer **`AskQuestion`** multiple-choice for blocking decisions (same pattern as [story-end](story-end.md) §4a for **Linear state** after “end story”) instead of only long prose lists. Put the **agent recommendation** in the chat message that introduces the questions (`AskQuestion` has no recommendation field).
### Clarifications gate (hard requirement)
@ -55,14 +56,14 @@ When the user starts work on a **Linear issue** (e.g. issue key `NEO-5`, phrases
**Required sections** in that file (do **not** ship a plan that omits any of these):
- Story reference (key, title, link if available)
- Kickoff clarifications (questions asked + answers, or explicit “No clarifications needed” with reason)
- Kickoff clarifications — table with **Topic**, **Question**, **Agent recommendation**, **Answer** (or explicit “No clarifications needed” with reason; no table required when zero questions)
- Goal, scope, and out-of-scope (from Linear)
- Acceptance criteria checklist (from Linear)
- Technical approach (concise)
- **Files to add****mandatory:** explicit list of new file paths (or state “none” with one line why)
- **Files to modify****mandatory:** explicit list of paths **with a one-line rationale each** (or state “none” with one line why)
- **Tests****mandatory:** explicit list of test files to **add** or **change**, and what each will cover; if truly no automated tests, say why (e.g. no harness yet) and what manual verification replaces them
- Open questions / risks (if none, write “None.”)
- Open questions / risks — each item: **question or risk**, **agent recommendation**, **status** (`pending` / `adopted` / `deferred`); if none, write “None.”
These three lists (**files to add**, **files to modify**, **tests**) must **always** be generated during kickoff—they are not optional prose and must not be left implicit inside “Technical approach” only.

View File

@ -0,0 +1,25 @@
meta {
name: GET health (item catalog boot NEO-51)
type: http
seq: 1
}
get {
url: {{baseUrl}}/health
body: none
auth: none
}
docs {
NEO-51 loads content/items/*_items.json at startup (fail-fast). No item 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");
});
}

View File

@ -0,0 +1,3 @@
meta {
name: item-catalog
}

View File

@ -62,6 +62,8 @@ The **first shipped six-item spine** under `content/items/*.json` is **frozen**
Epic 3 **Slice 1** — MVP inventory; `item_created`, transfer failures.
**Server load (NEO-51):** On host startup, `server/NeonSprawl.Server/Game/Items/` loads `content/items/*_items.json` with the same validation gates as CI (`scripts/validate_content.py`) and **refuses to listen** when the catalog is invalid. Config and discovery: [server README — Item catalog](../../../server/README.md#item-catalog-contentitems-neo-51). Plan: [NEO-51 implementation plan](../../plans/NEO-51-implementation-plan.md).
**Linear backlog (decomposed):** [E3M3-prototype-backlog.md](../../plans/E3M3-prototype-backlog.md) — **E3M3-01** [NEO-50](https://linear.app/neon-sprawl/issue/NEO-50) (content + CI) through **E3M3-07** [NEO-56](https://linear.app/neon-sprawl/issue/NEO-56) (telemetry hooks).
## Source anchors

View File

@ -54,7 +54,7 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not
| 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-37NEO-41, NEO-42NEO-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. **Still planned:** `GatherResult`, yields, inventory per [E3_M1](E3_M1_ResourceNodeAndGatherLoop.md). | [NEO-41](../../plans/NEO-41-implementation-plan.md), [E3_M1](E3_M1_ResourceNodeAndGatherLoop.md); `server/NeonSprawl.Server/Game/Interaction/`, `Game/Skills/` |
| 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) enforce schema, duplicate `id`, exact six ids, and **`prototypeRole`** coverage; designer note in [E3_M3](E3_M3_ItemizationAndInventorySchema.md) + [`content/README.md`](../../../content/README.md). **Still planned:** server load (NEO-51+), inventory store, HTTP. | [NEO-50](../../plans/NEO-50-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). **Still planned:** `IItemDefinitionRegistry` (NEO-52+), inventory store, HTTP. | [NEO-50](../../plans/NEO-50-implementation-plan.md), [NEO-51](../../plans/NEO-51-implementation-plan.md), [E3M3-prototype-backlog](../../plans/E3M3-prototype-backlog.md), [E3_M3](E3_M3_ItemizationAndInventorySchema.md) |
---

View File

@ -48,7 +48,7 @@ Fleshed-out scope, contracts, and integration notes live in **per-module documen
| E3.M4 | SinkAndDurabilityLifecycle | E3.M3, E8.M3 | DurabilityState, ItemSinkEvent, RepairCostRule | Pre-production | Planned |
| E3.M5 | EconomyBalancePolicy | E3.M4, E9.M2 | EconomyPolicy, PriceBandRule, FaucetSinkRatio | Pre-production | Planned |
**E3.M3 note:** Epic 3 **Slice 1** backlog in Linear ([Epic 3 — Crafting, Gathering, and Itemization Economy](https://linear.app/neon-sprawl/project/epic-3-crafting-gathering-and-itemization-economy-65785ed05bc2)): [NEO-50](https://linear.app/neon-sprawl/issue/NEO-50) → [NEO-56](https://linear.app/neon-sprawl/issue/NEO-56); label **`E3.M3`**. See [E3M3-prototype-backlog.md](../../plans/E3M3-prototype-backlog.md), [E3_M3_ItemizationAndInventorySchema.md](E3_M3_ItemizationAndInventorySchema.md). **NEO-50+** moves the register row to **In Progress** when slices land.
**E3.M3 note:** Epic 3 **Slice 1** backlog in Linear ([Epic 3 — Crafting, Gathering, and Itemization Economy](https://linear.app/neon-sprawl/project/epic-3-crafting-gathering-and-itemization-economy-65785ed05bc2)): [NEO-50](https://linear.app/neon-sprawl/issue/NEO-50) → [NEO-56](https://linear.app/neon-sprawl/issue/NEO-56); label **`E3.M3`**. See [E3M3-prototype-backlog.md](../../plans/E3M3-prototype-backlog.md), [E3_M3_ItemizationAndInventorySchema.md](E3_M3_ItemizationAndInventorySchema.md). **NEO-50** (content + CI) and **NEO-51** (server fail-fast load) moved the register row to **In Progress**; later slices update the alignment table as they land.
### Epic 4 — World Topology

View File

@ -0,0 +1,101 @@
# NEO-51 — Implementation plan
## Story reference
| Field | Value |
|--------|--------|
| **Key** | NEO-51 |
| **Title** | E3.M3: Server loads item catalog at startup (fail-fast) |
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-51/e3m3-server-loads-item-catalog-at-startup-fail-fast |
| **Module** | [E3.M3 — ItemizationAndInventorySchema](../decomposition/modules/E3_M3_ItemizationAndInventorySchema.md) · Epic 3 Slice 1 (E3M3-02) |
| **Branch** | `NEO-51-server-item-catalog-load-fail-fast` |
| **Precursor** | [NEO-50](https://linear.app/neon-sprawl/issue/NEO-50) — frozen six-item catalog + CI (**Done** on `main`) |
## Kickoff clarifications
| Topic | Question | Agent recommendation | Answer |
|--------|----------|----------------------|--------|
| **NEO-51 vs NEO-52** | Ship `IItemDefinitionRegistry` + `ItemDefinitionRegistry` on this story, or only load + `ItemDefinitionCatalog`? | **Strict split** — NEO-51: loader + `ItemDefinitionCatalog` + eager fail-fast boot; [NEO-52](https://linear.app/neon-sprawl/issue/NEO-52) adds injectable `IItemDefinitionRegistry` (E3M3-03). Rationale: E3M3 backlog separates E3M3-02/03; skills combined registry in NEO-34 but items already have a dedicated registry ticket. | **User:** strict split (kickoff re-ask). |
| **Startup failure tests** | Loader unit tests only, or also `WebApplicationFactory` host boot failure? | **Both** — mirror [NEO-34](NEO-34-implementation-plan.md) (`SkillDefinitionCatalogLoaderTests` happy path, failure modes, `Host_ShouldResolveCatalogFromDi`, `Host_ShouldFailStartup_WhenCatalogDirectoryInvalid`). | **User:** both loader + host boot tests (kickoff re-ask). |
| **Runtime validation** | In-process C# vs subprocess `validate_content.py`? | **In-process C#** mirroring `scripts/validate_content.py` (Draft 2020-12 + Slice 1 item gate). Same rationale as NEO-34 (no Python on server images; shared constants commented against Python). | **Adopted** — NEO-34 precedent; no separate question needed. |
| **Options location** | New `ItemCatalogOptions` vs extend `ContentPathsOptions`? | **Extend** [`ContentPathsOptions`](../../server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs) with `ItemsDirectory` / `ItemDefSchemaPath` (skills + mastery already share `Content` section). | **Adopted** |
| **Lookup API on NEO-51** | How does “registry resolves `ItemDef` by `id`” land without NEO-52? | **`ItemDefinitionCatalog.ById`** (read-only map) + optional `TryGetItem(string id, out ItemDefRow row)` on the catalog type; no `IItemDefinitionRegistry` interface yet. | **Adopted** |
## Goal, scope, and out-of-scope
**Goal:** On host startup, load all `content/items/*_items.json` catalogs (validated in CI per [NEO-50](NEO-50-implementation-plan.md)), build an in-memory **`ItemDefinitionCatalog`**, **log** item count and resolved paths, and **refuse to listen** when any file is missing, malformed, has duplicate `id`, or fails the prototype Slice 1 gate. Mirror [E2.M1 / NEO-34](NEO-34-implementation-plan.md) skill-catalog behavior.
**In scope (from Linear + E3M3-02):**
- Loader + catalog under `server/NeonSprawl.Server/Game/Items/`.
- Configurable **items directory** and schema path (auto-discovery + overrides).
- Fail-fast semantics aligned with [`scripts/validate_content.py`](../../scripts/validate_content.py) item validation (`schemaVersion`, row schema, duplicate `id`, frozen six ids, `prototypeRole` coverage, equip_stub → `equipment` + `stackMax` 1).
- DI registration of **`ItemDefinitionCatalog`** singleton; eager resolve in `Program.cs` before `Run()`.
- Unit + host startup tests (AAA).
**Out of scope (from Linear):**
- Per-player inventory, HTTP ([NEO-53+](E3M3-prototype-backlog.md)).
- **`IItemDefinitionRegistry`** / `ItemDefinitionRegistry` (**NEO-52**).
## Acceptance criteria checklist
- [x] Server refuses boot when item catalog invalid (missing dir, no `*_items.json`, bad JSON, `schemaVersion` ≠ 1, schema violation, duplicate `id`, Slice 1 gate failure).
- [x] `ItemDefinitionCatalog` resolves prototype rows by `id` (e.g. `scrap_metal_bulk`, `prototype_armor_shell`).
- [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 **item count**, **catalog directory**, and **file count** (Information).
## Technical approach
1. **`Game/Items/` loader** — `ItemDefinitionCatalogLoader.Load(itemsDirectory, schemaPath, logger)`:
- Enumerate `*_items.json` (top-level only), ordered by path.
- Per file: parse JSON; require `schemaVersion == 1` and top-level `items` array.
- Per row: validate with `item-def.schema.json` via existing **JsonSchema.Net**; on clean rows, track `id`, `prototypeRole`, `inventorySlotKind`, `stackMax` for gates; build `ItemDefRow` dictionary.
- Reject duplicate `id` across files with paths in the error message.
- Run **`PrototypeSlice1ItemCatalogRules.TryGetSlice1GateError`** (mirror `PROTOTYPE_SLICE1_ITEM_IDS`, `PROTOTYPE_SLICE1_ITEM_ROLES`, equip_stub rules in `validate_content.py`).
- Throw **`InvalidOperationException`** with aggregated, sorted messages prefixed `Item catalog validation failed:`.
2. **`ItemDefinitionCatalog`** — immutable snapshot: `ItemsDirectory`, `ById`, `DistinctItemCount`, `CatalogJsonFileCount`; `TryGetItem(id, out ItemDefRow?)` for lookups.
3. **`ItemCatalogPathResolution`** — `TryDiscoverItemsDirectory`, `ResolveItemsDirectory`, `ResolveItemDefSchemaPath` (parallel to skill path resolution: parent `schemas/item-def.schema.json` when schema path unset).
4. **`ItemCatalogServiceCollectionExtensions.AddItemDefinitionCatalog`** — bind `ContentPathsOptions`; register **`ItemDefinitionCatalog`** singleton factory (load on first resolve / same pattern as skills).
5. **`Program.cs`** — `AddItemDefinitionCatalog(configuration)`; after `Build()`, `GetRequiredService<ItemDefinitionCatalog>()` before routes/`Run()`.
6. **Tests** — temp-dir fixtures copying repo `item-def.schema.json`; valid six-item JSON matching [prototype_items.json](../../content/items/prototype_items.json); negative cases for each failure class; `InMemoryWebApplicationFactory` sets `Content:ItemsDirectory` to discovered repo path; host failure uses empty temp dir like skills test.
## Files to add
| Path | Purpose |
|------|---------|
| `server/NeonSprawl.Server/Game/Items/ItemDefRow.cs` | Load-time row DTO (`Id`, `DisplayName`, `PrototypeRole`, `StackMax`, `InventorySlotKind`, optional forward-compat fields). |
| `server/NeonSprawl.Server/Game/Items/ItemDefinitionCatalog.cs` | In-memory catalog + `ById` + `TryGetItem`. |
| `server/NeonSprawl.Server/Game/Items/ItemDefinitionCatalogLoader.cs` | Disk I/O, schema validation, Slice 1 gate, logging. |
| `server/NeonSprawl.Server/Game/Items/ItemCatalogPathResolution.cs` | Directory/schema discovery and config resolution. |
| `server/NeonSprawl.Server/Game/Items/PrototypeSlice1ItemCatalogRules.cs` | Frozen six ids / six roles + equip_stub slot rules (sync comment to Python). |
| `server/NeonSprawl.Server/Game/Items/ItemCatalogServiceCollectionExtensions.cs` | `AddItemDefinitionCatalog` DI registration. |
| `server/NeonSprawl.Server.Tests/Game/Items/ItemCatalogTestPaths.cs` | Repo `content/items` + schema discovery for tests. |
| `server/NeonSprawl.Server.Tests/Game/Items/ItemDefinitionCatalogLoaderTests.cs` | AAA loader + host startup tests (mirror `SkillDefinitionCatalogLoaderTests`). |
## Files to modify
| Path | Rationale |
|------|-----------|
| `server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs` | Add `ItemsDirectory`, `ItemDefSchemaPath` under existing `Content` section. |
| `server/NeonSprawl.Server/Program.cs` | Register item catalog; eager-resolve after build. |
| `server/NeonSprawl.Server/appsettings.json` | Optional empty `Content:ItemsDirectory` / `ItemDefSchemaPath` keys for override documentation. |
| `server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs` | Pin `Content:ItemsDirectory` to repo catalog so existing tests keep booting. |
| `server/README.md` | New **Item catalog (`content/items`, NEO-51)** section (config keys, discovery, fail-fast). |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E3.M3 row — note NEO-51 server load in progress / landed when done. |
## Tests
| Test file | What it covers |
|-----------|------------------|
| `server/NeonSprawl.Server.Tests/Game/Items/ItemDefinitionCatalogLoaderTests.cs` | **Unit:** valid six-item prototype catalog loads; `items` not array; schema violation; duplicate `id` across files; wrong/missing Slice 1 ids; wrong `prototypeRole` set; equip_stub not `equipment` or `stackMax` ≠ 1; `schemaVersion` ≠ 1. **Host:** `InMemoryWebApplicationFactory` + `/health` + DI `ItemDefinitionCatalog` count 6; `WebApplicationFactory` with empty items dir fails startup with actionable message. |
## Open questions / risks
| Question / risk | Agent recommendation | Status |
|-----------------|----------------------|--------|
| **Schema path when only `ItemsDirectory` is set** | Resolve `{itemsDir}/../schemas/item-def.schema.json` (same rule as skills/mastery). Document in README + `ItemCatalogPathResolution`. | `adopted` |
| **Test cwd** | `InMemoryWebApplicationFactory` must set `Content:ItemsDirectory` explicitly (do not rely on discovery alone in CI). | `adopted` |
| **Constants drift vs Python** | Comment on `PrototypeSlice1ItemCatalogRules` pointing at `scripts/validate_content.py` `PROTOTYPE_SLICE1_ITEM_IDS` / `_prototype_slice1_item_gate`. | `adopted` |
None blocking beyond the above.

View File

@ -0,0 +1,60 @@
# Code review — NEO-51 item catalog fail-fast load
**Date:** 2026-05-17
**Scope:** Branch `NEO-51-server-item-catalog-load-fail-fast` (`c2366ee` … `b5674c4` vs `main`)
**Base:** `main` (post NEO-50 merge)
## Verdict
**Approve with nits**
**Follow-up:** Suggestions 13 and the temp-dir nit below are **done** (strikethrough + **Done.**).
## Summary
NEO-51 adds server-side fail-fast loading of `content/items/*_items.json` at startup, mirroring the NEO-34 skill-catalog pattern: JsonSchema.Net row validation, duplicate-`id` rejection, `schemaVersion` 1 enforcement, and a C# prototype Slice 1 gate aligned with `scripts/validate_content.py`. `ItemDefinitionCatalog` is registered as a singleton and eagerly resolved in `Program.cs` before the host listens; tests cover loader happy/failure paths and host boot success/failure. The change is localized, well-tested for the main failure classes, and documentation/plan alignment is strong. Residual risk is low: constants drift vs Python (mitigated by comments) and two plan-listed negative tests not yet written.
## Documentation checked
| Document | Result |
|----------|--------|
| [`docs/plans/NEO-51-implementation-plan.md`](../plans/NEO-51-implementation-plan.md) | **Matches** — loader, path resolution, DI, eager boot, README, alignment table, strict NEO-51/NEO-52 split, in-process validation, `ContentPathsOptions` extension. Acceptance checklist marked complete. |
| [`docs/decomposition/modules/E3_M3_ItemizationAndInventorySchema.md`](../decomposition/modules/E3_M3_ItemizationAndInventorySchema.md) | **Matches** — frozen six-item roster, roles, slot kinds, and stack rules enforced at load; no inventory/HTTP (out of scope). Module **Status** line still generic “In Progress”; server load not called out on that page (see suggestion). |
| [`docs/decomposition/modules/CT_M1_ContentValidationPipeline.md`](../decomposition/modules/CT_M1_ContentValidationPipeline.md) | **Matches** — server load duplicates CI item rules (schema, duplicates, Slice 1 gate). |
| [`docs/decomposition/modules/documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) | **Matches** — E3.M3 row updated with NEO-51 landed + README link. |
| [`docs/decomposition/modules/module_dependency_register.md`](../decomposition/modules/module_dependency_register.md) | **Partially matches** — E3.M3 still **In Progress** (correct); footnote still says “NEO-50+ moves row” without naming NEO-51 server load (optional doc tweak). |
## Blocking issues
None.
## Suggestions
1. ~~**Test gaps vs plan** — [`NEO-51-implementation-plan.md`](../plans/NEO-51-implementation-plan.md) lists unit tests for **wrong `prototypeRole` set** and **`equip_stub` with `stackMax` ≠ 1**. Coverage today includes incomplete ids and bag slot on equip_stub, but not those two cases. Add focused loader tests (AAA) so a regression in `PrototypeSlice1ItemCatalogRules` cannot slip through on role multiset or stack max alone.~~ **Done.**`Load_ShouldThrow_WhenSlice1PrototypeRolesDoNotMatch`, `Load_ShouldThrow_WhenEquipStubStackMaxIsNotOne` in `ItemDefinitionCatalogLoaderTests.cs`.
2. ~~**E3.M3 module page** — Add a short “Server load (NEO-51)” bullet under related implementation (or refresh the summary **Status**) pointing at `Game/Items/` and fail-fast boot, parallel to E2.M1s NEO-34 note in the alignment table. Keeps module doc and alignment doc in sync for readers who skip the register.~~ **Done.** — [E3_M3_ItemizationAndInventorySchema.md](../decomposition/modules/E3_M3_ItemizationAndInventorySchema.md) **Server load (NEO-51)** bullet; register footnote names NEO-51.
3. ~~**Early-path loader tests (optional)** — Consider tests for missing items directory and missing schema file to lock the first `ThrowIfAny` messages; low priority because host boot test covers empty catalog dir.~~ **Done.**`Load_ShouldThrow_WhenItemsDirectoryMissing`, `Load_ShouldThrow_WhenSchemaFileMissing`.
## Nits
- ~~Nit: `Host_ShouldFailStartup_WhenCatalogDirectoryInvalid` creates a temp directory and does not delete it (minor test hygiene).~~ **Done.**`finally` deletes `badDir`.
- Nit: `ItemDefinitionCatalog` copies `byId` into a new `Dictionary` then wraps `ReadOnlyDictionary` — fine for startup immutability; only worth revisiting if catalogs grow large.
- Nit: Slice 1 id error strings use single-quoted lists; Python CI uses `repr()` — behavior equivalent, messages differ cosmetically in logs.
## Verification
```bash
cd server
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj --filter "FullyQualifiedName~ItemDefinitionCatalog"
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj
```
Manual (optional):
```bash
cd server/NeonSprawl.Server && dotnet run
# GET http://localhost:5253/health — expect 200 after Information log with item count
```
Bruno: `bruno/neon-sprawl-server/item-catalog/Health after item catalog load.bru` after server is up.

View File

@ -0,0 +1,17 @@
using NeonSprawl.Server.Game.Items;
namespace NeonSprawl.Server.Tests.Game.Items;
internal static class ItemCatalogTestPaths
{
internal static string DiscoverRepoItemsDirectory() =>
ItemCatalogPathResolution.TryDiscoverItemsDirectory(AppContext.BaseDirectory)
?? throw new InvalidOperationException(
"Could not discover repo content/items from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
internal static string DiscoverRepoItemDefSchemaPath() =>
ItemCatalogPathResolution.ResolveItemDefSchemaPath(
DiscoverRepoItemsDirectory(),
configuredSchemaPath: null,
contentRootPath: string.Empty);
}

View File

@ -0,0 +1,357 @@
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.Items;
using NeonSprawl.Server.Tests;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Items;
public class ItemDefinitionCatalogLoaderTests
{
private const string ValidPrototypeCatalogJson =
"""
{
"schemaVersion": 1,
"items": [
{
"id": "scrap_metal_bulk",
"displayName": "Scrap Metal (Bulk)",
"prototypeRole": "material",
"stackMax": 999,
"inventorySlotKind": "bag"
},
{
"id": "refined_plate_stock",
"displayName": "Refined Plate Stock",
"prototypeRole": "intermediate",
"stackMax": 999,
"inventorySlotKind": "bag"
},
{
"id": "field_stim_mk0",
"displayName": "Field Stim Mk0",
"prototypeRole": "consumable",
"stackMax": 20,
"inventorySlotKind": "bag"
},
{
"id": "survey_drone_kit",
"displayName": "Survey Drone Kit",
"prototypeRole": "utility",
"stackMax": 1,
"inventorySlotKind": "bag"
},
{
"id": "contract_handoff_token",
"displayName": "Contract Handoff Token",
"prototypeRole": "quest_token",
"stackMax": 1,
"inventorySlotKind": "bag"
},
{
"id": "prototype_armor_shell",
"displayName": "Prototype Armor Shell",
"prototypeRole": "equip_stub",
"stackMax": 1,
"inventorySlotKind": "equipment"
}
]
}
""";
private static (string Root, string ItemsDir, string SchemaPath) CreateTempContentLayout()
{
var root = Directory.CreateTempSubdirectory("neon-sprawl-itemcat-");
var itemsDir = Path.Combine(root.FullName, "content", "items");
var schemaDir = Path.Combine(root.FullName, "content", "schemas");
Directory.CreateDirectory(itemsDir);
Directory.CreateDirectory(schemaDir);
var schemaPath = Path.Combine(schemaDir, "item-def.schema.json");
File.Copy(ItemCatalogTestPaths.DiscoverRepoItemDefSchemaPath(), schemaPath, overwrite: true);
return (root.FullName, itemsDir, schemaPath);
}
private static void WriteCatalog(string itemsDir, string catalogJson) =>
File.WriteAllText(Path.Combine(itemsDir, "prototype_items.json"), catalogJson, Encoding.UTF8);
private static JsonObject GetItemRow(JsonObject catalogRoot, string itemId)
{
var items = catalogRoot["items"] as JsonArray
?? throw new InvalidOperationException("expected items array");
foreach (var node in items)
{
if (node is JsonObject row && row["id"]?.GetValue<string>() == itemId)
return row;
}
throw new InvalidOperationException($"item id not found: {itemId}");
}
[Fact]
public void Load_ShouldSucceed_WhenCatalogMatchesPrototypeContract()
{
// Arrange
var (_, itemsDir, schemaPath) = CreateTempContentLayout();
File.WriteAllText(Path.Combine(itemsDir, "prototype_items.json"), ValidPrototypeCatalogJson, Encoding.UTF8);
// Act
var catalog = ItemDefinitionCatalogLoader.Load(itemsDir, schemaPath, NullLogger.Instance);
// Assert
Assert.Equal(6, catalog.DistinctItemCount);
Assert.Equal(1, catalog.CatalogJsonFileCount);
Assert.True(catalog.TryGetItem("scrap_metal_bulk", out var row));
Assert.NotNull(row);
Assert.Equal("material", row!.PrototypeRole);
Assert.Equal(999, row.StackMax);
}
[Fact]
public void Load_ShouldThrow_WhenItemsIsNotArray()
{
// Arrange
var (_, itemsDir, schemaPath) = CreateTempContentLayout();
File.WriteAllText(
Path.Combine(itemsDir, "bad_items.json"),
"""{"schemaVersion": 1, "items": "nope"}""",
Encoding.UTF8);
// Act
var ex = Record.Exception(() => ItemDefinitionCatalogLoader.Load(itemsDir, schemaPath, NullLogger.Instance));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("bad_items.json", ioe.Message, StringComparison.Ordinal);
Assert.Contains("expected top-level 'items' array", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenSchemaVersionIsNotOne()
{
// Arrange
var (_, itemsDir, schemaPath) = CreateTempContentLayout();
File.WriteAllText(
Path.Combine(itemsDir, "bad_items.json"),
"""{"schemaVersion": 2, "items": []}""",
Encoding.UTF8);
// Act
var ex = Record.Exception(() => ItemDefinitionCatalogLoader.Load(itemsDir, schemaPath, NullLogger.Instance));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("expected schemaVersion 1", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenRowViolatesSchema()
{
// Arrange
var (_, itemsDir, schemaPath) = CreateTempContentLayout();
const string bad = """
{
"schemaVersion": 1,
"items": [
{
"id": "scrap_metal_bulk",
"displayName": "",
"prototypeRole": "material",
"stackMax": 999,
"inventorySlotKind": "bag"
}
]
}
""";
File.WriteAllText(Path.Combine(itemsDir, "bad_items.json"), bad, Encoding.UTF8);
// Act
var ex = Record.Exception(() => ItemDefinitionCatalogLoader.Load(itemsDir, schemaPath, NullLogger.Instance));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("bad_items.json", ioe.Message, StringComparison.Ordinal);
Assert.Contains("Item catalog validation failed", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenDuplicateIdAcrossFiles()
{
// Arrange
var (_, itemsDir, schemaPath) = CreateTempContentLayout();
const string singleItem = """
{
"schemaVersion": 1,
"items": [
{
"id": "scrap_metal_bulk",
"displayName": "Scrap Metal (Bulk)",
"prototypeRole": "material",
"stackMax": 999,
"inventorySlotKind": "bag"
}
]
}
""";
File.WriteAllText(Path.Combine(itemsDir, "a_items.json"), singleItem, Encoding.UTF8);
File.WriteAllText(Path.Combine(itemsDir, "b_items.json"), singleItem, Encoding.UTF8);
// Act
var ex = Record.Exception(() => ItemDefinitionCatalogLoader.Load(itemsDir, schemaPath, NullLogger.Instance));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("duplicate item id", ioe.Message, StringComparison.Ordinal);
Assert.Contains("scrap_metal_bulk", ioe.Message, StringComparison.Ordinal);
Assert.Contains("a_items.json", ioe.Message, StringComparison.Ordinal);
Assert.Contains("b_items.json", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenSlice1IdsIncomplete()
{
// Arrange
var (_, itemsDir, schemaPath) = CreateTempContentLayout();
const string twoOnly = """
{
"schemaVersion": 1,
"items": [
{
"id": "scrap_metal_bulk",
"displayName": "Scrap Metal (Bulk)",
"prototypeRole": "material",
"stackMax": 999,
"inventorySlotKind": "bag"
},
{
"id": "refined_plate_stock",
"displayName": "Refined Plate Stock",
"prototypeRole": "intermediate",
"stackMax": 999,
"inventorySlotKind": "bag"
}
]
}
""";
File.WriteAllText(Path.Combine(itemsDir, "partial_items.json"), twoOnly, Encoding.UTF8);
// Act
var ex = Record.Exception(() => ItemDefinitionCatalogLoader.Load(itemsDir, schemaPath, NullLogger.Instance));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("prototype Slice 1 expects exactly item ids", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenEquipStubUsesBagSlot()
{
// Arrange
var (_, itemsDir, schemaPath) = CreateTempContentLayout();
var badEquip = ValidPrototypeCatalogJson.Replace(
"\"inventorySlotKind\": \"equipment\"",
"\"inventorySlotKind\": \"bag\"",
StringComparison.Ordinal);
File.WriteAllText(Path.Combine(itemsDir, "prototype_items.json"), badEquip, Encoding.UTF8);
// Act
var ex = Record.Exception(() => ItemDefinitionCatalogLoader.Load(itemsDir, schemaPath, NullLogger.Instance));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("equip_stub", ioe.Message, StringComparison.Ordinal);
Assert.Contains("inventorySlotKind 'equipment'", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenSlice1PrototypeRolesDoNotMatch()
{
// Arrange
var (_, itemsDir, schemaPath) = CreateTempContentLayout();
var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject
?? throw new InvalidOperationException("expected object root");
GetItemRow(root, "refined_plate_stock")["prototypeRole"] = "material";
WriteCatalog(itemsDir, root.ToJsonString());
// Act
var ex = Record.Exception(() => ItemDefinitionCatalogLoader.Load(itemsDir, schemaPath, NullLogger.Instance));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("requires exactly one row per prototypeRole", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenEquipStubStackMaxIsNotOne()
{
// Arrange
var (_, itemsDir, schemaPath) = CreateTempContentLayout();
var root = JsonNode.Parse(ValidPrototypeCatalogJson) as JsonObject
?? throw new InvalidOperationException("expected object root");
GetItemRow(root, "prototype_armor_shell")["stackMax"] = 5;
WriteCatalog(itemsDir, root.ToJsonString());
// Act
var ex = Record.Exception(() => ItemDefinitionCatalogLoader.Load(itemsDir, schemaPath, NullLogger.Instance));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("prototype_armor_shell", ioe.Message, StringComparison.Ordinal);
Assert.Contains("stackMax 1", ioe.Message, StringComparison.Ordinal);
}
[Fact]
public void Load_ShouldThrow_WhenItemsDirectoryMissing()
{
// Arrange
var missingDir = Path.Combine(Path.GetTempPath(), "neon-sprawl-no-items-" + Guid.NewGuid().ToString("n"));
var schemaPath = ItemCatalogTestPaths.DiscoverRepoItemDefSchemaPath();
// Act
var ex = Record.Exception(() => ItemDefinitionCatalogLoader.Load(missingDir, schemaPath, 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_WhenSchemaFileMissing()
{
// Arrange
var (_, itemsDir, _) = CreateTempContentLayout();
var missingSchema = Path.Combine(itemsDir, "missing-item-def.schema.json");
// Act
var ex = Record.Exception(() => ItemDefinitionCatalogLoader.Load(itemsDir, missingSchema, NullLogger.Instance));
// Assert
var ioe = Assert.IsType<InvalidOperationException>(ex);
Assert.Contains("missing schema file", ioe.Message, StringComparison.Ordinal);
Assert.Contains(missingSchema, 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<ItemDefinitionCatalog>();
Assert.Equal(6, catalog.DistinctItemCount);
Assert.True(catalog.TryGetItem("prototype_armor_shell", out var shell));
Assert.Equal("equipment", shell!.InventorySlotKind);
}
[Fact]
public void Host_ShouldFailStartup_WhenCatalogDirectoryInvalid()
{
// Arrange
var badDir = Path.Combine(Path.GetTempPath(), "neon-sprawl-empty-items-" + Guid.NewGuid().ToString("n"));
Directory.CreateDirectory(badDir);
try
{
// Act
var ex = Record.Exception(() =>
{
using var factory = new WebApplicationFactory<Program>().WithWebHostBuilder(b =>
b.UseSetting("Content:ItemsDirectory", badDir));
factory.CreateClient();
});
// Assert
Assert.NotNull(ex);
Assert.Contains("Item catalog validation failed", ex.ToString(), StringComparison.Ordinal);
}
finally
{
if (Directory.Exists(badDir))
Directory.Delete(badDir);
}
}
}

View File

@ -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.Items;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Skills;
@ -30,8 +31,12 @@ public sealed class InMemoryWebApplicationFactory : WebApplicationFactory<Progra
var masteryDir = MasteryCatalogPathResolution.TryDiscoverMasteryDirectory(AppContext.BaseDirectory)
?? throw new InvalidOperationException(
"Could not discover repo content/mastery from AppContext.BaseDirectory; run tests from the neon-sprawl clone.");
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.");
builder.UseSetting("Content:SkillsDirectory", skillsDir);
builder.UseSetting("Content:MasteryDirectory", masteryDir);
builder.UseSetting("Content:ItemsDirectory", itemsDir);
builder.UseSetting("Game:EnableMasteryFixtureApi", "true");
builder.ConfigureTestServices(services =>

View File

@ -0,0 +1,60 @@
namespace NeonSprawl.Server.Game.Items;
/// <summary>Resolves item catalog paths for local dev, tests, and container layouts (NEO-51).</summary>
public static class ItemCatalogPathResolution
{
/// <summary>Walks <paramref name="startDirectory"/> and parents for an existing <c>content/items</c> directory.</summary>
public static string? TryDiscoverItemsDirectory(string startDirectory)
{
for (var dir = new DirectoryInfo(startDirectory); dir is not null; dir = dir.Parent)
{
var candidate = Path.Combine(dir.FullName, "content", "items");
if (Directory.Exists(candidate))
return Path.GetFullPath(candidate);
}
return null;
}
/// <summary>
/// Resolves the items catalog directory.
/// Empty <paramref name="configuredItemsDirectory"/> triggers discovery from <see cref="AppContext.BaseDirectory"/>.
/// </summary>
public static string ResolveItemsDirectory(string? configuredItemsDirectory, string contentRootPath)
{
if (string.IsNullOrWhiteSpace(configuredItemsDirectory))
{
var discovered = TryDiscoverItemsDirectory(AppContext.BaseDirectory);
if (discovered is not null)
return discovered;
throw new InvalidOperationException(
"Content:ItemsDirectory is not set and auto-discovery failed (no ancestor of AppContext.BaseDirectory contains 'content/items'). " +
"Set Content:ItemsDirectory in configuration or environment (e.g. Content__ItemsDirectory).");
}
var trimmed = configuredItemsDirectory.Trim();
if (Path.IsPathRooted(trimmed))
return Path.GetFullPath(trimmed);
return Path.GetFullPath(Path.Combine(contentRootPath, trimmed));
}
/// <summary>Resolves JSON Schema path for a single item row (Draft 2020-12).</summary>
public static string ResolveItemDefSchemaPath(
string itemsDirectory,
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(itemsDirectory, "..", "schemas", "item-def.schema.json"));
}
}

View File

@ -0,0 +1,35 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Game.Items;
/// <summary>DI registration for the fail-fast item catalog (NEO-51).</summary>
public static class ItemCatalogServiceCollectionExtensions
{
/// <summary>Binds <see cref="ContentPathsOptions"/> and registers <see cref="ItemDefinitionCatalog"/> as a singleton.</summary>
public static IServiceCollection AddItemDefinitionCatalog(this IServiceCollection services, IConfiguration configuration)
{
services.AddOptions<ContentPathsOptions>()
.Bind(configuration.GetSection(ContentPathsOptions.SectionName));
services.AddSingleton<ItemDefinitionCatalog>(sp =>
{
var hostEnv = sp.GetRequiredService<IHostEnvironment>();
var opts = sp.GetRequiredService<IOptions<ContentPathsOptions>>().Value;
var logger = sp.GetRequiredService<ILoggerFactory>()
.CreateLogger("NeonSprawl.Server.Game.Items.ItemCatalog");
var itemsDir = ItemCatalogPathResolution.ResolveItemsDirectory(opts.ItemsDirectory, hostEnv.ContentRootPath);
var schemaPath = ItemCatalogPathResolution.ResolveItemDefSchemaPath(
itemsDir,
opts.ItemDefSchemaPath,
hostEnv.ContentRootPath);
return ItemDefinitionCatalogLoader.Load(itemsDir, schemaPath, logger);
});
return services;
}
}

View File

@ -0,0 +1,12 @@
namespace NeonSprawl.Server.Game.Items;
/// <summary>One validated <c>ItemDef</c> row from <c>content/items/*_items.json</c> (NEO-51).</summary>
public sealed record ItemDefRow(
string Id,
string DisplayName,
string PrototypeRole,
int StackMax,
string InventorySlotKind,
string? Rarity,
string? BindPolicy,
int? DurabilityMax);

View File

@ -0,0 +1,25 @@
using System.Collections.ObjectModel;
namespace NeonSprawl.Server.Game.Items;
/// <summary>In-memory item catalog loaded at startup (NEO-51). Game code should prefer injectable item registry for lookups (NEO-52).</summary>
public sealed class ItemDefinitionCatalog(
string itemsDirectory,
IReadOnlyDictionary<string, ItemDefRow> byId,
int catalogJsonFileCount)
{
/// <summary>Absolute path to the directory that was enumerated for <c>*_items.json</c> catalogs.</summary>
public string ItemsDirectory { get; } = itemsDirectory;
public IReadOnlyDictionary<string, ItemDefRow> ById { get; } = new ReadOnlyDictionary<string, ItemDefRow>(new Dictionary<string, ItemDefRow>(byId, StringComparer.Ordinal));
public int DistinctItemCount => ById.Count;
/// <summary>Number of <c>*_items.json</c> files under <see cref="ItemsDirectory"/>.</summary>
public int CatalogJsonFileCount { get; } = catalogJsonFileCount;
/// <summary>Resolves a catalog row by stable <paramref name="id"/>.</summary>
public bool TryGetItem(string id, out ItemDefRow? row) =>
ById.TryGetValue(id, out row);
}

View File

@ -0,0 +1,222 @@
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Json.Schema;
using Microsoft.Extensions.Logging;
namespace NeonSprawl.Server.Game.Items;
/// <summary>Loads and validates <c>content/items/*_items.json</c> using the same rules as <c>scripts/validate_content.py</c> (NEO-51).</summary>
public static class ItemDefinitionCatalogLoader
{
/// <summary>Loads catalogs from disk or throws <see cref="InvalidOperationException"/> with actionable messages.</summary>
public static ItemDefinitionCatalog Load(string itemsDirectory, string schemaPath, ILogger logger)
{
itemsDirectory = Path.GetFullPath(itemsDirectory);
schemaPath = Path.GetFullPath(schemaPath);
var errors = new List<string>();
if (!File.Exists(schemaPath))
errors.Add($"error: missing schema file {schemaPath}");
if (!Directory.Exists(itemsDirectory))
errors.Add($"error: missing directory {itemsDirectory}");
if (errors.Count > 0)
ThrowIfAny(errors);
var jsonFiles = Directory.GetFiles(itemsDirectory, "*_items.json", SearchOption.TopDirectoryOnly)
.OrderBy(p => p, StringComparer.Ordinal)
.ToArray();
if (jsonFiles.Length == 0)
errors.Add($"error: no *_items.json files under {itemsDirectory}");
if (errors.Count > 0)
ThrowIfAny(errors);
var schema = JsonSchema.FromText(File.ReadAllText(schemaPath));
var evalOptions = new EvaluationOptions { OutputFormat = OutputFormat.List };
var itemIdToSourceFile = new Dictionary<string, string>(StringComparer.Ordinal);
var idToRole = new Dictionary<string, string>(StringComparer.Ordinal);
var idToSlotKind = new Dictionary<string, string>(StringComparer.Ordinal);
var idToStackMax = new Dictionary<string, int>(StringComparer.Ordinal);
var rows = new Dictionary<string, ItemDefRow>(StringComparer.Ordinal);
foreach (var path in jsonFiles)
{
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 itemsNode = rootObj["items"];
if (itemsNode is not JsonArray itemsArray)
{
errors.Add($"error: {path}: expected top-level 'items' array");
continue;
}
for (var i = 0; i < itemsArray.Count; i++)
{
var item = itemsArray[i];
if (item is not JsonObject rowObj)
{
errors.Add($"error: {path}: items[{i}] must be an object");
continue;
}
var eval = schema.Evaluate(rowObj, evalOptions);
var schemaMsgs = CollectSchemaMessages(eval, path, i).OrderBy(m => m, StringComparer.Ordinal).ToList();
if (!eval.IsValid)
{
if (schemaMsgs.Count == 0)
schemaMsgs.Add($"error: {path} items[{i}] (root): schema validation failed");
errors.AddRange(schemaMsgs);
}
var rowSchemaErrors = schemaMsgs.Count;
var iid = (rowObj["id"] as JsonValue)?.GetValue<string>();
if (iid is not null && rowSchemaErrors == 0)
{
if (itemIdToSourceFile.TryGetValue(iid, out var prevPath))
{
errors.Add($"error: duplicate item id '{iid}' in {prevPath} and {path}");
continue;
}
itemIdToSourceFile[iid] = path;
var role = (rowObj["prototypeRole"] as JsonValue)?.GetValue<string>();
if (role is not null)
idToRole[iid] = role;
var slotKind = (rowObj["inventorySlotKind"] as JsonValue)?.GetValue<string>();
if (slotKind is not null)
idToSlotKind[iid] = slotKind;
if (rowObj["stackMax"] is JsonValue stackMaxValue &&
stackMaxValue.TryGetValue<int>(out var stackMax))
{
idToStackMax[iid] = stackMax;
}
rows[iid] = ParseRow(rowObj);
}
}
}
ThrowIfAny(errors);
var slice1 = PrototypeSlice1ItemCatalogRules.TryGetSlice1GateError(
itemIdToSourceFile,
idToRole,
idToSlotKind,
idToStackMax);
if (slice1 is not null)
{
errors.Add(slice1);
ThrowIfAny(errors);
}
if (logger.IsEnabled(LogLevel.Information))
{
logger.LogInformation(
"Loaded item catalog from {ItemsDirectory}: {ItemCount} item(s) across {CatalogFileCount} JSON catalog file(s).",
itemsDirectory,
rows.Count,
jsonFiles.Length);
}
return new ItemDefinitionCatalog(itemsDirectory, rows, jsonFiles.Length);
}
private static ItemDefRow ParseRow(JsonObject rowObj)
{
var id = (rowObj["id"] as JsonValue)!.GetValue<string>();
var displayName = (rowObj["displayName"] as JsonValue)!.GetValue<string>();
var prototypeRole = (rowObj["prototypeRole"] as JsonValue)!.GetValue<string>();
var stackMax = (rowObj["stackMax"] as JsonValue)!.GetValue<int>();
var inventorySlotKind = (rowObj["inventorySlotKind"] as JsonValue)!.GetValue<string>();
string? rarity = null;
if (rowObj["rarity"] is JsonValue rarityValue)
rarity = rarityValue.GetValue<string>();
string? bindPolicy = null;
if (rowObj["bindPolicy"] is JsonValue bindPolicyValue)
bindPolicy = bindPolicyValue.GetValue<string>();
int? durabilityMax = null;
if (rowObj["durabilityMax"] is JsonValue durabilityMaxValue &&
durabilityMaxValue.TryGetValue<int>(out var dmax))
{
durabilityMax = dmax;
}
return new ItemDefRow(id, displayName, prototypeRole, stackMax, inventorySlotKind, rarity, bindPolicy, durabilityMax);
}
private static List<string> CollectSchemaMessages(EvaluationResults eval, string filePath, int index)
{
var sink = new List<string>();
AppendSchemaMessages(eval, filePath, index, sink);
return sink;
}
private static void AppendSchemaMessages(EvaluationResults r, string filePath, int index, List<string> sink)
{
if (r.HasDetails)
{
foreach (var d in r.Details!)
AppendSchemaMessages(d, filePath, 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} items[{index}] {loc}: {kv.Key} — {kv.Value}");
}
}
private static void ThrowIfAny(List<string> errors)
{
if (errors.Count == 0)
return;
var sb = new StringBuilder();
sb.AppendLine("Item catalog validation failed:");
foreach (var e in errors.OrderBy(x => x, StringComparer.Ordinal))
sb.AppendLine(e);
throw new InvalidOperationException(sb.ToString().TrimEnd());
}
}

View File

@ -0,0 +1,81 @@
using System.Collections.Frozen;
namespace NeonSprawl.Server.Game.Items;
/// <summary>
/// Prototype Slice 1 roster gate (NEO-50), mirrored from <c>scripts/validate_content.py</c>
/// <c>PROTOTYPE_SLICE1_ITEM_IDS</c> / <c>_prototype_slice1_item_gate</c>.
/// </summary>
public static class PrototypeSlice1ItemCatalogRules
{
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_SLICE1_ITEM_IDS</c>.</summary>
public static readonly FrozenSet<string> ExpectedItemIds = FrozenSet.ToFrozenSet(
[
"scrap_metal_bulk",
"refined_plate_stock",
"field_stim_mk0",
"survey_drone_kit",
"contract_handoff_token",
"prototype_armor_shell",
],
StringComparer.Ordinal);
/// <summary>Keep in sync with <c>scripts/validate_content.py</c> <c>PROTOTYPE_SLICE1_ITEM_ROLES</c>.</summary>
public static readonly FrozenSet<string> ExpectedPrototypeRoles = FrozenSet.ToFrozenSet(
["material", "intermediate", "consumable", "utility", "quest_token", "equip_stub"],
StringComparer.Ordinal);
/// <summary>Returns a human-readable error if the Slice 1 contract fails, otherwise <see langword="null"/>.</summary>
public static string? TryGetSlice1GateError(
IReadOnlyDictionary<string, string> itemIdToSourceFile,
IReadOnlyDictionary<string, string> idToRole,
IReadOnlyDictionary<string, string> idToSlotKind,
IReadOnlyDictionary<string, int> idToStackMax)
{
var ids = itemIdToSourceFile.Keys.ToFrozenSet(StringComparer.Ordinal);
if (!ids.SetEquals(ExpectedItemIds))
{
return
"error: prototype Slice 1 expects exactly item ids " +
$"[{string.Join(", ", ExpectedItemIds.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}], " +
$"got [{string.Join(", ", ids.Order(StringComparer.Ordinal).Select(s => "'" + s + "'"))}]";
}
var roles = idToRole.Values.ToHashSet(StringComparer.Ordinal);
if (!roles.SetEquals(ExpectedPrototypeRoles))
{
return
"error: prototype Slice 1 requires exactly one row per prototypeRole " +
$"[{string.Join(", ", ExpectedPrototypeRoles.Order(StringComparer.Ordinal).Select(r => "'" + r + "'"))}], " +
$"roles seen: [{string.Join(", ", roles.Order(StringComparer.Ordinal).Select(r => "'" + r + "'"))}]";
}
string? equipStubId = null;
foreach (var kv in idToRole)
{
if (kv.Value == "equip_stub")
{
equipStubId = kv.Key;
break;
}
}
if (equipStubId is not null)
{
if (!idToSlotKind.TryGetValue(equipStubId, out var slotKind) || slotKind != "equipment")
{
idToSlotKind.TryGetValue(equipStubId, out slotKind);
return
$"error: '{equipStubId}' (equip_stub) must use inventorySlotKind 'equipment', got '{slotKind}'";
}
if (!idToStackMax.TryGetValue(equipStubId, out var stackMax) || stackMax != 1)
{
idToStackMax.TryGetValue(equipStubId, out stackMax);
return $"error: '{equipStubId}' (equip_stub) must use stackMax 1, got {stackMax}";
}
}
return null;
}
}

View File

@ -28,4 +28,16 @@ public sealed class ContentPathsOptions
/// When unset, resolved as <c>{parent of mastery directory}/schemas/mastery-catalog.schema.json</c>.
/// </summary>
public string? MasteryCatalogSchemaPath { 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/items</c> directory.
/// </summary>
public string? ItemsDirectory { get; set; }
/// <summary>
/// Optional override for <c>item-def.schema.json</c>.
/// When unset, resolved as <c>{parent of items directory}/schemas/item-def.schema.json</c>.
/// </summary>
public string? ItemDefSchemaPath { get; set; }
}

View File

@ -1,5 +1,6 @@
using NeonSprawl.Server.Game.AbilityInput;
using NeonSprawl.Server.Game.Interaction;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Skills;
@ -13,10 +14,12 @@ builder.Services.AddPerkStateStore(builder.Configuration);
builder.Services.AddAbilityCooldownStore();
builder.Services.AddSingleton<IPlayerTargetLockStore, InMemoryPlayerTargetLockStore>();
builder.Services.AddSkillDefinitionCatalog(builder.Configuration);
builder.Services.AddItemDefinitionCatalog(builder.Configuration);
builder.Services.AddMasteryCatalog(builder.Configuration);
var app = builder.Build();
_ = app.Services.GetRequiredService<SkillDefinitionCatalog>();
_ = app.Services.GetRequiredService<ItemDefinitionCatalog>();
_ = app.Services.GetRequiredService<MasteryCatalog>();
_ = app.Services.GetRequiredService<ISkillLevelCurve>();

View File

@ -8,7 +8,9 @@
"AllowedHosts": "*",
"Content": {
"SkillsDirectory": "",
"SkillDefSchemaPath": ""
"SkillDefSchemaPath": "",
"ItemsDirectory": "",
"ItemDefSchemaPath": ""
},
"Game": {
"DevPlayerId": "dev-local-1",

View File

@ -37,6 +37,19 @@ On startup the host loads every **`*_skills.json`** under the skills directory,
On success, **Information** logs include the resolved skills directory path and distinct skill count.
## Item catalog (`content/items`, NEO-51)
On startup the host loads every **`*_items.json`** under the items directory, validates each row against **`content/schemas/item-def.schema.json`**, requires **`schemaVersion` 1** per file, rejects **duplicate `id`** values across files, and enforces the **prototype Slice 1** 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:ItemsDirectory`** | Optional. Absolute path, or path relative to the server **content root**. When unset, walks **ancestors of `AppContext.BaseDirectory`** until it finds **`content/items`**. |
| **`Content:ItemDefSchemaPath`** | Optional override for **`item-def.schema.json`**. When unset, **`{parent of items directory}/schemas/item-def.schema.json`**. |
**Docker / CI:** include **`content/items`** and **`content/schemas/item-def.schema.json`** in the mounted **`content/`** tree; set **`Content__ItemsDirectory`** when layout differs.
On success, **Information** logs include the resolved items directory path, distinct item count, and catalog file count. Game code should use **`IItemDefinitionRegistry`** for lookups after **NEO-52**; until then use **`ItemDefinitionCatalog.TryGetItem`**.
## Mastery catalog (`content/mastery`, NEO-46)
After the skill catalog loads, the host loads every **`*_mastery.json`** under the mastery directory, validates each file against **`content/schemas/mastery-catalog.schema.json`**, cross-checks track **`skillId`** values against **`ISkillDefinitionRegistry`**, and enforces the same post-schema gates as **`scripts/validate_content.py`** (unknown perk references, duplicate perk ids, branch-set equality tier-to-tier, unreferenced perks, prototype **Slice 4** single **`salvage`** track). The server also requires **`tierIndex`** values per track to be **unique and sequential 1..N** (stricter than CI today). Invalid data **exits during startup**—no silent fallback.