From dff58adfe84c5bf327b942e095cac6d30f7f9efe Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 24 May 2026 19:54:10 -0400 Subject: [PATCH 1/9] NEO-74: Add kickoff implementation plan for client craft UI. Documents E3S5-03 scope, kickoff HUD decisions, and file/test targets for recipe list, craft POST client, and refine XP visibility. --- docs/plans/NEO-74-implementation-plan.md | 161 +++++++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 docs/plans/NEO-74-implementation-plan.md 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. From c16b6104680063bdba150f330ace835b4dfa6a34 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 24 May 2026 19:56:38 -0400 Subject: [PATCH 2/9] NEO-74: Implement client craft UI, recipe list, and tests. Add recipe-definitions and craft HTTP clients, scrollable recipe panel with Craft buttons, CraftFeedbackLabel, refine skill HUD row, GdUnit coverage, manual QA, and plan/module doc reconciliation. --- client/README.md | 12 +- client/scenes/main.tscn | 52 ++++++- client/scripts/craft_client.gd | 108 ++++++++++++++ client/scripts/craft_client.gd.uid | 1 + client/scripts/craft_recipe_panel.gd | 84 +++++++++++ client/scripts/craft_recipe_panel.gd.uid | 1 + client/scripts/main.gd | 116 ++++++++++++++- client/scripts/recipe_definitions_client.gd | 97 +++++++++++++ .../scripts/recipe_definitions_client.gd.uid | 1 + client/test/craft_client_test.gd | 132 ++++++++++++++++++ client/test/craft_feedback_refresh_test.gd | 99 +++++++++++++ client/test/recipe_definitions_client_test.gd | 97 +++++++++++++ .../E3_M2_RefinementAndRecipeExecution.md | 4 +- ...umentation_and_implementation_alignment.md | 6 +- docs/manual-qa/NEO-74.md | 30 ++++ docs/plans/E3S5-client-prototype-backlog.md | 6 +- docs/plans/NEO-74-implementation-plan.md | 13 +- 17 files changed, 836 insertions(+), 23 deletions(-) create mode 100644 client/scripts/craft_client.gd create mode 100644 client/scripts/craft_client.gd.uid create mode 100644 client/scripts/craft_recipe_panel.gd create mode 100644 client/scripts/craft_recipe_panel.gd.uid create mode 100644 client/scripts/recipe_definitions_client.gd create mode 100644 client/scripts/recipe_definitions_client.gd.uid create mode 100644 client/test/craft_client_test.gd create mode 100644 client/test/craft_feedback_refresh_test.gd create mode 100644 client/test/recipe_definitions_client_test.gd create mode 100644 docs/manual-qa/NEO-74.md diff --git a/client/README.md b/client/README.md index 5b5028f..9fd2140 100644 --- a/client/README.md +++ b/client/README.md @@ -154,6 +154,16 @@ Full checklist: [`docs/manual-qa/NEO-72.md`](../docs/manual-qa/NEO-72.md). Full checklist: [`docs/manual-qa/NEO-73.md`](../docs/manual-qa/NEO-73.md). +## Craft UI + recipe list (NEO-74) + +- **`GET /game/world/recipe-definitions`** — eight prototype recipes on boot; scrollable panel below **`SkillProgressionLabel`**. +- **`POST /game/players/{id}/craft`** — JSON body `schemaVersion`, `recipeId`, `quantity` (default **1**); see [server README — Craft HTTP](../server/README.md). +- **Scripts:** `scripts/recipe_definitions_client.gd`, `scripts/craft_client.gd`, `scripts/craft_recipe_panel.gd`; wired from `main.gd`. +- **HUD:** `UICanvas/CraftFeedbackLabel` — success **`Craft: +N {displayName}`** from POST **`outputsGranted`**; deny **`Craft: denied — {reasonCode}`**. **`SkillProgressionLabel`** adds **`refine`** row (boot + post-craft refresh). **`InventoryLabel`** refreshes on craft success only (no **I**). +- **Panel:** per-recipe **Craft** button (qty **1**); input summary uses cached item **`displayName`** when available. + +Full checklist: [`docs/manual-qa/NEO-74.md`](../docs/manual-qa/NEO-74.md). + ### Manual check (NEO-24) 1. Run the server and client as in NEO-7 / NEO-9; confirm spawn snap at `(-5, 0.9, -5)`. @@ -213,7 +223,7 @@ On **Linux** (including GitHub Actions), the path must be **`gdUnit4`** with a c **CI:** The **GDScript** workflow fails the job if Godot prints **`SCRIPT ERROR:`** or **`ERROR: Failed to load script`** while running GdUnit. GdUnit alone can still exit **0** when a test file fails to parse (that suite is skipped); the workflow guards against that. -**Scope:** Unit tests cover **`player.gd`**, **`player_locomotion_wasd.gd`** (pure WASD seam helpers), **`position_authority_client.gd`**, **`inventory_client.gd`**, **`item_definitions_client.gd`** (NEO-72), **`skill_progression_client.gd`**, **`prototype_interactable_picker.gd`**, extended **`interaction_request_client_test.gd`**, **`gather_feedback_refresh_test.gd`** (NEO-73), **`isometric_follow_camera.gd`** (eye math + effective zoom distance), **`camera_state.gd`**, and **`zoom_band_config.gd`**. **`main.gd`** and scene wiring are **not** automated here—use manual checks above. +**Scope:** Unit tests cover **`player.gd`**, **`player_locomotion_wasd.gd`** (pure WASD seam helpers), **`position_authority_client.gd`**, **`inventory_client.gd`**, **`item_definitions_client.gd`** (NEO-72), **`skill_progression_client.gd`**, **`prototype_interactable_picker.gd`**, extended **`interaction_request_client_test.gd`**, **`gather_feedback_refresh_test.gd`** (NEO-73), **`craft_client.gd`**, **`recipe_definitions_client.gd`**, **`craft_feedback_refresh_test.gd`** (NEO-74), **`isometric_follow_camera.gd`** (eye math + effective zoom distance), **`camera_state.gd`**, and **`zoom_band_config.gd`**. **`main.gd`** and scene wiring are **not** automated here—use manual checks above. **Camera zoom:** Input actions **`camera_zoom_in`** / **`camera_zoom_out`** (mouse wheel, **`=`** / **`-`**, keypad **+** / **−**) are defined in **`project.godot`**. On layouts where **`+`** requires **Shift**, remap or add a binding under **Project → Project Settings → Input Map** if needed. diff --git a/client/scenes/main.tscn b/client/scenes/main.tscn index 9fc1ea0..7b80b26 100644 --- a/client/scenes/main.tscn +++ b/client/scenes/main.tscn @@ -15,6 +15,9 @@ [ext_resource type="Script" path="res://scripts/item_definitions_client.gd" id="14_item_defs"] [ext_resource type="Script" path="res://scripts/inventory_client.gd" id="15_inventory"] [ext_resource type="Script" path="res://scripts/skill_progression_client.gd" id="16_skill_prog"] +[ext_resource type="Script" path="res://scripts/recipe_definitions_client.gd" id="17_recipe_defs"] +[ext_resource type="Script" path="res://scripts/craft_client.gd" id="18_craft"] +[ext_resource type="Script" path="res://scripts/craft_recipe_panel.gd" id="19_craft_panel"] [sub_resource type="NavigationMesh" id="NavigationMesh_district"] geometry_collision_mask = 1 @@ -1101,6 +1104,12 @@ script = ExtResource("15_inventory") [node name="SkillProgressionClient" type="Node" parent="." unique_id=2500008] script = ExtResource("16_skill_prog") +[node name="RecipeDefinitionsClient" type="Node" parent="." unique_id=2500009] +script = ExtResource("17_recipe_defs") + +[node name="CraftClient" type="Node" parent="." unique_id=2500010] +script = ExtResource("18_craft") + [node name="UICanvas" type="CanvasLayer" parent="." unique_id=9000001] [node name="MoveRejectLabel" type="Label" parent="UICanvas" unique_id=9000002] @@ -1174,11 +1183,25 @@ theme_override_font_sizes/font_size = 14 autowrap_mode = 3 text = "Gather: —" +[node name="CraftFeedbackLabel" type="Label" parent="UICanvas" unique_id=9000010] +offset_left = 8.0 +offset_top = 404.0 +offset_right = 520.0 +offset_bottom = 430.0 +grow_horizontal = 0 +grow_vertical = 0 +theme_override_colors/font_color = Color(0.95, 0.82, 0.72, 1) +theme_override_colors/font_outline_color = Color(0.08, 0.08, 0.1, 1) +theme_override_constants/outline_size = 6 +theme_override_font_sizes/font_size = 14 +autowrap_mode = 3 +text = "Craft: —" + [node name="CooldownSlotsLabel" type="Label" parent="UICanvas" unique_id=9000006] offset_left = 8.0 -offset_top = 408.0 +offset_top = 436.0 offset_right = 520.0 -offset_bottom = 484.0 +offset_bottom = 512.0 grow_horizontal = 0 grow_vertical = 0 theme_override_colors/font_color = Color(0.78, 0.92, 0.86, 1) @@ -1190,9 +1213,9 @@ text = "Cooldowns: —" [node name="InventoryLabel" type="Label" parent="UICanvas" unique_id=9000007] offset_left = 8.0 -offset_top = 496.0 +offset_top = 524.0 offset_right = 520.0 -offset_bottom = 644.0 +offset_bottom = 672.0 grow_horizontal = 0 grow_vertical = 0 theme_override_colors/font_color = Color(0.9, 0.86, 0.98, 1) @@ -1205,9 +1228,9 @@ Loading…" [node name="SkillProgressionLabel" type="Label" parent="UICanvas" unique_id=9000009] offset_left = 8.0 -offset_top = 656.0 +offset_top = 684.0 offset_right = 520.0 -offset_bottom = 720.0 +offset_bottom = 768.0 grow_horizontal = 0 grow_vertical = 0 theme_override_colors/font_color = Color(0.82, 0.9, 1, 1) @@ -1217,3 +1240,20 @@ theme_override_font_sizes/font_size = 14 autowrap_mode = 3 text = "Skills: Loading…" + +[node name="CraftRecipePanel" type="Control" parent="UICanvas" unique_id=9000011] +anchors_preset = 0 +offset_left = 8.0 +offset_top = 776.0 +offset_right = 520.0 +offset_bottom = 1056.0 +script = ExtResource("19_craft_panel") + +[node name="ScrollContainer" type="ScrollContainer" parent="UICanvas/CraftRecipePanel" unique_id=9000012] +layout_mode = 0 +offset_right = 512.0 +offset_bottom = 280.0 + +[node name="VBoxContainer" type="VBoxContainer" parent="UICanvas/CraftRecipePanel/ScrollContainer" unique_id=9000013] +layout_mode = 2 +size_flags_horizontal = 3 diff --git a/client/scripts/craft_client.gd b/client/scripts/craft_client.gd new file mode 100644 index 0000000..e474e18 --- /dev/null +++ b/client/scripts/craft_client.gd @@ -0,0 +1,108 @@ +extends Node + +## NEO-74: prototype HTTP client for [code]POST /game/players/{id}/craft[/code] (NEO-70). + +signal craft_result_received(recipe_id: String, result: Dictionary) +signal craft_request_failed(recipe_id: String, reason: String) + +const SCHEMA_VERSION := 1 + +@export var base_url: String = "http://127.0.0.1:5253" +@export var dev_player_id: String = "dev-local-1" +@export var injected_http: Node = null + +var _http: Node +var _busy: bool = false +var _current_recipe_id: String = "" + + +func _ready() -> void: + if injected_http != null: + _http = injected_http + else: + _http = HTTPRequest.new() + add_child(_http) + if _http is HTTPRequest: + (_http as HTTPRequest).timeout = 30.0 + @warning_ignore("unsafe_method_access") + _http.request_completed.connect(_on_request_completed) + + +func is_busy() -> bool: + return _busy + + +## Returns [code]true[/code] when the HTTP POST was queued. +func request_craft(recipe_id: String, quantity: int = 1) -> bool: + if _busy: + return false + var rid := recipe_id.strip_edges() + if rid.is_empty(): + return false + _current_recipe_id = rid + var payload: Dictionary = { + "schemaVersion": SCHEMA_VERSION, + "recipeId": rid, + "quantity": quantity, + } + var url := "%s/game/players/%s/craft" % [_base_root(), _player_path_segment()] + var headers := PackedStringArray(["Content-Type: application/json"]) + var err: Error = _http.request(url, headers, HTTPClient.METHOD_POST, JSON.stringify(payload)) + if err != OK: + _current_recipe_id = "" + push_warning("CraftClient: POST failed to start (%s)" % err) + return false + _busy = true + return true + + +func _base_root() -> String: + return base_url.strip_edges().rstrip("/") + + +func _player_path_segment() -> String: + return dev_player_id.strip_edges() + + +static func parse_craft_response_json(text: String) -> Variant: + var parsed: Variant = JSON.parse_string(text) + if not parsed is Dictionary: + return null + var root: Dictionary = parsed + if int(root.get("schemaVersion", -1)) != SCHEMA_VERSION: + return null + if not root.has("success"): + return null + return root + + +func _on_request_completed( + result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray +) -> void: + var recipe_echo: String = _current_recipe_id + _current_recipe_id = "" + _busy = false + if result != HTTPRequest.RESULT_SUCCESS: + var reason := "HTTP failed (result=%s)" % result + push_warning("CraftClient: %s" % reason) + craft_request_failed.emit(recipe_echo, reason) + return + if response_code < 200 or response_code >= 300: + var reason_code := "HTTP %s" % response_code + push_warning("CraftClient: %s" % reason_code) + craft_request_failed.emit(recipe_echo, reason_code) + return + var text := body.get_string_from_utf8() + var parsed: Variant = parse_craft_response_json(text) + if parsed == null: + var reason_json := "non-JSON body or schemaVersion mismatch" + push_warning("CraftClient: %s" % reason_json) + craft_request_failed.emit(recipe_echo, reason_json) + return + var data: Dictionary = parsed as Dictionary + var success: bool = bool(data.get("success", false)) + if not success: + var reason_variant: Variant = data.get("reasonCode", "") + var reason_str: String = reason_variant as String if reason_variant is String else "" + push_warning("craft_denied reasonCode=%s" % reason_str) + craft_result_received.emit(recipe_echo, data.duplicate(true)) diff --git a/client/scripts/craft_client.gd.uid b/client/scripts/craft_client.gd.uid new file mode 100644 index 0000000..46cc001 --- /dev/null +++ b/client/scripts/craft_client.gd.uid @@ -0,0 +1 @@ +uid://bneo74craftclient01 diff --git a/client/scripts/craft_recipe_panel.gd b/client/scripts/craft_recipe_panel.gd new file mode 100644 index 0000000..a9735f3 --- /dev/null +++ b/client/scripts/craft_recipe_panel.gd @@ -0,0 +1,84 @@ +extends Control + +## NEO-74: scrollable prototype recipe list with per-row Craft buttons. + +signal craft_requested(recipe_id: String) + +@onready var _scroll: ScrollContainer = $ScrollContainer +@onready var _vbox: VBoxContainer = $ScrollContainer/VBoxContainer + +var _item_defs_client: Node = null +var _craft_buttons: Array[Button] = [] + + +func setup(item_defs_client: Node) -> void: + _item_defs_client = item_defs_client + + +func populate(recipes: Array) -> void: + if not is_instance_valid(_vbox): + return + for child in _vbox.get_children(): + child.queue_free() + _craft_buttons.clear() + for row_variant in recipes: + if not row_variant is Dictionary: + continue + var recipe: Dictionary = row_variant + var recipe_id: String = str(recipe.get("id", "")) + if recipe_id.is_empty(): + continue + var row := HBoxContainer.new() + row.size_flags_horizontal = Control.SIZE_EXPAND_FILL + var info := Label.new() + info.size_flags_horizontal = Control.SIZE_EXPAND_FILL + info.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + info.text = _format_recipe_line(recipe) + row.add_child(info) + var craft_btn := Button.new() + craft_btn.text = "Craft" + craft_btn.pressed.connect(_on_craft_pressed.bind(recipe_id)) + row.add_child(craft_btn) + _vbox.add_child(row) + _craft_buttons.append(craft_btn) + + +func set_craft_busy(busy: bool) -> void: + for btn in _craft_buttons: + if is_instance_valid(btn): + btn.disabled = busy + + +func _format_recipe_line(recipe: Dictionary) -> String: + var recipe_id: String = str(recipe.get("id", "")) + var display_name: String = str(recipe.get("displayName", recipe_id)) + var inputs_line: String = _format_inputs(recipe.get("inputs", [])) + return "%s (%s)\nneeds: %s" % [display_name, recipe_id, inputs_line] + + +func _format_inputs(raw_inputs: Variant) -> String: + if raw_inputs == null or not raw_inputs is Array: + return "—" + var parts: PackedStringArray = PackedStringArray() + for row_variant in raw_inputs as Array: + if not row_variant is Dictionary: + continue + var row: Dictionary = row_variant + var item_id: String = str(row.get("itemId", "")) + var qty: int = int(row.get("quantity", 0)) + if item_id.is_empty() or qty <= 0: + continue + parts.append("%d× %s" % [qty, _item_display_name(item_id)]) + if parts.is_empty(): + return "—" + return ", ".join(parts) + + +func _item_display_name(item_id: String) -> String: + if is_instance_valid(_item_defs_client) and _item_defs_client.has_method("display_name_for"): + return str(_item_defs_client.call("display_name_for", item_id)) + return item_id + + +func _on_craft_pressed(recipe_id: String) -> void: + craft_requested.emit(recipe_id) diff --git a/client/scripts/craft_recipe_panel.gd.uid b/client/scripts/craft_recipe_panel.gd.uid new file mode 100644 index 0000000..acf11b5 --- /dev/null +++ b/client/scripts/craft_recipe_panel.gd.uid @@ -0,0 +1 @@ +uid://bneo74craftpanel01 diff --git a/client/scripts/main.gd b/client/scripts/main.gd index 7ae5185..ba813ac 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -45,6 +45,7 @@ const CooldownStateScript := preload("res://scripts/cooldown_state.gd") const COOLDOWN_REASON_ON_CD := "on_cooldown" const SCRAP_METAL_BULK_ID := "scrap_metal_bulk" const SALVAGE_SKILL_ID := "salvage" +const REFINE_SKILL_ID := "refine" ## Bump on each rejection so older one-shot timers do not clear a newer message. var _move_reject_msg_token: int = 0 @@ -85,6 +86,7 @@ var _progression_error: String = "" var _gather_pre_scrap_qty: int = 0 var _gather_pending_interactable_id: String = "" var _gather_awaiting_inventory_finalize: bool = false +var _cached_recipe_rows: Array = [] @onready var _camera: Camera3D = $World/IsometricFollowCamera/Camera3D @onready var _world: Node3D = $World @@ -101,10 +103,14 @@ var _gather_awaiting_inventory_finalize: bool = false @onready var _cooldown_slots_label: Label = $UICanvas/CooldownSlotsLabel @onready var _inventory_label: Label = $UICanvas/InventoryLabel @onready var _gather_feedback_label: Label = $UICanvas/GatherFeedbackLabel +@onready var _craft_feedback_label: Label = $UICanvas/CraftFeedbackLabel @onready var _skill_progression_label: Label = $UICanvas/SkillProgressionLabel +@onready var _craft_recipe_panel: Node = $UICanvas/CraftRecipePanel @onready var _inventory_client: Node = $InventoryClient @onready var _item_defs_client: Node = $ItemDefinitionsClient @onready var _skill_progression_client: Node = $SkillProgressionClient +@onready var _recipe_defs_client: Node = $RecipeDefinitionsClient +@onready var _craft_client: Node = $CraftClient @onready var _target_client: Node = $TargetSelectionClient @onready var _interaction_client: Node = $InteractionRequestClient @onready var _floor: StaticBody3D = $World/NavigationRegion3D/Floor @@ -149,6 +155,7 @@ func _ready() -> void: _setup_inventory_sync() _setup_skill_progression_sync() _setup_gather_interact_feedback() + _setup_craft_ui() func _physics_process(_delta: float) -> void: @@ -362,6 +369,7 @@ func _on_item_definitions_ready(_definitions_by_id: Dictionary) -> void: return if not _last_inventory_snapshot.is_empty(): _render_inventory_label() + _populate_craft_recipe_panel_if_ready() func _on_inventory_received(snapshot: Dictionary) -> void: @@ -504,12 +512,18 @@ func _render_skill_progression_label() -> void: _skill_progression_label.text = "%s\nLoading…" % header return var salvage_row: Dictionary = _skill_row(SALVAGE_SKILL_ID) - if salvage_row.is_empty(): - _skill_progression_label.text = "%s\nsalvage: — (missing row)" % header + var refine_row: Dictionary = _skill_row(REFINE_SKILL_ID) + if salvage_row.is_empty() or refine_row.is_empty(): + _skill_progression_label.text = "%s\nsalvage/refine: — (missing row)" % header return - var level: int = int(salvage_row.get("level", 0)) - var xp: int = int(salvage_row.get("xp", 0)) - _skill_progression_label.text = "%s\nsalvage: level %d, xp %d" % [header, level, xp] + var salvage_level: int = int(salvage_row.get("level", 0)) + var salvage_xp: int = int(salvage_row.get("xp", 0)) + var refine_level: int = int(refine_row.get("level", 0)) + var refine_xp: int = int(refine_row.get("xp", 0)) + _skill_progression_label.text = ( + "%s\nsalvage: level %d, xp %d\nrefine: level %d, xp %d" + % [header, salvage_level, salvage_xp, refine_level, refine_xp] + ) func _skill_row(skill_id: String) -> Dictionary: @@ -528,6 +542,98 @@ func _render_gather_feedback_label(text: String = "Gather: —") -> void: _gather_feedback_label.text = text +func _setup_craft_ui() -> void: + # NEO-74: recipe defs + craft POST + scrollable recipe panel. + _apply_authority_http_config_to_client(_recipe_defs_client) + _apply_authority_http_config_to_client(_craft_client) + if _craft_recipe_panel.has_method("setup"): + _craft_recipe_panel.call("setup", _item_defs_client) + if _recipe_defs_client.has_signal("recipes_ready"): + _recipe_defs_client.connect("recipes_ready", Callable(self, "_on_recipes_ready")) + if _craft_recipe_panel.has_signal("craft_requested"): + _craft_recipe_panel.connect("craft_requested", Callable(self, "_on_craft_requested")) + if _craft_client.has_signal("craft_result_received"): + _craft_client.connect("craft_result_received", Callable(self, "_on_craft_result_received")) + if _craft_client.has_signal("craft_request_failed"): + _craft_client.connect("craft_request_failed", Callable(self, "_on_craft_request_failed")) + _render_craft_feedback_label() + if _recipe_defs_client.has_method("request_sync_from_server"): + _recipe_defs_client.call("request_sync_from_server") + + +func _on_recipes_ready(recipes: Array) -> void: + _cached_recipe_rows = recipes.duplicate(true) + _populate_craft_recipe_panel_if_ready() + + +func _populate_craft_recipe_panel_if_ready() -> void: + if _cached_recipe_rows.is_empty(): + return + if is_instance_valid(_craft_recipe_panel) and _craft_recipe_panel.has_method("populate"): + _craft_recipe_panel.call("populate", _cached_recipe_rows) + + +func _on_craft_requested(recipe_id: String) -> void: + if not is_instance_valid(_craft_client): + return + if _craft_client.has_method("is_busy") and bool(_craft_client.call("is_busy")): + return + if _craft_recipe_panel.has_method("set_craft_busy"): + _craft_recipe_panel.call("set_craft_busy", true) + if _craft_client.has_method("request_craft"): + var started: bool = bool(_craft_client.call("request_craft", recipe_id, 1)) + if not started and _craft_recipe_panel.has_method("set_craft_busy"): + _craft_recipe_panel.call("set_craft_busy", false) + + +func _on_craft_result_received(_recipe_id: String, result: Dictionary) -> void: + if _craft_recipe_panel.has_method("set_craft_busy"): + _craft_recipe_panel.call("set_craft_busy", false) + if bool(result.get("success", false)): + _render_craft_feedback_label(_format_craft_success_line(result.get("outputsGranted", []))) + _request_inventory_refresh() + if ( + is_instance_valid(_skill_progression_client) + and _skill_progression_client.has_method("request_sync_from_server") + ): + _skill_progression_client.call("request_sync_from_server") + return + var rc := str(result.get("reasonCode", "")).strip_edges() + if rc.is_empty(): + _render_craft_feedback_label("Craft: denied (no reasonCode)") + else: + _render_craft_feedback_label("Craft: denied — %s" % rc) + + +func _on_craft_request_failed(_recipe_id: String, reason: String) -> void: + if _craft_recipe_panel.has_method("set_craft_busy"): + _craft_recipe_panel.call("set_craft_busy", false) + _render_craft_feedback_label("Craft: failed — %s" % reason) + + +func _format_craft_success_line(raw_outputs: Variant) -> String: + if raw_outputs == null or not raw_outputs is Array: + return "Craft: ok (no outputsGranted)" + var parts: PackedStringArray = PackedStringArray() + for row_variant in raw_outputs as Array: + if not row_variant is Dictionary: + continue + var row: Dictionary = row_variant + var item_id: String = str(row.get("itemId", "")) + var qty: int = int(row.get("quantity", 0)) + if item_id.is_empty() or qty <= 0: + continue + parts.append("+%d %s" % [qty, _inventory_item_label(item_id)]) + if parts.is_empty(): + return "Craft: ok (empty outputsGranted)" + return "Craft: %s" % ", ".join(parts) + + +func _render_craft_feedback_label(text: String = "Craft: —") -> void: + if is_instance_valid(_craft_feedback_label): + _craft_feedback_label.text = text + + func _clear_gather_session() -> void: _gather_pending_interactable_id = "" _gather_awaiting_inventory_finalize = false diff --git a/client/scripts/recipe_definitions_client.gd b/client/scripts/recipe_definitions_client.gd new file mode 100644 index 0000000..7b20883 --- /dev/null +++ b/client/scripts/recipe_definitions_client.gd @@ -0,0 +1,97 @@ +extends Node + +## NEO-74: fetches [code]GET /game/world/recipe-definitions[/code] (NEO-68). +## Caches ordered recipe rows for craft UI. + +signal recipes_ready(recipes: Array) +signal recipes_sync_failed(reason: String) + +const SCHEMA_VERSION := 1 + +@export var base_url: String = "http://127.0.0.1:5253" +@export var injected_http: Node = null + +var _http: Node +var _busy: bool = false +var _recipes: Array = [] + + +func _ready() -> void: + if injected_http != null: + _http = injected_http + else: + _http = HTTPRequest.new() + add_child(_http) + if _http is HTTPRequest: + (_http as HTTPRequest).timeout = 30.0 + @warning_ignore("unsafe_method_access") + _http.request_completed.connect(_on_request_completed) + + +func request_sync_from_server() -> void: + if _busy: + return + _busy = true + var url := "%s/game/world/recipe-definitions" % _base_root() + var err: Error = _http.request(url) + if err != OK: + var reason := "GET failed to start (%s)" % err + push_warning("RecipeDefinitionsClient: %s" % reason) + _busy = false + recipes_sync_failed.emit(reason) + + +func recipes_snapshot() -> Array: + return _recipes.duplicate(true) + + +func recipe_by_id(recipe_id: String) -> Dictionary: + for row_variant in _recipes: + if not row_variant is Dictionary: + continue + var row: Dictionary = row_variant + if str(row.get("id", "")) == recipe_id: + return row.duplicate(true) + return {} + + +func _base_root() -> String: + return base_url.strip_edges().rstrip("/") + + +static func parse_recipes_json(text: String) -> Variant: + var parsed: Variant = JSON.parse_string(text) + if not parsed is Dictionary: + return null + var root: Dictionary = parsed + if int(root.get("schemaVersion", -1)) != SCHEMA_VERSION: + return null + var recipes: Variant = root.get("recipes", null) + if recipes == null or not recipes is Array: + return null + return recipes as Array + + +func _on_request_completed( + result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray +) -> void: + _busy = false + if result != HTTPRequest.RESULT_SUCCESS: + var reason := "HTTP failed (result=%s)" % result + push_warning("RecipeDefinitionsClient: %s" % reason) + recipes_sync_failed.emit(reason) + return + if response_code < 200 or response_code >= 300: + var reason_code := "HTTP %s" % response_code + push_warning("RecipeDefinitionsClient: %s" % reason_code) + recipes_sync_failed.emit(reason_code) + return + var text := body.get_string_from_utf8() + var recipes: Variant = parse_recipes_json(text) + if recipes == null: + var reason_json := "non-JSON body or schemaVersion mismatch" + push_warning("RecipeDefinitionsClient: %s" % reason_json) + recipes_sync_failed.emit(reason_json) + return + _recipes = (recipes as Array).duplicate(true) + recipes_ready.emit(_recipes.duplicate(true)) diff --git a/client/scripts/recipe_definitions_client.gd.uid b/client/scripts/recipe_definitions_client.gd.uid new file mode 100644 index 0000000..448f387 --- /dev/null +++ b/client/scripts/recipe_definitions_client.gd.uid @@ -0,0 +1 @@ +uid://bneo74recipedef01 diff --git a/client/test/craft_client_test.gd b/client/test/craft_client_test.gd new file mode 100644 index 0000000..5213134 --- /dev/null +++ b/client/test/craft_client_test.gd @@ -0,0 +1,132 @@ +extends GdUnitTestSuite + +## NEO-74: `CraftClient` POST payload + success/deny parse. + +const CraftClient := preload("res://scripts/craft_client.gd") + +var _failed_reason: String = "" +var _failed_recipe_id: String = "" + + +func _capture_failed(recipe_id: String, reason: String) -> void: + _failed_recipe_id = recipe_id + _failed_reason = reason + + +class MockHttpTransport: + extends Node + signal request_completed( + result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray + ) + var last_url: String = "" + var last_body: String = "" + var response_code: int = 200 + var body_json: String = "" + + func request( + url: String, + _custom_headers: PackedStringArray = PackedStringArray(), + _method: HTTPClient.Method = HTTPClient.METHOD_POST, + request_data: String = "" + ) -> Error: + last_url = url + last_body = request_data + request_completed.emit( + HTTPRequest.RESULT_SUCCESS, + response_code, + PackedStringArray(), + body_json.to_utf8_buffer() + ) + return OK + + +static func _success_craft_json() -> String: + return ( + '{"schemaVersion":1,"success":true,"reasonCode":null,' + + '"inputsConsumed":[{"itemId":"scrap_metal_bulk","quantity":5}],' + + '"outputsGranted":[{"itemId":"refined_plate_stock","quantity":1}],' + + '"xpGrantSummary":{"skillId":"refine","amount":10,"sourceKind":"craft_refine"}}' + ) + + +func _make_client(transport: Node) -> Node: + var c: Node = CraftClient.new() + c.set("injected_http", transport) + auto_free(transport) + auto_free(c) + add_child(c) + return c + + +func test_request_craft_posts_recipe_id_and_schema_version() -> void: + # Arrange + var transport := MockHttpTransport.new() + transport.body_json = _success_craft_json() + var c := _make_client(transport) + # Act + var started: bool = bool(c.call("request_craft", "refine_scrap_standard", 1)) + # Assert + assert_that(started).is_true() + assert_that(transport.last_url).contains("/craft") + var parsed: Variant = JSON.parse_string(transport.last_body) + assert_that(parsed is Dictionary).is_true() + var body: Dictionary = parsed as Dictionary + assert_that(int(body.get("schemaVersion", -1))).is_equal(1) + assert_that(str(body.get("recipeId", ""))).is_equal("refine_scrap_standard") + assert_that(int(body.get("quantity", -1))).is_equal(1) + + +func test_success_response_emits_craft_result_received() -> void: + # Arrange + var transport := MockHttpTransport.new() + transport.body_json = _success_craft_json() + var c := _make_client(transport) + var got: Array = [] + c.connect( + "craft_result_received", + func(recipe_id: String, result: Dictionary) -> void: got.append([recipe_id, result]) + ) + # Act + c.call("request_craft", "refine_scrap_standard") + # Assert + assert_that(got.size()).is_equal(1) + assert_that(str(got[0][0])).is_equal("refine_scrap_standard") + var result: Dictionary = got[0][1] + assert_that(bool(result.get("success", false))).is_true() + var outputs: Variant = result.get("outputsGranted", null) + assert_that(outputs is Array).is_true() + assert_that((outputs as Array).size()).is_equal(1) + + +func test_deny_response_emits_craft_result_with_reason_code() -> void: + # Arrange + var transport := MockHttpTransport.new() + transport.body_json = ( + '{"schemaVersion":1,"success":false,"reasonCode":"insufficient_materials",' + + '"inputsConsumed":[],"outputsGranted":[]}' + ) + var c := _make_client(transport) + var got: Array = [] + c.connect( + "craft_result_received", + func(_recipe_id: String, result: Dictionary) -> void: got.append(result) + ) + # Act + c.call("request_craft", "refine_scrap_standard") + # Assert + var result: Dictionary = got[0] + assert_that(bool(result.get("success", true))).is_false() + assert_that(str(result.get("reasonCode", ""))).is_equal("insufficient_materials") + + +func test_http_error_emits_craft_request_failed() -> void: + # Arrange + _failed_reason = "" + var transport := MockHttpTransport.new() + transport.response_code = 404 + var c := _make_client(transport) + c.connect("craft_request_failed", Callable(self, "_capture_failed")) + # Act + c.call("request_craft", "refine_scrap_standard") + # Assert + assert_that(_failed_reason).contains("404") diff --git a/client/test/craft_feedback_refresh_test.gd b/client/test/craft_feedback_refresh_test.gd new file mode 100644 index 0000000..0f88163 --- /dev/null +++ b/client/test/craft_feedback_refresh_test.gd @@ -0,0 +1,99 @@ +extends GdUnitTestSuite + +## NEO-74: successful craft triggers inventory + skill refresh; deny skips refresh. + +const CraftClient := preload("res://scripts/craft_client.gd") + + +class MockCraftTransport: + extends Node + signal request_completed( + result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray + ) + var body_json: String = '{"schemaVersion":1,"success":true,"outputsGranted":[]}' + + func request( + _url: String, + _custom_headers: PackedStringArray = PackedStringArray(), + _method: HTTPClient.Method = HTTPClient.METHOD_POST, + _request_data: String = "" + ) -> Error: + request_completed.emit( + HTTPRequest.RESULT_SUCCESS, + 200, + PackedStringArray(), + body_json.to_utf8_buffer() + ) + return OK + + +class SpySyncClient: + extends Node + var sync_calls: int = 0 + + func request_sync_from_server() -> void: + sync_calls += 1 + + +class CraftRefreshHarness: + extends Node + var inventory_sync_calls: int = 0 + var skill_sync_calls: int = 0 + var _inventory: SpySyncClient + var _skill: SpySyncClient + var _craft: Node + + func setup(parent: Node, transport: Node) -> void: + _inventory = SpySyncClient.new() + _skill = SpySyncClient.new() + _craft = CraftClient.new() + _craft.set("injected_http", transport) + parent.add_child(_craft) + parent.add_child(_inventory) + parent.add_child(_skill) + _craft.connect("craft_result_received", Callable(self, "_on_craft_result")) + + func _on_craft_result(_recipe_id: String, result: Dictionary) -> void: + if not bool(result.get("success", false)): + return + _inventory.request_sync_from_server() + _skill.request_sync_from_server() + + func post_craft(recipe_id: String) -> void: + _craft.call("request_craft", recipe_id) + + +func test_successful_craft_triggers_both_refreshes() -> void: + # Arrange + var transport := MockCraftTransport.new() + var harness := CraftRefreshHarness.new() + auto_free(transport) + auto_free(harness) + add_child(harness) + harness.setup(self, transport) + monitor_signals(harness._craft) + # Act + harness.post_craft("refine_scrap_standard") + # Assert + assert_signal(harness._craft).is_emitted("craft_result_received") + assert_that(harness._inventory.sync_calls).is_equal(1) + assert_that(harness._skill.sync_calls).is_equal(1) + + +func test_denied_craft_skips_refreshes() -> void: + # Arrange + var transport := MockCraftTransport.new() + transport.body_json = ( + '{"schemaVersion":1,"success":false,"reasonCode":"insufficient_materials",' + + '"inputsConsumed":[],"outputsGranted":[]}' + ) + var harness := CraftRefreshHarness.new() + auto_free(transport) + auto_free(harness) + add_child(harness) + harness.setup(self, transport) + # Act + harness.post_craft("refine_scrap_standard") + # Assert + assert_that(harness._inventory.sync_calls).is_equal(0) + assert_that(harness._skill.sync_calls).is_equal(0) diff --git a/client/test/recipe_definitions_client_test.gd b/client/test/recipe_definitions_client_test.gd new file mode 100644 index 0000000..111b409 --- /dev/null +++ b/client/test/recipe_definitions_client_test.gd @@ -0,0 +1,97 @@ +extends GdUnitTestSuite + +## NEO-74: `RecipeDefinitionsClient` GET parse + `recipes_ready` signal. + +const RecipeDefinitionsClient := preload("res://scripts/recipe_definitions_client.gd") + +var _recipes_capture: Array = [] + + +func _capture_recipes(recipes: Array) -> void: + _recipes_capture = recipes + + +class MockHttpTransport: + extends Node + signal request_completed( + result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray + ) + var last_url: String = "" + var response_code: int = 200 + var body_json: String = "" + + func request( + url: String, + _custom_headers: PackedStringArray = PackedStringArray(), + _method: HTTPClient.Method = HTTPClient.METHOD_GET, + _request_data: String = "" + ) -> Error: + last_url = url + request_completed.emit( + HTTPRequest.RESULT_SUCCESS, + response_code, + PackedStringArray(), + body_json.to_utf8_buffer() + ) + return OK + + +static func _two_recipes_json() -> String: + return ( + '{"schemaVersion":1,"recipes":[' + + '{"id":"make_armor_quick","displayName":"Quick Armor","recipeKind":"make",' + + '"requiredSkillId":"refine","inputs":[],"outputs":[]},' + + '{"id":"refine_scrap_standard","displayName":"Refine Scrap (Standard)",' + + '"recipeKind":"process","requiredSkillId":"refine",' + + '"inputs":[{"itemId":"scrap_metal_bulk","quantity":5}],' + + '"outputs":[{"itemId":"refined_plate_stock","quantity":1}]}' + + "]}" + ) + + +func _make_client(transport: Node) -> Node: + var c: Node = RecipeDefinitionsClient.new() + c.set("injected_http", transport) + auto_free(transport) + auto_free(c) + add_child(c) + return c + + +func test_parse_recipes_json_reads_array() -> void: + # Arrange + var json := _two_recipes_json() + # Act + var recipes: Variant = RecipeDefinitionsClient.parse_recipes_json(json) + # Assert + assert_that(recipes is Array).is_true() + assert_that((recipes as Array).size()).is_equal(2) + + +func test_request_sync_emits_recipes_ready_in_order() -> void: + # Arrange + _recipes_capture = [] + var transport := MockHttpTransport.new() + transport.body_json = _two_recipes_json() + var c := _make_client(transport) + c.connect("recipes_ready", Callable(self, "_capture_recipes")) + monitor_signals(c) + # Act + c.call("request_sync_from_server") + # Assert + assert_signal(c).is_emitted("recipes_ready") + assert_that(_recipes_capture.size()).is_equal(2) + assert_that(str(_recipes_capture[0].get("id", ""))).is_equal("make_armor_quick") + assert_that(transport.last_url).contains("/recipe-definitions") + + +func test_schema_mismatch_emits_recipes_sync_failed() -> void: + # Arrange + var transport := MockHttpTransport.new() + transport.body_json = '{"schemaVersion":99,"recipes":[]}' + var c := _make_client(transport) + monitor_signals(c) + # Act + c.call("request_sync_from_server") + # Assert + assert_signal(c).is_emitted("recipes_sync_failed") diff --git a/docs/decomposition/modules/E3_M2_RefinementAndRecipeExecution.md b/docs/decomposition/modules/E3_M2_RefinementAndRecipeExecution.md index 966e69e..f15bf8c 100644 --- a/docs/decomposition/modules/E3_M2_RefinementAndRecipeExecution.md +++ b/docs/decomposition/modules/E3_M2_RefinementAndRecipeExecution.md @@ -7,7 +7,7 @@ | **Module ID** | E3.M2 | | **Epic** | [Epic 3 — Crafting Economy](../epics/epic_03_crafting_economy.md) | | **Stage target** | Prototype | -| **Status** | Ready — Slice 3 server backlog [E3M2-prototype-backlog](../../plans/E3M2-prototype-backlog.md): E3M2-01 [NEO-65](https://linear.app/neon-sprawl/issue/NEO-65) through E3M2-07 [NEO-71](https://linear.app/neon-sprawl/issue/NEO-71) landed (see [dependency register](module_dependency_register.md)); client craft UI [NEO-74](https://linear.app/neon-sprawl/issue/NEO-74) is Slice 5 | +| **Status** | Ready — Slice 3 server backlog [E3M2-prototype-backlog](../../plans/E3M2-prototype-backlog.md): E3M2-01 [NEO-65](https://linear.app/neon-sprawl/issue/NEO-65) through E3M2-07 [NEO-71](https://linear.app/neon-sprawl/issue/NEO-71) landed; Slice 5 client craft UI [NEO-74](https://linear.app/neon-sprawl/issue/NEO-74) landed (capstone [NEO-75](https://linear.app/neon-sprawl/issue/NEO-75) remaining) | ## Implementation snapshot @@ -88,7 +88,7 @@ The **first shipped eight-recipe spine** under `content/recipes/*.json` is **fro Epic 3 **Slice 3** — gather → refine → item used in combat or quest; telemetry `item_crafted`, `craft_failed`, time-to-first-craft. -**Linear backlog (decomposed):** [E3M2-prototype-backlog.md](../../plans/E3M2-prototype-backlog.md) — **E3M2-01** [NEO-65](https://linear.app/neon-sprawl/issue/NEO-65) (content + CI) through **E3M2-07** [NEO-71](https://linear.app/neon-sprawl/issue/NEO-71) (telemetry hooks). **Client (Slice 5):** craft UI — [E3S5-client-prototype-backlog.md](../../plans/E3S5-client-prototype-backlog.md) **E3S5-03** [NEO-74](https://linear.app/neon-sprawl/issue/NEO-74). +**Linear backlog (decomposed):** [E3M2-prototype-backlog.md](../../plans/E3M2-prototype-backlog.md) — **E3M2-01** [NEO-65](https://linear.app/neon-sprawl/issue/NEO-65) (content + CI) through **E3M2-07** [NEO-71](https://linear.app/neon-sprawl/issue/NEO-71) (telemetry hooks). **Client (Slice 5):** craft UI — [E3S5-client-prototype-backlog.md](../../plans/E3S5-client-prototype-backlog.md) **E3S5-03** [NEO-74](https://linear.app/neon-sprawl/issue/NEO-74) (**landed** — `recipe_definitions_client.gd`, `craft_client.gd`, `craft_recipe_panel.gd`, [`NEO-74` manual QA](../../manual-qa/NEO-74.md)). ## Risks and telemetry diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md index 1d4fd90..f56ec69 100644 --- a/docs/decomposition/modules/documentation_and_implementation_alignment.md +++ b/docs/decomposition/modules/documentation_and_implementation_alignment.md @@ -53,9 +53,9 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not | E2.M1 | In Progress | **NEO-33 landed:** frozen prototype trio **`salvage`** / **`refine`** / **`intrusion`** in [`content/skills/prototype_skills.json`](../../../content/skills/prototype_skills.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) enforce schema, duplicate `id`, exact trio ids, and category coverage; designer note on **`allowedXpSourceKinds`** in [E2_M1](E2_M1_SkillDefinitionRegistry.md) + [`content/README.md`](../../../content/README.md). **NEO-34 landed:** fail-fast server load of `content/skills/*.json` at startup — `server/NeonSprawl.Server/Game/Skills/` ([NEO-34](../../plans/NEO-34-implementation-plan.md)); config + discovery in [server README — Skill catalog](../../../server/README.md#skill-catalog-contentskills-neo-34). **NEO-35 landed:** `ISkillDefinitionRegistry` / `SkillDefinitionRegistry` + DI ([NEO-35](../../plans/NEO-35-implementation-plan.md)). **NEO-36 landed:** versioned **`GET /game/world/skill-definitions`** ([NEO-36](../../plans/NEO-36-implementation-plan.md)) — `SkillDefinitionsWorldApi` + `SkillDefinitionsListDtos` in `server/NeonSprawl.Server/Game/Skills/`; Bruno `bruno/neon-sprawl-server/skill-definitions/`; manual QA [`NEO-36`](../../manual-qa/NEO-36.md). **E2.M2 consumer (NEO-38 landed):** grant path validates `skillId` + `sourceKind` vs catalog **`allowedXpSourceKinds`** on **`POST …/skill-progression`** ([NEO-38](../../plans/NEO-38-implementation-plan.md)); see [server README — Skill progression grant](../../../server/README.md#skill-progression-grant-neo-38) and [E2_M2](E2_M2_XpAwardAndLevelEngine.md). | [NEO-33](../../plans/NEO-33-implementation-plan.md), [NEO-34](../../plans/NEO-34-implementation-plan.md), [NEO-35](../../plans/NEO-35-implementation-plan.md), [NEO-36](../../plans/NEO-36-implementation-plan.md); [E2_M1](E2_M1_SkillDefinitionRegistry.md); [module_dependency_register.md](module_dependency_register.md) **E2.M1 note**; `server/NeonSprawl.Server/Game/Skills/`; [`docs/manual-qa/NEO-36.md`](../../manual-qa/NEO-36.md); [server README — Skill definitions (NEO-36)](../../../server/README.md#skill-definitions-neo-36) | | E2.M3 | In Progress | **NEO-45 landed:** prototype **`salvage`** mastery catalog + CI gates (see [NEO-45 plan](../../plans/NEO-45-implementation-plan.md)). **NEO-46 landed:** fail-fast server load under `server/NeonSprawl.Server/Game/Mastery/` — `MasteryCatalogLoader`, `IMasteryCatalogRegistry`, cross-check vs `ISkillDefinitionRegistry`, Slice 4 + **`tierIndex`** 1..N gate; see [NEO-46 plan](../../plans/NEO-46-implementation-plan.md). **NEO-47 landed:** `PerkUnlockEngine`, `IPlayerPerkStateStore` + **`V004`**, level-up hook in skill XP grants; see [NEO-47 plan](../../plans/NEO-47-implementation-plan.md). **NEO-49 landed:** comment-only **`perk_unlock`** telemetry hook site in [`PerkUnlockEngine.TryUnlockPerks`](../../../server/NeonSprawl.Server/Game/Mastery/PerkUnlockEngine.cs) ([NEO-49 plan](../../plans/NEO-49-implementation-plan.md), [`NEO-49` manual QA](../../manual-qa/NEO-49.md)); [server README — Perk unlock telemetry (NEO-49)](../../../server/README.md#perk-unlock-engine-and-telemetry-hooks-neo-47-neo-49). **NEO-48 landed:** **`GET`/`POST /game/players/{id}/perk-state`** — `PerkStateApi` + DTOs in `Game/Mastery/` ([NEO-48](../../plans/NEO-48-implementation-plan.md), [`NEO-48` manual QA](../../manual-qa/NEO-48.md)); [server README — Perk state (NEO-48)](../../../server/README.md#perk-state-neo-48); Bruno `bruno/neon-sprawl-server/perk-state/`. | [NEO-45](../../plans/NEO-45-implementation-plan.md), [NEO-46](../../plans/NEO-46-implementation-plan.md), [NEO-47](../../plans/NEO-47-implementation-plan.md), [NEO-48](../../plans/NEO-48-implementation-plan.md), [NEO-49](../../plans/NEO-49-implementation-plan.md), [E2M3-pre-production-backlog](../../plans/E2M3-pre-production-backlog.md), [E2_M3](E2_M3_MasteryAndPerkUnlocks.md) | | E2.M2 | In Progress | **NEO-37 landed:** versioned **`GET /game/players/{id}/skill-progression`** ([NEO-37](../../plans/NEO-37-implementation-plan.md)) — read model for every registered skill; known-player gate via `IPositionStateStore`; [server README — Skill progression snapshot (NEO-37)](../../../server/README.md#skill-progression-snapshot-neo-37); manual QA [`NEO-37`](../../manual-qa/NEO-37.md). **NEO-38 landed:** **`POST`** same path — grant apply, persistence (`IPlayerSkillProgressionStore`, `V003` migration), structured denies + **`levelUps`** ([NEO-38](../../plans/NEO-38-implementation-plan.md)); manual QA [`NEO-38`](../../manual-qa/NEO-38.md); Bruno `bruno/neon-sprawl-server/skill-progression/`; [server README — Skill progression grant (NEO-38)](../../../server/README.md#skill-progression-grant-neo-38). **NEO-39 landed:** data-driven `LevelCurve` content + schema + CI validation (`*_level_curve.json`) with fail-fast startup schema checks; progression GET/POST level resolution now uses `ISkillLevelCurve` content-backed thresholds ([NEO-39](../../plans/NEO-39-implementation-plan.md)); manual QA [`NEO-39`](../../manual-qa/NEO-39.md). **NEO-40 landed:** comment-only telemetry hook sites in [`SkillProgressionSnapshotApi.cs`](../../../server/NeonSprawl.Server/Game/Skills/SkillProgressionSnapshotApi.cs) on **`POST …/skill-progression`** for future **`xp_grant`** / **`level_up`** ([NEO-40](../../plans/NEO-40-implementation-plan.md), [`NEO-40` manual QA](../../manual-qa/NEO-40.md)). **NEO-41 landed:** gather prototype — **`POST …/interact`** with **`kind: resource_node`** grants **`salvage`** + **`activity`** (10 XP) via [`SkillProgressionGrantOperations`](../../../server/NeonSprawl.Server/Game/Skills/SkillProgressionGrantOperations.cs) ([NEO-41](../../plans/NEO-41-implementation-plan.md), [`NEO-41` manual QA](../../manual-qa/NEO-41.md)); [server README — Interaction](../../../server/README.md#interaction-neo-9). **NEO-42 landed (prep):** **`RefineActivitySkillXpGrant`** / **`RefineSkillXpConstants`** + deny-registry factory + tests — same NEO-38 grant stack; **E3.M2** must invoke on craft/refine success ([NEO-42](../../plans/NEO-42-implementation-plan.md), [`NEO-42` manual QA](../../manual-qa/NEO-42.md)); [server README — Craft / refine hook (NEO-42)](../../../server/README.md#craft--refine-hook--skill-xp-neo-42). **NEO-43 landed (prep):** **`MissionRewardSkillXpGrant`** / **`MissionRewardSkillXpConstants`** + deny-registry factory + tests — same NEO-38 grant stack with fixed **`sourceKind: mission_reward`**; **E7.M2** must invoke from quest hand-in ([NEO-43](../../plans/NEO-43-implementation-plan.md), [`NEO-43` manual QA](../../manual-qa/NEO-43.md)); [server README — Mission / quest reward (NEO-43)](../../../server/README.md#mission--quest-reward--skill-xp-neo-43). **Slice 3 still open:** [NEO-44](https://linear.app/neon-sprawl/issue/NEO-44). See [epic_02 — E2.M2 + Slice 3](../epics/epic_02_skills_and_progression.md). | [NEO-37](../../plans/NEO-37-implementation-plan.md), [NEO-38](../../plans/NEO-38-implementation-plan.md), [NEO-39](../../plans/NEO-39-implementation-plan.md), [NEO-40](../../plans/NEO-40-implementation-plan.md), [NEO-41](../../plans/NEO-41-implementation-plan.md), [NEO-42](../../plans/NEO-42-implementation-plan.md), [NEO-43](../../plans/NEO-43-implementation-plan.md), [E2_M2](E2_M2_XpAwardAndLevelEngine.md); label **`E2.M2`** on NEO-37–NEO-41, NEO-42–NEO-44 | -| E3.M1 | Ready | **NEO-41 landed (prototype, superseded on interact by NEO-63):** inline XP on **`resource_node`** interact. **NEO-57–NEO-61** — content, registry, HTTP defs, depletion store (see prior alignment). **NEO-62 landed:** **`GatherOperations`** + **`GatherResult`**. **NEO-63 landed:** **`POST …/interact`** **`resource_node`** → gather engine; **four** **`PrototypeInteractableRegistry`** anchors; Bruno gather → inventory + depletion deny ([NEO-63](../../plans/NEO-63-implementation-plan.md), [`NEO-63` manual QA](../../manual-qa/NEO-63.md)); [server README — Gather via interact (NEO-63)](../../../server/README.md#gather-via-interact-neo-63). **NEO-64 landed:** comment-only **`resource_gathered`** / **`gather_node_depleted`** hook sites in **`GatherOperations.TryGather`** ([NEO-64](../../plans/NEO-64-implementation-plan.md), [`NEO-64` manual QA](../../manual-qa/NEO-64.md)); Epic 3 Slice 2 backlog **E3M1-01**–**E3M1-08** complete. **NEO-73 landed:** client gather feedback — **`skill_progression_client.gd`**, **`prototype_interactable_picker.gd`** (nearest in-range **R**), **`GatherFeedbackLabel`** + **`SkillProgressionLabel`**, auto inventory/skill refresh after successful gather ([NEO-73](../../plans/NEO-73-implementation-plan.md), [`NEO-73` manual QA](../../manual-qa/NEO-73.md)); `client/README.md` gather section. **Slice 5 client (remaining):** craft UI [NEO-74](https://linear.app/neon-sprawl/issue/NEO-74), capstone [NEO-75](https://linear.app/neon-sprawl/issue/NEO-75) — [E3S5 backlog](../../plans/E3S5-client-prototype-backlog.md). | [NEO-41](../../plans/NEO-41-implementation-plan.md) … [NEO-64](../../plans/NEO-64-implementation-plan.md), [NEO-73](../../plans/NEO-73-implementation-plan.md), [E3S5-client-prototype-backlog](../../plans/E3S5-client-prototype-backlog.md), [E3_M1](E3_M1_ResourceNodeAndGatherLoop.md); `server/NeonSprawl.Server/Game/Gathering/`, `Game/Interaction/`, `client/scripts/skill_progression_client.gd`, `client/scripts/prototype_interactable_picker.gd` | -| E3.M3 | In Progress | **NEO-50 landed:** frozen prototype six-item catalog in [`content/items/prototype_items.json`](../../../content/items/prototype_items.json); [`item-def.schema.json`](../../../content/schemas/item-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py). **NEO-51 landed:** fail-fast server load of `content/items/*_items.json` at startup — `server/NeonSprawl.Server/Game/Items/` ([NEO-51](../../plans/NEO-51-implementation-plan.md)); [server README — Item catalog](../../../server/README.md#item-catalog-contentitems-neo-51). **NEO-52 landed:** injectable **`IItemDefinitionRegistry`** + lookup tests ([NEO-52](../../plans/NEO-52-implementation-plan.md)). **NEO-53 landed:** **`GET /game/world/item-definitions`** — `ItemDefinitionsWorldApi` + DTOs in `Game/Items/` ([NEO-53](../../plans/NEO-53-implementation-plan.md), [`NEO-53` manual QA](../../manual-qa/NEO-53.md)); [server README — Item definitions (NEO-53)](../../../server/README.md#item-definitions-neo-53); Bruno `bruno/neon-sprawl-server/item-definitions/`. **NEO-54 landed:** **`IPlayerInventoryStore`** + **`PlayerInventoryOperations`** — 24 bag + 1 equipment slots, stack rules, `V005` migration ([NEO-54](../../plans/NEO-54-implementation-plan.md)). **NEO-55 landed:** **`GET`/`POST /game/players/{id}/inventory`** — `PlayerInventoryApi` + DTOs in `Game/Items/` ([NEO-55](../../plans/NEO-55-implementation-plan.md)); [server README — Player inventory (NEO-54 store, NEO-55 HTTP)](../../../server/README.md#player-inventory-neo-54-store-neo-55-http); Bruno `bruno/neon-sprawl-server/inventory/`. **NEO-56 landed:** comment-only **`item_created`** / **`inventory_transfer_denied`** telemetry hook sites in [`PlayerInventoryOperations`](../../../server/NeonSprawl.Server/Game/Items/PlayerInventoryOperations.cs) ([NEO-56](../../plans/NEO-56-implementation-plan.md), [`NEO-56` manual QA](../../manual-qa/NEO-56.md)); no E9.M1 ingest. **NEO-72 landed:** client inventory HUD — **`inventory_client.gd`**, **`item_definitions_client.gd`**, **`InventoryLabel`**, boot hydrate + **`I`** refresh ([NEO-72](../../plans/NEO-72-implementation-plan.md), [`NEO-72` manual QA](../../manual-qa/NEO-72.md)); `client/README.md` inventory HUD section. **Slice 5 client (remaining):** craft UI [NEO-74](https://linear.app/neon-sprawl/issue/NEO-74), capstone [NEO-75](https://linear.app/neon-sprawl/issue/NEO-75) — [E3S5 backlog](../../plans/E3S5-client-prototype-backlog.md). | [NEO-50](../../plans/NEO-50-implementation-plan.md), [NEO-51](../../plans/NEO-51-implementation-plan.md), [NEO-52](../../plans/NEO-52-implementation-plan.md), [NEO-53](../../plans/NEO-53-implementation-plan.md), [NEO-54](../../plans/NEO-54-implementation-plan.md), [NEO-55](../../plans/NEO-55-implementation-plan.md), [NEO-56](../../plans/NEO-56-implementation-plan.md), [NEO-72](../../plans/NEO-72-implementation-plan.md), [E3S5-client-prototype-backlog](../../plans/E3S5-client-prototype-backlog.md), [E3M3-prototype-backlog](../../plans/E3M3-prototype-backlog.md), [E3_M3](E3_M3_ItemizationAndInventorySchema.md) | -| E3.M2 | Ready | **NEO-65 landed:** frozen prototype eight-recipe catalog in [`content/recipes/prototype_recipes.json`](../../../content/recipes/prototype_recipes.json); [`recipe-def.schema.json`](../../../content/schemas/recipe-def.schema.json) + [`recipe-io-row.schema.json`](../../../content/schemas/recipe-io-row.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) Slice 3 gates ([NEO-65](../../plans/NEO-65-implementation-plan.md)). **NEO-66 landed:** fail-fast server load of `content/recipes/*_recipes.json` at startup — `server/NeonSprawl.Server/Game/Crafting/` ([NEO-66](../../plans/NEO-66-implementation-plan.md)); [server README — Recipe catalog](../../../server/README.md#recipe-catalog-contentrecipes-neo-66). **NEO-67 landed:** injectable **`IRecipeDefinitionRegistry`** + lookup tests ([NEO-67](../../plans/NEO-67-implementation-plan.md)). **NEO-68 landed:** **`GET /game/world/recipe-definitions`** — `RecipeDefinitionsWorldApi` + DTOs in `Game/Crafting/` ([NEO-68](../../plans/NEO-68-implementation-plan.md), [`NEO-68` manual QA](../../manual-qa/NEO-68.md)); [server README — Recipe definitions (NEO-68)](../../../server/README.md#recipe-definitions-neo-68); Bruno `bruno/neon-sprawl-server/recipe-definitions/`. **NEO-69 landed:** **`CraftOperations.TryCraft`** + **`CraftResult`** — pre-flight inputs/outputs, inventory mutation, refine XP ([NEO-69](../../plans/NEO-69-implementation-plan.md)); [server README — Craft engine (NEO-69)](../../../server/README.md#craft-engine-neo-69-and-craft-http-neo-70). **NEO-70 landed:** **`POST /game/players/{id}/craft`** — `PlayerCraftApi` + wire **`CraftResponse`**; Bruno gather→refine→make spine ([NEO-70](../../plans/NEO-70-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/craft/`. **NEO-71 landed:** comment-only **`item_crafted`** / **`craft_failed`** telemetry hook sites in **`CraftOperations.TryCraft`** ([NEO-71](../../plans/NEO-71-implementation-plan.md)); [server README — Craft telemetry hooks (NEO-71)](../../../server/README.md#craft-telemetry-hooks-neo-71). **NEO-42 landed (prep):** **`RefineActivitySkillXpGrant`** / **`RefineSkillXpConstants`** — wired from craft engine success path. Epic 3 Slice 3 server backlog **E3M2-01**–**E3M2-07** complete. Upstream: E3.M1 **Ready**, E3.M3 inventory landed. **Slice 5 client (planned):** craft UI [NEO-74](https://linear.app/neon-sprawl/issue/NEO-74); capstone playable loop [NEO-75](https://linear.app/neon-sprawl/issue/NEO-75) — [E3S5 backlog](../../plans/E3S5-client-prototype-backlog.md). | [NEO-42](../../plans/NEO-42-implementation-plan.md), [NEO-65](../../plans/NEO-65-implementation-plan.md), [NEO-66](../../plans/NEO-66-implementation-plan.md), [NEO-67](../../plans/NEO-67-implementation-plan.md), [NEO-68](../../plans/NEO-68-implementation-plan.md), [NEO-69](../../plans/NEO-69-implementation-plan.md), [NEO-70](../../plans/NEO-70-implementation-plan.md), [NEO-71](../../plans/NEO-71-implementation-plan.md) … [NEO-75](https://linear.app/neon-sprawl/issue/NEO-75), [E3M2-prototype-backlog](../../plans/E3M2-prototype-backlog.md), [E3S5-client-prototype-backlog](../../plans/E3S5-client-prototype-backlog.md), [E3_M2](E3_M2_RefinementAndRecipeExecution.md) | +| E3.M1 | Ready | **NEO-41 landed (prototype, superseded on interact by NEO-63):** inline XP on **`resource_node`** interact. **NEO-57–NEO-61** — content, registry, HTTP defs, depletion store (see prior alignment). **NEO-62 landed:** **`GatherOperations`** + **`GatherResult`**. **NEO-63 landed:** **`POST …/interact`** **`resource_node`** → gather engine; **four** **`PrototypeInteractableRegistry`** anchors; Bruno gather → inventory + depletion deny ([NEO-63](../../plans/NEO-63-implementation-plan.md), [`NEO-63` manual QA](../../manual-qa/NEO-63.md)); [server README — Gather via interact (NEO-63)](../../../server/README.md#gather-via-interact-neo-63). **NEO-64 landed:** comment-only **`resource_gathered`** / **`gather_node_depleted`** hook sites in **`GatherOperations.TryGather`** ([NEO-64](../../plans/NEO-64-implementation-plan.md), [`NEO-64` manual QA](../../manual-qa/NEO-64.md)); Epic 3 Slice 2 backlog **E3M1-01**–**E3M1-08** complete. **NEO-73 landed:** client gather feedback — **`skill_progression_client.gd`**, **`prototype_interactable_picker.gd`** (nearest in-range **R**), **`GatherFeedbackLabel`** + **`SkillProgressionLabel`**, auto inventory/skill refresh after successful gather ([NEO-73](../../plans/NEO-73-implementation-plan.md), [`NEO-73` manual QA](../../manual-qa/NEO-73.md)); `client/README.md` gather section. **Slice 5 client (remaining):** capstone [NEO-75](https://linear.app/neon-sprawl/issue/NEO-75) — [E3S5 backlog](../../plans/E3S5-client-prototype-backlog.md). | [NEO-41](../../plans/NEO-41-implementation-plan.md) … [NEO-64](../../plans/NEO-64-implementation-plan.md), [NEO-73](../../plans/NEO-73-implementation-plan.md), [E3S5-client-prototype-backlog](../../plans/E3S5-client-prototype-backlog.md), [E3_M1](E3_M1_ResourceNodeAndGatherLoop.md); `server/NeonSprawl.Server/Game/Gathering/`, `Game/Interaction/`, `client/scripts/skill_progression_client.gd`, `client/scripts/prototype_interactable_picker.gd` | +| E3.M3 | In Progress | **NEO-50 landed:** frozen prototype six-item catalog in [`content/items/prototype_items.json`](../../../content/items/prototype_items.json); [`item-def.schema.json`](../../../content/schemas/item-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py). **NEO-51 landed:** fail-fast server load of `content/items/*_items.json` at startup — `server/NeonSprawl.Server/Game/Items/` ([NEO-51](../../plans/NEO-51-implementation-plan.md)); [server README — Item catalog](../../../server/README.md#item-catalog-contentitems-neo-51). **NEO-52 landed:** injectable **`IItemDefinitionRegistry`** + lookup tests ([NEO-52](../../plans/NEO-52-implementation-plan.md)). **NEO-53 landed:** **`GET /game/world/item-definitions`** — `ItemDefinitionsWorldApi` + DTOs in `Game/Items/` ([NEO-53](../../plans/NEO-53-implementation-plan.md), [`NEO-53` manual QA](../../manual-qa/NEO-53.md)); [server README — Item definitions (NEO-53)](../../../server/README.md#item-definitions-neo-53); Bruno `bruno/neon-sprawl-server/item-definitions/`. **NEO-54 landed:** **`IPlayerInventoryStore`** + **`PlayerInventoryOperations`** — 24 bag + 1 equipment slots, stack rules, `V005` migration ([NEO-54](../../plans/NEO-54-implementation-plan.md)). **NEO-55 landed:** **`GET`/`POST /game/players/{id}/inventory`** — `PlayerInventoryApi` + DTOs in `Game/Items/` ([NEO-55](../../plans/NEO-55-implementation-plan.md)); [server README — Player inventory (NEO-54 store, NEO-55 HTTP)](../../../server/README.md#player-inventory-neo-54-store-neo-55-http); Bruno `bruno/neon-sprawl-server/inventory/`. **NEO-56 landed:** comment-only **`item_created`** / **`inventory_transfer_denied`** telemetry hook sites in [`PlayerInventoryOperations`](../../../server/NeonSprawl.Server/Game/Items/PlayerInventoryOperations.cs) ([NEO-56](../../plans/NEO-56-implementation-plan.md), [`NEO-56` manual QA](../../manual-qa/NEO-56.md)); no E9.M1 ingest. **NEO-72 landed:** client inventory HUD — **`inventory_client.gd`**, **`item_definitions_client.gd`**, **`InventoryLabel`**, boot hydrate + **`I`** refresh ([NEO-72](../../plans/NEO-72-implementation-plan.md), [`NEO-72` manual QA](../../manual-qa/NEO-72.md)); `client/README.md` inventory HUD section. **Slice 5 client (remaining):** capstone [NEO-75](https://linear.app/neon-sprawl/issue/NEO-75) — [E3S5 backlog](../../plans/E3S5-client-prototype-backlog.md). | [NEO-50](../../plans/NEO-50-implementation-plan.md), [NEO-51](../../plans/NEO-51-implementation-plan.md), [NEO-52](../../plans/NEO-52-implementation-plan.md), [NEO-53](../../plans/NEO-53-implementation-plan.md), [NEO-54](../../plans/NEO-54-implementation-plan.md), [NEO-55](../../plans/NEO-55-implementation-plan.md), [NEO-56](../../plans/NEO-56-implementation-plan.md), [NEO-72](../../plans/NEO-72-implementation-plan.md), [E3S5-client-prototype-backlog](../../plans/E3S5-client-prototype-backlog.md), [E3M3-prototype-backlog](../../plans/E3M3-prototype-backlog.md), [E3_M3](E3_M3_ItemizationAndInventorySchema.md) | +| E3.M2 | Ready | **NEO-65 landed:** frozen prototype eight-recipe catalog in [`content/recipes/prototype_recipes.json`](../../../content/recipes/prototype_recipes.json); [`recipe-def.schema.json`](../../../content/schemas/recipe-def.schema.json) + [`recipe-io-row.schema.json`](../../../content/schemas/recipe-io-row.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) Slice 3 gates ([NEO-65](../../plans/NEO-65-implementation-plan.md)). **NEO-66 landed:** fail-fast server load of `content/recipes/*_recipes.json` at startup — `server/NeonSprawl.Server/Game/Crafting/` ([NEO-66](../../plans/NEO-66-implementation-plan.md)); [server README — Recipe catalog](../../../server/README.md#recipe-catalog-contentrecipes-neo-66). **NEO-67 landed:** injectable **`IRecipeDefinitionRegistry`** + lookup tests ([NEO-67](../../plans/NEO-67-implementation-plan.md)). **NEO-68 landed:** **`GET /game/world/recipe-definitions`** — `RecipeDefinitionsWorldApi` + DTOs in `Game/Crafting/` ([NEO-68](../../plans/NEO-68-implementation-plan.md), [`NEO-68` manual QA](../../manual-qa/NEO-68.md)); [server README — Recipe definitions (NEO-68)](../../../server/README.md#recipe-definitions-neo-68); Bruno `bruno/neon-sprawl-server/recipe-definitions/`. **NEO-69 landed:** **`CraftOperations.TryCraft`** + **`CraftResult`** — pre-flight inputs/outputs, inventory mutation, refine XP ([NEO-69](../../plans/NEO-69-implementation-plan.md)); [server README — Craft engine (NEO-69)](../../../server/README.md#craft-engine-neo-69-and-craft-http-neo-70). **NEO-70 landed:** **`POST /game/players/{id}/craft`** — `PlayerCraftApi` + wire **`CraftResponse`**; Bruno gather→refine→make spine ([NEO-70](../../plans/NEO-70-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/craft/`. **NEO-71 landed:** comment-only **`item_crafted`** / **`craft_failed`** telemetry hook sites in **`CraftOperations.TryCraft`** ([NEO-71](../../plans/NEO-71-implementation-plan.md)); [server README — Craft telemetry hooks (NEO-71)](../../../server/README.md#craft-telemetry-hooks-neo-71). **NEO-42 landed (prep):** **`RefineActivitySkillXpGrant`** / **`RefineSkillXpConstants`** — wired from craft engine success path. Epic 3 Slice 3 server backlog **E3M2-01**–**E3M2-07** complete. Upstream: E3.M1 **Ready**, E3.M3 inventory landed. **NEO-74 landed:** client craft UI — **`recipe_definitions_client.gd`**, **`craft_client.gd`**, **`craft_recipe_panel.gd`**, **`CraftFeedbackLabel`**, **refine** skill row ([NEO-74](../../plans/NEO-74-implementation-plan.md), [`NEO-74` manual QA](../../manual-qa/NEO-74.md)); `client/README.md` craft section. **Slice 5 client (remaining):** capstone playable loop [NEO-75](https://linear.app/neon-sprawl/issue/NEO-75) — [E3S5 backlog](../../plans/E3S5-client-prototype-backlog.md). | [NEO-42](../../plans/NEO-42-implementation-plan.md), [NEO-65](../../plans/NEO-65-implementation-plan.md), [NEO-66](../../plans/NEO-66-implementation-plan.md), [NEO-67](../../plans/NEO-67-implementation-plan.md), [NEO-68](../../plans/NEO-68-implementation-plan.md), [NEO-69](../../plans/NEO-69-implementation-plan.md), [NEO-70](../../plans/NEO-70-implementation-plan.md), [NEO-71](../../plans/NEO-71-implementation-plan.md), [NEO-74](../../plans/NEO-74-implementation-plan.md) … [NEO-75](https://linear.app/neon-sprawl/issue/NEO-75), [E3M2-prototype-backlog](../../plans/E3M2-prototype-backlog.md), [E3S5-client-prototype-backlog](../../plans/E3S5-client-prototype-backlog.md), [E3_M2](E3_M2_RefinementAndRecipeExecution.md) | --- diff --git a/docs/manual-qa/NEO-74.md b/docs/manual-qa/NEO-74.md new file mode 100644 index 0000000..974808e --- /dev/null +++ b/docs/manual-qa/NEO-74.md @@ -0,0 +1,30 @@ +# NEO-74 — Manual QA checklist + +| 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 | +| Plan | `docs/plans/NEO-74-implementation-plan.md` | +| Branch | `NEO-74-e3m2-client-craft-ui-recipe-list` | + +## Preconditions + +- Server running with default dev player (`dev-local-1`); craft HTTP landed (NEO-70); recipe defs GET landed (NEO-68); inventory HUD (NEO-72) and gather/skill HUD (NEO-73) working. + +## Checklist + +1. Start server: `cd server/NeonSprawl.Server && dotnet run`. +2. Run Godot main scene (**F5**). Confirm **`SkillProgressionLabel`** shows **`salvage`** and **`refine`** rows after boot. +3. Scroll the recipe panel below skills — **eight** prototype recipes with **Craft** buttons. +4. On a fresh player (empty bag), click **Craft** on **`refine_scrap_standard`**. **`CraftFeedbackLabel`** shows **`Craft: denied — insufficient_materials`**. Press **I** — inventory unchanged. +5. Gather **≥5** **`scrap_metal_bulk`** (see [NEO-73 manual QA](NEO-73.md) **R** at resource nodes). +6. Click **Craft** on **`refine_scrap_standard`**. **`CraftFeedbackLabel`** shows **`Craft: +1 …`** (refined plate output). **`InventoryLabel`** updates without **I**. **`refine`** xp increases on **`SkillProgressionLabel`**. +7. Gather **≥1** more scrap if needed; ensure **≥2** **`refined_plate_stock`** and **≥1** **`scrap_metal_bulk`** in bag (craft **`refine_scrap_standard`** again or gather). +8. Click **Craft** on **`make_field_stim_mk0`**. Success feedback shows **`field_stim_mk0`** output; bag lists consumable; **`refine`** xp increases again. +9. Optional deny spot-check: with a full bag, attempt a craft whose output cannot fit — expect **`Craft: denied — inventory_full`**. + +## Notes + +- Craft quantity is fixed at **1** per button (no spinner in this story). +- Success copy comes from POST **`outputsGranted`**; inventory and skill rows refresh via GET after success only. diff --git a/docs/plans/E3S5-client-prototype-backlog.md b/docs/plans/E3S5-client-prototype-backlog.md index 62d9717..751206e 100644 --- a/docs/plans/E3S5-client-prototype-backlog.md +++ b/docs/plans/E3S5-client-prototype-backlog.md @@ -127,9 +127,9 @@ Working backlog for **Epic 3 — Slice 5** ([client economy integration](../deco **Acceptance criteria** -- [ ] Player can **`refine_scrap_standard`** and **`make_field_stim_mk0`** from UI when materials allow. -- [ ] Denied craft shows **`reasonCode`** on HUD; inventory unchanged (verify via GET). -- [ ] **Refine** skill XP visible and increases on successful craft. +- [x] Player can **`refine_scrap_standard`** and **`make_field_stim_mk0`** from UI when materials allow. +- [x] Denied craft shows **`reasonCode`** on HUD; inventory unchanged (verify via GET). +- [x] **Refine** skill XP visible and increases on successful craft. --- diff --git a/docs/plans/NEO-74-implementation-plan.md b/docs/plans/NEO-74-implementation-plan.md index ea87f0e..beb15f4 100644 --- a/docs/plans/NEO-74-implementation-plan.md +++ b/docs/plans/NEO-74-implementation-plan.md @@ -48,9 +48,16 @@ No other blocking decisions — list **all eight** prototype recipes per [E3S5 k ## 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. +- [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 From 9b6abf093bccab1316a204c6c6b6d656118bb5a4 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 24 May 2026 19:57:28 -0400 Subject: [PATCH 3/9] NEO-74: add code review for client craft UI branch. --- docs/reviews/2026-05-24-NEO-74.md | 56 +++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 docs/reviews/2026-05-24-NEO-74.md diff --git a/docs/reviews/2026-05-24-NEO-74.md b/docs/reviews/2026-05-24-NEO-74.md new file mode 100644 index 0000000..d22cb0b --- /dev/null +++ b/docs/reviews/2026-05-24-NEO-74.md @@ -0,0 +1,56 @@ +# Code review — NEO-74 client craft UI + recipe list + +**Date:** 2026-05-24 +**Scope:** Branch `NEO-74-e3m2-client-craft-ui-recipe-list` · commits `dff58ad`–`c16b610` vs `origin/main` +**Base:** `origin/main` + +## Verdict + +**Approve with nits** — implementation matches the adopted plan and E3S5-03 acceptance criteria; all nine new GdUnit cases pass headless. Remaining items are non-blocking (test `.uid` companions, optional recipe-sync failure HUD). + +## Summary + +The branch adds **`recipe_definitions_client.gd`**, **`craft_client.gd`**, and **`craft_recipe_panel.gd`**, wires them from **`main.gd`** with **`CraftFeedbackLabel`**, a scrollable eight-recipe panel, **refine** row on **`SkillProgressionLabel`**, and post-success inventory + skill refresh (deny leaves bag unchanged). Three GdUnit suites cover POST payload/parse, recipe GET parse, and refresh policy. Manual QA, plan reconciliation, E3.M2 module note, alignment register, and E3S5 backlog checkboxes are updated. Design follows NEO-72/73/31 patterns (thin HTTP clients, `_busy` guard, injectable transport, authority config copy, dedicated feedback label). HTTP handlers correctly use **`PackedStringArray`** headers (NEO-72 lesson applied). + +## Documentation checked + +| Document | Result | +|----------|--------| +| [`docs/plans/NEO-74-implementation-plan.md`](../plans/NEO-74-implementation-plan.md) | **Matches** — kickoff decisions (POST **`outputsGranted`** copy, dedicated **`CraftFeedbackLabel`**, left scroll panel, qty **1**, deny refresh policy) reflected; acceptance checklist and reconciliation complete. | +| [`docs/plans/E3S5-client-prototype-backlog.md`](../plans/E3S5-client-prototype-backlog.md) · **E3S5-03** | **Matches** — acceptance criteria checked; scope/out-of-scope honored; does not claim capstone (NEO-75) complete. | +| [`docs/decomposition/modules/E3_M2_RefinementAndRecipeExecution.md`](../decomposition/modules/E3_M2_RefinementAndRecipeExecution.md) | **Matches** — NEO-74 client slice **landed** note added. | +| [`docs/decomposition/modules/documentation_and_implementation_alignment.md`](../decomposition/modules/documentation_and_implementation_alignment.md) | **Matches** — E3.M2 row notes NEO-74 landed; NEO-75 cited as remaining Slice 5 client work. | +| [`docs/decomposition/modules/client_server_authority.md`](../decomposition/modules/client_server_authority.md) | **Matches** — client sends craft **intent** via POST; inventory/skill display refreshed from server GET after success. | +| [`docs/manual-qa/NEO-74.md`](../manual-qa/NEO-74.md) | **Matches** — deny/success/refine XP walkthrough; optional **`inventory_full`** spot-check. | +| [`client/README.md`](../../client/README.md) craft UI section | **Matches** — endpoints, HUD labels, refresh policy; automated test Scope lists NEO-74 suites. | +| Full-stack epic decomposition | **Matches** — E3S5-03 **client** counterpart for server NEO-68/NEO-70; Godot manual QA present. | + +## Blocking issues + +(none) + +## Suggestions + +1. **Wire `recipes_sync_failed` in `main.gd`** — `RecipeDefinitionsClient` emits **`recipes_sync_failed`** (tested in GdUnit) but **`main.gd`** never connects it. On boot failure the recipe panel stays empty with no HUD line (inventory/skill clients show errors). Connect to **`CraftFeedbackLabel`** or **`push_warning`** + a one-line error for parity with **`inventory_sync_failed`** / **`progression_sync_failed`**. + +2. **Add `.gd.uid` companions for new GdUnit test scripts** — `craft_client_test.gd`, `recipe_definitions_client_test.gd`, and `craft_feedback_refresh_test.gd` have no tracked **`*.gd.uid`** files (NEO-73 established this for new tests under `client/test/`). Generate in Godot and commit alongside the tests per [godot-client-script-organization](../../.cursor/rules/godot-client-script-organization.md). + +## Nits + +- Nit: **`craft_recipe_panel.gd`** declares **`@onready var _scroll`** but only uses **`_vbox`** — remove unused binding or use **`_scroll`** for max-height policy. +- Nit: **`craft_feedback_refresh_test.gd`** harness duplicates refresh logic from **`main.gd`** (intentional isolation) — acceptable; no **`main.gd`** integration test remains (same gap as NEO-73 gather refresh test). + +## Verification + +```bash +# Server (separate terminal) +cd server/NeonSprawl.Server && dotnet run + +# NEO-74 GdUnit suites (from client/) +godot --headless --path . -s res://addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode \ + -a res://test/craft_client_test.gd \ + -a res://test/recipe_definitions_client_test.gd \ + -a res://test/craft_feedback_refresh_test.gd +``` + +Manual: follow [`docs/manual-qa/NEO-74.md`](../manual-qa/NEO-74.md) — deny **`insufficient_materials`**, **`refine_scrap_standard`** success, **`make_field_stim_mk0`**, **`refine`** XP delta without pressing **I** on success. From 2fec42f8f6f030259e2c991cbda99d505e723ffe Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 24 May 2026 19:59:00 -0400 Subject: [PATCH 4/9] =?UTF-8?q?NEO-74:=20Address=20code=20review=20?= =?UTF-8?q?=E2=80=94=20recipe=20sync=20HUD=20and=20test=20uids.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire recipes_sync_failed to CraftFeedbackLabel, remove unused _scroll binding, add GdUnit .uid companions, extend recipe client HTTP error test, strike through review items. --- client/scripts/craft_recipe_panel.gd | 1 - client/scripts/main.gd | 12 ++++++++++++ client/test/craft_client_test.gd.uid | 1 + client/test/craft_feedback_refresh_test.gd.uid | 1 + client/test/recipe_definitions_client_test.gd | 13 +++++++++++++ client/test/recipe_definitions_client_test.gd.uid | 1 + docs/reviews/2026-05-24-NEO-74.md | 11 ++++++----- 7 files changed, 34 insertions(+), 6 deletions(-) create mode 100644 client/test/craft_client_test.gd.uid create mode 100644 client/test/craft_feedback_refresh_test.gd.uid create mode 100644 client/test/recipe_definitions_client_test.gd.uid diff --git a/client/scripts/craft_recipe_panel.gd b/client/scripts/craft_recipe_panel.gd index a9735f3..9c1d55e 100644 --- a/client/scripts/craft_recipe_panel.gd +++ b/client/scripts/craft_recipe_panel.gd @@ -4,7 +4,6 @@ extends Control signal craft_requested(recipe_id: String) -@onready var _scroll: ScrollContainer = $ScrollContainer @onready var _vbox: VBoxContainer = $ScrollContainer/VBoxContainer var _item_defs_client: Node = null diff --git a/client/scripts/main.gd b/client/scripts/main.gd index ba813ac..f014a7e 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -87,6 +87,7 @@ var _gather_pre_scrap_qty: int = 0 var _gather_pending_interactable_id: String = "" var _gather_awaiting_inventory_finalize: bool = false var _cached_recipe_rows: Array = [] +var _recipe_defs_error: String = "" @onready var _camera: Camera3D = $World/IsometricFollowCamera/Camera3D @onready var _world: Node3D = $World @@ -550,6 +551,8 @@ func _setup_craft_ui() -> void: _craft_recipe_panel.call("setup", _item_defs_client) if _recipe_defs_client.has_signal("recipes_ready"): _recipe_defs_client.connect("recipes_ready", Callable(self, "_on_recipes_ready")) + if _recipe_defs_client.has_signal("recipes_sync_failed"): + _recipe_defs_client.connect("recipes_sync_failed", Callable(self, "_on_recipes_sync_failed")) if _craft_recipe_panel.has_signal("craft_requested"): _craft_recipe_panel.connect("craft_requested", Callable(self, "_on_craft_requested")) if _craft_client.has_signal("craft_result_received"): @@ -562,10 +565,19 @@ func _setup_craft_ui() -> void: func _on_recipes_ready(recipes: Array) -> void: + if not _recipe_defs_error.is_empty(): + _render_craft_feedback_label() + _recipe_defs_error = "" _cached_recipe_rows = recipes.duplicate(true) _populate_craft_recipe_panel_if_ready() +func _on_recipes_sync_failed(reason: String) -> void: + _recipe_defs_error = reason + _cached_recipe_rows = [] + _render_craft_feedback_label("Recipes: error — %s" % reason) + + func _populate_craft_recipe_panel_if_ready() -> void: if _cached_recipe_rows.is_empty(): return diff --git a/client/test/craft_client_test.gd.uid b/client/test/craft_client_test.gd.uid new file mode 100644 index 0000000..905143b --- /dev/null +++ b/client/test/craft_client_test.gd.uid @@ -0,0 +1 @@ +uid://bneo74craftcltest diff --git a/client/test/craft_feedback_refresh_test.gd.uid b/client/test/craft_feedback_refresh_test.gd.uid new file mode 100644 index 0000000..257c016 --- /dev/null +++ b/client/test/craft_feedback_refresh_test.gd.uid @@ -0,0 +1 @@ +uid://bneo74craftfbtest diff --git a/client/test/recipe_definitions_client_test.gd b/client/test/recipe_definitions_client_test.gd index 111b409..820fdfc 100644 --- a/client/test/recipe_definitions_client_test.gd +++ b/client/test/recipe_definitions_client_test.gd @@ -95,3 +95,16 @@ func test_schema_mismatch_emits_recipes_sync_failed() -> void: c.call("request_sync_from_server") # Assert assert_signal(c).is_emitted("recipes_sync_failed") + + +func test_http_error_emits_recipes_sync_failed_with_status() -> void: + # Arrange + var transport := MockHttpTransport.new() + transport.response_code = 503 + var c := _make_client(transport) + var failed_reason := "" + c.connect("recipes_sync_failed", func(reason: String) -> void: failed_reason = reason) + # Act + c.call("request_sync_from_server") + # Assert + assert_that(failed_reason).contains("503") diff --git a/client/test/recipe_definitions_client_test.gd.uid b/client/test/recipe_definitions_client_test.gd.uid new file mode 100644 index 0000000..48dc5b1 --- /dev/null +++ b/client/test/recipe_definitions_client_test.gd.uid @@ -0,0 +1 @@ +uid://bneo74recipedefst diff --git a/docs/reviews/2026-05-24-NEO-74.md b/docs/reviews/2026-05-24-NEO-74.md index d22cb0b..2bdc1f1 100644 --- a/docs/reviews/2026-05-24-NEO-74.md +++ b/docs/reviews/2026-05-24-NEO-74.md @@ -2,11 +2,12 @@ **Date:** 2026-05-24 **Scope:** Branch `NEO-74-e3m2-client-craft-ui-recipe-list` · commits `dff58ad`–`c16b610` vs `origin/main` -**Base:** `origin/main` +**Base:** `origin/main` +**Follow-up:** Review feedback addressed (recipe-sync failure HUD, test `.uid` companions, unused `_scroll` binding). ## Verdict -**Approve with nits** — implementation matches the adopted plan and E3S5-03 acceptance criteria; all nine new GdUnit cases pass headless. Remaining items are non-blocking (test `.uid` companions, optional recipe-sync failure HUD). +**Approve** — implementation matches the adopted plan and E3S5-03 acceptance criteria; GdUnit suites pass. Follow-up wired **`recipes_sync_failed`** HUD and added test **`.uid`** companions. ## Summary @@ -31,13 +32,13 @@ The branch adds **`recipe_definitions_client.gd`**, **`craft_client.gd`**, and * ## Suggestions -1. **Wire `recipes_sync_failed` in `main.gd`** — `RecipeDefinitionsClient` emits **`recipes_sync_failed`** (tested in GdUnit) but **`main.gd`** never connects it. On boot failure the recipe panel stays empty with no HUD line (inventory/skill clients show errors). Connect to **`CraftFeedbackLabel`** or **`push_warning`** + a one-line error for parity with **`inventory_sync_failed`** / **`progression_sync_failed`**. +1. ~~**Wire `recipes_sync_failed` in `main.gd`** — `RecipeDefinitionsClient` emits **`recipes_sync_failed`** (tested in GdUnit) but **`main.gd`** never connects it. On boot failure the recipe panel stays empty with no HUD line (inventory/skill clients show errors). Connect to **`CraftFeedbackLabel`** or **`push_warning`** + a one-line error for parity with **`inventory_sync_failed`** / **`progression_sync_failed`**.~~ **Done.** **`_on_recipes_sync_failed`** paints **`Recipes: error — …`** on **`CraftFeedbackLabel`**. -2. **Add `.gd.uid` companions for new GdUnit test scripts** — `craft_client_test.gd`, `recipe_definitions_client_test.gd`, and `craft_feedback_refresh_test.gd` have no tracked **`*.gd.uid`** files (NEO-73 established this for new tests under `client/test/`). Generate in Godot and commit alongside the tests per [godot-client-script-organization](../../.cursor/rules/godot-client-script-organization.md). +2. ~~**Add `.gd.uid` companions for new GdUnit test scripts** — `craft_client_test.gd`, `recipe_definitions_client_test.gd`, and `craft_feedback_refresh_test.gd` have no tracked **`*.gd.uid`** files (NEO-73 established this for new tests under `client/test/`). Generate in Godot and commit alongside the tests per [godot-client-script-organization](../../.cursor/rules/godot-client-script-organization.md).~~ **Done.** ## Nits -- Nit: **`craft_recipe_panel.gd`** declares **`@onready var _scroll`** but only uses **`_vbox`** — remove unused binding or use **`_scroll`** for max-height policy. +- ~~Nit: **`craft_recipe_panel.gd`** declares **`@onready var _scroll`** but only uses **`_vbox`** — remove unused binding or use **`_scroll`** for max-height policy.~~ **Done.** - Nit: **`craft_feedback_refresh_test.gd`** harness duplicates refresh logic from **`main.gd`** (intentional isolation) — acceptable; no **`main.gd`** integration test remains (same gap as NEO-73 gather refresh test). ## Verification From 1533687aa8123649c85c848bf4cb688d546100f8 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 24 May 2026 20:02:26 -0400 Subject: [PATCH 5/9] NEO-74: Fix gdlint class-definitions-order in craft panel. --- client/scripts/craft_recipe_panel.gd | 4 ++-- client/test/craft_client_test.gd | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/scripts/craft_recipe_panel.gd b/client/scripts/craft_recipe_panel.gd index 9c1d55e..8780163 100644 --- a/client/scripts/craft_recipe_panel.gd +++ b/client/scripts/craft_recipe_panel.gd @@ -4,11 +4,11 @@ extends Control signal craft_requested(recipe_id: String) -@onready var _vbox: VBoxContainer = $ScrollContainer/VBoxContainer - var _item_defs_client: Node = null var _craft_buttons: Array[Button] = [] +@onready var _vbox: VBoxContainer = $ScrollContainer/VBoxContainer + func setup(item_defs_client: Node) -> void: _item_defs_client = item_defs_client diff --git a/client/test/craft_client_test.gd b/client/test/craft_client_test.gd index 5213134..03a2794 100644 --- a/client/test/craft_client_test.gd +++ b/client/test/craft_client_test.gd @@ -1,6 +1,6 @@ extends GdUnitTestSuite -## NEO-74: `CraftClient` POST payload + success/deny parse. +## NEO-74: `CraftClient` POST payload + success/deny parse (craft panel wiring tested separately). const CraftClient := preload("res://scripts/craft_client.gd") From 501834279e216edbee4322309a1e679a2830cc87 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 24 May 2026 20:02:47 -0400 Subject: [PATCH 6/9] NEO-74: Apply gdformat to main and craft refresh test. --- client/scripts/main.gd | 4 +++- client/test/craft_feedback_refresh_test.gd | 5 +---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/client/scripts/main.gd b/client/scripts/main.gd index f014a7e..4b177d9 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -552,7 +552,9 @@ func _setup_craft_ui() -> void: if _recipe_defs_client.has_signal("recipes_ready"): _recipe_defs_client.connect("recipes_ready", Callable(self, "_on_recipes_ready")) if _recipe_defs_client.has_signal("recipes_sync_failed"): - _recipe_defs_client.connect("recipes_sync_failed", Callable(self, "_on_recipes_sync_failed")) + _recipe_defs_client.connect( + "recipes_sync_failed", Callable(self, "_on_recipes_sync_failed") + ) if _craft_recipe_panel.has_signal("craft_requested"): _craft_recipe_panel.connect("craft_requested", Callable(self, "_on_craft_requested")) if _craft_client.has_signal("craft_result_received"): diff --git a/client/test/craft_feedback_refresh_test.gd b/client/test/craft_feedback_refresh_test.gd index 0f88163..10ed5d0 100644 --- a/client/test/craft_feedback_refresh_test.gd +++ b/client/test/craft_feedback_refresh_test.gd @@ -19,10 +19,7 @@ class MockCraftTransport: _request_data: String = "" ) -> Error: request_completed.emit( - HTTPRequest.RESULT_SUCCESS, - 200, - PackedStringArray(), - body_json.to_utf8_buffer() + HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), body_json.to_utf8_buffer() ) return OK From f939132f38c5ec21e7c1098ebd26d47b6f8756f6 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 24 May 2026 20:05:22 -0400 Subject: [PATCH 7/9] NEO-74: Fix recipe client HTTP error test signal capture. Use got-array lambda pattern; GDScript cannot assign outer locals from signal callbacks, which left failed_reason empty in CI GdUnit runs. --- client/test/recipe_definitions_client_test.gd | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/client/test/recipe_definitions_client_test.gd b/client/test/recipe_definitions_client_test.gd index 820fdfc..85c9a7b 100644 --- a/client/test/recipe_definitions_client_test.gd +++ b/client/test/recipe_definitions_client_test.gd @@ -102,9 +102,10 @@ func test_http_error_emits_recipes_sync_failed_with_status() -> void: var transport := MockHttpTransport.new() transport.response_code = 503 var c := _make_client(transport) - var failed_reason := "" - c.connect("recipes_sync_failed", func(reason: String) -> void: failed_reason = reason) + var got: Array = [] + c.connect("recipes_sync_failed", func(reason: String) -> void: got.append(reason)) # Act c.call("request_sync_from_server") # Assert - assert_that(failed_reason).contains("503") + assert_that(got.size()).is_equal(1) + assert_that(str(got[0])).contains("503") From d6d95ec43831ad25b422437ee193f425a3c7fcbe Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 24 May 2026 20:14:05 -0400 Subject: [PATCH 8/9] NEO-74: Fix craft busy flag ordering; drop unused recipe helpers. Set _busy before HTTP request so sync mock completion cannot leave the client stuck busy; remove uncalled recipes_snapshot/recipe_by_id; add GdUnit guard for is_busy after sync response. --- client/scripts/craft_client.gd | 3 ++- client/scripts/recipe_definitions_client.gd | 14 -------------- client/test/craft_client_test.gd | 12 ++++++++++++ 3 files changed, 14 insertions(+), 15 deletions(-) diff --git a/client/scripts/craft_client.gd b/client/scripts/craft_client.gd index e474e18..055b960 100644 --- a/client/scripts/craft_client.gd +++ b/client/scripts/craft_client.gd @@ -40,6 +40,7 @@ func request_craft(recipe_id: String, quantity: int = 1) -> bool: if rid.is_empty(): return false _current_recipe_id = rid + _busy = true var payload: Dictionary = { "schemaVersion": SCHEMA_VERSION, "recipeId": rid, @@ -49,10 +50,10 @@ func request_craft(recipe_id: String, quantity: int = 1) -> bool: var headers := PackedStringArray(["Content-Type: application/json"]) var err: Error = _http.request(url, headers, HTTPClient.METHOD_POST, JSON.stringify(payload)) if err != OK: + _busy = false _current_recipe_id = "" push_warning("CraftClient: POST failed to start (%s)" % err) return false - _busy = true return true diff --git a/client/scripts/recipe_definitions_client.gd b/client/scripts/recipe_definitions_client.gd index 7b20883..5de122c 100644 --- a/client/scripts/recipe_definitions_client.gd +++ b/client/scripts/recipe_definitions_client.gd @@ -41,20 +41,6 @@ func request_sync_from_server() -> void: recipes_sync_failed.emit(reason) -func recipes_snapshot() -> Array: - return _recipes.duplicate(true) - - -func recipe_by_id(recipe_id: String) -> Dictionary: - for row_variant in _recipes: - if not row_variant is Dictionary: - continue - var row: Dictionary = row_variant - if str(row.get("id", "")) == recipe_id: - return row.duplicate(true) - return {} - - func _base_root() -> String: return base_url.strip_edges().rstrip("/") diff --git a/client/test/craft_client_test.gd b/client/test/craft_client_test.gd index 03a2794..9f09c50 100644 --- a/client/test/craft_client_test.gd +++ b/client/test/craft_client_test.gd @@ -130,3 +130,15 @@ func test_http_error_emits_craft_request_failed() -> void: c.call("request_craft", "refine_scrap_standard") # Assert assert_that(_failed_reason).contains("404") + + +func test_request_craft_clears_busy_after_sync_mock_response() -> void: + # Arrange + var transport := MockHttpTransport.new() + transport.body_json = _success_craft_json() + var c := _make_client(transport) + # Act + var started: bool = bool(c.call("request_craft", "refine_scrap_standard", 1)) + # Assert + assert_that(started).is_true() + assert_that(bool(c.call("is_busy"))).is_false() From a8cedcbd64d55bfff840f76ef893ca3c5c565abc Mon Sep 17 00:00:00 2001 From: VinPropane Date: Sun, 24 May 2026 20:21:37 -0400 Subject: [PATCH 9/9] NEO-74: Repopulate recipe panel when item defs load. Run craft panel refresh before inventory-error guard in _on_item_definitions_ready so display names resolve even when inventory GET failed first. --- client/scripts/main.gd | 2 +- client/test/recipe_definitions_client_test.gd | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/client/scripts/main.gd b/client/scripts/main.gd index 4b177d9..28e04c2 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -366,11 +366,11 @@ func _apply_authority_http_config_to_client(client: Node) -> void: func _on_item_definitions_ready(_definitions_by_id: Dictionary) -> void: + _populate_craft_recipe_panel_if_ready() if not _inventory_error.is_empty(): return if not _last_inventory_snapshot.is_empty(): _render_inventory_label() - _populate_craft_recipe_panel_if_ready() func _on_inventory_received(snapshot: Dictionary) -> void: diff --git a/client/test/recipe_definitions_client_test.gd b/client/test/recipe_definitions_client_test.gd index 85c9a7b..21f7d28 100644 --- a/client/test/recipe_definitions_client_test.gd +++ b/client/test/recipe_definitions_client_test.gd @@ -1,6 +1,7 @@ extends GdUnitTestSuite ## NEO-74: `RecipeDefinitionsClient` GET parse + `recipes_ready` signal. +## `main.gd` repopulates craft panel on item `definitions_ready` even when inventory GET failed. const RecipeDefinitionsClient := preload("res://scripts/recipe_definitions_client.gd")