Compare commits

...

10 Commits

Author SHA1 Message Date
VinPropane 97a64d179c
Merge pull request #88 from ViPro-Technologies/NEO-53-get-world-item-definitions-bruno
NEO-53: GET world item-definitions + Bruno
2026-05-23 19:16:19 -04:00
VinPropane 52590fa749 NEO-53: address code review suggestions
Register footnote NEO-53, Bruno exact id-order test, test indent fix,
review doc strikethroughs.
2026-05-23 19:12:20 -04:00
VinPropane 68c4980e42 NEO-53: Add code review for item-definitions HTTP + Bruno. 2026-05-23 19:11:28 -04:00
VinPropane 08428c4da4 NEO-53: GET world item-definitions, Bruno, docs, and tests
Registry-backed read model at /game/world/item-definitions; Bruno
collection, manual QA checklist, README and module alignment updates.
2026-05-23 19:09:46 -04:00
VinPropane 9e2e4ab3a7 NEO-53: add implementation plan for world item-definitions HTTP
Kickoff clarifications: required-five JSON fields, /game/world route,
manual QA doc — all adopted per user choices.
2026-05-23 19:08:24 -04:00
VinPropane 910bd4850d
Merge pull request #87 from ViPro-Technologies/NEO-52-item-definition-registry-di
NEO-52: E3.M3 item definition registry + DI
2026-05-23 18:21:30 -04:00
VinPropane 0d904527ac NEO-52: address code review doc suggestions for registry
Add NEO-52 registry bullet to E3.M3 module doc, extend dependency
register footnote, and mark review suggestions done.
2026-05-23 18:17:27 -04:00
VinPropane 7bd4064b93 NEO-52: Add code review for item definition registry + DI. 2026-05-23 18:16:17 -04:00
VinPropane 792b38d8a9 NEO-52: add IItemDefinitionRegistry with DI and lookup tests
Thin adapter over ItemDefinitionCatalog mirroring NEO-35 skill registry;
registers singleton in AddItemDefinitionCatalog with TryGetDefinition,
GetDefinitionsInIdOrder, and AAA unit + host DI tests.
2026-05-23 18:11:33 -04:00
VinPropane 1e9f1e9a3a NEO-52: add implementation plan for item definition registry + DI
Kickoff plan mirrors NEO-35 skill registry pattern on NEO-51 catalog;
includes GetDefinitionsInIdOrder and TryGetDefinition per kickoff answers.
2026-05-23 18:09:02 -04:00
20 changed files with 792 additions and 5 deletions

View File

@ -0,0 +1,64 @@
meta {
name: GET item definitions
type: http
seq: 1
}
get {
url: {{baseUrl}}/game/world/item-definitions
body: none
auth: none
}
tests {
test("returns 200 JSON with schema v1 and items array", function () {
expect(res.getStatus()).to.equal(200);
expect(res.getHeader("content-type")).to.contain("application/json");
const body = res.getBody();
expect(body.schemaVersion).to.equal(1);
expect(body.items).to.be.an("array");
expect(body.items.length).to.equal(6);
});
test("items are ascending by id (ordinal)", function () {
const body = res.getBody();
const ids = body.items.map((x) => x.id);
// Match server StringComparer.Ordinal (prototype ids are ASCII snake_case).
const sorted = [...ids].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
expect(ids).to.eql(sorted);
});
test("frozen prototype six matches registry id order", function () {
const body = res.getBody();
const ids = body.items.map((x) => x.id);
expect(ids).to.eql([
"contract_handoff_token",
"field_stim_mk0",
"prototype_armor_shell",
"refined_plate_stock",
"scrap_metal_bulk",
"survey_drone_kit",
]);
});
test("frozen prototype six is present", function () {
const body = res.getBody();
const ids = new Set(body.items.map((x) => x.id));
expect(ids.has("scrap_metal_bulk")).to.equal(true);
expect(ids.has("refined_plate_stock")).to.equal(true);
expect(ids.has("field_stim_mk0")).to.equal(true);
expect(ids.has("survey_drone_kit")).to.equal(true);
expect(ids.has("contract_handoff_token")).to.equal(true);
expect(ids.has("prototype_armor_shell")).to.equal(true);
});
test("scrap_metal_bulk row matches catalog", function () {
const body = res.getBody();
const row = body.items.find((x) => x.id === "scrap_metal_bulk");
expect(row).to.be.an("object");
expect(row.displayName).to.equal("Scrap Metal (Bulk)");
expect(row.prototypeRole).to.equal("material");
expect(row.stackMax).to.equal(999);
expect(row.inventorySlotKind).to.equal("bag");
});
}

View File

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

View File

