From 874c5720d96cd10def8f867443608167c367cf61 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Wed, 27 May 2026 21:59:40 -0400 Subject: [PATCH 01/14] NEO-93: Add kickoff implementation plan for NPC behavior state machine. Co-authored-by: Cursor --- docs/plans/NEO-93-implementation-plan.md | 154 +++++++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 docs/plans/NEO-93-implementation-plan.md diff --git a/docs/plans/NEO-93-implementation-plan.md b/docs/plans/NEO-93-implementation-plan.md new file mode 100644 index 0000000..25845bf --- /dev/null +++ b/docs/plans/NEO-93-implementation-plan.md @@ -0,0 +1,154 @@ +# 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 + +- [ ] 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 **`idle` → `aggro` → `telegraph_windup` → `attack_execute` → `recover`** is reachable with holder set (deterministic injected clock). + +## 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** | From 48813998d76623a3a5077b4b4a60c3229e761c46 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Wed, 27 May 2026 22:02:39 -0400 Subject: [PATCH 02/14] NEO-93: Add NPC runtime state machine and lazy tick advance. Co-authored-by: Cursor --- .../Combat/CombatTargetFixtureApiTests.cs | 12 + .../Npc/InMemoryNpcRuntimeStateStoreTests.cs | 94 +++++++ .../Game/Npc/NpcRuntimeOperationsTests.cs | 202 ++++++++++++++ .../Game/Combat/CombatTargetFixtureApi.cs | 3 +- .../Game/Npc/INpcRuntimeStateStore.cs | 25 ++ .../Game/Npc/InMemoryNpcRuntimeStateStore.cs | 108 ++++++++ .../Game/Npc/NpcBehaviorState.cs | 27 ++ .../Game/Npc/NpcRuntimeOperations.cs | 262 ++++++++++++++++++ .../NpcRuntimeServiceCollectionExtensions.cs | 12 + .../Game/Npc/NpcRuntimeStateSnapshot.cs | 17 ++ server/NeonSprawl.Server/Program.cs | 1 + 11 files changed, 762 insertions(+), 1 deletion(-) create mode 100644 server/NeonSprawl.Server.Tests/Game/Npc/InMemoryNpcRuntimeStateStoreTests.cs create mode 100644 server/NeonSprawl.Server.Tests/Game/Npc/NpcRuntimeOperationsTests.cs create mode 100644 server/NeonSprawl.Server/Game/Npc/INpcRuntimeStateStore.cs create mode 100644 server/NeonSprawl.Server/Game/Npc/InMemoryNpcRuntimeStateStore.cs create mode 100644 server/NeonSprawl.Server/Game/Npc/NpcBehaviorState.cs create mode 100644 server/NeonSprawl.Server/Game/Npc/NpcRuntimeOperations.cs create mode 100644 server/NeonSprawl.Server/Game/Npc/NpcRuntimeServiceCollectionExtensions.cs create mode 100644 server/NeonSprawl.Server/Game/Npc/NpcRuntimeStateSnapshot.cs diff --git a/server/NeonSprawl.Server.Tests/Game/Combat/CombatTargetFixtureApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Combat/CombatTargetFixtureApiTests.cs index b7d7a56..b609f3a 100644 --- a/server/NeonSprawl.Server.Tests/Game/Combat/CombatTargetFixtureApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Combat/CombatTargetFixtureApiTests.cs @@ -21,8 +21,17 @@ public sealed class CombatTargetFixtureApiTests var client = factory.CreateClient(); var store = factory.Services.GetRequiredService(); var threatStore = factory.Services.GetRequiredService(); + var runtimeStore = factory.Services.GetRequiredService(); _ = 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] diff --git a/server/NeonSprawl.Server.Tests/Game/Npc/InMemoryNpcRuntimeStateStoreTests.cs b/server/NeonSprawl.Server.Tests/Game/Npc/InMemoryNpcRuntimeStateStoreTests.cs new file mode 100644 index 0000000..c54a31a --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Npc/InMemoryNpcRuntimeStateStoreTests.cs @@ -0,0 +1,94 @@ +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); + } + } + + [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); + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/Npc/NpcRuntimeOperationsTests.cs b/server/NeonSprawl.Server.Tests/Game/Npc/NpcRuntimeOperationsTests.cs new file mode 100644 index 0000000..88fcf47 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Npc/NpcRuntimeOperationsTests.cs @@ -0,0 +1,202 @@ +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); + runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); + // Assert + 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); + runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); + // Assert + 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); + runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); + // Assert + 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); + runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); + // Assert + 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); + runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); + // Assert + Assert.Equal(NpcBehaviorState.Aggro, snapshot.BehaviorState); + } + + [Fact] + public void AdvanceAll_ShouldCompleteMeleeWindupDuration_BeforeAttackExecute() + { + // 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); + runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var duringWindup); + Advance(T0.AddSeconds(4.5), runtime, threat, behavior); + runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var afterWindup); + // Assert + Assert.Equal(NpcBehaviorState.TelegraphWindup, duringWindup.BehaviorState); + Assert.Equal(NpcBehaviorState.Recover, afterWindup.BehaviorState); + Assert.Equal(T0.AddSeconds(4.5), afterWindup.PhaseStartedUtc); + Assert.Null(afterWindup.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); + runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); + // Assert + 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); + runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); + // Assert + Assert.Equal(NpcBehaviorState.Idle, snapshot.BehaviorState); + Assert.Null(snapshot.ActiveTelegraph); + } + + [Theory] + [InlineData("prototype_npc_ranged", 4.0, 2.0)] + [InlineData("prototype_npc_elite", 5.0, 2.5)] + public void AdvanceAll_ShouldUseCatalogTimings_ForEachArchetype( + string npcInstanceId, + double attackCooldownSeconds, + double telegraphWindupSeconds) + { + // Arrange + var (runtime, threat, behavior) = CreateFixture(); + SetHolder(threat, npcInstanceId); + Advance(T0, runtime, threat, behavior); + // Act + Advance(T0.AddSeconds(attackCooldownSeconds), runtime, threat, behavior); + runtime.TryGet(npcInstanceId, out var telegraphSnapshot); + Advance( + T0.AddSeconds(attackCooldownSeconds + telegraphWindupSeconds), + runtime, + threat, + behavior); + runtime.TryGet(npcInstanceId, out var recoverSnapshot); + // Assert + Assert.Equal(NpcBehaviorState.TelegraphWindup, telegraphSnapshot.BehaviorState); + Assert.Equal(T0.AddSeconds(attackCooldownSeconds), telegraphSnapshot.PhaseStartedUtc); + Assert.Equal(NpcBehaviorState.Recover, recoverSnapshot.BehaviorState); + Assert.Equal( + T0.AddSeconds(attackCooldownSeconds + telegraphWindupSeconds), + recoverSnapshot.PhaseStartedUtc); + } + + [Fact] + public void AdvanceAll_ShouldCapSimulatedDelta_PerAdvanceCall() + { + // Arrange + var (runtime, threat, behavior) = CreateFixture(); + SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); + Advance(T0, runtime, threat, behavior); + // Act — 10s gap with 5s cap should not reach second telegraph (needs 7.5s from aggro start) + Advance(T0.AddSeconds(10), runtime, threat, behavior, maxDeltaSeconds: 5); + runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); + // Assert + Assert.Equal(NpcBehaviorState.Recover, snapshot.BehaviorState); + Assert.Equal(T0.AddSeconds(4.5), snapshot.PhaseStartedUtc); + } +} diff --git a/server/NeonSprawl.Server/Game/Combat/CombatTargetFixtureApi.cs b/server/NeonSprawl.Server/Game/Combat/CombatTargetFixtureApi.cs index 0d3b4ac..2d27d96 100644 --- a/server/NeonSprawl.Server/Game/Combat/CombatTargetFixtureApi.cs +++ b/server/NeonSprawl.Server/Game/Combat/CombatTargetFixtureApi.cs @@ -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 diff --git a/server/NeonSprawl.Server/Game/Npc/INpcRuntimeStateStore.cs b/server/NeonSprawl.Server/Game/Npc/INpcRuntimeStateStore.cs new file mode 100644 index 0000000..69072a5 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Npc/INpcRuntimeStateStore.cs @@ -0,0 +1,25 @@ +using System.Diagnostics.CodeAnalysis; + +namespace NeonSprawl.Server.Game.Npc; + +/// +/// Session in-memory NPC runtime behavior state (NEO-93). +/// HTTP projection lands in NEO-94; lazy tick advance reads/writes rows here. +/// +public interface INpcRuntimeStateStore +{ + /// UTC instant of the last successful call. + DateTimeOffset LastAdvancedUtc { get; set; } + + /// Reads runtime state for a known prototype NPC instance. Lazy-initializes an idle row. + bool TryGet(string? npcInstanceId, [NotNullWhen(true)] out NpcRuntimeStateSnapshot snapshot); + + /// Writes runtime state for a known prototype NPC instance. + bool TryWrite(string? npcInstanceId, in NpcRuntimeStateSnapshot snapshot); + + /// Resets one prototype NPC runtime row to idle with no active telegraph. + bool TryResetPrototypeRow(string? npcInstanceId); + + /// Resets all prototype NPC runtime rows to idle. + void ResetAllPrototypeRows(); +} diff --git a/server/NeonSprawl.Server/Game/Npc/InMemoryNpcRuntimeStateStore.cs b/server/NeonSprawl.Server/Game/Npc/InMemoryNpcRuntimeStateStore.cs new file mode 100644 index 0000000..6a9d712 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Npc/InMemoryNpcRuntimeStateStore.cs @@ -0,0 +1,108 @@ +using System.Collections.Concurrent; + +namespace NeonSprawl.Server.Game.Npc; + +/// Thread-safe in-memory NPC runtime state for prototype instances (NEO-93). +public sealed class InMemoryNpcRuntimeStateStore : INpcRuntimeStateStore +{ + private readonly ConcurrentDictionary rowsByNpcId = new(StringComparer.Ordinal); + + private readonly ConcurrentDictionary idLocks = new(StringComparer.Ordinal); + + /// + public DateTimeOffset LastAdvancedUtc { get; set; } = DateTimeOffset.MinValue; + + /// + 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; + } + } + + /// + 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; + } + } + + /// + 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; + } + } + + /// + public void ResetAllPrototypeRows() + { + foreach (var npcInstanceId in PrototypeNpcRegistry.GetInstanceIdsInOrder()) + { + _ = TryResetPrototypeRow(npcInstanceId); + } + } + + 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, + }; + } +} diff --git a/server/NeonSprawl.Server/Game/Npc/NpcBehaviorState.cs b/server/NeonSprawl.Server/Game/Npc/NpcBehaviorState.cs new file mode 100644 index 0000000..b3c76bc --- /dev/null +++ b/server/NeonSprawl.Server/Game/Npc/NpcBehaviorState.cs @@ -0,0 +1,27 @@ +namespace NeonSprawl.Server.Game.Npc; + +/// Prototype NPC behavior states (NEO-93). Wire names are stable for NEO-94 JSON. +public enum NpcBehaviorState +{ + Idle, + Aggro, + TelegraphWindup, + AttackExecute, + Recover, +} + +/// Stable snake_case wire names for . +public static class NpcBehaviorStateWire +{ + /// Returns the JSON wire name for . + 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), + }; +} diff --git a/server/NeonSprawl.Server/Game/Npc/NpcRuntimeOperations.cs b/server/NeonSprawl.Server/Game/Npc/NpcRuntimeOperations.cs new file mode 100644 index 0000000..c095d54 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Npc/NpcRuntimeOperations.cs @@ -0,0 +1,262 @@ +namespace NeonSprawl.Server.Game.Npc; + +/// Deterministic prototype NPC behavior state machine + lazy tick advance (NEO-93). +public static class NpcRuntimeOperations +{ + /// Default per-advance simulation cap (seconds) for lazy poll ticks. + public const double DefaultMaxDeltaSeconds = 5.0; + + /// + /// Advances all prototype NPC runtime rows by up to since the last call. + /// + 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); + } + + runtimeStore.LastAdvancedUtc = nowUtc; + 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 = nowUtc; + } + + /// Resets all prototype NPC runtime rows to idle (dev fixture). + 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 (!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; + } + + 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) + continue; + } + + case NpcBehaviorState.TelegraphWindup: + { + var phaseEnd = snapshot.PhaseStartedUtc.AddSeconds(behavior.TelegraphWindupSeconds); + if (windowEnd < phaseEnd) + { + return; + } + + cursor = phaseEnd; + WriteState( + runtimeStore, + npcInstanceId, + NpcBehaviorState.AttackExecute, + cursor, + activeTelegraph: null); + WriteState( + runtimeStore, + npcInstanceId, + NpcBehaviorState.Recover, + cursor, + activeTelegraph: null); + // telemetry: telegraph_fired / npc_state_transition (NEO-96); NEO-95 applies attackDamage at windup complete + continue; + } + + case NpcBehaviorState.AttackExecute: + WriteState( + runtimeStore, + npcInstanceId, + NpcBehaviorState.Recover, + cursor, + activeTelegraph: null); + // telemetry: npc_state_transition (NEO-96); NEO-95 applies attackDamage here + continue; + + 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) + continue; + } + + default: + return; + } + } + } + + 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)); + } +} diff --git a/server/NeonSprawl.Server/Game/Npc/NpcRuntimeServiceCollectionExtensions.cs b/server/NeonSprawl.Server/Game/Npc/NpcRuntimeServiceCollectionExtensions.cs new file mode 100644 index 0000000..9740e56 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Npc/NpcRuntimeServiceCollectionExtensions.cs @@ -0,0 +1,12 @@ +namespace NeonSprawl.Server.Game.Npc; + +/// Registers prototype NPC runtime behavior state store (NEO-93). +public static class NpcRuntimeServiceCollectionExtensions +{ + /// Registers as an in-memory singleton. + public static IServiceCollection AddNpcRuntimeStateStore(this IServiceCollection services) + { + services.AddSingleton(); + return services; + } +} diff --git a/server/NeonSprawl.Server/Game/Npc/NpcRuntimeStateSnapshot.cs b/server/NeonSprawl.Server/Game/Npc/NpcRuntimeStateSnapshot.cs new file mode 100644 index 0000000..5d89db3 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Npc/NpcRuntimeStateSnapshot.cs @@ -0,0 +1,17 @@ +namespace NeonSprawl.Server.Game.Npc; + +/// Active telegraph row while an NPC is in (NEO-93). +/// Stable id for NEO-94 wire projection. +/// UTC instant when windup phase began. +public readonly record struct ActiveTelegraphSnapshot(string TelegraphId, DateTimeOffset WindupStartedUtc); + +/// Per-NPC runtime behavior state snapshot (NEO-93). +/// Lowercase prototype NPC instance id. +/// Current behavior state. +/// UTC instant when the current phase began. +/// Non-null only during . +public readonly record struct NpcRuntimeStateSnapshot( + string NpcInstanceId, + NpcBehaviorState BehaviorState, + DateTimeOffset PhaseStartedUtc, + ActiveTelegraphSnapshot? ActiveTelegraph); diff --git a/server/NeonSprawl.Server/Program.cs b/server/NeonSprawl.Server/Program.cs index e0a4e1d..543d87e 100644 --- a/server/NeonSprawl.Server/Program.cs +++ b/server/NeonSprawl.Server/Program.cs @@ -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(); From ecffba9e4b1bed28c07bac0134c3bcc1bad36f7c Mon Sep 17 00:00:00 2001 From: VinPropane Date: Wed, 27 May 2026 22:02:41 -0400 Subject: [PATCH 03/14] NEO-93: Reconcile plan, backlog, and docs for runtime state machine. Co-authored-by: Cursor --- .../modules/E5_M2_NpcAiAndBehaviorProfiles.md | 4 +++- ...umentation_and_implementation_alignment.md | 2 +- docs/plans/E5M2-prototype-backlog.md | 8 ++++--- docs/plans/NEO-93-implementation-plan.md | 16 +++++++++---- server/README.md | 23 ++++++++++++++++++- 5 files changed, 43 insertions(+), 10 deletions(-) diff --git a/docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md b/docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md index 1738afd..225f05b 100644 --- a/docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md +++ b/docs/decomposition/modules/E5_M2_NpcAiAndBehaviorProfiles.md @@ -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 diff --git a/docs/decomposition/modules/documentation_and_implementation_alignment.md b/docs/decomposition/modules/documentation_and_implementation_alignment.md index be10ffb..c6e8f5b 100644 --- a/docs/decomposition/modules/documentation_and_implementation_alignment.md +++ b/docs/decomposition/modules/documentation_and_implementation_alignment.md @@ -57,7 +57,7 @@ Rows appear when work starts; default for unlisted modules is **Planned** / not | E3.M3 | In Progress | **NEO-50 landed:** frozen prototype six-item catalog in [`content/items/prototype_items.json`](../../../content/items/prototype_items.json); [`item-def.schema.json`](../../../content/schemas/item-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py). **NEO-51 landed:** fail-fast server load of `content/items/*_items.json` at startup — `server/NeonSprawl.Server/Game/Items/` ([NEO-51](../../plans/NEO-51-implementation-plan.md)); [server README — Item catalog](../../../server/README.md#item-catalog-contentitems-neo-51). **NEO-52 landed:** injectable **`IItemDefinitionRegistry`** + lookup tests ([NEO-52](../../plans/NEO-52-implementation-plan.md)). **NEO-53 landed:** **`GET /game/world/item-definitions`** — `ItemDefinitionsWorldApi` + DTOs in `Game/Items/` ([NEO-53](../../plans/NEO-53-implementation-plan.md), [`NEO-53` manual QA](../../manual-qa/NEO-53.md)); [server README — Item definitions (NEO-53)](../../../server/README.md#item-definitions-neo-53); Bruno `bruno/neon-sprawl-server/item-definitions/`. **NEO-54 landed:** **`IPlayerInventoryStore`** + **`PlayerInventoryOperations`** — 24 bag + 1 equipment slots, stack rules, `V005` migration ([NEO-54](../../plans/NEO-54-implementation-plan.md)). **NEO-55 landed:** **`GET`/`POST /game/players/{id}/inventory`** — `PlayerInventoryApi` + DTOs in `Game/Items/` ([NEO-55](../../plans/NEO-55-implementation-plan.md)); [server README — Player inventory (NEO-54 store, NEO-55 HTTP)](../../../server/README.md#player-inventory-neo-54-store-neo-55-http); Bruno `bruno/neon-sprawl-server/inventory/`. **NEO-56 landed:** comment-only **`item_created`** / **`inventory_transfer_denied`** telemetry hook sites in [`PlayerInventoryOperations`](../../../server/NeonSprawl.Server/Game/Items/PlayerInventoryOperations.cs) ([NEO-56](../../plans/NEO-56-implementation-plan.md), [`NEO-56` manual QA](../../manual-qa/NEO-56.md)); no E9.M1 ingest. **NEO-72 landed:** client inventory HUD — **`inventory_client.gd`**, **`item_definitions_client.gd`**, **`InventoryLabel`**, boot hydrate + **`I`** refresh ([NEO-72](../../plans/NEO-72-implementation-plan.md), [`NEO-72` manual QA](../../manual-qa/NEO-72.md)); `client/README.md` inventory HUD section. **NEO-75 landed:** **`prototype_economy_hud_section.gd`** collapse toggle + capstone manual QA ([NEO-75](../../plans/NEO-75-implementation-plan.md), [`NEO-75` manual QA](../../manual-qa/NEO-75.md)); Epic 3 Slice 5 client complete. | [NEO-50](../../plans/NEO-50-implementation-plan.md), [NEO-51](../../plans/NEO-51-implementation-plan.md), [NEO-52](../../plans/NEO-52-implementation-plan.md), [NEO-53](../../plans/NEO-53-implementation-plan.md), [NEO-54](../../plans/NEO-54-implementation-plan.md), [NEO-55](../../plans/NEO-55-implementation-plan.md), [NEO-56](../../plans/NEO-56-implementation-plan.md), [NEO-72](../../plans/NEO-72-implementation-plan.md), [NEO-75](../../plans/NEO-75-implementation-plan.md), [E3S5-client-prototype-backlog](../../plans/E3S5-client-prototype-backlog.md), [E3M3-prototype-backlog](../../plans/E3M3-prototype-backlog.md), [E3_M3](E3_M3_ItemizationAndInventorySchema.md) | | E3.M2 | Ready | **NEO-65 landed:** frozen prototype eight-recipe catalog in [`content/recipes/prototype_recipes.json`](../../../content/recipes/prototype_recipes.json); [`recipe-def.schema.json`](../../../content/schemas/recipe-def.schema.json) + [`recipe-io-row.schema.json`](../../../content/schemas/recipe-io-row.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) Slice 3 gates ([NEO-65](../../plans/NEO-65-implementation-plan.md)). **NEO-66 landed:** fail-fast server load of `content/recipes/*_recipes.json` at startup — `server/NeonSprawl.Server/Game/Crafting/` ([NEO-66](../../plans/NEO-66-implementation-plan.md)); [server README — Recipe catalog](../../../server/README.md#recipe-catalog-contentrecipes-neo-66). **NEO-67 landed:** injectable **`IRecipeDefinitionRegistry`** + lookup tests ([NEO-67](../../plans/NEO-67-implementation-plan.md)). **NEO-68 landed:** **`GET /game/world/recipe-definitions`** — `RecipeDefinitionsWorldApi` + DTOs in `Game/Crafting/` ([NEO-68](../../plans/NEO-68-implementation-plan.md), [`NEO-68` manual QA](../../manual-qa/NEO-68.md)); [server README — Recipe definitions (NEO-68)](../../../server/README.md#recipe-definitions-neo-68); Bruno `bruno/neon-sprawl-server/recipe-definitions/`. **NEO-69 landed:** **`CraftOperations.TryCraft`** + **`CraftResult`** — pre-flight inputs/outputs, inventory mutation, refine XP ([NEO-69](../../plans/NEO-69-implementation-plan.md)); [server README — Craft engine (NEO-69)](../../../server/README.md#craft-engine-neo-69-and-craft-http-neo-70). **NEO-70 landed:** **`POST /game/players/{id}/craft`** — `PlayerCraftApi` + wire **`CraftResponse`**; Bruno gather→refine→make spine ([NEO-70](../../plans/NEO-70-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/craft/`. **NEO-71 landed:** comment-only **`item_crafted`** / **`craft_failed`** telemetry hook sites in **`CraftOperations.TryCraft`** ([NEO-71](../../plans/NEO-71-implementation-plan.md)); [server README — Craft telemetry hooks (NEO-71)](../../../server/README.md#craft-telemetry-hooks-neo-71). **NEO-42 landed (prep):** **`RefineActivitySkillXpGrant`** / **`RefineSkillXpConstants`** — wired from craft engine success path. Epic 3 Slice 3 server backlog **E3M2-01**–**E3M2-07** complete. Upstream: E3.M1 **Ready**, E3.M3 inventory landed. **NEO-74 landed:** client craft UI — **`recipe_definitions_client.gd`**, **`craft_client.gd`**, **`craft_recipe_panel.gd`**, **`CraftFeedbackLabel`**, **refine** skill row ([NEO-74](../../plans/NEO-74-implementation-plan.md), [`NEO-74` manual QA](../../manual-qa/NEO-74.md)); `client/README.md` craft section. **NEO-75 landed:** capstone playable gather→refine→make loop — [`NEO-75` manual QA](../../manual-qa/NEO-75.md), **`prototype_economy_hud_section.gd`**; Epic 3 Slice 5 client complete. | [NEO-42](../../plans/NEO-42-implementation-plan.md), [NEO-65](../../plans/NEO-65-implementation-plan.md), [NEO-66](../../plans/NEO-66-implementation-plan.md), [NEO-67](../../plans/NEO-67-implementation-plan.md), [NEO-68](../../plans/NEO-68-implementation-plan.md), [NEO-69](../../plans/NEO-69-implementation-plan.md), [NEO-70](../../plans/NEO-70-implementation-plan.md), [NEO-71](../../plans/NEO-71-implementation-plan.md), [NEO-74](../../plans/NEO-74-implementation-plan.md), [NEO-75](../../plans/NEO-75-implementation-plan.md), [E3M2-prototype-backlog](../../plans/E3M2-prototype-backlog.md), [E3S5-client-prototype-backlog](../../plans/E3S5-client-prototype-backlog.md), [E3_M2](E3_M2_RefinementAndRecipeExecution.md) | | E5.M1 | Ready | **NEO-76 landed:** frozen prototype four-ability catalog in [`content/abilities/prototype_abilities.json`](../../../content/abilities/prototype_abilities.json); [`ability-def.schema.json`](../../../content/schemas/ability-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) E5M1 four-id gate ([NEO-76](../../plans/NEO-76-implementation-plan.md)). **NEO-77 landed:** fail-fast server load of `content/abilities/*_abilities.json` at startup — `server/NeonSprawl.Server/Game/Combat/` ([NEO-77](../../plans/NEO-77-implementation-plan.md)); [server README — Ability catalog](../../../server/README.md#ability-catalog-contentabilities-neo-77). **NEO-79 landed:** injectable **`IAbilityDefinitionRegistry`** + DI; hotbar/cast use **`TryNormalizeKnown`** ([NEO-79](../../plans/NEO-79-implementation-plan.md)); Bruno `bruno/neon-sprawl-server/hotbar-loadout/` + `ability-cast/` unknown-ability deny smokes. **NEO-78 landed:** **`GET /game/world/ability-definitions`** — `AbilityDefinitionsWorldApi` + DTOs in `Game/Combat/` ([NEO-78](../../plans/NEO-78-implementation-plan.md)); [server README — Ability definitions (NEO-78)](../../../server/README.md#ability-definitions-neo-78); Bruno `bruno/neon-sprawl-server/ability-definitions/`. **NEO-80 landed:** **`ICombatEntityHealthStore`** / **`InMemoryCombatEntityHealthStore`** — lazy init (Slice 1 flat 100 HP; **superseded by [NEO-91](../../plans/NEO-91-implementation-plan.md)** per-archetype catalog max HP + three NPC instance ids); DI via **`AddCombatEntityHealthStore()`** ([NEO-80](../../plans/NEO-80-implementation-plan.md)); [server README — Combat entity health (NEO-80, NEO-91)](../../../server/README.md#combat-entity-health-neo-80-neo-91). **NEO-81 landed:** **`CombatOperations.TryResolve`** + **`CombatResult`** + **`CombatReasonCodes`** — defeated pre-check deny, catalog damage ([NEO-81](../../plans/NEO-81-implementation-plan.md)); [server README — Combat engine (NEO-81)](../../../server/README.md#combat-engine-neo-81). **NEO-82 landed:** **`AbilityCastApi`** → **`CombatOperations.TryResolve`**; nested wire **`combatResolution`** on accept; per-ability catalog cooldown; **`target_defeated`** cast deny ([NEO-82](../../plans/NEO-82-implementation-plan.md)); [server README — Ability cast (NEO-82)](../../../server/README.md#ability-cast-neo-31-neo-82); Bruno `bruno/neon-sprawl-server/ability-cast/` defeat spine. **NEO-83 landed:** **`GET /game/world/combat-targets`** — `CombatTargetsWorldApi` + DTOs; authoritative HP snapshot from **`ICombatEntityHealthStore`** ([NEO-83](../../plans/NEO-83-implementation-plan.md)); [server README — Combat targets snapshot (NEO-83)](../../../server/README.md#combat-targets-snapshot-neo-83); Bruno `bruno/neon-sprawl-server/combat-targets/`. **NEO-44 landed:** **`CombatDefeatGigXpGrant`** on cast **`targetDefeated`** — **25** gig XP to **`breach`** via **`IPlayerGigProgressionStore`** (V007); **`GET …/gig-progression`** ([NEO-44](../../plans/NEO-44-implementation-plan.md), [`NEO-44` manual QA](../../manual-qa/NEO-44.md)); [server README — Gig progression (NEO-44)](../../../server/README.md#gig-progression-snapshot-neo-44); Bruno `bruno/neon-sprawl-server/gig-progression/`. **NEO-84 landed:** comment-only **`ability_used`** / **`enemy_defeat`** telemetry hook sites in **`CombatOperations.TryResolve`**; reserved **`encounter_start`** / **`player_death`** ([NEO-84](../../plans/NEO-84-implementation-plan.md)); [server README — Combat telemetry hooks (NEO-84)](../../../server/README.md#combat-telemetry-hooks-neo-84). **NEO-85 landed:** client combat HUD — **`ability_cast_client.gd`** parses **`combatResolution`**; **`combat_targets_client.gd`**, **`CastFeedbackLabel`** resolution copy, **`CombatTargetHpLabel`**; event-driven GET refresh ([NEO-85](../../plans/NEO-85-implementation-plan.md), [`NEO-85` manual QA](../../manual-qa/NEO-85.md)); `client/README.md` combat HUD section. **NEO-86 landed:** playable combat capstone — **`gig_progression_client.gd`**, **`GigXpLabel`**, defeat-triggered gig GET refresh; capstone manual QA [`NEO-86`](../../manual-qa/NEO-86.md); `client/README.md` end-to-end combat loop section ([NEO-86](../../plans/NEO-86-implementation-plan.md)). **Epic 5 Slice 1 client capstone complete.** **Backlog decomposed:** Epic 5 Slice 1 — **E5M1-01**–**E5M1-12** landed. **Precursor landed:** E1.M4 cast funnel (NEO-28/31/32). | [E5M1-prototype-backlog.md](../../plans/E5M1-prototype-backlog.md), [E5_M1](E5_M1_CombatRulesEngine.md), [NEO-77](../../plans/NEO-77-implementation-plan.md), [NEO-79](../../plans/NEO-79-implementation-plan.md), [NEO-78](../../plans/NEO-78-implementation-plan.md), [NEO-80](../../plans/NEO-80-implementation-plan.md), [NEO-81](../../plans/NEO-81-implementation-plan.md), [NEO-82](../../plans/NEO-82-implementation-plan.md), [NEO-83](../../plans/NEO-83-implementation-plan.md), [NEO-44](../../plans/NEO-44-implementation-plan.md), [NEO-84](../../plans/NEO-84-implementation-plan.md), [NEO-85](../../plans/NEO-85-implementation-plan.md), [NEO-86](../../plans/NEO-86-implementation-plan.md), [E1M4-prototype-backlog.md](../../plans/E1M4-prototype-backlog.md) | -| E5.M2 | In Progress | **NEO-87 landed:** frozen prototype three-behavior catalog in [`content/npc-behaviors/prototype_npc_behaviors.json`](../../../content/npc-behaviors/prototype_npc_behaviors.json); [`npc-behavior-def.schema.json`](../../../content/schemas/npc-behavior-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) E5M2 three-id gate ([NEO-87](../../plans/NEO-87-implementation-plan.md)). **NEO-88 landed:** fail-fast server load of `content/npc-behaviors/*_npc_behaviors.json` at startup — `server/NeonSprawl.Server/Game/Npc/` ([NEO-88](../../plans/NEO-88-implementation-plan.md)); [server README — NPC behavior catalog](../../../server/README.md#npc-behavior-catalog-contentnpc-behaviors-neo-88). **NEO-89 landed:** injectable **`INpcBehaviorDefinitionRegistry`** + DI; **`PrototypeNpcBehaviorRegistry`** id constants ([NEO-89](../../plans/NEO-89-implementation-plan.md)). **NEO-90 landed:** **`GET /game/world/npc-behavior-definitions`** — `NpcBehaviorDefinitionsWorldApi` + DTOs in `Game/Npc/` ([NEO-90](../../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/`. **NEO-91 landed:** **`PrototypeNpcRegistry`** + combat-target migration — three NPC instance ids replace alpha/beta; per-archetype max HP from behavior catalog ([NEO-91](../../plans/NEO-91-implementation-plan.md)); [server README — Combat entity health (NEO-91)](../../../server/README.md#combat-entity-health-neo-80-neo-91). **NEO-92 landed:** **`IThreatStateStore`** + **`AggroOperations`** — first-hit acquire on damaging cast, leash clear on move/cast; cast/move/fixture hooks ([NEO-92](../../plans/NEO-92-implementation-plan.md)); [server README — Threat / aggro state (NEO-92)](../../../server/README.md#threat--aggro-state-neo-92). **Backlog decomposed:** Epic 5 Slice 2 — [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md) **E5M2-01** [NEO-87](https://linear.app/neon-sprawl/issue/NEO-87) → **E5M2-12** [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98); client capstone **NEO-98**. Three archetypes (`prototype_melee_pressure`, `prototype_ranged_control`, `prototype_elite_mini_boss`); replaces alpha/beta dummies with **`prototype_npc_melee` / `ranged` / `elite`**. Owns **`ThreatState`**, **`TelegraphEvent`**, **`GET …/npc-runtime-snapshot`**, session **`IPlayerCombatHealthStore`**. Upstream **E5.M1 Ready**. | [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md), [E5_M2](E5_M2_NpcAiAndBehaviorProfiles.md), [NEO-87](../../plans/NEO-87-implementation-plan.md), [NEO-88](../../plans/NEO-88-implementation-plan.md), [NEO-89](../../plans/NEO-89-implementation-plan.md), [NEO-90](../../plans/NEO-90-implementation-plan.md), [NEO-91](../../plans/NEO-91-implementation-plan.md), [NEO-92](../../plans/NEO-92-implementation-plan.md), label **`E5.M2`** on NEO-87–NEO-98 | +| E5.M2 | In Progress | **NEO-87 landed:** frozen prototype three-behavior catalog in [`content/npc-behaviors/prototype_npc_behaviors.json`](../../../content/npc-behaviors/prototype_npc_behaviors.json); [`npc-behavior-def.schema.json`](../../../content/schemas/npc-behavior-def.schema.json); PR gate + [`validate_content.py`](../../../scripts/validate_content.py) E5M2 three-id gate ([NEO-87](../../plans/NEO-87-implementation-plan.md)). **NEO-88 landed:** fail-fast server load of `content/npc-behaviors/*_npc_behaviors.json` at startup — `server/NeonSprawl.Server/Game/Npc/` ([NEO-88](../../plans/NEO-88-implementation-plan.md)); [server README — NPC behavior catalog](../../../server/README.md#npc-behavior-catalog-contentnpc-behaviors-neo-88). **NEO-89 landed:** injectable **`INpcBehaviorDefinitionRegistry`** + DI; **`PrototypeNpcBehaviorRegistry`** id constants ([NEO-89](../../plans/NEO-89-implementation-plan.md)). **NEO-90 landed:** **`GET /game/world/npc-behavior-definitions`** — `NpcBehaviorDefinitionsWorldApi` + DTOs in `Game/Npc/` ([NEO-90](../../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/`. **NEO-91 landed:** **`PrototypeNpcRegistry`** + combat-target migration — three NPC instance ids replace alpha/beta; per-archetype max HP from behavior catalog ([NEO-91](../../plans/NEO-91-implementation-plan.md)); [server README — Combat entity health (NEO-91)](../../../server/README.md#combat-entity-health-neo-80-neo-91). **NEO-92 landed:** **`IThreatStateStore`** + **`AggroOperations`** — first-hit acquire on damaging cast, leash clear on move/cast; cast/move/fixture hooks ([NEO-92](../../plans/NEO-92-implementation-plan.md)); [server README — Threat / aggro state (NEO-92)](../../../server/README.md#threat--aggro-state-neo-92). **NEO-93 landed:** **`INpcRuntimeStateStore`** + **`NpcRuntimeOperations.AdvanceAll`** — catalog-driven behavior state machine, lazy tick advance with delta cap; dev fixture reset ([NEO-93](../../plans/NEO-93-implementation-plan.md)); [server README — NPC runtime behavior state (NEO-93)](../../../server/README.md#npc-runtime-behavior-state-neo-93). **Backlog decomposed:** Epic 5 Slice 2 — [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md) **E5M2-01** [NEO-87](https://linear.app/neon-sprawl/issue/NEO-87) → **E5M2-12** [NEO-98](https://linear.app/neon-sprawl/issue/NEO-98); client capstone **NEO-98**. Three archetypes (`prototype_melee_pressure`, `prototype_ranged_control`, `prototype_elite_mini_boss`); replaces alpha/beta dummies with **`prototype_npc_melee` / `ranged` / `elite`**. Owns **`ThreatState`**, **`TelegraphEvent`**, **`GET …/npc-runtime-snapshot`**, session **`IPlayerCombatHealthStore`**. Upstream **E5.M1 Ready**. | [E5M2-prototype-backlog.md](../../plans/E5M2-prototype-backlog.md), [E5_M2](E5_M2_NpcAiAndBehaviorProfiles.md), [NEO-87](../../plans/NEO-87-implementation-plan.md), [NEO-88](../../plans/NEO-88-implementation-plan.md), [NEO-89](../../plans/NEO-89-implementation-plan.md), [NEO-90](../../plans/NEO-90-implementation-plan.md), [NEO-91](../../plans/NEO-91-implementation-plan.md), [NEO-92](../../plans/NEO-92-implementation-plan.md), [NEO-93](../../plans/NEO-93-implementation-plan.md), label **`E5.M2`** on NEO-87–NEO-98 | --- diff --git a/docs/plans/E5M2-prototype-backlog.md b/docs/plans/E5M2-prototype-backlog.md index 10b0198..3a0965e 100644 --- a/docs/plans/E5M2-prototype-backlog.md +++ b/docs/plans/E5M2-prototype-backlog.md @@ -241,9 +241,11 @@ 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. + +**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). --- diff --git a/docs/plans/NEO-93-implementation-plan.md b/docs/plans/NEO-93-implementation-plan.md index 25845bf..5ed4b25 100644 --- a/docs/plans/NEO-93-implementation-plan.md +++ b/docs/plans/NEO-93-implementation-plan.md @@ -50,10 +50,18 @@ ## 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 **`idle` → `aggro` → `telegraph_windup` → `attack_execute` → `recover`** is reachable with holder set (deterministic injected clock). +- [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. +- **17** NEO-93-focused tests green (`NpcRuntimeOperationsTests`, `InMemoryNpcRuntimeStateStoreTests`, fixture extension). ## Technical approach diff --git a/server/README.md b/server/README.md index 80d588c..a76fb6b 100644 --- a/server/README.md +++ b/server/README.md @@ -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. From eed176d2260836c4eb217e8e86566c7733d9e540 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Wed, 27 May 2026 22:58:33 -0400 Subject: [PATCH 04/14] NEO-93: Add code review for NPC runtime state machine branch. Co-authored-by: Cursor --- docs/reviews/2026-05-27-NEO-93.md | 54 +++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 docs/reviews/2026-05-27-NEO-93.md diff --git a/docs/reviews/2026-05-27-NEO-93.md b/docs/reviews/2026-05-27-NEO-93.md new file mode 100644 index 0000000..5f41f64 --- /dev/null +++ b/docs/reviews/2026-05-27-NEO-93.md @@ -0,0 +1,54 @@ +# 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` + +## 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, holder clear, archetype spot checks, delta cap) plus store and fixture integration; **18** focused tests passed locally. Risk is low for merge; remaining items are doc parity and test-layout polish. + +## 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) | **Partially matches** — AC checkboxes and landed note updated; **In scope** still says advance is “called from snapshot GET handler” (NEO-94 wires that; NEO-93 is engine-only). Plan’s fourth AC (full transition chain) is not mirrored in backlog AC list. | +| `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. +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. +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. +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. + +## 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. +- Nit: Plan reconciliation cites **17** NEO-93-focused tests; local filter run reports **18** (theory counts as two cases) — trivial count drift. +- 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. From d3f178b5fbe9774ce091224445329892282d806f Mon Sep 17 00:00:00 2001 From: VinPropane Date: Wed, 27 May 2026 23:08:34 -0400 Subject: [PATCH 05/14] =?UTF-8?q?NEO-93:=20Address=20code=20review=20?= =?UTF-8?q?=E2=80=94=20AAA=20tests,=20single-call=20advance,=20windup=20fi?= =?UTF-8?q?x.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../Game/Npc/NpcRuntimeOperationsTests.cs | 101 ++++++++++++------ .../Game/Npc/NpcRuntimeOperations.cs | 8 +- 2 files changed, 72 insertions(+), 37 deletions(-) diff --git a/server/NeonSprawl.Server.Tests/Game/Npc/NpcRuntimeOperationsTests.cs b/server/NeonSprawl.Server.Tests/Game/Npc/NpcRuntimeOperationsTests.cs index 88fcf47..2a17430 100644 --- a/server/NeonSprawl.Server.Tests/Game/Npc/NpcRuntimeOperationsTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Npc/NpcRuntimeOperationsTests.cs @@ -35,8 +35,8 @@ public sealed class NpcRuntimeOperationsTests var (runtime, threat, behavior) = CreateFixture(); // Act Advance(T0, runtime, threat, behavior); - runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); // Assert + runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); Assert.Equal(NpcBehaviorState.Idle, snapshot.BehaviorState); Assert.Null(snapshot.ActiveTelegraph); } @@ -49,8 +49,8 @@ public sealed class NpcRuntimeOperationsTests SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); // Act Advance(T0, runtime, threat, behavior); - runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); // Assert + runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); Assert.Equal(NpcBehaviorState.Aggro, snapshot.BehaviorState); Assert.Equal(T0, snapshot.PhaseStartedUtc); Assert.Null(snapshot.ActiveTelegraph); @@ -64,8 +64,8 @@ public sealed class NpcRuntimeOperationsTests runtime.LastAdvancedUtc = T0; // Act Advance(T0.AddSeconds(10), runtime, threat, behavior); - runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); // Assert + runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); Assert.Equal(NpcBehaviorState.Idle, snapshot.BehaviorState); Assert.Null(snapshot.ActiveTelegraph); } @@ -79,8 +79,8 @@ public sealed class NpcRuntimeOperationsTests Advance(T0, runtime, threat, behavior); // Act Advance(T0.AddSeconds(3), runtime, threat, behavior); - runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); // 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); @@ -95,13 +95,13 @@ public sealed class NpcRuntimeOperationsTests Advance(T0, runtime, threat, behavior); // Act Advance(T0.AddSeconds(2.9), runtime, threat, behavior); - runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); // Assert + runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); Assert.Equal(NpcBehaviorState.Aggro, snapshot.BehaviorState); } [Fact] - public void AdvanceAll_ShouldCompleteMeleeWindupDuration_BeforeAttackExecute() + public void AdvanceAll_ShouldRemainTelegraphWindup_BeforeMeleeWindupCompletes() { // Arrange var (runtime, threat, behavior) = CreateFixture(); @@ -110,14 +110,27 @@ public sealed class NpcRuntimeOperationsTests Advance(T0.AddSeconds(3), runtime, threat, behavior); // Act Advance(T0.AddSeconds(4.4), runtime, threat, behavior); - runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var duringWindup); - Advance(T0.AddSeconds(4.5), runtime, threat, behavior); - runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var afterWindup); // Assert - Assert.Equal(NpcBehaviorState.TelegraphWindup, duringWindup.BehaviorState); - Assert.Equal(NpcBehaviorState.Recover, afterWindup.BehaviorState); - Assert.Equal(T0.AddSeconds(4.5), afterWindup.PhaseStartedUtc); - Assert.Null(afterWindup.ActiveTelegraph); + 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] @@ -131,8 +144,24 @@ public sealed class NpcRuntimeOperationsTests Advance(T0.AddSeconds(4.5), runtime, threat, behavior); // Act Advance(T0.AddSeconds(7.5), runtime, threat, behavior); - runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); // 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); @@ -149,16 +178,35 @@ public sealed class NpcRuntimeOperationsTests _ = threat.TryClearHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId); // Act Advance(T0.AddSeconds(3.5), runtime, threat, behavior); - runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); // 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_ShouldUseCatalogTimings_ForEachArchetype( + public void AdvanceAll_ShouldEnterRecover_AfterCatalogWindupForArchetype( string npcInstanceId, double attackCooldownSeconds, double telegraphWindupSeconds) @@ -167,22 +215,15 @@ public sealed class NpcRuntimeOperationsTests var (runtime, threat, behavior) = CreateFixture(); SetHolder(threat, npcInstanceId); Advance(T0, runtime, threat, behavior); - // Act Advance(T0.AddSeconds(attackCooldownSeconds), runtime, threat, behavior); - runtime.TryGet(npcInstanceId, out var telegraphSnapshot); - Advance( - T0.AddSeconds(attackCooldownSeconds + telegraphWindupSeconds), - runtime, - threat, - behavior); - runtime.TryGet(npcInstanceId, out var recoverSnapshot); + // Act + Advance(T0.AddSeconds(attackCooldownSeconds + telegraphWindupSeconds), runtime, threat, behavior); // Assert - Assert.Equal(NpcBehaviorState.TelegraphWindup, telegraphSnapshot.BehaviorState); - Assert.Equal(T0.AddSeconds(attackCooldownSeconds), telegraphSnapshot.PhaseStartedUtc); - Assert.Equal(NpcBehaviorState.Recover, recoverSnapshot.BehaviorState); + runtime.TryGet(npcInstanceId, out var snapshot); + Assert.Equal(NpcBehaviorState.Recover, snapshot.BehaviorState); Assert.Equal( T0.AddSeconds(attackCooldownSeconds + telegraphWindupSeconds), - recoverSnapshot.PhaseStartedUtc); + snapshot.PhaseStartedUtc); } [Fact] @@ -192,10 +233,10 @@ public sealed class NpcRuntimeOperationsTests var (runtime, threat, behavior) = CreateFixture(); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); Advance(T0, runtime, threat, behavior); - // Act — 10s gap with 5s cap should not reach second telegraph (needs 7.5s from aggro start) + // Act Advance(T0.AddSeconds(10), runtime, threat, behavior, maxDeltaSeconds: 5); - runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); // Assert + runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); Assert.Equal(NpcBehaviorState.Recover, snapshot.BehaviorState); Assert.Equal(T0.AddSeconds(4.5), snapshot.PhaseStartedUtc); } diff --git a/server/NeonSprawl.Server/Game/Npc/NpcRuntimeOperations.cs b/server/NeonSprawl.Server/Game/Npc/NpcRuntimeOperations.cs index c095d54..0964e23 100644 --- a/server/NeonSprawl.Server/Game/Npc/NpcRuntimeOperations.cs +++ b/server/NeonSprawl.Server/Game/Npc/NpcRuntimeOperations.cs @@ -171,19 +171,13 @@ public static class NpcRuntimeOperations } cursor = phaseEnd; - WriteState( - runtimeStore, - npcInstanceId, - NpcBehaviorState.AttackExecute, - cursor, - activeTelegraph: null); WriteState( runtimeStore, npcInstanceId, NpcBehaviorState.Recover, cursor, activeTelegraph: null); - // telemetry: telegraph_fired / npc_state_transition (NEO-96); NEO-95 applies attackDamage at windup complete + // telemetry: telegraph_fired / npc_state_transition (NEO-96); logical attack_execute → recover (NEO-95 applies attackDamage) continue; } From 4fcbb998811cdb16800226f91c4ec32de7d6186e Mon Sep 17 00:00:00 2001 From: VinPropane Date: Wed, 27 May 2026 23:08:34 -0400 Subject: [PATCH 06/14] NEO-93: Reconcile backlog and review doc after code review follow-up. Co-authored-by: Cursor --- docs/plans/E5M2-prototype-backlog.md | 3 ++- docs/plans/NEO-93-implementation-plan.md | 2 +- docs/reviews/2026-05-27-NEO-93.md | 18 ++++++++++-------- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/docs/plans/E5M2-prototype-backlog.md b/docs/plans/E5M2-prototype-backlog.md index 3a0965e..5a36305 100644 --- a/docs/plans/E5M2-prototype-backlog.md +++ b/docs/plans/E5M2-prototype-backlog.md @@ -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. @@ -244,6 +244,7 @@ Working backlog for **Epic 5 — Slice 2** ([NPC archetypes and telegraphs](../d - [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). diff --git a/docs/plans/NEO-93-implementation-plan.md b/docs/plans/NEO-93-implementation-plan.md index 5ed4b25..9775028 100644 --- a/docs/plans/NEO-93-implementation-plan.md +++ b/docs/plans/NEO-93-implementation-plan.md @@ -61,7 +61,7 @@ - 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. -- **17** NEO-93-focused tests green (`NpcRuntimeOperationsTests`, `InMemoryNpcRuntimeStateStoreTests`, fixture extension). +- **22** NEO-93-focused tests green (`NpcRuntimeOperationsTests`, `InMemoryNpcRuntimeStateStoreTests`, fixture extension). ## Technical approach diff --git a/docs/reviews/2026-05-27-NEO-93.md b/docs/reviews/2026-05-27-NEO-93.md index 5f41f64..ebef78e 100644 --- a/docs/reviews/2026-05-27-NEO-93.md +++ b/docs/reviews/2026-05-27-NEO-93.md @@ -4,20 +4,22 @@ **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, holder clear, archetype spot checks, delta cap) plus store and fixture integration; **18** focused tests passed locally. Risk is low for merge; remaining items are doc parity and test-layout polish. +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) | **Partially matches** — AC checkboxes and landed note updated; **In scope** still says advance is “called from snapshot GET handler” (NEO-94 wires that; NEO-93 is engine-only). Plan’s fourth AC (full transition chain) is not mirrored in backlog AC list. | +| `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). | @@ -29,15 +31,15 @@ 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. -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. -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. -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. +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. -- Nit: Plan reconciliation cites **17** NEO-93-focused tests; local filter run reports **18** (theory counts as two cases) — trivial count drift. +- ~~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 From 056b0e77f0aed76a503e036a41efe6f27e374666 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Wed, 27 May 2026 23:16:35 -0400 Subject: [PATCH 07/14] NEO-93: Fix delta cap LastAdvancedUtc to preserve gradual catch-up. When simulation is capped, advance LastAdvancedUtc to windowEnd instead of nowUtc so subsequent polls do not burst through missed NPC cycles. Co-authored-by: Cursor --- docs/plans/NEO-93-implementation-plan.md | 2 +- .../Game/Npc/NpcRuntimeOperationsTests.cs | 18 ++++++++++++++++++ .../Game/Npc/NpcRuntimeOperations.cs | 2 +- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/docs/plans/NEO-93-implementation-plan.md b/docs/plans/NEO-93-implementation-plan.md index 9775028..a9bb235 100644 --- a/docs/plans/NEO-93-implementation-plan.md +++ b/docs/plans/NEO-93-implementation-plan.md @@ -61,7 +61,7 @@ - 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). +- **23** NEO-93-focused tests green (`NpcRuntimeOperationsTests`, `InMemoryNpcRuntimeStateStoreTests`, fixture extension). ## Technical approach diff --git a/server/NeonSprawl.Server.Tests/Game/Npc/NpcRuntimeOperationsTests.cs b/server/NeonSprawl.Server.Tests/Game/Npc/NpcRuntimeOperationsTests.cs index 2a17430..eeb1107 100644 --- a/server/NeonSprawl.Server.Tests/Game/Npc/NpcRuntimeOperationsTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Npc/NpcRuntimeOperationsTests.cs @@ -239,5 +239,23 @@ public sealed class NpcRuntimeOperationsTests 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); } } diff --git a/server/NeonSprawl.Server/Game/Npc/NpcRuntimeOperations.cs b/server/NeonSprawl.Server/Game/Npc/NpcRuntimeOperations.cs index 0964e23..3e142ba 100644 --- a/server/NeonSprawl.Server/Game/Npc/NpcRuntimeOperations.cs +++ b/server/NeonSprawl.Server/Game/Npc/NpcRuntimeOperations.cs @@ -72,7 +72,7 @@ public static class NpcRuntimeOperations behaviorRegistry); } - runtimeStore.LastAdvancedUtc = nowUtc; + runtimeStore.LastAdvancedUtc = windowEnd; } /// Resets all prototype NPC runtime rows to idle (dev fixture). From 90e6cfea28f226fc6cea8c5aa96241e62d5f0466 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Wed, 27 May 2026 23:21:00 -0400 Subject: [PATCH 08/14] chore: add macOS trackpad scroll and pinch camera zoom. Map InputEventPanGesture and InputEventMagnifyGesture to the existing discrete zoom bands; document in client README. Unrelated to NEO-93. Co-authored-by: Cursor --- client/README.md | 2 +- client/scripts/isometric_follow_camera.gd | 40 +++++++++++++++++++- client/test/isometric_follow_camera_test.gd | 41 +++++++++++++++++++++ 3 files changed, 81 insertions(+), 2 deletions(-) diff --git a/client/README.md b/client/README.md index 431dbce..ec6fe40 100644 --- a/client/README.md +++ b/client/README.md @@ -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 ([`InputEventPanGesture`](https://docs.godotengine.org/en/stable/classes/class_inputeventpangesture.html)) and pinch ([`InputEventMagnifyGesture`](https://docs.godotengine.org/en/stable/classes/class_inputeventmagnifygesture.html)) to the same discrete zoom bands. 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. diff --git a/client/scripts/isometric_follow_camera.gd b/client/scripts/isometric_follow_camera.gd index 3f511da..e4f4166 100644 --- a/client/scripts/isometric_follow_camera.gd +++ b/client/scripts/isometric_follow_camera.gd @@ -14,7 +14,11 @@ 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) -const CameraStateScript := preload("res://scripts/camera_state.gd") +## Minimum |delta.y| before a pan gesture advances one zoom band (macOS trackpad). +const PAN_ZOOM_DELTA_THRESHOLD: float = 0.05 + +## Magnify factor must exceed 1.0 ± this before one zoom band step (trackpad pinch). +const MAGNIFY_ZOOM_FACTOR_EPSILON: float = 0.02 const ZoomBandConfigScript := preload("res://scripts/zoom_band_config.gd") const OcclusionPolicyScript := preload("res://scripts/occlusion_policy.gd") @@ -128,12 +132,46 @@ func _exit_tree() -> void: func _unhandled_input(event: InputEvent) -> void: if event.is_echo(): return + var pan_step: int = zoom_step_from_pan_gesture(event) + if pan_step != 0: + _apply_zoom_step(pan_step) + return + var magnify_step: int = zoom_step_from_magnify_gesture(event) + if magnify_step != 0: + _apply_zoom_step(magnify_step) + 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) +## Maps macOS trackpad two-finger scroll to a discrete zoom band step, or **0** when ignored. +static func zoom_step_from_pan_gesture(event: InputEvent) -> int: + if event == null or not event is InputEventPanGesture: + return 0 + var pan: InputEventPanGesture = event as InputEventPanGesture + if absf(pan.delta.y) <= absf(pan.delta.x): + return 0 + if pan.delta.y <= -PAN_ZOOM_DELTA_THRESHOLD: + return -1 + if pan.delta.y >= PAN_ZOOM_DELTA_THRESHOLD: + return 1 + return 0 + + +## Maps trackpad pinch ([InputEventMagnifyGesture]) to a discrete zoom band step, or **0**. +static func zoom_step_from_magnify_gesture(event: InputEvent) -> int: + if event == null or not event is InputEventMagnifyGesture: + return 0 + var mag: InputEventMagnifyGesture = event as InputEventMagnifyGesture + if mag.factor >= 1.0 + MAGNIFY_ZOOM_FACTOR_EPSILON: + return -1 + if mag.factor <= 1.0 - MAGNIFY_ZOOM_FACTOR_EPSILON: + return 1 + return 0 + + ## **Near** = lower band index. Zoom in → closer → decrease index (clamped). func _apply_zoom_step(delta_bands: int) -> void: if not _zoom_config_valid(): diff --git a/client/test/isometric_follow_camera_test.gd b/client/test/isometric_follow_camera_test.gd index c709394..5007ab7 100644 --- a/client/test/isometric_follow_camera_test.gd +++ b/client/test/isometric_follow_camera_test.gd @@ -138,3 +138,44 @@ 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_zoom_step_from_pan_gesture_ignores_non_pan() -> void: + # Arrange + var key := InputEventKey.new() + # Act + var step: int = IsoScript.zoom_step_from_pan_gesture(key) + # Assert + assert_that(step).is_equal(0) + + +func test_zoom_step_from_pan_gesture_maps_vertical_scroll_to_bands() -> void: + # Arrange + var scroll_up := InputEventPanGesture.new() + scroll_up.delta = Vector2(0.0, -0.2) + var scroll_down := InputEventPanGesture.new() + scroll_down.delta = Vector2(0.0, 0.2) + var horizontal := InputEventPanGesture.new() + horizontal.delta = Vector2(0.2, 0.0) + # Act + var up_step: int = IsoScript.zoom_step_from_pan_gesture(scroll_up) + var down_step: int = IsoScript.zoom_step_from_pan_gesture(scroll_down) + var horiz_step: int = IsoScript.zoom_step_from_pan_gesture(horizontal) + # Assert + assert_that(up_step).is_equal(-1) + assert_that(down_step).is_equal(1) + assert_that(horiz_step).is_equal(0) + + +func test_zoom_step_from_magnify_gesture_maps_pinch_to_bands() -> void: + # Arrange + var pinch_in := InputEventMagnifyGesture.new() + pinch_in.factor = 0.9 + var pinch_out := InputEventMagnifyGesture.new() + pinch_out.factor = 1.1 + # Act + var in_step: int = IsoScript.zoom_step_from_magnify_gesture(pinch_in) + var out_step: int = IsoScript.zoom_step_from_magnify_gesture(pinch_out) + # Assert + assert_that(in_step).is_equal(1) + assert_that(out_step).is_equal(-1) From 9b8593c145b813a21d539337ec6bf9f9958ac257 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Wed, 27 May 2026 23:22:48 -0400 Subject: [PATCH 09/14] chore: restore CameraStateScript preload removed in trackpad edit. Co-authored-by: Cursor --- client/scripts/isometric_follow_camera.gd | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/scripts/isometric_follow_camera.gd b/client/scripts/isometric_follow_camera.gd index e4f4166..9a0354b 100644 --- a/client/scripts/isometric_follow_camera.gd +++ b/client/scripts/isometric_follow_camera.gd @@ -19,6 +19,8 @@ const PAN_ZOOM_DELTA_THRESHOLD: float = 0.05 ## Magnify factor must exceed 1.0 ± this before one zoom band step (trackpad pinch). const MAGNIFY_ZOOM_FACTOR_EPSILON: float = 0.02 + +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") From 9fa0fbef4ba5ebbfba905d60a1229f90283a8c22 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Wed, 27 May 2026 23:24:30 -0400 Subject: [PATCH 10/14] chore: accumulate trackpad pan delta before camera zoom steps. Mac trackpads emit many pan events per gesture; stepping one band per event jumped across all zoom levels. Accumulate delta with a threshold, cap one step per event, and add a 150 ms cooldown between trackpad steps. Co-authored-by: Cursor --- client/README.md | 2 +- client/scripts/isometric_follow_camera.gd | 92 ++++++++++++++------- client/test/isometric_follow_camera_test.gd | 53 ++++++------ 3 files changed, 88 insertions(+), 59 deletions(-) diff --git a/client/README.md b/client/README.md index ec6fe40..f5036ad 100644 --- a/client/README.md +++ b/client/README.md @@ -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`**. **`isometric_follow_camera.gd`** also maps **macOS trackpad** two-finger scroll ([`InputEventPanGesture`](https://docs.godotengine.org/en/stable/classes/class_inputeventpangesture.html)) and pinch ([`InputEventMagnifyGesture`](https://docs.godotengine.org/en/stable/classes/class_inputeventmagnifygesture.html)) to the same discrete zoom bands. 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. diff --git a/client/scripts/isometric_follow_camera.gd b/client/scripts/isometric_follow_camera.gd index 9a0354b..69e2657 100644 --- a/client/scripts/isometric_follow_camera.gd +++ b/client/scripts/isometric_follow_camera.gd @@ -14,11 +14,14 @@ 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) -## Minimum |delta.y| before a pan gesture advances one zoom band (macOS trackpad). -const PAN_ZOOM_DELTA_THRESHOLD: float = 0.05 +## Accumulated vertical pan before one discrete zoom band step (macOS trackpad). +const PAN_ZOOM_BAND_ACCUMULATOR_THRESHOLD: float = 1.25 -## Magnify factor must exceed 1.0 ± this before one zoom band step (trackpad pinch). -const MAGNIFY_ZOOM_FACTOR_EPSILON: float = 0.02 +## 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") @@ -55,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. @@ -134,13 +140,19 @@ func _exit_tree() -> void: func _unhandled_input(event: InputEvent) -> void: if event.is_echo(): return - var pan_step: int = zoom_step_from_pan_gesture(event) - if pan_step != 0: - _apply_zoom_step(pan_step) + 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 - var magnify_step: int = zoom_step_from_magnify_gesture(event) - if magnify_step != 0: - _apply_zoom_step(magnify_step) + 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) @@ -148,30 +160,46 @@ func _unhandled_input(event: InputEvent) -> void: _apply_zoom_step(1) -## Maps macOS trackpad two-finger scroll to a discrete zoom band step, or **0** when ignored. -static func zoom_step_from_pan_gesture(event: InputEvent) -> int: - if event == null or not event is InputEventPanGesture: - return 0 - var pan: InputEventPanGesture = event as InputEventPanGesture - if absf(pan.delta.y) <= absf(pan.delta.x): - return 0 - if pan.delta.y <= -PAN_ZOOM_DELTA_THRESHOLD: - return -1 - if pan.delta.y >= PAN_ZOOM_DELTA_THRESHOLD: - return 1 - return 0 +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) -## Maps trackpad pinch ([InputEventMagnifyGesture]) to a discrete zoom band step, or **0**. -static func zoom_step_from_magnify_gesture(event: InputEvent) -> int: - if event == null or not event is InputEventMagnifyGesture: - return 0 - var mag: InputEventMagnifyGesture = event as InputEventMagnifyGesture - if mag.factor >= 1.0 + MAGNIFY_ZOOM_FACTOR_EPSILON: - return -1 - if mag.factor <= 1.0 - MAGNIFY_ZOOM_FACTOR_EPSILON: - return 1 - return 0 +## 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). diff --git a/client/test/isometric_follow_camera_test.gd b/client/test/isometric_follow_camera_test.gd index 5007ab7..6d1ca34 100644 --- a/client/test/isometric_follow_camera_test.gd +++ b/client/test/isometric_follow_camera_test.gd @@ -140,42 +140,43 @@ func test_occluder_override_key_is_valid_false_after_free() -> void: assert_that(ok).is_false() -func test_zoom_step_from_pan_gesture_ignores_non_pan() -> void: +func test_consume_pan_zoom_accumulator_ignores_horizontal_pan() -> void: # Arrange - var key := InputEventKey.new() # Act - var step: int = IsoScript.zoom_step_from_pan_gesture(key) + var result: Dictionary = IsoScript.consume_pan_zoom_accumulator(0.0, Vector2(0.2, 0.0)) # Assert - assert_that(step).is_equal(0) + assert_that(int(result.get("step", -9))).is_equal(0) + assert_that(float(result.get("accumulator", -1.0))).is_equal(0.0) -func test_zoom_step_from_pan_gesture_maps_vertical_scroll_to_bands() -> void: +func test_consume_pan_zoom_accumulator_requires_threshold_before_step() -> void: # Arrange - var scroll_up := InputEventPanGesture.new() - scroll_up.delta = Vector2(0.0, -0.2) - var scroll_down := InputEventPanGesture.new() - scroll_down.delta = Vector2(0.0, 0.2) - var horizontal := InputEventPanGesture.new() - horizontal.delta = Vector2(0.2, 0.0) # Act - var up_step: int = IsoScript.zoom_step_from_pan_gesture(scroll_up) - var down_step: int = IsoScript.zoom_step_from_pan_gesture(scroll_down) - var horiz_step: int = IsoScript.zoom_step_from_pan_gesture(horizontal) + 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(up_step).is_equal(-1) - assert_that(down_step).is_equal(1) - assert_that(horiz_step).is_equal(0) + assert_that(int(partial.get("step", -9))).is_equal(0) + assert_that(int(complete.get("step", -9))).is_equal(-1) -func test_zoom_step_from_magnify_gesture_maps_pinch_to_bands() -> void: +func test_consume_pan_zoom_accumulator_emits_one_step_per_call_even_for_large_delta() -> void: # Arrange - var pinch_in := InputEventMagnifyGesture.new() - pinch_in.factor = 0.9 - var pinch_out := InputEventMagnifyGesture.new() - pinch_out.factor = 1.1 # Act - var in_step: int = IsoScript.zoom_step_from_magnify_gesture(pinch_in) - var out_step: int = IsoScript.zoom_step_from_magnify_gesture(pinch_out) + var result: Dictionary = IsoScript.consume_pan_zoom_accumulator(0.0, Vector2(0.0, -5.0)) # Assert - assert_that(in_step).is_equal(1) - assert_that(out_step).is_equal(-1) + 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) From 83d1bb13e4c479010799f6c626bce1231b31eb19 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Wed, 27 May 2026 23:26:17 -0400 Subject: [PATCH 11/14] NEO-93: Reset LastAdvancedUtc on dev fixture runtime clear. Fixture reset cleared NPC rows but left a stale lazy-tick marker, causing catch-up bursts after re-acquire. Reset to MinValue with prototype rows. Co-authored-by: Cursor --- .../Npc/InMemoryNpcRuntimeStateStoreTests.cs | 2 ++ .../Game/Npc/NpcRuntimeOperationsTests.cs | 19 +++++++++++++++++++ .../Game/Npc/InMemoryNpcRuntimeStateStore.cs | 2 ++ 3 files changed, 23 insertions(+) diff --git a/server/NeonSprawl.Server.Tests/Game/Npc/InMemoryNpcRuntimeStateStoreTests.cs b/server/NeonSprawl.Server.Tests/Game/Npc/InMemoryNpcRuntimeStateStoreTests.cs index c54a31a..c4984a4 100644 --- a/server/NeonSprawl.Server.Tests/Game/Npc/InMemoryNpcRuntimeStateStoreTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Npc/InMemoryNpcRuntimeStateStoreTests.cs @@ -78,6 +78,8 @@ public sealed class InMemoryNpcRuntimeStateStoreTests Assert.Equal(NpcBehaviorState.Idle, snapshot.BehaviorState); Assert.Null(snapshot.ActiveTelegraph); } + + Assert.Equal(DateTimeOffset.MinValue, store.LastAdvancedUtc); } [Fact] diff --git a/server/NeonSprawl.Server.Tests/Game/Npc/NpcRuntimeOperationsTests.cs b/server/NeonSprawl.Server.Tests/Game/Npc/NpcRuntimeOperationsTests.cs index eeb1107..430e108 100644 --- a/server/NeonSprawl.Server.Tests/Game/Npc/NpcRuntimeOperationsTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Npc/NpcRuntimeOperationsTests.cs @@ -258,4 +258,23 @@ public sealed class NpcRuntimeOperationsTests 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); + } } diff --git a/server/NeonSprawl.Server/Game/Npc/InMemoryNpcRuntimeStateStore.cs b/server/NeonSprawl.Server/Game/Npc/InMemoryNpcRuntimeStateStore.cs index 6a9d712..7ee4883 100644 --- a/server/NeonSprawl.Server/Game/Npc/InMemoryNpcRuntimeStateStore.cs +++ b/server/NeonSprawl.Server/Game/Npc/InMemoryNpcRuntimeStateStore.cs @@ -69,6 +69,8 @@ public sealed class InMemoryNpcRuntimeStateStore : INpcRuntimeStateStore { _ = TryResetPrototypeRow(npcInstanceId); } + + LastAdvancedUtc = DateTimeOffset.MinValue; } private static string NormalizeNpcInstanceId(string? npcInstanceId) => From a22c90065540e2d7142fde326421858f2fa15cc2 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Wed, 27 May 2026 23:28:37 -0400 Subject: [PATCH 12/14] chore: gdformat isometric_follow_camera.gd for CI. Co-authored-by: Cursor --- client/scripts/isometric_follow_camera.gd | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/client/scripts/isometric_follow_camera.gd b/client/scripts/isometric_follow_camera.gd index 69e2657..6e879e3 100644 --- a/client/scripts/isometric_follow_camera.gd +++ b/client/scripts/isometric_follow_camera.gd @@ -172,9 +172,7 @@ func _try_apply_trackpad_zoom_step(step: int) -> void: ## 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 + accumulator: float, delta: Vector2, threshold: float = PAN_ZOOM_BAND_ACCUMULATOR_THRESHOLD ) -> Dictionary: if absf(delta.y) <= absf(delta.x): return {"accumulator": accumulator, "step": 0} @@ -188,9 +186,7 @@ static func consume_pan_zoom_accumulator( ## 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 + accumulator: float, factor: float, threshold: float = MAGNIFY_ZOOM_LOG_ACCUMULATOR_THRESHOLD ) -> Dictionary: if factor <= 0.0: return {"accumulator": accumulator, "step": 0} From e4f08b20154aff1778e3d59437e72f2ee0761e27 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Wed, 27 May 2026 23:44:13 -0400 Subject: [PATCH 13/14] NEO-93: Preserve LastAdvancedUtc on clock regression and guard zero timings. Do not move the lazy-tick marker backward when nowUtc regresses; reject non-positive catalog durations and break the phase loop when cursor stalls. Co-authored-by: Cursor --- .../Game/Npc/NpcRuntimeOperationsTests.cs | 39 +++++++++++++++++++ .../Game/Npc/NpcRuntimeOperations.cs | 24 +++++++++--- 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/server/NeonSprawl.Server.Tests/Game/Npc/NpcRuntimeOperationsTests.cs b/server/NeonSprawl.Server.Tests/Game/Npc/NpcRuntimeOperationsTests.cs index 430e108..203b098 100644 --- a/server/NeonSprawl.Server.Tests/Game/Npc/NpcRuntimeOperationsTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Npc/NpcRuntimeOperationsTests.cs @@ -277,4 +277,43 @@ public sealed class NpcRuntimeOperationsTests 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); + } } diff --git a/server/NeonSprawl.Server/Game/Npc/NpcRuntimeOperations.cs b/server/NeonSprawl.Server/Game/Npc/NpcRuntimeOperations.cs index 3e142ba..ceaad3f 100644 --- a/server/NeonSprawl.Server/Game/Npc/NpcRuntimeOperations.cs +++ b/server/NeonSprawl.Server/Game/Npc/NpcRuntimeOperations.cs @@ -53,7 +53,6 @@ public static class NpcRuntimeOperations behaviorRegistry); } - runtimeStore.LastAdvancedUtc = nowUtc; return; } @@ -93,6 +92,11 @@ public static class NpcRuntimeOperations return; } + if (!HasPositiveTimingDurations(behavior)) + { + return; + } + if (!runtimeStore.TryGet(npcInstanceId, out var snapshot)) { return; @@ -137,6 +141,7 @@ public static class NpcRuntimeOperations return; } + var loopCursorBefore = cursor; switch (snapshot.BehaviorState) { case NpcBehaviorState.Idle: @@ -159,7 +164,7 @@ public static class NpcRuntimeOperations cursor, telegraph); // telemetry: npc_state_transition (NEO-96) - continue; + break; } case NpcBehaviorState.TelegraphWindup: @@ -178,7 +183,7 @@ public static class NpcRuntimeOperations cursor, activeTelegraph: null); // telemetry: telegraph_fired / npc_state_transition (NEO-96); logical attack_execute → recover (NEO-95 applies attackDamage) - continue; + break; } case NpcBehaviorState.AttackExecute: @@ -189,7 +194,7 @@ public static class NpcRuntimeOperations cursor, activeTelegraph: null); // telemetry: npc_state_transition (NEO-96); NEO-95 applies attackDamage here - continue; + break; case NpcBehaviorState.Recover: { @@ -208,15 +213,24 @@ public static class NpcRuntimeOperations cursor, telegraph); // telemetry: npc_state_transition (NEO-96) - continue; + break; } default: return; } + + if (cursor <= loopCursorBefore) + { + return; + } } } + /// Catalog timings must be positive; CI/content gate enforces for prototype defs. + internal static bool HasPositiveTimingDurations(NpcBehaviorDefRow behavior) => + behavior.AttackCooldownSeconds > 0 && behavior.TelegraphWindupSeconds > 0; + private static bool TryHasAggroHolder( string npcInstanceId, IThreatStateStore threatStore, From c76180d73736f5ac0b9ddecc2a7c3cd3e1675810 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Wed, 27 May 2026 23:44:19 -0400 Subject: [PATCH 14/14] NEO-93: Update plan test count after Bugbot guard tests. Co-authored-by: Cursor --- docs/plans/NEO-93-implementation-plan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plans/NEO-93-implementation-plan.md b/docs/plans/NEO-93-implementation-plan.md index a9bb235..a7ec07f 100644 --- a/docs/plans/NEO-93-implementation-plan.md +++ b/docs/plans/NEO-93-implementation-plan.md @@ -61,7 +61,7 @@ - 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. -- **23** NEO-93-focused tests green (`NpcRuntimeOperationsTests`, `InMemoryNpcRuntimeStateStoreTests`, fixture extension). +- **25** NEO-93-focused tests green (`NpcRuntimeOperationsTests`, `InMemoryNpcRuntimeStateStoreTests`, fixture extension). ## Technical approach