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

12 KiB

NEO-93 — Implementation plan

Story reference

Field Value
Key NEO-93
Title E5M2-07: NPC behavior state machine + lazy tick advance
Linear https://linear.app/neon-sprawl/issue/NEO-93/e5m2-07-npc-behavior-state-machine-lazy-tick-advance
Module E5.M2 — NpcAiAndBehaviorProfiles · Epic 5 Slice 2 · backlog E5M2-07
Branch NEO-93-npc-behavior-state-machine-lazy-tick
Precursor NEO-92IThreatStateStore + aggro acquire/leash clear (Done on main); NEO-89INpcBehaviorDefinitionRegistry (Done on main)
Pattern NEO-92 — in-memory session store + static ops + DI; no HTTP wire until downstream story
Blocks NEO-94 — snapshot GET + DTO projection; NEO-96 — telemetry hook sites
Client counterpart None for this slice — runtime projection lands in NEO-94; Godot poll + HUD in NEO-97

Kickoff clarifications

Topic Question Agent recommendation Answer
Proximity auto-aggro Add idle→aggro when player enters aggroRadius without a damaging cast? Holder-only — drive idle→aggro from IThreatStateStore only (NEO-92 first-hit acquire); defer proximity to a follow-up issue. User: holder-only.
Lazy tick wiring Wire AdvanceAll to HTTP in NEO-93? Engine + DI only — no HTTP; NEO-94 invokes AdvanceAll from GET /game/world/npc-runtime-snapshot. User: engine-only.
attack_execute semantics Apply player damage in NEO-93? Instant pass-through — transition attack_execute → recover in the same advance step; no IPlayerCombatHealthStore (NEO-95). Adopted — E5M2-09 owns damage.
First telegraph after acquire Wait full attackCooldownSeconds or telegraph immediately? Wait full cooldown — E5M2-07 AC: “enters telegraph after attackCooldownSeconds elapses from last attack”; treat first cycle as cooldown starting at aggro entry. Adopted — backlog AC.
Recover phase Duration when no catalog field? recover is the post-attack cooldown wait — hold until attackCooldownSeconds elapses, then → telegraph_windup while holder remains. Adopted — closes idle→aggro→telegraph→attack→recover loop.
Telemetry events Emit npc_state_transition now? Comment-only hook sites at transition points; full instrumentation in NEO-96. Adopted — E5M2-10 scope.

Goal, scope, and out-of-scope

Goal: Server-owned NPC behavior state machine with lazy tick advance — prototype states idleaggrotelegraph_windupattack_executerecover, with phase durations from the behavior catalog.

In scope (from Linear + E5M2-07):

  • INpcRuntimeStateStore + in-memory implementation for all PrototypeNpcRegistry instance ids.
  • NpcRuntimeOperations.AdvanceAll (monotonic clock, per-advance delta cap) reading IThreatStateStore + INpcBehaviorDefinitionRegistry.
  • Holder sync: empty holder → idle; holder set while idleaggro (no proximity acquire).
  • Catalog-driven timings: attackCooldownSeconds, telegraphWindupSeconds.
  • Internal active-telegraph snapshot while in telegraph_windup (for NEO-94 DTO mapping).
  • Comment-only telemetry hook sites at state transitions.
  • Extend dev combat-targets-fixture to reset runtime rows.
  • Unit tests with injected TimeProvider / controlled advance deltas (AAA).

Out of scope (from Linear + backlog):

  • HTTP DTO projection — GET /game/world/npc-runtime-snapshot (NEO-94).
  • Player damage on attack complete (NEO-95).
  • Proximity auto-aggro inside aggroRadius without damaging cast.
  • Godot / client HUD (NEO-97).
  • docs/manual-qa/NEO-93.md — server-only; unit tests verify AC (NEO-92 pattern).

Acceptance criteria checklist

  • Aggro'd NPC enters telegraph after attackCooldownSeconds elapses from last attack (or from aggro entry on first cycle).
  • Windup duration matches telegraphWindupSeconds from behavior def.
  • Idle NPC with no aggro holder does not telegraph.
  • Full transition chain idleaggrotelegraph_windupattack_executerecover is reachable with holder set (deterministic injected clock).

Implementation reconciliation (shipped)

  • NpcBehaviorState, INpcRuntimeStateStore, InMemoryNpcRuntimeStateStore, and NpcRuntimeOperations.AdvanceAll landed in Game/Npc/.
  • Holder-only idle→aggro sync from IThreatStateStore; no proximity auto-aggro.
  • attack_execute chains instantly to recover within the same advance step (no player damage — NEO-95).
  • Dev combat-targets-fixture resets runtime rows alongside HP + threat.
  • 25 NEO-93-focused tests green (NpcRuntimeOperationsTests, InMemoryNpcRuntimeStateStoreTests, fixture extension).

Technical approach

State machine (per prototype NPC instance)

State Enter when Exit when
idle No aggro holder Holder set → aggro (phase start = now)
aggro Holder set from idle, or after recover cooldown (optional shortcut: recover → telegraph directly) Elapsed ≥ attackCooldownSecondstelegraph_windup
telegraph_windup Cooldown complete while holder set Elapsed ≥ telegraphWindupSecondsattack_execute
attack_execute Windup complete Same advance step → recover (no damage in NEO-93)
recover Attack step complete Elapsed ≥ attackCooldownSecondstelegraph_windup

Holder cleared (leash break / fixture reset) from any state → idle, clear active telegraph.

No proximity acquire: AdvanceAll never sets holders; only reads IThreatStateStore.

