diff --git a/docs/plans/NEO-74-implementation-plan.md b/docs/plans/NEO-74-implementation-plan.md new file mode 100644 index 0000000..ea87f0e --- /dev/null +++ b/docs/plans/NEO-74-implementation-plan.md @@ -0,0 +1,161 @@ +# NEO-74 — Implementation plan + +## Story reference + +| Field | Value | +|--------|--------| +| **Key** | NEO-74 | +| **Title** | E3.M2: Client craft UI + recipe list (E3S5-03) | +| **Linear** | https://linear.app/neon-sprawl/issue/NEO-74/e3m2-client-craft-ui-recipe-list-e3s5-03 | +| **Module** | [E3.M2 — RefinementAndRecipeExecution](../decomposition/modules/E3_M2_RefinementAndRecipeExecution.md) · Epic 3 **Slice 5** (client 3 of 4) · backlog **E3S5-03** | +| **Branch** | `NEO-74-e3m2-client-craft-ui-recipe-list` | +| **Server deps** | [NEO-68](https://linear.app/neon-sprawl/issue/NEO-68) — `GET /game/world/recipe-definitions` (**Done**); [NEO-70](https://linear.app/neon-sprawl/issue/NEO-70) — `POST …/craft` (**Done**); [NEO-72](https://linear.app/neon-sprawl/issue/NEO-72) — inventory HUD + refresh (**Done**); [NEO-37](https://linear.app/neon-sprawl/issue/NEO-37) — skill-progression GET (**Done**) | +| **Client deps** | [NEO-73](https://linear.app/neon-sprawl/issue/NEO-73) — `SkillProgressionClient`, gather refresh pattern (**Done** on `main`) | +| **Pattern** | [NEO-72](https://linear.app/neon-sprawl/issue/NEO-72) / [NEO-73](https://linear.app/neon-sprawl/issue/NEO-73) — thin HTTP clients, `main.gd` wiring, debug HUD labels, GdUnit mock transport | +| **Downstream** | [NEO-75](https://linear.app/neon-sprawl/issue/NEO-75) capstone gather→refine→make loop; optional HUD collapse | + +## Kickoff clarifications + +| Topic | Question | Agent recommendation | Answer | +|--------|----------|----------------------|--------| +| **Craft success copy** | What should success feedback show? | **`outputsGranted` from POST body** — e.g. `Craft: +1 {displayName}`; still GET inventory + skill-progression after success (E3S5 refresh policy). | **User:** POST **`outputsGranted`** summary. | +| **Craft feedback label** | Dedicated label vs reuse gather? | **Dedicated `CraftFeedbackLabel`** below **`GatherFeedbackLabel`** — mirrors cast/gather separation. | **User:** dedicated **`CraftFeedbackLabel`**. | +| **Recipe panel layout** | Where to place recipe list + Craft buttons? | **Left-column `ScrollContainer`** below **`SkillProgressionLabel`** — matches debug HUD stack; NEO-75 can collapse if crowded. | **User:** left scroll below skills. | + +No other blocking decisions — list **all eight** prototype recipes per [E3S5 kickoff table](E3S5-client-prototype-backlog.md#kickoff-decisions-slice-5-client); qty **1** only (no spinner); deny copy **`Craft: denied — {reasonCode}`** mirrors gather; **`refine`** row added to existing **`SkillProgressionLabel`** (salvage row unchanged). + +## Goal, scope, and out-of-scope + +**Goal:** Player selects a prototype recipe and **`POST /game/players/{id}/craft`** from Godot; sees success/deny, updated inventory, and **refine** XP on HUD — not Output-panel-only proof. + +**In scope (from Linear + [E3S5-03](E3S5-client-prototype-backlog.md#e3s5-03--client-craft-ui--recipe-list-e3m2)):** + +- **`recipe_definitions_client.gd`:** GET world recipe defs (NEO-68); cache ordered list; emit **`recipes_ready`**. +- **`craft_client.gd`:** POST `{ schemaVersion: 1, recipeId }` (qty **1** implicit); parse success/deny + **`reasonCode`** + I/O lists; emit **`craft_result_received`**. +- **`craft_recipe_panel.gd`** (child under **`UICanvas`**): build scrollable recipe rows (id / **`displayName`**, inputs summary via **`ItemDefinitionsClient.display_name_for`**, **Craft** **`Button`** per row); signal **`craft_requested(recipe_id)`**. +- **`CraftFeedbackLabel`:** success **`Craft: +N {displayName}`** from **`outputsGranted`**; deny **`Craft: denied — {reasonCode}`**; no refresh on deny. +- Extend **`SkillProgressionLabel`:** add **`refine: level N, xp M`** row below salvage; boot hydrate + refresh after successful craft. +- **`main.gd` craft path:** on **`craft_requested`** → **`craft_client.request_craft(recipe_id)`**; on success → paint feedback from POST outputs → **`inventory_client.request_sync_from_server()`** + **`skill_progression_client.request_sync_from_server()`**; on deny → feedback only (inventory unchanged per AC). +- Boot: chain **`recipe_definitions_client.request_sync_from_server()`** after item defs (or parallel); panel populates on **`recipes_ready`**. +- GdUnit: craft POST payload/parse; recipe defs parse; deny/success signal paths. +- **`client/README.md`** craft UI subsection. +- **`docs/manual-qa/NEO-74.md`**. + +**Out of scope (from Linear):** + +- Station/bench world objects; craft quantity spinner; recipe filtering by skill level. +- HUD collapse/toggle (NEO-75 optional). + +## Acceptance criteria checklist + +- [ ] **`refine_scrap_standard`** and **`make_field_stim_mk0`** craftable from UI when materials allow. +- [ ] Denied craft shows **`reasonCode`** on **`CraftFeedbackLabel`**; inventory unchanged (verify via GET / no auto-refresh on deny). +- [ ] **Refine** skill level/XP visible on **`SkillProgressionLabel`** and increases after successful craft. + +## Technical approach + +1. **`recipe_definitions_client.gd`** + - Mirror **`item_definitions_client.gd`**: injectable HTTP, **`_busy`** guard, **`GET …/world/recipe-definitions`**. + - **`parse_recipes_json(text)`** static: require **`schemaVersion` 1**, **`recipes`** array. + - Cache **`Array`** of recipe dicts in server id order; emit **`recipes_ready(recipes)`**. + - Helper **`recipe_by_id(id)`** for panel lookups. + +2. **`craft_client.gd`** + - Mirror **`ability_cast_client.gd`** POST pattern: injectable HTTP, **`_busy`** guard. + - **`request_craft(recipe_id: String, quantity: int = 1) -> bool`**: POST body `{ "schemaVersion": 1, "recipeId": recipe_id, "quantity": quantity }` to **`…/players/{id}/craft`**. + - **`parse_craft_response_json(text)`** static: require **`schemaVersion` 1**, **`success`** bool. + - Signal **`craft_result_received(result: Dictionary)`** with parsed body (includes **`success`**, **`reasonCode`**, **`outputsGranted`**, **`inputsConsumed`**, optional **`xpGrantSummary`**). + - On HTTP transport failure / non-2xx / JSON mismatch: **`craft_request_failed(recipe_id, reason)`** (mirror interaction client failure path). + - **`push_warning`** on deny with stable reason (NEO-31 pattern). + +3. **`craft_recipe_panel.gd`** + - **`@export`** refs or **`setup(...)`** for **`ItemDefinitionsClient`** display-name lookup. + - **`populate(recipes: Array)`**: clear **`ScrollContainer/VBoxContainer`** children; for each recipe row: + - **`Label`**: **`{displayName}`** (`{id}`) + inputs line e.g. **`needs: 5× Scrap Metal Bulk`** (join **`inputs`** with **`display_name_for(itemId)`**). + - **`Button`**: text **`Craft`**; pressed → emit **`craft_requested(recipe.get("id"))`**. + - Disable buttons while **`craft_client`** busy (optional **`set_craft_busy(busy)`** from **`main`**). + +4. **`main.gd` craft orchestration** + - Constants: **`REFINE_SKILL_ID := "refine"`**. + - State: **`_last_craft_recipe_id: String`**, **`_craft_busy`** (or delegate to client **`_busy`**). + - **`_setup_craft_ui()`**: wire clients, panel, feedback label; connect **`craft_requested`**, **`craft_result_received`**, **`craft_request_failed`**. + - Success handler: + - Format **`outputsGranted`** rows → **`Craft: +{qty} {displayName}`** (multi-output: comma-join). + - Trigger inventory + skill progression refresh (same as gather success). + - Deny handler: **`Craft: denied — {reasonCode}`**; no GET refresh. + - Extend **`_render_skill_progression_label()`**: salvage row + **`refine`** row (two lines under **`Skills:`** header). + +5. **Scene layout (`main.tscn`)** + - Add nodes: **`RecipeDefinitionsClient`**, **`CraftClient`**, **`CraftFeedbackLabel`**, **`CraftRecipePanel`** (with **`ScrollContainer` > `VBoxContainer`**). + - **`CraftFeedbackLabel`:** below **`GatherFeedbackLabel`** (~offset_top 404); shift **`CooldownSlotsLabel`** down (~+28px) and cascade inventory/skills/panel offsets to avoid overlap. + - **`CraftRecipePanel`:** below **`SkillProgressionLabel`** (~offset_top 728); fixed height scroll (~200–280px) for eight rows. + - HTTP client nodes as siblings (same pattern as **`InventoryClient`**). + +6. **Tests** + - **`craft_client_test.gd`:** mock 200 success → **`success=true`**, **`outputsGranted`** parsed; mock 200 deny → **`success=false`**, **`reasonCode=insufficient_materials`**; verify POST URL + JSON body includes **`recipeId`**. AAA layout. + - **`recipe_definitions_client_test.gd`:** mock 200 with two recipes → **`recipes_ready`** length + id order. AAA layout. + - **`craft_feedback_refresh_test.gd`:** stub handler — craft allow signal → inventory + skill **`request_sync_from_server`** invoked; deny → refresh **not** invoked. AAA layout. + +7. **Manual QA (`docs/manual-qa/NEO-74.md`)** + - Fresh player: craft deny **`insufficient_materials`** for **`refine_scrap_standard`** → HUD reason + bag unchanged. + - After gather ≥5 scrap: **`refine_scrap_standard`** success → feedback line, inventory shows **`refined_plate_stock`**, refine XP bumped. + - With 2 refined + 1 scrap: **`make_field_stim_mk0`** success → **`field_stim_mk0`** in bag. + - Deny paths spot-check: unknown recipe (if test hook), bag full (optional note — may need pre-fill). + +8. **Docs on land** + - Update [E3_M2](../decomposition/modules/E3_M2_RefinementAndRecipeExecution.md) client slice note + [documentation_and_implementation_alignment.md](../decomposition/modules/documentation_and_implementation_alignment.md) when implementation completes. + - Check **E3S5-03** boxes in **`E3S5-client-prototype-backlog.md`**. + +## Files to add + +| Path | Purpose | +|------|---------| +| `docs/plans/NEO-74-implementation-plan.md` | This plan. | +| `client/scripts/recipe_definitions_client.gd` | GET world recipe defs; **`recipes_ready`**; parse v1 **`recipes`** array. | +| `client/scripts/recipe_definitions_client.gd.uid` | Godot uid companion (tracked). | +| `client/scripts/craft_client.gd` | POST craft; **`craft_result_received`** / **`craft_request_failed`**. | +| `client/scripts/craft_client.gd.uid` | Godot uid companion (tracked). | +| `client/scripts/craft_recipe_panel.gd` | Scrollable recipe list + **Craft** buttons; **`craft_requested`** signal. | +| `client/scripts/craft_recipe_panel.gd.uid` | Godot uid companion (tracked). | +| `client/test/craft_client_test.gd` | GdUnit: POST payload, success/deny parse. AAA layout. | +| `client/test/recipe_definitions_client_test.gd` | GdUnit: parse v1 recipes array. AAA layout. | +| `client/test/craft_feedback_refresh_test.gd` | GdUnit: success → inventory + skill refresh; deny → no refresh. AAA layout. | +| `docs/manual-qa/NEO-74.md` | Craft spine UI session: deny, refine, make stim, refine XP delta. | + +## Files to modify + +| Path | Rationale | +|------|-----------| +| `client/scripts/main.gd` | Boot wiring for recipe defs + craft clients; craft orchestration; **`CraftFeedbackLabel`** render; **`refine`** skill row; authority HTTP config for new clients. | +| `client/scenes/main.tscn` | Add **`RecipeDefinitionsClient`**, **`CraftClient`**, **`CraftFeedbackLabel`**, **`CraftRecipePanel`**; adjust HUD vertical offsets. | +| `client/README.md` | Craft UI subsection: recipe panel, craft POST, feedback labels, boot hydrate, server deps. | + +## Tests + +| Test file | What it covers | +|-----------|----------------| +| `client/test/craft_client_test.gd` | Mock 200 success → **`craft_result_received`** with **`outputsGranted`**; mock deny → **`reasonCode`**; POST body **`recipeId`** + **`schemaVersion` 1**. AAA layout. | +| `client/test/recipe_definitions_client_test.gd` | Mock 200 → **`recipes_ready`** emits ordered array; schema mismatch → no emit. AAA layout. | +| `client/test/craft_feedback_refresh_test.gd` | Craft success handler triggers inventory + skill **`request_sync_from_server`**; deny path skips refresh (spy/mock). AAA layout. | + +No new **C#** tests (client-only). No new Bruno (server covered by NEO-68/NEO-70). + +## Open questions / risks + +| Question / risk | Agent recommendation | Status | +|-----------------|----------------------|--------| +| **HUD vertical crowding (8 recipes)** | Left scroll with ~200–280px max height; full collapse deferred to NEO-75. | **adopted** (kickoff) | +| **Multi-output success copy** | Comma-join **`outputsGranted`** rows — prototype recipes have single output except batch variants. | **adopted** | +| **Craft busy double-click** | Guard with **`craft_client._busy`**; disable panel buttons while in flight. | **adopted** | +| **Deny without inventory refresh** | Do not call inventory GET on **`success=false`** — AC requires unchanged bag; manual QA verifies. | **adopted** | +| **Item display names on recipe inputs** | Reuse landed **`ItemDefinitionsClient`** cache; fall back to raw **`itemId`**. | **adopted** | +| **Slice 5 module docs** | Update alignment register when NEO-74 lands, not at kickoff. | **deferred** (implementation) | + +## Decisions (kickoff) + +- **Craft success copy:** **`outputsGranted`** from POST response formatted on **`CraftFeedbackLabel`** (user confirmed). +- **Craft feedback:** dedicated **`CraftFeedbackLabel`** below gather (user confirmed). +- **Recipe panel:** left-column **`ScrollContainer`** below **`SkillProgressionLabel`** (user confirmed). +- **Recipe scope:** all **eight** prototype recipes with qty-**1** **Craft** buttons (E3S5 backlog). +- **Skill HUD:** add **`refine`** row to **`SkillProgressionLabel`**; refresh after successful craft. +- **Refresh policy:** GET inventory + skill-progression on craft success only; no GET on deny.