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); } }