Lazy tick advance

  1. NpcRuntimeOperations.AdvanceAll(nowUtc, runtimeStore, threatStore, behaviorRegistry, maxDeltaSeconds = 5.0)

    • Compute delta = min(nowUtc - runtimeStore.LastAdvancedUtc, maxDeltaSeconds); skip when delta <= 0.
    • Update LastAdvancedUtc = nowUtc after processing all instances.
    • For each PrototypeNpcRegistry id: load threat holder + behavior def + runtime row; apply holder sync then phase transitions consuming delta (may multi-step within one advance when delta spans phases — loop until delta exhausted or waiting on timer).
  2. INpcRuntimeStateStore

    • DateTimeOffset LastAdvancedUtc (lazy-init to DateTimeOffset.MinValue so first advance applies full delta).
    • TryGet(npcInstanceId, out NpcRuntimeStateSnapshot)BehaviorState, PhaseStartedUtc, optional ActiveTelegraph (TelegraphId, WindupStartedUtc).
    • TryResetPrototypeRow(npcInstanceId) / ResetAllPrototypeRows() for fixture.
  3. NpcBehaviorState enum — wire string values: idle, aggro, telegraph_windup, attack_execute, recover (stable for NEO-94 JSON).

  4. DIAddNpcRuntimeStateStore() singleton alongside AddThreatStateStore() in Program.cs.

  5. Dev fixture — after HP + threat reset, call NpcRuntimeOperations.ResetAllPrototypeRows(runtimeStore).

  6. Docsserver/README.md NPC runtime section: states, lazy advance (NEO-94 wires poll), fixture reset, no HTTP yet.

Catalog timings (test expectations)

Instance Behavior def attackCooldownSeconds telegraphWindupSeconds
prototype_npc_melee prototype_melee_pressure 3.0 1.5
prototype_npc_ranged prototype_ranged_control 4.0 2.0
prototype_npc_elite prototype_elite_mini_boss 5.0 2.5

Example cycle (melee, holder already set)

t=0   idle + holder → aggro
t=3   aggro → telegraph_windup
t=4.5 telegraph_windup → attack_execute → recover (instant attack stub)
t=7.5 recover → telegraph_windup (next cycle)

Files to add

Path Purpose
server/NeonSprawl.Server/Game/Npc/NpcBehaviorState.cs Enum of prototype behavior states (stable wire names).
server/NeonSprawl.Server/Game/Npc/NpcRuntimeStateSnapshot.cs Immutable runtime row + optional active telegraph snapshot.
server/NeonSprawl.Server/Game/Npc/INpcRuntimeStateStore.cs Store contract + LastAdvancedUtc for lazy tick.
server/NeonSprawl.Server/Game/Npc/InMemoryNpcRuntimeStateStore.cs Thread-safe in-memory prototype implementation.
server/NeonSprawl.Server/Game/Npc/NpcRuntimeOperations.cs AdvanceAll, ResetAllPrototypeRows, holder sync + phase transitions.
server/NeonSprawl.Server/Game/Npc/NpcRuntimeServiceCollectionExtensions.cs DI registration for INpcRuntimeStateStore.
server/NeonSprawl.Server.Tests/Game/Npc/NpcRuntimeOperationsTests.cs AAA unit tests with fake TimeProvider / manual advance timestamps.
server/NeonSprawl.Server.Tests/Game/Npc/InMemoryNpcRuntimeStateStoreTests.cs AAA store tests: lazy rows, reset, unknown id.
docs/plans/NEO-93-implementation-plan.md This plan.

Files to modify

Path Rationale
server/NeonSprawl.Server/Program.cs Register AddNpcRuntimeStateStore().
server/NeonSprawl.Server/Game/Combat/CombatTargetFixtureApi.cs Reset runtime state rows alongside HP + threat on dev fixture.
server/README.md Document NPC runtime state machine, lazy advance, fixture reset; note NEO-94 HTTP projection.
docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md NEO-93 landed note when complete.
docs/plans/E5M2-prototype-backlog.md E5M2-07 acceptance checkboxes + landed note when complete.

Tests

File Coverage
NpcRuntimeOperationsTests.cs Idle + no holder stays idle; idle + holder → aggro; no telegraph without holder; aggro → telegraph after melee attackCooldownSeconds (3.0); windup duration matches melee telegraphWindupSeconds (1.5); full chain through attack_execute → recover → second telegraph; holder clear mid-windup → idle; elite/ranged catalog timings spot check; delta cap does not overshoot single phase in one advance.
InMemoryNpcRuntimeStateStoreTests.cs Unknown npc id fails; lazy init rows; reset all prototype ids; LastAdvancedUtc update.
CombatTargetFixtureApiTests.cs Extend existing fixture test — after reset, runtime rows are idle (no active telegraph).

No Bruno changes (no HTTP wire). No docs/manual-qa/NEO-93.md.

Open questions / risks

Question / risk Agent recommendation Status
Linear blockedBy NEO-92 / NEO-89 Proceed — both Done on main. adopted
No HTTP in NEO-93 Accept — NEO-94 calls AdvanceAll before snapshot read; unit tests drive advance directly. adopted (kickoff)
Proximity auto-aggro Deferred — holder-only per kickoff; E5M2 default proximity re-aggro is a follow-up if still desired. deferred
Multi-step advance in one poll Loop phase transitions until delta consumed or phase needs more time — prevents stuck states when client polls slowly. adopted
attack_execute duration Zero-duration stub; NEO-95 adds damage resolve at telegraph complete. adopted
Thread safety Mirror InMemoryThreatStateStore lock pattern for three fixed prototype ids. adopted