# 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

- [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

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.
