# NEO-73 — Implementation plan ## Story reference | Field | Value | |--------|--------| | **Key** | NEO-73 | | **Title** | E3.M1: Client gather feedback on interact (E3S5-02) | | **Linear** | https://linear.app/neon-sprawl/issue/NEO-73/e3m1-client-gather-feedback-on-interact-e3s5-02 | | **Module** | [E3.M1 — ResourceNodeAndGatherLoop](../decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md) · Epic 3 **Slice 5** (client 2 of 4) · backlog **E3S5-02** | | **Branch** | `NEO-73-e3m1-client-gather-feedback-on-interact` | | **Server deps** | [NEO-63](https://linear.app/neon-sprawl/issue/NEO-63) — gather via **`POST …/interact`** (**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) — **`GET …/skill-progression`** (**Done**) | | **Pattern** | [NEO-28](https://linear.app/neon-sprawl/issue/NEO-28) / [NEO-72](https://linear.app/neon-sprawl/issue/NEO-72) — 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 loop; [NEO-74](https://linear.app/neon-sprawl/issue/NEO-74) reuses **`SkillProgressionClient`** for **refine** row | ## Kickoff clarifications | Topic | Question | Agent recommendation | Answer | |--------|----------|----------------------|--------| | **Four-node R wiring** | How should **R** pick the resource node id? | **Nearest in-range `resource_node`** from cached interactables catalog — no new keys; walk to each anchor, press **R**. | **User:** nearest in-range from catalog. | | **Gather success copy** | What does **`GatherFeedbackLabel`** show? | **Inventory delta after refresh** — `+N {displayName}` for **`scrap_metal_bulk`** (compare pre/post snapshot qty). | **User:** inventory delta. | | **Salvage XP HUD** | Where does salvage xp/level live? | **`SkillProgressionLabel`** — persistent salvage row; boot hydrate + refresh after successful gather. | **User:** dedicated skill progression label. | No other blocking decisions — **`node_depleted`** deny copy follows **`CastFeedbackLabel`** pattern (`Gather: denied — node_depleted`); terminal **E** interact unchanged. ## Goal, scope, and out-of-scope **Goal:** After successful **`resource_node`** interact, the player **sees** inventory change and **salvage** XP movement on HUD — not Output-panel-only proof. **In scope (from Linear + [E3S5-02](E3S5-client-prototype-backlog.md#e3s5-02--client-gather-feedback-on-interact-e3m1)):** - **`skill_progression_client.gd`:** GET snapshot; parse v1 envelope; emit **`progression_received`** / **`progression_sync_failed`**; public **`request_sync_from_server()`**; **`skill_row(skill_id)`** helper for **`salvage`**. - **`SkillProgressionLabel`** under **`UICanvas`:** shows **`salvage: level N, xp M`** (and optional **`refine`** stub line omitted until NEO-74). - **`GatherFeedbackLabel`:** success delta line + deny lines for gather interacts. - Extend **`interaction_request_client.gd`:** emit **`interaction_result_received(interactable_id, allowed, reason_code)`** on parsed 200 (keep Output **`print`** for dev). - **`main.gd` gather path:** - Cache interactables catalog descriptors from **`catalog_ready`**. - **R** → resolve **nearest in-range** descriptor with **`kind == resource_node`** (horizontal X/Z, inclusive **`<= interactionRadius`** — same math as **`interaction_radius_indicators.gd`**); fall back to **`prototype_resource_node_alpha`** when none in range (server returns **`out_of_range`**). - On **`interaction_result_received`** when id is a known resource node: - **`allowed=true`:** snapshot pre-gather **`scrap_metal_bulk`** qty → **`inventory_client.request_sync_from_server()`** + **`skill_progression_client.request_sync_from_server()`** → on both (or inventory-first then skill) compute delta → paint **`GatherFeedbackLabel`**. - **`allowed=false`:** paint **`GatherFeedbackLabel`** with **`Gather: denied — {reasonCode}`** (including **`node_depleted`**); no refresh. - Boot: chain **`skill_progression_client.request_sync_from_server()`** alongside inventory hydrate (after authority config wired). - GdUnit: mock interact 200 → **`main`** handler (or client signal) triggers inventory + skill refresh; skill client parse tests. - **`client/README.md`** gather feedback subsection. - **`docs/manual-qa/NEO-73.md`**. **Out of scope (from Linear):** - New gather HTTP route; node respawn visuals; gather VFX. - **`refine`** skill row polish (NEO-74); resource-node-defs preview client (NEO-60 optional). ## Acceptance criteria checklist - [x] In-range interact on fresh node updates **inventory HUD** without manual **I** refresh. - [x] Depleted node shows **`node_depleted`** (or agreed copy) on **`GatherFeedbackLabel`**. - [x] **Salvage** skill level/XP visible on **`SkillProgressionLabel`** and changes after successful gather. ## Implementation reconciliation (shipped) - **Clients:** `skill_progression_client.gd`; `interaction_request_client.gd` emits **`interaction_result_received`** + **`post_interact_id`**. - **Picker:** `prototype_interactable_picker.gd` — nearest in-range **`resource_node`** on **R** (alpha fallback). - **HUD:** `GatherFeedbackLabel` (delta / deny); `SkillProgressionLabel` (salvage row); inventory auto-refresh on gather success. - **Tests:** `skill_progression_client_test.gd`, `prototype_interactable_picker_test.gd`, `gather_feedback_refresh_test.gd`; extended `interaction_request_client_test.gd`. - **Docs:** `client/README.md` gather section; [`NEO-73` manual QA](../manual-qa/NEO-73.md). ## Technical approach 1. **`skill_progression_client.gd`** - Mirror **`inventory_client.gd`**: injectable HTTP, **`_busy`** guard, **`GET …/skill-progression`**. - **`parse_progression_json(text)`** static: require **`schemaVersion` 1**, **`skills`** array. - **`skill_row(skill_id: String) -> Dictionary`:** find row by **`id`**; empty dict if missing. - Signals: **`progression_received(snapshot)`**, **`progression_sync_failed(reason)`**. 2. **`interaction_request_client.gd`** - Add **`signal interaction_result_received(interactable_id: String, allowed: bool, reason_code: String)`**. - On 200 + parsed dict: emit signal; **`reason_code`** empty string when allowed. - Add **`post_interact_id(interactable_id: String)`** public entry (used by **`main`** for nearest-node resolution); keep **`post_interact_resource()`** as thin wrapper or deprecate in favor of **`main`**-resolved id only. 3. **Nearest resource node helper (`main.gd` or small `prototype_interactable_picker.gd`)** - Input: cached descriptor **`Array`**, player **`Vector3`** world position. - Filter **`kind == "resource_node"`**; compute horizontal distance to **`anchor`**; pick minimum **`<= interactionRadius`**. - Tie-break: ascending **`interactableId`** (stable with server ordering). - Store descriptors in **`_interactables_catalog: Array`** on **`catalog_ready`**. 4. **Gather feedback orchestration (`main.gd`)** - **`_gather_pre_scrap_qty: int`** — set from **`_total_bag_qty("scrap_metal_bulk")`** immediately before POST when posting a resource node id. - Connect **`interaction_result_received`** → **`_on_interaction_result_for_gather`**. - Success path: refresh inventory + skill progression; on **`inventory_received`**, if pending gather feedback flag set, compute **`delta = new_qty - _gather_pre_scrap_qty`**, format **`Gather: +{delta} {displayName}`** on **`GatherFeedbackLabel`**; clear flag. - Deny path: **`Gather: denied — {reasonCode}`** on label ( **`node_depleted`**, **`inventory_full`**, **`out_of_range`**, etc.). - Terminal **E** interact: optional **`Interaction: …`** line or leave **`GatherFeedbackLabel`** unchanged (prefer unchanged unless we want generic interact deny — keep gather label gather-specific only). 5. **`SkillProgressionLabel` rendering** - Header **`Skills:`**; row **`salvage: level {level}, xp {xp}`** from cached snapshot. - Error line on **`progression_sync_failed`** (mirror inventory error UX). - Re-render on **`progression_received`** and after gather refresh. 6. **Scene layout (`main.tscn`)** - Add **`SkillProgressionClient`**, **`GatherFeedbackLabel`**, **`SkillProgressionLabel`** nodes. - **`GatherFeedbackLabel`:** below **`CastFeedbackLabel`** (~offset_top 378) or between cast and cooldown — short single-line feedback. - **`SkillProgressionLabel`:** below **`InventoryLabel`** (~offset_top 628) or stack under inventory block; shift layout if needed for readability. - **`SkillProgressionClient`:** sibling HTTP client node like **`InventoryClient`**. 7. **Tests** - **`skill_progression_client_test.gd`:** mock 200 with trio skills → **`skill_row("salvage")`** has xp/level; 404 → failure signal. - **`interaction_request_client_test.gd`:** extend — mock 200 deny → signal **`allowed=false`**, **`reason_code=node_depleted`**. - **`gather_feedback_refresh_test.gd`** (or extend **`interaction_request_client_test`**): stub **`main`** gather handler / test double that verifies inventory + skill **`request_sync_from_server`** called on allowed resource node signal (AAA). 8. **Docs on land** - Update [E3_M1](../decomposition/modules/E3_M1_ResourceNodeAndGatherLoop.md) client slice note + [documentation_and_implementation_alignment.md](../decomposition/modules/documentation_and_implementation_alignment.md) E3.M1 row when implementation completes. - **`E3S5-client-prototype-backlog.md`:** check E3S5-02 boxes. ## Files to add | Path | Purpose | |------|---------| | `docs/plans/NEO-73-implementation-plan.md` | This plan. | | `client/scripts/skill_progression_client.gd` | GET player skill progression; **`progression_received`** / **`progression_sync_failed`**; **`skill_row`**. | | `client/scripts/skill_progression_client.gd.uid` | Godot uid companion (tracked). | | `client/test/skill_progression_client_test.gd` | GdUnit: parse v1 skills; salvage row lookup; HTTP error path. AAA layout. | | `client/test/gather_feedback_refresh_test.gd` | GdUnit: interact allow signal → inventory + skill refresh invoked (mock/spy). AAA layout. | | `docs/manual-qa/NEO-73.md` | Fresh node gather, four-node **R** nearest pick, depletion deny, salvage XP delta, no manual **I**. | ## Files to modify | Path | Rationale | |------|-----------| | `client/scripts/interaction_request_client.gd` | Emit **`interaction_result_received`**; add **`post_interact_id`**. | | `client/scripts/main.gd` | Catalog cache, nearest-node **R** routing, gather feedback orchestration, skill/inventory boot + signal wiring, HUD render helpers. | | `client/scenes/main.tscn` | Add **`SkillProgressionClient`**, **`GatherFeedbackLabel`**, **`SkillProgressionLabel`**. | | `client/test/interaction_request_client_test.gd` | Assert new signal on allow/deny mock responses. | | `client/README.md` | Gather feedback: **R** nearest-node rule, HUD labels, boot hydrate, server deps. | ## Tests | Test file | What it covers | |-----------|----------------| | `client/test/skill_progression_client_test.gd` | Mock 200 with **`salvage`** row **`xp`/`level`** → **`progression_received`** + **`skill_row`**; 404 → **`progression_sync_failed`**. AAA layout. | | `client/test/interaction_request_client_test.gd` | Mock deny 200 → **`interaction_result_received(false, "node_depleted")`**; allow → **`allowed=true`**. AAA layout. | | `client/test/gather_feedback_refresh_test.gd` | Lightweight **`main.gd`** gather handler test or extracted callable: resource node allow → **`request_sync_from_server`** on inventory + skill clients (spy nodes). AAA layout. | No new **C#** tests (client-only). No new Bruno (server covered by NEO-63). ## Open questions / risks | Question / risk | Agent recommendation | Status | |-----------------|----------------------|--------| | **Async refresh race (inventory vs skill)** | Paint gather delta when inventory arrives; refresh skill label independently on **`progression_received`** — acceptable lag ≤1 RTT. | **adopted** | | **Multi-item deltas** | Prototype yields only **`scrap_metal_bulk`**; sum bag qty for that id only. | **adopted** | | **No in-range node on R** | Fall back to alpha id; server **`out_of_range`** + HUD deny line — same as today when far from nodes. | **adopted** | | **Terminal E pollutes GatherFeedbackLabel** | Leave gather label unchanged on non-resource-node interacts. | **adopted** | | **HUD vertical space** | Offset labels below inventory; optional collapse deferred to NEO-75. | **deferred** (NEO-75) | | **Slice 5 module docs** | Update alignment register when NEO-73 lands, not at kickoff. | **deferred** (implementation) | ## Decisions (kickoff) - **R wiring:** nearest in-range **`resource_node`** from cached interactables catalog (user confirmed). - **Gather success copy:** inventory delta **`+N displayName`** after refresh (user confirmed). - **Skill HUD:** dedicated **`SkillProgressionLabel`** with salvage row; boot + post-gather refresh (user confirmed). - **Deny UX:** **`GatherFeedbackLabel`** + existing Output **`print`** (interaction client). - **No new gather HTTP** on client; reuse **`POST …/interact`**.