neon-sprawl/docs/plans/NEO-85-implementation-plan.md

150 lines
12 KiB
Markdown

# NEO-85 — Implementation plan
## Story reference
| Field | Value |
|--------|--------|
| **Key** | NEO-85 |
| **Title** | E5M1-11: Client combat feedback + target HP HUD |
| **Linear** | https://linear.app/neon-sprawl/issue/NEO-85/e5m1-11-client-combat-feedback-target-hp-hud |
| **Module** | [E5.M1 — CombatRulesEngine](../decomposition/modules/E5_M1_CombatRulesEngine.md) · Epic 5 Slice 1 · backlog **E5M1-11** |
| **Branch** | `NEO-85-client-combat-feedback-hp-hud` |
| **Server deps** | [NEO-82](https://linear.app/neon-sprawl/issue/NEO-82) — cast POST + nested **`combatResolution`** (**Done** on `main`); [NEO-83](https://linear.app/neon-sprawl/issue/NEO-83) — **`GET /game/world/combat-targets`** (**Done** on `main`) |
| **Pattern** | [NEO-28](https://linear.app/neon-sprawl/issue/NEO-28) / [NEO-73](https://linear.app/neon-sprawl/issue/NEO-73) — thin HTTP clients, `main.gd` HUD wiring, GdUnit mock transport |
| **Downstream** | [NEO-86](https://linear.app/neon-sprawl/issue/NEO-86) capstone playable tab-target combat loop (manual QA script) |
| **Server counterpart** | [NEO-82](https://linear.app/neon-sprawl/issue/NEO-82) + [NEO-83](https://linear.app/neon-sprawl/issue/NEO-83) — this issue is the **client** half; Bruno-only verification is not prototype-complete per [full-stack epic decomposition](../../.cursor/rules/full-stack-epic-decomposition.md) |
## Kickoff clarifications
| Topic | Question | Agent recommendation | Answer |
|--------|----------|----------------------|--------|
| **HP refresh** | Event-driven vs periodic poll for **`GET /game/world/combat-targets`**? | **Event-driven only** — refresh after successful cast accept + on target lock change; matches cooldown sync pattern and minimizes HTTP chatter ([E5M1-11 backlog](E5M1-prototype-backlog.md#e5m1-11--client-combat-feedback--target-hp-hud)). | **User:** event-driven only. |
| **Cast feedback surface** | Extend **`CastFeedbackLabel`** vs dedicated combat line? | **Extend `CastFeedbackLabel`** with resolution copy on accept — NEO-28 precedent; keep HUD compact. | **User:** extend `CastFeedbackLabel`. |
| **HP label placement** | Dedicated label vs append to **`TargetLockLabel`**? | **New `CombatTargetHpLabel`** below **`CastFeedbackLabel`** — AC requires readable HP readout; **`TargetLockLabel`** is already dense lock/range debug. | **User:** dedicated `CombatTargetHpLabel`. |
No other blocking decisions — wire field names follow NEO-82 **`combatResolution`** / NEO-83 **`targets`** row vocabulary; zero-damage accepts show **`0 dmg`** from server fields (no local combat math).
## Goal, scope, and out-of-scope
**Goal:** Godot displays authoritative cast outcomes and locked dummy HP without local combat math — player-visible damage/defeat feedback and an HP label that matches the server snapshot after casts.
**In scope (from Linear + [E5M1-11](E5M1-prototype-backlog.md#e5m1-11--client-combat-feedback--target-hp-hud)):**
- Extend **`ability_cast_client.gd`** to parse nested **`combatResolution`** on accept and surface it on **`cast_result_received`**.
- Update **`CastFeedbackLabel`** with damage / defeat messaging on accept (e.g. **`Cast: 25 dmg → 75 HP`**, **`Cast: target defeated!`** when **`targetDefeated`**).
- **`combat_targets_client.gd`** — **`GET /game/world/combat-targets`**; cache snapshot; **`target_row(target_id)`** helper.
- **`CombatTargetHpLabel`** — shows locked target **`currentHp/maxHp`** (+ **`defeated`** suffix); **`—`** when no lock.
- **`main.gd` wiring:** event-driven combat-target refresh on successful cast + **`target_state_changed`**; boot hydrate when first lock arrives.
- GdUnit tests with HTTP doubles ([testing-expectations.md](../../.cursor/rules/testing-expectations.md)).
- **`docs/manual-qa/NEO-85.md`** — Godot steps (server + client running).
- **`client/README.md`** combat HUD section.
**Out of scope (from Linear + backlog):**
- Final combat VFX, floating damage numbers art pass, player HP bar.
- Gig XP HUD row — optional in [NEO-86](https://linear.app/neon-sprawl/issue/NEO-86) capstone.
- Periodic HP polling, local damage prediction, server changes.
## Acceptance criteria checklist
- [x] Player sees damage/defeat feedback in HUD after digit-key cast (not Output-only).
- [x] Target HP label matches server snapshot after cast sequence.
- [x] Manual QA exercisable without Bruno.
## Implementation reconciliation (shipped)
- **Clients:** `ability_cast_client.gd` parses **`combatResolution`** on accept; **`combat_targets_client.gd`** GET world snapshot + **`target_row`**.
- **HUD:** **`CastFeedbackLabel`** resolution copy; **`CombatTargetHpLabel`** for locked target HP; event-driven refresh on cast accept + lock change.
- **Tests:** extended `ability_cast_client_test.gd`; new `combat_targets_client_test.gd`, `combat_feedback_refresh_test.gd`.
- **Docs:** `client/README.md` combat HUD section; [`NEO-85` manual QA](../manual-qa/NEO-85.md); E5M1 backlog + module alignment rows updated.
## Technical approach
1. **`ability_cast_client.gd` (NEO-85)**
- Parse optional **`combatResolution`** dict when **`accepted: true`** (`abilityId`, `targetId`, `damageDealt`, `targetRemainingHp`, `targetDefeated`).
- Extend signal: **`cast_result_received(accepted, reason_code, resolution)`** — **`resolution`** empty **`Dictionary`** on deny or missing block.
- Add static **`parse_cast_response_json(text) -> Dictionary`** for tests (returns `{accepted, reasonCode, resolution}`).
2. **`combat_targets_client.gd` (new)**
- Mirror **`interactables_catalog_client.gd`** / **`skill_progression_client.gd`**: injectable HTTP, **`_busy`** guard.
- **`GET {base}/game/world/combat-targets`** — no player id segment (world route).
- **`parse_targets_json(text)`** static: require **`schemaVersion` 1**, **`targets`** array.
- Signals: **`targets_received(snapshot: Dictionary)`**, **`targets_sync_failed(reason: String)`**.
- **`request_sync_from_server()`** public entry.
- **`target_row(target_id: String, snapshot: Dictionary = {}) -> Dictionary`** — find row by **`targetId`**; uses cached last snapshot when second arg omitted.
3. **Cast feedback copy (`main.gd` → `_on_cast_result_received`)**
- **Deny:** unchanged NEO-28 lines (`ability_cast_denied: {reasonCode}`).
- **Accept + resolution present:**
- If **`targetDefeated`:** **`Cast: {damageDealt} dmg → 0 HP — defeated!`**
- Else: **`Cast: {damageDealt} dmg → {targetRemainingHp} HP`**
- **Accept + empty resolution:** fallback **`Cast: accepted`** (defensive; should not occur post-NEO-82).
- Keep cooldown sync on accept (existing NEO-32 path).
4. **Combat target HP HUD (`main.gd`)**
- Cache **`_combat_targets_snapshot: Dictionary`** on **`targets_received`**.
- **`_render_combat_target_hp_label()`** — read **`_last_target_state.lockedTargetId`**; lookup row; format **`Target HP: {id} {current}/{max}`** + **` (defeated)`** when **`defeated`**; **`Target HP: — (no lock)`** when unlocked.
- **Refresh triggers (event-driven, kickoff decision):**
- **`target_state_changed`** → **`combat_targets_client.request_sync_from_server()`** when lock id changes or validity transitions to a locked id.
- **`cast_result_received`** when **`accepted`** → request combat-target sync (authoritative HP after cast; resolution fields give immediate cast feedback, GET reconciles label).
- Re-render HP label after snapshot received and when target state changes (even if GET still in flight, show last cached row for current lock).
5. **Scene layout (`main.tscn`)**
- Add **`CombatTargetHpLabel`** between **`CastFeedbackLabel`** and **`GatherFeedbackLabel`** (~offset_top 374, shift **`GatherFeedbackLabel`** / **`CraftFeedbackLabel`** / economy block down ~22px if needed for non-overlap).
- Style: match existing left HUD labels (outline, font_size 15, warm tint distinct from cast line).
6. **Client boot wiring (`main.gd`)**
- Instantiate **`CombatTargetsClient`** in hotbar/cast setup block; apply authority **`base_url`** (world GET — no **`dev_player_id`**).
- Connect **`targets_received`** → cache + **`_render_combat_target_hp_label`**.
- Update **`cast_result_received`** connection signature for third **`resolution`** arg.
7. **Tests (GdUnit, AAA)**
- **`ability_cast_client_test.gd`:** extend — accept body with **`combatResolution`** → signal carries resolution dict; deny → empty resolution.
- **`combat_targets_client_test.gd`:** mock GET 200 two-target snapshot → **`target_row("prototype_target_alpha")`** fields; schema mismatch → failure signal.
- **`combat_feedback_refresh_test.gd`:** harness — cast accept signal triggers **`combat_targets_client.request_sync_from_server`** (spy/mock); target lock change triggers sync (mirrors **`craft_feedback_refresh_test.gd`** / **`gather_feedback_refresh_test.gd`**).
8. **Docs on land**
- **`client/README.md`:** combat HUD subsection — cast resolution copy, HP label, event-driven GET refresh, dev fixture reset note (`POST /game/__dev/combat-targets-fixture` when enabled).
- **`docs/manual-qa/NEO-85.md`:** Tab lock alpha → bind slot 0 pulse → cast until defeat; verify **`CastFeedbackLabel`** + **`CombatTargetHpLabel`**; optional fixture reset between runs.
- Reconcile [E5M1-prototype-backlog.md](E5M1-prototype-backlog.md) E5M1-11 checkboxes, [E5_M1_CombatRulesEngine.md](../decomposition/modules/E5_M1_CombatRulesEngine.md), [documentation_and_implementation_alignment.md](../decomposition/modules/documentation_and_implementation_alignment.md) when implementation completes.
## Files to add
| Path | Purpose |
|------|---------|
| `docs/plans/NEO-85-implementation-plan.md` | This plan. |
| `client/scripts/combat_targets_client.gd` | GET world combat-targets snapshot; parse v1; **`targets_received`** / **`targets_sync_failed`**; **`target_row`**. |
| `client/scripts/combat_targets_client.gd.uid` | Godot uid companion (tracked). |
| `client/test/combat_targets_client_test.gd` | GdUnit: parse two-target snapshot; **`target_row`** lookup; HTTP error path. AAA layout. |
| `client/test/combat_feedback_refresh_test.gd` | GdUnit: cast accept + target lock change invoke combat-target sync (spy harness). AAA layout. |
| `docs/manual-qa/NEO-85.md` | Manual QA checklist — Godot combat feedback + HP HUD without Bruno. |
## Files to modify
| Path | Rationale |
|------|-----------|
| `client/scripts/ability_cast_client.gd` | Parse **`combatResolution`**; extend **`cast_result_received`** with resolution dict; static parse helper for tests. |
| `client/test/ability_cast_client_test.gd` | Cover resolution parsing + extended signal payload on accept/deny. |
| `client/scripts/main.gd` | Wire **`CombatTargetsClient`**; combat HP label render; cast feedback copy with resolution; event-driven refresh hooks. |
| `client/scenes/main.tscn` | Add **`CombatTargetHpLabel`** node; adjust downstream label offsets if needed. |
| `client/README.md` | Document combat HUD (cast resolution feedback + target HP label + refresh triggers). |
## Tests
| Path | Change |
|------|--------|
| `client/test/ability_cast_client_test.gd` | **Add/extend:** accept JSON with **`combatResolution`** → signal third arg has **`damageDealt`**, **`targetRemainingHp`**, **`targetDefeated`**; deny → empty resolution. |
| `client/test/combat_targets_client_test.gd` | **Add:** GET parse v1; alpha/beta rows; 404/invalid schema failure signal. |
| `client/test/combat_feedback_refresh_test.gd` | **Add:** successful cast accept triggers combat-target sync; target lock change triggers sync. |
**Manual verification:** [`docs/manual-qa/NEO-85.md`](manual-qa/NEO-85.md) — server + Godot; Tab lock **`prototype_target_alpha`**, digit cast **`prototype_pulse`**, observe stepping HP on label and defeat copy on 4th hit; **`target_defeated`** deny on 5th preserves HUD state.
## Open questions / risks
| Question or risk | Agent recommendation | Status |
|------------------|----------------------|--------|
| **Signal signature break** | Extend **`cast_result_received`** with third arg (empty dict on deny) — update **`main.gd`** + tests in same commit. | **adopted** |
| **HP label stale during in-flight GET** | Show last cached snapshot for locked id immediately after cast feedback; refresh when GET completes. | **adopted** |
| **Layout shift in `main.tscn`** | Insert HP label between cast and gather lines; bump gather/craft offsets minimally. | **adopted** |
| **Dev fixture reset for QA** | Document optional **`POST /game/__dev/combat-targets-fixture`** in manual QA when Development host enabled (NEO-83 README). | **adopted** |