# 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

- [x] **`refine_scrap_standard`** and **`make_field_stim_mk0`** craftable from UI when materials allow.
- [x] Denied craft shows **`reasonCode`** on **`CraftFeedbackLabel`**; inventory unchanged (verify via GET / no auto-refresh on deny).
- [x] **Refine** skill level/XP visible on **`SkillProgressionLabel`** and increases after successful craft.

## Implementation reconciliation (shipped)

- **Clients:** `recipe_definitions_client.gd`, `craft_client.gd`; `craft_recipe_panel.gd` scroll list + **Craft** buttons.
- **HUD:** `CraftFeedbackLabel` (POST **`outputsGranted`** / deny); **`SkillProgressionLabel`** salvage + **refine** rows; inventory refresh on craft success only.
- **Tests:** `craft_client_test.gd`, `recipe_definitions_client_test.gd`, `craft_feedback_refresh_test.gd`.
- **Docs:** `client/README.md` craft section; [`NEO-74` manual QA](../manual-qa/NEO-74.md).

## 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.
