161 lines
13 KiB
Markdown
161 lines
13 KiB
Markdown
# 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`**.
|