# 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](../decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md) · Epic 5 Slice 2 · backlog **E5M2-11** | | **Branch** | `NEO-97-e5m2-11-client-telegraph-hud` | | **Server deps** | [NEO-94](https://linear.app/neon-sprawl/issue/NEO-94) — **`GET /game/world/npc-runtime-snapshot`** (**Done**); [NEO-95](https://linear.app/neon-sprawl/issue/NEO-95) — **`GET /game/players/{id}/combat-health`** (**Done**) | | **Pattern** | [NEO-85](https://linear.app/neon-sprawl/issue/NEO-85) / [NEO-32](https://linear.app/neon-sprawl/issue/NEO-32) — thin HTTP clients, `main.gd` HUD wiring, GdUnit mock transport, optional local tick-down between polls | | **Downstream** | [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98) — playable NPC telegraph combat capstone (manual QA script) | | **Server counterpart** | [NEO-94](https://linear.app/neon-sprawl/issue/NEO-94) + [NEO-95](https://linear.app/neon-sprawl/issue/NEO-95) — 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 | |--------|----------|----------------------|--------| | **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](https://linear.app/neon-sprawl/issue/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](E5M2-prototype-backlog.md#e5m2-11--client-telegraph-hud--npc-archetype-markers)):** - **`npc_runtime_client.gd`** — poll **`GET /game/world/npc-runtime-snapshot`** (~1 Hz when combat-active per kickoff). - **`player_combat_health_client.gd`** — poll **`GET /game/players/{id}/combat-health`** on 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.gd` wiring:** 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 to `npc_archetype_markers.gd`). - **`client/README.md`** — NPC / telegraph HUD section; fix stale alpha/beta copy. - GdUnit tests with HTTP doubles ([testing-expectations.md](../../.cursor/rules/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](https://linear.app/neon-sprawl/issue/NEO-94), [NEO-95](https://linear.app/neon-sprawl/issue/NEO-95) already landed). - Playable capstone script ([NEO-98](https://linear.app/neon-sprawl/issue/NEO-98)). - Telemetry ingest ([NEO-96](https://linear.app/neon-sprawl/issue/NEO-96) server comment hooks only). ## Acceptance criteria checklist - [x] Player-visible telegraph countdown matches server snapshot within one poll frame (local tick-down between polls; reconciles on each GET). - [x] Tab cycle visits three NPC instances in documented order (`prototype_npc_elite` → `prototype_npc_melee` → `prototype_npc_ranged`). - [x] 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`**, **`PlayerCombatHpLabel`** render 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 in **`npc_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 on `main` (NEO-91); no rename to `npc_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 uses `PrototypeTargetConstants.ORDERED_IDS`. ### HTTP clients 1. **`npc_runtime_client.gd`** - Mirror **`combat_targets_client.gd`**: injectable HTTP, `_busy` guard. - **`GET {base}/game/world/npc-runtime-snapshot`** (world route — no player id). - **`parse_runtime_json(text)`** static: require **`schemaVersion` 1**, **`npcInstances`** array. - 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 where **`aggroHolderPlayerId`** matches). 2. **`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: **`schemaVersion` 1**, **`currentHp`**, **`maxHp`**, **`defeated`**. - Signals: **`health_received(snapshot: Dictionary)`**, **`health_sync_failed(reason: String)`**. ### Local windup mirror (`npc_runtime_hud_state.gd`) - On **`runtime_received`**: store per-row **`windupRemainingSeconds`** at receive tick (no `serverTimeUtc` anchor — display smoothing only). - For each row with nested **`activeTelegraph`**: record **`windupRemainingSeconds`** at 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().lockedTargetId`** is a known NPC id, **or** - Last **`npc-runtime-snapshot`** has **`aggroHolderPlayerId == dev_player_id`** on 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): 1. **Locked row:** **`NPC state: {shortName} → {state}`** when lock is a known NPC id. 2. **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})`** - **`remaining`** from **`npc_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 1. **`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). 2. **`docs/plans/E5M2-prototype-backlog.md`** — E5M2-11 checkboxes + landed note when complete. 3. **`docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md`** — NEO-97 landed line when complete. 4. **`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](NEO-94-implementation-plan.md)). | **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** |