Merge pull request #131 from ViPro-Technologies/NEO-93-npc-behavior-state-machine-lazy-tick

NEO-93: NPC behavior state machine + lazy tick advance
pull/132/head
VinPropane 2026-05-27 23:57:24 -04:00 committed by GitHub
commit fd60699931
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 1249 additions and 9 deletions

View File

@ -284,7 +284,7 @@ On **Linux** (including GitHub Actions), the path must be **`gdUnit4`** with a c
**Scope:** Unit tests cover **`player.gd`**, **`player_locomotion_wasd.gd`** (pure WASD seam helpers), **`position_authority_client.gd`**, **`inventory_client.gd`**, **`item_definitions_client.gd`** (NEO-72), **`skill_progression_client.gd`**, **`gig_progression_client.gd`** (NEO-86), **`prototype_interactable_picker.gd`**, extended **`interaction_request_client_test.gd`**, **`gather_feedback_refresh_test.gd`** (NEO-73), **`craft_client.gd`**, **`recipe_definitions_client.gd`**, **`craft_feedback_refresh_test.gd`** (NEO-74), **`prototype_economy_hud_section.gd`** (NEO-75), **`combat_targets_client.gd`**, **`combat_feedback_refresh_test.gd`** (NEO-85), **`gig_feedback_refresh_test.gd`** (NEO-86), **`isometric_follow_camera.gd`** (eye math + effective zoom distance), **`camera_state.gd`**, and **`zoom_band_config.gd`**. **`main.gd`** and scene wiring are **not** automated here—use manual checks above.
**Camera zoom:** Input actions **`camera_zoom_in`** / **`camera_zoom_out`** (mouse wheel, **`=`** / **`-`**, keypad **+** / ****) are defined in **`project.godot`**. On layouts where **`+`** requires **Shift**, remap or add a binding under **Project → Project Settings → Input Map** if needed.
**Camera zoom:** Input actions **`camera_zoom_in`** / **`camera_zoom_out`** (mouse wheel, **`=`** / **`-`**, keypad **+** / ****) are defined in **`project.godot`**. **`isometric_follow_camera.gd`** also maps **macOS trackpad** two-finger scroll and pinch to the same discrete zoom bands using **accumulated delta** (one band step per ~1.25 pan units, **150 ms** cooldown between trackpad steps) so small gestures do not traverse all bands at once. On layouts where **`+`** requires **Shift**, remap or add a binding under **Project → Project Settings → Input Map** if needed.
**Dev (NEO-18):** **`dev_toggle_occluder_obstacle`** (default **Ctrl+Shift+K**) toggles the prototype `Obstacle` visibility/collision in **`main.gd`**. **F9** / **F10** are used by the embedded debugger; use this action instead when the game is running in the editor.

View File

@ -14,6 +14,15 @@ extends Node3D
## [signal zoom_band_changed] when schema exists; optional occlusion/perf counters per module doc.
signal zoom_band_changed(new_index: int, distance: float)
## Accumulated vertical pan before one discrete zoom band step (macOS trackpad).
const PAN_ZOOM_BAND_ACCUMULATOR_THRESHOLD: float = 1.25
## Accumulated log(factor) before one pinch zoom band step.
const MAGNIFY_ZOOM_LOG_ACCUMULATOR_THRESHOLD: float = 0.15
## Minimum ms between trackpad-driven zoom band steps (avoids flick bursts).
const TRACKPAD_ZOOM_STEP_COOLDOWN_MSEC: int = 150
const CameraStateScript := preload("res://scripts/camera_state.gd")
const ZoomBandConfigScript := preload("res://scripts/zoom_band_config.gd")
const OcclusionPolicyScript := preload("res://scripts/occlusion_policy.gd")
@ -49,6 +58,9 @@ var _state = null
var _smoothed_eye: Vector3 = Vector3.ZERO
var _orbit_yaw_rad: float = 0.0
var _zoom_band_index: int = 0
var _pan_zoom_accumulator: float = 0.0
var _magnify_zoom_accumulator: float = 0.0
var _last_trackpad_zoom_step_msec: int = -1_000_000
var _warned_missing_follow_target: bool = false
## Keys: [code]int[/code] [method Object.get_instance_id] of the occluder body. Values: Array of
## {mesh, surface, original} restore entries.
@ -128,12 +140,64 @@ func _exit_tree() -> void:
func _unhandled_input(event: InputEvent) -> void:
if event.is_echo():
return
if event is InputEventPanGesture:
var pan: InputEventPanGesture = event as InputEventPanGesture
var pan_result: Dictionary = consume_pan_zoom_accumulator(_pan_zoom_accumulator, pan.delta)
_pan_zoom_accumulator = float(pan_result.get("accumulator", _pan_zoom_accumulator))
_try_apply_trackpad_zoom_step(int(pan_result.get("step", 0)))
return
if event is InputEventMagnifyGesture:
var mag: InputEventMagnifyGesture = event as InputEventMagnifyGesture
var mag_result: Dictionary = consume_magnify_zoom_accumulator(
_magnify_zoom_accumulator, mag.factor
)
_magnify_zoom_accumulator = float(mag_result.get("accumulator", _magnify_zoom_accumulator))
_try_apply_trackpad_zoom_step(int(mag_result.get("step", 0)))
return
if event.is_action_pressed("camera_zoom_in"):
_apply_zoom_step(-1)
elif event.is_action_pressed("camera_zoom_out"):
_apply_zoom_step(1)
func _try_apply_trackpad_zoom_step(step: int) -> void:
if step == 0:
return
var now_msec: int = Time.get_ticks_msec()
if now_msec - _last_trackpad_zoom_step_msec < TRACKPAD_ZOOM_STEP_COOLDOWN_MSEC:
return
_last_trackpad_zoom_step_msec = now_msec
_apply_zoom_step(step)
## Accumulates trackpad pan delta; emits at most **one** band step per call.
static func consume_pan_zoom_accumulator(
accumulator: float, delta: Vector2, threshold: float = PAN_ZOOM_BAND_ACCUMULATOR_THRESHOLD
) -> Dictionary:
if absf(delta.y) <= absf(delta.x):
return {"accumulator": accumulator, "step": 0}
var next: float = accumulator + delta.y
if next <= -threshold:
return {"accumulator": next + threshold, "step": -1}
if next >= threshold:
return {"accumulator": next - threshold, "step": 1}
return {"accumulator": next, "step": 0}
## Accumulates pinch log-scale; emits at most **one** band step per call.
static func consume_magnify_zoom_accumulator(
accumulator: float, factor: float, threshold: float = MAGNIFY_ZOOM_LOG_ACCUMULATOR_THRESHOLD
) -> Dictionary:
if factor <= 0.0:
return {"accumulator": accumulator, "step": 0}
var next: float = accumulator + log(factor)
if next >= threshold:
return {"accumulator": next - threshold, "step": -1}
if next <= -threshold:
return {"accumulator": next + threshold, "step": 1}
return {"accumulator": next, "step": 0}
## **Near** = lower band index. Zoom in → closer → decrease index (clamped).
func _apply_zoom_step(delta_bands: int) -> void:
if not _zoom_config_valid():

View File

