diff --git a/.cursor/rules/planning-implementation-docs.md b/.cursor/rules/planning-implementation-docs.md index 50410d6..f28edf6 100644 --- a/.cursor/rules/planning-implementation-docs.md +++ b/.cursor/rules/planning-implementation-docs.md @@ -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 diff --git a/.cursor/rules/story-kickoff.md b/.cursor/rules/story-kickoff.md index b5d9503..fe17602 100644 --- a/.cursor/rules/story-kickoff.md +++ b/.cursor/rules/story-kickoff.md @@ -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 **agent’s 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. diff --git a/bruno/neon-sprawl-server/item-catalog/Health after item catalog load.bru b/bruno/neon-sprawl-server/item-catalog/Health after item catalog load.bru new file mode 100644 index 0000000..7985da6 --- /dev/null +++ b/bruno/neon-sprawl-server/item-catalog/Health after item catalog load.bru @@ -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"); + }); +} diff --git a/bruno/neon-sprawl-server/item-catalog/folder.bru b/bruno/neon-sprawl-server/item-catalog/folder.bru new file mode 100644 index 0000000..d950aa9 --- /dev/null +++ b/bruno/neon-sprawl-server/item-catalog/folder.bru @@ -0,0 +1,3 @@ +meta { + name: item-catalog +} diff --git a/docs/decomposition/modules/E3_M3_ItemizationAndInventorySchema.md b/docs/decomposition/modules/E3_M3_ItemizationAndInventorySchema.md index cb68857..f502ff6 100644 --- a/docs/decomposition/modules/E3_M3_ItemizationAndInventorySchema.md +++ b/docs/decomposition/modules/E3_M3_ItemizationAndInventorySchema.md @@ -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 diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md index 0a3cd17..6477757 100644 --- a/docs/decomposition/modules/documentation_and_implementation_alignment.md +++ b/docs/decomposition/modules/documentation_and_implementation_alignment.md @@ -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-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. **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) | --- diff --git a/docs/decomposition/modules/module_dependency_register.md b/docs/decomposition/modules/module_dependency_register.md index 10796c5..6138a68 100644 --- a/docs/decomposition/modules/module_dependency_register.md +++ b/docs/decomposition/modules/module_dependency_register.md @@ -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 diff --git a/docs/plans/NEO-51-implementation-plan.md b/docs/plans/NEO-51-implementation-plan.md new file mode 100644 index 0000000..a282558 --- /dev/null +++ b/docs/plans/NEO-51-implementation-plan.md @@ -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()` 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. diff --git a/docs/reviews/2026-05-17-NEO-51.md b/docs/reviews/2026-05-17-NEO-51.md new file mode 100644 index 0000000..c33af7e --- /dev/null +++ b/docs/reviews/2026-05-17-NEO-51.md @@ -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 1–3 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.M1’s 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. diff --git a/server/NeonSprawl.Server.Tests/Game/Items/ItemCatalogTestPaths.cs b/server/NeonSprawl.Server.Tests/Game/Items/ItemCatalogTestPaths.cs new file mode 100644 index 0000000..664b27c --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Items/ItemCatalogTestPaths.cs @@ -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); +} diff --git a/server/NeonSprawl.Server.Tests/Game/Items/ItemDefinitionCatalogLoaderTests.cs b/server/NeonSprawl.Server.Tests/Game/Items/ItemDefinitionCatalogLoaderTests.cs new file mode 100644 index 0000000..a2ac9a8 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Items/ItemDefinitionCatalogLoaderTests.cs @@ -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() == 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(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(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(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(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(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(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(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(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(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(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(); + 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().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); + } + } +} diff --git a/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs b/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs index 4eb9aa1..bf7f11f 100644 --- a/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs +++ b/server/NeonSprawl.Server.Tests/InMemoryWebApplicationFactory.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Time.Testing; using NeonSprawl.Server.Game.AbilityInput; +using NeonSprawl.Server.Game.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 diff --git a/server/NeonSprawl.Server/Game/Items/ItemCatalogPathResolution.cs b/server/NeonSprawl.Server/Game/Items/ItemCatalogPathResolution.cs new file mode 100644 index 0000000..24561f4 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Items/ItemCatalogPathResolution.cs @@ -0,0 +1,60 @@ +namespace NeonSprawl.Server.Game.Items; + +/// Resolves item catalog paths for local dev, tests, and container layouts (NEO-51). +public static class ItemCatalogPathResolution +{ + /// Walks and parents for an existing content/items directory. + 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; + } + + /// + /// Resolves the items catalog directory. + /// Empty triggers discovery from . + /// + 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)); + } + + /// Resolves JSON Schema path for a single item row (Draft 2020-12). + 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")); + } +} diff --git a/server/NeonSprawl.Server/Game/Items/ItemCatalogServiceCollectionExtensions.cs b/server/NeonSprawl.Server/Game/Items/ItemCatalogServiceCollectionExtensions.cs new file mode 100644 index 0000000..3a0c731 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Items/ItemCatalogServiceCollectionExtensions.cs @@ -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; + +/// DI registration for the fail-fast item catalog (NEO-51). +public static class ItemCatalogServiceCollectionExtensions +{ + /// Binds and registers as a singleton. + public static IServiceCollection AddItemDefinitionCatalog(this IServiceCollection services, IConfiguration configuration) + { + services.AddOptions() + .Bind(configuration.GetSection(ContentPathsOptions.SectionName)); + + services.AddSingleton(sp => + { + var hostEnv = sp.GetRequiredService(); + var opts = sp.GetRequiredService>().Value; + var logger = sp.GetRequiredService() + .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; + } +} diff --git a/server/NeonSprawl.Server/Game/Items/ItemDefRow.cs b/server/NeonSprawl.Server/Game/Items/ItemDefRow.cs new file mode 100644 index 0000000..3e73abc --- /dev/null +++ b/server/NeonSprawl.Server/Game/Items/ItemDefRow.cs @@ -0,0 +1,12 @@ +namespace NeonSprawl.Server.Game.Items; + +/// One validated ItemDef row from content/items/*_items.json (NEO-51). +public sealed record ItemDefRow( + string Id, + string DisplayName, + string PrototypeRole, + int StackMax, + string InventorySlotKind, + string? Rarity, + string? BindPolicy, + int? DurabilityMax); diff --git a/server/NeonSprawl.Server/Game/Items/ItemDefinitionCatalog.cs b/server/NeonSprawl.Server/Game/Items/ItemDefinitionCatalog.cs new file mode 100644 index 0000000..1ee21ea --- /dev/null +++ b/server/NeonSprawl.Server/Game/Items/ItemDefinitionCatalog.cs @@ -0,0 +1,25 @@ +using System.Collections.ObjectModel; + +namespace NeonSprawl.Server.Game.Items; + +/// In-memory item catalog loaded at startup (NEO-51). Game code should prefer injectable item registry for lookups (NEO-52). +public sealed class ItemDefinitionCatalog( + string itemsDirectory, + IReadOnlyDictionary byId, + int catalogJsonFileCount) +{ + + /// Absolute path to the directory that was enumerated for *_items.json catalogs. + public string ItemsDirectory { get; } = itemsDirectory; + + public IReadOnlyDictionary ById { get; } = new ReadOnlyDictionary(new Dictionary(byId, StringComparer.Ordinal)); + + public int DistinctItemCount => ById.Count; + + /// Number of *_items.json files under . + public int CatalogJsonFileCount { get; } = catalogJsonFileCount; + + /// Resolves a catalog row by stable . + public bool TryGetItem(string id, out ItemDefRow? row) => + ById.TryGetValue(id, out row); +} diff --git a/server/NeonSprawl.Server/Game/Items/ItemDefinitionCatalogLoader.cs b/server/NeonSprawl.Server/Game/Items/ItemDefinitionCatalogLoader.cs new file mode 100644 index 0000000..2edce09 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Items/ItemDefinitionCatalogLoader.cs @@ -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; + +/// Loads and validates content/items/*_items.json using the same rules as scripts/validate_content.py (NEO-51). +public static class ItemDefinitionCatalogLoader +{ + /// Loads catalogs from disk or throws with actionable messages. + public static ItemDefinitionCatalog Load(string itemsDirectory, string schemaPath, ILogger logger) + { + itemsDirectory = Path.GetFullPath(itemsDirectory); + schemaPath = Path.GetFullPath(schemaPath); + + var errors = new List(); + + 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(StringComparer.Ordinal); + var idToRole = new Dictionary(StringComparer.Ordinal); + var idToSlotKind = new Dictionary(StringComparer.Ordinal); + var idToStackMax = new Dictionary(StringComparer.Ordinal); + var rows = new Dictionary(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(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(); + 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(); + if (role is not null) + idToRole[iid] = role; + + var slotKind = (rowObj["inventorySlotKind"] as JsonValue)?.GetValue(); + if (slotKind is not null) + idToSlotKind[iid] = slotKind; + + if (rowObj["stackMax"] is JsonValue stackMaxValue && + stackMaxValue.TryGetValue(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(); + var displayName = (rowObj["displayName"] as JsonValue)!.GetValue(); + var prototypeRole = (rowObj["prototypeRole"] as JsonValue)!.GetValue(); + var stackMax = (rowObj["stackMax"] as JsonValue)!.GetValue(); + var inventorySlotKind = (rowObj["inventorySlotKind"] as JsonValue)!.GetValue(); + string? rarity = null; + if (rowObj["rarity"] is JsonValue rarityValue) + rarity = rarityValue.GetValue(); + string? bindPolicy = null; + if (rowObj["bindPolicy"] is JsonValue bindPolicyValue) + bindPolicy = bindPolicyValue.GetValue(); + int? durabilityMax = null; + if (rowObj["durabilityMax"] is JsonValue durabilityMaxValue && + durabilityMaxValue.TryGetValue(out var dmax)) + { + durabilityMax = dmax; + } + + return new ItemDefRow(id, displayName, prototypeRole, stackMax, inventorySlotKind, rarity, bindPolicy, durabilityMax); + } + + private static List CollectSchemaMessages(EvaluationResults eval, string filePath, int index) + { + var sink = new List(); + AppendSchemaMessages(eval, filePath, index, sink); + return sink; + } + + private static void AppendSchemaMessages(EvaluationResults r, string filePath, int index, List 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 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()); + } +} diff --git a/server/NeonSprawl.Server/Game/Items/PrototypeSlice1ItemCatalogRules.cs b/server/NeonSprawl.Server/Game/Items/PrototypeSlice1ItemCatalogRules.cs new file mode 100644 index 0000000..3a717b1 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Items/PrototypeSlice1ItemCatalogRules.cs @@ -0,0 +1,81 @@ +using System.Collections.Frozen; + +namespace NeonSprawl.Server.Game.Items; + +/// +/// Prototype Slice 1 roster gate (NEO-50), mirrored from scripts/validate_content.py +/// PROTOTYPE_SLICE1_ITEM_IDS / _prototype_slice1_item_gate. +/// +public static class PrototypeSlice1ItemCatalogRules +{ + /// Keep in sync with scripts/validate_content.py PROTOTYPE_SLICE1_ITEM_IDS. + public static readonly FrozenSet ExpectedItemIds = FrozenSet.ToFrozenSet( + [ + "scrap_metal_bulk", + "refined_plate_stock", + "field_stim_mk0", + "survey_drone_kit", + "contract_handoff_token", + "prototype_armor_shell", + ], + StringComparer.Ordinal); + + /// Keep in sync with scripts/validate_content.py PROTOTYPE_SLICE1_ITEM_ROLES. + public static readonly FrozenSet ExpectedPrototypeRoles = FrozenSet.ToFrozenSet( + ["material", "intermediate", "consumable", "utility", "quest_token", "equip_stub"], + StringComparer.Ordinal); + + /// Returns a human-readable error if the Slice 1 contract fails, otherwise . + public static string? TryGetSlice1GateError( + IReadOnlyDictionary itemIdToSourceFile, + IReadOnlyDictionary idToRole, + IReadOnlyDictionary idToSlotKind, + IReadOnlyDictionary 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; + } +} diff --git a/server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs b/server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs index f9713e4..7debb9e 100644 --- a/server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs +++ b/server/NeonSprawl.Server/Game/Skills/ContentPathsOptions.cs @@ -28,4 +28,16 @@ public sealed class ContentPathsOptions /// When unset, resolved as {parent of mastery directory}/schemas/mastery-catalog.schema.json. /// public string? MasteryCatalogSchemaPath { get; set; } + + /// + /// Optional. Absolute path, or path relative to . + /// When unset, the host walks ancestors of for a content/items directory. + /// + public string? ItemsDirectory { get; set; } + + /// + /// Optional override for item-def.schema.json. + /// When unset, resolved as {parent of items directory}/schemas/item-def.schema.json. + /// + public string? ItemDefSchemaPath { get; set; } } diff --git a/server/NeonSprawl.Server/Program.cs b/server/NeonSprawl.Server/Program.cs index a7aaf11..164db2e 100644 --- a/server/NeonSprawl.Server/Program.cs +++ b/server/NeonSprawl.Server/Program.cs @@ -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(); builder.Services.AddSkillDefinitionCatalog(builder.Configuration); +builder.Services.AddItemDefinitionCatalog(builder.Configuration); builder.Services.AddMasteryCatalog(builder.Configuration); var app = builder.Build(); _ = app.Services.GetRequiredService(); +_ = app.Services.GetRequiredService(); _ = app.Services.GetRequiredService(); _ = app.Services.GetRequiredService(); diff --git a/server/NeonSprawl.Server/appsettings.json b/server/NeonSprawl.Server/appsettings.json index be0b44f..fe7bede 100644 --- a/server/NeonSprawl.Server/appsettings.json +++ b/server/NeonSprawl.Server/appsettings.json @@ -8,7 +8,9 @@ "AllowedHosts": "*", "Content": { "SkillsDirectory": "", - "SkillDefSchemaPath": "" + "SkillDefSchemaPath": "", + "ItemsDirectory": "", + "ItemDefSchemaPath": "" }, "Game": { "DevPlayerId": "dev-local-1", diff --git a/server/README.md b/server/README.md index 8eef9d9..78b7002 100644 --- a/server/README.md +++ b/server/README.md @@ -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.