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.