@ -138,3 +138,45 @@ func test_occluder_override_key_is_valid_false_after_free() -> void:
var ok: bool = IsoScript.occluder_override_key_is_valid(n)
# Assert
assert_that(ok).is_false()
func test_consume_pan_zoom_accumulator_ignores_horizontal_pan() -> void:
# Arrange
# Act
var result: Dictionary = IsoScript.consume_pan_zoom_accumulator(0.0, Vector2(0.2, 0.0))
# Assert
assert_that(int(result.get("step", -9))).is_equal(0)
assert_that(float(result.get("accumulator", -1.0))).is_equal(0.0)
func test_consume_pan_zoom_accumulator_requires_threshold_before_step() -> void:
# Arrange
# Act
var partial: Dictionary = IsoScript.consume_pan_zoom_accumulator(0.0, Vector2(0.0, -0.4))
var complete: Dictionary = IsoScript.consume_pan_zoom_accumulator(
float(partial.get("accumulator", 0.0)), Vector2(0.0, -0.9)
)
# Assert
assert_that(int(partial.get("step", -9))).is_equal(0)
assert_that(int(complete.get("step", -9))).is_equal(-1)
func test_consume_pan_zoom_accumulator_emits_one_step_per_call_even_for_large_delta() -> void:
# Arrange
# Act
var result: Dictionary = IsoScript.consume_pan_zoom_accumulator(0.0, Vector2(0.0, -5.0))
# Assert
assert_that(int(result.get("step", -9))).is_equal(-1)
assert_that(float(result.get("accumulator", 0.0))).is_equal(-3.75)
func test_consume_magnify_zoom_accumulator_requires_threshold_before_step() -> void:
# Arrange
# Act
var partial: Dictionary = IsoScript.consume_magnify_zoom_accumulator(0.0, 1.05)
var complete: Dictionary = IsoScript.consume_magnify_zoom_accumulator(
float(partial.get("accumulator", 0.0)), 1.12
)
# Assert
assert_that(int(partial.get("step", -9))).is_equal(0)
assert_that(int(complete.get("step", -9))).is_equal(-1)

View File