@ -64,6 +64,10 @@ 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).
**Item definition registry (NEO-52):** **`IItemDefinitionRegistry`** in `server/NeonSprawl.Server/Game/Items/` wraps the startup-loaded catalog for read-only lookup by stable `itemId` and ordered enumeration. **NEO-54+** (inventory engine) and future craft/gather callers should inject the interface rather than `ItemDefinitionCatalog`. Plan: [NEO-52 implementation plan](../../plans/NEO-52-implementation-plan.md).
**Item definitions HTTP (NEO-53):** **`GET /game/world/item-definitions`** — versioned read-only projection (`schemaVersion` **1**, **`items`**) backed by **`IItemDefinitionRegistry`**; Bruno `bruno/neon-sprawl-server/item-definitions/`. Plan: [NEO-53 implementation plan](../../plans/NEO-53-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). **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) |
| E3.M3 | In Progress | **NEO-50 landed:** frozen prototype six-item catalog in [`content/items/prototype_items.json`](../../../content/items/prototype_items.json); [`item-def.schema.json`](../../../content/schemas/item-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py). **NEO-51 landed:** fail-fast server load of `content/items/*_items.json` at startup — `server/NeonSprawl.Server/Game/Items/` ([NEO-51](../../plans/NEO-51-implementation-plan.md)); [server README — Item catalog](../../../server/README.md#item-catalog-contentitems-neo-51). **NEO-52 landed:** injectable **`IItemDefinitionRegistry`** + lookup tests ([NEO-52](../../plans/NEO-52-implementation-plan.md)). **NEO-53 landed:** **`GET /game/world/item-definitions`** — `ItemDefinitionsWorldApi` + DTOs in `Game/Items/` ([NEO-53](../../plans/NEO-53-implementation-plan.md), [`NEO-53` manual QA](../../manual-qa/NEO-53.md)); [server README — Item definitions (NEO-53)](../../../server/README.md#item-definitions-neo-53); Bruno `bruno/neon-sprawl-server/item-definitions/`. **Still planned:** inventory store, per-player HTTP. | [NEO-50](../../plans/NEO-50-implementation-plan.md), [NEO-51](../../plans/NEO-51-implementation-plan.md), [NEO-52](../../plans/NEO-52-implementation-plan.md), [NEO-53](../../plans/NEO-53-implementation-plan.md), [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** (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.
**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), **NEO-51** (server fail-fast load), **NEO-52** (`IItemDefinitionRegistry` + DI), and **NEO-53** (`GET /game/world/item-definitions`) 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,28 @@
# NEO-53 — Manual QA checklist
| Field | Value |
|-------|-------|
| Key | NEO-53 |
| Title | E3.M3: GET world item-definitions + Bruno |
| Linear | https://linear.app/neon-sprawl/issue/NEO-53/e3m3-get-world-item-definitions-bruno |
| Plan | `docs/plans/NEO-53-implementation-plan.md` |
| Branch | `NEO-53-get-world-item-definitions-bruno` |
## Preconditions
- Server built and configured with default `Content:ItemsDirectory` pointing at repo `content/items` (local dev / `InMemoryWebApplicationFactory` tests use the same layout).
## Checklist
1. Start **`NeonSprawl.Server`** (e.g. `dotnet run` from `server/NeonSprawl.Server`).
2. **`GET /game/world/item-definitions`** — expect **200** and **`Content-Type`** containing **`application/json`**. Example (default dev URL from `Properties/launchSettings.json` and Bruno `environments/Local.bru`; change the host/port if yours differs):
```bash
curl -sS -i "http://localhost:5253/game/world/item-definitions"
```
3. Parse JSON — expect **`schemaVersion`** === **1**, **`items`** array length **6**.
4. Confirm **`id`** values include the frozen prototype six: **`contract_handoff_token`**, **`field_stim_mk0`**, **`prototype_armor_shell`**, **`refined_plate_stock`**, **`scrap_metal_bulk`**, **`survey_drone_kit`** (ordinal order).
5. Spot-check **`scrap_metal_bulk`**: **`displayName`** “Scrap Metal (Bulk)”, **`prototypeRole`** **`material`**, **`stackMax`** **999**, **`inventorySlotKind`** **`bag`**.
6. Spot-check **`prototype_armor_shell`**: **`inventorySlotKind`** **`equipment`**, **`prototypeRole`** **`equip_stub`**.
7. Optional: run **`bruno/neon-sprawl-server/item-definitions/Get item definitions.bru`** against the same **`baseUrl`** (see `environments/Local.bru`).

View File

