Merge pull request #136 from ViPro-Technologies/NEO-97-e5m2-11-client-telegraph-hud

NEO-97: E5M2-11 client telegraph HUD + NPC runtime poll
pull/138/head
VinPropane 2026-05-29 22:18:52 -04:00 committed by GitHub
commit c3dee2b721
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 1555 additions and 128 deletions

View File

@ -99,11 +99,11 @@ Full checklist: [`docs/manual-qa/NEO-25.md`](../manual-qa/NEO-25.md).
`scripts/target_selection_client.gd` wires the server targeting API from [NEO-23](../docs/plans/NEO-23-implementation-plan.md) to a tab-cycle input and a HUD overlay.
- **Bindings** (in `project.godot`): **`target_tab`** → **Tab** cycles targets in the order defined by **`scripts/prototype_target_constants.gd`** (`prototype_target_alpha`, then `prototype_target_beta`; matches `PrototypeTargetRegistry.cs` ascending id), wrapping from last → first. When the player reference is wired (via `set_freshness_kick`, which `main.gd` does), Tab **skips client-computed out-of-range anchors** and selects the first in-range id in cycle order so pressing Tab while visibly standing next to an anchor actually locks it instead of trying the next-in-order id that happens to be across the map. Falls back to the plain cycle when nothing is in range so the server still picks the denial reason. **`target_clear`** → **Esc** POSTs with `targetId` omitted to clear the server lock.
- **Bindings** (in `project.godot`): **`target_tab`** → **Tab** cycles targets in the order defined by **`scripts/prototype_target_constants.gd`** (`prototype_npc_elite`, then `prototype_npc_melee`, then `prototype_npc_ranged`; matches `PrototypeNpcRegistry.GetInstanceIdsInOrder()` ascending id), wrapping from last → first. When the player reference is wired (via `set_freshness_kick`, which `main.gd` does), Tab **skips client-computed out-of-range anchors** and selects the first in-range id in cycle order so pressing Tab while visibly standing next to an anchor actually locks it instead of trying the next-in-order id that happens to be across the map. Falls back to the plain cycle when nothing is in range so the server still picks the denial reason. **`target_clear`** → **Esc** POSTs with `targetId` omitted to clear the server lock.
- **HUD:** `UICanvas/TargetLockLabel` shows the last **server-acknowledged** state: `lockedTargetId` (or **`—`** when no lock), `validity` (`none` / `ok` / `out_of_range` / `invalid_target`), `sequence`, one **display-only** line per known target in the form `<id>: <dist> m / <radius> (in|out)` (computed client-side from the live capsule position against `prototype_target_constants.gd`), and (on denials) `Denied: <reasonCode>` on the last line. **UI never shows an optimistic lock the server denied** — it always paints from the authoritative `targetState` echoed by each `200` response. Distance lines are a lag diagnostic: if the HUD shows `Validity: ok` but `alpha: 7.1 m / 6.0 (out)`, the server's position snapshot is lagging the visible capsule, and the next `authoritative_ack` refresh will reconcile. `UICanvas/PlayerPositionLabel` also shows the last **server-acknowledged** world position + horizontal delta from the visible capsule — when that `Δ` is large, targeting denials are being evaluated against a stale server snapshot.
- **`positionHint` on select (NEO-24 follow-up #5):** when `TargetSelectionClient.set_freshness_kick(authority, player)` is wired (as `main.gd` does), every `POST /target/select` body also carries `positionHint: {x,y,z}` = the live capsule position. The server uses the hint for the radius check and for the `validity` field in the echoed `targetState`, eliminating the cross-request race between `move-stream` and `target/select` POSTs — a Tab pressed several seconds after stopping (with an old stored snap on the server) now still succeeds when you are visibly in range. The hint is advisory (server never writes it to the position store); `move-stream` remains the sole write path. Tests that omit `set_freshness_kick(...)` fall back to the no-hint code path.
- **Freshness kick (ancillary):** alongside the hint, `set_freshness_kick(...)` also has the target client nudge the authority with `submit_stream_targets([player.global_position])` before each select POST. That write is no longer load-bearing for denial correctness (the hint handles that), but it keeps the stored snap reasonably fresh so the movement-triggered validity GET that runs on the next `authoritative_ack` sees current data.
- **Visible anchors:** `scripts/prototype_target_markers.gd` (under `World/PrototypeTargetMarkers`) spawns a **capsule dummy body**, floating **Label3D** name (**Dummy α** / **Dummy β**), and a flat translucent **lock ring** at every `ANCHORS` entry. **Orange** = `prototype_target_alpha` at **`(-3, 0.5, -3)`**; **purple** = `prototype_target_beta` at **`(3, 0, 3)`** — both near default spawn **`(-5, 0.9, -5)`** with overlapping 6 m rings at the origin. **Tab** locks a dummy; the locked capsule + ring **brighten**. All prototype targets share `PrototypeTargetConstants.SHARED_LOCK_RADIUS` = **6 m** (mirrors `PrototypeTargetRegistry.cs`). Keep client constants and server registry in the same commit when anchors move.
- **Visible anchors:** `scripts/prototype_target_markers.gd` (under `World/PrototypeTargetMarkers`) spawns a **prop mesh or capsule body**, floating **Label3D** short name (**Elite** / **Melee** / **Ranged**), and a flat translucent **lock ring** at every `ANCHORS` entry. **Gold** = **`prototype_npc_elite`** at origin; **orange** = **`prototype_npc_melee`** west; **purple** = **`prototype_npc_ranged`** south-east — all near default spawn **`(-5, 0.9, -5)`** with overlapping 6 m rings. **Tab** locks an NPC; the locked marker **brightens**. All prototype NPCs share `PrototypeTargetConstants.SHARED_LOCK_RADIUS` = **6 m** (mirrors `PrototypeNpcRegistry.SharedLockRadius`). Keep client constants and server registry in the same commit when anchors move.
- **Soft lock refresh:** the client does **not** poll on a timer. It refreshes via `GET …/target` on boot, on every `POST …/target/select` response (body already carries `targetState`), and — while a lock is currently held — on **`authoritative_ack`** from `PositionAuthorityClient`, throttled to at most one GET per **250 ms**. `authoritative_ack` fires on every server-confirmed position (boot `GET` 200 **and** successful `move-stream` 200), so a held lock revalidates during normal WASD locomotion without re-introducing snap rubber-banding. Boot is naturally skipped because no lock exists yet. Purely server-driven state flips (future PvP / combat categories) would require revisiting this policy; today NEO-23 has none.
- **Inspector:** match **`base_url`** / **`dev_player_id`** on the `TargetSelectionClient` node to the other HTTP clients / `Game:DevPlayerId`.
@ -141,24 +141,38 @@ Full checklist: [`docs/manual-qa/NEO-31.md`](../docs/manual-qa/NEO-31.md) · cas
- **Scripts:** `scripts/ability_cast_client.gd` (parses **`combatResolution`** on accept), `scripts/combat_targets_client.gd`; wired from `main.gd` in `_setup_hotbar_loadout_sync()` / `_setup_combat_targets_sync()`.
- **HUD:**
- **`UICanvas/CastFeedbackLabel`** — on accept: **`Cast: {damage} dmg → {remaining} HP`** or **`… — defeated!`** when **`targetDefeated`**; denies unchanged from NEO-28.
- **`UICanvas/CombatTargetHpLabel`** — locked target **`currentHp/maxHp`** (+ **` (defeated)`**); **`Target HP: — (Tab → orange/purple dummy near spawn)`** when unlocked.
- **World dummies:** **`World/PrototypeTargetMarkers`** — orange **Dummy α** west of spawn, purple **Dummy β** south-east; **Tab** locks and highlights the active dummy (see Targeting section above).
- **`UICanvas/CombatTargetHpLabel`** — locked target **`currentHp/maxHp`** (+ **` (defeated)`**); **`Target HP: — (Tab → elite/melee/ranged near spawn)`** when unlocked.
- **World markers:** **`World/PrototypeTargetMarkers`** — **Elite** (gold, origin), **Melee** (orange, west), **Ranged** (purple, south-east); **Tab** locks and highlights the active marker (see Targeting section above).
- **Refresh (event-driven):** combat-target GET after successful cast accept and when **`lockedTargetId`** changes (Tab lock / Esc clear / server correction). No periodic HP poll.
- **Dev hotbar bootstrap (NEO-85):** on first hotbar sync, if slot **0** is empty the client POSTs a bind for **`prototype_pulse`** once (same preset as Bruno `Post hotbar bind slot0 pulse`) so digit **1** works without a manual bind step when the server is up.
- **Dev fixture reset:** when the server exposes **`POST /game/__dev/combat-targets-fixture`** (Development/Testing), reset dummy HP between manual QA runs — see [server README](../server/README.md#combat-entity-health-store-neo-80).
Full checklist: [`docs/manual-qa/NEO-85.md`](../docs/manual-qa/NEO-85.md).
## NPC runtime + telegraph HUD (NEO-97)
- **`GET /game/world/npc-runtime-snapshot`** — authoritative NPC runtime rows + nested **`activeTelegraph`** with server-computed **`windupRemainingSeconds`** (NEO-94); poll advances server lazy tick.
- **`GET /game/players/{id}/combat-health`** — session player HP after NPC telegraph damage (NEO-95).
- **Scripts:** `scripts/npc_runtime_client.gd`, `scripts/player_combat_health_client.gd`, `scripts/npc_runtime_hud_state.gd` (local windup tick-down between polls); wired from `main.gd` in `_setup_npc_combat_sync()`.
- **HUD:**
- **`UICanvas/PlayerCombatHpLabel`** — **`Player HP: {current}/{max}`** (+ **` (defeated)`**).
- **`UICanvas/NpcStateLabel`** — locked NPC state + **`Incoming:`** row when player is aggro holder on a different NPC.
- **`UICanvas/TelegraphLabel`** — interpolated countdown from server snapshot (**`Telegraph: Elite … · 2.5s (elite)`**).
- **Poll gate (~1 Hz):** runs while an NPC lock is held **or** any snapshot row lists the player as **`aggroHolderPlayerId`**; also fast-path sync on lock change + cast accept.
- **Tab cycle:** **`prototype_npc_elite`** → **`prototype_npc_melee`** → **`prototype_npc_ranged`** (see **`prototype_target_constants.gd`**).
Full checklist: [`docs/manual-qa/NEO-97.md`](../docs/manual-qa/NEO-97.md).
## End-to-end combat loop (NEO-86)
Epic 5 Slice 1 capstone — tab-target lock, cast, defeat, and gig XP visibility **in Godot** without Bruno.
**Flow:** boot hotbar sync (auto **`prototype_pulse`** bind on slot 0 when empty) → walk to orange **Dummy α****Tab** lock → digit **1** cast until defeat → verify combat HUD + **`breach`** gig XP refresh.
**Flow:** boot hotbar sync (auto **`prototype_pulse`** bind on slot 0 when empty) → walk to **Melee** marker**Tab** lock → digit **1** cast until defeat → verify combat HUD + **`breach`** gig XP refresh.
| Step | Input / trigger | HUD / server outcome |
|------|-----------------|----------------------|
| Boot | Godot **F5** + server running | Hotbar slot 0 binds **`prototype_pulse`**; **`GigXpLabel`** hydrates **`breach: L1 · 0 xp`** |
| Lock | **Tab** on **`prototype_target_alpha`** | Dummy brightens; **`CombatTargetHpLabel`** shows **`100/100`** |
| Lock | **Tab** on **`prototype_npc_melee`** | Marker brightens; **`CombatTargetHpLabel`** shows **`100/100`** |
| Cast ×4 | **1** (respect ~3s cooldown) | **`CastFeedbackLabel`** steps damage; HP label tracks server snapshot |
| Defeat | 4th pulse accept | **`Cast: … — defeated!`**; HP **`0/100 (defeated)`**; **`GigXpLabel`** → **`25 xp`** |
| Deny | **1** on defeated target | **`ability_cast_denied: target_defeated`** |
@ -226,12 +240,11 @@ Full capstone checklist: [`docs/manual-qa/NEO-75.md`](../docs/manual-qa/NEO-75.m
### Manual check (NEO-24)
1. Run the server and client as in NEO-7 / NEO-9; confirm spawn snap at `(-5, 0.9, -5)`.
2. Press **Tab**: HUD → `Target: prototype_target_alpha`, `Validity: ok` (spawn is ~2.83 m from alpha's anchor `(-3, -3)`, well inside the shared 6 m radius).
3. Press **Tab** again: server denies (beta anchor `(3, 3)` is ~11.3 m from spawn, outside 6 m). The range-aware picker skips the currently-locked alpha and falls back to the cycle so the server gets a clean "swap to beta" request and can deny it; per NEO-23 soft-lock policy the persisted lock does not change, so HUD stays at `Target: prototype_target_alpha, Validity: ok` with `Denied: out_of_range` appended.
4. **WASD** away from spawn past ~6 m horizontally from alpha; HUD flips to `Validity: out_of_range` on the next movement-triggered refresh (≤250 ms cadence) while `lockedTargetId` stays `alpha` — this is the **soft lock** round-tripped from the server.
5. Walk to the **origin** (both rings visibly overlap): HUD shows `alpha` and `beta` distance lines both `(in)` (~4.24 m each). Press **Tab** to flip alpha → beta → alpha → … without moving. This is the main overlap sanity.
6. Walk toward beta (inside `(3, 3)` within 6 m, outside alpha's ring) and press **Tab** from a cleared lock: the range-aware picker skips alpha and locks beta directly. A further **Tab** from beta-only-in-range wraps to `alpha` and the server denies (same soft-lock behavior as step 3).
7. Press **Esc**: HUD → `Target: —`, `Validity: none`, sequence advances.
2. Press **Tab**: HUD → `Target: prototype_npc_elite`, `Validity: ok` (spawn is inside elite's 6 m ring at origin).
3. Press **Tab** again: cycles toward **Melee** / **Ranged**; server may deny when out of range — soft-lock policy keeps last-good lock with `Denied: out_of_range` when applicable.
4. **WASD** away past ~6 m horizontally from locked anchor; HUD flips to `Validity: out_of_range` on the next movement-triggered refresh (≤250 ms cadence) while `lockedTargetId` stays set — **soft lock** from the server.
5. Walk among overlapping rings near **origin**: distance lines show `(in)` for nearby NPCs. Press **Tab** to cycle elite → melee → ranged → wrap.
6. Press **Esc**: HUD → `Target: —`, `Validity: none`, sequence advances.
## Movement prototype (NEO-5 → NEO-11)

View File

@ -1128,179 +1128,195 @@ anchor_top = 1.0
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = 12.0
offset_top = -44.0
offset_top = -56.0
offset_right = -12.0
offset_bottom = -8.0
grow_horizontal = 2
theme_override_colors/font_color = Color(0.95, 0.75, 0.75, 1)
theme_override_colors/font_outline_color = Color(0.08, 0.08, 0.1, 1)
theme_override_constants/outline_size = 8
theme_override_font_sizes/font_size = 22
autowrap_mode = 3
[node name="PlayerPositionLabel" type="Label" parent="UICanvas" unique_id=9000003]
[node name="HudRoot" type="VBoxContainer" parent="UICanvas" unique_id=9000022]
offset_left = 8.0
offset_top = 8.0
offset_right = 360.0
offset_bottom = 106.0
grow_horizontal = 0
grow_vertical = 0
offset_right = 664.0
theme_override_constants/separation = 12
[node name="PlayerPositionLabel" type="Label" parent="UICanvas/HudRoot" unique_id=9000003]
layout_mode = 2
size_flags_horizontal = 3
theme_override_colors/font_color = Color(0.92, 0.95, 0.98, 1)
theme_override_colors/font_outline_color = Color(0.08, 0.08, 0.1, 1)
theme_override_constants/outline_size = 6
theme_override_font_sizes/font_size = 15
theme_override_constants/outline_size = 8
theme_override_font_sizes/font_size = 22
autowrap_mode = 3
text = "x: —
y: —
z: —
srv: —"
[node name="TargetLockLabel" type="Label" parent="UICanvas" unique_id=9000004]
offset_left = 8.0
offset_top = 122.0
offset_right = 360.0
offset_bottom = 306.0
grow_horizontal = 0
grow_vertical = 0
[node name="TargetLockLabel" type="Label" parent="UICanvas/HudRoot" unique_id=9000004]
layout_mode = 2
size_flags_horizontal = 3
theme_override_colors/font_color = Color(0.86, 0.94, 1, 1)
theme_override_colors/font_outline_color = Color(0.08, 0.08, 0.1, 1)
theme_override_constants/outline_size = 6
theme_override_font_sizes/font_size = 15
theme_override_constants/outline_size = 8
theme_override_font_sizes/font_size = 22
autowrap_mode = 3
text = "Target: —
Validity: —
Seq: —"
[node name="CastFeedbackLabel" type="Label" parent="UICanvas" unique_id=9000005]
offset_left = 8.0
offset_top = 318.0
offset_right = 520.0
offset_bottom = 372.0
grow_horizontal = 0
grow_vertical = 0
[node name="CastFeedbackLabel" type="Label" parent="UICanvas/HudRoot" unique_id=9000005]
layout_mode = 2
size_flags_horizontal = 3
theme_override_colors/font_color = Color(0.95, 0.88, 0.72, 1)
theme_override_colors/font_outline_color = Color(0.08, 0.08, 0.1, 1)
theme_override_constants/outline_size = 6
theme_override_font_sizes/font_size = 15
theme_override_constants/outline_size = 8
theme_override_font_sizes/font_size = 22
autowrap_mode = 3
text = "Cast: —"
[node name="CombatTargetHpLabel" type="Label" parent="UICanvas" unique_id=9000018]
offset_left = 8.0
offset_top = 374.0
offset_right = 520.0
offset_bottom = 398.0
grow_horizontal = 0
grow_vertical = 0
[node name="CombatTargetHpLabel" type="Label" parent="UICanvas/HudRoot" unique_id=9000018]
layout_mode = 2
size_flags_horizontal = 3
theme_override_colors/font_color = Color(0.92, 0.78, 0.82, 1)
theme_override_colors/font_outline_color = Color(0.08, 0.08, 0.1, 1)
theme_override_constants/outline_size = 6
theme_override_font_sizes/font_size = 15
theme_override_constants/outline_size = 8
theme_override_font_sizes/font_size = 22
autowrap_mode = 3
text = "Target HP: — (Tab → dummy)"
[node name="GatherFeedbackLabel" type="Label" parent="UICanvas" unique_id=9000008]
offset_left = 8.0
offset_top = 402.0
offset_right = 520.0
offset_bottom = 428.0
grow_horizontal = 0
grow_vertical = 0
[node name="PlayerCombatHpLabel" type="Label" parent="UICanvas/HudRoot" unique_id=9000019]
layout_mode = 2
size_flags_horizontal = 3
theme_override_colors/font_color = Color(0.82, 0.92, 0.98, 1)
theme_override_colors/font_outline_color = Color(0.08, 0.08, 0.1, 1)
theme_override_constants/outline_size = 8
theme_override_font_sizes/font_size = 22
autowrap_mode = 3
text = "Player HP: —"
[node name="NpcStateLabel" type="Label" parent="UICanvas/HudRoot" unique_id=9000020]
layout_mode = 2
size_flags_horizontal = 3
theme_override_colors/font_color = Color(0.88, 0.82, 0.98, 1)
theme_override_colors/font_outline_color = Color(0.08, 0.08, 0.1, 1)
theme_override_constants/outline_size = 8
theme_override_font_sizes/font_size = 22
autowrap_mode = 3
text = "NPC state: —"
[node name="TelegraphLabel" type="Label" parent="UICanvas/HudRoot" unique_id=9000021]
layout_mode = 2
size_flags_horizontal = 3
theme_override_colors/font_color = Color(1, 0.72, 0.55, 1)
theme_override_colors/font_outline_color = Color(0.08, 0.08, 0.1, 1)
theme_override_constants/outline_size = 8
theme_override_font_sizes/font_size = 22
autowrap_mode = 3
text = "Telegraph: —"
[node name="GatherFeedbackLabel" type="Label" parent="UICanvas/HudRoot" unique_id=9000008]
layout_mode = 2
size_flags_horizontal = 3
theme_override_colors/font_color = Color(0.88, 0.95, 0.72, 1)
theme_override_colors/font_outline_color = Color(0.08, 0.08, 0.1, 1)
theme_override_constants/outline_size = 6
theme_override_font_sizes/font_size = 14
theme_override_constants/outline_size = 8
theme_override_font_sizes/font_size = 20
autowrap_mode = 3
text = "Gather: —"
[node name="CraftFeedbackLabel" type="Label" parent="UICanvas" unique_id=9000010]
offset_left = 8.0
offset_top = 430.0
offset_right = 520.0
offset_bottom = 456.0
grow_horizontal = 0
grow_vertical = 0
[node name="CraftFeedbackLabel" type="Label" parent="UICanvas/HudRoot" unique_id=9000010]
layout_mode = 2
size_flags_horizontal = 3
theme_override_colors/font_color = Color(0.95, 0.82, 0.72, 1)
theme_override_colors/font_outline_color = Color(0.08, 0.08, 0.1, 1)
theme_override_constants/outline_size = 6
theme_override_font_sizes/font_size = 14
theme_override_constants/outline_size = 8
theme_override_font_sizes/font_size = 20
autowrap_mode = 3
text = "Craft: —"
[node name="CooldownSlotsLabel" type="Label" parent="UICanvas" unique_id=9000006]
offset_left = 8.0
offset_top = 462.0
offset_right = 520.0
offset_bottom = 538.0
grow_horizontal = 0
grow_vertical = 0
[node name="CooldownSlotsLabel" type="Label" parent="UICanvas/HudRoot" unique_id=9000006]
layout_mode = 2
size_flags_horizontal = 3
theme_override_colors/font_color = Color(0.78, 0.92, 0.86, 1)
theme_override_colors/font_outline_color = Color(0.08, 0.08, 0.1, 1)
theme_override_constants/outline_size = 6
theme_override_font_sizes/font_size = 14
theme_override_constants/outline_size = 8
theme_override_font_sizes/font_size = 20
autowrap_mode = 3
text = "Cooldowns: —"
[node name="EconomyHudSection" type="VBoxContainer" parent="UICanvas" unique_id=9000014]
offset_left = 8.0
offset_top = 550.0
offset_right = 520.0
offset_bottom = 1082.0
grow_horizontal = 0
grow_vertical = 0
[node name="EconomyHudSection" type="VBoxContainer" parent="UICanvas/HudRoot" unique_id=9000014]
layout_mode = 2
size_flags_horizontal = 3
script = ExtResource("20_economy_hud")
[node name="HeaderRow" type="HBoxContainer" parent="UICanvas/EconomyHudSection" unique_id=9000015]
[node name="HeaderRow" type="HBoxContainer" parent="UICanvas/HudRoot/EconomyHudSection" unique_id=9000015]
layout_mode = 2
[node name="ToggleButton" type="CheckButton" parent="UICanvas/EconomyHudSection/HeaderRow" unique_id=9000016]
[node name="ToggleButton" type="CheckButton" parent="UICanvas/HudRoot/EconomyHudSection/HeaderRow" unique_id=9000016]
layout_mode = 2
theme_override_font_sizes/font_size = 20
text = "Economy HUD"
[node name="Body" type="VBoxContainer" parent="UICanvas/EconomyHudSection" unique_id=9000017]
[node name="Body" type="VBoxContainer" parent="UICanvas/HudRoot/EconomyHudSection" unique_id=9000017]
layout_mode = 2
theme_override_constants/separation = 8
[node name="InventoryLabel" type="Label" parent="UICanvas/EconomyHudSection/Body" unique_id=9000007]
custom_minimum_size = Vector2(512, 148)
[node name="InventoryLabel" type="Label" parent="UICanvas/HudRoot/EconomyHudSection/Body" unique_id=9000007]
custom_minimum_size = Vector2(656, 0)
layout_mode = 2
size_flags_horizontal = 3
theme_override_colors/font_color = Color(0.9, 0.86, 0.98, 1)
theme_override_colors/font_outline_color = Color(0.08, 0.08, 0.1, 1)
theme_override_constants/outline_size = 6
theme_override_font_sizes/font_size = 14
theme_override_constants/outline_size = 8
theme_override_font_sizes/font_size = 20
autowrap_mode = 3
text = "Inventory: (refresh: I)
Loading…"
[node name="SkillProgressionLabel" type="Label" parent="UICanvas/EconomyHudSection/Body" unique_id=9000009]
custom_minimum_size = Vector2(512, 84)
[node name="SkillProgressionLabel" type="Label" parent="UICanvas/HudRoot/EconomyHudSection/Body" unique_id=9000009]
custom_minimum_size = Vector2(656, 0)
layout_mode = 2
size_flags_horizontal = 3
theme_override_colors/font_color = Color(0.82, 0.9, 1, 1)
theme_override_colors/font_outline_color = Color(0.08, 0.08, 0.1, 1)
theme_override_constants/outline_size = 6
theme_override_font_sizes/font_size = 14
theme_override_constants/outline_size = 8
theme_override_font_sizes/font_size = 20
autowrap_mode = 3
text = "Skills:
Loading…"
[node name="GigXpLabel" type="Label" parent="UICanvas/EconomyHudSection/Body" unique_id=9000018]
custom_minimum_size = Vector2(512, 48)
[node name="GigXpLabel" type="Label" parent="UICanvas/HudRoot/EconomyHudSection/Body" unique_id=9000018]
custom_minimum_size = Vector2(656, 0)
layout_mode = 2
size_flags_horizontal = 3
theme_override_colors/font_color = Color(0.88, 0.78, 1, 1)
theme_override_colors/font_outline_color = Color(0.08, 0.08, 0.1, 1)
theme_override_constants/outline_size = 6
theme_override_font_sizes/font_size = 14
theme_override_constants/outline_size = 8
theme_override_font_sizes/font_size = 20
autowrap_mode = 3
text = "Gig XP:
Loading…"
[node name="CraftRecipePanel" type="Control" parent="UICanvas/EconomyHudSection/Body" unique_id=9000011]
custom_minimum_size = Vector2(512, 280)
[node name="CraftRecipePanel" type="Control" parent="UICanvas/HudRoot/EconomyHudSection/Body" unique_id=9000011]
custom_minimum_size = Vector2(656, 320)
layout_mode = 2
size_flags_horizontal = 3
script = ExtResource("19_craft_panel")
[node name="ScrollContainer" type="ScrollContainer" parent="UICanvas/EconomyHudSection/Body/CraftRecipePanel" unique_id=9000012]
layout_mode = 0
offset_right = 512.0
offset_bottom = 280.0
[node name="ScrollContainer" type="ScrollContainer" parent="UICanvas/HudRoot/EconomyHudSection/Body/CraftRecipePanel" unique_id=9000012]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
[node name="VBoxContainer" type="VBoxContainer" parent="UICanvas/EconomyHudSection/Body/CraftRecipePanel/ScrollContainer" unique_id=9000013]
[node name="VBoxContainer" type="VBoxContainer" parent="UICanvas/HudRoot/EconomyHudSection/Body/CraftRecipePanel/ScrollContainer" unique_id=9000013]
layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/separation = 8

View File

@ -4,6 +4,8 @@ extends Control
signal craft_requested(recipe_id: String)
const PrototypeHudTheme := preload("res://scripts/prototype_hud_theme.gd")
var _item_defs_client: Node = null
var _craft_buttons: Array[Button] = []
@ -33,9 +35,11 @@ func populate(recipes: Array) -> void:
info.size_flags_horizontal = Control.SIZE_EXPAND_FILL
info.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
info.text = _format_recipe_line(recipe)
PrototypeHudTheme.apply_to_label(info, false)
row.add_child(info)
var craft_btn := Button.new()
craft_btn.text = "Craft"
PrototypeHudTheme.apply_to_button(craft_btn, false)
craft_btn.pressed.connect(_on_craft_pressed.bind(recipe_id))
row.add_child(craft_btn)
_vbox.add_child(row)

View File

@ -17,7 +17,7 @@ extends Node3D
## Steep ramp: **2 m / 1.5 m** rise/run (~53°) so slope exceeds `Player` `floor_max_angle` (~50°)
## and is not walkable; gentle ramp stays shallower than `NavigationMesh.agent_max_climb`. Steep is
## under `World` only (not nav source geometry).
## Prototype HUD: world `CharacterBody3D` position in `UICanvas/PlayerPositionLabel`
## Prototype HUD: world `CharacterBody3D` position in `UICanvas/HudRoot/PlayerPositionLabel`
## (updated in `_physics_process` so it matches physics ticks).
const MOVE_REJECT_MSG_SECONDS: float = 4.0
@ -42,6 +42,12 @@ const PrototypeTargetConstants := preload("res://scripts/prototype_target_consta
const PrototypeInteractablePicker := preload("res://scripts/prototype_interactable_picker.gd")
const HotbarCastSlotResolver := preload("res://scripts/hotbar_cast_slot_resolver.gd")
const CooldownStateScript := preload("res://scripts/cooldown_state.gd")
const NpcRuntimeClientScript := preload("res://scripts/npc_runtime_client.gd")
const PlayerCombatHealthClientScript := preload("res://scripts/player_combat_health_client.gd")
const NpcRuntimeHudStateScript := preload("res://scripts/npc_runtime_hud_state.gd")
const NpcCombatHudHelpers := preload("res://scripts/npc_combat_hud_helpers.gd")
const PrototypeHudTheme := preload("res://scripts/prototype_hud_theme.gd")
const COMBAT_POLL_INTERVAL_SEC: float = 1.0
const COOLDOWN_REASON_ON_CD := "on_cooldown"
const DEV_BOOTSTRAP_CAST_ABILITY_ID := "prototype_pulse"
const SCRAP_METAL_BULK_ID := "scrap_metal_bulk"
@ -79,9 +85,18 @@ var _hotbar_client: Node = null
var _ability_cast_client: Node = null
var _combat_targets_client: Node = null
var _combat_targets_snapshot: Dictionary = {}
var _npc_runtime_client: Node = null
var _player_combat_health_client: Node = null
var _npc_runtime_hud_state: RefCounted = null
var _npc_runtime_snapshot: Dictionary = {}
var _npc_runtime_sync_error: String = ""
var _player_combat_health_snapshot: Dictionary = {}
var _player_combat_health_error: String = ""
var _cooldown_state: RefCounted = null
var _cooldown_client: Node = null
var _cooldown_poll_timer: Timer = null
var _combat_poll_timer: Timer = null
var _npc_combat_hud_needs_tick: bool = false
var _last_inventory_snapshot: Dictionary = {}
var _inventory_error: String = ""
var _interactables_catalog: Array = []
@ -105,15 +120,19 @@ var _dev_pulse_bootstrap_attempted: bool = false
@onready var _interactables_root: Node3D = $World/NavigationRegion3D/InteractablesRoot
@onready var _radius_preview: Node3D = $World/InteractionMarkers
@onready var _move_reject_label: Label = $UICanvas/MoveRejectLabel
@onready var _player_pos_label: Label = $UICanvas/PlayerPositionLabel
@onready var _target_lock_label: Label = $UICanvas/TargetLockLabel
@onready var _cast_feedback_label: Label = $UICanvas/CastFeedbackLabel
@onready var _combat_target_hp_label: Label = $UICanvas/CombatTargetHpLabel
@onready var _cooldown_slots_label: Label = $UICanvas/CooldownSlotsLabel
@onready var _economy_hud_section: VBoxContainer = $UICanvas/EconomyHudSection
@onready var _hud_root: VBoxContainer = $UICanvas/HudRoot
@onready var _player_pos_label: Label = $UICanvas/HudRoot/PlayerPositionLabel
@onready var _target_lock_label: Label = $UICanvas/HudRoot/TargetLockLabel
@onready var _cast_feedback_label: Label = $UICanvas/HudRoot/CastFeedbackLabel
@onready var _combat_target_hp_label: Label = $UICanvas/HudRoot/CombatTargetHpLabel
@onready var _player_combat_hp_label: Label = $UICanvas/HudRoot/PlayerCombatHpLabel
@onready var _npc_state_label: Label = $UICanvas/HudRoot/NpcStateLabel
@onready var _telegraph_label: Label = $UICanvas/HudRoot/TelegraphLabel
@onready var _cooldown_slots_label: Label = $UICanvas/HudRoot/CooldownSlotsLabel
@onready var _economy_hud_section: VBoxContainer = $UICanvas/HudRoot/EconomyHudSection
@onready var _inventory_label: Label = _economy_hud_section.get_node("Body/InventoryLabel")
@onready var _gather_feedback_label: Label = $UICanvas/GatherFeedbackLabel
@onready var _craft_feedback_label: Label = $UICanvas/CraftFeedbackLabel
@onready var _gather_feedback_label: Label = $UICanvas/HudRoot/GatherFeedbackLabel
@onready var _craft_feedback_label: Label = $UICanvas/HudRoot/CraftFeedbackLabel
@onready
var _skill_progression_label: Label = _economy_hud_section.get_node("Body/SkillProgressionLabel")
@onready var _gig_xp_label: Label = _economy_hud_section.get_node("Body/GigXpLabel")
@ -133,6 +152,7 @@ var _skill_progression_label: Label = _economy_hud_section.get_node("Body/SkillP
func _ready() -> void:
set_physics_process(true)
set_process_unhandled_key_input(true)
_apply_prototype_hud_theme()
await get_tree().process_frame
_dev_obstacle_smoke = get_node_or_null("World/NavigationRegion3D/Obstacle") as Node3D
# Runtime load + .call avoids class_name / preload static resolution issues across Godot parses.
@ -194,6 +214,8 @@ func _physics_process(_delta: float) -> void:
if _cooldown_state != null and _cooldown_state.has_method("any_slot_cooling"):
if bool(_cooldown_state.call("any_slot_cooling")):
_render_cooldown_slots_label()
if _npc_combat_hud_needs_tick:
_render_npc_combat_hud_labels()
## HUD: client position on top, and (once a `move-stream` / boot GET has landed) the
@ -266,6 +288,12 @@ func _on_authoritative_position(world: Vector3, apply_as_snap: bool) -> void:
## [code]push_warning[/code] in [code]ability_cast_client.gd[/code].
## HUD [member _cast_feedback_label] (NEO-28). Deny branches in [code]AbilityCastApi.cs[/code].
## TODO(E9.M1): catalog + ingest.
func _apply_prototype_hud_theme() -> void:
PrototypeHudTheme.apply_to_label(_move_reject_label, true)
if is_instance_valid(_hud_root):
PrototypeHudTheme.apply_to_hud_root(_hud_root)
func _on_authoritative_ack_for_hud(world: Vector3) -> void:
_last_ack_world = world
_have_last_ack = true
@ -277,16 +305,19 @@ func _on_authoritative_ack_for_hud(world: Vector3) -> void:
## live distance readouts stay in sync with the player capsule even while
## the server state is idle.
func _on_target_state_changed(state: Dictionary) -> void:
var prev_lock := _locked_target_id_from_state(_last_target_state)
var prev_lock := NpcCombatHudHelpers.locked_target_id_from_state(_last_target_state)
_last_target_state = state.duplicate()
if is_instance_valid(_player):
_render_target_lock_label(_player.global_position)
_render_combat_target_hp_label()
var new_lock := _locked_target_id_from_state(_last_target_state)
var new_lock := NpcCombatHudHelpers.locked_target_id_from_state(_last_target_state)
if is_instance_valid(_target_markers) and _target_markers.has_method("set_locked_target"):
_target_markers.call("set_locked_target", new_lock)
if new_lock != prev_lock:
_request_combat_targets_refresh()
_update_combat_poll_gate()
if _is_combat_active():
_request_npc_combat_sync()
func _setup_hotbar_loadout_sync() -> void:
@ -318,6 +349,7 @@ func _setup_hotbar_loadout_sync() -> void:
if _hotbar_client.has_method("request_sync_from_server"):
_hotbar_client.call("request_sync_from_server")
_setup_combat_targets_sync()
_setup_npc_combat_sync()
_setup_cooldown_sync()
@ -333,6 +365,35 @@ func _setup_combat_targets_sync() -> void:
)
func _setup_npc_combat_sync() -> void:
# NEO-97: npc-runtime snapshot + session player combat HP for telegraph HUD.
_npc_runtime_hud_state = NpcRuntimeHudStateScript.new()
_npc_runtime_client = NpcRuntimeClientScript.new()
_npc_runtime_client.name = "NpcRuntimeClient"
add_child(_npc_runtime_client)
_apply_authority_http_config_to_client(_npc_runtime_client)
_player_combat_health_client = PlayerCombatHealthClientScript.new()
_player_combat_health_client.name = "PlayerCombatHealthClient"
add_child(_player_combat_health_client)
_apply_authority_http_config_to_client(_player_combat_health_client)
if _npc_runtime_client.has_signal("runtime_received"):
_npc_runtime_client.connect("runtime_received", Callable(self, "_on_npc_runtime_received"))
if _npc_runtime_client.has_signal("runtime_sync_failed"):
_npc_runtime_client.connect(
"runtime_sync_failed", Callable(self, "_on_npc_runtime_sync_failed")
)
if _player_combat_health_client.has_signal("health_received"):
_player_combat_health_client.connect(
"health_received", Callable(self, "_on_player_combat_health_received")
)
if _player_combat_health_client.has_signal("health_sync_failed"):
_player_combat_health_client.connect(
"health_sync_failed", Callable(self, "_on_player_combat_health_sync_failed")
)
_ensure_combat_poll_timer()
_render_npc_combat_hud_labels()
func _setup_cooldown_sync() -> void:
# NEO-32: server cooldown snapshot + local mirror for HUD and cast spam guard.
_cooldown_state = CooldownStateScript.new()
@ -366,6 +427,17 @@ func _ensure_cooldown_poll_timer() -> void:
add_child(_cooldown_poll_timer)
func _ensure_combat_poll_timer() -> void:
if is_instance_valid(_combat_poll_timer):
return
_combat_poll_timer = Timer.new()
_combat_poll_timer.name = "CombatPollTimer"
_combat_poll_timer.one_shot = false
_combat_poll_timer.wait_time = COMBAT_POLL_INTERVAL_SEC
_combat_poll_timer.timeout.connect(_on_combat_poll_timeout)
add_child(_combat_poll_timer)
func _setup_inventory_sync() -> void:
# NEO-72: item defs for display names, then inventory GET for bag/equipment HUD.
_apply_authority_http_config_to_client(_item_defs_client)
@ -928,6 +1000,9 @@ func _on_cast_result_received(accepted: bool, reason_code: String, resolution: D
if _cooldown_client.has_method("request_sync_from_server"):
_cooldown_client.call("request_sync_from_server")
_request_combat_targets_refresh()
_update_combat_poll_gate()
if _is_combat_active():
_request_npc_combat_sync()
if bool(resolution.get("targetDefeated", false)):
_request_gig_progression_refresh()
return
@ -958,17 +1033,10 @@ func _request_combat_targets_refresh() -> void:
_combat_targets_client.call("request_sync_from_server")
func _locked_target_id_from_state(state: Dictionary) -> String:
var locked_variant: Variant = state.get("lockedTargetId", null)
if locked_variant is String:
return (locked_variant as String).strip_edges()
return ""
func _render_combat_target_hp_label() -> void:
if not is_instance_valid(_combat_target_hp_label):
return
var lock_id := _locked_target_id_from_state(_last_target_state)
var lock_id := NpcCombatHudHelpers.locked_target_id_from_state(_last_target_state)
if lock_id.is_empty():
_combat_target_hp_label.text = ("Target HP: — (Tab → elite/melee/ranged near spawn)")
return
@ -992,6 +1060,120 @@ func _render_combat_target_hp_label() -> void:
)
func _dev_player_id() -> String:
if is_instance_valid(_authority):
var player_id_variant: Variant = _authority.get("dev_player_id")
if player_id_variant is String and not (player_id_variant as String).is_empty():
return (player_id_variant as String).strip_edges()
return "dev-local-1"
func _is_combat_active() -> bool:
return NpcCombatHudHelpers.is_combat_active(
_last_target_state, _npc_runtime_snapshot, _dev_player_id()
)
func _resolve_hud_npc_rows() -> Dictionary:
return NpcCombatHudHelpers.resolve_hud_npc_rows(
_last_target_state, _npc_runtime_snapshot, _dev_player_id(), _npc_runtime_client
)
func _request_npc_combat_sync() -> void:
if is_instance_valid(_npc_runtime_client):
if _npc_runtime_client.has_method("request_sync_from_server"):
_npc_runtime_client.call("request_sync_from_server")
if is_instance_valid(_player_combat_health_client):
if _player_combat_health_client.has_method("request_sync_from_server"):
_player_combat_health_client.call("request_sync_from_server")
func _update_combat_poll_gate() -> void:
_ensure_combat_poll_timer()
if not is_instance_valid(_combat_poll_timer):
return
if _is_combat_active():
if _combat_poll_timer.is_stopped():
_combat_poll_timer.start()
return
_combat_poll_timer.stop()
_npc_combat_hud_needs_tick = false
_render_npc_combat_hud_labels()
func _on_combat_poll_timeout() -> void:
if not _is_combat_active():
_update_combat_poll_gate()
return
_request_npc_combat_sync()
func _on_npc_runtime_received(snapshot: Dictionary) -> void:
_npc_runtime_sync_error = ""
_npc_runtime_snapshot = snapshot.duplicate(true)
if _npc_runtime_hud_state != null and _npc_runtime_hud_state.has_method("apply_snapshot"):
_npc_runtime_hud_state.call("apply_snapshot", _npc_runtime_snapshot)
_update_combat_poll_gate()
_render_npc_combat_hud_labels()
func _on_npc_runtime_sync_failed(reason: String) -> void:
_npc_runtime_sync_error = reason
_render_npc_combat_hud_labels()
func _on_player_combat_health_received(snapshot: Dictionary) -> void:
_player_combat_health_error = ""
_player_combat_health_snapshot = snapshot.duplicate(true)
_render_player_combat_hp_label()
func _on_player_combat_health_sync_failed(reason: String) -> void:
_player_combat_health_error = reason
_player_combat_health_snapshot = {}
_render_player_combat_hp_label()
func _render_player_combat_hp_label() -> void:
if not is_instance_valid(_player_combat_hp_label):
return
if not _player_combat_health_error.is_empty():
_player_combat_hp_label.text = "Player HP: — (%s)" % _player_combat_health_error
return
if _player_combat_health_snapshot.is_empty():
_player_combat_hp_label.text = "Player HP: —"
return
var current: int = int(_player_combat_health_snapshot.get("currentHp", 0))
var max_hp: int = int(_player_combat_health_snapshot.get("maxHp", 0))
var defeated: bool = bool(_player_combat_health_snapshot.get("defeated", false))
var defeated_suffix := " (defeated)" if defeated else ""
_player_combat_hp_label.text = "Player HP: %d/%d%s" % [current, max_hp, defeated_suffix]
func _render_npc_combat_hud_labels() -> void:
var rows: Dictionary = _resolve_hud_npc_rows()
_render_npc_state_label(rows)
_render_telegraph_label(rows)
_npc_combat_hud_needs_tick = NpcCombatHudHelpers.has_interpolated_telegraph_display(
rows, _npc_runtime_hud_state
)
func _render_npc_state_label(rows: Dictionary) -> void:
if not is_instance_valid(_npc_state_label):
return
_npc_state_label.text = NpcCombatHudHelpers.build_npc_state_label(rows, _npc_runtime_sync_error)
func _render_telegraph_label(rows: Dictionary) -> void:
if not is_instance_valid(_telegraph_label):
return
_telegraph_label.text = NpcCombatHudHelpers.build_telegraph_label(
rows, _npc_runtime_hud_state, _npc_runtime_sync_error
)
func _on_move_rejected(reason_code: String) -> void:
_authority_force_snap_next = true
# Rejected stream: server state may differ; next snap is forced.

View File

@ -0,0 +1,147 @@
extends RefCounted
## NEO-97: shared combat-active gate + NPC/telegraph HUD copy for [code]main.gd[/code] and tests.
const PrototypeTargetConstants := preload("res://scripts/prototype_target_constants.gd")
const NpcRuntimeClient := preload("res://scripts/npc_runtime_client.gd")
static func locked_target_id_from_state(state: Dictionary) -> String:
var locked_variant: Variant = state.get("lockedTargetId", null)
if locked_variant is String:
return (locked_variant as String).strip_edges()
return ""
static func is_known_npc_id(target_id: String) -> bool:
return PrototypeTargetConstants.ANCHORS.has(target_id.strip_edges())
static func incoming_aggro_row(
dev_player_id: String, npc_runtime_snapshot: Dictionary
) -> Dictionary:
if npc_runtime_snapshot.is_empty():
return {}
return NpcRuntimeClient.row_for_aggro_holder(dev_player_id, npc_runtime_snapshot)
static func is_combat_active(
last_target_state: Dictionary, npc_runtime_snapshot: Dictionary, dev_player_id: String
) -> bool:
var lock_id := locked_target_id_from_state(last_target_state)
if is_known_npc_id(lock_id):
return true
return not incoming_aggro_row(dev_player_id, npc_runtime_snapshot).is_empty()
static func resolve_hud_npc_rows(
last_target_state: Dictionary,
npc_runtime_snapshot: Dictionary,
dev_player_id: String,
runtime_client: Node = null
) -> Dictionary:
var lock_id := locked_target_id_from_state(last_target_state)
var locked_row: Dictionary = {}
if is_known_npc_id(lock_id):
if is_instance_valid(runtime_client) and runtime_client.has_method("npc_row"):
locked_row = runtime_client.call("npc_row", lock_id, npc_runtime_snapshot) as Dictionary
else:
locked_row = NpcRuntimeClient.npc_row_in_snapshot(lock_id, npc_runtime_snapshot)
var incoming_row: Dictionary = {}
var aggro_row: Dictionary = incoming_aggro_row(dev_player_id, npc_runtime_snapshot)
if not aggro_row.is_empty():
var aggro_id: String = str(aggro_row.get("npcInstanceId", "")).strip_edges()
if aggro_id != lock_id:
incoming_row = aggro_row
return {"locked": locked_row, "incoming": incoming_row}
static func build_npc_state_label(rows: Dictionary, runtime_sync_error: String = "") -> String:
var lines: PackedStringArray = PackedStringArray()
var locked_row: Dictionary = rows.get("locked", {}) as Dictionary
if not locked_row.is_empty():
var locked_id: String = str(locked_row.get("npcInstanceId", ""))
(
lines
. append(
(
"NPC state: %s%s"
% [
PrototypeTargetConstants.display_short_name(locked_id),
str(locked_row.get("state", "unknown")),
]
)
)
)
var incoming_row: Dictionary = rows.get("incoming", {}) as Dictionary
if not incoming_row.is_empty():
var incoming_id: String = str(incoming_row.get("npcInstanceId", ""))
(
lines
. append(
(
"Incoming: %s%s"
% [
PrototypeTargetConstants.display_short_name(incoming_id),
str(incoming_row.get("state", "unknown")),
]
)
)
)
if lines.is_empty():
if not runtime_sync_error.is_empty():
return "NPC state: — (%s)" % runtime_sync_error
return "NPC state: —"
if not runtime_sync_error.is_empty():
lines.append("(runtime sync: %s)" % runtime_sync_error)
return "\n".join(lines)
static func format_telegraph_line(row: Dictionary, hud_state: RefCounted = null) -> String:
var instance_id: String = str(row.get("npcInstanceId", "")).strip_edges()
var telegraph: Variant = row.get("activeTelegraph", null)
if telegraph == null or not telegraph is Dictionary:
return ""
var telegraph_dict: Dictionary = telegraph as Dictionary
var remaining: float = float(telegraph_dict.get("windupRemainingSeconds", 0.0))
if hud_state != null and hud_state.has_method("display_windup_remaining"):
remaining = float(hud_state.call("display_windup_remaining", instance_id))
if remaining <= 0.0:
return ""
var short_name: String = PrototypeTargetConstants.display_short_name(instance_id)
var telegraph_id: String = str(telegraph_dict.get("telegraphId", ""))
var archetype: String = str(telegraph_dict.get("archetypeKind", ""))
return "Telegraph: %s %s · %.1fs (%s)" % [short_name, telegraph_id, remaining, archetype]
static func build_telegraph_label(
rows: Dictionary, hud_state: RefCounted = null, runtime_sync_error: String = ""
) -> String:
var locked_row: Dictionary = rows.get("locked", {}) as Dictionary
var line: String = format_telegraph_line(locked_row, hud_state)
if line.is_empty():
line = format_telegraph_line(rows.get("incoming", {}) as Dictionary, hud_state)
if line.is_empty():
if not runtime_sync_error.is_empty():
return "Telegraph: — (%s)" % runtime_sync_error
return "Telegraph: —"
return line
static func has_interpolated_telegraph_display(rows: Dictionary, hud_state: RefCounted) -> bool:
if hud_state == null:
return false
for key: String in ["locked", "incoming"]:
var row: Dictionary = rows.get(key, {}) as Dictionary
if row.is_empty():
continue
var instance_id: String = str(row.get("npcInstanceId", "")).strip_edges()
if instance_id.is_empty():
continue
var telegraph: Variant = row.get("activeTelegraph", null)
if telegraph == null or not telegraph is Dictionary:
continue
if hud_state.has_method("has_active_windup"):
if bool(hud_state.call("has_active_windup", instance_id)):
return true
return false

View File

@ -0,0 +1 @@
uid://bneo97hudhlpr1

View File

@ -0,0 +1,121 @@
extends Node
## NEO-97: prototype HTTP client for [code]GET /game/world/npc-runtime-snapshot[/code] (NEO-94).
signal runtime_received(snapshot: Dictionary)
signal runtime_sync_failed(reason: String)
const SCHEMA_VERSION := 1
@export var base_url: String = "http://127.0.0.1:5253"
@export var injected_http: Node = null
var _http: Node
var _busy: bool = false
var _last_snapshot: Dictionary = {}
func _ready() -> void:
if injected_http != null:
_http = injected_http
else:
_http = HTTPRequest.new()
add_child(_http)
if _http is HTTPRequest:
(_http as HTTPRequest).timeout = 30.0
@warning_ignore("unsafe_method_access")
_http.request_completed.connect(_on_request_completed)
func request_sync_from_server() -> void:
if _busy:
return
_busy = true
var url := "%s/game/world/npc-runtime-snapshot" % _base_root()
var err: Error = _http.request(url)
if err != OK:
var reason := "GET failed to start (%s)" % err
push_warning("NpcRuntimeClient: %s" % reason)
_busy = false
runtime_sync_failed.emit(reason)
static func parse_runtime_json(text: String) -> Variant:
var parsed: Variant = JSON.parse_string(text)
if not parsed is Dictionary:
return null
var root: Dictionary = parsed
if int(root.get("schemaVersion", -1)) != SCHEMA_VERSION:
return null
var instances: Variant = root.get("npcInstances", null)
if instances == null or not instances is Array:
return null
return root
func npc_row(instance_id: String, snapshot: Dictionary = {}) -> Dictionary:
var source: Dictionary = snapshot
if source.is_empty():
source = _last_snapshot
return npc_row_in_snapshot(instance_id, source)
static func npc_row_in_snapshot(instance_id: String, snapshot: Dictionary) -> Dictionary:
var source: Dictionary = snapshot
if source.is_empty():
return {}
var instances: Variant = source.get("npcInstances", null)
if instances == null or not instances is Array:
return {}
for row_variant in instances as Array:
if not row_variant is Dictionary:
continue
var row: Dictionary = row_variant
if str(row.get("npcInstanceId", "")) == instance_id:
return row
return {}
static func row_for_aggro_holder(player_id: String, snapshot: Dictionary = {}) -> Dictionary:
var holder: String = player_id.strip_edges()
if holder.is_empty() or snapshot.is_empty():
return {}
var instances: Variant = snapshot.get("npcInstances", null)
if instances == null or not instances is Array:
return {}
for row_variant in instances as Array:
if not row_variant is Dictionary:
continue
var row: Dictionary = row_variant
if str(row.get("aggroHolderPlayerId", "")) == holder:
return row
return {}
func _base_root() -> String:
return base_url.strip_edges().rstrip("/")
func _on_request_completed(
result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray
) -> void:
_busy = false
if result != HTTPRequest.RESULT_SUCCESS:
var reason := "HTTP failed (result=%s)" % result
push_warning("NpcRuntimeClient: %s" % reason)
runtime_sync_failed.emit(reason)
return
if response_code < 200 or response_code >= 300:
var reason_code := "HTTP %s" % response_code
push_warning("NpcRuntimeClient: %s" % reason_code)
runtime_sync_failed.emit(reason_code)
return
var text := body.get_string_from_utf8()
var snapshot: Variant = parse_runtime_json(text)
if snapshot == null:
var reason_json := "non-JSON body or schemaVersion mismatch"
push_warning("NpcRuntimeClient: %s" % reason_json)
runtime_sync_failed.emit(reason_json)
return
_last_snapshot = snapshot as Dictionary
runtime_received.emit(_last_snapshot)

View File

@ -0,0 +1 @@
uid://bneo97npcrt01

View File

@ -0,0 +1,45 @@
extends RefCounted
## NEO-97: local windup display mirror between [code]npc-runtime-snapshot[/code] polls.
## Uses receive tick + server [code]windupRemainingSeconds[/code] — no client windup scheduling.
var _windup_at_receive: Dictionary = {}
var _ticks_msec_at_receive: Dictionary = {}
func apply_snapshot(snapshot: Dictionary) -> void:
_windup_at_receive.clear()
_ticks_msec_at_receive.clear()
var now_msec: int = Time.get_ticks_msec()
var instances: Variant = snapshot.get("npcInstances", null)
if instances == null or not instances is Array:
return
for row_variant in instances as Array:
if not row_variant is Dictionary:
continue
var row: Dictionary = row_variant
var instance_id: String = str(row.get("npcInstanceId", "")).strip_edges()
if instance_id.is_empty():
continue
var telegraph: Variant = row.get("activeTelegraph", null)
if telegraph == null or not telegraph is Dictionary:
continue
var remaining: float = float((telegraph as Dictionary).get("windupRemainingSeconds", 0.0))
if remaining <= 0.0:
continue
_windup_at_receive[instance_id] = remaining
_ticks_msec_at_receive[instance_id] = now_msec
func display_windup_remaining(instance_id: String) -> float:
var id: String = instance_id.strip_edges()
if id.is_empty() or not _windup_at_receive.has(id):
return 0.0
var anchor: float = float(_windup_at_receive[id])
var start_msec: int = int(_ticks_msec_at_receive.get(id, 0))
var elapsed: float = (Time.get_ticks_msec() - start_msec) * 0.001
return maxf(0.0, anchor - elapsed)
func has_active_windup(instance_id: String) -> bool:
return display_windup_remaining(instance_id) > 0.0

View File

@ -0,0 +1 @@
uid://bneo97hudst01

View File

@ -0,0 +1,89 @@
extends Node
## NEO-97: prototype HTTP client for [code]GET /game/players/{id}/combat-health[/code] (NEO-95).
signal health_received(snapshot: Dictionary)
signal health_sync_failed(reason: String)
const SCHEMA_VERSION := 1
@export var base_url: String = "http://127.0.0.1:5253"
@export var dev_player_id: String = "dev-local-1"
@export var injected_http: Node = null
var _http: Node
var _busy: bool = false
func _ready() -> void:
if injected_http != null:
_http = injected_http
else:
_http = HTTPRequest.new()
add_child(_http)
if _http is HTTPRequest:
(_http as HTTPRequest).timeout = 30.0
@warning_ignore("unsafe_method_access")
_http.request_completed.connect(_on_request_completed)
func request_sync_from_server() -> void:
if _busy:
return
_busy = true
var url := "%s/game/players/%s/combat-health" % [_base_root(), _player_path_segment()]
var err: Error = _http.request(url)
if err != OK:
var reason := "GET failed to start (%s)" % err
push_warning("PlayerCombatHealthClient: %s" % reason)
_busy = false
health_sync_failed.emit(reason)
static func parse_combat_health_json(text: String) -> Variant:
var parsed: Variant = JSON.parse_string(text)
if not parsed is Dictionary:
return null
var root: Dictionary = parsed
if int(root.get("schemaVersion", -1)) != SCHEMA_VERSION:
return null
if not root.has("currentHp") or not root.has("maxHp"):
return null
return root
func _base_root() -> String:
return base_url.strip_edges().rstrip("/")
func _player_path_segment() -> String:
return dev_player_id.strip_edges()
func _on_request_completed(
result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray
) -> void:
_busy = false
if result != HTTPRequest.RESULT_SUCCESS:
var reason := "HTTP failed (result=%s)" % result
push_warning("PlayerCombatHealthClient: %s" % reason)
health_sync_failed.emit(reason)
return
if response_code == 404:
var reason_404 := "HTTP 404 (player unknown)"
push_warning("PlayerCombatHealthClient: %s" % reason_404)
health_sync_failed.emit(reason_404)
return
if response_code < 200 or response_code >= 300:
var reason_code := "HTTP %s" % response_code
push_warning("PlayerCombatHealthClient: %s" % reason_code)
health_sync_failed.emit(reason_code)
return
var text := body.get_string_from_utf8()
var snapshot: Variant = parse_combat_health_json(text)
if snapshot == null:
var reason_json := "non-JSON body or schemaVersion mismatch"
push_warning("PlayerCombatHealthClient: %s" % reason_json)
health_sync_failed.emit(reason_json)
return
health_received.emit(snapshot as Dictionary)

View File

@ -0,0 +1 @@
uid://bneo97plrhp01

View File

@ -0,0 +1,44 @@
extends RefCounted
## Applies shared prototype HUD font sizing to scene labels and dynamic craft rows.
const Constants := preload("res://scripts/prototype_hud_theme_constants.gd")
static func apply_to_label(label: Label, primary: bool = true) -> void:
if not is_instance_valid(label):
return
var font_size: int = Constants.FONT_SIZE_PRIMARY if primary else Constants.FONT_SIZE_SECONDARY
label.add_theme_font_size_override("font_size", font_size)
label.add_theme_constant_override("outline_size", Constants.OUTLINE_SIZE)
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
label.custom_minimum_size.x = Constants.HUD_STACK_WIDTH
static func apply_to_button(button: BaseButton, primary: bool = true) -> void:
if not is_instance_valid(button):
return
var font_size: int = Constants.FONT_SIZE_PRIMARY if primary else Constants.FONT_SIZE_SECONDARY
button.add_theme_font_size_override("font_size", font_size)
static func apply_to_hud_root(root: VBoxContainer) -> void:
if not is_instance_valid(root):
return
root.add_theme_constant_override("separation", Constants.STACK_SEPARATION)
for child in root.get_children():
if child is Label:
var label: Label = child as Label
apply_to_label(label, not Constants.SECONDARY_LABEL_NAMES.has(label.name))
elif child.name == "EconomyHudSection":
apply_to_economy_section(child)
static func apply_to_economy_section(section: Node) -> void:
if not is_instance_valid(section):
return
for label in section.find_children("*", "Label", true, false):
apply_to_label(label as Label, false)
var toggle: Node = section.get_node_or_null("HeaderRow/ToggleButton")
if toggle is BaseButton:
apply_to_button(toggle as BaseButton, false)

View File

@ -0,0 +1 @@
uid://c8hudthemeapply01

View File

@ -0,0 +1,15 @@
extends RefCounted
## Prototype HUD typography + stack spacing (NEO-97 readability pass).
const FONT_SIZE_PRIMARY: int = 22
const FONT_SIZE_SECONDARY: int = 20
const OUTLINE_SIZE: int = 8
const STACK_SEPARATION: int = 12
const HUD_STACK_WIDTH: float = 656.0
const SECONDARY_LABEL_NAMES: Array[StringName] = [
&"GatherFeedbackLabel",
&"CraftFeedbackLabel",
&"CooldownSlotsLabel",
]

View File

@ -0,0 +1 @@
uid://c8hudthemeconst01

View File

@ -0,0 +1,160 @@
extends GdUnitTestSuite
## NEO-97: combat-active gate + HUD label copy from snapshot fixtures.
const NpcRuntimeClient := preload("res://scripts/npc_runtime_client.gd")
const NpcRuntimeHudState := preload("res://scripts/npc_runtime_hud_state.gd")
const NpcCombatHudHelpers := preload("res://scripts/npc_combat_hud_helpers.gd")
static func _elite_aggro_snapshot_json() -> String:
return (
'{"schemaVersion":1,"serverTimeUtc":"2026-05-29T12:00:00Z","npcInstances":['
+ '{"npcInstanceId":"prototype_npc_elite","behaviorDefId":"prototype_elite_boss",'
+ '"state":"aggro","aggroHolderPlayerId":"dev-local-1","activeTelegraph":null},'
+ '{"npcInstanceId":"prototype_npc_melee","behaviorDefId":"prototype_melee_rush",'
+ '"state":"idle","aggroHolderPlayerId":null,"activeTelegraph":null}'
+ "]}"
)
static func _elite_telegraph_snapshot_json() -> String:
return (
'{"schemaVersion":1,"serverTimeUtc":"2026-05-29T12:00:00Z","npcInstances":['
+ '{"npcInstanceId":"prototype_npc_elite","behaviorDefId":"prototype_elite_boss",'
+ '"state":"telegraph_windup","aggroHolderPlayerId":"dev-local-1",'
+ '"activeTelegraph":{"telegraphId":"prototype_elite_slam","npcInstanceId":'
+ '"prototype_npc_elite","windupRemainingSeconds":2.5,"archetypeKind":"elite"}},'
+ '{"npcInstanceId":"prototype_npc_melee","behaviorDefId":"prototype_melee_rush",'
+ '"state":"idle","aggroHolderPlayerId":null,"activeTelegraph":null}'
+ "]}"
)
func test_npc_lock_makes_combat_active_without_aggro_snapshot() -> void:
# Arrange
var target_state := {"lockedTargetId": "prototype_npc_melee", "validity": "ok"}
# Act
var active: bool = NpcCombatHudHelpers.is_combat_active(target_state, {}, "dev-local-1")
# Assert
assert_that(active).is_true()
func test_aggro_holder_makes_combat_active_without_lock() -> void:
# Arrange
var parsed: Variant = NpcRuntimeClient.parse_runtime_json(_elite_aggro_snapshot_json())
# Act
var active: bool = NpcCombatHudHelpers.is_combat_active({}, parsed as Dictionary, "dev-local-1")
# Assert
assert_that(active).is_true()
func test_idle_without_lock_is_not_combat_active() -> void:
# Arrange
# Act
var active: bool = NpcCombatHudHelpers.is_combat_active({}, {}, "dev-local-1")
# Assert
assert_that(active).is_false()
func test_npc_state_label_resets_when_lock_cleared_without_aggro() -> void:
# Arrange
var cleared_target := {"lockedTargetId": null, "validity": "none"}
var rows: Dictionary = NpcCombatHudHelpers.resolve_hud_npc_rows(
cleared_target, {}, "dev-local-1"
)
# Act
var label: String = NpcCombatHudHelpers.build_npc_state_label(rows)
# Assert
assert_that(label).is_equal("NPC state: —")
func test_combat_poll_gate_stops_when_idle() -> void:
# Arrange
var timer := Timer.new()
timer.one_shot = false
add_child(timer)
timer.start()
# Act
var should_poll: bool = NpcCombatHudHelpers.is_combat_active({}, {}, "dev-local-1")
if not should_poll:
timer.stop()
# Assert
assert_that(should_poll).is_false()
assert_that(timer.is_stopped()).is_true()
func test_combat_poll_gate_starts_when_lock_held() -> void:
# Arrange
var timer := Timer.new()
timer.one_shot = false
add_child(timer)
var target_state := {"lockedTargetId": "prototype_npc_elite", "validity": "ok"}
# Act
var should_poll: bool = NpcCombatHudHelpers.is_combat_active(target_state, {}, "dev-local-1")
if should_poll and timer.is_stopped():
timer.start()
# Assert
assert_that(should_poll).is_true()
assert_that(timer.is_stopped()).is_false()
func test_locked_plus_incoming_state_label() -> void:
# Arrange
var target_state := {"lockedTargetId": "prototype_npc_melee", "validity": "ok"}
var parsed: Variant = NpcRuntimeClient.parse_runtime_json(_elite_aggro_snapshot_json())
var rows: Dictionary = NpcCombatHudHelpers.resolve_hud_npc_rows(
target_state, parsed as Dictionary, "dev-local-1"
)
# Act
var label: String = NpcCombatHudHelpers.build_npc_state_label(rows)
# Assert
assert_that(label).contains("NPC state: Melee → idle")
assert_that(label).contains("Incoming: Elite → aggro")
func test_telegraph_label_falls_back_to_incoming_row() -> void:
# Arrange
var target_state := {"lockedTargetId": "prototype_npc_melee", "validity": "ok"}
var parsed: Variant = NpcRuntimeClient.parse_runtime_json(_elite_telegraph_snapshot_json())
var rows: Dictionary = NpcCombatHudHelpers.resolve_hud_npc_rows(
target_state, parsed as Dictionary, "dev-local-1"
)
# Act
var label: String = NpcCombatHudHelpers.build_telegraph_label(rows)
# Assert
assert_that(label).contains("Telegraph: Elite prototype_elite_slam")
assert_that(label).contains("(elite)")
func test_telegraph_label_empty_when_local_windup_expired() -> void:
# Arrange
var short_windup_json := (
'{"schemaVersion":1,"serverTimeUtc":"2026-05-29T12:00:00Z","npcInstances":['
+ '{"npcInstanceId":"prototype_npc_elite","behaviorDefId":"prototype_elite_boss",'
+ '"state":"telegraph_windup","aggroHolderPlayerId":"dev-local-1",'
+ '"activeTelegraph":{"telegraphId":"prototype_elite_slam","npcInstanceId":'
+ '"prototype_npc_elite","windupRemainingSeconds":0.1,"archetypeKind":"elite"}}'
+ "]}"
)
var parsed: Variant = NpcRuntimeClient.parse_runtime_json(short_windup_json)
var rows: Dictionary = NpcCombatHudHelpers.resolve_hud_npc_rows(
{"lockedTargetId": "prototype_npc_elite", "validity": "ok"},
parsed as Dictionary,
"dev-local-1"
)
var hud_state: RefCounted = NpcRuntimeHudState.new()
hud_state.call("apply_snapshot", parsed as Dictionary)
await (Engine.get_main_loop() as SceneTree).create_timer(0.15).timeout
# Act
var label: String = NpcCombatHudHelpers.build_telegraph_label(rows, hud_state)
# Assert
assert_that(label).is_equal("Telegraph: —")
func test_runtime_sync_error_surfaces_on_empty_npc_state_label() -> void:
# Arrange
# Act
var label: String = NpcCombatHudHelpers.build_npc_state_label({}, "HTTP 500")
# Assert
assert_that(label).is_equal("NPC state: — (HTTP 500)")

View File

@ -0,0 +1 @@
uid://bppioj8bo1k6o

View File

@ -0,0 +1,130 @@
extends GdUnitTestSuite
## NEO-97: `NpcRuntimeClient` GET parse + row helpers.
const NpcRuntimeClient := preload("res://scripts/npc_runtime_client.gd")
class MockHttpTransport:
extends Node
signal request_completed(
result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray
)
var last_url: String = ""
var response_code: int = 200
var body_json: String = ""
func request(
url: String,
_custom_headers: PackedStringArray = PackedStringArray(),
_method: HTTPClient.Method = HTTPClient.METHOD_GET,
_request_data: String = ""
) -> Error:
last_url = url
request_completed.emit(
HTTPRequest.RESULT_SUCCESS,
response_code,
PackedStringArray(),
body_json.to_utf8_buffer()
)
return OK
static func _runtime_snapshot_json() -> String:
return (
'{"schemaVersion":1,"serverTimeUtc":"2026-05-29T12:00:00Z","npcInstances":['
+ '{"npcInstanceId":"prototype_npc_elite","behaviorDefId":"prototype_elite_boss",'
+ '"state":"telegraph_windup","aggroHolderPlayerId":"dev-local-1",'
+ '"activeTelegraph":{"telegraphId":"prototype_elite_slam","npcInstanceId":'
+ '"prototype_npc_elite","windupRemainingSeconds":2.5,"archetypeKind":"elite"}},'
+ '{"npcInstanceId":"prototype_npc_melee","behaviorDefId":"prototype_melee_rush",'
+ '"state":"idle","aggroHolderPlayerId":null,"activeTelegraph":null}'
+ "]}"
)
func _make_client(transport: Node) -> Node:
var c: Node = NpcRuntimeClient.new()
c.set("injected_http", transport)
auto_free(transport)
auto_free(c)
add_child(c)
return c
func test_request_sync_gets_npc_runtime_snapshot_url() -> void:
# Arrange
var transport := MockHttpTransport.new()
transport.body_json = _runtime_snapshot_json()
var c := _make_client(transport)
# Act
c.call("request_sync_from_server")
# Assert
assert_that(transport.last_url).contains("/game/world/npc-runtime-snapshot")
func test_runtime_received_emits_parsed_snapshot() -> void:
# Arrange
var transport := MockHttpTransport.new()
transport.body_json = _runtime_snapshot_json()
var c := _make_client(transport)
var got: Array = []
c.connect("runtime_received", func(snapshot: Dictionary) -> void: got.append(snapshot))
# Act
c.call("request_sync_from_server")
# Assert
assert_that(got.size()).is_equal(1)
var instances: Variant = (got[0] as Dictionary).get("npcInstances", null)
assert_that(instances is Array).is_true()
assert_that((instances as Array).size()).is_equal(2)
func test_npc_row_returns_elite_telegraph_fields() -> void:
# Arrange
var parsed: Variant = NpcRuntimeClient.parse_runtime_json(_runtime_snapshot_json())
# Act
var row: Dictionary = NpcRuntimeClient.npc_row_in_snapshot(
"prototype_npc_elite", parsed as Dictionary
)
# Assert
assert_that(str(row.get("state", ""))).is_equal("telegraph_windup")
var telegraph: Variant = row.get("activeTelegraph", null)
assert_that(telegraph is Dictionary).is_true()
assert_that(float((telegraph as Dictionary).get("windupRemainingSeconds", 0.0))).is_equal(2.5)
func test_row_for_aggro_holder_returns_elite_row() -> void:
# Arrange
var parsed: Variant = NpcRuntimeClient.parse_runtime_json(_runtime_snapshot_json())
# Act
var row: Dictionary = NpcRuntimeClient.row_for_aggro_holder("dev-local-1", parsed as Dictionary)
# Assert
assert_that(str(row.get("npcInstanceId", ""))).is_equal("prototype_npc_elite")
func test_npc_row_falls_back_to_last_snapshot_on_instance() -> void:
# Arrange
var transport := MockHttpTransport.new()
transport.body_json = _runtime_snapshot_json()
var c := _make_client(transport)
var got: Array = []
c.connect("runtime_received", func(snapshot: Dictionary) -> void: got.append(snapshot))
c.call("request_sync_from_server")
# Act
var row: Dictionary = c.call("npc_row", "prototype_npc_elite") as Dictionary
# Assert
assert_that(str(row.get("state", ""))).is_equal("telegraph_windup")
func test_invalid_schema_emits_sync_failed() -> void:
# Arrange
var transport := MockHttpTransport.new()
transport.body_json = '{"schemaVersion":2,"npcInstances":[]}'
var c := _make_client(transport)
var failed: Array = []
c.connect("runtime_sync_failed", func(reason: String) -> void: failed.append(reason))
# Act
c.call("request_sync_from_server")
# Assert
assert_that(failed.size()).is_equal(1)
assert_that(str(failed[0])).contains("schemaVersion")

View File

@ -0,0 +1 @@
uid://bbu7b0lvpfvcr

View File

@ -0,0 +1,49 @@
extends GdUnitTestSuite
## NEO-97: windup interpolation between npc-runtime polls.
const NpcRuntimeHudState := preload("res://scripts/npc_runtime_hud_state.gd")
static func _telegraph_snapshot(remaining: float) -> Dictionary:
return {
"npcInstances":
[
{
"npcInstanceId": "prototype_npc_elite",
"state": "telegraph_windup",
"activeTelegraph":
{
"telegraphId": "prototype_elite_slam",
"windupRemainingSeconds": remaining,
"archetypeKind": "elite",
},
}
]
}
func test_display_windup_remaining_decreases_over_time() -> void:
# Arrange
var state: RefCounted = NpcRuntimeHudState.new()
state.call("apply_snapshot", _telegraph_snapshot(2.0))
var first: float = float(state.call("display_windup_remaining", "prototype_npc_elite"))
# Act
await (Engine.get_main_loop() as SceneTree).create_timer(0.05).timeout
var second: float = float(state.call("display_windup_remaining", "prototype_npc_elite"))
# Assert
assert_that(second).is_less(first)
assert_that(second).is_greater(1.9)
func test_new_snapshot_reconciles_windup_anchor() -> void:
# Arrange
var state: RefCounted = NpcRuntimeHudState.new()
state.call("apply_snapshot", _telegraph_snapshot(2.5))
await (Engine.get_main_loop() as SceneTree).create_timer(0.05).timeout
# Act
state.call("apply_snapshot", _telegraph_snapshot(1.8))
var remaining: float = float(state.call("display_windup_remaining", "prototype_npc_elite"))
# Assert
assert_that(remaining).is_less_equal(1.81)
assert_that(remaining).is_greater_equal(1.79)

View File

@ -0,0 +1 @@
uid://ds0vmcwwd3bw8

View File

@ -0,0 +1,99 @@
extends GdUnitTestSuite
## NEO-97: `PlayerCombatHealthClient` GET parse.
const PlayerCombatHealthClient := preload("res://scripts/player_combat_health_client.gd")
class MockHttpTransport:
extends Node
signal request_completed(
result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray
)
var last_url: String = ""
var response_code: int = 200
var body_json: String = ""
func request(
url: String,
_custom_headers: PackedStringArray = PackedStringArray(),
_method: HTTPClient.Method = HTTPClient.METHOD_GET,
_request_data: String = ""
) -> Error:
last_url = url
request_completed.emit(
HTTPRequest.RESULT_SUCCESS,
response_code,
PackedStringArray(),
body_json.to_utf8_buffer()
)
return OK
static func _health_json() -> String:
return (
'{"schemaVersion":1,"playerId":"dev-local-1","maxHp":100,'
+ '"currentHp":75,"defeated":false}'
)
func _make_client(transport: Node) -> Node:
var c: Node = PlayerCombatHealthClient.new()
c.set("injected_http", transport)
c.set("dev_player_id", "dev-local-1")
auto_free(transport)
auto_free(c)
add_child(c)
return c
func test_request_sync_gets_combat_health_url() -> void:
# Arrange
var transport := MockHttpTransport.new()
transport.body_json = _health_json()
var c := _make_client(transport)
# Act
c.call("request_sync_from_server")
# Assert
assert_that(transport.last_url).contains("/game/players/dev-local-1/combat-health")
func test_health_received_emits_parsed_snapshot() -> void:
# Arrange
var transport := MockHttpTransport.new()
transport.body_json = _health_json()
var c := _make_client(transport)
var got: Array = []
c.connect("health_received", func(snapshot: Dictionary) -> void: got.append(snapshot))
# Act
c.call("request_sync_from_server")
# Assert
assert_that(got.size()).is_equal(1)
var snapshot: Dictionary = got[0]
assert_that(int(snapshot.get("currentHp", 0))).is_equal(75)
assert_that(int(snapshot.get("maxHp", 0))).is_equal(100)
assert_that(bool(snapshot.get("defeated", false))).is_false()
func test_parse_combat_health_json_rejects_missing_hp_fields() -> void:
# Arrange
var text := '{"schemaVersion":1,"playerId":"dev-local-1","defeated":false}'
# Act
var parsed: Variant = PlayerCombatHealthClient.parse_combat_health_json(text)
# Assert
assert_that(parsed).is_null()
func test_http_404_emits_sync_failed() -> void:
# Arrange
var transport := MockHttpTransport.new()
transport.response_code = 404
transport.body_json = ""
var c := _make_client(transport)
var failed: Array = []
c.connect("health_sync_failed", func(reason: String) -> void: failed.append(reason))
# Act
c.call("request_sync_from_server")
# Assert
assert_that(failed.size()).is_equal(1)
assert_that(str(failed[0])).contains("404")

View File

@ -0,0 +1 @@
uid://b1m0yujmrbutt

View File

@ -81,7 +81,7 @@ The **first shipped three-archetype NPC spine** under `content/npc-behaviors/*.j
**NPC behavior definition registry (NEO-89):** **`INpcBehaviorDefinitionRegistry`** in `server/NeonSprawl.Server/Game/Npc/` wraps the startup-loaded catalog for read-only lookup by stable behavior `id` (`TryGetDefinition`), trim/lowercase validation (`TryNormalizeKnown`), and ordered enumeration (`GetDefinitionsInIdOrder`). Stable prototype id constants: **`PrototypeNpcBehaviorRegistry`**. **NEO-91+** (instance registry, runtime tick) should inject the interface rather than `NpcBehaviorDefinitionCatalog`. Plan: [NEO-89 implementation plan](../../plans/NEO-89-implementation-plan.md).
**NPC instance registry (NEO-91):** **`PrototypeNpcRegistry`** in `server/NeonSprawl.Server/Game/Npc/` — instance ids **`prototype_npc_melee`**, **`prototype_npc_ranged`**, **`prototype_npc_elite`** with behavior bindings + world anchors; **`ICombatEntityHealthStore`** uses catalog **`maxHp`** per instance. Plan: [NEO-91 implementation plan](../../plans/NEO-91-implementation-plan.md). **Breaking:** Godot constants migrate in [NEO-97](https://linear.app/neon-sprawl/issue/NEO-97).
**NPC instance registry (NEO-91):** **`PrototypeNpcRegistry`** in `server/NeonSprawl.Server/Game/Npc/` — instance ids **`prototype_npc_melee`**, **`prototype_npc_ranged`**, **`prototype_npc_elite`** with behavior bindings + world anchors; **`ICombatEntityHealthStore`** uses catalog **`maxHp`** per instance. Plan: [NEO-91 implementation plan](../../plans/NEO-91-implementation-plan.md). Godot **`prototype_target_constants.gd`** migration **landed in NEO-91**; [NEO-97](https://linear.app/neon-sprawl/issue/NEO-97) adds HUD poll clients only.
**Threat / aggro store (NEO-92):** **`IThreatStateStore`** + **`AggroOperations`** in `server/NeonSprawl.Server/Game/Npc/` — first damaging cast acquires holder; leash break clears holder (move + cast hooks). HTTP projection: **`aggroHolderPlayerId`** on **`GET /game/world/npc-runtime-snapshot`** ([NEO-94](../../plans/NEO-94-implementation-plan.md)). Plan: [NEO-92 implementation plan](../../plans/NEO-92-implementation-plan.md).
@ -93,11 +93,13 @@ The **first shipped three-archetype NPC spine** under `content/npc-behaviors/*.j
**NPC runtime telemetry hooks (NEO-96):** comment-only **`telegraph_fired`** + **`npc_state_transition`** hook sites in **`NpcRuntimeOperations.AdvanceAll`** (`TODO(E9.M1)`; no ingest). Plan: [NEO-96 implementation plan](../../plans/NEO-96-implementation-plan.md); [server README — NPC runtime telemetry hooks (NEO-96)](../../../server/README.md#npc-runtime-telemetry-hooks-neo-96).
**Client telegraph HUD (NEO-97):** Godot poll of **`npc-runtime-snapshot`** + **`combat-health`**; **`TelegraphLabel`**, **`NpcStateLabel`**, **`PlayerCombatHpLabel`**; combat-active ~1 Hz poll gate. Plan: [NEO-97 implementation plan](../../plans/NEO-97-implementation-plan.md); [client README — NPC runtime + telegraph HUD (NEO-97)](../../../client/README.md#npc-runtime--telegraph-hud-neo-97); manual QA [NEO-97](../../manual-qa/NEO-97.md).
**NPC behavior definitions HTTP (NEO-90):** **`GET /game/world/npc-behavior-definitions`** — versioned read-only projection (`schemaVersion` **1**, **`npcBehaviors`**) backed by **`INpcBehaviorDefinitionRegistry`**. Plan: [NEO-90 implementation plan](../../plans/NEO-90-implementation-plan.md); [server README — NPC behavior definitions (NEO-90)](../../../server/README.md#npc-behavior-definitions-neo-90); Bruno `bruno/neon-sprawl-server/npc-behavior-definitions/`.
## Risks and telemetry
- Telegraph desync: single **`npc-runtime-snapshot`** poll surface; no client windup math.
- Telegraph desync: single **`npc-runtime-snapshot`** poll surface; client display-only windup tick-down between polls (reconciled on each GET via **`npc_runtime_hud_state.gd`**).
- Aggro confusion: deterministic first-hit + leash rules; instrument `npc_state_transition` when E9.M1 lands.
## Source anchors

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,41 @@
# NEO-97 — Manual QA checklist
| 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 |
| Plan | `docs/plans/NEO-97-implementation-plan.md` |
## 1) Server
- [ ] Start server: `cd server/NeonSprawl.Server && dotnet run`.
- [ ] `curl -s http://localhost:5253/health` returns JSON with `status: ok`.
- [ ] Optional reset between runs (Development host): `curl -sS -X POST http://localhost:5253/game/__dev/combat-targets-fixture -H 'Content-Type: application/json' -d '{"schemaVersion":1}'` returns **200** when the dev fixture route is enabled.
## 2) Client — NPC markers + tab cycle
- [ ] Open Godot main scene with server running; after hotbar sync, slot **0** auto-binds to **`prototype_pulse`** when empty.
- [ ] Three NPC markers near spawn: **Elite** (gold, origin), **Melee** (orange, west), **Ranged** (purple, south-east); each has a lock ring.
- [ ] **Tab** cycles locks in order **`prototype_npc_elite`** → **`prototype_npc_melee`** → **`prototype_npc_ranged`** → wrap; locked marker **brightens**.
- [ ] **`TargetLockLabel`** shows server-acknowledged lock id + validity (no optimistic lock).
## 3) Client — telegraph HUD + player HP
- [ ] Walk to **Elite** marker (origin); **Tab** until **`prototype_npc_elite`** is locked with **`Validity: ok`**.
- [ ] Press **1** (damaging cast): **`CastFeedbackLabel`** shows damage; **`NpcStateLabel`** eventually shows **`NPC state: Elite → aggro`** (after ~1 Hz poll).
- [ ] **`PlayerCombatHpLabel`** shows **`Player HP: 100/100`** once combat poll starts (lock or aggro).
- [ ] Wait **≥ 5 s** while staying in range (elite attack cooldown): **`NpcStateLabel`** shows **`telegraph_windup`**; **`TelegraphLabel`** shows countdown (e.g. **`Telegraph: Elite prototype_elite_slam · 2.x s (elite)`**) ticking down between polls.
- [ ] Wait for windup to complete (**≥ 2.5 s** after telegraph appears, **≥ 7.5 s** total from first damaging cast): **`PlayerCombatHpLabel`** drops to **`75/100`** (elite **25** damage).
- [ ] Telegraph countdown at poll boundaries matches server within one poll frame (~1 s); local tick-down is smooth between polls.
## 4) Incoming threat row
- [ ] With elite aggro on you, **Tab** lock to **Melee** (different NPC): **`NpcStateLabel`** shows both **`NPC state: Melee → …`** and **`Incoming: Elite → …`** when elite still holds aggro.
- [ ] **`TelegraphLabel`** prefers locked NPC telegraph; when locked NPC has no telegraph, shows incoming elite telegraph during windup.
## 5) Regression
- [ ] **`CombatTargetHpLabel`** still updates on cast + lock change (NEO-85).
- [ ] Cast without lock still shows **`ability_cast_denied: invalid_target`** (NEO-28).
- [ ] Cooldown HUD still refreshes after accepted casts (NEO-32).

View File

@ -349,9 +349,11 @@ Working backlog for **Epic 5 — Slice 2** ([NPC archetypes and telegraphs](../d
**Acceptance criteria**
- [ ] Player-visible telegraph countdown matches server snapshot within one poll frame.
- [ ] Tab cycle visits three NPC instances in documented order.
- [ ] Manual QA checklist exercisable without Bruno.
- [x] Player-visible telegraph countdown matches server snapshot within one poll frame.
- [x] Tab cycle visits three NPC instances in documented order.
- [x] Manual QA checklist exercisable without Bruno.
**Landed ([NEO-97](https://linear.app/neon-sprawl/issue/NEO-97)):** Godot poll clients + telegraph/NPC state/player HP HUD labels; combat-active ~1 Hz poll gate; plan [NEO-97-implementation-plan.md](NEO-97-implementation-plan.md); manual QA [NEO-97](../manual-qa/NEO-97.md).
**Client counterpart:** this issue (**NEO-97**).

View File

@ -0,0 +1,190 @@
# 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 NPCs 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** |

View File

@ -0,0 +1,68 @@
# Code review — NEO-97 (E5M2-11)
**Date:** 2026-05-29
**Scope:** Branch `NEO-97-e5m2-11-client-telegraph-hud` vs `origin/main` — commits `b0d0c42``cfda0ac`
**Base:** `origin/main`
## Verdict
**Approve with nits**
## Summary
NEO-97 adds the Epic 5 Slice 2 **client half**: thin HTTP clients for **`GET /game/world/npc-runtime-snapshot`** and **`GET /game/players/{id}/combat-health`**, a RefCounted windup display mirror between polls, ~1 Hz combat-active polling wired from `main.gd`, and three HUD labels (`PlayerCombatHpLabel`, `NpcStateLabel`, `TelegraphLabel`). The implementation follows NEO-85/NEO-32 patterns (injectable HTTP, `_busy` guard, local tick-down smoothing) and matches kickoff decisions (combat-active gate, locked + incoming rows, interpolated windup). Four GdUnit suites cover parse/URL helpers, windup reconciliation, and combat-active / NPC state label logic via a test harness. Docs (plan, manual QA, README, E5M2 backlog, module + alignment register) are updated. Primary risk is **logic duplication** between `main.gd` and the test harness (drift over time) and a **stale module risk bullet** that still says “no client windup math.” No blocking correctness issues for the documented manual QA flows (lock → cast → telegraph → player HP drop).
## Documentation checked
| Path | Result |
|------|--------|
| `docs/plans/NEO-97-implementation-plan.md` | **Matches** — kickoff decisions (combat-active gate, locked + incoming HUD, interpolate windup, keep `prototype_target_markers.gd`) reflected in code; reconciliation section accurate; AC checked. |
| `docs/plans/E5M2-prototype-backlog.md` (E5M2-11) | **Matches** — AC checked, landed note present; script name in backlog still says `npc_archetype_markers.gd` but plan kickoff explicitly adopted `prototype_target_markers.gd` (documented divergence). |
| `docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md` | **Matches** — NEO-97 landed line correct; Risks + NPC instance row updated for windup display + NEO-91 constants migration. |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E5.M2 row includes **NEO-97 landed** with README/plan/manual QA links. |
| `docs/manual-qa/NEO-97.md` | **Matches** — Godot steps cover lock, cast, telegraph countdown, player HP drop, incoming threat row, regressions. |
| `client/README.md` | **Matches** — NPC runtime + telegraph HUD section documents poll gate, labels, tab order, script paths. |
| `docs/decomposition/modules/client_server_authority.md` | **Matches** — read-only GET polling; no client authority over combat/NPC state; poll advances server lazy tick (accepted per plan). |
## Blocking issues
None.
## Suggestions
1. ~~**E5.M2 Risks bullet** — Update “Telegraph desync: … no client windup math” to “display-only windup tick-down between polls; reconciled on each `npc-runtime-snapshot` GET” so module docs align with the NEO-97 kickoff decision and `npc_runtime_hud_state.gd`.~~ **Done.**
2. ~~**E5.M2 NPC instance row** — Change “Breaking: Godot constants migrate in NEO-97” to note migration **landed in NEO-91**; NEO-97 adds HUD poll clients only.~~ **Done.**
3. ~~**Test coverage vs plan** — `npc_combat_hud_refresh_test.gd` covers combat-active bool + `NpcStateLabel` copy but not **`TelegraphLabel`** formatting or **timer start/stop** (called out in the implementation plan test table). Add at least one telegraph line assertion (locked vs incoming fallback) and, if feasible without heavy scene wiring, a timer-gate test mirroring `_update_combat_poll_gate()`.~~ **Done.** Telegraph fallback + local windup expiry + timer mirror tests added.
4. ~~**Shared HUD helpers** — Combat-active gate, row resolution, and label formatters are duplicated in `main.gd` (~180 lines) and `NpcCombatHudHarness` in tests. Extract to a small RefCounted (e.g. `npc_combat_hud_helpers.gd`) used by both to prevent drift; optional follow-up given NEO-85 precedent of wiring in `main.gd`.~~ **Done.** `npc_combat_hud_helpers.gd` shared by `main.gd` and tests.
5. ~~**Telegraph at zero** — `_format_telegraph_line` can show **`· 0.0s`** while the snapshot row still carries `activeTelegraph` until the next poll. Consider returning empty when `display_windup_remaining` ≤ 0 so `TelegraphLabel` shows **`—`** between local expiry and server reconcile.~~ **Done.** `format_telegraph_line` returns empty when remaining ≤ 0.
6. ~~**Runtime sync failure** — `_on_npc_runtime_sync_failed` re-renders from the last good snapshot without surfacing failure on `NpcStateLabel` / `TelegraphLabel` (player HP already shows error). Acceptable for prototype; optionally dash NPC/telegraph lines or append a sync-failed hint for QA clarity.~~ **Done.** `_npc_runtime_sync_error` appended to NPC/telegraph labels when empty or alongside stale rows.
## Nits
- ~~Nit: `npc_runtime_client.gd` stores `_last_snapshot` but static `npc_row()` does not fall back to it (unlike `combat_targets_client.target_row()`); field is write-only today.~~ **Done.** Instance `npc_row()` falls back to `_last_snapshot`.
- Nit: Duplicate `MockHttpTransport` inner classes in `npc_runtime_client_test.gd` and `player_combat_health_client_test.gd` — fine for now; a shared test helper would reduce copy-paste.
- Nit: `main.gd` grows ~250 lines for NEO-97; repo policy prefers thin `main.gd` — track for a future extract when the next HUD story lands. **Partially addressed** — helpers extracted; poll wiring remains in `main.gd`.
- ~~Nit: Plan text references `serverTimeUtc` anchor in `npc_runtime_hud_state.gd`; implementation uses receive tick + server `windupRemainingSeconds` only — sufficient for display smoothing; plan wording could be tightened.~~ **Done.**
## Verification
```bash
# GdUnit (CI uses Godot 4.6 headless — not available locally in review env)
cd client && ~/godot/Godot_v4.6-stable_linux.x86_64 --headless --path . \
-s res://addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode -a res://test
# Lint / format (CI)
pip install "gdtoolkit==4.5.0"
gdlint client/scripts client/test
gdformat --check client/scripts client/test
```
**Manual (required before merge):** [`docs/manual-qa/NEO-97.md`](../manual-qa/NEO-97.md) — server + Godot F5; elite lock → cast → ~7.5 s telegraph window → player HP **75/100**; incoming threat row when locking a different NPC while elite holds aggro.