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), 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);
}
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;
}
}
}
/// <summary>Catalog timings must be positive; CI/content gate enforces for prototype defs.</summary>
internal static bool HasPositiveTimingDurations(NpcBehaviorDefRow behavior) =>
behavior.AttackCooldownSeconds > 0 && behavior.TelegraphWindupSeconds > 0;
private static bool TryHasAggroHolder(
string npcInstanceId,
IThreatStateStore threatStore,