From e4f08b20154aff1778e3d59437e72f2ee0761e27 Mon Sep 17 00:00:00 2001 From: VinPropane Date: Wed, 27 May 2026 23:44:13 -0400 Subject: [PATCH] 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,