14 KiB
NEO-97 — Implementation plan
Story reference
| Field | Value |
|---|---|
| Key | NEO-97 |
| Title | E5M2-11: Client telegraph HUD + NPC archetype markers |
| Linear | https://linear.app/neon-sprawl/issue/NEO-97/e5m2-11-client-telegraph-hud-npc-archetype-markers |
| Module | E5.M2 — NpcAiAndBehaviorProfiles · Epic 5 Slice 2 · backlog E5M2-11 |
| Branch | NEO-97-e5m2-11-client-telegraph-hud |
| Server deps | NEO-94 — GET /game/world/npc-runtime-snapshot (Done); NEO-95 — GET /game/players/{id}/combat-health (Done) |
| Pattern | NEO-85 / NEO-32 — thin HTTP clients, main.gd HUD wiring, GdUnit mock transport, optional local tick-down between polls |
| Downstream | NEO-98 — playable NPC telegraph combat capstone (manual QA script) |
| Server counterpart | NEO-94 + NEO-95 — 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 |
|---|---|---|---|
| Poll gating | When does ~1 Hz polling run? | When lock held OR player is aggroHolderPlayerId on any NPC — combat HUD visible without requiring lock on the attacking NPC (NEO-95 incoming telegraph/HP feedback). |
User: combat-active gate. |
| HUD scope | Which NPC row(s) for TelegraphLabel + NpcStateLabel? |
Locked NPC + incoming threat — primary row for locked target; when player is aggro holder on a different NPC, show that NPC’s state/telegraph as incoming threat. | User: locked + incoming. |
| Windup display | Raw poll value vs local tick-down? | Server value at poll + local tick-down between polls — cooldown_state.gd pattern; AC “within one poll frame” satisfied at poll boundaries. |
User: interpolate. |
| Markers script name | Backlog npc_archetype_markers.gd vs existing prototype_target_markers.gd? |
Keep prototype_target_markers.gd — already wired in main.tscn as World/PrototypeTargetMarkers; NEO-24/NEO-91 precedent. |
Adopted (repo precedent; not asked). |
| Constants / tab cycle | Migrate prototype_target_constants.gd to three NPC ids? |
Already landed on main (NEO-91) — verify parity with PrototypeNpcRegistry.GetInstanceIdsInOrder(); no duplicate migration. |
Adopted (already on main). |
| Manual QA doc | docs/manual-qa/NEO-97.md? |
Yes — player-visible client story; E5M2-11 AC + NEO-85 precedent. | Adopted (Linear AC + backlog). |
Goal, scope, and out-of-scope
Goal: Godot shows server-owned telegraph timing, NPC runtime state, player combat HP, and three archetype world markers — driven by npc-runtime-snapshot + combat-health poll, completing the client half of Epic 5 Slice 2 combat feedback.
In scope (from Linear + E5M2-11):
npc_runtime_client.gd— pollGET /game/world/npc-runtime-snapshot(~1 Hz when combat-active per kickoff).player_combat_health_client.gd— pollGET /game/players/{id}/combat-healthon the same timer gate.npc_runtime_hud_state.gd(RefCounted) — cache snapshot + local windup tick-down between polls (kickoff interpolate decision).- HUD labels under
UICanvas:TelegraphLabel,NpcStateLabel,PlayerCombatHpLabel. main.gdwiring: combat poll timer, render helpers, combat-active gate (lock held OR player is aggro holder).prototype_target_markers.gd— already spawns three archetype markers; verify highlight + labels match migrated constants (no rename tonpc_archetype_markers.gd).client/README.md— NPC / telegraph HUD section; fix stale alpha/beta copy.- GdUnit tests with HTTP doubles (testing-expectations.md).
docs/manual-qa/NEO-97.md— Godot steps (server + client running).
Out of scope (from Linear + backlog):
- Final VFX art; dodge mechanics (prototype shows telegraph only).
- Server API / DTO changes (NEO-94, NEO-95 already landed).
- Playable capstone script (NEO-98).
- Telemetry ingest (NEO-96 server comment hooks only).
Acceptance criteria checklist
- Player-visible telegraph countdown matches server snapshot within one poll frame (local tick-down between polls; reconciles on each GET).
- Tab cycle visits three NPC instances in documented order (
prototype_npc_elite→prototype_npc_melee→prototype_npc_ranged). - Manual QA checklist exercisable without Bruno.
Implementation reconciliation (shipped)
npc_runtime_client.gd,player_combat_health_client.gd,npc_runtime_hud_state.gd,npc_combat_hud_helpers.gd— poll + parse + windup interpolation + shared HUD formatters.main.gd— combat-active ~1 Hz poll timer;TelegraphLabel,NpcStateLabel,PlayerCombatHpLabelrender helpers (locked + incoming threat rows).main.tscn— three HUD labels; downstream label offsets shifted.- GdUnit:
npc_runtime_client_test.gd,player_combat_health_client_test.gd,npc_runtime_hud_state_test.gd,npc_combat_hud_refresh_test.gd; shared formatters innpc_combat_hud_helpers.gd. docs/manual-qa/NEO-97.md,client/README.md, E5M2 backlog + module + alignment register updated.prototype_target_constants.gd/prototype_target_markers.gd— verified onmain(NEO-91); no rename tonpc_archetype_markers.gd.
Technical approach
Already on main (verify, do not re-migrate)
prototype_target_constants.gd— three NPC ids in tab order + anchors/colors/display names.prototype_target_markers.gd+prototype_district_visual_catalog.gd— three world markers with Elite/Melee/Ranged labels.target_selection_client.gd— tab cycle already usesPrototypeTargetConstants.ORDERED_IDS.
HTTP clients
-
npc_runtime_client.gd- Mirror
combat_targets_client.gd: injectable HTTP,_busyguard. GET {base}/game/world/npc-runtime-snapshot(world route — no player id).parse_runtime_json(text)static: requireschemaVersion1,npcInstancesarray.- Signals:
runtime_received(snapshot: Dictionary),runtime_sync_failed(reason: String). - Helpers:
npc_row(instance_id, snapshot),row_for_aggro_holder(player_id, snapshot)(first row whereaggroHolderPlayerIdmatches).
- Mirror
-
player_combat_health_client.gd- Mirror
cooldown_snapshot_client.gd: player-scoped GET. GET {base}/game/players/{id}/combat-health.parse_combat_health_json(text)static:schemaVersion1,currentHp,maxHp,defeated.- Signals:
health_received(snapshot: Dictionary),health_sync_failed(reason: String).
- Mirror
Local windup mirror (npc_runtime_hud_state.gd)
- On
runtime_received: store per-rowwindupRemainingSecondsat receive tick (noserverTimeUtcanchor — display smoothing only). - For each row with nested
activeTelegraph: recordwindupRemainingSecondsat receive time. display_windup_remaining(instance_id) -> float: anchor value minus elapsed local seconds since receive (floor at 0).- Reconcile on every poll — no client windup scheduling; only display smoothing between polls.
Combat-active poll gate
Start Timer (~1.0 s, one-shot loop) when any of:
TargetSelectionClient.cached_state().lockedTargetIdis a known NPC id, or- Last
npc-runtime-snapshothasaggroHolderPlayerId == dev_player_idon any row.
Stop timer when neither condition holds. On timeout: request both GETs (sequential or parallel — parallel preferred if _busy guards are independent).
Also trigger an immediate sync when lock changes to an NPC or first cast accept (optional fast-path; timer owns steady state).
HUD rendering (main.gd)
PlayerCombatHpLabel — format Player HP: {current}/{max} + (defeated); — on sync failure / before first poll.
NpcStateLabel — two-line priority (kickoff locked + incoming):
- Locked row:
NPC state: {shortName} → {state}when lock is a known NPC id. - Incoming row: when player is aggro holder on a different NPC (or no lock), append
Incoming: {shortName} → {state}.
Use server snake_case state as-is for prototype debug readability (idle, aggro, telegraph_windup, recover).
TelegraphLabel — when locked or incoming row has activeTelegraph:
Telegraph: {shortName} {telegraphId} · {remaining:0.1}s ({archetypeKind})remainingfromnpc_runtime_hud_state.display_windup_remaining(interpolated).—when no active telegraph on displayed rows.
Scene layout (main.tscn): insert three labels after CombatTargetHpLabel (~offset_top 400 / 426 / 452); shift GatherFeedbackLabel and economy block down ~78 px if needed.
Cross-references
client/README.md— new NPC runtime + telegraph HUD (NEO-97) section; update targeting/combat sections (remove alpha/beta; document three NPC tab order + poll gate).docs/plans/E5M2-prototype-backlog.md— E5M2-11 checkboxes + landed note when complete.docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md— NEO-97 landed line when complete.docs/decomposition/modules/documentation_and_implementation_alignment.md— E5.M2 row bump when E5M2-11 lands.
Wire vocabulary (from server DTOs)
npc-runtime-snapshot row: npcInstanceId, behaviorDefId, state, aggroHolderPlayerId, optional activeTelegraph { telegraphId, windupRemainingSeconds, archetypeKind }.
combat-health: playerId, maxHp, currentHp, defeated.
Files to add
| Path | Purpose |
|---|---|
docs/plans/NEO-97-implementation-plan.md |
This plan. |
client/scripts/npc_runtime_client.gd |
GET world npc-runtime-snapshot; parse + row helpers. |
client/scripts/npc_runtime_client.gd.uid |
Godot uid companion (same commit as .gd). |
client/scripts/player_combat_health_client.gd |
GET player combat-health. |
client/scripts/player_combat_health_client.gd.uid |
Godot uid companion. |
client/scripts/npc_runtime_hud_state.gd |
Snapshot cache + windup tick-down between polls. |
client/scripts/npc_runtime_hud_state.gd.uid |
Godot uid companion. |
client/scripts/npc_combat_hud_helpers.gd |
Shared combat-active gate + NPC/telegraph label formatters. |
client/scripts/npc_combat_hud_helpers.gd.uid |
Godot uid companion. |
client/test/npc_runtime_client_test.gd |
HTTP double: URL, parse, row lookup, aggro-holder helper. |
client/test/player_combat_health_client_test.gd |
HTTP double: URL, parse, schema guard. |
client/test/npc_runtime_hud_state_test.gd |
Windup interpolation + reconcile on new snapshot. |
client/test/npc_combat_hud_refresh_test.gd |
Combat-active gate + timer mirror; telegraph fallback/expiry; sync error copy. |
docs/manual-qa/NEO-97.md |
Godot manual QA — lock elite, cast, observe telegraph countdown + player HP drop. |
Files to modify
| Path | Rationale |
|---|---|
client/scripts/main.gd |
Wire npc-runtime + combat-health clients, combat poll timer, HUD render via helpers. |
client/scenes/main.tscn |
Add TelegraphLabel, NpcStateLabel, PlayerCombatHpLabel; adjust downstream label offsets. |
client/README.md |
NEO-97 NPC/telegraph HUD section; fix stale alpha/beta targeting/combat copy. |
docs/plans/E5M2-prototype-backlog.md |
E5M2-11 acceptance checkboxes + landed note when complete. |
client/scripts/npc_runtime_client.gd |
Instance npc_row falls back to _last_snapshot. |
client/scripts/npc_combat_hud_helpers.gd |
Code-review follow-up: shared HUD helpers extracted from main.gd. |
docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md |
Risks + NPC instance row aligned with NEO-97 kickoff. |
docs/reviews/2026-05-29-NEO-97.md |
Strike-through review suggestions when fixed. |
docs/decomposition/modules/documentation_and_implementation_alignment.md |
E5.M2 alignment register — E5M2-11 complete when shipped. |
Tests
| File | Coverage |
|---|---|
client/test/npc_runtime_client_test.gd |
GET URL; parse; row_for_aggro_holder; instance npc_row _last_snapshot fallback. |
client/test/player_combat_health_client_test.gd |
GET URL with player id; parse success/fail; defeated flag. |
client/test/npc_runtime_hud_state_test.gd |
Apply snapshot → interpolated windup decreases; new poll reconciles to server value. |
client/test/npc_combat_hud_refresh_test.gd |
Combat-active gate + timer mirror; telegraph fallback/expiry; sync error on labels. |
| (existing unchanged) | prototype_target_constants_test.gd, target_selection_client_test.gd, prototype_target_markers_test.gd — three-NPC tab/marker behavior already covered. |
Open questions / risks
| Question / risk | Agent recommendation | Status |
|---|---|---|
| NEO-94/NEO-95 blockers | Proceed — both Done on main. |
adopted |
| Partial constants/markers migration | Verify only; do not duplicate NEO-91 work. | adopted |
| README still references alpha/beta | Update in NEO-97 README pass. | adopted |
| Poll advances server sim | Accept — GET invokes AdvanceAll; ~1 Hz during combat is intended prototype behavior (NEO-94 plan). |
adopted |
| Elite telegraph timeline ~7.5 s | Manual QA must multi-poll (5 s aggro + 2.5 s windup); document waits in NEO-97.md (mirror NEO-95 Bruno notes). |
adopted |