# NEO-94 — Implementation plan ## Story reference | Field | Value | |--------|--------| | **Key** | NEO-94 | | **Title** | E5M2-08: TelegraphEvent snapshot GET + wire DTOs | | **Linear** | https://linear.app/neon-sprawl/issue/NEO-94/e5m2-08-telegraphevent-snapshot-get-wire-dtos | | **Module** | [E5.M2 — NpcAiAndBehaviorProfiles](../decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md) · Epic 5 Slice 2 · backlog **E5M2-08** | | **Branch** | `NEO-94-npc-runtime-snapshot-get` | | **Precursor** | [NEO-93](https://linear.app/neon-sprawl/issue/NEO-93) — NPC behavior state machine + lazy tick advance (**Done** on `main`) | | **Pattern** | [NEO-90](https://linear.app/neon-sprawl/issue/NEO-90) — world GET + versioned DTOs; [NEO-32](https://linear.app/neon-sprawl/issue/NEO-32) — lazy poll + `TimeProvider` + `serverTimeUtc` | | **Blocks** | [NEO-95](https://linear.app/neon-sprawl/issue/NEO-95) — NPC attack resolve + player combat HP on snapshot | | **Client counterpart** | [NEO-97](https://linear.app/neon-sprawl/issue/NEO-97) — telegraph HUD + archetype markers (polls this GET); capstone [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98) | ## Kickoff clarifications | Topic | Question | Agent recommendation | Answer | |--------|----------|----------------------|--------| | **Telegraph wire layout** | Nested on NPC row vs separate top-level array? | **Nested optional `activeTelegraph`** on each `npcInstances` row — matches E5M2-08 in-scope bullet; one array for client markers + HUD. | **User:** nested. | | **Windup timing field** | `windupRemainingSeconds` vs `windupEndsAtUtc`? | **`windupRemainingSeconds`** — backlog AC + “no client windup math”; server computes from catalog + `TimeProvider`. | **User:** `windupRemainingSeconds`. | | **Manual QA doc** | Add `docs/manual-qa/NEO-94.md`? | **Skip** — server-only HTTP; Bruno + integration tests (NEO-90/93 pattern). | **User:** skip. | ## Goal, scope, and out-of-scope **Goal:** Expose **`GET /game/world/npc-runtime-snapshot`** — authoritative NPC runtime rows + active telegraphs for client poll (~1 Hz in combat). Handler invokes **`NpcRuntimeOperations.AdvanceAll`** before read (lazy tick). **In scope (from Linear + [E5M2-08](E5M2-prototype-backlog.md#e5m2-08--telegraphevent-snapshot-get--wire-dtos)):** - **`NpcRuntimeSnapshotWorldApi`** + DTOs projecting all three **`PrototypeNpcRegistry`** instances. - Per-row fields: **`npcInstanceId`**, **`behaviorDefId`**, **`state`** (stable snake_case via **`NpcBehaviorStateWire`**), **`aggroHolderPlayerId`**, optional nested **`activeTelegraph`**. - **`activeTelegraph`** when in **`telegraph_windup`**: **`telegraphId`**, **`npcInstanceId`**, **`windupRemainingSeconds`**, **`archetypeKind`** (from bound behavior def). - Envelope: **`schemaVersion` 1**, **`serverTimeUtc`**, **`npcInstances`** array (ascending instance id order). - Handler calls **`AdvanceAll(nowUtc, …)`** with default **5.0 s** delta cap before building the response. - Integration tests (AAA) with **`InMemoryWebApplicationFactory.FakeClock`** — cast acquire → aggro → advance → telegraph with decreasing **`windupRemainingSeconds`**. - Bruno folder **`bruno/neon-sprawl-server/npc-runtime-snapshot/`** — lock → damage → advance time → poll telegraph smoke. - **`server/README.md`** route section; update threat/runtime “HTTP read” notes. **Out of scope (from Linear + backlog):** - Godot poll client ([NEO-97](https://linear.app/neon-sprawl/issue/NEO-97)). - Player damage / combat HP on snapshot ([NEO-95](https://linear.app/neon-sprawl/issue/NEO-95)). - Telemetry instrumentation ([NEO-96](https://linear.app/neon-sprawl/issue/NEO-96)). - `docs/manual-qa/NEO-94.md` (kickoff decision). ## Acceptance criteria checklist - [x] Snapshot reflects state transition within one poll generation after aggro (cast acquire → next GET shows **`aggro`** on locked NPC). - [x] Active telegraph row appears during windup with decreasing **`windupRemainingSeconds`** across polls. - [x] Bruno smoke: lock NPC → damage → poll until **`activeTelegraph`** present on melee row. ## Technical approach ### Handler flow 1. Resolve **`TimeProvider clock`**, **`INpcRuntimeStateStore`**, **`IThreatStateStore`**, **`INpcBehaviorDefinitionRegistry`** from DI. 2. `var now = clock.GetUtcNow()`. 3. **`NpcRuntimeOperations.AdvanceAll(now, runtimeStore, threatStore, behaviorRegistry)`**. 4. Return **`Results.Json(BuildSnapshot(...))`** from a testable static **`BuildSnapshot`** (mirror **`CooldownSnapshotApi.BuildSnapshot`**). ### Response envelope (v1) ```json { "schemaVersion": 1, "serverTimeUtc": "2026-04-30T12:00:03Z", "npcInstances": [ { "npcInstanceId": "prototype_npc_elite", "behaviorDefId": "prototype_elite_mini_boss", "state": "idle", "aggroHolderPlayerId": null, "activeTelegraph": null } ] } ``` During windup, **`activeTelegraph`** is non-null: ```json "activeTelegraph": { "telegraphId": "prototype_npc_melee:638…", "npcInstanceId": "prototype_npc_melee", "windupRemainingSeconds": 1.2, "archetypeKind": "melee_pressure" } ``` **`windupRemainingSeconds`** = `max(0, behaviorDef.telegraphWindupSeconds - (nowUtc - windupStartedUtc).TotalSeconds)` — clamp at zero; omit telegraph object when state ≠ **`telegraph_windup`** (even if internal row still winding down within same advance step — projection reads post-**`AdvanceAll`** store). ### Row assembly (per prototype instance id) | Source | Field | |--------|--------| | **`PrototypeNpcRegistry`** | **`npcInstanceId`**, **`behaviorDefId`** | | **`IThreatStateStore`** | **`aggroHolderPlayerId`** | | **`INpcRuntimeStateStore`** | **`state`** via **`NpcBehaviorStateWire.ToWireName`** | | Runtime row + behavior def | **`activeTelegraph`** when **`TelegraphWindup`** | All three instances always present (same contract shape as **`GET /game/world/combat-targets`**). ### Lazy tick on GET - First GET after process start: **`AdvanceAll`** initializes rows (NEO-93 **`LastAdvancedUtc == MinValue`** path). - Subsequent GETs: delta capped at **5.0 s**; **`FakeClock.Advance`** in tests drives telegraph timing without real sleeps. - Cast does **not** advance NPC runtime — only snapshot GET (and future stories that explicitly call **`AdvanceAll`**). ### Bruno smoke (E5M2-08 AC) Pre-request script pattern (from **`combat-targets/Get combat targets after one cast.bru`**): 1. Reset fixture (**`combat-targets-reset-helper.js`**). 2. Bind slot 0 **`prototype_pulse`**, lock **`prototype_npc_melee`**, damaging cast. 3. Advance fake/server time **≥ 3.0 s** (melee **`attackCooldownSeconds`**) — Bruno uses real clock against dev server; test asserts telegraph within a follow-up GET after wait or documents fixed **`sleep`** if needed for local Bruno (integration tests use **`FakeClock`** as source of truth). 4. GET **`/game/world/npc-runtime-snapshot`** — assert melee **`state === "telegraph_windup"`** and **`activeTelegraph.windupRemainingSeconds > 0`**. ### Example timeline (melee, integration test with **`FakeClock`**) ``` t=0 cast (acquire holder) + GET → melee state "aggro", activeTelegraph null t=3 GET (AdvanceAll) → "telegraph_windup", windupRemainingSeconds ≈ 1.5 t=4 GET → windupRemainingSeconds ≈ 0.5 (decreasing) t=4.5 GET → state advances past windup (attack_execute/recover); activeTelegraph null ``` ## Files to add | Path | Purpose | |------|---------| | `server/NeonSprawl.Server/Game/Npc/NpcRuntimeSnapshotWorldApi.cs` | **`GET /game/world/npc-runtime-snapshot`** — **`AdvanceAll`** + **`BuildSnapshot`**. | | `server/NeonSprawl.Server/Game/Npc/NpcRuntimeSnapshotDtos.cs` | Response envelope + **`NpcInstanceRuntimeJson`** + **`ActiveTelegraphJson`**. | | `server/NeonSprawl.Server.Tests/Game/Npc/NpcRuntimeSnapshotWorldApiTests.cs` | AAA integration tests (schema, idle rows, cast→aggro, advance→telegraph, decreasing remaining). | | `bruno/neon-sprawl-server/npc-runtime-snapshot/folder.bru` | Bruno folder metadata. | | `bruno/neon-sprawl-server/npc-runtime-snapshot/Get npc runtime snapshot.bru` | Happy GET — schema v1 + three instances. | | `bruno/neon-sprawl-server/npc-runtime-snapshot/Get snapshot after cast telegraph.bru` | Lock → damage → poll telegraph smoke (E5M2-08 AC). | | `docs/plans/NEO-94-implementation-plan.md` | This plan. | ## Files to modify | Path | Rationale | |------|-----------| | `server/NeonSprawl.Server/Program.cs` | Register **`app.MapNpcRuntimeSnapshotWorldApi()`** alongside other world GET routes. | | `server/README.md` | Add **`GET /game/world/npc-runtime-snapshot`** section; replace “HTTP read | Not exposed yet” in threat + runtime tables. | | `docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md` | NEO-94 landed note + HTTP read model cross-link when complete. | | `docs/plans/E5M2-prototype-backlog.md` | E5M2-08 acceptance checkboxes + landed note when complete. | ## Tests | File | Coverage | |------|----------| | **`NpcRuntimeSnapshotWorldApiTests.cs`** | **Idle baseline:** GET returns **`schemaVersion` 1**, **`serverTimeUtc`**, three **`npcInstances`** in ascending id order; all **`idle`**, null holders, null **`activeTelegraph`**. **Cast → aggro:** after damaging cast on melee, immediate GET shows melee **`state`** **`aggro`**, **`aggroHolderPlayerId`** **`dev-local-1`**, no telegraph. **Advance → telegraph:** advance **`FakeClock`** by **3.0 s**, GET shows **`telegraph_windup`** + **`activeTelegraph`** with **`archetypeKind`** **`melee_pressure`** and **`windupRemainingSeconds`** ≈ **1.5**. **Decreasing remaining:** advance **1.0 s**, second GET shows lower **`windupRemainingSeconds`**. **Non-target NPCs unchanged:** ranged/elite stay **`idle`** in cast scenario. | | **Bruno `npc-runtime-snapshot/`** | CI Bruno step — happy GET + cast→telegraph smoke per E5M2-08 AC. | No `docs/manual-qa/NEO-94.md`. No Godot tests. ## Open questions / risks | Question / risk | Agent recommendation | Status | |-----------------|---------------------|--------| | **Linear blockedBy NEO-93** | Proceed — NEO-93 **Done** on `main` (PR #131). | **adopted** | | **Bruno real-time wait vs FakeClock** | Integration tests own timing precision; Bruno against dev server may use short **`sleep(3100)`** in pre-request or poll loop — document in `.bru` docs block. | **adopted** | | **`behaviorDefId` on row** | Include — static from **`PrototypeNpcRegistry`**; saves NEO-97 from extra behavior-definitions join for archetype markers. | **adopted** | | **Player HP on snapshot** | Defer to NEO-95 — this slice exposes NPC runtime + telegraphs only. | **deferred** | | **Top-level `telegraphEvents[]`** | Rejected at kickoff — nested **`activeTelegraph`** only. | **adopted** (kickoff) |