@ -0,0 +1,92 @@
# NEO-52 — Implementation plan
## Story reference
| Field | Value |
|--------|--------|
| **Key** | NEO-52 |
| **Title** | E3.M3: Item definition registry + DI |
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-52/e3m3-item-definition-registry-di |
| **Module** | [E3.M3 — ItemizationAndInventorySchema](../decomposition/modules/E3_M3_ItemizationAndInventorySchema.md) · Epic 3 Slice 1 (E3M3-03) |
| **Branch** | `NEO-52-item-definition-registry-di` |
| **Precursor** | [NEO-51](https://linear.app/neon-sprawl/issue/NEO-51) — fail-fast `ItemDefinitionCatalog` load (**Done** on `main`) |
## Kickoff clarifications
| Topic | Question | Agent recommendation | Answer |
|--------|----------|----------------------|--------|
| **Registry API surface** | Include `GetDefinitionsInIdOrder()` now or TryGet-only? | **Include enumeration** — mirror [NEO-35](NEO-35-implementation-plan.md) / `ISkillDefinitionRegistry`; [NEO-53](https://linear.app/neon-sprawl/issue/NEO-53) `GET /game/world/item-definitions` will need ordered defs without reaching into `ItemDefinitionCatalog`. | **User:** include `GetDefinitionsInIdOrder`. |
| **Lookup method name** | `TryGetDefinition` vs `TryGetItem`? | **`TryGetDefinition(string? itemId, …)`** — same naming as `ISkillDefinitionRegistry.TryGetDefinition`; catalog keeps `TryGetItem` for direct catalog access. | **User:** `TryGetDefinition`. |
| **NEO-51 vs NEO-52 split** | Combine registry with catalog load? | **Strict split** (already decided on NEO-51 kickoff) — NEO-51 ships loader + catalog; this story adds injectable registry only. | **Adopted** — NEO-51 plan + user answer on NEO-51 kickoff. |
| **Program.cs eager resolve** | Eager-resolve `IItemDefinitionRegistry` at boot? | **Omit**`ItemDefinitionCatalog` is already eager-resolved in `Program.cs` (NEO-51); registry is a thin adapter (NEO-35 default). | **Adopted** |
| **Runtime validation** | Re-validate items in registry? | **No** — catalog load is fail-fast (NEO-51); registry delegates to loaded `ItemDefRow` only. | **Adopted** |
## Goal, scope, and out-of-scope
**Goal:** Provide **`IItemDefinitionRegistry`** backed by the startup-loaded **`ItemDefinitionCatalog`**: resolve by stable **`itemId`**, enumerate definitions in id order, expose prototype metadata (`displayName`, `prototypeRole`, `stackMax`, `inventorySlotKind`). Register in DI so **NEO-53+** (HTTP), **NEO-54** (inventory engine), and future craft/gather callers depend on the interface instead of the catalog type.
**In scope (from Linear + E3M3-03):**
- `ItemDefinitionRegistry` thin adapter over `ItemDefinitionCatalog`.
- DI registration alongside existing catalog singleton.
- Unit tests (AAA): known prototype id lookup + metadata; unknown `itemId` returns false without throwing; enumeration order; host resolves registry from DI.
**Out of scope (from Linear):**
- HTTP ([NEO-53](https://linear.app/neon-sprawl/issue/NEO-53)).
- Persistence, per-player inventory, stack mutation ([NEO-54+](E3M3-prototype-backlog.md)).
- Changing loader, Slice 1 gate, or catalog load semantics (NEO-51).
## Acceptance criteria checklist
- [x] DI resolves `IItemDefinitionRegistry` at host startup (test via `InMemoryWebApplicationFactory`).
- [x] Unit tests (AAA): lookup for known prototype `itemId` (e.g. `scrap_metal_bulk`, `prototype_armor_shell`) with expected metadata.
- [x] Unit tests (AAA): unknown `itemId` returns false / absent without throwing.
- [x] `GetDefinitionsInIdOrder` returns all loaded rows ordered by `id` (ordinal).
## Technical approach
1. **`IItemDefinitionRegistry`** — mirror [`ISkillDefinitionRegistry`](../../server/NeonSprawl.Server/Game/Skills/ISkillDefinitionRegistry.cs):
- `TryGetDefinition(string? itemId, [NotNullWhen(true)] out ItemDefRow? definition)` — null and unknown ids return false without throwing.
- `GetDefinitionsInIdOrder()` — all rows ordered by `ItemDefRow.Id` (ordinal).
- XML remarks: inventory/craft/gather callers (NEO-54+) should use this interface; HTTP read model (NEO-53) should not reach into `ItemDefinitionCatalog`.
2. **`ItemDefinitionRegistry`** — primary-constructor adapter over `ItemDefinitionCatalog` (same pattern as [`SkillDefinitionRegistry`](../../server/NeonSprawl.Server/Game/Skills/SkillDefinitionRegistry.cs)).
3. **DI** — extend [`AddItemDefinitionCatalog`](../../server/NeonSprawl.Server/Game/Items/ItemCatalogServiceCollectionExtensions.cs) to register `IItemDefinitionRegistry``ItemDefinitionRegistry` after catalog registration. No change to `Program.cs` eager-resolve (catalog only).
4. **Comments** — update `ItemDefinitionCatalog` summary if needed to point at `IItemDefinitionRegistry`; update [server README](../../server/README.md) item-catalog section (currently says “until NEO-52”).
5. **Tests** — new `ItemDefinitionRegistryTests.cs` mirroring [`SkillDefinitionRegistryTests`](../../server/NeonSprawl.Server.Tests/Game/Skills/SkillDefinitionRegistryTests.cs): in-memory catalog helper, prototype fixture via loader + `ItemCatalogTestPaths`, host DI test.
## Files to add
| Path | Purpose |
|------|---------|
| `server/NeonSprawl.Server/Game/Items/IItemDefinitionRegistry.cs` | Public contract: lookup, enumerate, remarks for NEO-53/NEO-54 callers. |
| `server/NeonSprawl.Server/Game/Items/ItemDefinitionRegistry.cs` | Singleton adapter over `ItemDefinitionCatalog`. |
| `server/NeonSprawl.Server.Tests/Game/Items/ItemDefinitionRegistryTests.cs` | AAA unit + host DI tests (mirror `SkillDefinitionRegistryTests`). |
## Files to modify
| Path | Rationale |
|------|-----------|
| `server/NeonSprawl.Server/Game/Items/ItemCatalogServiceCollectionExtensions.cs` | Register `IItemDefinitionRegistry``ItemDefinitionRegistry` after catalog singleton. |
| `server/NeonSprawl.Server/Game/Items/ItemDefinitionCatalog.cs` | Tighten summary comment to reference `IItemDefinitionRegistry` (replace “NEO-52” placeholder). |
| `server/README.md` | Item catalog section: document `IItemDefinitionRegistry` as preferred lookup surface. |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E3.M3 row — note NEO-52 registry when landed. |
## Tests
| Test file | What it covers |
|-----------|----------------|
| `server/NeonSprawl.Server.Tests/Game/Items/ItemDefinitionRegistryTests.cs` | **Unit:** `TryGetDefinition` for `scrap_metal_bulk` (or equip stub) returns true with `StackMax`, `PrototypeRole`, `InventorySlotKind`; null and unknown id return false without throw; `GetDefinitionsInIdOrder` count and ordinal order for multi-row fixture; loader-backed prototype fixture matches catalog. **Host:** `InMemoryWebApplicationFactory` resolves `IItemDefinitionRegistry` and finds `scrap_metal_bulk`. |
## Open questions / risks
| Question / risk | Agent recommendation | Status |
|-----------------|----------------------|--------|
| **Catalog vs registry for game code** | New callers inject `IItemDefinitionRegistry`; leave `ItemDefinitionCatalog` eager-resolve in `Program.cs` for fail-fast only. | `adopted` |
| **Duplicate API on catalog** | Keep `ItemDefinitionCatalog.TryGetItem` for loader/tests; registry is the game-facing surface (NEO-35 precedent with catalog `ById`). | `adopted` |
None blocking beyond the above.

View File

@ -0,0 +1,100 @@
# NEO-53 — Implementation plan
## Story reference
| Field | Value |
|--------|--------|
| **Key** | NEO-53 |
| **Title** | E3.M3: GET world item-definitions + Bruno |
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-53/e3m3-get-world-item-definitions-bruno |
| **Module** | [E3.M3 — ItemizationAndInventorySchema](../decomposition/modules/E3_M3_ItemizationAndInventorySchema.md) · Epic 3 Slice 1 (E3M3-04) |
| **Branch** | `NEO-53-get-world-item-definitions-bruno` |
| **Precursor** | [NEO-52](https://linear.app/neon-sprawl/issue/NEO-52) — `IItemDefinitionRegistry` + DI (**Done** on `main`) |
| **Pattern** | [NEO-36](https://linear.app/neon-sprawl/issue/NEO-36) — `GET /game/world/skill-definitions` |
## Kickoff clarifications
| Topic | Question | Agent recommendation | Answer |
|--------|----------|----------------------|--------|
| **JSON row fields** | Expose required five only vs all schema fields (optional nullables)? | **Required five only** — prototype rows omit `rarity` / `bindPolicy` / `durabilityMax`; mirrors NEO-36 exposing loaded catalog fields only. | **User:** required five only. |
| **Route path** | `/game/world/…` vs `/game/content/…`? | **`GET /game/world/item-definitions`** — E3M3 backlog + parallel to `skill-definitions`. | **User:** world path. |
| **Manual QA doc** | Add `docs/manual-qa/NEO-53.md`? | **Yes** — user-visible HTTP surface; same pattern as NEO-36. | **User:** yes. |
## Goal, scope, and out-of-scope
**Goal:** Expose a stable, read-only JSON endpoint that returns all loaded prototype item definitions for dev tooling, Bruno, manual QA, and future client/codegen, backed by **`IItemDefinitionRegistry`** without duplicating catalog truth outside the server.
**In scope (from Linear + E3M3-04):**
- **`GET /game/world/item-definitions`** with versioned envelope (`schemaVersion` **1**, **`items`** array).
- Row fields: **`id`**, **`displayName`**, **`prototypeRole`**, **`stackMax`**, **`inventorySlotKind`** (camelCase JSON).
- Items ordered by **`id`** ordinal, matching **`GetDefinitionsInIdOrder()`**.
- Bruno folder `bruno/neon-sprawl-server/item-definitions/`.
- `docs/manual-qa/NEO-53.md`.
- `server/README.md` section.
- API integration tests (AAA).
**Out of scope (from Linear):**
- Per-player inventory mutation ([NEO-54+](E3M3-prototype-backlog.md)).
- Optional schema fields (`rarity`, `bindPolicy`, `durabilityMax`) until content ships them.
- Client HUD.
## Acceptance criteria checklist
- [x] GET returns all **six** prototype defs with **`schemaVersion`** **1**.
- [x] Bruno happy path documented and runnable against dev server.
- [x] Automated API tests (AAA) assert six ids, id order, and spot-check metadata.
## Technical approach
1. **Route:** **`GET /game/world/item-definitions`** — parameterless read; inject **`IItemDefinitionRegistry`** only (not `ItemDefinitionCatalog`).
2. **Response shape:** Top-level **`schemaVersion`** (`ItemDefinitionsListResponse.CurrentSchemaVersion` = **1**) plus **`items`** array. Each row maps from **`ItemDefRow`**: **`id`**, **`displayName`**, **`prototypeRole`**, **`stackMax`**, **`inventorySlotKind`**. Do **not** emit optional null fields on this contract.
3. **Ordering:** Build list from **`registry.GetDefinitionsInIdOrder()`** (already ordinal by id). Tests assert exact id sequence:
`contract_handoff_token`, `field_stim_mk0`, `prototype_armor_shell`, `refined_plate_stock`, `scrap_metal_bulk`, `survey_drone_kit`.
4. **Implementation:** New **`ItemDefinitionsWorldApi`** + **`ItemDefinitionsListDtos.cs`** in `Game/Items/` (mirror [`SkillDefinitionsWorldApi`](../../server/NeonSprawl.Server/Game/Skills/SkillDefinitionsWorldApi.cs)). Wire **`app.MapItemDefinitionsWorldApi()`** from **`Program.cs`** next to **`MapSkillDefinitionsWorldApi()`**.
5. **Bruno:** `bruno/neon-sprawl-server/item-definitions/` with **`Get item definitions.bru`** + **`folder.bru`**. Tests: 200, JSON, **`schemaVersion` 1**, **`items.length === 6`**, ascending id order, frozen six ids present, spot-check **`scrap_metal_bulk`** (`stackMax` 999, `prototypeRole` `material`).
6. **Docs:** `docs/manual-qa/NEO-53.md` (mirror NEO-36). Update **`server/README.md`** item section with GET + curl. Update [E3_M3](E3_M3_ItemizationAndInventorySchema.md) **Related implementation slices** and [documentation_and_implementation_alignment.md](documentation_and_implementation_alignment.md) E3.M3 row when landed.
## Files to add
| Path | Purpose |
|------|---------|
| `server/NeonSprawl.Server/Game/Items/ItemDefinitionsWorldApi.cs` | `Map*` extension registering GET route; maps registry → JSON. |
| `server/NeonSprawl.Server/Game/Items/ItemDefinitionsListDtos.cs` | Versioned response + row DTOs for JSON serialization. |
| `server/NeonSprawl.Server.Tests/Game/Items/ItemDefinitionsWorldApiTests.cs` | HTTP integration: 200, schema v1, six ids in order, spot-check rows. |
| `bruno/neon-sprawl-server/item-definitions/Get item definitions.bru` | Manual / Bruno verification against dev server. |
| `bruno/neon-sprawl-server/item-definitions/folder.bru` | Bruno folder metadata (match `skill-definitions`). |
| `docs/manual-qa/NEO-53.md` | Checklist: start server, curl GET, confirm six ids + spot-check. |
## Files to modify
| Path | Rationale |
|------|-----------|
| `server/NeonSprawl.Server/Program.cs` | Register `MapItemDefinitionsWorldApi()` alongside other game APIs. |
| `server/README.md` | Document `GET /game/world/item-definitions`, curl example, links to plan/manual QA/Bruno. |
| `docs/decomposition/modules/E3_M3_ItemizationAndInventorySchema.md` | **Related implementation slices** — HTTP read model bullet (NEO-53). |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E3.M3 row — note NEO-53 HTTP projection when landed. |
## Tests
| Test file | What it covers |
|-----------|----------------|
| `server/NeonSprawl.Server.Tests/Game/Items/ItemDefinitionsWorldApiTests.cs` | **Integration:** `GET /game/world/item-definitions` via **`InMemoryWebApplicationFactory`**; **`ReadFromJsonAsync`**; assert **`schemaVersion` 1**; **`items`** count **6**; ordered **`id`** list equals frozen six (ordinal); spot-check **`scrap_metal_bulk`** (`displayName`, `stackMax` 999, `prototypeRole` `material`, `inventorySlotKind` `bag`) and **`prototype_armor_shell`** (`inventorySlotKind` `equipment`). **AAA** per [csharp-style](../../.cursor/rules/csharp-style.md). |
Bruno scripts complement automated tests for manual dev-server verification.
## Open questions / risks
| Question / risk | Agent recommendation | Status |
|-----------------|----------------------|--------|
| **Linear blockedBy NEO-52** | Rebase/branch from **`main`** where NEO-52 is merged (already on `main` at kickoff). | `adopted` |
| **Optional fields later** | When content adds `rarity` / bind / durability, bump **`schemaVersion`** or extend v1 with documented null semantics in a follow-up issue. | `deferred` |
None blocking.

View File

@ -0,0 +1,54 @@
# Code review — NEO-52 item definition registry + DI
**Date:** 2026-05-23
**Scope:** Branch `NEO-52-item-definition-registry-di` · commits `1e9f1e9``792b38d` vs `origin/main`
**Base:** `origin/main`
## Verdict
**Approve with nits**
## Summary
NEO-52 adds **`IItemDefinitionRegistry`** and **`ItemDefinitionRegistry`** as a thin adapter over the NEO-51 **`ItemDefinitionCatalog`**, registers both in **`AddItemDefinitionCatalog`**, and ships six AAA unit tests plus a host DI smoke test mirroring **`SkillDefinitionRegistry`** (NEO-35). Kickoff decisions (`TryGetDefinition`, `GetDefinitionsInIdOrder`, no extra eager resolve in `Program.cs`, no re-validation) are implemented as specified. Risk is low: no HTTP, persistence, or gameplay callers yet; enumeration allocates per call at prototype scale (same as skills). Tests pass locally.
## Documentation checked
| Document | Result |
|----------|--------|
| [`docs/plans/NEO-52-implementation-plan.md`](../plans/NEO-52-implementation-plan.md) | **Matches** — interface surface, adapter, DI, README, alignment table, test matrix; acceptance checklist complete. |
| [`docs/decomposition/modules/E3_M3_ItemizationAndInventorySchema.md`](../decomposition/modules/E3_M3_ItemizationAndInventorySchema.md) | **Matches** — frozen roster, NEO-51 server load, and NEO-52 registry bullet under **Related implementation slices**. |
| [`docs/decomposition/modules/documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) | **Matches** — E3.M3 row notes NEO-52 landed with plan link. |
| [`docs/decomposition/modules/module_dependency_register.md`](../decomposition/modules/module_dependency_register.md) | **Matches** — E3.M3 **In Progress**; footnote names NEO-50/NEO-51/NEO-52. |
| [`docs/decomposition/modules/client_server_authority.md`](../decomposition/modules/client_server_authority.md) | **N/A** — read-only definition registry; no client trust boundary change. |
| [`server/README.md`](../../server/README.md) | **Matches** — item catalog section points game code at **`IItemDefinitionRegistry`**. |
Register/tracking: **E3.M3** row update in alignment doc is appropriate; no module **Status** promotion required until HTTP/inventory slices land.
## Blocking issues
None.
## Suggestions
1. ~~**E3.M3 module page — NEO-52 registry** — Under **Related implementation slices**, add a short bullet parallel to **Server load (NEO-51)** documenting **`IItemDefinitionRegistry`** in `Game/Items/` and that NEO-53+ / inventory callers should inject the interface (link [NEO-52 plan](../plans/NEO-52-implementation-plan.md)). Readers who open the module doc before the alignment register will otherwise only see catalog load.~~ **Done.** — bullet added to [`E3_M3_ItemizationAndInventorySchema.md`](../decomposition/modules/E3_M3_ItemizationAndInventorySchema.md).
2. ~~**Dependency register footnote (optional)** — Extend the **E3.M3 note** in [`module_dependency_register.md`](../decomposition/modules/module_dependency_register.md) to mention **NEO-52** alongside NEO-50/NEO-51 so the register matches the alignment table.~~ **Done.**
## Nits
- Nit: **`Host_ShouldResolveRegistryFromDi_WhenStartupSucceeds`** places **`TryGetDefinition`** in **Act** (metadata assertions in **Assert**). Same pattern as **`SkillDefinitionRegistryTests`**; strictly, only **`GetRequiredService<IItemDefinitionRegistry>()`** belongs in Act — optional tidy for both registries later, not blocking.
- Nit: **`GetDefinitionsInIdOrder`** rebuilds a sorted list on every call (keys sort + list materialization). Acceptable for six prototype rows and consistent with **`SkillDefinitionRegistry`**; revisit only if callers poll in hot paths.
- Nit: **`ItemDefinitionRegistry.TryGetDefinition`** duplicates **`ItemDefinitionCatalog.ById`** lookup rather than delegating to **`TryGetItem`** — intentional per plan (game surface vs catalog helper); **`TryGetItem`** does not guard **`null`** ids while the registry does (good for public API).
## Verification
```bash
cd server
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj --filter "FullyQualifiedName~ItemDefinitionRegistryTests"
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj
```
Manual QA: not required for this story (no user-visible API); NEO-53 will exercise enumeration via HTTP.

View File

@ -0,0 +1,55 @@
# Code review — NEO-53 GET world item-definitions + Bruno
**Date:** 2026-05-23
**Scope:** Branch `NEO-53-get-world-item-definitions-bruno` · commits `910bd48``08428c4` vs `origin/main`
**Base:** `origin/main`
## Verdict
**Approve with nits**
## Summary
NEO-53 adds **`GET /game/world/item-definitions`**: a versioned read-only JSON projection (`schemaVersion` **1**, **`items`**) backed by **`IItemDefinitionRegistry`**, mirroring **`SkillDefinitionsWorldApi`** (NEO-36). Implementation is thin (`ItemDefinitionsWorldApi`, DTOs, `Program.cs` wiring), optional catalog fields are correctly omitted from the contract, and integration tests assert the frozen six ids in ordinal order plus row spot-checks. Bruno, manual QA, README, and E3.M3 alignment docs are in place. Risk is low: no auth change, no second catalog source, no inventory mutation.
## Documentation checked
| Document | Result |
|----------|--------|
| [`docs/plans/NEO-53-implementation-plan.md`](../plans/NEO-53-implementation-plan.md) | **Matches** — route, five required row fields, registry-only injection, frozen six order, Bruno/manual QA/README, acceptance checklist complete. |
| [`docs/decomposition/modules/E3_M3_ItemizationAndInventorySchema.md`](../decomposition/modules/E3_M3_ItemizationAndInventorySchema.md) | **Matches** — NEO-53 HTTP bullet under **Related implementation slices**; frozen roster table aligns with test/Bruno assertions. |
| [`docs/decomposition/modules/documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) | **Matches** — E3.M3 row notes NEO-53 landed with plan, manual QA, README, Bruno links. |
| [`docs/decomposition/modules/module_dependency_register.md`](../decomposition/modules/module_dependency_register.md) | **Matches** — E3.M3 **In Progress**; footnote includes NEO-53. |
| [`docs/decomposition/modules/client_server_authority.md`](../decomposition/modules/client_server_authority.md) | **Matches** — read-only world catalog; server remains source of truth; no client-trusted mutation. |
| [`docs/manual-qa/NEO-53.md`](../manual-qa/NEO-53.md) | **Matches** — curl, schema v1, six ids, spot-checks aligned with automated tests. |
| [`server/README.md`](../../server/README.md) | **Matches** — Item definitions section with GET path and curl example. |
Register/tracking: E3.M3 alignment row update is appropriate; module **Status** stays **In Progress** until inventory slices (NEO-54+) land.
## Blocking issues
None.
## Suggestions
1. ~~**Dependency register footnote (optional)** — Extend the **E3.M3 note** in [`module_dependency_register.md`](../decomposition/modules/module_dependency_register.md) to mention **NEO-53** alongside NEO-50NEO-52 so the register matches the alignment table (same pattern as NEO-52 review).~~ **Done.**
2. ~~**Bruno — exact id order (optional)** — C# integration test asserts the full frozen six sequence; Bruno only checks ascending sort plus set membership. Adding an explicit `ids` array assertion (or mirroring the C# frozen list) would catch registry ordering regressions in manual runs without running `dotnet test`.~~ **Done.**`frozen prototype six matches registry id order` test in [`Get item definitions.bru`](../../bruno/neon-sprawl-server/item-definitions/Get%20item%20definitions.bru).
## Nits
- ~~Nit: **`ItemDefinitionsWorldApiTests`** — `[Fact]` and method body use **2-space** indent while the class uses **4-space**; [`SkillDefinitionsWorldApiTests.cs`](../../server/NeonSprawl.Server.Tests/Game/Skills/SkillDefinitionsWorldApiTests.cs) is consistently 4-space. Format-only tidy.~~ **Done.**
- ~~Nit: Bruno **`items are ascending by id`** block omits the **StringComparer.Ordinal** comment present in [`Get skill definitions.bru`](../../bruno/neon-sprawl-server/skill-definitions/Get%20skill%20definitions.bru); harmless for ASCII snake_case ids but copy the comment for parity if touching the file.~~ **Done.**
- Nit: **`ItemDefinitionsWorldApi`** allocates a `List<ItemDefinitionJson>` per request — same pattern as skills; fine at six rows.
## Verification
```bash
cd server
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj --filter "FullyQualifiedName~ItemDefinitionsWorldApi"
dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj
```
Manual: [`docs/manual-qa/NEO-53.md`](../manual-qa/NEO-53.md) and `bruno/neon-sprawl-server/item-definitions/Get item definitions.bru` against dev `baseUrl`.

View File

@ -0,0 +1,195 @@
using System.IO;
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 ItemDefinitionRegistryTests
{
private static ItemDefinitionRegistry CreateRegistryFromRows(IReadOnlyDictionary<string, ItemDefRow> byId)
{
var catalog = new ItemDefinitionCatalog("/tmp/catalog", byId, catalogJsonFileCount: 1);
return new ItemDefinitionRegistry(catalog);
}
[Fact]
public void TryGetDefinition_ShouldReturnTrueAndExpectedMetadata_WhenIdExists()
{
// Arrange
var rows = new Dictionary<string, ItemDefRow>(StringComparer.Ordinal)
{
["scrap_metal_bulk"] = new ItemDefRow(
"scrap_metal_bulk",
"Scrap Metal (Bulk)",
"material",
StackMax: 999,
InventorySlotKind: "bag",
Rarity: null,
BindPolicy: null,
DurabilityMax: null),
};
var registry = CreateRegistryFromRows(rows);
// Act
var found = registry.TryGetDefinition("scrap_metal_bulk", out var def);
// Assert
Assert.True(found);
Assert.NotNull(def);
Assert.Equal("material", def.PrototypeRole);
Assert.Equal("Scrap Metal (Bulk)", def.DisplayName);
Assert.Equal(999, def.StackMax);
Assert.Equal("bag", def.InventorySlotKind);
}
[Fact]
public void TryGetDefinition_ShouldReturnFalse_WhenItemIdIsNull()
{
// Arrange
var rows = new Dictionary<string, ItemDefRow>(StringComparer.Ordinal)
{
["scrap_metal_bulk"] = new ItemDefRow(
"scrap_metal_bulk",
"Scrap Metal (Bulk)",
"material",
StackMax: 999,
InventorySlotKind: "bag",
Rarity: null,
BindPolicy: null,
DurabilityMax: null),
};
var registry = CreateRegistryFromRows(rows);
// Act
var found = registry.TryGetDefinition(null, out var def);
// Assert
Assert.False(found);
Assert.Null(def);
}
[Fact]
public void TryGetDefinition_ShouldReturnFalse_WhenIdUnknown()
{
// Arrange
var rows = new Dictionary<string, ItemDefRow>(StringComparer.Ordinal)
{
["scrap_metal_bulk"] = new ItemDefRow(
"scrap_metal_bulk",
"Scrap Metal (Bulk)",
"material",
StackMax: 999,
InventorySlotKind: "bag",
Rarity: null,
BindPolicy: null,
DurabilityMax: null),
};
var registry = CreateRegistryFromRows(rows);
// Act
var found = registry.TryGetDefinition("not_a_real_item", out var def);
// Assert
Assert.False(found);
Assert.Null(def);
}
[Fact]
public void GetDefinitionsInIdOrder_ShouldListAllRowsOrderedById_WhenMultipleItems()
{
// Arrange
var rows = new Dictionary<string, ItemDefRow>(StringComparer.Ordinal)
{
["survey_drone_kit"] = new ItemDefRow(
"survey_drone_kit",
"Survey Drone Kit",
"utility",
StackMax: 1,
InventorySlotKind: "bag",
Rarity: null,
BindPolicy: null,
DurabilityMax: null),
["scrap_metal_bulk"] = new ItemDefRow(
"scrap_metal_bulk",
"Scrap Metal (Bulk)",
"material",
StackMax: 999,
InventorySlotKind: "bag",
Rarity: null,
BindPolicy: null,
DurabilityMax: null),
["prototype_armor_shell"] = new ItemDefRow(
"prototype_armor_shell",
"Prototype Armor Shell",
"equip_stub",
StackMax: 1,
InventorySlotKind: "equipment",
Rarity: null,
BindPolicy: null,
DurabilityMax: null),
};
var registry = CreateRegistryFromRows(rows);
// Act
var list = registry.GetDefinitionsInIdOrder();
// Assert
Assert.Equal(3, list.Count);
Assert.Equal("prototype_armor_shell", list[0].Id);
Assert.Equal("scrap_metal_bulk", list[1].Id);
Assert.Equal("survey_drone_kit", list[2].Id);
}
[Fact]
public void TryGetDefinition_ShouldMatchLoaderCatalog_WhenUsingPrototypeFixture()
{
// Arrange
var root = Directory.CreateTempSubdirectory("neon-sprawl-item-registry-loader-");
try
{
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);
File.Copy(
Path.Combine(ItemCatalogTestPaths.DiscoverRepoItemsDirectory(), "prototype_items.json"),
Path.Combine(itemsDir, "prototype_items.json"),
overwrite: true);
var loaded = ItemDefinitionCatalogLoader.Load(itemsDir, schemaPath, NullLogger.Instance);
var registry = new ItemDefinitionRegistry(loaded);
// Act
var ok = registry.TryGetDefinition("prototype_armor_shell", out var equipStub);
// Assert
Assert.True(ok);
Assert.NotNull(equipStub);
Assert.Equal("equip_stub", equipStub.PrototypeRole);
Assert.Equal("equipment", equipStub.InventorySlotKind);
Assert.Equal(1, equipStub.StackMax);
}
finally
{
try
{
Directory.Delete(root.FullName, recursive: true);
}
catch (IOException)
{
// Best-effort: transient lock or race on some hosts; temp dir is unique per run.
}
}
}
[Fact]
public async Task Host_ShouldResolveRegistryFromDi_WhenStartupSucceeds()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
using var client = factory.CreateClient();
_ = await client.GetAsync("/health");
// Act
var registry = factory.Services.GetRequiredService<IItemDefinitionRegistry>();
var found = registry.TryGetDefinition("scrap_metal_bulk", out var scrap);
// Assert
Assert.True(found);
Assert.NotNull(scrap);
Assert.Equal("material", scrap.PrototypeRole);
Assert.Equal(999, scrap.StackMax);
}
}

View File

@ -0,0 +1,49 @@
using System.Linq;
using System.Net;
using System.Net.Http.Json;
using NeonSprawl.Server.Game.Items;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Items;
public class ItemDefinitionsWorldApiTests
{
private static readonly string[] FrozenSixInIdOrder =
[
"contract_handoff_token",
"field_stim_mk0",
"prototype_armor_shell",
"refined_plate_stock",
"scrap_metal_bulk",
"survey_drone_kit",
];
[Fact]
public async Task GetItemDefinitions_ShouldReturnSchemaV1_WithFrozenSixInIdOrder()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
// Act
var response = await client.GetAsync("/game/world/item-definitions");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<ItemDefinitionsListResponse>();
Assert.NotNull(body);
Assert.Equal(ItemDefinitionsListResponse.CurrentSchemaVersion, body!.SchemaVersion);
Assert.NotNull(body.Items);
var ids = body.Items.Select(static i => i.Id).ToList();
Assert.Equal(FrozenSixInIdOrder, ids);
var scrap = body.Items.Single(i => i.Id == "scrap_metal_bulk");
Assert.Equal("Scrap Metal (Bulk)", scrap.DisplayName);
Assert.Equal("material", scrap.PrototypeRole);
Assert.Equal(999, scrap.StackMax);
Assert.Equal("bag", scrap.InventorySlotKind);
var armor = body.Items.Single(i => i.Id == "prototype_armor_shell");
Assert.Equal("equip_stub", armor.PrototypeRole);
Assert.Equal(1, armor.StackMax);
Assert.Equal("equipment", armor.InventorySlotKind);
}
}

View File

@ -0,0 +1,20 @@
using System.Diagnostics.CodeAnalysis;
namespace NeonSprawl.Server.Game.Items;
/// <summary>
/// Read-only access to validated <see cref="ItemDefRow"/> entries loaded at startup (<see cref="ItemDefinitionCatalog"/>).
/// </summary>
/// <remarks>
/// <para><b>E3.M3 (inventory / craft / gather):</b> callers granting or validating items should depend on this interface
/// rather than <see cref="ItemDefinitionCatalog"/> so stack limits and slot kinds stay centralized.</para>
/// <para><b>NEO-53:</b> HTTP/read-model projections should depend on this interface rather than reaching into the catalog.</para>
/// </remarks>
public interface IItemDefinitionRegistry
{
/// <summary>Attempts to resolve an item by stable <c>id</c> (see <c>item-def.schema.json</c>). Unknown ids and <c>null</c> return <c>false</c> without throwing.</summary>
bool TryGetDefinition(string? itemId, [NotNullWhen(true)] out ItemDefRow? definition);
/// <summary>Every loaded definition, ordered by <see cref="ItemDefRow.Id"/> (ordinal).</summary>
IReadOnlyList<ItemDefRow> GetDefinitionsInIdOrder();
}

View File

@ -8,7 +8,7 @@ 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>
/// <summary>Binds <see cref="ContentPathsOptions"/> and registers <see cref="ItemDefinitionCatalog"/> and <see cref="IItemDefinitionRegistry"/> as singletons.</summary>
public static IServiceCollection AddItemDefinitionCatalog(this IServiceCollection services, IConfiguration configuration)
{
services.AddOptions<ContentPathsOptions>()
@ -30,6 +30,9 @@ public static class ItemCatalogServiceCollectionExtensions
return ItemDefinitionCatalogLoader.Load(itemsDir, schemaPath, logger);
});
services.AddSingleton<IItemDefinitionRegistry>(sp =>
new ItemDefinitionRegistry(sp.GetRequiredService<ItemDefinitionCatalog>()));
return services;
}
}

