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

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-83GET /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.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.gdGET /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).
  • 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 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

  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_changedcombat_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-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