11 KiB
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 · Epic 5 Slice 2 · backlog E5M2-08 |
| Branch | NEO-94-npc-runtime-snapshot-get |
| Precursor | NEO-93 — NPC behavior state machine + lazy tick advance (Done on main) |
| Pattern | NEO-90 — world GET + versioned DTOs; NEO-32 — lazy poll + TimeProvider + serverTimeUtc |
| Blocks | NEO-95 — NPC attack resolve + player combat HP on snapshot |
| Client counterpart | NEO-97 — telegraph HUD + archetype markers (polls this GET); capstone 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):
NpcRuntimeSnapshotWorldApi+ DTOs projecting all threePrototypeNpcRegistryinstances.- Per-row fields:
npcInstanceId,behaviorDefId,state(stable snake_case viaNpcBehaviorStateWire),aggroHolderPlayerId, optional nestedactiveTelegraph. activeTelegraphwhen intelegraph_windup:telegraphId,npcInstanceId,windupRemainingSeconds,archetypeKind(from bound behavior def).- Envelope:
schemaVersion1,serverTimeUtc,npcInstancesarray (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 decreasingwindupRemainingSeconds. - Bruno folder
bruno/neon-sprawl-server/npc-runtime-snapshot/— lock → damage → advance time → poll telegraph smoke. server/README.mdroute section; update threat/runtime “HTTP read” notes.
Out of scope (from Linear + backlog):
- Godot poll client (NEO-97).
- Player damage / combat HP on snapshot (NEO-95).
- Telemetry instrumentation (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
aggroon locked NPC). - Active telegraph row appears during windup with decreasing
windupRemainingSecondsacross polls. - Bruno smoke: lock NPC → damage → poll until
activeTelegraphpresent on melee row.
Technical approach
Handler flow
- Resolve
TimeProvider clock,INpcRuntimeStateStore,IThreatStateStore,INpcBehaviorDefinitionRegistryfrom DI. var now = clock.GetUtcNow().NpcRuntimeOperations.AdvanceAll(now, runtimeStore, threatStore, behaviorRegistry).- Return
Results.Json(BuildSnapshot(...))from a testable staticBuildSnapshot(mirrorCooldownSnapshotApi.BuildSnapshot).
Response envelope (v1)
{
"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:
"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:
AdvanceAllinitializes rows (NEO-93LastAdvancedUtc == MinValuepath). - Subsequent GETs: delta capped at 5.0 s;
FakeClock.Advancein 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):
- Reset fixture (
combat-targets-reset-helper.js). - Bind slot 0
prototype_pulse, lockprototype_npc_melee, damaging cast. - 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 fixedsleepif needed for local Bruno (integration tests useFakeClockas source of truth). - GET
/game/world/npc-runtime-snapshot— assert meleestate === "telegraph_windup"andactiveTelegraph.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 |
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) |