neon-sprawl/docs/plans/NEO-94-implementation-plan.md

167 lines
11 KiB
Markdown

# 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) |