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

163 lines
12 KiB
Markdown

# 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](../decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md) · Epic 5 Slice 2 · backlog **E5M2-07** |
| **Branch** | `NEO-93-npc-behavior-state-machine-lazy-tick` |
| **Precursor** | [NEO-92](https://linear.app/neon-sprawl/issue/NEO-92) — `IThreatStateStore` + aggro acquire/leash clear (**Done** on `main`); [NEO-89](https://linear.app/neon-sprawl/issue/NEO-89) — `INpcBehaviorDefinitionRegistry` (**Done** on `main`) |
| **Pattern** | [NEO-92](https://linear.app/neon-sprawl/issue/NEO-92) — in-memory session store + static ops + DI; no HTTP wire until downstream story |
| **Blocks** | [NEO-94](https://linear.app/neon-sprawl/issue/NEO-94) — snapshot GET + DTO projection; [NEO-96](https://linear.app/neon-sprawl/issue/NEO-96) — telemetry hook sites |
| **Client counterpart** | None for this slice — runtime projection lands in [NEO-94](https://linear.app/neon-sprawl/issue/NEO-94); Godot poll + HUD in [NEO-97](https://linear.app/neon-sprawl/issue/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](https://linear.app/neon-sprawl/issue/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](https://linear.app/neon-sprawl/issue/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](https://linear.app/neon-sprawl/issue/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 **`idle`** → **`aggro`** → **`telegraph_windup`** → **`attack_execute`** → **`recover`**, with phase durations from the behavior catalog.
**In scope (from Linear + [E5M2-07](E5M2-prototype-backlog.md#e5m2-07--npc-behavior-state-machine--lazy-tick-advance)):**
- `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 **`idle`** → **`aggro`** (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](https://linear.app/neon-sprawl/issue/NEO-94)).
- Player damage on attack complete ([NEO-95](https://linear.app/neon-sprawl/issue/NEO-95)).
- Proximity auto-aggro inside **`aggroRadius`** without damaging cast.
- Godot / client HUD ([NEO-97](https://linear.app/neon-sprawl/issue/NEO-97)).
- `docs/manual-qa/NEO-93.md` — server-only; unit tests verify AC (NEO-92 pattern).
## Acceptance criteria checklist
- [x] Aggro'd NPC enters telegraph after `attackCooldownSeconds` elapses from last attack (or from aggro entry on first cycle).
- [x] Windup duration matches `telegraphWindupSeconds` from behavior def.
- [x] Idle NPC with no aggro holder does not telegraph.
- [x] Full transition chain **`idle``aggro``telegraph_windup``attack_execute``recover`** 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.
- **22** 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 ≥ **`attackCooldownSeconds`** → **`telegraph_windup`** |
| **`telegraph_windup`** | Cooldown complete while holder set | Elapsed ≥ **`telegraphWindupSeconds`** → **`attack_execute`** |
| **`attack_execute`** | Windup complete | Same advance step → **`recover`** (no damage in NEO-93) |
| **`recover`** | Attack step complete | Elapsed ≥ **`attackCooldownSeconds`** → **`telegraph_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. **DI****`AddNpcRuntimeStateStore()`** singleton alongside **`AddThreatStateStore()`** in **`Program.cs`**.
5. **Dev fixture** — after HP + threat reset, call **`NpcRuntimeOperations.ResetAllPrototypeRows(runtimeStore)`**.
6. **Docs****`server/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** |