12 KiB
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 · Epic 5 Slice 1 · backlog E5M1-11 |
| Branch | NEO-85-client-combat-feedback-hp-hud |
| Server deps | NEO-82 — cast POST + nested combatResolution (Done on main); NEO-83 — GET /game/world/combat-targets (Done on main) |
| Pattern | NEO-28 / NEO-73 — thin HTTP clients, main.gd HUD wiring, GdUnit mock transport |
| Downstream | NEO-86 capstone playable tab-target combat loop (manual QA script) |
| Server counterpart | NEO-82 + NEO-83 — this issue is the client half; Bruno-only verification is not prototype-complete per full-stack epic decomposition |
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). | 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):
- Extend
ability_cast_client.gdto parse nestedcombatResolutionon accept and surface it oncast_result_received. - Update
CastFeedbackLabelwith damage / defeat messaging on accept (e.g.Cast: 25 dmg → 75 HP,Cast: target defeated!whentargetDefeated). combat_targets_client.gd—GET /game/world/combat-targets; cache snapshot;target_row(target_id)helper.CombatTargetHpLabel— shows locked targetcurrentHp/maxHp(+defeatedsuffix);—when no lock.main.gdwiring: 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).
docs/manual-qa/NEO-85.md— Godot steps (server + client running).client/README.mdcombat 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 capstone.
- Periodic HP polling, local damage prediction, server changes.
Acceptance criteria checklist
- Player sees damage/defeat feedback in HUD after digit-key cast (not Output-only).
- Target HP label matches server snapshot after cast sequence.
- Manual QA exercisable without Bruno.
Technical approach
-
ability_cast_client.gd(NEO-85)- Parse optional
combatResolutiondict whenaccepted: true(abilityId,targetId,damageDealt,targetRemainingHp,targetDefeated). - Extend signal:
cast_result_received(accepted, reason_code, resolution)—resolutionemptyDictionaryon deny or missing block. - Add static
parse_cast_response_json(text) -> Dictionaryfor tests (returns{accepted, reasonCode, resolution}).
- Parse optional
-
combat_targets_client.gd(new)- Mirror
interactables_catalog_client.gd/skill_progression_client.gd: injectable HTTP,_busyguard. GET {base}/game/world/combat-targets— no player id segment (world route).parse_targets_json(text)static: requireschemaVersion1,targetsarray.- 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 bytargetId; uses cached last snapshot when second arg omitted.
- Mirror
-
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
- If
- Accept + empty resolution: fallback
Cast: accepted(defensive; should not occur post-NEO-82). - Keep cooldown sync on accept (existing NEO-32 path).
- Deny: unchanged NEO-28 lines (
-
Combat target HP HUD (
main.gd)- Cache
_combat_targets_snapshot: Dictionaryontargets_received. _render_combat_target_hp_label()— read_last_target_state.lockedTargetId; lookup row; formatTarget HP: {id} {current}/{max}+(defeated)whendefeated;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_receivedwhenaccepted→ 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).
- Cache
-
Scene layout (
main.tscn)- Add
CombatTargetHpLabelbetweenCastFeedbackLabelandGatherFeedbackLabel(~offset_top 374, shiftGatherFeedbackLabel/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).
- Add
-
Client boot wiring (
main.gd)- Instantiate
CombatTargetsClientin hotbar/cast setup block; apply authoritybase_url(world GET — nodev_player_id). - Connect
targets_received→ cache +_render_combat_target_hp_label. - Update
cast_result_receivedconnection signature for thirdresolutionarg.
- Instantiate
-
Tests (GdUnit, AAA)
ability_cast_client_test.gd: extend — accept body withcombatResolution→ 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 triggerscombat_targets_client.request_sync_from_server(spy/mock); target lock change triggers sync (mirrorscraft_feedback_refresh_test.gd/gather_feedback_refresh_test.gd).
-
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-fixturewhen enabled).docs/manual-qa/NEO-85.md: Tab lock alpha → bind slot 0 pulse → cast until defeat; verifyCastFeedbackLabel+CombatTargetHpLabel; optional fixture reset between runs.- Reconcile E5M1-prototype-backlog.md E5M1-11 checkboxes, E5_M1_CombatRulesEngine.md, 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 — 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 |