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

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 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).
  • 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 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)

{
  "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: 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-snapshotAdvanceAll + 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 windupRemainingSeconds1.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)