From 54429108ad1aa32f64b8bb34251062f5bafe74e6 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Thu, 28 May 2026 22:28:31 -0400 Subject: [PATCH] NEO-94: add implementation plan for npc-runtime-snapshot GET Kickoff clarifications: nested activeTelegraph on npcInstances rows, windupRemainingSeconds server-computed, no manual QA doc. --- docs/plans/NEO-94-implementation-plan.md | 166 +++++++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 docs/plans/NEO-94-implementation-plan.md diff --git a/docs/plans/NEO-94-implementation-plan.md b/docs/plans/NEO-94-implementation-plan.md new file mode 100644 index 0000000..19f99ad --- /dev/null +++ b/docs/plans/NEO-94-implementation-plan.md @@ -0,0 +1,166 @@ +# 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 + +- [ ] Snapshot reflects state transition within one poll generation after aggro (cast acquire → next GET shows **`aggro`** on locked NPC). +- [ ] Active telegraph row appears during windup with decreasing **`windupRemainingSeconds`** across polls. +- [ ] 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) |