diff --git a/docs/plans/NEO-72-implementation-plan.md b/docs/plans/NEO-72-implementation-plan.md new file mode 100644 index 0000000..8c1d175 --- /dev/null +++ b/docs/plans/NEO-72-implementation-plan.md @@ -0,0 +1,137 @@ +# NEO-72 — Implementation plan + +## Story reference + +| Field | Value | +|--------|--------| +| **Key** | NEO-72 | +| **Title** | E3.M3: Client inventory snapshot HUD (E3S5-01) | +| **Linear** | https://linear.app/neon-sprawl/issue/NEO-72/e3m3-client-inventory-snapshot-hud-e3s5-01 | +| **Module** | [E3.M3 — ItemizationAndInventorySchema](../decomposition/modules/E3_M3_ItemizationAndInventorySchema.md) · Epic 3 **Slice 5** (client 1 of 4) · backlog **E3S5-01** | +| **Branch** | `NEO-72-client-inventory-snapshot-hud` | +| **Server deps** | [NEO-55](https://linear.app/neon-sprawl/issue/NEO-55) — `GET /game/players/{id}/inventory` (**Done**); [NEO-53](https://linear.app/neon-sprawl/issue/NEO-53) — `GET /game/world/item-definitions` (**Done**) | +| **Pattern** | [NEO-24](https://linear.app/neon-sprawl/issue/NEO-24) / [NEO-32](https://linear.app/neon-sprawl/issue/NEO-32) — thin HTTP client nodes, `main.gd` wiring, debug HUD labels, GdUnit mock transport | +| **Downstream** | [NEO-73](https://linear.app/neon-sprawl/issue/NEO-73) gather feedback calls inventory refresh; [NEO-74](https://linear.app/neon-sprawl/issue/NEO-74) craft UI reuses inventory + defs clients | + +## Kickoff clarifications + +| Topic | Question | Agent recommendation | Answer | +|--------|----------|----------------------|--------| +| **Display names** | Resolve **`displayName`** via **`GET /game/world/item-definitions`** or raw **`itemId`** only? | **Include `item_definitions_client`** + cached lookup — NEO-53 is landed; readable labels help gather/craft QA in NEO-73/74. | **User:** include defs + **`displayName`**. | +| **Failed GET UX** | How to surface inventory GET errors? | **`InventoryLabel` error line + `push_warning`** — visible in-game (NEO-28 cast deny HUD) and dev console (cooldown/interaction HTTP clients). | **User:** HUD + **`push_warning`**. | +| **Refresh key** | Manual refresh binding? | **Bind `I` → `inventory_refresh`** — E3S5 backlog QA note; public **`request_sync_from_server()`** entrypoint for NEO-73. | **User:** **`I`** key. | + +## Goal, scope, and out-of-scope + +**Goal:** Godot client for **`GET /game/players/{id}/inventory`** plus a readable bag/equipment debug HUD; boot hydrate and a manual refresh entrypoint for later Slice 5 stories. + +**In scope (from Linear + [E3S5-01](E3S5-client-prototype-backlog.md#e3s5-01--client-inventory-snapshot-hud-e3m3)):** + +- **`inventory_client.gd`:** GET snapshot, parse v1 envelope, emit signal with parsed body; public **`request_sync_from_server()`**; structured **`push_warning`** on transport/HTTP/JSON failures. +- **`item_definitions_client.gd`:** GET world item defs once (or on boot), cache **`id → displayName`** (and optional **`inventorySlotKind`** for row prefix); emit **`definitions_ready`**. +- **`InventoryLabel`** under **`UICanvas`:** lists **non-empty bag slots** + **equipment slot 0** (show **`— empty`** when quantity 0); resolve labels via defs cache when available, fall back to raw **`itemId`**. +- Boot hydrate after scene ready (chain: item defs → inventory GET, mirroring hotbar → cooldown boot order in NEO-32). +- **`I`** input → **`inventory_refresh`** in **`project.godot`**; handled in **`main.gd`** **`_unhandled_key_input`** (same pattern as E/R interact forwarding). +- GdUnit: mock HTTP — happy-path parse + error paths for both clients. +- **`client/README.md`** inventory HUD subsection. +- **`docs/manual-qa/NEO-72.md`**. + +**Out of scope (from Linear):** + +- Drag-drop, stack split, equip UX polish. +- POST inventory mutations from UI (gather/craft stories own refresh triggers). +- Server route or DTO changes. + +## Acceptance criteria checklist + +- [ ] Boot shows current server inventory for **`dev-local-1`** (empty bag + empty equipment stub on fresh dev player). +- [ ] HUD lists all **non-empty** **`bagSlots`** and **equipment slot 0** from GET body (with **`displayName`** when defs loaded). +- [ ] Failed GET surfaces visible error on **`InventoryLabel`** and **`push_warning`** in Output. +- [ ] **`I`** triggers manual inventory refresh; **`request_sync_from_server()`** callable for NEO-73 wiring. + +## Technical approach + +1. **`item_definitions_client.gd`** + - Injectable HTTP transport; **`base_url`** / shared authority config from **`main.gd`** (same as **`CooldownSnapshotClient`**). + - **`request_sync_from_server()`** → **`GET …/game/world/item-definitions`**. + - On 200 + v1 JSON: build **`Dictionary`** map **`itemId → { displayName, inventorySlotKind }`**; emit **`definitions_ready(map)`**. + - Errors: **`push_warning("ItemDefinitionsClient: …")`**; emit nothing (inventory HUD falls back to raw ids). + +2. **`inventory_client.gd`** + - **`request_sync_from_server()`** → **`GET …/game/players/{dev_player_id}/inventory`**. + - On 200: validate **`schemaVersion` 1**, emit **`inventory_received(snapshot: Dictionary)`** with full parsed body. + - On failure (transport, non-2xx, non-JSON): **`push_warning("InventoryClient: …")`** + emit **`inventory_sync_failed(reason: String)`** so **`main.gd`** can paint the HUD error line without parsing a snapshot. + - **`_busy`** guard like **`CooldownSnapshotClient`** (ignore overlapping GETs). + +3. **Boot sequence in `main.gd`** + - Add **`_setup_inventory_sync()`** from **`_ready()`** after authority is wired. + - Instantiate **`ItemDefinitionsClient`** + **`InventoryClient`** as child nodes; copy **`base_url`** / **`dev_player_id`** from **`PositionAuthorityClient`**. + - Connect **`definitions_ready`** → cache map on **`main`** (or hold ref to defs client with **`display_name_for(item_id)`** helper method). + - On boot: **`item_definitions_client.request_sync_from_server()`** then **`inventory_client.request_sync_from_server()`** (defs may still be in flight when first inventory paints — re-render label when defs arrive). + - Connect **`inventory_received`** / **`inventory_sync_failed`** → **`_render_inventory_label()`**. + +4. **HUD formatting (`_render_inventory_label`)** + - Header: **`Inventory:`** + optional **`(refresh: I)`** hint for QA. + - **Bag:** iterate **`bagSlots`**; emit lines only where **`quantity > 0`**: **`slot {slotIndex}: {displayName or itemId} x{quantity}`**. + - **Equipment:** always show **`slot 0`**: item line if occupied, else **`slot 0: — empty`**. + - On **`inventory_sync_failed`**: replace body with **`Inventory: error — {reason}`** (keep header); also **`push_warning`** already fired from client. + +5. **Scene + input** + - **`main.tscn`:** add **`InventoryClient`**, **`ItemDefinitionsClient`** nodes; **`InventoryLabel`** below **`CooldownSlotsLabel`** (offset ~460px top, autowrap, same font theme as sibling HUD labels). + - **`project.godot`:** **`inventory_refresh`** → **I** (physical key). + - **`main.gd` `_unhandled_key_input`:** if **`inventory_refresh`** pressed → **`inventory_client.request_sync_from_server()`**. + +6. **Public refresh contract for NEO-73** + - Expose **`InventoryClient.request_sync_from_server()`** (no wrapper required in **`main`** unless we add **`refresh_inventory_from_server()`** alias — prefer direct client call from **`main`** helpers NEO-73 can reuse). + +7. **Docs on land** + - Update [E3_M3](../decomposition/modules/E3_M3_ItemizationAndInventorySchema.md) client slice note + [documentation_and_implementation_alignment.md](../decomposition/modules/documentation_and_implementation_alignment.md) E3.M3 row when implementation completes. + - **`E3S5-client-prototype-backlog.md`:** check E3S5-01 boxes. + +## Files to add + +| Path | Purpose | +|------|---------| +| `docs/plans/NEO-72-implementation-plan.md` | This plan. | +| `client/scripts/item_definitions_client.gd` | GET world item defs; cache id → display metadata; **`definitions_ready`** signal. | +| `client/scripts/item_definitions_client.gd.uid` | Godot uid companion (tracked). | +| `client/scripts/inventory_client.gd` | GET player inventory snapshot; **`inventory_received`** / **`inventory_sync_failed`** signals. | +| `client/scripts/inventory_client.gd.uid` | Godot uid companion (tracked). | +| `client/test/item_definitions_client_test.gd` | GdUnit: parse v1 items list; lookup helper; HTTP error **`push_warning`**. | +| `client/test/inventory_client_test.gd` | GdUnit: parse bag/equipment slots; 404/500 failure emits **`inventory_sync_failed`**. | +| `docs/manual-qa/NEO-72.md` | Boot empty inventory, Bruno seed + refresh, failed GET (server stopped), **`I`** refresh. | + +## Files to modify + +| Path | Rationale | +|------|-----------| +| `client/scenes/main.tscn` | Add **`InventoryClient`**, **`ItemDefinitionsClient`**, **`InventoryLabel`** nodes. | +| `client/scripts/main.gd` | Boot wiring, HUD render, **`I`** refresh input, defs cache + inventory signal handlers. | +| `client/project.godot` | Register **`inventory_refresh`** input action (**I**). | +| `client/README.md` | Inventory HUD subsection: bindings, label format, boot/refresh behavior, server deps. | + +## Tests + +| Test file | What it covers | +|-----------|----------------| +| `client/test/item_definitions_client_test.gd` | Mock transport 200 with six-item v1 JSON → map contains **`scrap_metal_bulk`** **`displayName`**; non-200 → no **`definitions_ready`**, warning path. AAA layout. | +| `client/test/inventory_client_test.gd` | Mock 200 with empty 24+1 slot grid → **`inventory_received`** payload has **`bagSlots.size()` 24**; 404 → **`inventory_sync_failed`** + warning; malformed JSON → failure signal. AAA layout. | + +No new **C#** tests (client-only; server contracts covered by NEO-55/NEO-53). No new Bruno (server already exercised). + +## Open questions / risks + +| Question / risk | Agent recommendation | Status | +|-----------------|----------------------|--------| +| **Defs/inventory race on boot** | First inventory paint may use raw ids; re-render on **`definitions_ready`** — acceptable for prototype. | **adopted** | +| **Empty bag UX** | Show **`Bag: (empty)`** when no occupied bag slots; equipment stub always listed. | **adopted** | +| **HUD clutter at 24 slots** | Non-empty-only rows keep label readable until NEO-75 optional collapse. | **adopted** | +| **Slice 5 module docs** | Update alignment register when NEO-72 lands, not at kickoff. | **deferred** (implementation) | + +## Decisions (kickoff) + +- **Include `item_definitions_client`** with **`displayName`** resolution (user confirmed). +- **Error UX:** **`InventoryLabel`** error line + **`push_warning`** (user confirmed). +- **Manual refresh:** **`I` → `inventory_refresh`** (user confirmed). +- **Equipment row:** always show slot **0** (empty or occupied) per Linear AC “equipment stub slot”. +- **No POST inventory** from UI in this story.