From cfda0acd9aab534b9deeaf3fff1a758d9230657f Mon Sep 17 00:00:00 2001 From: VinPropane Date: Fri, 29 May 2026 20:59:11 -0400 Subject: [PATCH] NEO-97: add Godot telegraph HUD and NPC runtime poll clients Wire npc-runtime-snapshot and combat-health GET clients with combat-active ~1 Hz polling, interpolated telegraph countdown, and PlayerCombatHp/NpcState/ Telegraph HUD labels; includes GdUnit tests, manual QA, and doc updates. --- client/README.md | 37 ++- client/scenes/main.tscn | 58 +++- client/scripts/main.gd | 250 ++++++++++++++++++ client/scripts/npc_runtime_client.gd | 114 ++++++++ client/scripts/npc_runtime_client.gd.uid | 1 + client/scripts/npc_runtime_hud_state.gd | 45 ++++ client/scripts/npc_runtime_hud_state.gd.uid | 1 + client/scripts/player_combat_health_client.gd | 89 +++++++ .../player_combat_health_client.gd.uid | 1 + client/test/npc_combat_hud_refresh_test.gd | 125 +++++++++ client/test/npc_runtime_client_test.gd | 118 +++++++++ client/test/npc_runtime_hud_state_test.gd | 49 ++++ .../test/player_combat_health_client_test.gd | 99 +++++++ .../modules/E5_M2_NpcAiAndBehaviorProfiles.md | 2 + ...umentation_and_implementation_alignment.md | 2 +- docs/manual-qa/NEO-97.md | 41 +++ docs/plans/E5M2-prototype-backlog.md | 8 +- docs/plans/NEO-97-implementation-plan.md | 15 +- 18 files changed, 1028 insertions(+), 27 deletions(-) create mode 100644 client/scripts/npc_runtime_client.gd create mode 100644 client/scripts/npc_runtime_client.gd.uid create mode 100644 client/scripts/npc_runtime_hud_state.gd create mode 100644 client/scripts/npc_runtime_hud_state.gd.uid create mode 100644 client/scripts/player_combat_health_client.gd create mode 100644 client/scripts/player_combat_health_client.gd.uid create mode 100644 client/test/npc_combat_hud_refresh_test.gd create mode 100644 client/test/npc_runtime_client_test.gd create mode 100644 client/test/npc_runtime_hud_state_test.gd create mode 100644 client/test/player_combat_health_client_test.gd create mode 100644 docs/manual-qa/NEO-97.md diff --git a/client/README.md b/client/README.md index f5036ad..522e64d 100644 --- a/client/README.md +++ b/client/README.md @@ -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 `: m / (in|out)` (computed client-side from the live capsule position against `prototype_target_constants.gd`), and (on denials) `Denied: ` 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) diff --git a/client/scenes/main.tscn b/client/scenes/main.tscn index e077411..51a807d 100644 --- a/client/scenes/main.tscn +++ b/client/scenes/main.tscn @@ -1193,11 +1193,53 @@ theme_override_font_sizes/font_size = 15 autowrap_mode = 3 text = "Target HP: — (Tab → dummy)" +[node name="PlayerCombatHpLabel" type="Label" parent="UICanvas" unique_id=9000019] +offset_left = 8.0 +offset_top = 400.0 +offset_right = 520.0 +offset_bottom = 424.0 +grow_horizontal = 0 +grow_vertical = 0 +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 = 6 +theme_override_font_sizes/font_size = 15 +autowrap_mode = 3 +text = "Player HP: —" + +[node name="NpcStateLabel" type="Label" parent="UICanvas" unique_id=9000020] +offset_left = 8.0 +offset_top = 426.0 +offset_right = 520.0 +offset_bottom = 456.0 +grow_horizontal = 0 +grow_vertical = 0 +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 = 6 +theme_override_font_sizes/font_size = 15 +autowrap_mode = 3 +text = "NPC state: —" + +[node name="TelegraphLabel" type="Label" parent="UICanvas" unique_id=9000021] +offset_left = 8.0 +offset_top = 458.0 +offset_right = 520.0 +offset_bottom = 482.0 +grow_horizontal = 0 +grow_vertical = 0 +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 = 6 +theme_override_font_sizes/font_size = 15 +autowrap_mode = 3 +text = "Telegraph: —" + [node name="GatherFeedbackLabel" type="Label" parent="UICanvas" unique_id=9000008] offset_left = 8.0 -offset_top = 402.0 +offset_top = 484.0 offset_right = 520.0 -offset_bottom = 428.0 +offset_bottom = 510.0 grow_horizontal = 0 grow_vertical = 0 theme_override_colors/font_color = Color(0.88, 0.95, 0.72, 1) @@ -1209,9 +1251,9 @@ text = "Gather: —" [node name="CraftFeedbackLabel" type="Label" parent="UICanvas" unique_id=9000010] offset_left = 8.0 -offset_top = 430.0 +offset_top = 512.0 offset_right = 520.0 -offset_bottom = 456.0 +offset_bottom = 538.0 grow_horizontal = 0 grow_vertical = 0 theme_override_colors/font_color = Color(0.95, 0.82, 0.72, 1) @@ -1223,9 +1265,9 @@ text = "Craft: —" [node name="CooldownSlotsLabel" type="Label" parent="UICanvas" unique_id=9000006] offset_left = 8.0 -offset_top = 462.0 +offset_top = 544.0 offset_right = 520.0 -offset_bottom = 538.0 +offset_bottom = 620.0 grow_horizontal = 0 grow_vertical = 0 theme_override_colors/font_color = Color(0.78, 0.92, 0.86, 1) @@ -1237,9 +1279,9 @@ text = "Cooldowns: —" [node name="EconomyHudSection" type="VBoxContainer" parent="UICanvas" unique_id=9000014] offset_left = 8.0 -offset_top = 550.0 +offset_top = 632.0 offset_right = 520.0 -offset_bottom = 1082.0 +offset_bottom = 1164.0 grow_horizontal = 0 grow_vertical = 0 script = ExtResource("20_economy_hud") diff --git a/client/scripts/main.gd b/client/scripts/main.gd index 411be45..e8389d0 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -42,6 +42,10 @@ 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 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 +83,17 @@ 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 _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 = [] @@ -109,6 +121,9 @@ var _dev_pulse_bootstrap_attempted: bool = false @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 _player_combat_hp_label: Label = $UICanvas/PlayerCombatHpLabel +@onready var _npc_state_label: Label = $UICanvas/NpcStateLabel +@onready var _telegraph_label: Label = $UICanvas/TelegraphLabel @onready var _cooldown_slots_label: Label = $UICanvas/CooldownSlotsLabel @onready var _economy_hud_section: VBoxContainer = $UICanvas/EconomyHudSection @onready var _inventory_label: Label = _economy_hud_section.get_node("Body/InventoryLabel") @@ -194,6 +209,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 @@ -287,6 +304,9 @@ func _on_target_state_changed(state: Dictionary) -> void: _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 +338,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 +354,37 @@ 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 +418,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 +991,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 @@ -992,6 +1058,190 @@ 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_known_npc_id(target_id: String) -> bool: + return PrototypeTargetConstants.ANCHORS.has(target_id.strip_edges()) + + +func _is_combat_active() -> 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().is_empty() + + +func _incoming_aggro_row() -> Dictionary: + if _npc_runtime_snapshot.is_empty(): + return {} + return NpcRuntimeClientScript.row_for_aggro_holder(_dev_player_id(), _npc_runtime_snapshot) + + +func _resolve_hud_npc_rows() -> Dictionary: + var lock_id := _locked_target_id_from_state(_last_target_state) + var locked_row: Dictionary = {} + if _is_known_npc_id(lock_id): + locked_row = NpcRuntimeClientScript.npc_row(lock_id, _npc_runtime_snapshot) + var incoming_row: Dictionary = {} + var aggro_row: Dictionary = _incoming_aggro_row() + 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} + + +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 + + +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_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: + _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_suffix := " (defeated)" if bool(_player_combat_health_snapshot.get("defeated", false)) else "" + _player_combat_hp_label.text = "Player HP: %d/%d%s" % [current, max_hp, defeated_suffix] + + +func _render_npc_combat_hud_labels() -> void: + _render_npc_state_label() + _render_telegraph_label() + _npc_combat_hud_needs_tick = _has_interpolated_telegraph_display() + + +func _has_interpolated_telegraph_display() -> bool: + if _npc_runtime_hud_state == null: + return false + var rows: Dictionary = _resolve_hud_npc_rows() + 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 _npc_runtime_hud_state.has_method("has_active_windup"): + if bool(_npc_runtime_hud_state.call("has_active_windup", instance_id)): + return true + return false + + +func _render_npc_state_label() -> void: + if not is_instance_valid(_npc_state_label): + return + var rows: Dictionary = _resolve_hud_npc_rows() + 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", "")) + var locked_name: String = PrototypeTargetConstants.display_short_name(locked_id) + var locked_state: String = str(locked_row.get("state", "unknown")) + lines.append("NPC state: %s → %s" % [locked_name, locked_state]) + var incoming_row: Dictionary = rows.get("incoming", {}) as Dictionary + if not incoming_row.is_empty(): + var incoming_id: String = str(incoming_row.get("npcInstanceId", "")) + var incoming_name: String = PrototypeTargetConstants.display_short_name(incoming_id) + var incoming_state: String = str(incoming_row.get("state", "unknown")) + lines.append("Incoming: %s → %s" % [incoming_name, incoming_state]) + if lines.is_empty(): + _npc_state_label.text = "NPC state: —" + return + _npc_state_label.text = "\n".join(lines) + + +func _format_telegraph_line(row: Dictionary) -> 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 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", "")) + var remaining: float = float(telegraph_dict.get("windupRemainingSeconds", 0.0)) + if _npc_runtime_hud_state != null and _npc_runtime_hud_state.has_method("display_windup_remaining"): + remaining = float(_npc_runtime_hud_state.call("display_windup_remaining", instance_id)) + return "Telegraph: %s %s · %.1fs (%s)" % [short_name, telegraph_id, remaining, archetype] + + +func _render_telegraph_label() -> void: + if not is_instance_valid(_telegraph_label): + return + var rows: Dictionary = _resolve_hud_npc_rows() + var locked_row: Dictionary = rows.get("locked", {}) as Dictionary + var line: String = _format_telegraph_line(locked_row) + if line.is_empty(): + line = _format_telegraph_line(rows.get("incoming", {}) as Dictionary) + if line.is_empty(): + _telegraph_label.text = "Telegraph: —" + return + _telegraph_label.text = line + + func _on_move_rejected(reason_code: String) -> void: _authority_force_snap_next = true # Rejected stream: server state may differ; next snap is forced. diff --git a/client/scripts/npc_runtime_client.gd b/client/scripts/npc_runtime_client.gd new file mode 100644 index 0000000..78263b4 --- /dev/null +++ b/client/scripts/npc_runtime_client.gd @@ -0,0 +1,114 @@ +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 + + +static func npc_row(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) diff --git a/client/scripts/npc_runtime_client.gd.uid b/client/scripts/npc_runtime_client.gd.uid new file mode 100644 index 0000000..0f9203d --- /dev/null +++ b/client/scripts/npc_runtime_client.gd.uid @@ -0,0 +1 @@ +uid://bneo97npcrt01 diff --git a/client/scripts/npc_runtime_hud_state.gd b/client/scripts/npc_runtime_hud_state.gd new file mode 100644 index 0000000..93f859c --- /dev/null +++ b/client/scripts/npc_runtime_hud_state.gd @@ -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 diff --git a/client/scripts/npc_runtime_hud_state.gd.uid b/client/scripts/npc_runtime_hud_state.gd.uid new file mode 100644 index 0000000..898ea90 --- /dev/null +++ b/client/scripts/npc_runtime_hud_state.gd.uid @@ -0,0 +1 @@ +uid://bneo97hudst01 diff --git a/client/scripts/player_combat_health_client.gd b/client/scripts/player_combat_health_client.gd new file mode 100644 index 0000000..dbad2a6 --- /dev/null +++ b/client/scripts/player_combat_health_client.gd @@ -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) diff --git a/client/scripts/player_combat_health_client.gd.uid b/client/scripts/player_combat_health_client.gd.uid new file mode 100644 index 0000000..cdb1c5c --- /dev/null +++ b/client/scripts/player_combat_health_client.gd.uid @@ -0,0 +1 @@ +uid://bneo97plrhp01 diff --git a/client/test/npc_combat_hud_refresh_test.gd b/client/test/npc_combat_hud_refresh_test.gd new file mode 100644 index 0000000..26281e3 --- /dev/null +++ b/client/test/npc_combat_hud_refresh_test.gd @@ -0,0 +1,125 @@ +extends GdUnitTestSuite + +## NEO-97: combat-active gate + HUD label copy from snapshot fixtures. + +const NpcRuntimeClient := preload("res://scripts/npc_runtime_client.gd") +const PrototypeTargetConstants := preload("res://scripts/prototype_target_constants.gd") + + +class NpcCombatHudHarness: + extends RefCounted + var last_target_state: Dictionary = {} + var npc_runtime_snapshot: Dictionary = {} + var dev_player_id: String = "dev-local-1" + + func is_combat_active() -> bool: + var lock_id := locked_target_id() + if PrototypeTargetConstants.ANCHORS.has(lock_id): + return true + return not incoming_aggro_row().is_empty() + + func locked_target_id() -> String: + var locked_variant: Variant = last_target_state.get("lockedTargetId", null) + if locked_variant is String: + return (locked_variant as String).strip_edges() + return "" + + func incoming_aggro_row() -> Dictionary: + if npc_runtime_snapshot.is_empty(): + return {} + return NpcRuntimeClient.row_for_aggro_holder(dev_player_id, npc_runtime_snapshot) + + func resolve_hud_npc_rows() -> Dictionary: + var lock_id := locked_target_id() + var locked_row: Dictionary = {} + if PrototypeTargetConstants.ANCHORS.has(lock_id): + locked_row = NpcRuntimeClient.npc_row(lock_id, npc_runtime_snapshot) + var incoming_row: Dictionary = {} + var aggro_row: Dictionary = incoming_aggro_row() + 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} + + func build_npc_state_label() -> String: + var rows: Dictionary = resolve_hud_npc_rows() + 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(): + return "NPC state: —" + return "\n".join(lines) + + +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}' + + "]}" + ) + + +func test_npc_lock_makes_combat_active_without_aggro_snapshot() -> void: + # Arrange + var harness := NpcCombatHudHarness.new() + harness.last_target_state = {"lockedTargetId": "prototype_npc_melee", "validity": "ok"} + # Act + var active: bool = harness.is_combat_active() + # Assert + assert_that(active).is_true() + + +func test_aggro_holder_makes_combat_active_without_lock() -> void: + # Arrange + var harness := NpcCombatHudHarness.new() + var parsed: Variant = NpcRuntimeClient.parse_runtime_json(_elite_aggro_snapshot_json()) + harness.npc_runtime_snapshot = parsed as Dictionary + # Act + var active: bool = harness.is_combat_active() + # Assert + assert_that(active).is_true() + + +func test_idle_without_lock_is_not_combat_active() -> void: + # Arrange + var harness := NpcCombatHudHarness.new() + harness.npc_runtime_snapshot = {} + # Act + var active: bool = harness.is_combat_active() + # Assert + assert_that(active).is_false() + + +func test_locked_plus_incoming_state_label() -> void: + # Arrange + var harness := NpcCombatHudHarness.new() + harness.last_target_state = {"lockedTargetId": "prototype_npc_melee", "validity": "ok"} + var parsed: Variant = NpcRuntimeClient.parse_runtime_json(_elite_aggro_snapshot_json()) + harness.npc_runtime_snapshot = parsed as Dictionary + # Act + var label: String = harness.build_npc_state_label() + # Assert + assert_that(label).contains("NPC state: Melee → idle") + assert_that(label).contains("Incoming: Elite → aggro") diff --git a/client/test/npc_runtime_client_test.gd b/client/test/npc_runtime_client_test.gd new file mode 100644 index 0000000..4d7575d --- /dev/null +++ b/client/test/npc_runtime_client_test.gd @@ -0,0 +1,118 @@ +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( + "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_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") diff --git a/client/test/npc_runtime_hud_state_test.gd b/client/test/npc_runtime_hud_state_test.gd new file mode 100644 index 0000000..c29249a --- /dev/null +++ b/client/test/npc_runtime_hud_state_test.gd @@ -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) diff --git a/client/test/player_combat_health_client_test.gd b/client/test/player_combat_health_client_test.gd new file mode 100644 index 0000000..3dfc1cb --- /dev/null +++ b/client/test/player_combat_health_client_test.gd @@ -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") diff --git a/docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md b/docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md index 34c489e..9814b79 100644 --- a/docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md +++ b/docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md @@ -93,6 +93,8 @@ 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 diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md index 8c9b078..8e8828b 100644 --- a/docs/decomposition/modules/documentation_and_implementation_alignment.md +++ b/docs/decomposition/modules/documentation_and_implementation_alignment.md @@ -57,7 +57,7 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not | E3.M3 | In Progress | **NEO-50 landed:** frozen prototype six-item catalog in [`content/items/prototype_items.json`](../../../content/items/prototype_items.json); [`item-def.schema.json`](../../../content/schemas/item-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py). **NEO-51 landed:** fail-fast server load of `content/items/*_items.json` at startup — `server/NeonSprawl.Server/Game/Items/` ([NEO-51](../../plans/NEO-51-implementation-plan.md)); [server README — Item catalog](../../../server/README.md#item-catalog-contentitems-neo-51). **NEO-52 landed:** injectable **`IItemDefinitionRegistry`** + lookup tests ([NEO-52](../../plans/NEO-52-implementation-plan.md)). **NEO-53 landed:** **`GET /game/world/item-definitions`** — `ItemDefinitionsWorldApi` + DTOs in `Game/Items/` ([NEO-53](../../plans/NEO-53-implementation-plan.md), [`NEO-53` manual QA](../../manual-qa/NEO-53.md)); [server README — Item definitions (NEO-53)](../../../server/README.md#item-definitions-neo-53); Bruno `bruno/neon-sprawl-server/item-definitions/`. **NEO-54 landed:** **`IPlayerInventoryStore`** + **`PlayerInventoryOperations`** — 24 bag + 1 equipment slots, stack rules, `V005` migration ([NEO-54](../../plans/NEO-54-implementation-plan.md)). **NEO-55 landed:** **`GET`/`POST /game/players/{id}/inventory`** — `PlayerInventoryApi` + DTOs in `Game/Items/` ([NEO-55](../../plans/NEO-55-implementation-plan.md)); [server README — Player inventory (NEO-54 store, NEO-55 HTTP)](../../../server/README.md#player-inventory-neo-54-store-neo-55-http); Bruno `bruno/neon-sprawl-server/inventory/`. **NEO-56 landed:** comment-only **`item_created`** / **`inventory_transfer_denied`** telemetry hook sites in [`PlayerInventoryOperations`](../../../server/NeonSprawl.Server/Game/Items/PlayerInventoryOperations.cs) ([NEO-56](../../plans/NEO-56-implementation-plan.md), [`NEO-56` manual QA](../../manual-qa/NEO-56.md)); no E9.M1 ingest. **NEO-72 landed:** client inventory HUD — **`inventory_client.gd`**, **`item_definitions_client.gd`**, **`InventoryLabel`**, boot hydrate + **`I`** refresh ([NEO-72](../../plans/NEO-72-implementation-plan.md), [`NEO-72` manual QA](../../manual-qa/NEO-72.md)); `client/README.md` inventory HUD section. **NEO-75 landed:** **`prototype_economy_hud_section.gd`** collapse toggle + capstone manual QA ([NEO-75](../../plans/NEO-75-implementation-plan.md), [`NEO-75` manual QA](../../manual-qa/NEO-75.md)); Epic 3 Slice 5 client complete. | [NEO-50](../../plans/NEO-50-implementation-plan.md), [NEO-51](../../plans/NEO-51-implementation-plan.md), [NEO-52](../../plans/NEO-52-implementation-plan.md), [NEO-53](../../plans/NEO-53-implementation-plan.md), [NEO-54](../../plans/NEO-54-implementation-plan.md), [NEO-55](../../plans/NEO-55-implementation-plan.md), [NEO-56](../../plans/NEO-56-implementation-plan.md), [NEO-72](../../plans/NEO-72-implementation-plan.md), [NEO-75](../../plans/NEO-75-implementation-plan.md), [E3S5-client-prototype-backlog](../../plans/E3S5-client-prototype-backlog.md), [E3M3-prototype-backlog](../../plans/E3M3-prototype-backlog.md), [E3_M3](E3_M3_ItemizationAndInventorySchema.md) | | E3.M2 | Ready | **NEO-65 landed:** frozen prototype eight-recipe catalog in [`content/recipes/prototype_recipes.json`](../../../content/recipes/prototype_recipes.json); [`recipe-def.schema.json`](../../../content/schemas/recipe-def.schema.json) + [`recipe-io-row.schema.json`](../../../content/schemas/recipe-io-row.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) Slice 3 gates ([NEO-65](../../plans/NEO-65-implementation-plan.md)). **NEO-66 landed:** fail-fast server load of `content/recipes/*_recipes.json` at startup — `server/NeonSprawl.Server/Game/Crafting/` ([NEO-66](../../plans/NEO-66-implementation-plan.md)); [server README — Recipe catalog](../../../server/README.md#recipe-catalog-contentrecipes-neo-66). **NEO-67 landed:** injectable **`IRecipeDefinitionRegistry`** + lookup tests ([NEO-67](../../plans/NEO-67-implementation-plan.md)). **NEO-68 landed:** **`GET /game/world/recipe-definitions`** — `RecipeDefinitionsWorldApi` + DTOs in `Game/Crafting/` ([NEO-68](../../plans/NEO-68-implementation-plan.md), [`NEO-68` manual QA](../../manual-qa/NEO-68.md)); [server README — Recipe definitions (NEO-68)](../../../server/README.md#recipe-definitions-neo-68); Bruno `bruno/neon-sprawl-server/recipe-definitions/`. **NEO-69 landed:** **`CraftOperations.TryCraft`** + **`CraftResult`** — pre-flight inputs/outputs, inventory mutation, refine XP ([NEO-69](../../plans/NEO-69-implementation-plan.md)); [server README — Craft engine (NEO-69)](../../../server/README.md#craft-engine-neo-69-and-craft-http-neo-70). **NEO-70 landed:** **`POST /game/players/{id}/craft`** — `PlayerCraftApi` + wire **`CraftResponse`**; Bruno gather→refine→make spine ([NEO-70](../../plans/NEO-70-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/craft/`. **NEO-71 landed:** comment-only **`item_crafted`** / **`craft_failed`** telemetry hook sites in **`CraftOperations.TryCraft`** ([NEO-71](../../plans/NEO-71-implementation-plan.md)); [server README — Craft telemetry hooks (NEO-71)](../../../server/README.md#craft-telemetry-hooks-neo-71). **NEO-42 landed (prep):** **`RefineActivitySkillXpGrant`** / **`RefineSkillXpConstants`** — wired from craft engine success path. Epic 3 Slice 3 server backlog **E3M2-01**–**E3M2-07** complete. Upstream: E3.M1 **Ready**, E3.M3 inventory landed. **NEO-74 landed:** client craft UI — **`recipe_definitions_client.gd`**, **`craft_client.gd`**, **`craft_recipe_panel.gd`**, **`CraftFeedbackLabel`**, **refine** skill row ([NEO-74](../../plans/NEO-74-implementation-plan.md), [`NEO-74` manual QA](../../manual-qa/NEO-74.md)); `client/README.md` craft section. **NEO-75 landed:** capstone playable gather→refine→make loop — [`NEO-75` manual QA](../../manual-qa/NEO-75.md), **`prototype_economy_hud_section.gd`**; Epic 3 Slice 5 client complete. | [NEO-42](../../plans/NEO-42-implementation-plan.md), [NEO-65](../../plans/NEO-65-implementation-plan.md), [NEO-66](../../plans/NEO-66-implementation-plan.md), [NEO-67](../../plans/NEO-67-implementation-plan.md), [NEO-68](../../plans/NEO-68-implementation-plan.md), [NEO-69](../../plans/NEO-69-implementation-plan.md), [NEO-70](../../plans/NEO-70-implementation-plan.md), [NEO-71](../../plans/NEO-71-implementation-plan.md), [NEO-74](../../plans/NEO-74-implementation-plan.md), [NEO-75](../../plans/NEO-75-implementation-plan.md), [E3M2-prototype-backlog](../../plans/E3M2-prototype-backlog.md), [E3S5-client-prototype-backlog](../../plans/E3S5-client-prototype-backlog.md), [E3_M2](E3_M2_RefinementAndRecipeExecution.md) | | E5.M1 | Ready | **NEO-76 landed:** frozen prototype four-ability catalog in [`content/abilities/prototype_abilities.json`](../../../content/abilities/prototype_abilities.json); [`ability-def.schema.json`](../../../content/schemas/ability-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) E5M1 four-id gate ([NEO-76](../../plans/NEO-76-implementation-plan.md)). **NEO-77 landed:** fail-fast server load of `content/abilities/*_abilities.json` at startup — `server/NeonSprawl.Server/Game/Combat/` ([NEO-77](../../plans/NEO-77-implementation-plan.md)); [server README — Ability catalog](../../../server/README.md#ability-catalog-contentabilities-neo-77). **NEO-79 landed:** injectable **`IAbilityDefinitionRegistry`** + DI; hotbar/cast use **`TryNormalizeKnown`** ([NEO-79](../../plans/NEO-79-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/hotbar-loadout/` + `ability-cast/` unknown-ability deny smokes. **NEO-78 landed:** **`GET /game/world/ability-definitions`** — `AbilityDefinitionsWorldApi` + DTOs in `Game/Combat/` ([NEO-78](../../plans/NEO-78-implementation-plan.md)); [server README — Ability definitions (NEO-78)](../../../server/README.md#ability-definitions-neo-78); Bruno `bruno/neon-sprawl-server/ability-definitions/`. **NEO-80 landed:** **`ICombatEntityHealthStore`** / **`InMemoryCombatEntityHealthStore`** — lazy init (Slice 1 flat 100 HP; **superseded by [NEO-91](../../plans/NEO-91-implementation-plan.md)** per-archetype catalog max HP + three NPC instance ids); DI via **`AddCombatEntityHealthStore()`** ([NEO-80](../../plans/NEO-80-implementation-plan.md)); [server README — Combat entity health (NEO-80, NEO-91)](../../../server/README.md#combat-entity-health-neo-80-neo-91). **NEO-81 landed:** **`CombatOperations.TryResolve`** + **`CombatResult`** + **`CombatReasonCodes`** — defeated pre-check deny, catalog damage ([NEO-81](../../plans/NEO-81-implementation-plan.md)); [server README — Combat engine (NEO-81)](../../../server/README.md#combat-engine-neo-81). **NEO-82 landed:** **`AbilityCastApi`** → **`CombatOperations.TryResolve`**; nested wire **`combatResolution`** on accept; per-ability catalog cooldown; **`target_defeated`** cast deny ([NEO-82](../../plans/NEO-82-implementation-plan.md)); [server README — Ability cast (NEO-82)](../../../server/README.md#ability-cast-neo-31-neo-82); Bruno `bruno/neon-sprawl-server/ability-cast/` defeat spine. **NEO-83 landed:** **`GET /game/world/combat-targets`** — `CombatTargetsWorldApi` + DTOs; authoritative HP snapshot from **`ICombatEntityHealthStore`** ([NEO-83](../../plans/NEO-83-implementation-plan.md)); [server README — Combat targets snapshot (NEO-83)](../../../server/README.md#combat-targets-snapshot-neo-83); Bruno `bruno/neon-sprawl-server/combat-targets/`. **NEO-44 landed:** **`CombatDefeatGigXpGrant`** on cast **`targetDefeated`** — **25** gig XP to **`breach`** via **`IPlayerGigProgressionStore`** (V007); **`GET …/gig-progression`** ([NEO-44](../../plans/NEO-44-implementation-plan.md), [`NEO-44` manual QA](../../manual-qa/NEO-44.md)); [server README — Gig progression (NEO-44)](../../../server/README.md#gig-progression-snapshot-neo-44); Bruno `bruno/neon-sprawl-server/gig-progression/`. **NEO-84 landed:** comment-only **`ability_used`** / **`enemy_defeat`** telemetry hook sites in **`CombatOperations.TryResolve`**; reserved **`encounter_start`** / **`player_death`** ([NEO-84](../../plans/NEO-84-implementation-plan.md)); [server README — Combat telemetry hooks (NEO-84)](../../../server/README.md#combat-telemetry-hooks-neo-84). **NEO-85 landed:** client combat HUD — **`ability_cast_client.gd`** parses **`combatResolution`**; **`combat_targets_client.gd`**, **`CastFeedbackLabel`** resolution copy, **`CombatTargetHpLabel`**; event-driven GET refresh ([NEO-85](../../plans/NEO-85-implementation-plan.md), [`NEO-85` manual QA](../../manual-qa/NEO-85.md)); `client/README.md` combat HUD section. **NEO-86 landed:** playable combat capstone — **`gig_progression_client.gd`**, **`GigXpLabel`**, defeat-triggered gig GET refresh; capstone manual QA [`NEO-86`](../../manual-qa/NEO-86.md); `client/README.md` end-to-end combat loop section ([NEO-86](../../plans/NEO-86-implementation-plan.md)). **Epic 5 Slice 1 client capstone complete.** **Backlog decomposed:** Epic 5 Slice 1 — **E5M1-01**–**E5M1-12** landed. **Precursor landed:** E1.M4 cast funnel (NEO-28/31/32). | [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md), [E5_M1](E5_M1_CombatRulesEngine.md), [NEO-77](../../plans/NEO-77-implementation-plan.md), [NEO-79](../../plans/NEO-79-implementation-plan.md), [NEO-78](../../plans/NEO-78-implementation-plan.md), [NEO-80](../../plans/NEO-80-implementation-plan.md), [NEO-81](../../plans/NEO-81-implementation-plan.md), [NEO-82](../../plans/NEO-82-implementation-plan.md), [NEO-83](../../plans/NEO-83-implementation-plan.md), [NEO-44](../../plans/NEO-44-implementation-plan.md), [NEO-84](../../plans/NEO-84-implementation-plan.md), [NEO-85](../../plans/NEO-85-implementation-plan.md), [NEO-86](../../plans/NEO-86-implementation-plan.md), [E1M4-prototype-backlog.md](../../plans/E1M4-prototype-backlog.md) | -| E5.M2 | In Progress | **NEO-87 landed:** frozen prototype three-behavior catalog in [`content/npc-behaviors/prototype_npc_behaviors.json`](../../../content/npc-behaviors/prototype_npc_behaviors.json); [`npc-behavior-def.schema.json`](../../../content/schemas/npc-behavior-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) E5M2 three-id gate ([NEO-87](../../plans/NEO-87-implementation-plan.md)). **NEO-88 landed:** fail-fast server load of `content/npc-behaviors/*_npc_behaviors.json` at startup — `server/NeonSprawl.Server/Game/Npc/` ([NEO-88](../../plans/NEO-88-implementation-plan.md)); [server README — NPC behavior catalog](../../../server/README.md#npc-behavior-catalog-contentnpc-behaviors-neo-88). **NEO-89 landed:** injectable **`INpcBehaviorDefinitionRegistry`** + DI; **`PrototypeNpcBehaviorRegistry`** id constants ([NEO-89](../../plans/NEO-89-implementation-plan.md)). **NEO-90 landed:** **`GET /game/world/npc-behavior-definitions`** — `NpcBehaviorDefinitionsWorldApi` + DTOs in `Game/Npc/` ([NEO-90](../../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/`. **NEO-91 landed:** **`PrototypeNpcRegistry`** + combat-target migration — three NPC instance ids replace alpha/beta; per-archetype max HP from behavior catalog ([NEO-91](../../plans/NEO-91-implementation-plan.md)); [server README — Combat entity health (NEO-91)](../../../server/README.md#combat-entity-health-neo-80-neo-91). **NEO-92 landed:** **`IThreatStateStore`** + **`AggroOperations`** — first-hit acquire on damaging cast, leash clear on move/cast; cast/move/fixture hooks ([NEO-92](../../plans/NEO-92-implementation-plan.md)); [server README — Threat / aggro state (NEO-92)](../../../server/README.md#threat--aggro-state-neo-92). **NEO-93 landed:** **`INpcRuntimeStateStore`** + **`NpcRuntimeOperations.AdvanceAll`** — catalog-driven behavior state machine, lazy tick advance with delta cap; dev fixture reset ([NEO-93](../../plans/NEO-93-implementation-plan.md)); [server README — NPC runtime behavior state (NEO-93)](../../../server/README.md#npc-runtime-behavior-state-neo-93). **NEO-94 landed:** **`GET /game/world/npc-runtime-snapshot`** — `NpcRuntimeSnapshotWorldApi` + DTOs; lazy **`AdvanceAll`** on poll; nested **`activeTelegraph`** with server-computed **`windupRemainingSeconds`** ([NEO-94](../../plans/NEO-94-implementation-plan.md)); [server README — NPC runtime snapshot (NEO-94)](../../../server/README.md#npc-runtime-snapshot-neo-94); Bruno `bruno/neon-sprawl-server/npc-runtime-snapshot/`. **NEO-95 landed:** **`IPlayerCombatHealthStore`** + **`NpcAttackOperations.TryResolveTelegraphComplete`** — telegraph complete applies catalog **`attackDamage`** to aggro holder; **`GET /game/players/{id}/combat-health`** read model ([NEO-95](../../plans/NEO-95-implementation-plan.md)); [server README — Session player combat HP (NEO-95)](../../../server/README.md#session-player-combat-hp-neo-95); Bruno `bruno/neon-sprawl-server/combat-health/`. **NEO-96 landed:** comment-only **`telegraph_fired`** + **`npc_state_transition`** hook sites in **`NpcRuntimeOperations.AdvanceAll`** ([NEO-96](../../plans/NEO-96-implementation-plan.md)); [server README — NPC runtime telemetry hooks (NEO-96)](../../../server/README.md#npc-runtime-telemetry-hooks-neo-96). **Backlog decomposed:** Epic 5 Slice 2 — [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md) **E5M2-01** [NEO-87](https://linear.app/neon-sprawl/issue/NEO-87) → **E5M2-12** [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98); client capstone **NEO-98**. Three archetypes (`prototype_melee_pressure`, `prototype_ranged_control`, `prototype_elite_mini_boss`); replaces alpha/beta dummies with **`prototype_npc_melee` / `ranged` / `elite`**. Owns **`ThreatState`**, **`TelegraphEvent`**, **`GET …/npc-runtime-snapshot`**, session **`IPlayerCombatHealthStore`**. Upstream **E5.M1 Ready**. | [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md), [E5_M2](E5_M2_NpcAiAndBehaviorProfiles.md), [NEO-87](../../plans/NEO-87-implementation-plan.md), [NEO-88](../../plans/NEO-88-implementation-plan.md), [NEO-89](../../plans/NEO-89-implementation-plan.md), [NEO-90](../../plans/NEO-90-implementation-plan.md), [NEO-91](../../plans/NEO-91-implementation-plan.md), [NEO-92](../../plans/NEO-92-implementation-plan.md), [NEO-93](../../plans/NEO-93-implementation-plan.md), [NEO-94](../../plans/NEO-94-implementation-plan.md), [NEO-95](../../plans/NEO-95-implementation-plan.md), [NEO-96](../../plans/NEO-96-implementation-plan.md), label **`E5.M2`** on NEO-87–NEO-98 | +| E5.M2 | In Progress | **NEO-87 landed:** frozen prototype three-behavior catalog in [`content/npc-behaviors/prototype_npc_behaviors.json`](../../../content/npc-behaviors/prototype_npc_behaviors.json); [`npc-behavior-def.schema.json`](../../../content/schemas/npc-behavior-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) E5M2 three-id gate ([NEO-87](../../plans/NEO-87-implementation-plan.md)). **NEO-88 landed:** fail-fast server load of `content/npc-behaviors/*_npc_behaviors.json` at startup — `server/NeonSprawl.Server/Game/Npc/` ([NEO-88](../../plans/NEO-88-implementation-plan.md)); [server README — NPC behavior catalog](../../../server/README.md#npc-behavior-catalog-contentnpc-behaviors-neo-88). **NEO-89 landed:** injectable **`INpcBehaviorDefinitionRegistry`** + DI; **`PrototypeNpcBehaviorRegistry`** id constants ([NEO-89](../../plans/NEO-89-implementation-plan.md)). **NEO-90 landed:** **`GET /game/world/npc-behavior-definitions`** — `NpcBehaviorDefinitionsWorldApi` + DTOs in `Game/Npc/` ([NEO-90](../../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/`. **NEO-91 landed:** **`PrototypeNpcRegistry`** + combat-target migration — three NPC instance ids replace alpha/beta; per-archetype max HP from behavior catalog ([NEO-91](../../plans/NEO-91-implementation-plan.md)); [server README — Combat entity health (NEO-91)](../../../server/README.md#combat-entity-health-neo-80-neo-91). **NEO-92 landed:** **`IThreatStateStore`** + **`AggroOperations`** — first-hit acquire on damaging cast, leash clear on move/cast; cast/move/fixture hooks ([NEO-92](../../plans/NEO-92-implementation-plan.md)); [server README — Threat / aggro state (NEO-92)](../../../server/README.md#threat--aggro-state-neo-92). **NEO-93 landed:** **`INpcRuntimeStateStore`** + **`NpcRuntimeOperations.AdvanceAll`** — catalog-driven behavior state machine, lazy tick advance with delta cap; dev fixture reset ([NEO-93](../../plans/NEO-93-implementation-plan.md)); [server README — NPC runtime behavior state (NEO-93)](../../../server/README.md#npc-runtime-behavior-state-neo-93). **NEO-94 landed:** **`GET /game/world/npc-runtime-snapshot`** — `NpcRuntimeSnapshotWorldApi` + DTOs; lazy **`AdvanceAll`** on poll; nested **`activeTelegraph`** with server-computed **`windupRemainingSeconds`** ([NEO-94](../../plans/NEO-94-implementation-plan.md)); [server README — NPC runtime snapshot (NEO-94)](../../../server/README.md#npc-runtime-snapshot-neo-94); Bruno `bruno/neon-sprawl-server/npc-runtime-snapshot/`. **NEO-95 landed:** **`IPlayerCombatHealthStore`** + **`NpcAttackOperations.TryResolveTelegraphComplete`** — telegraph complete applies catalog **`attackDamage`** to aggro holder; **`GET /game/players/{id}/combat-health`** read model ([NEO-95](../../plans/NEO-95-implementation-plan.md)); [server README — Session player combat HP (NEO-95)](../../../server/README.md#session-player-combat-hp-neo-95); Bruno `bruno/neon-sprawl-server/combat-health/`. **NEO-96 landed:** comment-only **`telegraph_fired`** + **`npc_state_transition`** hook sites in **`NpcRuntimeOperations.AdvanceAll`** ([NEO-96](../../plans/NEO-96-implementation-plan.md)); [server README — NPC runtime telemetry hooks (NEO-96)](../../../server/README.md#npc-runtime-telemetry-hooks-neo-96). **NEO-97 landed:** client telegraph HUD — **`npc_runtime_client.gd`**, **`player_combat_health_client.gd`**, **`npc_runtime_hud_state.gd`**, **`TelegraphLabel`** / **`NpcStateLabel`** / **`PlayerCombatHpLabel`**; combat-active ~1 Hz poll ([NEO-97](../../plans/NEO-97-implementation-plan.md), [`NEO-97` manual QA](../../manual-qa/NEO-97.md)); [client README — NPC runtime + telegraph HUD (NEO-97)](../../../client/README.md#npc-runtime--telegraph-hud-neo-97). **Backlog decomposed:** Epic 5 Slice 2 — [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md) **E5M2-01** [NEO-87](https://linear.app/neon-sprawl/issue/NEO-87) → **E5M2-12** [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98); client capstone **NEO-98**. Three archetypes (`prototype_melee_pressure`, `prototype_ranged_control`, `prototype_elite_mini_boss`); replaces alpha/beta dummies with **`prototype_npc_melee` / `ranged` / `elite`**. Owns **`ThreatState`**, **`TelegraphEvent`**, **`GET …/npc-runtime-snapshot`**, session **`IPlayerCombatHealthStore`**. Upstream **E5.M1 Ready**. | [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md), [E5_M2](E5_M2_NpcAiAndBehaviorProfiles.md), [NEO-87](../../plans/NEO-87-implementation-plan.md), [NEO-88](../../plans/NEO-88-implementation-plan.md), [NEO-89](../../plans/NEO-89-implementation-plan.md), [NEO-90](../../plans/NEO-90-implementation-plan.md), [NEO-91](../../plans/NEO-91-implementation-plan.md), [NEO-92](../../plans/NEO-92-implementation-plan.md), [NEO-93](../../plans/NEO-93-implementation-plan.md), [NEO-94](../../plans/NEO-94-implementation-plan.md), [NEO-95](../../plans/NEO-95-implementation-plan.md), [NEO-96](../../plans/NEO-96-implementation-plan.md), [NEO-97](../../plans/NEO-97-implementation-plan.md), label **`E5.M2`** on NEO-87–NEO-98 | --- diff --git a/docs/manual-qa/NEO-97.md b/docs/manual-qa/NEO-97.md new file mode 100644 index 0000000..125c1ad --- /dev/null +++ b/docs/manual-qa/NEO-97.md @@ -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). diff --git a/docs/plans/E5M2-prototype-backlog.md b/docs/plans/E5M2-prototype-backlog.md index 85e1d4d..937a6f7 100644 --- a/docs/plans/E5M2-prototype-backlog.md +++ b/docs/plans/E5M2-prototype-backlog.md @@ -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**). diff --git a/docs/plans/NEO-97-implementation-plan.md b/docs/plans/NEO-97-implementation-plan.md index 9083eca..49c3712 100644 --- a/docs/plans/NEO-97-implementation-plan.md +++ b/docs/plans/NEO-97-implementation-plan.md @@ -50,9 +50,18 @@ ## Acceptance criteria checklist -- [ ] Player-visible telegraph countdown matches server snapshot within one poll frame (local tick-down between polls; reconciles on each GET). -- [ ] Tab cycle visits three NPC instances in documented order (`prototype_npc_elite` → `prototype_npc_melee` → `prototype_npc_ranged`). -- [ ] Manual QA checklist exercisable without Bruno. +- [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`** — poll + parse + windup interpolation. +- **`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`**. +- **`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