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