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 <cursoragent@cursor.com>
pull/131/head
VinPropane 2026-05-27 23:44:13 -04:00
parent a22c900655
commit e4f08b2015
2 changed files with 58 additions and 5 deletions

View File

@ -277,4 +277,43 @@ public sealed class NpcRuntimeOperationsTests
Assert.Equal(T0.AddHours(1).AddSeconds(10), snapshot.PhaseStartedUtc); Assert.Equal(T0.AddHours(1).AddSeconds(10), snapshot.PhaseStartedUtc);
Assert.Equal(T0.AddHours(1).AddSeconds(10), runtime.LastAdvancedUtc); Assert.Equal(T0.AddHours(1).AddSeconds(10), runtime.LastAdvancedUtc);
} }
[Fact]
public void AdvanceAll_ShouldPreserveLastAdvancedUtc_WhenClockRegresses()
{
// Arrange
var (runtime, threat, behavior) = CreateFixture();
Advance(T0, runtime, threat, behavior);
runtime.LastAdvancedUtc = T0.AddSeconds(10);
// Act
Advance(T0.AddSeconds(5), runtime, threat, behavior);
// Assert
Assert.Equal(T0.AddSeconds(10), runtime.LastAdvancedUtc);
}
[Fact]
public void HasPositiveTimingDurations_ShouldRejectNonPositiveCatalogTimings()
{
// Arrange
var invalidCooldown = new NpcBehaviorDefRow(
"bad",
"Bad",
"melee_pressure",
100,
8.0,
16.0,
1.5,
15,
0.0);
var invalidWindup = invalidCooldown with { Id = "bad2", TelegraphWindupSeconds = 0.0 };
var valid = invalidCooldown with { AttackCooldownSeconds = 3.0, TelegraphWindupSeconds = 1.5 };
// Act
var cooldownOk = NpcRuntimeOperations.HasPositiveTimingDurations(invalidCooldown);
var windupOk = NpcRuntimeOperations.HasPositiveTimingDurations(invalidWindup);
var validOk = NpcRuntimeOperations.HasPositiveTimingDurations(valid);
// Assert
Assert.False(cooldownOk);
Assert.False(windupOk);
Assert.True(validOk);
}
} }

View File

@ -53,7 +53,6 @@ public static class NpcRuntimeOperations
behaviorRegistry); behaviorRegistry);
} }
runtimeStore.LastAdvancedUtc = nowUtc;
return; return;
} }
@ -93,6 +92,11 @@ public static class NpcRuntimeOperations
return; return;
} }
if (!HasPositiveTimingDurations(behavior))
{
return;
}
if (!runtimeStore.TryGet(npcInstanceId, out var snapshot)) if (!runtimeStore.TryGet(npcInstanceId, out var snapshot))
{ {
return; return;
@ -137,6 +141,7 @@ public static class NpcRuntimeOperations
return; return;
} }
var loopCursorBefore = cursor;
switch (snapshot.BehaviorState) switch (snapshot.BehaviorState)
{ {
case NpcBehaviorState.Idle: case NpcBehaviorState.Idle:
@ -159,7 +164,7 @@ public static class NpcRuntimeOperations
cursor, cursor,
telegraph); telegraph);
// telemetry: npc_state_transition (NEO-96) // telemetry: npc_state_transition (NEO-96)
continue; break;
} }
case NpcBehaviorState.TelegraphWindup: case NpcBehaviorState.TelegraphWindup:
@ -178,7 +183,7 @@ public static class NpcRuntimeOperations
cursor, cursor,
activeTelegraph: null); activeTelegraph: null);
// telemetry: telegraph_fired / npc_state_transition (NEO-96); logical attack_execute → recover (NEO-95 applies attackDamage) // telemetry: telegraph_fired / npc_state_transition (NEO-96); logical attack_execute → recover (NEO-95 applies attackDamage)
continue; break;
} }
case NpcBehaviorState.AttackExecute: case NpcBehaviorState.AttackExecute:
@ -189,7 +194,7 @@ public static class NpcRuntimeOperations
cursor, cursor,
activeTelegraph: null); activeTelegraph: null);
// telemetry: npc_state_transition (NEO-96); NEO-95 applies attackDamage here // telemetry: npc_state_transition (NEO-96); NEO-95 applies attackDamage here
continue; break;
case NpcBehaviorState.Recover: case NpcBehaviorState.Recover:
{ {
@ -208,15 +213,24 @@ public static class NpcRuntimeOperations
cursor, cursor,
telegraph); telegraph);
// telemetry: npc_state_transition (NEO-96) // telemetry: npc_state_transition (NEO-96)
continue; break;
} }
default: default:
return; return;
} }
if (cursor <= loopCursorBefore)
{
return;
}
} }
} }
/// <summary>Catalog timings must be positive; CI/content gate enforces for prototype defs.</summary>
internal static bool HasPositiveTimingDurations(NpcBehaviorDefRow behavior) =>
behavior.AttackCooldownSeconds > 0 && behavior.TelegraphWindupSeconds > 0;
private static bool TryHasAggroHolder( private static bool TryHasAggroHolder(
string npcInstanceId, string npcInstanceId,
IThreatStateStore threatStore, IThreatStateStore threatStore,