From b0d0c4262e30c9de6f318eb5c22c9526ee8a34ed Mon Sep 17 00:00:00 2001 From: VinPropane Date: Fri, 29 May 2026 20:56:34 -0400 Subject: [PATCH 01/12] NEO-97: add E5M2-11 client telegraph HUD implementation plan Kickoff plan for Godot poll clients, combat-active HUD labels, and manual QA; records kickoff decisions on poll gating, HUD scope, and windup interpolation. --- docs/plans/NEO-97-implementation-plan.md | 176 +++++++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 docs/plans/NEO-97-implementation-plan.md diff --git a/docs/plans/NEO-97-implementation-plan.md b/docs/plans/NEO-97-implementation-plan.md new file mode 100644 index 0000000..9083eca --- /dev/null +++ b/docs/plans/NEO-97-implementation-plan.md @@ -0,0 +1,176 @@ +# NEO-97 — Implementation plan + +## Story reference + +| Field | Value | +|--------|--------| +| **Key** | NEO-97 | +| **Title** | E5M2-11: Client telegraph HUD + NPC archetype markers | +| **Linear** | https://linear.app/neon-sprawl/issue/NEO-97/e5m2-11-client-telegraph-hud-npc-archetype-markers | +| **Module** | [E5.M2 — NpcAiAndBehaviorProfiles](../decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md) · Epic 5 Slice 2 · backlog **E5M2-11** | +| **Branch** | `NEO-97-e5m2-11-client-telegraph-hud` | +| **Server deps** | [NEO-94](https://linear.app/neon-sprawl/issue/NEO-94) — **`GET /game/world/npc-runtime-snapshot`** (**Done**); [NEO-95](https://linear.app/neon-sprawl/issue/NEO-95) — **`GET /game/players/{id}/combat-health`** (**Done**) | +| **Pattern** | [NEO-85](https://linear.app/neon-sprawl/issue/NEO-85) / [NEO-32](https://linear.app/neon-sprawl/issue/NEO-32) — thin HTTP clients, `main.gd` HUD wiring, GdUnit mock transport, optional local tick-down between polls | +| **Downstream** | [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98) — playable NPC telegraph combat capstone (manual QA script) | +| **Server counterpart** | [NEO-94](https://linear.app/neon-sprawl/issue/NEO-94) + [NEO-95](https://linear.app/neon-sprawl/issue/NEO-95) — this issue is the **client** half; Bruno-only verification is not prototype-complete per [full-stack epic decomposition](../../.cursor/rules/full-stack-epic-decomposition.md) | + +## Kickoff clarifications + +| Topic | Question | Agent recommendation | Answer | +|--------|----------|----------------------|--------| +| **Poll gating** | When does ~1 Hz polling run? | **When lock held OR player is `aggroHolderPlayerId` on any NPC** — combat HUD visible without requiring lock on the attacking NPC ([NEO-95](https://linear.app/neon-sprawl/issue/NEO-95) incoming telegraph/HP feedback). | **User:** combat-active gate. | +| **HUD scope** | Which NPC row(s) for `TelegraphLabel` + `NpcStateLabel`? | **Locked NPC + incoming threat** — primary row for locked target; when player is aggro holder on a different NPC, show that NPC’s state/telegraph as incoming threat. | **User:** locked + incoming. | +| **Windup display** | Raw poll value vs local tick-down? | **Server value at poll + local tick-down between polls** — `cooldown_state.gd` pattern; AC “within one poll frame” satisfied at poll boundaries. | **User:** interpolate. | +| **Markers script name** | Backlog `npc_archetype_markers.gd` vs existing `prototype_target_markers.gd`? | **Keep `prototype_target_markers.gd`** — already wired in `main.tscn` as `World/PrototypeTargetMarkers`; NEO-24/NEO-91 precedent. | **Adopted** (repo precedent; not asked). | +| **Constants / tab cycle** | Migrate `prototype_target_constants.gd` to three NPC ids? | **Already landed on `main`** (NEO-91) — verify parity with `PrototypeNpcRegistry.GetInstanceIdsInOrder()`; no duplicate migration. | **Adopted** (already on `main`). | +| **Manual QA doc** | `docs/manual-qa/NEO-97.md`? | **Yes** — player-visible client story; E5M2-11 AC + NEO-85 precedent. | **Adopted** (Linear AC + backlog). | + +## Goal, scope, and out-of-scope + +**Goal:** Godot shows **server-owned** telegraph timing, NPC runtime state, player combat HP, and three archetype world markers — driven by **`npc-runtime-snapshot`** + **`combat-health`** poll, completing the client half of Epic 5 Slice 2 combat feedback. + +**In scope (from Linear + [E5M2-11](E5M2-prototype-backlog.md#e5m2-11--client-telegraph-hud--npc-archetype-markers)):** + +- **`npc_runtime_client.gd`** — poll **`GET /game/world/npc-runtime-snapshot`** (~1 Hz when combat-active per kickoff). +- **`player_combat_health_client.gd`** — poll **`GET /game/players/{id}/combat-health`** on the same timer gate. +- **`npc_runtime_hud_state.gd`** (RefCounted) — cache snapshot + local windup tick-down between polls (kickoff interpolate decision). +- HUD labels under **`UICanvas`**: **`TelegraphLabel`**, **`NpcStateLabel`**, **`PlayerCombatHpLabel`**. +- **`main.gd` wiring:** combat poll timer, render helpers, combat-active gate (lock held OR player is aggro holder). +- **`prototype_target_markers.gd`** — already spawns three archetype markers; verify highlight + labels match migrated constants (no rename to `npc_archetype_markers.gd`). +- **`client/README.md`** — NPC / telegraph HUD section; fix stale alpha/beta copy. +- GdUnit tests with HTTP doubles ([testing-expectations.md](../../.cursor/rules/testing-expectations.md)). +- **`docs/manual-qa/NEO-97.md`** — Godot steps (server + client running). + +**Out of scope (from Linear + backlog):** + +- Final VFX art; dodge mechanics (prototype shows telegraph only). +- Server API / DTO changes ([NEO-94](https://linear.app/neon-sprawl/issue/NEO-94), [NEO-95](https://linear.app/neon-sprawl/issue/NEO-95) already landed). +- Playable capstone script ([NEO-98](https://linear.app/neon-sprawl/issue/NEO-98)). +- Telemetry ingest ([NEO-96](https://linear.app/neon-sprawl/issue/NEO-96) server comment hooks only). + +## Acceptance criteria checklist + +- [ ] 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. + +## 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 **`serverTimeUtc`** anchor + receive tick (same pattern as **`cooldown_state.gd`**). +- 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/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 + label render harness (mock clients + snapshot fixtures). | +| `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 helpers, combat-active gate. | +| `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. | +| `docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md` | NEO-97 client landed note under Related implementation slices. | +| `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_runtime_json`** schema v1; **`npc_row`**; **`row_for_aggro_holder`**. | +| `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 starts/stops timer; locked + incoming label copy from fixture snapshots. | +| *(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** | From cfda0acd9aab534b9deeaf3fff1a758d9230657f Mon Sep 17 00:00:00 2001 From: VinPropane Date: Fri, 29 May 2026 20:59:11 -0400 Subject: [PATCH 02/12] 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 From 39ec50efa27ad65cd44f6ab093a5c5f55315dc86 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Fri, 29 May 2026 21:00:55 -0400 Subject: [PATCH 03/12] NEO-97: add code review for E5M2-11 client telegraph HUD --- docs/reviews/2026-05-29-NEO-97.md | 68 +++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 docs/reviews/2026-05-29-NEO-97.md diff --git a/docs/reviews/2026-05-29-NEO-97.md b/docs/reviews/2026-05-29-NEO-97.md new file mode 100644 index 0000000..ca7e362 --- /dev/null +++ b/docs/reviews/2026-05-29-NEO-97.md @@ -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` | **Partially matches** — NEO-97 landed line under Related implementation slices is correct; **Risks** bullet still says “no client windup math” (conflicts with kickoff interpolate decision); NPC instance row still says “Breaking: Godot constants migrate in NEO-97” though constants landed in NEO-91. | +| `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`. + +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. + +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()`. + +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`. + +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. + +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. + +## 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. + +- 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. + +- 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. + +## 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. From fd0b6d3db898dec1344043d4803fbdeb608a8ce5 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Fri, 29 May 2026 21:07:02 -0400 Subject: [PATCH 04/12] =?UTF-8?q?NEO-97:=20address=20code=20review=20?= =?UTF-8?q?=E2=80=94=20helpers,=20tests,=20doc=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract npc_combat_hud_helpers.gd for shared HUD formatters; hide telegraph at zero windup; surface runtime sync errors; npc_row last-snapshot fallback; expand GdUnit coverage; align E5.M2 module docs and strike review findings. --- client/scripts/main.gd | 105 ++-------- client/scripts/npc_combat_hud_helpers.gd | 133 +++++++++++++ client/scripts/npc_combat_hud_helpers.gd.uid | 1 + client/scripts/npc_runtime_client.gd | 11 ++ client/test/npc_combat_hud_refresh_test.gd | 179 ++++++++++-------- client/test/npc_runtime_client_test.gd | 14 ++ .../modules/E5_M2_NpcAiAndBehaviorProfiles.md | 4 +- docs/plans/NEO-97-implementation-plan.md | 21 +- docs/reviews/2026-05-29-NEO-97.md | 20 +- 9 files changed, 305 insertions(+), 183 deletions(-) create mode 100644 client/scripts/npc_combat_hud_helpers.gd create mode 100644 client/scripts/npc_combat_hud_helpers.gd.uid diff --git a/client/scripts/main.gd b/client/scripts/main.gd index e8389d0..c7da57f 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -45,6 +45,7 @@ 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 COMBAT_POLL_INTERVAL_SEC: float = 1.0 const COOLDOWN_REASON_ON_CD := "on_cooldown" const DEV_BOOTSTRAP_CAST_ABILITY_ID := "prototype_pulse" @@ -87,6 +88,7 @@ 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 @@ -1066,35 +1068,16 @@ func _dev_player_id() -> String: 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) + return NpcCombatHudHelpers.is_combat_active( + _last_target_state, _npc_runtime_snapshot, _dev_player_id() + ) 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} + return NpcCombatHudHelpers.resolve_hud_npc_rows( + _last_target_state, _npc_runtime_snapshot, _dev_player_id() + ) func _request_npc_combat_sync() -> void: @@ -1126,6 +1109,7 @@ func _on_combat_poll_timeout() -> void: 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) @@ -1133,7 +1117,8 @@ func _on_npc_runtime_received(snapshot: Dictionary) -> void: _render_npc_combat_hud_labels() -func _on_npc_runtime_sync_failed(_reason: String) -> void: +func _on_npc_runtime_sync_failed(reason: String) -> void: + _npc_runtime_sync_error = reason _render_npc_combat_hud_labels() @@ -1171,75 +1156,25 @@ func _render_npc_combat_hud_labels() -> void: 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 + return NpcCombatHudHelpers.has_interpolated_telegraph_display( + _resolve_hud_npc_rows(), _npc_runtime_hud_state + ) 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] + _npc_state_label.text = NpcCombatHudHelpers.build_npc_state_label( + _resolve_hud_npc_rows(), _npc_runtime_sync_error + ) 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 + _telegraph_label.text = NpcCombatHudHelpers.build_telegraph_label( + _resolve_hud_npc_rows(), _npc_runtime_hud_state, _npc_runtime_sync_error + ) func _on_move_rejected(reason_code: String) -> void: diff --git a/client/scripts/npc_combat_hud_helpers.gd b/client/scripts/npc_combat_hud_helpers.gd new file mode 100644 index 0000000..1d66ebb --- /dev/null +++ b/client/scripts/npc_combat_hud_helpers.gd @@ -0,0 +1,133 @@ +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 +) -> 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 = NpcRuntimeClient.npc_row(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 diff --git a/client/scripts/npc_combat_hud_helpers.gd.uid b/client/scripts/npc_combat_hud_helpers.gd.uid new file mode 100644 index 0000000..00b4f25 --- /dev/null +++ b/client/scripts/npc_combat_hud_helpers.gd.uid @@ -0,0 +1 @@ +uid://bneo97hudhlpr1 diff --git a/client/scripts/npc_runtime_client.gd b/client/scripts/npc_runtime_client.gd index 78263b4..b4dbd89 100644 --- a/client/scripts/npc_runtime_client.gd +++ b/client/scripts/npc_runtime_client.gd @@ -53,7 +53,18 @@ static func parse_runtime_json(text: String) -> Variant: 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(instance_id: String, snapshot: Dictionary = {}) -> Dictionary: + return npc_row_in_snapshot(instance_id, snapshot) + + +static func npc_row_in_snapshot(instance_id: String, snapshot: Dictionary) -> Dictionary: var source: Dictionary = snapshot if source.is_empty(): return {} diff --git a/client/test/npc_combat_hud_refresh_test.gd b/client/test/npc_combat_hud_refresh_test.gd index 26281e3..e031e07 100644 --- a/client/test/npc_combat_hud_refresh_test.gd +++ b/client/test/npc_combat_hud_refresh_test.gd @@ -3,71 +3,8 @@ 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) +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: @@ -81,45 +18,131 @@ static func _elite_aggro_snapshot_json() -> String: ) +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 harness := NpcCombatHudHarness.new() - harness.last_target_state = {"lockedTargetId": "prototype_npc_melee", "validity": "ok"} + var target_state := {"lockedTargetId": "prototype_npc_melee", "validity": "ok"} # Act - var active: bool = harness.is_combat_active() + 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 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() + 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 - var harness := NpcCombatHudHarness.new() - harness.npc_runtime_snapshot = {} # Act - var active: bool = harness.is_combat_active() + var active: bool = NpcCombatHudHelpers.is_combat_active({}, {}, "dev-local-1") # Assert assert_that(active).is_false() +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 harness := NpcCombatHudHarness.new() - harness.last_target_state = {"lockedTargetId": "prototype_npc_melee", "validity": "ok"} + var 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 + var rows: Dictionary = NpcCombatHudHelpers.resolve_hud_npc_rows( + target_state, parsed as Dictionary, "dev-local-1" + ) # Act - var label: String = harness.build_npc_state_label() + 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)") diff --git a/client/test/npc_runtime_client_test.gd b/client/test/npc_runtime_client_test.gd index 4d7575d..37149f6 100644 --- a/client/test/npc_runtime_client_test.gd +++ b/client/test/npc_runtime_client_test.gd @@ -104,6 +104,20 @@ func test_row_for_aggro_holder_returns_elite_row() -> void: 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() diff --git a/docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md b/docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md index 9814b79..a241f37 100644 --- a/docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md +++ b/docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md @@ -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). @@ -99,7 +99,7 @@ The **first shipped three-archetype NPC spine** under `content/npc-behaviors/*.j ## 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 diff --git a/docs/plans/NEO-97-implementation-plan.md b/docs/plans/NEO-97-implementation-plan.md index 49c3712..f7a9bed 100644 --- a/docs/plans/NEO-97-implementation-plan.md +++ b/docs/plans/NEO-97-implementation-plan.md @@ -56,10 +56,10 @@ ## Implementation reconciliation (shipped) -- **`npc_runtime_client.gd`**, **`player_combat_health_client.gd`**, **`npc_runtime_hud_state.gd`** — poll + parse + windup interpolation. +- **`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`**. +- 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`. @@ -88,7 +88,7 @@ ### Local windup mirror (`npc_runtime_hud_state.gd`) -- On **`runtime_received`**: store **`serverTimeUtc`** anchor + receive tick (same pattern as **`cooldown_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. @@ -147,31 +147,36 @@ Use server snake_case **`state`** as-is for prototype debug readability (`idle`, | `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 + label render harness (mock clients + snapshot fixtures). | +| `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 helpers, combat-active gate. | +| `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. | -| `docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md` | NEO-97 client landed note under Related implementation slices. | +| `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_runtime_json`** schema v1; **`npc_row`**; **`row_for_aggro_holder`**. | +| `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 starts/stops timer; locked + incoming label copy from fixture snapshots. | +| `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 diff --git a/docs/reviews/2026-05-29-NEO-97.md b/docs/reviews/2026-05-29-NEO-97.md index ca7e362..5015ae0 100644 --- a/docs/reviews/2026-05-29-NEO-97.md +++ b/docs/reviews/2026-05-29-NEO-97.md @@ -18,7 +18,7 @@ NEO-97 adds the Epic 5 Slice 2 **client half**: thin HTTP clients for **`GET /ga |------|--------| | `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` | **Partially matches** — NEO-97 landed line under Related implementation slices is correct; **Risks** bullet still says “no client windup math” (conflicts with kickoff interpolate decision); NPC instance row still says “Breaking: Godot constants migrate in NEO-97” though constants landed in NEO-91. | +| `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. | @@ -30,27 +30,27 @@ 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`. +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. +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()`. +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`. +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. +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. +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. +- ~~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. +- 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. +- ~~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 From 55bb8d987a4e35e4a64e02236715c615e7b7237f Mon Sep 17 00:00:00 2001 From: VinPropane Date: Fri, 29 May 2026 21:09:25 -0400 Subject: [PATCH 05/12] NEO-97: fix duplicate npc_row parse error in runtime client GDScript cannot define instance and static npc_row with the same name; helpers and tests now call npc_row_in_snapshot for static lookup. --- client/scripts/npc_combat_hud_helpers.gd | 2 +- client/scripts/npc_runtime_client.gd | 4 ---- client/test/npc_runtime_client_test.gd | 2 +- 3 files changed, 2 insertions(+), 6 deletions(-) diff --git a/client/scripts/npc_combat_hud_helpers.gd b/client/scripts/npc_combat_hud_helpers.gd index 1d66ebb..c7d22da 100644 --- a/client/scripts/npc_combat_hud_helpers.gd +++ b/client/scripts/npc_combat_hud_helpers.gd @@ -38,7 +38,7 @@ static func resolve_hud_npc_rows( var lock_id := locked_target_id_from_state(last_target_state) var locked_row: Dictionary = {} if is_known_npc_id(lock_id): - locked_row = NpcRuntimeClient.npc_row(lock_id, npc_runtime_snapshot) + 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(): diff --git a/client/scripts/npc_runtime_client.gd b/client/scripts/npc_runtime_client.gd index b4dbd89..cb361d2 100644 --- a/client/scripts/npc_runtime_client.gd +++ b/client/scripts/npc_runtime_client.gd @@ -60,10 +60,6 @@ func npc_row(instance_id: String, snapshot: Dictionary = {}) -> Dictionary: return npc_row_in_snapshot(instance_id, source) -static func npc_row(instance_id: String, snapshot: Dictionary = {}) -> Dictionary: - return npc_row_in_snapshot(instance_id, snapshot) - - static func npc_row_in_snapshot(instance_id: String, snapshot: Dictionary) -> Dictionary: var source: Dictionary = snapshot if source.is_empty(): diff --git a/client/test/npc_runtime_client_test.gd b/client/test/npc_runtime_client_test.gd index 37149f6..b8b9c81 100644 --- a/client/test/npc_runtime_client_test.gd +++ b/client/test/npc_runtime_client_test.gd @@ -83,7 +83,7 @@ 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( + var row: Dictionary = NpcRuntimeClient.npc_row_in_snapshot( "prototype_npc_elite", parsed as Dictionary ) # Assert From d86ff337bc40e9030c4b8e62292211d5086a867e Mon Sep 17 00:00:00 2001 From: VinPropane Date: Fri, 29 May 2026 21:09:28 -0400 Subject: [PATCH 06/12] NEO-97: add GdUnit test script uid companions --- client/test/npc_combat_hud_refresh_test.gd.uid | 1 + client/test/npc_runtime_client_test.gd.uid | 1 + client/test/npc_runtime_hud_state_test.gd.uid | 1 + client/test/player_combat_health_client_test.gd.uid | 1 + 4 files changed, 4 insertions(+) create mode 100644 client/test/npc_combat_hud_refresh_test.gd.uid create mode 100644 client/test/npc_runtime_client_test.gd.uid create mode 100644 client/test/npc_runtime_hud_state_test.gd.uid create mode 100644 client/test/player_combat_health_client_test.gd.uid diff --git a/client/test/npc_combat_hud_refresh_test.gd.uid b/client/test/npc_combat_hud_refresh_test.gd.uid new file mode 100644 index 0000000..05e2ad2 --- /dev/null +++ b/client/test/npc_combat_hud_refresh_test.gd.uid @@ -0,0 +1 @@ +uid://bppioj8bo1k6o diff --git a/client/test/npc_runtime_client_test.gd.uid b/client/test/npc_runtime_client_test.gd.uid new file mode 100644 index 0000000..c071408 --- /dev/null +++ b/client/test/npc_runtime_client_test.gd.uid @@ -0,0 +1 @@ +uid://bbu7b0lvpfvcr diff --git a/client/test/npc_runtime_hud_state_test.gd.uid b/client/test/npc_runtime_hud_state_test.gd.uid new file mode 100644 index 0000000..1571fa6 --- /dev/null +++ b/client/test/npc_runtime_hud_state_test.gd.uid @@ -0,0 +1 @@ +uid://ds0vmcwwd3bw8 diff --git a/client/test/player_combat_health_client_test.gd.uid b/client/test/player_combat_health_client_test.gd.uid new file mode 100644 index 0000000..969748f --- /dev/null +++ b/client/test/player_combat_health_client_test.gd.uid @@ -0,0 +1 @@ +uid://b1m0yujmrbutt From d2765dca826f00010b8bb69ebf29cf9b3411fb85 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Fri, 29 May 2026 21:30:35 -0400 Subject: [PATCH 07/12] NEO-97: increase prototype HUD label font sizes Bump UICanvas combat and economy labels from 15/14 to 18/17 and shift vertical offsets so larger text does not clip. --- client/scenes/main.tscn | 60 ++++++++++++++++++++++------------------- 1 file changed, 32 insertions(+), 28 deletions(-) diff --git a/client/scenes/main.tscn b/client/scenes/main.tscn index 51a807d..12ca05e 100644 --- a/client/scenes/main.tscn +++ b/client/scenes/main.tscn @@ -1132,19 +1132,23 @@ offset_top = -44.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 = 6 +theme_override_font_sizes/font_size = 18 autowrap_mode = 3 [node name="PlayerPositionLabel" type="Label" parent="UICanvas" unique_id=9000003] offset_left = 8.0 offset_top = 8.0 offset_right = 360.0 -offset_bottom = 106.0 +offset_bottom = 118.0 grow_horizontal = 0 grow_vertical = 0 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_font_sizes/font_size = 18 text = "x: — y: — z: — @@ -1160,7 +1164,7 @@ grow_vertical = 0 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_font_sizes/font_size = 18 text = "Target: — Validity: — Seq: —" @@ -1175,7 +1179,7 @@ grow_vertical = 0 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_font_sizes/font_size = 18 autowrap_mode = 3 text = "Cast: —" @@ -1183,103 +1187,103 @@ text = "Cast: —" offset_left = 8.0 offset_top = 374.0 offset_right = 520.0 -offset_bottom = 398.0 +offset_bottom = 402.0 grow_horizontal = 0 grow_vertical = 0 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_font_sizes/font_size = 18 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_top = 404.0 offset_right = 520.0 -offset_bottom = 424.0 +offset_bottom = 432.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 +theme_override_font_sizes/font_size = 18 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_top = 434.0 offset_right = 520.0 -offset_bottom = 456.0 +offset_bottom = 472.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 +theme_override_font_sizes/font_size = 18 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_top = 474.0 offset_right = 520.0 -offset_bottom = 482.0 +offset_bottom = 502.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 +theme_override_font_sizes/font_size = 18 autowrap_mode = 3 text = "Telegraph: —" [node name="GatherFeedbackLabel" type="Label" parent="UICanvas" unique_id=9000008] offset_left = 8.0 -offset_top = 484.0 +offset_top = 504.0 offset_right = 520.0 -offset_bottom = 510.0 +offset_bottom = 532.0 grow_horizontal = 0 grow_vertical = 0 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_font_sizes/font_size = 17 autowrap_mode = 3 text = "Gather: —" [node name="CraftFeedbackLabel" type="Label" parent="UICanvas" unique_id=9000010] offset_left = 8.0 -offset_top = 512.0 +offset_top = 534.0 offset_right = 520.0 -offset_bottom = 538.0 +offset_bottom = 562.0 grow_horizontal = 0 grow_vertical = 0 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_font_sizes/font_size = 17 autowrap_mode = 3 text = "Craft: —" [node name="CooldownSlotsLabel" type="Label" parent="UICanvas" unique_id=9000006] offset_left = 8.0 -offset_top = 544.0 +offset_top = 564.0 offset_right = 520.0 -offset_bottom = 620.0 +offset_bottom = 642.0 grow_horizontal = 0 grow_vertical = 0 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_font_sizes/font_size = 17 autowrap_mode = 3 text = "Cooldowns: —" [node name="EconomyHudSection" type="VBoxContainer" parent="UICanvas" unique_id=9000014] offset_left = 8.0 -offset_top = 632.0 +offset_top = 648.0 offset_right = 520.0 offset_bottom = 1164.0 grow_horizontal = 0 @@ -1303,7 +1307,7 @@ 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_font_sizes/font_size = 17 autowrap_mode = 3 text = "Inventory: (refresh: I) Loading…" @@ -1315,7 +1319,7 @@ 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_font_sizes/font_size = 17 autowrap_mode = 3 text = "Skills: Loading…" @@ -1327,7 +1331,7 @@ 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_font_sizes/font_size = 17 autowrap_mode = 3 text = "Gig XP: Loading…" From ab0debd0b47d82cae8fa08ba854e3f7157c5b163 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Fri, 29 May 2026 21:32:18 -0400 Subject: [PATCH 08/12] NEO-97: fix HUD overlap with vertical stack and larger fonts Replace fixed-height absolute labels with HudRoot VBoxContainer so text expands without overlapping; apply 22/20px theme via prototype_hud_theme. --- client/scenes/main.tscn | 210 ++++++++---------- client/scripts/craft_recipe_panel.gd | 4 + client/scripts/main.gd | 33 ++- client/scripts/prototype_hud_theme.gd | 48 ++++ client/scripts/prototype_hud_theme.gd.uid | 1 + .../scripts/prototype_hud_theme_constants.gd | 15 ++ .../prototype_hud_theme_constants.gd.uid | 1 + 7 files changed, 180 insertions(+), 132 deletions(-) create mode 100644 client/scripts/prototype_hud_theme.gd create mode 100644 client/scripts/prototype_hud_theme.gd.uid create mode 100644 client/scripts/prototype_hud_theme_constants.gd create mode 100644 client/scripts/prototype_hud_theme_constants.gd.uid diff --git a/client/scenes/main.tscn b/client/scenes/main.tscn index 12ca05e..7237929 100644 --- a/client/scenes/main.tscn +++ b/client/scenes/main.tscn @@ -1128,225 +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 = 6 -theme_override_font_sizes/font_size = 18 +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 = 118.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 = 18 +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 = 18 +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 = 18 +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 = 402.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 = 18 +theme_override_constants/outline_size = 8 +theme_override_font_sizes/font_size = 22 autowrap_mode = 3 text = "Target HP: — (Tab → dummy)" -[node name="PlayerCombatHpLabel" type="Label" parent="UICanvas" unique_id=9000019] -offset_left = 8.0 -offset_top = 404.0 -offset_right = 520.0 -offset_bottom = 432.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 = 6 -theme_override_font_sizes/font_size = 18 +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" unique_id=9000020] -offset_left = 8.0 -offset_top = 434.0 -offset_right = 520.0 -offset_bottom = 472.0 -grow_horizontal = 0 -grow_vertical = 0 +[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 = 6 -theme_override_font_sizes/font_size = 18 +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" unique_id=9000021] -offset_left = 8.0 -offset_top = 474.0 -offset_right = 520.0 -offset_bottom = 502.0 -grow_horizontal = 0 -grow_vertical = 0 +[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 = 6 -theme_override_font_sizes/font_size = 18 +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" unique_id=9000008] -offset_left = 8.0 -offset_top = 504.0 -offset_right = 520.0 -offset_bottom = 532.0 -grow_horizontal = 0 -grow_vertical = 0 +[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 = 17 +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 = 534.0 -offset_right = 520.0 -offset_bottom = 562.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 = 17 +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 = 564.0 -offset_right = 520.0 -offset_bottom = 642.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 = 17 +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 = 648.0 -offset_right = 520.0 -offset_bottom = 1164.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 = 17 +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 = 17 +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 = 17 +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 diff --git a/client/scripts/craft_recipe_panel.gd b/client/scripts/craft_recipe_panel.gd index 8780163..8c6f36e 100644 --- a/client/scripts/craft_recipe_panel.gd +++ b/client/scripts/craft_recipe_panel.gd @@ -2,6 +2,8 @@ extends Control ## NEO-74: scrollable prototype recipe list with per-row Craft buttons. +const PrototypeHudTheme := preload("res://scripts/prototype_hud_theme.gd") + signal craft_requested(recipe_id: String) var _item_defs_client: Node = null @@ -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) diff --git a/client/scripts/main.gd b/client/scripts/main.gd index c7da57f..4354777 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -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 @@ -46,6 +46,7 @@ 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" @@ -119,18 +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 _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 _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") @@ -150,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. @@ -285,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 diff --git a/client/scripts/prototype_hud_theme.gd b/client/scripts/prototype_hud_theme.gd new file mode 100644 index 0000000..38086c1 --- /dev/null +++ b/client/scripts/prototype_hud_theme.gd @@ -0,0 +1,48 @@ +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) diff --git a/client/scripts/prototype_hud_theme.gd.uid b/client/scripts/prototype_hud_theme.gd.uid new file mode 100644 index 0000000..2939f47 --- /dev/null +++ b/client/scripts/prototype_hud_theme.gd.uid @@ -0,0 +1 @@ +uid://c8hudthemeapply01 diff --git a/client/scripts/prototype_hud_theme_constants.gd b/client/scripts/prototype_hud_theme_constants.gd new file mode 100644 index 0000000..e0e9912 --- /dev/null +++ b/client/scripts/prototype_hud_theme_constants.gd @@ -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", +] diff --git a/client/scripts/prototype_hud_theme_constants.gd.uid b/client/scripts/prototype_hud_theme_constants.gd.uid new file mode 100644 index 0000000..edcf812 --- /dev/null +++ b/client/scripts/prototype_hud_theme_constants.gd.uid @@ -0,0 +1 @@ +uid://c8hudthemeconst01 From bd1dd6b01f2d5336350728b8e995ec4a5202b573 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Fri, 29 May 2026 21:42:49 -0400 Subject: [PATCH 09/12] NEO-97: fix gdlint and gdformat CI failures Wrap long lines, restore signal-before-const order in craft_recipe_panel, and run gdformat on flagged HUD files. --- client/scripts/craft_recipe_panel.gd | 4 +-- client/scripts/main.gd | 7 ++-- client/scripts/npc_combat_hud_helpers.gd | 46 ++++++++++++++---------- client/scripts/prototype_hud_theme.gd | 8 ++--- client/test/npc_runtime_client_test.gd | 4 +-- 5 files changed, 35 insertions(+), 34 deletions(-) diff --git a/client/scripts/craft_recipe_panel.gd b/client/scripts/craft_recipe_panel.gd index 8c6f36e..0d10dab 100644 --- a/client/scripts/craft_recipe_panel.gd +++ b/client/scripts/craft_recipe_panel.gd @@ -2,10 +2,10 @@ extends Control ## NEO-74: scrollable prototype recipe list with per-row Craft buttons. -const PrototypeHudTheme := preload("res://scripts/prototype_hud_theme.gd") - 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] = [] diff --git a/client/scripts/main.gd b/client/scripts/main.gd index 4354777..1fdca8f 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -377,9 +377,7 @@ func _setup_npc_combat_sync() -> void: 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") - ) + _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") @@ -1154,7 +1152,8 @@ func _render_player_combat_hp_label() -> void: 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 "" + 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] diff --git a/client/scripts/npc_combat_hud_helpers.gd b/client/scripts/npc_combat_hud_helpers.gd index c7d22da..d8c0e2d 100644 --- a/client/scripts/npc_combat_hud_helpers.gd +++ b/client/scripts/npc_combat_hud_helpers.gd @@ -17,7 +17,9 @@ 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: +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) @@ -48,29 +50,37 @@ static func resolve_hud_npc_rows( return {"locked": locked_row, "incoming": incoming_row} -static func build_npc_state_label( - rows: Dictionary, runtime_sync_error: String = "" -) -> String: +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")), - ] + ( + 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")), - ] + ( + 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(): @@ -112,9 +122,7 @@ static func build_telegraph_label( return line -static func has_interpolated_telegraph_display( - rows: Dictionary, hud_state: RefCounted -) -> bool: +static func has_interpolated_telegraph_display(rows: Dictionary, hud_state: RefCounted) -> bool: if hud_state == null: return false for key: String in ["locked", "incoming"]: diff --git a/client/scripts/prototype_hud_theme.gd b/client/scripts/prototype_hud_theme.gd index 38086c1..c5c3fd3 100644 --- a/client/scripts/prototype_hud_theme.gd +++ b/client/scripts/prototype_hud_theme.gd @@ -8,9 +8,7 @@ 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 - ) + 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 @@ -20,9 +18,7 @@ static func apply_to_label(label: Label, primary: bool = true) -> void: 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 - ) + 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) diff --git a/client/test/npc_runtime_client_test.gd b/client/test/npc_runtime_client_test.gd index b8b9c81..03a7168 100644 --- a/client/test/npc_runtime_client_test.gd +++ b/client/test/npc_runtime_client_test.gd @@ -97,9 +97,7 @@ 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 - ) + 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") From 1bfa13b173b22a20d71226b691e2e2c17200f85e Mon Sep 17 00:00:00 2001 From: VinPropane Date: Fri, 29 May 2026 21:45:17 -0400 Subject: [PATCH 10/12] NEO-97: address Bugbot HUD row lookup and reuse Resolve HUD npc rows once per render tick; wire production locked-row lookup through NpcRuntimeClient.npc_row for last-snapshot fallback. --- client/scripts/main.gd | 25 ++++++++++-------------- client/scripts/npc_combat_hud_helpers.gd | 10 ++++++++-- 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/client/scripts/main.gd b/client/scripts/main.gd index 1fdca8f..66e3275 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -1083,7 +1083,7 @@ func _is_combat_active() -> bool: func _resolve_hud_npc_rows() -> Dictionary: return NpcCombatHudHelpers.resolve_hud_npc_rows( - _last_target_state, _npc_runtime_snapshot, _dev_player_id() + _last_target_state, _npc_runtime_snapshot, _dev_player_id(), _npc_runtime_client ) @@ -1158,30 +1158,25 @@ func _render_player_combat_hp_label() -> void: 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: - return NpcCombatHudHelpers.has_interpolated_telegraph_display( - _resolve_hud_npc_rows(), _npc_runtime_hud_state + 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() -> void: +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( - _resolve_hud_npc_rows(), _npc_runtime_sync_error - ) + _npc_state_label.text = NpcCombatHudHelpers.build_npc_state_label(rows, _npc_runtime_sync_error) -func _render_telegraph_label() -> void: +func _render_telegraph_label(rows: Dictionary) -> void: if not is_instance_valid(_telegraph_label): return _telegraph_label.text = NpcCombatHudHelpers.build_telegraph_label( - _resolve_hud_npc_rows(), _npc_runtime_hud_state, _npc_runtime_sync_error + rows, _npc_runtime_hud_state, _npc_runtime_sync_error ) diff --git a/client/scripts/npc_combat_hud_helpers.gd b/client/scripts/npc_combat_hud_helpers.gd index d8c0e2d..540b0d7 100644 --- a/client/scripts/npc_combat_hud_helpers.gd +++ b/client/scripts/npc_combat_hud_helpers.gd @@ -35,12 +35,18 @@ static func is_combat_active( static func resolve_hud_npc_rows( - last_target_state: Dictionary, npc_runtime_snapshot: Dictionary, dev_player_id: String + 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): - locked_row = NpcRuntimeClient.npc_row_in_snapshot(lock_id, npc_runtime_snapshot) + 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(): From 38fc38fde8122f0d8f4d3f597a06da38df7f7c98 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Fri, 29 May 2026 22:02:52 -0400 Subject: [PATCH 11/12] NEO-97: dedupe locked_target_id_from_state via helpers Use NpcCombatHudHelpers.locked_target_id_from_state from main.gd instead of maintaining a private copy (Bugbot). --- client/scripts/main.gd | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/client/scripts/main.gd b/client/scripts/main.gd index 66e3275..b90902d 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -305,12 +305,12 @@ 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: @@ -1033,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 From b4b7fddf3acd0dfe8b8571c2139cca2ec37ceb32 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Fri, 29 May 2026 22:10:42 -0400 Subject: [PATCH 12/12] NEO-97: refresh NPC HUD labels when combat poll stops Re-render NpcStateLabel and TelegraphLabel when combat-active gate turns off (e.g. Esc clear without aggro) so stale rows are cleared. --- client/scripts/main.gd | 1 + client/test/npc_combat_hud_refresh_test.gd | 12 ++++++++++++ 2 files changed, 13 insertions(+) diff --git a/client/scripts/main.gd b/client/scripts/main.gd index b90902d..e0c8d64 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -1099,6 +1099,7 @@ func _update_combat_poll_gate() -> void: return _combat_poll_timer.stop() _npc_combat_hud_needs_tick = false + _render_npc_combat_hud_labels() func _on_combat_poll_timeout() -> void: diff --git a/client/test/npc_combat_hud_refresh_test.gd b/client/test/npc_combat_hud_refresh_test.gd index e031e07..b3e6ee9 100644 --- a/client/test/npc_combat_hud_refresh_test.gd +++ b/client/test/npc_combat_hud_refresh_test.gd @@ -57,6 +57,18 @@ func test_idle_without_lock_is_not_combat_active() -> void: 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()