View File

@ -2,7 +2,7 @@ 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>
/// <summary>In-memory item catalog loaded at startup (NEO-51). Game code should prefer <see cref="IItemDefinitionRegistry"/> for lookups (NEO-52).</summary>
public sealed class ItemDefinitionCatalog(
string itemsDirectory,
IReadOnlyDictionary<string, ItemDefRow> byId,

View File

@ -0,0 +1,39 @@
using System.Diagnostics.CodeAnalysis;
namespace NeonSprawl.Server.Game.Items;
/// <summary>Adapter over <see cref="ItemDefinitionCatalog"/> (NEO-52).</summary>
public sealed class ItemDefinitionRegistry(ItemDefinitionCatalog catalog) : IItemDefinitionRegistry
{
/// <inheritdoc />
public bool TryGetDefinition(string? itemId, [NotNullWhen(true)] out ItemDefRow? definition)
{
if (itemId is null)
{
definition = null;
return false;
}
if (catalog.ById.TryGetValue(itemId, out var row))
{
definition = row;
return true;
}
definition = null;
return false;
}
/// <inheritdoc />
public IReadOnlyList<ItemDefRow> GetDefinitionsInIdOrder()
{
var ids = catalog.ById.Keys.OrderBy(k => k, StringComparer.Ordinal).ToArray();
var list = new List<ItemDefRow>(ids.Length);
foreach (var id in ids)
{
list.Add(catalog.ById[id]);
}
return list;
}
}

View File

@ -0,0 +1,35 @@
using System.Text.Json.Serialization;
namespace NeonSprawl.Server.Game.Items;
/// <summary>JSON body for <c>GET /game/world/item-definitions</c> (NEO-53).</summary>
public sealed class ItemDefinitionsListResponse
{
public const int CurrentSchemaVersion = 1;
[JsonPropertyName("schemaVersion")]
public int SchemaVersion { get; init; } = CurrentSchemaVersion;
/// <summary>Loaded items ordered by stable <c>id</c> (ordinal), matching <see cref="IItemDefinitionRegistry.GetDefinitionsInIdOrder"/>.</summary>
[JsonPropertyName("items")]
public required IReadOnlyList<ItemDefinitionJson> Items { get; init; }
}
/// <summary>One row in the read-only item definition projection.</summary>
public sealed class ItemDefinitionJson
{
[JsonPropertyName("id")]
public required string Id { get; init; }
[JsonPropertyName("displayName")]
public required string DisplayName { get; init; }
[JsonPropertyName("prototypeRole")]
public required string PrototypeRole { get; init; }
[JsonPropertyName("stackMax")]
public required int StackMax { get; init; }
[JsonPropertyName("inventorySlotKind")]
public required string InventorySlotKind { get; init; }
}

View File

@ -0,0 +1,37 @@
namespace NeonSprawl.Server.Game.Items;
/// <summary>Maps <c>GET /game/world/item-definitions</c> (NEO-53).</summary>
public static class ItemDefinitionsWorldApi
{
public static WebApplication MapItemDefinitionsWorldApi(this WebApplication app)
{
app.MapGet(
"/game/world/item-definitions",
(IItemDefinitionRegistry registry) =>
{
var defs = registry.GetDefinitionsInIdOrder();
var items = new List<ItemDefinitionJson>(defs.Count);
foreach (var d in defs)
{
items.Add(
new ItemDefinitionJson
{
Id = d.Id,
DisplayName = d.DisplayName,
PrototypeRole = d.PrototypeRole,
StackMax = d.StackMax,
InventorySlotKind = d.InventorySlotKind,
});
}
return Results.Json(
new ItemDefinitionsListResponse
{
SchemaVersion = ItemDefinitionsListResponse.CurrentSchemaVersion,
Items = items,
});
});
return app;
}
}

View File

@ -37,6 +37,7 @@ app.MapPositionStateApi();
app.MapInteractionApi();
app.MapInteractablesWorldApi();
app.MapSkillDefinitionsWorldApi();
app.MapItemDefinitionsWorldApi();
app.MapSkillProgressionSnapshotApi();
app.MapPerkStateApi();
if (app.Environment.IsDevelopment() ||

View File

@ -48,7 +48,15 @@ On startup the host loads every **`*_items.json`** under the items directory, va
**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`**.
On success, **Information** logs include the resolved items directory path, distinct item count, and catalog file count. Game code should use **`IItemDefinitionRegistry`** for lookups (NEO-52).
## Item definitions (NEO-53)
**`GET /game/world/item-definitions`** returns a versioned JSON body (`schemaVersion` **1**, **`items`**) backed by **`IItemDefinitionRegistry`** — the same prototype rows loaded at startup (no second source of truth). Plan: [NEO-53 implementation plan](../../docs/plans/NEO-53-implementation-plan.md); manual QA: [`docs/manual-qa/NEO-53.md`](../../docs/manual-qa/NEO-53.md); Bruno: `bruno/neon-sprawl-server/item-definitions/`.
```bash
curl -sS -i "http://localhost:5253/game/world/item-definitions"
```
## Mastery catalog (`content/mastery`, NEO-46)