@ -7,7 +7,7 @@
| **Module ID** | E5.M2 |
| **Epic** | [Epic 5 — PvE Combat](../epics/epic_05_pve_combat.md) |
| **Stage target** | Prototype |
| **Status** | In Progress — Slice 2 backlog [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md): **E5M2-01** [NEO-87](https://linear.app/neon-sprawl/issue/NEO-87) catalog + CI landed · **E5M2-02** [NEO-88](https://linear.app/neon-sprawl/issue/NEO-88) server load landed · **E5M2-03** [NEO-89](https://linear.app/neon-sprawl/issue/NEO-89) registry + DI landed · **E5M2-04** [NEO-90](https://linear.app/neon-sprawl/issue/NEO-90) behavior-definitions GET landed · **E5M2-05** [NEO-91](https://linear.app/neon-sprawl/issue/NEO-91) NPC instance registry + combat-target migration landed · **E5M2-06** [NEO-92](https://linear.app/neon-sprawl/issue/NEO-92) aggro rule + threat store landed → **E5M2-12** [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98) |
| **Status** | In Progress — Slice 2 backlog [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md): **E5M2-01** [NEO-87](https://linear.app/neon-sprawl/issue/NEO-87) catalog + CI landed · **E5M2-02** [NEO-88](https://linear.app/neon-sprawl/issue/NEO-88) server load landed · **E5M2-03** [NEO-89](https://linear.app/neon-sprawl/issue/NEO-89) registry + DI landed · **E5M2-04** [NEO-90](https://linear.app/neon-sprawl/issue/NEO-90) behavior-definitions GET landed · **E5M2-05** [NEO-91](https://linear.app/neon-sprawl/issue/NEO-91) NPC instance registry + combat-target migration landed · **E5M2-06** [NEO-92](https://linear.app/neon-sprawl/issue/NEO-92) aggro rule + threat store landed · **E5M2-07** [NEO-93](https://linear.app/neon-sprawl/issue/NEO-93) NPC runtime state machine landed **E5M2-12** [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98) |
| **Linear** | Label **`E5.M2`** · [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md) **E5M2-01** [NEO-87](https://linear.app/neon-sprawl/issue/NEO-87) · **E5M2-02** [NEO-88](https://linear.app/neon-sprawl/issue/NEO-88) · **E5M2-03** [NEO-89](https://linear.app/neon-sprawl/issue/NEO-89) · **E5M2-04** [NEO-90](https://linear.app/neon-sprawl/issue/NEO-90) · **E5M2-05** [NEO-91](https://linear.app/neon-sprawl/issue/NEO-91) → **E5M2-12** [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98) |
## Purpose
@ -85,6 +85,8 @@ The **first shipped three-archetype NPC spine** under `content/npc-behaviors/*.j
**Threat / aggro store (NEO-92):** **`IThreatStateStore`** + **`AggroOperations`** in `server/NeonSprawl.Server/Game/Npc/` — first damaging cast acquires holder; leash break clears holder (move + cast hooks). HTTP projection in [NEO-94](https://linear.app/neon-sprawl/issue/NEO-94). Plan: [NEO-92 implementation plan](../../plans/NEO-92-implementation-plan.md).
**NPC runtime state machine (NEO-93):** **`INpcRuntimeStateStore`** + **`NpcRuntimeOperations.AdvanceAll`** in `server/NeonSprawl.Server/Game/Npc/` — lazy tick advance for **`idle`** → **`aggro`** → **`telegraph_windup`** → **`attack_execute`** → **`recover`**; holder sync from **`IThreatStateStore`**; HTTP projection in [NEO-94](https://linear.app/neon-sprawl/issue/NEO-94). Plan: [NEO-93 implementation plan](../../plans/NEO-93-implementation-plan.md).
**NPC behavior definitions HTTP (NEO-90):** **`GET /game/world/npc-behavior-definitions`** — versioned read-only projection (`schemaVersion` **1**, **`npcBehaviors`**) backed by **`INpcBehaviorDefinitionRegistry`**. Plan: [NEO-90 implementation plan](../../plans/NEO-90-implementation-plan.md); [server README — NPC behavior definitions (NEO-90)](../../../server/README.md#npc-behavior-definitions-neo-90); Bruno `bruno/neon-sprawl-server/npc-behavior-definitions/`.
## Risks and telemetry

File diff suppressed because one or more lines are too long

View File

@ -231,7 +231,7 @@ Working backlog for **Epic 5 — Slice 2** ([NPC archetypes and telegraphs](../d
**In scope**
- `NpcRuntimeOperations.AdvanceAll` (or per-id) called from snapshot GET handler with monotonic clock delta cap.
- `NpcRuntimeOperations.AdvanceAll` (or per-id) — engine + DI only in NEO-93; [NEO-94](https://linear.app/neon-sprawl/issue/NEO-94) invokes from **`GET /game/world/npc-runtime-snapshot`** with monotonic clock delta cap.
- State transitions emit internal events for telemetry hooks (E5M2-10).
- Unit tests with injected clock for deterministic transitions.
@ -241,9 +241,12 @@ Working backlog for **Epic 5 — Slice 2** ([NPC archetypes and telegraphs](../d
**Acceptance criteria**
- [ ] Aggro'd NPC enters telegraph after `attackCooldownSeconds` elapses from last attack.
- [ ] Windup duration matches `telegraphWindupSeconds` from behavior def.
- [ ] Idle NPC with no aggro holder does not telegraph.
- [x] Aggro'd NPC enters telegraph after `attackCooldownSeconds` elapses from last attack.
- [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`** reachable with holder set (deterministic injected clock).
**Landed ([NEO-93](https://linear.app/neon-sprawl/issue/NEO-93)):** **`INpcRuntimeStateStore`** + **`NpcRuntimeOperations.AdvanceAll`** — catalog-driven state machine, lazy tick with delta cap; plan [NEO-93-implementation-plan.md](NEO-93-implementation-plan.md).
---

View File

@ -0,0 +1,162 @@
# 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.
- **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 ≥ **`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** |

View File

@ -0,0 +1,56 @@
# Code review — NEO-93 (E5M2-07)
**Date:** 2026-05-27
**Scope:** Branch `NEO-93-npc-behavior-state-machine-lazy-tick` vs `origin/main` — NPC runtime state machine, lazy tick advance, tests, and docs
**Base:** `origin/main`
**Follow-up:** Code review suggestions addressed (backlog in-scope/AC alignment, AAA test layout, single-call multi-phase test, plan test count, windup→recover write simplification).
## Verdict
**Approve with nits**
## Summary
This branch delivers the E5M2-07 server spine: `INpcRuntimeStateStore`, thread-safe in-memory rows, and `NpcRuntimeOperations.AdvanceAll` driving the prototype **`idle``aggro``telegraph_windup``attack_execute``recover`** loop from catalog timings and `IThreatStateStore` holders. Kickoff decisions are honored — holder-only aggro sync, no HTTP wire, instant `attack_execute` stub, comment-only telemetry hooks, and dev fixture reset alongside HP + threat. Tests cover the acceptance paths (cooldown, windup, full melee cycle, single-call multi-phase advance, holder clear, archetype spot checks, delta cap) plus store and fixture integration; **22** focused tests passed locally.
## Documentation checked
| Path | Result |
|------|--------|
| `docs/plans/NEO-93-implementation-plan.md` | **Matches** — store, ops, state machine, fixture, DI, tests, and reconciliation section align with the diff. |
| `docs/plans/E5M2-prototype-backlog.md` (E5M2-07) | **Matches** — AC checkboxes (including full-chain), landed note, and engine-only in-scope bullet align with kickoff and implementation. |
| `docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md` | **Matches** — E5M2-07 landed note and runtime state machine section present. |
| `docs/decomposition/modules/documentation_and_implementation_alignment.md` | **Matches** — E5.M2 row includes **NEO-93 landed** note and plan/README links. |
| `docs/decomposition/modules/client_server_authority.md` | **Matches** — server-owned behavior state; no client projection in this slice (NEO-94 deferred). |
| `server/README.md` | **Matches** — NPC runtime section documents states, lazy advance, holder sync, fixture reset, and NEO-94 deferral. |
## Blocking issues
None.
## Suggestions
1. ~~**Align E5M2-07 backlog “In scope” bullet** — Change “called from snapshot GET handler” to **engine + DI only in NEO-93; NEO-94 invokes `AdvanceAll` from `GET /game/world/npc-runtime-snapshot`** so the backlog matches the adopted kickoff decision and the implementation plan.~~ **Done.** E5M2-07 in-scope bullet updated.
2. ~~**Add full-chain AC to E5M2-07 backlog** — The implementation plan checks “full transition chain reachable”; add the same checkbox to the backlog AC list for traceability with Linear/E5M2-07.~~ **Done.** Fourth AC checkbox added to E5M2-07 backlog.
3. ~~**AAA layout in new tests** — Several `NpcRuntimeOperationsTests` methods call `TryGet` in **Act** (e.g. `AdvanceAll_ShouldCompleteMeleeWindupDuration_BeforeAttackExecute` performs a mid-Act read and a second `Advance`). Move verification reads to **Assert** per [csharp-style](../../.cursor/rules/csharp-style.md#unit-and-integration-tests-arrange-act-assert). Non-blocking but worth fixing before NEO-94 adds more tests on this file.~~ **Done.** `TryGet` moved to Assert; windup test split into two facts.
4. ~~**Optional: single-call multi-phase test** — Add one test that advances from aggro entry to second telegraph in **one** `AdvanceAll` call (e.g. `T0` → `T0+7.5`) to lock the inner `while (cursor < windowEnd)` multi-step loop independently of sequential polls.~~ **Done.** `AdvanceAll_ShouldRunFullMeleeCycle_ToSecondTelegraph_InSingleAdvanceCall` added.
## Nits
- ~~Nit: `TelegraphWindup` case writes `AttackExecute` then immediately overwrites with `Recover`; the intermediate write is never observable — harmless but could be a single `Recover` write until NEO-95 needs a visible execute phase.~~ **Done.** Windup completion writes `Recover` directly with comment for logical `attack_execute`.
- ~~Nit: Plan reconciliation cites **17** NEO-93-focused tests; local filter run reports **18** (theory counts as two cases) — trivial count drift.~~ **Done.** Plan reconciliation updated to **18**.
- Nit: `LastAdvancedUtc` is an unlocked property on the store (same pattern as typical session singletons); acceptable for prototype poll-driven advance.
## Verification
```bash
# NEO-93-focused tests (passed 2026-05-27)
dotnet test server/NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj \
--filter "FullyQualifiedName~NpcRuntime|FullyQualifiedName~InMemoryNpcRuntime|FullyQualifiedName~CombatTargetFixtureApiTests"
# Full server suite before merge
dotnet test server/NeonSprawl.Server.Tests/NeonSprawl.Server.Tests.csproj
```
Manual: no Bruno changes (no HTTP wire). Confirm dev fixture still resets HP + aggro + runtime rows between Bruno combat runs.

View File

@ -21,8 +21,17 @@ public sealed class CombatTargetFixtureApiTests
var client = factory.CreateClient();
var store = factory.Services.GetRequiredService<ICombatEntityHealthStore>();
var threatStore = factory.Services.GetRequiredService<IThreatStateStore>();
var runtimeStore = factory.Services.GetRequiredService<INpcRuntimeStateStore>();
_ = store.TryApplyDamage(PrototypeNpcRegistry.PrototypeNpcMeleeId, 100, out _);
_ = threatStore.TrySetHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId, "dev-local-1");
var windupStarted = new DateTimeOffset(2026, 5, 27, 12, 0, 0, TimeSpan.Zero);
_ = runtimeStore.TryWrite(
PrototypeNpcRegistry.PrototypeNpcMeleeId,
new NpcRuntimeStateSnapshot(
PrototypeNpcRegistry.PrototypeNpcMeleeId,
NpcBehaviorState.TelegraphWindup,
windupStarted,
new ActiveTelegraphSnapshot("prototype_npc_melee:test", windupStarted)));
// Act
var response = await client.PostAsJsonAsync(
@ -42,6 +51,9 @@ public sealed class CombatTargetFixtureApiTests
Assert.False(melee.Defeated);
threatStore.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var threat);
Assert.Null(threat.AggroHolderPlayerId);
runtimeStore.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var runtime);
Assert.Equal(NpcBehaviorState.Idle, runtime.BehaviorState);
Assert.Null(runtime.ActiveTelegraph);
}
[Fact]

View File

@ -0,0 +1,96 @@
using NeonSprawl.Server.Game.Npc;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Npc;
public sealed class InMemoryNpcRuntimeStateStoreTests
{
[Fact]
public void TryGet_ShouldLazyInitializeIdleRow_ForKnownNpcInstance()
{
// Arrange
var store = new InMemoryNpcRuntimeStateStore();
// Act
var found = store.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
// Assert
Assert.True(found);
Assert.Equal(PrototypeNpcRegistry.PrototypeNpcMeleeId, snapshot.NpcInstanceId);
Assert.Equal(NpcBehaviorState.Idle, snapshot.BehaviorState);
Assert.Null(snapshot.ActiveTelegraph);
}
[Fact]
public void TryGet_ShouldReturnFalse_ForUnknownNpcInstance()
{
// Arrange
var store = new InMemoryNpcRuntimeStateStore();
// Act
var found = store.TryGet("prototype_target_alpha", out var snapshot);
// Assert
Assert.False(found);
Assert.Equal(default, snapshot);
}
[Fact]
public void TryWrite_ShouldPersistRuntimeRow_ForKnownNpcInstance()
{
// Arrange
var store = new InMemoryNpcRuntimeStateStore();
var started = new DateTimeOffset(2026, 5, 27, 12, 0, 0, TimeSpan.Zero);
var telegraph = new ActiveTelegraphSnapshot("prototype_npc_melee:1", started);
// Act
var written = store.TryWrite(
PrototypeNpcRegistry.PrototypeNpcMeleeId,
new NpcRuntimeStateSnapshot(
PrototypeNpcRegistry.PrototypeNpcMeleeId,
NpcBehaviorState.TelegraphWindup,
started,
telegraph));
store.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
// Assert
Assert.True(written);
Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState);
Assert.Equal(started, snapshot.PhaseStartedUtc);
Assert.NotNull(snapshot.ActiveTelegraph);
Assert.Equal("prototype_npc_melee:1", snapshot.ActiveTelegraph!.Value.TelegraphId);
}
[Fact]
public void ResetAllPrototypeRows_ShouldReturnEachInstanceToIdle()
{
// Arrange
var store = new InMemoryNpcRuntimeStateStore();
var started = new DateTimeOffset(2026, 5, 27, 12, 0, 0, TimeSpan.Zero);
foreach (var npcId in PrototypeNpcRegistry.GetInstanceIdsInOrder())
{
_ = store.TryWrite(
npcId,
new NpcRuntimeStateSnapshot(npcId, NpcBehaviorState.Recover, started, null));
}
store.LastAdvancedUtc = started.AddSeconds(10);
// Act
store.ResetAllPrototypeRows();
// Assert
foreach (var npcId in PrototypeNpcRegistry.GetInstanceIdsInOrder())
{
store.TryGet(npcId, out var snapshot);
Assert.Equal(NpcBehaviorState.Idle, snapshot.BehaviorState);
Assert.Null(snapshot.ActiveTelegraph);
}
Assert.Equal(DateTimeOffset.MinValue, store.LastAdvancedUtc);
}
[Fact]
public void LastAdvancedUtc_ShouldBeSettable()
{
// Arrange
var store = new InMemoryNpcRuntimeStateStore();
var marker = new DateTimeOffset(2026, 5, 27, 8, 0, 0, TimeSpan.Zero);
// Act
store.LastAdvancedUtc = marker;
// Assert
Assert.Equal(marker, store.LastAdvancedUtc);
}
}

View File

@ -0,0 +1,319 @@
using NeonSprawl.Server.Game.Npc;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Npc;
public sealed class NpcRuntimeOperationsTests
{
private static readonly DateTimeOffset T0 =
new(2026, 5, 27, 12, 0, 0, TimeSpan.Zero);
private static (INpcRuntimeStateStore Runtime, IThreatStateStore Threat, INpcBehaviorDefinitionRegistry Behavior)
CreateFixture()
{
return (
new InMemoryNpcRuntimeStateStore(),
new InMemoryThreatStateStore(),
PrototypeNpcTestFixtures.CreateBehaviorRegistry());
}
private static void SetHolder(IThreatStateStore threatStore, string npcId, string playerId = "dev-local-1") =>
_ = threatStore.TrySetHolder(npcId, playerId);
private static void Advance(
DateTimeOffset nowUtc,
INpcRuntimeStateStore runtimeStore,
IThreatStateStore threatStore,
INpcBehaviorDefinitionRegistry behaviorRegistry,
double maxDeltaSeconds = NpcRuntimeOperations.DefaultMaxDeltaSeconds) =>
NpcRuntimeOperations.AdvanceAll(nowUtc, runtimeStore, threatStore, behaviorRegistry, maxDeltaSeconds);
[Fact]
public void AdvanceAll_ShouldKeepIdle_WhenNoAggroHolder()
{
// Arrange
var (runtime, threat, behavior) = CreateFixture();
// Act
Advance(T0, runtime, threat, behavior);
// Assert
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
Assert.Equal(NpcBehaviorState.Idle, snapshot.BehaviorState);
Assert.Null(snapshot.ActiveTelegraph);
}
[Fact]
public void AdvanceAll_ShouldEnterAggro_WhenHolderSetAndRowIdle()
{
// Arrange
var (runtime, threat, behavior) = CreateFixture();
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
// Act
Advance(T0, runtime, threat, behavior);
// Assert
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
Assert.Equal(NpcBehaviorState.Aggro, snapshot.BehaviorState);
Assert.Equal(T0, snapshot.PhaseStartedUtc);
Assert.Null(snapshot.ActiveTelegraph);
}
[Fact]
public void AdvanceAll_ShouldNotTelegraph_WhenNoAggroHolder()
{
// Arrange
var (runtime, threat, behavior) = CreateFixture();
runtime.LastAdvancedUtc = T0;
// Act
Advance(T0.AddSeconds(10), runtime, threat, behavior);
// Assert
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
Assert.Equal(NpcBehaviorState.Idle, snapshot.BehaviorState);
Assert.Null(snapshot.ActiveTelegraph);
}
[Fact]
public void AdvanceAll_ShouldEnterTelegraphAfterMeleeAttackCooldownSeconds()
{
// Arrange
var (runtime, threat, behavior) = CreateFixture();
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
Advance(T0, runtime, threat, behavior);
// Act
Advance(T0.AddSeconds(3), runtime, threat, behavior);
// Assert
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState);
Assert.Equal(T0.AddSeconds(3), snapshot.PhaseStartedUtc);
Assert.NotNull(snapshot.ActiveTelegraph);
}
[Fact]
public void AdvanceAll_ShouldRemainAggro_BeforeMeleeAttackCooldownCompletes()
{
// Arrange
var (runtime, threat, behavior) = CreateFixture();
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
Advance(T0, runtime, threat, behavior);
// Act
Advance(T0.AddSeconds(2.9), runtime, threat, behavior);
// Assert
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
Assert.Equal(NpcBehaviorState.Aggro, snapshot.BehaviorState);
}
[Fact]
public void AdvanceAll_ShouldRemainTelegraphWindup_BeforeMeleeWindupCompletes()
{
// Arrange
var (runtime, threat, behavior) = CreateFixture();
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
Advance(T0, runtime, threat, behavior);
Advance(T0.AddSeconds(3), runtime, threat, behavior);
// Act
Advance(T0.AddSeconds(4.4), runtime, threat, behavior);
// Assert
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState);
}
[Fact]
public void AdvanceAll_ShouldEnterRecover_WhenMeleeWindupCompletes()
{
// Arrange
var (runtime, threat, behavior) = CreateFixture();
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
Advance(T0, runtime, threat, behavior);
Advance(T0.AddSeconds(3), runtime, threat, behavior);
Advance(T0.AddSeconds(4.4), runtime, threat, behavior);
// Act
Advance(T0.AddSeconds(4.5), runtime, threat, behavior);
// Assert
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
Assert.Equal(NpcBehaviorState.Recover, snapshot.BehaviorState);
Assert.Equal(T0.AddSeconds(4.5), snapshot.PhaseStartedUtc);
Assert.Null(snapshot.ActiveTelegraph);
}
[Fact]
public void AdvanceAll_ShouldRunFullMeleeCycle_ToSecondTelegraph()
{
// Arrange
var (runtime, threat, behavior) = CreateFixture();
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
Advance(T0, runtime, threat, behavior);
Advance(T0.AddSeconds(3), runtime, threat, behavior);
Advance(T0.AddSeconds(4.5), runtime, threat, behavior);
// Act
Advance(T0.AddSeconds(7.5), runtime, threat, behavior);
// Assert
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState);
Assert.Equal(T0.AddSeconds(7.5), snapshot.PhaseStartedUtc);
Assert.NotNull(snapshot.ActiveTelegraph);
}
[Fact]
public void AdvanceAll_ShouldRunFullMeleeCycle_ToSecondTelegraph_InSingleAdvanceCall()
{
// Arrange
var (runtime, threat, behavior) = CreateFixture();
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
Advance(T0, runtime, threat, behavior);
// Act
Advance(T0.AddSeconds(7.5), runtime, threat, behavior, maxDeltaSeconds: 10);
// Assert
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState);
Assert.Equal(T0.AddSeconds(7.5), snapshot.PhaseStartedUtc);
Assert.NotNull(snapshot.ActiveTelegraph);
}
[Fact]
public void AdvanceAll_ShouldReturnToIdle_WhenHolderClearedMidWindup()
{
// Arrange
var (runtime, threat, behavior) = CreateFixture();
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
Advance(T0, runtime, threat, behavior);
Advance(T0.AddSeconds(3), runtime, threat, behavior);
_ = threat.TryClearHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId);
// Act
Advance(T0.AddSeconds(3.5), runtime, threat, behavior);
// Assert
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
Assert.Equal(NpcBehaviorState.Idle, snapshot.BehaviorState);
Assert.Null(snapshot.ActiveTelegraph);
}
[Theory]
[InlineData("prototype_npc_ranged", 4.0)]
[InlineData("prototype_npc_elite", 5.0)]
public void AdvanceAll_ShouldEnterTelegraph_AtCatalogCooldownForArchetype(
string npcInstanceId,
double attackCooldownSeconds)
{
// Arrange
var (runtime, threat, behavior) = CreateFixture();
SetHolder(threat, npcInstanceId);
Advance(T0, runtime, threat, behavior);
// Act
Advance(T0.AddSeconds(attackCooldownSeconds), runtime, threat, behavior);
// Assert
runtime.TryGet(npcInstanceId, out var snapshot);
Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState);
Assert.Equal(T0.AddSeconds(attackCooldownSeconds), snapshot.PhaseStartedUtc);
}
[Theory]
[InlineData("prototype_npc_ranged", 4.0, 2.0)]
[InlineData("prototype_npc_elite", 5.0, 2.5)]
public void AdvanceAll_ShouldEnterRecover_AfterCatalogWindupForArchetype(
string npcInstanceId,
double attackCooldownSeconds,
double telegraphWindupSeconds)
{
// Arrange
var (runtime, threat, behavior) = CreateFixture();
SetHolder(threat, npcInstanceId);
Advance(T0, runtime, threat, behavior);
Advance(T0.AddSeconds(attackCooldownSeconds), runtime, threat, behavior);
// Act
Advance(T0.AddSeconds(attackCooldownSeconds + telegraphWindupSeconds), runtime, threat, behavior);
// Assert
runtime.TryGet(npcInstanceId, out var snapshot);
Assert.Equal(NpcBehaviorState.Recover, snapshot.BehaviorState);
Assert.Equal(
T0.AddSeconds(attackCooldownSeconds + telegraphWindupSeconds),
snapshot.PhaseStartedUtc);
}
[Fact]
public void AdvanceAll_ShouldCapSimulatedDelta_PerAdvanceCall()
{
// Arrange
var (runtime, threat, behavior) = CreateFixture();
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
Advance(T0, runtime, threat, behavior);
// Act
Advance(T0.AddSeconds(10), runtime, threat, behavior, maxDeltaSeconds: 5);
// Assert
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
Assert.Equal(NpcBehaviorState.Recover, snapshot.BehaviorState);
Assert.Equal(T0.AddSeconds(4.5), snapshot.PhaseStartedUtc);
Assert.Equal(T0.AddSeconds(5), runtime.LastAdvancedUtc);
}
[Fact]
public void AdvanceAll_ShouldPreserveGradualCatchUp_AfterLongGapWithDeltaCap()
{
// Arrange
var (runtime, threat, behavior) = CreateFixture();
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
Advance(T0, runtime, threat, behavior);
Advance(T0.AddSeconds(100), runtime, threat, behavior, maxDeltaSeconds: 5);
// Act
Advance(T0.AddSeconds(100.5), runtime, threat, behavior, maxDeltaSeconds: 5);
// Assert
Assert.Equal(T0.AddSeconds(10), runtime.LastAdvancedUtc);
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
Assert.Equal(NpcBehaviorState.Recover, snapshot.BehaviorState);
Assert.Equal(T0.AddSeconds(9), snapshot.PhaseStartedUtc);
}
[Fact]
public void ResetAllPrototypeRows_ShouldClearLastAdvancedUtc_ForFreshAdvanceBaseline()
{
// Arrange
var (runtime, threat, behavior) = CreateFixture();
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
Advance(T0, runtime, threat, behavior);
runtime.LastAdvancedUtc = T0.AddHours(1);
NpcRuntimeOperations.ResetAllPrototypeRows(runtime);
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
// Act
Advance(T0.AddHours(1).AddSeconds(10), runtime, threat, behavior, maxDeltaSeconds: 5);
// Assert
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
Assert.Equal(NpcBehaviorState.Aggro, snapshot.BehaviorState);
Assert.Equal(T0.AddHours(1).AddSeconds(10), snapshot.PhaseStartedUtc);
Assert.Equal(T0.AddHours(1).AddSeconds(10), runtime.LastAdvancedUtc);
}
[Fact]
public void AdvanceAll_ShouldPreserveLastAdvancedUtc_WhenClockRegresses()
{
// Arrange
var (runtime, threat, behavior) = CreateFixture();
Advance(T0, runtime, threat, behavior);
runtime.LastAdvancedUtc = T0.AddSeconds(10);
// Act
Advance(T0.AddSeconds(5), runtime, threat, behavior);
// Assert
Assert.Equal(T0.AddSeconds(10), runtime.LastAdvancedUtc);
}
[Fact]
public void HasPositiveTimingDurations_ShouldRejectNonPositiveCatalogTimings()
{
// Arrange
var invalidCooldown = new NpcBehaviorDefRow(
"bad",
"Bad",
"melee_pressure",
100,
8.0,
16.0,
1.5,
15,
0.0);
var invalidWindup = invalidCooldown with { Id = "bad2", TelegraphWindupSeconds = 0.0 };
var valid = invalidCooldown with { AttackCooldownSeconds = 3.0, TelegraphWindupSeconds = 1.5 };
// Act
var cooldownOk = NpcRuntimeOperations.HasPositiveTimingDurations(invalidCooldown);
var windupOk = NpcRuntimeOperations.HasPositiveTimingDurations(invalidWindup);
var validOk = NpcRuntimeOperations.HasPositiveTimingDurations(valid);
// Assert
Assert.False(cooldownOk);
Assert.False(windupOk);
Assert.True(validOk);
}
}

View File

@ -9,7 +9,7 @@ public static class CombatTargetFixtureApi
{
app.MapPost(
"/game/__dev/combat-targets-fixture",
(CombatTargetFixtureRequest? body, ICombatEntityHealthStore healthStore, IThreatStateStore threatStore) =>
(CombatTargetFixtureRequest? body, ICombatEntityHealthStore healthStore, IThreatStateStore threatStore, INpcRuntimeStateStore runtimeStore) =>
{
if (body is null || body.SchemaVersion != CombatTargetFixtureRequest.CurrentSchemaVersion)
{
@ -25,6 +25,7 @@ public static class CombatTargetFixtureApi
}
AggroOperations.ClearAllPrototypeHolders(threatStore);
NpcRuntimeOperations.ResetAllPrototypeRows(runtimeStore);
return Results.Json(
new CombatTargetFixtureResponse

View File

@ -0,0 +1,25 @@
using System.Diagnostics.CodeAnalysis;
namespace NeonSprawl.Server.Game.Npc;
/// <summary>
/// Session in-memory NPC runtime behavior state (NEO-93).
/// HTTP projection lands in NEO-94; lazy tick advance reads/writes rows here.
/// </summary>
public interface INpcRuntimeStateStore
{
/// <summary>UTC instant of the last successful <see cref="NpcRuntimeOperations.AdvanceAll"/> call.</summary>
DateTimeOffset LastAdvancedUtc { get; set; }
/// <summary>Reads runtime state for a known prototype NPC instance. Lazy-initializes an idle row.</summary>
bool TryGet(string? npcInstanceId, [NotNullWhen(true)] out NpcRuntimeStateSnapshot snapshot);
/// <summary>Writes runtime state for a known prototype NPC instance.</summary>
bool TryWrite(string? npcInstanceId, in NpcRuntimeStateSnapshot snapshot);
/// <summary>Resets one prototype NPC runtime row to idle with no active telegraph.</summary>
bool TryResetPrototypeRow(string? npcInstanceId);
/// <summary>Resets all prototype NPC runtime rows to idle.</summary>
void ResetAllPrototypeRows();
}

View File

@ -0,0 +1,110 @@
using System.Collections.Concurrent;
namespace NeonSprawl.Server.Game.Npc;
/// <summary>Thread-safe in-memory NPC runtime state for prototype instances (NEO-93).</summary>
public sealed class InMemoryNpcRuntimeStateStore : INpcRuntimeStateStore
{
private readonly ConcurrentDictionary<string, NpcRuntimeRow> rowsByNpcId = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, object> idLocks = new(StringComparer.Ordinal);
/// <inheritdoc />
public DateTimeOffset LastAdvancedUtc { get; set; } = DateTimeOffset.MinValue;
/// <inheritdoc />
public bool TryGet(string? npcInstanceId, out NpcRuntimeStateSnapshot snapshot)
{
var key = NormalizeNpcInstanceId(npcInstanceId);
if (key.Length == 0 || !PrototypeNpcRegistry.TryGet(key, out _))
{
snapshot = default;
return false;
}
lock (idLocks.GetOrAdd(key, _ => new object()))
{
var row = rowsByNpcId.GetOrAdd(key, static id => NpcRuntimeRow.CreateIdle(id));
snapshot = row.ToSnapshot();
return true;
}
}
/// <inheritdoc />
public bool TryWrite(string? npcInstanceId, in NpcRuntimeStateSnapshot snapshot)
{
var key = NormalizeNpcInstanceId(npcInstanceId);
if (key.Length == 0 || !PrototypeNpcRegistry.TryGet(key, out _))
{
return false;
}
lock (idLocks.GetOrAdd(key, _ => new object()))
{
rowsByNpcId[key] = NpcRuntimeRow.FromSnapshot(snapshot with { NpcInstanceId = key });
return true;
}
}
/// <inheritdoc />
public bool TryResetPrototypeRow(string? npcInstanceId)
{
var key = NormalizeNpcInstanceId(npcInstanceId);
if (key.Length == 0 || !PrototypeNpcRegistry.TryGet(key, out _))
{
return false;
}
lock (idLocks.GetOrAdd(key, _ => new object()))
{
rowsByNpcId[key] = NpcRuntimeRow.CreateIdle(key);
return true;
}
}
/// <inheritdoc />
public void ResetAllPrototypeRows()
{
foreach (var npcInstanceId in PrototypeNpcRegistry.GetInstanceIdsInOrder())
{
_ = TryResetPrototypeRow(npcInstanceId);
}
LastAdvancedUtc = DateTimeOffset.MinValue;
}
private static string NormalizeNpcInstanceId(string? npcInstanceId) =>
npcInstanceId?.Trim().ToLowerInvariant() ?? string.Empty;
private sealed class NpcRuntimeRow
{
public required string NpcInstanceId { get; init; }
public NpcBehaviorState BehaviorState { get; init; }
public DateTimeOffset PhaseStartedUtc { get; init; }
public ActiveTelegraphSnapshot? ActiveTelegraph { get; init; }
public static NpcRuntimeRow CreateIdle(string npcInstanceId) =>
new()
{
NpcInstanceId = npcInstanceId,
BehaviorState = NpcBehaviorState.Idle,
PhaseStartedUtc = DateTimeOffset.MinValue,
ActiveTelegraph = null,
};
public NpcRuntimeStateSnapshot ToSnapshot() =>
new(NpcInstanceId, BehaviorState, PhaseStartedUtc, ActiveTelegraph);
public static NpcRuntimeRow FromSnapshot(NpcRuntimeStateSnapshot snapshot) =>
new()
{
NpcInstanceId = snapshot.NpcInstanceId,
BehaviorState = snapshot.BehaviorState,
PhaseStartedUtc = snapshot.PhaseStartedUtc,
ActiveTelegraph = snapshot.ActiveTelegraph,
};
}
}

View File

@ -0,0 +1,27 @@
namespace NeonSprawl.Server.Game.Npc;
/// <summary>Prototype NPC behavior states (NEO-93). Wire names are stable for NEO-94 JSON.</summary>
public enum NpcBehaviorState
{
Idle,
Aggro,
TelegraphWindup,
AttackExecute,
Recover,
}
/// <summary>Stable snake_case wire names for <see cref="NpcBehaviorState"/>.</summary>
public static class NpcBehaviorStateWire
{
/// <summary>Returns the JSON wire name for <paramref name="state"/>.</summary>
public static string ToWireName(NpcBehaviorState state) =>
state switch
{
NpcBehaviorState.Idle => "idle",
NpcBehaviorState.Aggro => "aggro",
NpcBehaviorState.TelegraphWindup => "telegraph_windup",
NpcBehaviorState.AttackExecute => "attack_execute",
NpcBehaviorState.Recover => "recover",
_ => throw new ArgumentOutOfRangeException(nameof(state), state, null),
};
}

View File

@ -0,0 +1,270 @@
namespace NeonSprawl.Server.Game.Npc;
/// <summary>Deterministic prototype NPC behavior state machine + lazy tick advance (NEO-93).</summary>
public static class NpcRuntimeOperations
{
/// <summary>Default per-advance simulation cap (seconds) for lazy poll ticks.</summary>
public const double DefaultMaxDeltaSeconds = 5.0;
/// <summary>
/// Advances all prototype NPC runtime rows by up to <paramref name="maxDeltaSeconds"/> since the last call.
/// </summary>
public static void AdvanceAll(
DateTimeOffset nowUtc,
INpcRuntimeStateStore runtimeStore,
IThreatStateStore threatStore,
INpcBehaviorDefinitionRegistry behaviorRegistry,
double maxDeltaSeconds = DefaultMaxDeltaSeconds)
{
if (maxDeltaSeconds <= 0)
{
return;
}
var lastAdvanced = runtimeStore.LastAdvancedUtc;
if (lastAdvanced == DateTimeOffset.MinValue)
{
foreach (var npcInstanceId in PrototypeNpcRegistry.GetInstanceIdsInOrder())
{
AdvanceOne(
npcInstanceId,
nowUtc,
nowUtc,
runtimeStore,
threatStore,
behaviorRegistry);
}
runtimeStore.LastAdvancedUtc = nowUtc;
return;
}
var rawDeltaSeconds = (nowUtc - lastAdvanced).TotalSeconds;
if (rawDeltaSeconds <= 0)
{
foreach (var npcInstanceId in PrototypeNpcRegistry.GetInstanceIdsInOrder())
{
AdvanceOne(
npcInstanceId,
nowUtc,
nowUtc,
runtimeStore,
threatStore,
behaviorRegistry);
}
return;
}
var deltaSeconds = Math.Min(rawDeltaSeconds, maxDeltaSeconds);
var windowStart = lastAdvanced;
var windowEnd = windowStart.AddSeconds(deltaSeconds);
foreach (var npcInstanceId in PrototypeNpcRegistry.GetInstanceIdsInOrder())
{
AdvanceOne(
npcInstanceId,
windowStart,
windowEnd,
runtimeStore,
threatStore,
behaviorRegistry);
}
runtimeStore.LastAdvancedUtc = windowEnd;
}
/// <summary>Resets all prototype NPC runtime rows to idle (dev fixture).</summary>
public static void ResetAllPrototypeRows(INpcRuntimeStateStore runtimeStore) =>
runtimeStore.ResetAllPrototypeRows();
private static void AdvanceOne(
string npcInstanceId,
DateTimeOffset windowStart,
DateTimeOffset windowEnd,
INpcRuntimeStateStore runtimeStore,
IThreatStateStore threatStore,
INpcBehaviorDefinitionRegistry behaviorRegistry)
{
if (!PrototypeNpcRegistry.TryGet(npcInstanceId, out var entry) ||
!behaviorRegistry.TryGetDefinition(entry.BehaviorDefId, out var behavior))
{
return;
}
if (!HasPositiveTimingDurations(behavior))
{
return;
}
if (!runtimeStore.TryGet(npcInstanceId, out var snapshot))
{
return;
}
if (!TryHasAggroHolder(npcInstanceId, threatStore, out _))
{
if (snapshot.BehaviorState != NpcBehaviorState.Idle || snapshot.ActiveTelegraph is not null)
{
WriteIdle(runtimeStore, npcInstanceId);
}
return;
}
if (snapshot.BehaviorState == NpcBehaviorState.Idle)
{
WriteState(
runtimeStore,
npcInstanceId,
NpcBehaviorState.Aggro,
windowStart,
activeTelegraph: null);
// telemetry: npc_state_transition (NEO-96)
if (!runtimeStore.TryGet(npcInstanceId, out snapshot))
{
return;
}
}
var cursor = windowStart;
while (cursor < windowEnd)
{
if (!TryHasAggroHolder(npcInstanceId, threatStore, out _))
{
WriteIdle(runtimeStore, npcInstanceId);
return;
}
if (!runtimeStore.TryGet(npcInstanceId, out snapshot))
{
return;
}
var loopCursorBefore = cursor;
switch (snapshot.BehaviorState)
{
case NpcBehaviorState.Idle:
return;
case NpcBehaviorState.Aggro:
{
var phaseEnd = snapshot.PhaseStartedUtc.AddSeconds(behavior.AttackCooldownSeconds);
if (windowEnd < phaseEnd)
{
return;
}
cursor = phaseEnd;
var telegraph = CreateTelegraph(npcInstanceId, cursor);
WriteState(
runtimeStore,
npcInstanceId,
NpcBehaviorState.TelegraphWindup,
cursor,
telegraph);
// telemetry: npc_state_transition (NEO-96)
break;
}
case NpcBehaviorState.TelegraphWindup:
{
var phaseEnd = snapshot.PhaseStartedUtc.AddSeconds(behavior.TelegraphWindupSeconds);
if (windowEnd < phaseEnd)
{
return;
}
cursor = phaseEnd;
WriteState(
runtimeStore,
npcInstanceId,
NpcBehaviorState.Recover,
cursor,
activeTelegraph: null);
// telemetry: telegraph_fired / npc_state_transition (NEO-96); logical attack_execute → recover (NEO-95 applies attackDamage)
break;
}
case NpcBehaviorState.AttackExecute:
WriteState(
runtimeStore,
npcInstanceId,
NpcBehaviorState.Recover,
cursor,
activeTelegraph: null);
// telemetry: npc_state_transition (NEO-96); NEO-95 applies attackDamage here
break;
case NpcBehaviorState.Recover:
{
var phaseEnd = snapshot.PhaseStartedUtc.AddSeconds(behavior.AttackCooldownSeconds);
if (windowEnd < phaseEnd)
{
return;
}
cursor = phaseEnd;
var telegraph = CreateTelegraph(npcInstanceId, cursor);
WriteState(
runtimeStore,
npcInstanceId,
NpcBehaviorState.TelegraphWindup,
cursor,
telegraph);
// telemetry: npc_state_transition (NEO-96)
break;
}
default:
return;
}
if (cursor <= loopCursorBefore)
{
return;
}
}
}
/// <summary>Catalog timings must be positive; CI/content gate enforces for prototype defs.</summary>
internal static bool HasPositiveTimingDurations(NpcBehaviorDefRow behavior) =>
behavior.AttackCooldownSeconds > 0 && behavior.TelegraphWindupSeconds > 0;
private static bool TryHasAggroHolder(
string npcInstanceId,
IThreatStateStore threatStore,
out string holderPlayerId)
{
holderPlayerId = string.Empty;
if (!threatStore.TryGet(npcInstanceId, out var threat) ||
string.IsNullOrEmpty(threat.AggroHolderPlayerId))
{
return false;
}
holderPlayerId = threat.AggroHolderPlayerId;
return true;
}
private static ActiveTelegraphSnapshot CreateTelegraph(string npcInstanceId, DateTimeOffset windupStartedUtc)
{
var telegraphId = $"{npcInstanceId}:{windupStartedUtc.UtcTicks}";
return new ActiveTelegraphSnapshot(telegraphId, windupStartedUtc);
}
private static void WriteIdle(INpcRuntimeStateStore runtimeStore, string npcInstanceId) =>
WriteState(runtimeStore, npcInstanceId, NpcBehaviorState.Idle, DateTimeOffset.MinValue, activeTelegraph: null);
private static void WriteState(
INpcRuntimeStateStore runtimeStore,
string npcInstanceId,
NpcBehaviorState behaviorState,
DateTimeOffset phaseStartedUtc,
ActiveTelegraphSnapshot? activeTelegraph)
{
_ = runtimeStore.TryWrite(
npcInstanceId,
new NpcRuntimeStateSnapshot(npcInstanceId, behaviorState, phaseStartedUtc, activeTelegraph));
}
}

View File

@ -0,0 +1,12 @@
namespace NeonSprawl.Server.Game.Npc;
/// <summary>Registers prototype NPC runtime behavior state store (NEO-93).</summary>
public static class NpcRuntimeServiceCollectionExtensions
{
/// <summary>Registers <see cref="INpcRuntimeStateStore"/> as an in-memory singleton.</summary>
public static IServiceCollection AddNpcRuntimeStateStore(this IServiceCollection services)
{
services.AddSingleton<INpcRuntimeStateStore, InMemoryNpcRuntimeStateStore>();
return services;
}
}

View File

@ -0,0 +1,17 @@
namespace NeonSprawl.Server.Game.Npc;
/// <summary>Active telegraph row while an NPC is in <see cref="NpcBehaviorState.TelegraphWindup"/> (NEO-93).</summary>
/// <param name="TelegraphId">Stable id for NEO-94 wire projection.</param>
/// <param name="WindupStartedUtc">UTC instant when windup phase began.</param>
public readonly record struct ActiveTelegraphSnapshot(string TelegraphId, DateTimeOffset WindupStartedUtc);
/// <summary>Per-NPC runtime behavior state snapshot (NEO-93).</summary>
/// <param name="NpcInstanceId">Lowercase prototype NPC instance id.</param>
/// <param name="BehaviorState">Current behavior state.</param>
/// <param name="PhaseStartedUtc">UTC instant when the current phase began.</param>
/// <param name="ActiveTelegraph">Non-null only during <see cref="NpcBehaviorState.TelegraphWindup"/>.</param>
public readonly record struct NpcRuntimeStateSnapshot(
string NpcInstanceId,
NpcBehaviorState BehaviorState,
DateTimeOffset PhaseStartedUtc,
ActiveTelegraphSnapshot? ActiveTelegraph);

View File

@ -26,6 +26,7 @@ builder.Services.AddRecipeDefinitionCatalog(builder.Configuration);
builder.Services.AddAbilityDefinitionCatalog(builder.Configuration);
builder.Services.AddNpcBehaviorDefinitionCatalog(builder.Configuration);
builder.Services.AddThreatStateStore();
builder.Services.AddNpcRuntimeStateStore();
builder.Services.AddMasteryCatalog(builder.Configuration);
var app = builder.Build();

View File

@ -152,7 +152,7 @@ Plan: [NEO-80 implementation plan](../../docs/plans/NEO-80-implementation-plan.m
curl -sS -i "http://localhost:5253/game/world/combat-targets"
```
**Dev combat-target fixture (Bruno/manual QA):** When `Game:EnableCombatTargetFixtureApi` is **true** (default in **Development** via `appsettings.Development.json`, **Testing**, or CI Bruno step) or the host environment is **Development** / **Testing**, **`POST /game/__dev/combat-targets-fixture`** with `schemaVersion` **1** resets all **`PrototypeNpcRegistry`** instances to catalog full HP via **`TryResetToFull`** and clears all prototype aggro holders (**NEO-92**). **404** when disabled. Bruno: `scripts/combat-targets-reset-helper.js`.
**Dev combat-target fixture (Bruno/manual QA):** When `Game:EnableCombatTargetFixtureApi` is **true** (default in **Development** via `appsettings.Development.json`, **Testing**, or CI Bruno step) or the host environment is **Development** / **Testing**, **`POST /game/__dev/combat-targets-fixture`** with `schemaVersion` **1** resets all **`PrototypeNpcRegistry`** instances to catalog full HP via **`TryResetToFull`**, clears all prototype aggro holders (**NEO-92**), and resets NPC runtime behavior rows to **`idle`** (**NEO-93**). **404** when disabled. Bruno: `scripts/combat-targets-reset-helper.js`.
## Threat / aggro state (NEO-92)
@ -167,6 +167,27 @@ Per-NPC aggro holders live in **`Game/Npc/`** as **`IThreatStateStore`** + **`In
Plan: [NEO-92 implementation plan](../../docs/plans/NEO-92-implementation-plan.md).
## NPC runtime behavior state (NEO-93)
Per-NPC behavior states live in **`Game/Npc/`** as **`INpcRuntimeStateStore`** + **`InMemoryNpcRuntimeStateStore`**, keyed by **`PrototypeNpcRegistry`** instance id. **`NpcRuntimeOperations.AdvanceAll`** drives lazy tick advance (monotonic clock, per-call delta cap **5.0 s**) using catalog **`attackCooldownSeconds`** and **`telegraphWindupSeconds`**.
| State | Meaning |
|-------|---------|
| **`idle`** | No aggro holder; no telegraph. |
| **`aggro`** | Holder set; waiting **`attackCooldownSeconds`** before windup. |
| **`telegraph_windup`** | Active telegraph; **`ActiveTelegraphSnapshot`** on the runtime row. |
| **`attack_execute`** | Instant stub in NEO-93 (player damage lands in [NEO-95](https://linear.app/neon-sprawl/issue/NEO-95)). |
| **`recover`** | Post-attack cooldown wait before next windup. |
| Rule | Behavior |
|------|----------|
| **Holder sync** | Empty holder → **`idle`**; holder while **`idle`** → **`aggro`**. No proximity auto-aggro (first-hit holder from NEO-92 only). |
| **Lazy advance** | **`AdvanceAll`** simulates up to **5.0 s** per call; [NEO-94](https://linear.app/neon-sprawl/issue/NEO-94) wires this to **`GET /game/world/npc-runtime-snapshot`**. |
| **Fixture reset** | Dev combat-target fixture clears runtime rows alongside HP + threat. |
| **HTTP read** | Not exposed yet — snapshot GET lands in [NEO-94](https://linear.app/neon-sprawl/issue/NEO-94). |
Plan: [NEO-93 implementation plan](../../docs/plans/NEO-93-implementation-plan.md).
## Combat engine (NEO-81)
**`CombatOperations.TryResolve`** in **`Game/Combat/`** resolves server-internal **`CombatResult`**: ability lookup via **`IAbilityDefinitionRegistry`**, target HP read via **`ICombatEntityHealthStore`**, defeated pre-check (no damage on re-hit), then catalog **`baseDamage`** application. Zero-damage abilities (**`prototype_guard`**, **`prototype_dash`**) succeed without mutating HP. **`AbilityCastApi`** (NEO-82) invokes **`TryResolve`** after E1.M4 gates and returns nested wire **`combatResolution`** on accept.