From db3b80d242b978633f6ae52d0184b4ecc3b5c8af Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sat, 23 May 2026 22:41:05 -0400 Subject: [PATCH 1/7] NEO-55: add implementation plan for inventory GET/POST + Bruno Kickoff clarifications: single POST with mutationKind, full fixed slot arrays, applied response field; no manual QA doc per user choice. --- docs/plans/NEO-55-implementation-plan.md | 122 +++++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 docs/plans/NEO-55-implementation-plan.md diff --git a/docs/plans/NEO-55-implementation-plan.md b/docs/plans/NEO-55-implementation-plan.md new file mode 100644 index 0000000..e355fa9 --- /dev/null +++ b/docs/plans/NEO-55-implementation-plan.md @@ -0,0 +1,122 @@ +# NEO-55 — Implementation plan + +## Story reference + +| Field | Value | +|--------|--------| +| **Key** | NEO-55 | +| **Title** | E3.M3: Player inventory GET/POST + Bruno | +| **Linear** | https://linear.app/neon-sprawl/issue/NEO-55/e3m3-player-inventory-getpost-bruno | +| **Module** | [E3.M3 — ItemizationAndInventorySchema](../decomposition/modules/E3_M3_ItemizationAndInventorySchema.md) · Epic 3 Slice 1 (E3M3-06) | +| **Branch** | `NEO-55-player-inventory-getpost-bruno` | +| **Precursor** | [NEO-54](https://linear.app/neon-sprawl/issue/NEO-54) — `IPlayerInventoryStore` + `PlayerInventoryOperations` (**Done** on `main`) | +| **Pattern** | [NEO-37](https://linear.app/neon-sprawl/issue/NEO-37) / [NEO-38](https://linear.app/neon-sprawl/issue/NEO-38) — `GET`/`POST /game/players/{id}/skill-progression` snapshot + mutating apply with structured deny | + +## Kickoff clarifications + +| Topic | Question | Agent recommendation | Answer | +|--------|----------|----------------------|--------| +| **POST shape** | Single POST vs separate add/remove routes? | **Single POST** on same path as GET with **`mutationKind`** **`add`** \| **`remove`**, **`itemId`**, **`quantity`**, **`schemaVersion`** — mirrors NEO-37/38 one-route read/write pattern and maps 1:1 to **`PlayerInventoryOperations`**. | **User:** single POST + **`mutationKind`**. | +| **GET slots** | Full fixed arrays vs sparse occupied-only? | **Full fixed arrays** — **`bagSlots`** length **24**, **`equipmentSlots`** length **1**; empty slots **`{ slotIndex, quantity: 0 }`** with **`itemId`** omitted — matches NEO-54 snapshot model and supports client grid rendering. | **User:** full fixed arrays. | +| **POST success field** | **`applied`** vs **`granted`**? | **`applied`** — inventory mutations are add/remove, not XP grants; aligns with hotbar **`updated`** naming. | **User:** **`applied`**. | +| **Manual QA doc** | Add **`docs/manual-qa/NEO-55.md`**? | **Yes** — user-visible HTTP surface; same pattern as NEO-53. | **User:** **No** — Bruno + README only. | + +## Goal, scope, and out-of-scope + +**Goal:** Versioned **`GET /game/players/{id}/inventory`** snapshot and mutating **`POST`** for manual QA and future client, delegating rules to **`PlayerInventoryOperations`** (NEO-54). + +**In scope (from Linear + [E3M3-06](E3M3-prototype-backlog.md#e3m3-06--player-inventory-http--bruno)):** + +- **`GET /game/players/{id}/inventory`** — instances + fixed slot arrays for known player. +- **`POST /game/players/{id}/inventory`** — **`mutationKind`** **`add`** / **`remove`** with engine-backed deny reason codes. +- Known-player gate via **`IPositionStateStore`** (NEO-37 pattern). +- Bruno **`bruno/neon-sprawl-server/inventory/`** — happy path + at least one deny case. +- **`server/README.md`** inventory HTTP subsection. +- API integration tests (AAA). + +**Out of scope (from Linear):** + +- Godot HUD. +- Gather node depletion ([E3.M1](../decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md)). +- Telemetry hook sites ([NEO-56](https://linear.app/neon-sprawl/issue/NEO-56)). +- **`docs/manual-qa/NEO-55.md`** (kickoff decision — Bruno + README cover manual verification). + +## Acceptance criteria checklist + +- [ ] GET returns instances + slots for known player (`dev-local-1` seeded empty inventory). +- [ ] POST add/remove matches E3M3-05 engine rules (`stackMax`, all-or-nothing add, stable **`reasonCode`** denies). +- [ ] Bruno exercises happy path + at least one deny (`inventory_full` or **`invalid_item`**). + +## Technical approach + +1. **Routes:** **`GET`** and **`POST`** **`/game/players/{id}/inventory`** — trim **`id`**; **404** when empty/unknown position (NEO-37 gate). + +2. **GET response (`PlayerInventorySnapshotResponse`, `schemaVersion` **1**):** + - **`playerId`**, **`schemaVersion`**, **`bagSlots`** (length **24**), **`equipmentSlots`** (length **1**). + - Each slot row: **`slotIndex`**, optional **`itemId`** (omitted when empty), **`quantity`** (**0** when empty). + - Map from **`PlayerInventorySnapshot`** via internal **`BuildSnapshot(playerId, store)`** helper (mirror **`SkillProgressionSnapshotApi.BuildSnapshot`**). + +3. **POST request (`PlayerInventoryMutationRequest`, `schemaVersion` **1**):** + - **`mutationKind`**: **`add`** \| **`remove`** (ordinal ignore-case). + - **`itemId`**, **`quantity`** (positive int for engine; non-positive handled by engine → **`invalid_quantity`** deny). + +4. **POST response (`PlayerInventoryMutationResponse`, `schemaVersion` **1**):** + - **`applied`**: **`true`** on success, **`false`** on rule deny. + - **`reasonCode`**: **`null`** on success; engine codes on deny (`inventory_full`, `invalid_item`, `insufficient_quantity`, `invalid_quantity`). + - **`inventory`**: full snapshot echo (unchanged on deny, updated on success) — mirror NEO-38 progression echo. + +5. **HTTP status mapping (NEO-38 precedent):** + - **400** — null body or **`schemaVersion`** mismatch; unknown **`mutationKind`** string. + - **404** — unknown player (position gate) or **`PlayerInventoryMutationKind.StoreMissing`**. + - **200** + JSON — rule **`Denied`** with **`applied: false`** + **`reasonCode`** + snapshot echo. + - **200** + JSON — **`Applied`** with **`applied: true`**, **`reasonCode: null`**, updated snapshot. + +6. **Implementation files:** **`PlayerInventoryApi.cs`** + **`PlayerInventoryDtos.cs`** in **`Game/Items/`**; wire **`app.MapPlayerInventoryApi()`** from **`Program.cs`** after item catalog maps (inventory store already registered via **`AddItemDefinitionCatalog`** chain). + +7. **Bruno (`bruno/neon-sprawl-server/inventory/`):** + - **`Get inventory.bru`** — 200, **`schemaVersion` 1**, **`bagSlots.length === 24`**, **`equipmentSlots.length === 1`**, all empty on fresh dev player. + - **`Post inventory add.bru`** — add **`scrap_metal_bulk`** qty **5**; assert **`applied` true**, slot shows item + quantity. + - **`Post inventory add deny invalid item.bru`** (or fill-bag deny) — assert **`applied` false** + stable **`reasonCode`**. + - **`folder.bru`** — match sibling folders. + +8. **Docs (on land):** Expand **`server/README.md`** NEO-54 inventory section with GET/POST curl examples and reason codes. Update [E3_M3](E3_M3_ItemizationAndInventorySchema.md) **Related implementation slices** and [documentation_and_implementation_alignment.md](documentation_and_implementation_alignment.md) E3.M3 row. + +## Files to add + +| Path | Purpose | +|------|---------| +| `server/NeonSprawl.Server/Game/Items/PlayerInventoryDtos.cs` | Versioned GET snapshot + POST request/response DTOs (`schemaVersion` 1). | +| `server/NeonSprawl.Server/Game/Items/PlayerInventoryApi.cs` | `MapPlayerInventoryApi` — GET/POST routes, position gate, engine dispatch, JSON mapping. | +| `server/NeonSprawl.Server.Tests/Game/Items/PlayerInventoryApiTests.cs` | AAA HTTP integration: GET 404/200 empty grid; POST add happy; POST deny (`invalid_item`, `insufficient_quantity`); schema mismatch 400; unknown player 404. | +| `bruno/neon-sprawl-server/inventory/folder.bru` | Bruno folder metadata. | +| `bruno/neon-sprawl-server/inventory/Get inventory.bru` | GET happy path against `dev-local-1`. | +| `bruno/neon-sprawl-server/inventory/Post inventory add.bru` | POST add happy path (`scrap_metal_bulk`). | +| `bruno/neon-sprawl-server/inventory/Post inventory add deny invalid item.bru` | POST deny with `invalid_item`. | + +## Files to modify + +| Path | Rationale | +|------|-----------| +| `server/NeonSprawl.Server/Program.cs` | Register **`MapPlayerInventoryApi()`** alongside other game APIs. | +| `server/README.md` | Document GET/POST inventory routes, request/response shapes, curl examples, reason codes; remove “no HTTP in NEO-54” deferral note. | +| `docs/decomposition/modules/E3_M3_ItemizationAndInventorySchema.md` | **Related implementation slices** — inventory HTTP bullet (NEO-55). | +| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | E3.M3 row — note NEO-55 GET/POST when landed. | + +## Tests + +| Test file | What it covers | +|-----------|----------------| +| `PlayerInventoryApiTests.cs` | **GET** unknown player → **404**. **GET** `dev-local-1` → **200**, **`schemaVersion` 1**, **24** bag + **1** equipment slots, all empty. **POST add** `scrap_metal_bulk` qty **10** → **`applied` true**, quantity in bag slot(s), **`reasonCode` null**. **POST add** unknown item → **200**, **`applied` false**, **`invalid_item`**, inventory unchanged. **POST remove** without prior add → **`insufficient_quantity`**. **POST** bad **`schemaVersion`** → **400**. **POST** unknown **`mutationKind`** → **400**. **AAA** per [csharp-style](../../.cursor/rules/csharp-style.md). | + +Bruno scripts complement automated tests for dev-server manual verification (no **`docs/manual-qa/NEO-55.md`** per kickoff). + +## Open questions / risks + +| Question / risk | Agent recommendation | Status | +|-----------------|----------------------|--------| +| **Unknown `mutationKind`** | **400 BadRequest** (client contract error), not engine deny — same class as schema mismatch. | `adopted` | +| **Empty slot JSON** | Omit **`itemId`** property when quantity 0 (System.Text.Json default for null optional). | `adopted` | +| **NEO-56 telemetry hooks** | No hook comments in this story — NEO-56 owns engine/POST hook placement. | `deferred` to NEO-56 | +| **Concurrent POSTs** | Last-write-wins at store layer (NEO-54 review note); acceptable for prototype QA. | `adopted` | + +None blocking. From 69327f50b45dbbbca8d5b364f97c65d94e762816 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sat, 23 May 2026 22:45:27 -0400 Subject: [PATCH 2/7] NEO-55: player inventory GET/POST HTTP, Bruno, and docs Versioned snapshot read and mutationKind add/remove POST backed by PlayerInventoryOperations; AAA API tests; Bruno happy path + deny case. --- .../inventory/Get inventory.bru | 41 ++++ .../Post inventory add deny invalid item.bru | 36 ++++ .../inventory/Post inventory add.bru | 41 ++++ bruno/neon-sprawl-server/inventory/folder.bru | 3 + .../E3_M3_ItemizationAndInventorySchema.md | 4 +- ...umentation_and_implementation_alignment.md | 2 +- docs/plans/NEO-55-implementation-plan.md | 6 +- .../Game/Items/PlayerInventoryApiTests.cs | 182 ++++++++++++++++++ .../Game/Items/PlayerInventoryApi.cs | 134 +++++++++++++ .../Game/Items/PlayerInventoryDtos.cs | 76 ++++++++ server/NeonSprawl.Server/Program.cs | 1 + server/README.md | 19 +- 12 files changed, 536 insertions(+), 9 deletions(-) create mode 100644 bruno/neon-sprawl-server/inventory/Get inventory.bru create mode 100644 bruno/neon-sprawl-server/inventory/Post inventory add deny invalid item.bru create mode 100644 bruno/neon-sprawl-server/inventory/Post inventory add.bru create mode 100644 bruno/neon-sprawl-server/inventory/folder.bru create mode 100644 server/NeonSprawl.Server.Tests/Game/Items/PlayerInventoryApiTests.cs create mode 100644 server/NeonSprawl.Server/Game/Items/PlayerInventoryApi.cs create mode 100644 server/NeonSprawl.Server/Game/Items/PlayerInventoryDtos.cs diff --git a/bruno/neon-sprawl-server/inventory/Get inventory.bru b/bruno/neon-sprawl-server/inventory/Get inventory.bru new file mode 100644 index 0000000..60e701e --- /dev/null +++ b/bruno/neon-sprawl-server/inventory/Get inventory.bru @@ -0,0 +1,41 @@ +meta { + name: GET inventory + type: http + seq: 1 +} + +docs { + NEO-55: per-player inventory read model; join with GET /game/world/item-definitions for stackMax and slot kinds. + Fresh dev server seeds dev-local-1 with empty 24+1 slot grid. +} + +get { + url: {{baseUrl}}/game/players/dev-local-1/inventory + body: none + auth: none +} + +tests { + test("returns 200 JSON with schema v1 and fixed slot arrays", 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.playerId).to.equal("dev-local-1"); + expect(body.bagSlots).to.be.an("array"); + expect(body.bagSlots.length).to.equal(24); + expect(body.equipmentSlots).to.be.an("array"); + expect(body.equipmentSlots.length).to.equal(1); + }); + + test("empty slots omit itemId and use quantity 0", function () { + const body = res.getBody(); + for (const slot of body.bagSlots) { + expect(slot.slotIndex).to.be.a("number"); + expect(slot.quantity).to.equal(0); + expect(slot.itemId).to.equal(undefined); + } + expect(body.equipmentSlots[0].quantity).to.equal(0); + expect(body.equipmentSlots[0].itemId).to.equal(undefined); + }); +} diff --git a/bruno/neon-sprawl-server/inventory/Post inventory add deny invalid item.bru b/bruno/neon-sprawl-server/inventory/Post inventory add deny invalid item.bru new file mode 100644 index 0000000..58961dd --- /dev/null +++ b/bruno/neon-sprawl-server/inventory/Post inventory add deny invalid item.bru @@ -0,0 +1,36 @@ +meta { + name: POST inventory add deny invalid item + type: http + seq: 3 +} + +docs { + NEO-55: unknown itemId denies with invalid_item and echoes unchanged inventory. +} + +post { + url: {{baseUrl}}/game/players/dev-local-1/inventory + body: json + auth: none +} + +body:json { + { + "schemaVersion": 1, + "mutationKind": "add", + "itemId": "not-a-prototype-item", + "quantity": 1 + } +} + +tests { + test("denies with invalid_item and echoes inventory", function () { + expect(res.getStatus()).to.equal(200); + const body = res.getBody(); + expect(body.schemaVersion).to.equal(1); + expect(body.applied).to.equal(false); + expect(body.reasonCode).to.equal("invalid_item"); + expect(body.inventory).to.be.an("object"); + expect(body.inventory.bagSlots.length).to.equal(24); + }); +} diff --git a/bruno/neon-sprawl-server/inventory/Post inventory add.bru b/bruno/neon-sprawl-server/inventory/Post inventory add.bru new file mode 100644 index 0000000..1be951e --- /dev/null +++ b/bruno/neon-sprawl-server/inventory/Post inventory add.bru @@ -0,0 +1,41 @@ +meta { + name: POST inventory add + type: http + seq: 2 +} + +docs { + NEO-55: add stack via mutationKind add; see server README inventory HTTP section. +} + +post { + url: {{baseUrl}}/game/players/dev-local-1/inventory + body: json + auth: none +} + +body:json { + { + "schemaVersion": 1, + "mutationKind": "add", + "itemId": "scrap_metal_bulk", + "quantity": 5 + } +} + +tests { + test("add returns 200 with applied true and updated inventory", function () { + expect(res.getStatus()).to.equal(200); + const body = res.getBody(); + expect(body.schemaVersion).to.equal(1); + expect(body.applied).to.equal(true); + expect(body.reasonCode).to.equal(null); + expect(body.inventory).to.be.an("object"); + expect(body.inventory.playerId).to.equal("dev-local-1"); + const occupied = body.inventory.bagSlots.filter((s) => s.quantity > 0); + expect(occupied.length).to.be.at.least(1); + const scrap = occupied.find((s) => s.itemId === "scrap_metal_bulk"); + expect(scrap).to.be.an("object"); + expect(scrap.quantity).to.be.at.least(5); + }); +} diff --git a/bruno/neon-sprawl-server/inventory/folder.bru b/bruno/neon-sprawl-server/inventory/folder.bru new file mode 100644 index 0000000..12b2b71 --- /dev/null +++ b/bruno/neon-sprawl-server/inventory/folder.bru @@ -0,0 +1,3 @@ +meta { + name: inventory +} diff --git a/docs/decomposition/modules/E3_M3_ItemizationAndInventorySchema.md b/docs/decomposition/modules/E3_M3_ItemizationAndInventorySchema.md index f1bdd31..942bc0e 100644 --- a/docs/decomposition/modules/E3_M3_ItemizationAndInventorySchema.md +++ b/docs/decomposition/modules/E3_M3_ItemizationAndInventorySchema.md @@ -68,7 +68,9 @@ Epic 3 **Slice 1** — MVP inventory; `item_created`, transfer failures. **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). -**Player inventory store (NEO-54):** **`IPlayerInventoryStore`** + **`PlayerInventoryOperations`** in `server/NeonSprawl.Server/Game/Items/` — fixed **24 bag + 1 equipment** slots, stack limits from catalog, stable deny reason codes; in-memory + Postgres (`V005__player_inventory.sql`). HTTP deferred to NEO-55. Plan: [NEO-54 implementation plan](../../plans/NEO-54-implementation-plan.md). +**Player inventory store (NEO-54):** **`IPlayerInventoryStore`** + **`PlayerInventoryOperations`** in `server/NeonSprawl.Server/Game/Items/` — fixed **24 bag + 1 equipment** slots, stack limits from catalog, stable deny reason codes; in-memory + Postgres (`V005__player_inventory.sql`). Plan: [NEO-54 implementation plan](../../plans/NEO-54-implementation-plan.md). + +**Player inventory HTTP (NEO-55):** **`GET` / `POST /game/players/{id}/inventory`** — versioned snapshot + **`mutationKind`** add/remove backed by **`PlayerInventoryOperations`**; Bruno `bruno/neon-sprawl-server/inventory/`. Plan: [NEO-55 implementation plan](../../plans/NEO-55-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). diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md index 7fee86f..5d147ec 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). **NEO-51 landed:** fail-fast server load of `content/items/*_items.json` at startup — `server/NeonSprawl.Server/Game/Items/` ([NEO-51](../../plans/NEO-51-implementation-plan.md)); [server README — Item catalog](../../../server/README.md#item-catalog-contentitems-neo-51). **NEO-52 landed:** injectable **`IItemDefinitionRegistry`** + lookup tests ([NEO-52](../../plans/NEO-52-implementation-plan.md)). **NEO-53 landed:** **`GET /game/world/item-definitions`** — `ItemDefinitionsWorldApi` + DTOs in `Game/Items/` ([NEO-53](../../plans/NEO-53-implementation-plan.md), [`NEO-53` manual QA](../../manual-qa/NEO-53.md)); [server README — Item definitions (NEO-53)](../../../server/README.md#item-definitions-neo-53); Bruno `bruno/neon-sprawl-server/item-definitions/`. **NEO-54 landed:** **`IPlayerInventoryStore`** + **`PlayerInventoryOperations`** — 24 bag + 1 equipment slots, stack rules, `V005` migration ([NEO-54](../../plans/NEO-54-implementation-plan.md)); [server README — Player inventory store (NEO-54)](../../../server/README.md#player-inventory-store-neo-54). **Still planned:** per-player inventory HTTP (NEO-55). | [NEO-50](../../plans/NEO-50-implementation-plan.md), [NEO-51](../../plans/NEO-51-implementation-plan.md), [NEO-52](../../plans/NEO-52-implementation-plan.md), [NEO-53](../../plans/NEO-53-implementation-plan.md), [NEO-54](../../plans/NEO-54-implementation-plan.md), [E3M3-prototype-backlog](../../plans/E3M3-prototype-backlog.md), [E3_M3](E3_M3_ItemizationAndInventorySchema.md) | +| E3.M3 | In Progress | **NEO-50 landed:** frozen prototype six-item catalog in [`content/items/prototype_items.json`](../../../content/items/prototype_items.json); [`item-def.schema.json`](../../../content/schemas/item-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py). **NEO-51 landed:** fail-fast server load of `content/items/*_items.json` at startup — `server/NeonSprawl.Server/Game/Items/` ([NEO-51](../../plans/NEO-51-implementation-plan.md)); [server README — Item catalog](../../../server/README.md#item-catalog-contentitems-neo-51). **NEO-52 landed:** injectable **`IItemDefinitionRegistry`** + lookup tests ([NEO-52](../../plans/NEO-52-implementation-plan.md)). **NEO-53 landed:** **`GET /game/world/item-definitions`** — `ItemDefinitionsWorldApi` + DTOs in `Game/Items/` ([NEO-53](../../plans/NEO-53-implementation-plan.md), [`NEO-53` manual QA](../../manual-qa/NEO-53.md)); [server README — Item definitions (NEO-53)](../../../server/README.md#item-definitions-neo-53); Bruno `bruno/neon-sprawl-server/item-definitions/`. **NEO-54 landed:** **`IPlayerInventoryStore`** + **`PlayerInventoryOperations`** — 24 bag + 1 equipment slots, stack rules, `V005` migration ([NEO-54](../../plans/NEO-54-implementation-plan.md)). **NEO-55 landed:** **`GET`/`POST /game/players/{id}/inventory`** — `PlayerInventoryApi` + DTOs in `Game/Items/` ([NEO-55](../../plans/NEO-55-implementation-plan.md)); [server README — Player inventory (NEO-54 store, NEO-55 HTTP)](../../../server/README.md#player-inventory-neo-54-store-neo-55-http); Bruno `bruno/neon-sprawl-server/inventory/`. **Still planned:** telemetry hook sites (NEO-56). | [NEO-50](../../plans/NEO-50-implementation-plan.md), [NEO-51](../../plans/NEO-51-implementation-plan.md), [NEO-52](../../plans/NEO-52-implementation-plan.md), [NEO-53](../../plans/NEO-53-implementation-plan.md), [NEO-54](../../plans/NEO-54-implementation-plan.md), [NEO-55](../../plans/NEO-55-implementation-plan.md), [E3M3-prototype-backlog](../../plans/E3M3-prototype-backlog.md), [E3_M3](E3_M3_ItemizationAndInventorySchema.md) | --- diff --git a/docs/plans/NEO-55-implementation-plan.md b/docs/plans/NEO-55-implementation-plan.md index e355fa9..f356d21 100644 --- a/docs/plans/NEO-55-implementation-plan.md +++ b/docs/plans/NEO-55-implementation-plan.md @@ -43,9 +43,9 @@ ## Acceptance criteria checklist -- [ ] GET returns instances + slots for known player (`dev-local-1` seeded empty inventory). -- [ ] POST add/remove matches E3M3-05 engine rules (`stackMax`, all-or-nothing add, stable **`reasonCode`** denies). -- [ ] Bruno exercises happy path + at least one deny (`inventory_full` or **`invalid_item`**). +- [x] GET returns instances + slots for known player (`dev-local-1` seeded empty inventory). +- [x] POST add/remove matches E3M3-05 engine rules (`stackMax`, all-or-nothing add, stable **`reasonCode`** denies). +- [x] Bruno exercises happy path + at least one deny (`inventory_full` or **`invalid_item`**). ## Technical approach diff --git a/server/NeonSprawl.Server.Tests/Game/Items/PlayerInventoryApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Items/PlayerInventoryApiTests.cs new file mode 100644 index 0000000..218d1ea --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Items/PlayerInventoryApiTests.cs @@ -0,0 +1,182 @@ +using System.Linq; +using System.Net; +using System.Net.Http.Json; +using NeonSprawl.Server.Game.Items; +using Xunit; + +namespace NeonSprawl.Server.Tests.Game.Items; + +/// Regression tests for GET/POST …/inventory (NEO-55). +public sealed class PlayerInventoryApiTests +{ + private static PlayerInventoryMutationRequest Mutation( + string mutationKind, + string itemId, + int quantity, + int schemaVersion = PlayerInventoryMutationRequest.CurrentSchemaVersion) => + new() + { + SchemaVersion = schemaVersion, + MutationKind = mutationKind, + ItemId = itemId, + Quantity = quantity, + }; + + [Fact] + public async Task GetInventory_ShouldReturnNotFound_WhenPlayerUnknown() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + + // Act + var response = await client.GetAsync("/game/players/missing-player/inventory"); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task GetInventory_ShouldReturnSchemaV1_WithEmptyFixedSlotArrays() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + + // Act + var response = await client.GetAsync("/game/players/dev-local-1/inventory"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + Assert.Equal(PlayerInventorySnapshotResponse.CurrentSchemaVersion, body!.SchemaVersion); + Assert.Equal("dev-local-1", body.PlayerId); + Assert.Equal(PlayerInventorySnapshot.BagSlotCount, body.BagSlots.Count); + Assert.Single(body.EquipmentSlots); + Assert.All(body.BagSlots, static slot => + { + Assert.Null(slot.ItemId); + Assert.Equal(0, slot.Quantity); + }); + Assert.All(body.EquipmentSlots, static slot => + { + Assert.Null(slot.ItemId); + Assert.Equal(0, slot.Quantity); + }); + Assert.Equal(Enumerable.Range(0, PlayerInventorySnapshot.BagSlotCount), body.BagSlots.Select(static s => s.SlotIndex)); + Assert.Equal(0, body.EquipmentSlots[0].SlotIndex); + } + + [Fact] + public async Task PostInventory_ShouldReturnBadRequest_WhenSchemaVersionMismatch() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + var request = Mutation("add", "scrap_metal_bulk", quantity: 1, schemaVersion: 99); + + // Act + var response = await client.PostAsJsonAsync("/game/players/dev-local-1/inventory", request); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task PostInventory_ShouldReturnBadRequest_WhenMutationKindUnknown() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + var request = Mutation("consume", "scrap_metal_bulk", quantity: 1); + + // Act + var response = await client.PostAsJsonAsync("/game/players/dev-local-1/inventory", request); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task PostInventory_ShouldReturnNotFound_WhenPlayerUnknown() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + + // Act + var response = await client.PostAsJsonAsync( + "/game/players/missing-player/inventory", + Mutation("add", "scrap_metal_bulk", quantity: 1)); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task PostInventory_ShouldApplyAdd_WithUpdatedSnapshot() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + + // Act + var response = await client.PostAsJsonAsync( + "/game/players/dev-local-1/inventory", + Mutation("add", "scrap_metal_bulk", quantity: 10)); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + Assert.True(body!.Applied); + Assert.Null(body.ReasonCode); + Assert.Equal("dev-local-1", body.Inventory.PlayerId); + var occupied = body.Inventory.BagSlots.Where(static s => s.Quantity > 0).ToList(); + Assert.Single(occupied); + Assert.Equal("scrap_metal_bulk", occupied[0].ItemId); + Assert.Equal(10, occupied[0].Quantity); + } + + [Fact] + public async Task PostInventory_ShouldDenyUnknownItem_WithInvalidItem_AndLeaveInventoryEmpty() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + + // Act + var response = await client.PostAsJsonAsync( + "/game/players/dev-local-1/inventory", + Mutation("add", "not-a-prototype-item", quantity: 1)); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + Assert.False(body!.Applied); + Assert.Equal(PlayerInventoryReasonCodes.InvalidItem, body.ReasonCode); + Assert.All(body.Inventory.BagSlots, static slot => Assert.Equal(0, slot.Quantity)); + } + + [Fact] + public async Task PostInventory_ShouldDenyRemove_WithInsufficientQuantity() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + + // Act + var response = await client.PostAsJsonAsync( + "/game/players/dev-local-1/inventory", + Mutation("remove", "scrap_metal_bulk", quantity: 1)); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + Assert.False(body!.Applied); + Assert.Equal(PlayerInventoryReasonCodes.InsufficientQuantity, body.ReasonCode); + } +} diff --git a/server/NeonSprawl.Server/Game/Items/PlayerInventoryApi.cs b/server/NeonSprawl.Server/Game/Items/PlayerInventoryApi.cs new file mode 100644 index 0000000..a8135a7 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Items/PlayerInventoryApi.cs @@ -0,0 +1,134 @@ +using NeonSprawl.Server.Game.PositionState; + +namespace NeonSprawl.Server.Game.Items; + +/// +/// Maps GET/POST /game/players/{{id}}/inventory — read snapshot and add/remove mutations (NEO-55). +/// +public static class PlayerInventoryApi +{ + public static WebApplication MapPlayerInventoryApi(this WebApplication app) + { + app.MapGet( + "/game/players/{id}/inventory", + (string id, IPositionStateStore positions, IPlayerInventoryStore store) => + { + var trimmedId = id.Trim(); + if (trimmedId.Length == 0 || !positions.TryGetPosition(trimmedId, out _)) + { + return Results.NotFound(); + } + + if (!store.TryGetSnapshot(trimmedId, out var snapshot)) + { + return Results.NotFound(); + } + + return Results.Json(MapSnapshot(trimmedId, snapshot)); + }); + + app.MapPost( + "/game/players/{id}/inventory", + (string id, PlayerInventoryMutationRequest? body, IPositionStateStore positions, + IItemDefinitionRegistry registry, IPlayerInventoryStore store) => + { + if (body is null || body.SchemaVersion != PlayerInventoryMutationRequest.CurrentSchemaVersion) + { + return Results.BadRequest(); + } + + if (!TryParseMutationKind(body.MutationKind, out var isAdd)) + { + return Results.BadRequest(); + } + + var trimmedId = id.Trim(); + if (trimmedId.Length == 0 || !positions.TryGetPosition(trimmedId, out _)) + { + return Results.NotFound(); + } + + var outcome = isAdd + ? PlayerInventoryOperations.TryAddStack(trimmedId, body.ItemId, body.Quantity, registry, store) + : PlayerInventoryOperations.TryRemoveStack(trimmedId, body.ItemId, body.Quantity, registry, store); + + return outcome.Kind switch + { + PlayerInventoryMutationKind.StoreMissing => Results.NotFound(), + PlayerInventoryMutationKind.Denied => Results.Json( + BuildMutationResponse(trimmedId, applied: false, outcome.ReasonCode, outcome.Snapshot, store)), + PlayerInventoryMutationKind.Applied => Results.Json( + BuildMutationResponse(trimmedId, applied: true, reasonCode: null, outcome.Snapshot, store)), + _ => throw new InvalidOperationException($"Unexpected inventory mutation outcome: {outcome.Kind}"), + }; + }); + + return app; + } + + internal static PlayerInventorySnapshotResponse MapSnapshot(string playerId, PlayerInventorySnapshot snapshot) => + new() + { + PlayerId = playerId, + SchemaVersion = PlayerInventorySnapshotResponse.CurrentSchemaVersion, + BagSlots = MapSlots(snapshot.BagSlots), + EquipmentSlots = MapSlots(snapshot.EquipmentSlots), + }; + + private static IReadOnlyList MapSlots(InventorySlotState[] slots) + { + var rows = new InventorySlotStateJson[slots.Length]; + for (var i = 0; i < slots.Length; i++) + { + var slot = slots[i]; + rows[i] = slot.IsEmpty + ? new InventorySlotStateJson { SlotIndex = slot.SlotIndex, Quantity = 0 } + : new InventorySlotStateJson + { + SlotIndex = slot.SlotIndex, + ItemId = slot.ItemId, + Quantity = slot.Quantity, + }; + } + + return rows; + } + + private static PlayerInventoryMutationResponse BuildMutationResponse( + string playerId, + bool applied, + string? reasonCode, + PlayerInventorySnapshot? snapshot, + IPlayerInventoryStore store) + { + if (snapshot is null && !store.TryGetSnapshot(playerId, out snapshot)) + { + snapshot = PlayerInventorySnapshot.Empty(); + } + + return new PlayerInventoryMutationResponse + { + Applied = applied, + ReasonCode = reasonCode, + Inventory = MapSnapshot(playerId, snapshot!), + }; + } + + private static bool TryParseMutationKind(string? raw, out bool isAdd) + { + if (string.Equals(raw, "add", StringComparison.OrdinalIgnoreCase)) + { + isAdd = true; + return true; + } + + if (string.Equals(raw, "remove", StringComparison.OrdinalIgnoreCase)) + { + isAdd = false; + return true; + } + + isAdd = false; + return false; + } +} diff --git a/server/NeonSprawl.Server/Game/Items/PlayerInventoryDtos.cs b/server/NeonSprawl.Server/Game/Items/PlayerInventoryDtos.cs new file mode 100644 index 0000000..ddd75db --- /dev/null +++ b/server/NeonSprawl.Server/Game/Items/PlayerInventoryDtos.cs @@ -0,0 +1,76 @@ +using System.Text.Json.Serialization; + +namespace NeonSprawl.Server.Game.Items; + +/// JSON body for GET /game/players/{{id}}/inventory (NEO-55). +public sealed class PlayerInventorySnapshotResponse +{ + public const int CurrentSchemaVersion = 1; + + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } = CurrentSchemaVersion; + + [JsonPropertyName("playerId")] + public required string PlayerId { get; init; } + + /// Fixed bag slots 0..23 in ascending order. + [JsonPropertyName("bagSlots")] + public required IReadOnlyList BagSlots { get; init; } + + /// Fixed equipment slot index 0. + [JsonPropertyName("equipmentSlots")] + public required IReadOnlyList EquipmentSlots { get; init; } +} + +/// POST body for inventory add/remove (NEO-55). +public sealed class PlayerInventoryMutationRequest +{ + public const int CurrentSchemaVersion = 1; + + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } + + /// add or remove (ordinal ignore-case). + [JsonPropertyName("mutationKind")] + public required string MutationKind { get; init; } + + [JsonPropertyName("itemId")] + public required string ItemId { get; init; } + + [JsonPropertyName("quantity")] + public int Quantity { get; init; } +} + +/// POST response for inventory mutations — applied or structured deny (NEO-55). +public sealed class PlayerInventoryMutationResponse +{ + public const int CurrentSchemaVersion = 1; + + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; init; } = CurrentSchemaVersion; + + [JsonPropertyName("applied")] + public bool Applied { get; init; } + + /// null on success; stable snake_case deny codes from on deny. + [JsonPropertyName("reasonCode")] + public string? ReasonCode { get; init; } + + [JsonPropertyName("inventory")] + public required PlayerInventorySnapshotResponse Inventory { get; init; } +} + +/// Wire shape for one inventory slot in GET/POST responses. +public sealed class InventorySlotStateJson +{ + [JsonPropertyName("slotIndex")] + public int SlotIndex { get; init; } + + /// Omitted when the slot is empty. + [JsonPropertyName("itemId")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ItemId { get; init; } + + [JsonPropertyName("quantity")] + public int Quantity { get; init; } +} diff --git a/server/NeonSprawl.Server/Program.cs b/server/NeonSprawl.Server/Program.cs index 55c9d79..77cda31 100644 --- a/server/NeonSprawl.Server/Program.cs +++ b/server/NeonSprawl.Server/Program.cs @@ -38,6 +38,7 @@ app.MapInteractionApi(); app.MapInteractablesWorldApi(); app.MapSkillDefinitionsWorldApi(); app.MapItemDefinitionsWorldApi(); +app.MapPlayerInventoryApi(); app.MapSkillProgressionSnapshotApi(); app.MapPerkStateApi(); if (app.Environment.IsDevelopment() || diff --git a/server/README.md b/server/README.md index 12944f2..d4cf729 100644 --- a/server/README.md +++ b/server/README.md @@ -58,16 +58,27 @@ On success, **Information** logs include the resolved items directory path, dist curl -sS -i "http://localhost:5253/game/world/item-definitions" ``` -## Player inventory store (NEO-54) +## Player inventory (NEO-54 store, NEO-55 HTTP) -Server-authoritative per-player inventory lives in **`Game/Items/`** as **`IPlayerInventoryStore`** + **`PlayerInventoryOperations`**. There is **no HTTP surface in NEO-54** — **`GET` / `POST` inventory** lands in [NEO-55](../../docs/plans/NEO-55-implementation-plan.md). +Server-authoritative per-player inventory lives in **`Game/Items/`** as **`IPlayerInventoryStore`** + **`PlayerInventoryOperations`**. **NEO-55** exposes versioned **`GET` / `POST /game/players/{id}/inventory`** for manual QA and future client. Plan: [NEO-55 implementation plan](../../docs/plans/NEO-55-implementation-plan.md); Bruno: `bruno/neon-sprawl-server/inventory/`. | Container | Fixed slots | Accepts `inventorySlotKind` | |-----------|-------------|-------------------------------| | **Bag** | **24** (indices 0–23) | `bag` | | **Equipment** | **1** (index 0) | `equipment` | -**Mutations:** **`TryAddStack`** and **`TryRemoveStack`** route items by catalog metadata, merge stacks up to **`ItemDef.stackMax`**, and use **all-or-nothing** adds (no partial silent loss). Stable deny **`reasonCode`** values: +**GET** returns **`schemaVersion` 1**, **`playerId`**, **`bagSlots`** (length 24), **`equipmentSlots`** (length 1). Empty slots use **`quantity: 0`** with **`itemId`** omitted. **404** when the player is unknown (position gate) or missing from the inventory store. + +**POST** body (**`schemaVersion` 1**): **`mutationKind`** **`add`** or **`remove`**, **`itemId`**, **`quantity`**. Response: **`applied`**, **`reasonCode`** (`null` on success), **`inventory`** snapshot echo. **400** on schema mismatch or unknown **`mutationKind`**; **404** on unknown player; **200** with **`applied: false`** + stable deny codes on rule failure. + +```bash +curl -sS "http://localhost:5253/game/players/dev-local-1/inventory" +curl -sS -X POST "http://localhost:5253/game/players/dev-local-1/inventory" \ + -H "Content-Type: application/json" \ + -d '{"schemaVersion":1,"mutationKind":"add","itemId":"scrap_metal_bulk","quantity":5}' +``` + +**Engine rules (NEO-54):** **`TryAddStack`** and **`TryRemoveStack`** route items by catalog metadata, merge stacks up to **`ItemDef.stackMax`**, and use **all-or-nothing** adds (no partial silent loss). Stable deny **`reasonCode`** values: | Code | Meaning | |------|---------| @@ -76,7 +87,7 @@ Server-authoritative per-player inventory lives in **`Game/Items/`** as **`IPlay | **`insufficient_quantity`** | Remove requested more than held | | **`invalid_quantity`** | Non-positive quantity | -**Persistence:** same **[NEO-29-style](#position-persistence-neo-8) split** as skill progression — **`player_inventory`** in PostgreSQL when **`ConnectionStrings:NeonSprawl`** is set (DDL [`V005__player_inventory.sql`](../db/migrations/V005__player_inventory.sql), sparse occupied-slot rows), otherwise in-memory fallback seeding the configured dev player. Plan: [NEO-54 implementation plan](../../docs/plans/NEO-54-implementation-plan.md). +**Persistence:** same **[NEO-29-style](#position-persistence-neo-8) split** as skill progression — **`player_inventory`** in PostgreSQL when **`ConnectionStrings:NeonSprawl`** is set (DDL [`V005__player_inventory.sql`](../db/migrations/V005__player_inventory.sql), sparse occupied-slot rows), otherwise in-memory fallback seeding the configured dev player. Store/engine plan: [NEO-54 implementation plan](../../docs/plans/NEO-54-implementation-plan.md). ## Mastery catalog (`content/mastery`, NEO-46) From d127de7bbb4203e1308a81ea441c47059c8d5443 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sat, 23 May 2026 22:49:31 -0400 Subject: [PATCH 3/7] NEO-55: add code review for inventory GET/POST HTTP and Bruno. --- docs/reviews/2026-05-23-NEO-55.md | 56 +++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 docs/reviews/2026-05-23-NEO-55.md diff --git a/docs/reviews/2026-05-23-NEO-55.md b/docs/reviews/2026-05-23-NEO-55.md new file mode 100644 index 0000000..4261f48 --- /dev/null +++ b/docs/reviews/2026-05-23-NEO-55.md @@ -0,0 +1,56 @@ +# Code review — NEO-55 player inventory GET/POST + Bruno + +**Date:** 2026-05-23 +**Scope:** Branch `NEO-55-player-inventory-getpost-bruno` · commit `69327f5` vs `origin/main` +**Base:** `origin/main` + +## Verdict + +**Approve with nits** + +## Summary + +NEO-55 adds versioned **`GET` / `POST /game/players/{id}/inventory`** with DTOs, position-gated reads, engine-backed add/remove mutations, and structured deny responses mirroring the NEO-37/38 skill-progression pattern. Implementation delegates all rules to **`PlayerInventoryOperations`** (NEO-54), maps HTTP status codes per plan (400 schema/`mutationKind`, 404 unknown player/store, 200 + `applied`/`reasonCode` for engine denies), and includes eight AAA integration tests plus three Bruno requests. Docs (plan, E3.M3 slice, alignment table, README) are updated and match the code. Risk is low for merge: thin HTTP adapter over a well-tested engine; remaining gaps are optional coverage and register footnote parity. + +## Documentation checked + +| Document | Result | +|----------|--------| +| [`docs/plans/NEO-55-implementation-plan.md`](../plans/NEO-55-implementation-plan.md) | **Matches** — single POST + `mutationKind`, fixed 24+1 slot arrays, `applied`/`reasonCode`/snapshot echo, status mapping, Bruno + README, no manual QA doc per kickoff; acceptance checklist complete. | +| [`docs/decomposition/modules/E3_M3_ItemizationAndInventorySchema.md`](../decomposition/modules/E3_M3_ItemizationAndInventorySchema.md) | **Matches** — NEO-55 HTTP 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-55 landed with README + Bruno pointers; NEO-56 telemetry still planned. | +| [`docs/decomposition/modules/module_dependency_register.md`](../decomposition/modules/module_dependency_register.md) | **Partially matches** — E3.M3 **In Progress** is correct; **E3.M3 note** footnote still lists through **NEO-54** only (NEO-55 not mentioned). | +| [`docs/decomposition/modules/client_server_authority.md`](../decomposition/modules/client_server_authority.md) | **Matches** — inventory mutations remain server-authoritative; HTTP is read/apply surface only. | +| [`server/README.md`](../../server/README.md) | **Matches** — Player inventory section documents GET/POST shapes, curl examples, reason codes, persistence; NEO-54 deferral removed. | + +Register/tracking: alignment table updated; optional footnote extension for NEO-55 in dependency register. + +## 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-55** (inventory HTTP) alongside NEO-50–NEO-54, matching the alignment table and prior NEO-54 review pattern. + +2. **POST remove happy path (optional)** — Integration tests cover add success and remove deny (`insufficient_quantity`) but not add-then-remove success. A short round-trip test would lock the full POST contract for both `mutationKind` values. + +3. **`invalid_quantity` HTTP smoke (optional)** — Engine returns `invalid_quantity` for non-positive quantity; no HTTP test asserts **200** + `applied: false` + that code. Low risk (engine tested in NEO-54) but would complete the documented reason-code surface at the API layer. + +## Nits + +- Nit: **`Post inventory add.bru`** uses `expect(scrap.quantity).to.be.at.least(5)` — correct for a cumulative dev server, but an exact `=== 5` assertion would fail if prior manual runs left stacks. Acceptable for prototype Bruno; document “fresh dev player” in folder docs if flakes appear. + +- Nit: Plan referenced a **`BuildSnapshot`** helper name (NEO-37 mirror); implementation uses **`MapSnapshot`** — behavior is equivalent, naming only. + +- Nit: No Bruno request for **`remove`** happy path — plan minimum (one deny) is met via `invalid_item`; a remove-after-add Bruno would complement manual QA. + +## Verification + +```bash +cd server +dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj --filter "FullyQualifiedName~PlayerInventoryApiTests" +dotnet test NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj --filter "FullyQualifiedName~PlayerInventory" +``` + +Run Bruno folder `bruno/neon-sprawl-server/inventory/` against a local dev server (`dotnet run`, player `dev-local-1`). From fddbb95a45e57be24b7b5ba2fae493230f56d9fa Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sat, 23 May 2026 22:51:59 -0400 Subject: [PATCH 4/7] NEO-55: address code review suggestions for inventory HTTP Add remove round-trip and invalid_quantity API tests, Bruno remove request, dependency register NEO-55 footnote, and strike-through review follow-ups. --- .../inventory/Post inventory remove.bru | 41 ++++++++++++++++ bruno/neon-sprawl-server/inventory/folder.bru | 7 +++ .../modules/module_dependency_register.md | 2 +- docs/reviews/2026-05-23-NEO-55.md | 14 +++--- .../Game/Items/PlayerInventoryApiTests.cs | 48 +++++++++++++++++++ 5 files changed, 104 insertions(+), 8 deletions(-) create mode 100644 bruno/neon-sprawl-server/inventory/Post inventory remove.bru diff --git a/bruno/neon-sprawl-server/inventory/Post inventory remove.bru b/bruno/neon-sprawl-server/inventory/Post inventory remove.bru new file mode 100644 index 0000000..1e3fbd7 --- /dev/null +++ b/bruno/neon-sprawl-server/inventory/Post inventory remove.bru @@ -0,0 +1,41 @@ +meta { + name: POST inventory remove + type: http + seq: 4 +} + +docs { + NEO-55: remove stack via mutationKind remove. Run Post inventory add first on a fresh server, + or expect applied true when dev-local-1 already holds scrap_metal_bulk. +} + +post { + url: {{baseUrl}}/game/players/dev-local-1/inventory + body: json + auth: none +} + +body:json { + { + "schemaVersion": 1, + "mutationKind": "remove", + "itemId": "scrap_metal_bulk", + "quantity": 2 + } +} + +tests { + test("remove returns 200 with applied true and reduced quantity", function () { + expect(res.getStatus()).to.equal(200); + const body = res.getBody(); + expect(body.schemaVersion).to.equal(1); + expect(body.applied).to.equal(true); + expect(body.reasonCode).to.equal(null); + expect(body.inventory).to.be.an("object"); + const scrap = body.inventory.bagSlots.find((s) => s.itemId === "scrap_metal_bulk"); + if (scrap) { + expect(scrap.quantity).to.be.a("number"); + expect(scrap.quantity).to.be.at.least(0); + } + }); +} diff --git a/bruno/neon-sprawl-server/inventory/folder.bru b/bruno/neon-sprawl-server/inventory/folder.bru index 12b2b71..238dd15 100644 --- a/bruno/neon-sprawl-server/inventory/folder.bru +++ b/bruno/neon-sprawl-server/inventory/folder.bru @@ -1,3 +1,10 @@ meta { name: inventory } + +docs { + NEO-55 inventory HTTP. Run GET first on a fresh dev server for empty 24+1 grid. + POST add/remove mutate dev-local-1; cumulative stacks from prior manual runs can make + Post inventory add assertions use at.least(5). Restart server (or truncate DB in Postgres + mode) for a clean inventory baseline. +} diff --git a/docs/decomposition/modules/module_dependency_register.md b/docs/decomposition/modules/module_dependency_register.md index a6b1fe1..6b59763 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** (content + CI), **NEO-51** (server fail-fast load), **NEO-52** (`IItemDefinitionRegistry` + DI), **NEO-53** (`GET /game/world/item-definitions`), and **NEO-54** (inventory store + stack/slot rules engine) 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), **NEO-53** (`GET /game/world/item-definitions`), **NEO-54** (inventory store + stack/slot rules engine), and **NEO-55** (`GET`/`POST /game/players/{id}/inventory`) moved the register row to **In Progress**; later slices update the alignment table as they land. ### Epic 4 — World Topology diff --git a/docs/reviews/2026-05-23-NEO-55.md b/docs/reviews/2026-05-23-NEO-55.md index 4261f48..b94942f 100644 --- a/docs/reviews/2026-05-23-NEO-55.md +++ b/docs/reviews/2026-05-23-NEO-55.md @@ -19,11 +19,11 @@ NEO-55 adds versioned **`GET` / `POST /game/players/{id}/inventory`** with DTOs, | [`docs/plans/NEO-55-implementation-plan.md`](../plans/NEO-55-implementation-plan.md) | **Matches** — single POST + `mutationKind`, fixed 24+1 slot arrays, `applied`/`reasonCode`/snapshot echo, status mapping, Bruno + README, no manual QA doc per kickoff; acceptance checklist complete. | | [`docs/decomposition/modules/E3_M3_ItemizationAndInventorySchema.md`](../decomposition/modules/E3_M3_ItemizationAndInventorySchema.md) | **Matches** — NEO-55 HTTP 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-55 landed with README + Bruno pointers; NEO-56 telemetry still planned. | -| [`docs/decomposition/modules/module_dependency_register.md`](../decomposition/modules/module_dependency_register.md) | **Partially matches** — E3.M3 **In Progress** is correct; **E3.M3 note** footnote still lists through **NEO-54** only (NEO-55 not mentioned). | +| [`docs/decomposition/modules/module_dependency_register.md`](../decomposition/modules/module_dependency_register.md) | **Matches** — E3.M3 **In Progress**; footnote lists NEO-50–NEO-55. | | [`docs/decomposition/modules/client_server_authority.md`](../decomposition/modules/client_server_authority.md) | **Matches** — inventory mutations remain server-authoritative; HTTP is read/apply surface only. | | [`server/README.md`](../../server/README.md) | **Matches** — Player inventory section documents GET/POST shapes, curl examples, reason codes, persistence; NEO-54 deferral removed. | -Register/tracking: alignment table updated; optional footnote extension for NEO-55 in dependency register. +Register/tracking: alignment table and dependency register footnote updated for NEO-55. ## Blocking issues @@ -31,19 +31,19 @@ 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-55** (inventory HTTP) alongside NEO-50–NEO-54, matching the alignment table and prior NEO-54 review pattern. +1. ~~**Dependency register footnote (optional)** — Extend the **E3.M3 note** in [`module_dependency_register.md`](../decomposition/modules/module_dependency_register.md) to mention **NEO-55** (inventory HTTP) alongside NEO-50–NEO-54, matching the alignment table and prior NEO-54 review pattern.~~ **Done.** -2. **POST remove happy path (optional)** — Integration tests cover add success and remove deny (`insufficient_quantity`) but not add-then-remove success. A short round-trip test would lock the full POST contract for both `mutationKind` values. +2. ~~**POST remove happy path (optional)** — Integration tests cover add success and remove deny (`insufficient_quantity`) but not add-then-remove success. A short round-trip test would lock the full POST contract for both `mutationKind` values.~~ **Done.** — `PostInventory_ShouldApplyRemove_AfterAdd_WithUpdatedSnapshot` in `PlayerInventoryApiTests.cs`. -3. **`invalid_quantity` HTTP smoke (optional)** — Engine returns `invalid_quantity` for non-positive quantity; no HTTP test asserts **200** + `applied: false` + that code. Low risk (engine tested in NEO-54) but would complete the documented reason-code surface at the API layer. +3. ~~**`invalid_quantity` HTTP smoke (optional)** — Engine returns `invalid_quantity` for non-positive quantity; no HTTP test asserts **200** + `applied: false` + that code. Low risk (engine tested in NEO-54) but would complete the documented reason-code surface at the API layer.~~ **Done.** — `PostInventory_ShouldDenyNonPositiveQuantity_WithInvalidQuantity` in `PlayerInventoryApiTests.cs`. ## Nits -- Nit: **`Post inventory add.bru`** uses `expect(scrap.quantity).to.be.at.least(5)` — correct for a cumulative dev server, but an exact `=== 5` assertion would fail if prior manual runs left stacks. Acceptable for prototype Bruno; document “fresh dev player” in folder docs if flakes appear. +- ~~Nit: **`Post inventory add.bru`** uses `expect(scrap.quantity).to.be.at.least(5)` — correct for a cumulative dev server, but an exact `=== 5` assertion would fail if prior manual runs left stacks. Acceptable for prototype Bruno; document “fresh dev player” in folder docs if flakes appear.~~ **Done.** — `folder.bru` docs note fresh-server baseline and cumulative stacks. - Nit: Plan referenced a **`BuildSnapshot`** helper name (NEO-37 mirror); implementation uses **`MapSnapshot`** — behavior is equivalent, naming only. -- Nit: No Bruno request for **`remove`** happy path — plan minimum (one deny) is met via `invalid_item`; a remove-after-add Bruno would complement manual QA. +- ~~Nit: No Bruno request for **`remove`** happy path — plan minimum (one deny) is met via `invalid_item`; a remove-after-add Bruno would complement manual QA.~~ **Done.** — `Post inventory remove.bru` (seq 4). ## Verification diff --git a/server/NeonSprawl.Server.Tests/Game/Items/PlayerInventoryApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Items/PlayerInventoryApiTests.cs index 218d1ea..5d20d3f 100644 --- a/server/NeonSprawl.Server.Tests/Game/Items/PlayerInventoryApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Items/PlayerInventoryApiTests.cs @@ -139,6 +139,54 @@ public sealed class PlayerInventoryApiTests Assert.Equal(10, occupied[0].Quantity); } + [Fact] + public async Task PostInventory_ShouldApplyRemove_AfterAdd_WithUpdatedSnapshot() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + + // Act + _ = await client.PostAsJsonAsync( + "/game/players/dev-local-1/inventory", + Mutation("add", "scrap_metal_bulk", quantity: 10)); + var response = await client.PostAsJsonAsync( + "/game/players/dev-local-1/inventory", + Mutation("remove", "scrap_metal_bulk", quantity: 4)); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + Assert.True(body!.Applied); + Assert.Null(body.ReasonCode); + var occupied = body.Inventory.BagSlots.Where(static s => s.Quantity > 0).ToList(); + Assert.Single(occupied); + Assert.Equal("scrap_metal_bulk", occupied[0].ItemId); + Assert.Equal(6, occupied[0].Quantity); + } + + [Fact] + public async Task PostInventory_ShouldDenyNonPositiveQuantity_WithInvalidQuantity() + { + // Arrange + await using var factory = new InMemoryWebApplicationFactory(); + var client = factory.CreateClient(); + + // Act + var response = await client.PostAsJsonAsync( + "/game/players/dev-local-1/inventory", + Mutation("add", "scrap_metal_bulk", quantity: 0)); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + Assert.False(body!.Applied); + Assert.Equal(PlayerInventoryReasonCodes.InvalidQuantity, body.ReasonCode); + Assert.All(body.Inventory.BagSlots, static slot => Assert.Equal(0, slot.Quantity)); + } + [Fact] public async Task PostInventory_ShouldDenyUnknownItem_WithInvalidItem_AndLeaveInventoryEmpty() { From 00ada50b95efc9b9faa971e638092b412fcb65cc Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sat, 23 May 2026 22:53:50 -0400 Subject: [PATCH 5/7] NEO-55: fix Bruno GET inventory test for persistent dev player state Assert slot shape rules instead of requiring an all-empty grid when dev-local-1 already holds items from prior collection runs. --- .../inventory/Get inventory.bru | 23 +++++++++++++------ bruno/neon-sprawl-server/inventory/folder.bru | 7 +++--- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/bruno/neon-sprawl-server/inventory/Get inventory.bru b/bruno/neon-sprawl-server/inventory/Get inventory.bru index 60e701e..93730cd 100644 --- a/bruno/neon-sprawl-server/inventory/Get inventory.bru +++ b/bruno/neon-sprawl-server/inventory/Get inventory.bru @@ -6,7 +6,8 @@ meta { docs { NEO-55: per-player inventory read model; join with GET /game/world/item-definitions for stackMax and slot kinds. - Fresh dev server seeds dev-local-1 with empty 24+1 slot grid. + Does not assume a fresh server — dev-local-1 inventory persists across Bruno runs (in-memory/Postgres). + Restart server (or truncate DB in Postgres mode) to see an all-empty grid again. } get { @@ -28,14 +29,22 @@ tests { expect(body.equipmentSlots.length).to.equal(1); }); - test("empty slots omit itemId and use quantity 0", function () { + test("empty slots omit itemId; occupied slots include itemId and positive quantity", function () { const body = res.getBody(); - for (const slot of body.bagSlots) { + const assertSlotShape = (slot) => { expect(slot.slotIndex).to.be.a("number"); - expect(slot.quantity).to.equal(0); - expect(slot.itemId).to.equal(undefined); + expect(slot.quantity).to.be.a("number"); + expect(slot.quantity).to.be.at.least(0); + if (slot.quantity === 0) { + expect(slot.itemId).to.equal(undefined); + } else { + expect(slot.itemId).to.be.a("string"); + expect(slot.itemId.length).to.be.at.least(1); + } + }; + for (const slot of body.bagSlots) { + assertSlotShape(slot); } - expect(body.equipmentSlots[0].quantity).to.equal(0); - expect(body.equipmentSlots[0].itemId).to.equal(undefined); + assertSlotShape(body.equipmentSlots[0]); }); } diff --git a/bruno/neon-sprawl-server/inventory/folder.bru b/bruno/neon-sprawl-server/inventory/folder.bru index 238dd15..508c5b3 100644 --- a/bruno/neon-sprawl-server/inventory/folder.bru +++ b/bruno/neon-sprawl-server/inventory/folder.bru @@ -3,8 +3,7 @@ meta { } docs { - NEO-55 inventory HTTP. Run GET first on a fresh dev server for empty 24+1 grid. - POST add/remove mutate dev-local-1; cumulative stacks from prior manual runs can make - Post inventory add assertions use at.least(5). Restart server (or truncate DB in Postgres - mode) for a clean inventory baseline. + NEO-55 inventory HTTP. GET does not assume an empty grid — dev-local-1 inventory persists across runs. + POST add/remove mutate dev-local-1; cumulative stacks from prior runs make add assertions use at.least(5). + Restart server (or truncate DB in Postgres mode) for a clean inventory baseline. } From 1cbe6302b5d84cbde38e0d3e9ca622ece85b38c7 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sat, 23 May 2026 22:57:53 -0400 Subject: [PATCH 6/7] NEO-55: make inventory Bruno tests order-independent Shared inventory-api-helper seeds stock and records baselines in pre-request scripts; assertions use exact deltas instead of cumulative at.least checks. Harden C# remove round-trip add assertion. --- .../inventory/Get inventory.bru | 7 +- .../Post inventory add deny invalid item.bru | 17 ++++- .../inventory/Post inventory add.bru | 26 +++++--- .../inventory/Post inventory remove.bru | 26 +++++--- bruno/neon-sprawl-server/inventory/folder.bru | 4 +- .../inventory/inventory-api-helper.js | 65 +++++++++++++++++++ .../Game/Items/PlayerInventoryApiTests.cs | 6 +- 7 files changed, 122 insertions(+), 29 deletions(-) create mode 100644 bruno/neon-sprawl-server/inventory/inventory-api-helper.js diff --git a/bruno/neon-sprawl-server/inventory/Get inventory.bru b/bruno/neon-sprawl-server/inventory/Get inventory.bru index 93730cd..4919224 100644 --- a/bruno/neon-sprawl-server/inventory/Get inventory.bru +++ b/bruno/neon-sprawl-server/inventory/Get inventory.bru @@ -6,12 +6,11 @@ meta { docs { NEO-55: per-player inventory read model; join with GET /game/world/item-definitions for stackMax and slot kinds. - Does not assume a fresh server — dev-local-1 inventory persists across Bruno runs (in-memory/Postgres). - Restart server (or truncate DB in Postgres mode) to see an all-empty grid again. + Order-independent: validates fixed slot arrays and slot JSON shape only (not empty-grid assumptions). } get { - url: {{baseUrl}}/game/players/dev-local-1/inventory + url: {{baseUrl}}/game/players/{{playerId}}/inventory body: none auth: none } @@ -22,7 +21,7 @@ tests { expect(res.getHeader("content-type")).to.contain("application/json"); const body = res.getBody(); expect(body.schemaVersion).to.equal(1); - expect(body.playerId).to.equal("dev-local-1"); + expect(body.playerId).to.equal(bru.getEnvVar("playerId") || bru.getVar("playerId")); expect(body.bagSlots).to.be.an("array"); expect(body.bagSlots.length).to.equal(24); expect(body.equipmentSlots).to.be.an("array"); diff --git a/bruno/neon-sprawl-server/inventory/Post inventory add deny invalid item.bru b/bruno/neon-sprawl-server/inventory/Post inventory add deny invalid item.bru index 58961dd..78875b3 100644 --- a/bruno/neon-sprawl-server/inventory/Post inventory add deny invalid item.bru +++ b/bruno/neon-sprawl-server/inventory/Post inventory add deny invalid item.bru @@ -5,11 +5,17 @@ meta { } docs { - NEO-55: unknown itemId denies with invalid_item and echoes unchanged inventory. + NEO-55: unknown itemId denies with invalid_item. Pre-request snapshots scrap_metal_bulk total to prove inventory unchanged on deny. +} + +script:pre-request { + const { getInventory, sumItemQuantity } = require("./inventory-api-helper.js"); + const inventory = await getInventory(bru); + bru.setVar("scrapBeforeDeny", sumItemQuantity(inventory, "scrap_metal_bulk")); } post { - url: {{baseUrl}}/game/players/dev-local-1/inventory + url: {{baseUrl}}/game/players/{{playerId}}/inventory body: json auth: none } @@ -24,7 +30,7 @@ body:json { } tests { - test("denies with invalid_item and echoes inventory", function () { + test("denies with invalid_item and leaves scrap_metal_bulk unchanged", function () { expect(res.getStatus()).to.equal(200); const body = res.getBody(); expect(body.schemaVersion).to.equal(1); @@ -32,5 +38,10 @@ tests { expect(body.reasonCode).to.equal("invalid_item"); expect(body.inventory).to.be.an("object"); expect(body.inventory.bagSlots.length).to.equal(24); + const before = bru.getVar("scrapBeforeDeny"); + const after = [...body.inventory.bagSlots, ...body.inventory.equipmentSlots] + .filter((slot) => slot.itemId === "scrap_metal_bulk") + .reduce((total, slot) => total + slot.quantity, 0); + expect(after).to.equal(before); }); } diff --git a/bruno/neon-sprawl-server/inventory/Post inventory add.bru b/bruno/neon-sprawl-server/inventory/Post inventory add.bru index 1be951e..8c64e5c 100644 --- a/bruno/neon-sprawl-server/inventory/Post inventory add.bru +++ b/bruno/neon-sprawl-server/inventory/Post inventory add.bru @@ -5,11 +5,18 @@ meta { } docs { - NEO-55: add stack via mutationKind add; see server README inventory HTTP section. + NEO-55: add stack via mutationKind add. Pre-request records scrap_metal_bulk baseline so assertions are order-independent. +} + +script:pre-request { + const { getInventory, sumItemQuantity } = require("./inventory-api-helper.js"); + const inventory = await getInventory(bru); + bru.setVar("scrapBeforeAdd", sumItemQuantity(inventory, "scrap_metal_bulk")); + bru.setVar("addQuantity", 5); } post { - url: {{baseUrl}}/game/players/dev-local-1/inventory + url: {{baseUrl}}/game/players/{{playerId}}/inventory body: json auth: none } @@ -24,18 +31,19 @@ body:json { } tests { - test("add returns 200 with applied true and updated inventory", function () { + test("add returns 200 with applied true and increases scrap by requested quantity", function () { expect(res.getStatus()).to.equal(200); const body = res.getBody(); expect(body.schemaVersion).to.equal(1); expect(body.applied).to.equal(true); expect(body.reasonCode).to.equal(null); expect(body.inventory).to.be.an("object"); - expect(body.inventory.playerId).to.equal("dev-local-1"); - const occupied = body.inventory.bagSlots.filter((s) => s.quantity > 0); - expect(occupied.length).to.be.at.least(1); - const scrap = occupied.find((s) => s.itemId === "scrap_metal_bulk"); - expect(scrap).to.be.an("object"); - expect(scrap.quantity).to.be.at.least(5); + expect(body.inventory.playerId).to.equal(bru.getEnvVar("playerId") || bru.getVar("playerId")); + const before = bru.getVar("scrapBeforeAdd"); + const addQuantity = bru.getVar("addQuantity"); + const after = [...body.inventory.bagSlots, ...body.inventory.equipmentSlots] + .filter((slot) => slot.itemId === "scrap_metal_bulk") + .reduce((total, slot) => total + slot.quantity, 0); + expect(after).to.equal(before + addQuantity); }); } diff --git a/bruno/neon-sprawl-server/inventory/Post inventory remove.bru b/bruno/neon-sprawl-server/inventory/Post inventory remove.bru index 1e3fbd7..67e455f 100644 --- a/bruno/neon-sprawl-server/inventory/Post inventory remove.bru +++ b/bruno/neon-sprawl-server/inventory/Post inventory remove.bru @@ -5,12 +5,19 @@ meta { } docs { - NEO-55: remove stack via mutationKind remove. Run Post inventory add first on a fresh server, - or expect applied true when dev-local-1 already holds scrap_metal_bulk. + NEO-55: remove stack via mutationKind remove. Pre-request seeds scrap_metal_bulk stock when needed so this request is order-independent. +} + +script:pre-request { + const { ensureItemQuantity } = require("./inventory-api-helper.js"); + const removeQuantity = 2; + const before = await ensureItemQuantity(bru, "scrap_metal_bulk", removeQuantity); + bru.setVar("scrapBeforeRemove", before); + bru.setVar("removeQuantity", removeQuantity); } post { - url: {{baseUrl}}/game/players/dev-local-1/inventory + url: {{baseUrl}}/game/players/{{playerId}}/inventory body: json auth: none } @@ -25,17 +32,18 @@ body:json { } tests { - test("remove returns 200 with applied true and reduced quantity", function () { + test("remove returns 200 with applied true and decreases scrap by requested quantity", function () { expect(res.getStatus()).to.equal(200); const body = res.getBody(); expect(body.schemaVersion).to.equal(1); expect(body.applied).to.equal(true); expect(body.reasonCode).to.equal(null); expect(body.inventory).to.be.an("object"); - const scrap = body.inventory.bagSlots.find((s) => s.itemId === "scrap_metal_bulk"); - if (scrap) { - expect(scrap.quantity).to.be.a("number"); - expect(scrap.quantity).to.be.at.least(0); - } + const before = bru.getVar("scrapBeforeRemove"); + const removeQuantity = bru.getVar("removeQuantity"); + const after = [...body.inventory.bagSlots, ...body.inventory.equipmentSlots] + .filter((slot) => slot.itemId === "scrap_metal_bulk") + .reduce((total, slot) => total + slot.quantity, 0); + expect(after).to.equal(before - removeQuantity); }); } diff --git a/bruno/neon-sprawl-server/inventory/folder.bru b/bruno/neon-sprawl-server/inventory/folder.bru index 508c5b3..6f9a880 100644 --- a/bruno/neon-sprawl-server/inventory/folder.bru +++ b/bruno/neon-sprawl-server/inventory/folder.bru @@ -3,7 +3,5 @@ meta { } docs { - NEO-55 inventory HTTP. GET does not assume an empty grid — dev-local-1 inventory persists across runs. - POST add/remove mutate dev-local-1; cumulative stacks from prior runs make add assertions use at.least(5). - Restart server (or truncate DB in Postgres mode) for a clean inventory baseline. + NEO-55 inventory HTTP. Requests are order-independent: pre-request scripts record baselines or seed stock via inventory-api-helper.js. } diff --git a/bruno/neon-sprawl-server/inventory/inventory-api-helper.js b/bruno/neon-sprawl-server/inventory/inventory-api-helper.js new file mode 100644 index 0000000..ecf4dec --- /dev/null +++ b/bruno/neon-sprawl-server/inventory/inventory-api-helper.js @@ -0,0 +1,65 @@ +const axios = require("axios"); + +function resolveConfig(bru) { + return { + baseUrl: bru.getEnvVar("baseUrl") || bru.getVar("baseUrl"), + playerId: bru.getEnvVar("playerId") || bru.getVar("playerId"), + jsonHeaders: { headers: { "Content-Type": "application/json" } }, + }; +} + +function inventoryUrl(baseUrl, playerId) { + return `${baseUrl}/game/players/${playerId}/inventory`; +} + +function sumItemQuantity(inventory, itemId) { + const slots = [...inventory.bagSlots, ...inventory.equipmentSlots]; + return slots + .filter((slot) => slot.itemId === itemId) + .reduce((total, slot) => total + slot.quantity, 0); +} + +async function getInventory(bru) { + const { baseUrl, playerId } = resolveConfig(bru); + const response = await axios.get(inventoryUrl(baseUrl, playerId)); + if (response.status !== 200) { + throw new Error(`GET inventory failed: ${response.status}`); + } + + return response.data; +} + +async function postMutation(bru, body) { + const { baseUrl, playerId, jsonHeaders } = resolveConfig(bru); + return axios.post(inventoryUrl(baseUrl, playerId), body, jsonHeaders); +} + +async function ensureItemQuantity(bru, itemId, minQuantity) { + const inventory = await getInventory(bru); + const current = sumItemQuantity(inventory, itemId); + if (current >= minQuantity) { + return current; + } + + const response = await postMutation(bru, { + schemaVersion: 1, + mutationKind: "add", + itemId, + quantity: minQuantity - current, + }); + if (response.status !== 200 || response.data?.applied !== true) { + throw new Error( + `seed add ${itemId} failed: ${response.status} ${JSON.stringify(response.data)}`, + ); + } + + const after = await getInventory(bru); + return sumItemQuantity(after, itemId); +} + +module.exports = { + sumItemQuantity, + getInventory, + postMutation, + ensureItemQuantity, +}; diff --git a/server/NeonSprawl.Server.Tests/Game/Items/PlayerInventoryApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Items/PlayerInventoryApiTests.cs index 5d20d3f..814eb60 100644 --- a/server/NeonSprawl.Server.Tests/Game/Items/PlayerInventoryApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Items/PlayerInventoryApiTests.cs @@ -147,7 +147,7 @@ public sealed class PlayerInventoryApiTests var client = factory.CreateClient(); // Act - _ = await client.PostAsJsonAsync( + var addResponse = await client.PostAsJsonAsync( "/game/players/dev-local-1/inventory", Mutation("add", "scrap_metal_bulk", quantity: 10)); var response = await client.PostAsJsonAsync( @@ -155,6 +155,10 @@ public sealed class PlayerInventoryApiTests Mutation("remove", "scrap_metal_bulk", quantity: 4)); // Assert + Assert.Equal(HttpStatusCode.OK, addResponse.StatusCode); + var addBody = await addResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(addBody); + Assert.True(addBody!.Applied); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var body = await response.Content.ReadFromJsonAsync(); Assert.NotNull(body); From b8ee4b55eec9b6c81a6a4c92be1f3fb66cbce516 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sat, 23 May 2026 22:59:58 -0400 Subject: [PATCH 7/7] NEO-55: fix Bruno local module resolution for inventory helper Move helper to scripts/ and whitelist via bruno.json additionalContextRoots so pre-request require() works in Bruno. --- bruno/neon-sprawl-server/bruno.json | 8 +++++++- .../inventory/Post inventory add deny invalid item.bru | 2 +- bruno/neon-sprawl-server/inventory/Post inventory add.bru | 2 +- .../inventory/Post inventory remove.bru | 2 +- bruno/neon-sprawl-server/inventory/folder.bru | 2 +- .../{inventory => scripts}/inventory-api-helper.js | 0 6 files changed, 11 insertions(+), 5 deletions(-) rename bruno/neon-sprawl-server/{inventory => scripts}/inventory-api-helper.js (100%) diff --git a/bruno/neon-sprawl-server/bruno.json b/bruno/neon-sprawl-server/bruno.json index 9535019..177dd65 100644 --- a/bruno/neon-sprawl-server/bruno.json +++ b/bruno/neon-sprawl-server/bruno.json @@ -1,5 +1,11 @@ { "version": "1", "name": "Neon Sprawl Server", - "type": "collection" + "type": "collection", + "scripts": { + "additionalContextRoots": ["./scripts"], + "filesystemAccess": { + "allow": true + } + } } diff --git a/bruno/neon-sprawl-server/inventory/Post inventory add deny invalid item.bru b/bruno/neon-sprawl-server/inventory/Post inventory add deny invalid item.bru index 78875b3..62c95c4 100644 --- a/bruno/neon-sprawl-server/inventory/Post inventory add deny invalid item.bru +++ b/bruno/neon-sprawl-server/inventory/Post inventory add deny invalid item.bru @@ -9,7 +9,7 @@ docs { } script:pre-request { - const { getInventory, sumItemQuantity } = require("./inventory-api-helper.js"); + const { getInventory, sumItemQuantity } = require("./scripts/inventory-api-helper.js"); const inventory = await getInventory(bru); bru.setVar("scrapBeforeDeny", sumItemQuantity(inventory, "scrap_metal_bulk")); } diff --git a/bruno/neon-sprawl-server/inventory/Post inventory add.bru b/bruno/neon-sprawl-server/inventory/Post inventory add.bru index 8c64e5c..506cbdd 100644 --- a/bruno/neon-sprawl-server/inventory/Post inventory add.bru +++ b/bruno/neon-sprawl-server/inventory/Post inventory add.bru @@ -9,7 +9,7 @@ docs { } script:pre-request { - const { getInventory, sumItemQuantity } = require("./inventory-api-helper.js"); + const { getInventory, sumItemQuantity } = require("./scripts/inventory-api-helper.js"); const inventory = await getInventory(bru); bru.setVar("scrapBeforeAdd", sumItemQuantity(inventory, "scrap_metal_bulk")); bru.setVar("addQuantity", 5); diff --git a/bruno/neon-sprawl-server/inventory/Post inventory remove.bru b/bruno/neon-sprawl-server/inventory/Post inventory remove.bru index 67e455f..a331356 100644 --- a/bruno/neon-sprawl-server/inventory/Post inventory remove.bru +++ b/bruno/neon-sprawl-server/inventory/Post inventory remove.bru @@ -9,7 +9,7 @@ docs { } script:pre-request { - const { ensureItemQuantity } = require("./inventory-api-helper.js"); + const { ensureItemQuantity } = require("./scripts/inventory-api-helper.js"); const removeQuantity = 2; const before = await ensureItemQuantity(bru, "scrap_metal_bulk", removeQuantity); bru.setVar("scrapBeforeRemove", before); diff --git a/bruno/neon-sprawl-server/inventory/folder.bru b/bruno/neon-sprawl-server/inventory/folder.bru index 6f9a880..612f395 100644 --- a/bruno/neon-sprawl-server/inventory/folder.bru +++ b/bruno/neon-sprawl-server/inventory/folder.bru @@ -3,5 +3,5 @@ meta { } docs { - NEO-55 inventory HTTP. Requests are order-independent: pre-request scripts record baselines or seed stock via inventory-api-helper.js. + NEO-55 inventory HTTP. Requests are order-independent: pre-request scripts record baselines or seed stock via scripts/inventory-api-helper.js (see bruno.json additionalContextRoots). } diff --git a/bruno/neon-sprawl-server/inventory/inventory-api-helper.js b/bruno/neon-sprawl-server/scripts/inventory-api-helper.js similarity index 100% rename from bruno/neon-sprawl-server/inventory/inventory-api-helper.js rename to bruno/neon-sprawl-server/scripts/inventory-api-helper.js