using NeonSprawl.Server.Game.Combat; 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, InMemoryCombatEntityHealthStore CombatHealth, IPlayerCombatHealthStore PlayerHealth) CreateFixture() { return ( new InMemoryNpcRuntimeStateStore(), new InMemoryThreatStateStore(), PrototypeNpcTestFixtures.CreateBehaviorRegistry(), PrototypeNpcTestFixtures.CreateHealthStore(), new InMemoryPlayerCombatHealthStore()); } 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, InMemoryCombatEntityHealthStore combatHealthStore, IPlayerCombatHealthStore playerHealthStore, double maxDeltaSeconds = NpcRuntimeOperations.DefaultMaxDeltaSeconds) => NpcRuntimeOperations.AdvanceAll( nowUtc, runtimeStore, threatStore, behaviorRegistry, combatHealthStore, playerHealthStore, maxDeltaSeconds); [Fact] public void AdvanceAll_ShouldKeepIdle_WhenNoAggroHolder() { // Arrange var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture(); // Act Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); // Assert runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); Assert.Equal(NpcBehaviorState.Idle, snapshot.BehaviorState); Assert.Null(snapshot.ActiveTelegraph); } [Fact] public void AdvanceAll_ShouldEnterAggro_WhenHolderSetAndRowIdle() { // Arrange var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture(); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); // Act Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); // Assert runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); 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, combatHealth, playerHealth) = CreateFixture(); runtime.LastAdvancedUtc = T0; // Act Advance(T0.AddSeconds(10), runtime, threat, behavior, combatHealth, playerHealth); // Assert runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); Assert.Equal(NpcBehaviorState.Idle, snapshot.BehaviorState); Assert.Null(snapshot.ActiveTelegraph); } [Fact] public void AdvanceAll_ShouldEnterTelegraphAfterMeleeAttackCooldownSeconds() { // Arrange var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture(); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); // Act Advance(T0.AddSeconds(3), runtime, threat, behavior, combatHealth, playerHealth); // 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); } [Fact] public void AdvanceAll_ShouldRemainAggro_BeforeMeleeAttackCooldownCompletes() { // Arrange var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture(); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); // Act Advance(T0.AddSeconds(2.9), runtime, threat, behavior, combatHealth, playerHealth); // Assert runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); Assert.Equal(NpcBehaviorState.Aggro, snapshot.BehaviorState); } [Fact] public void AdvanceAll_ShouldRemainTelegraphWindup_BeforeMeleeWindupCompletes() { // Arrange var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture(); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); Advance(T0.AddSeconds(3), runtime, threat, behavior, combatHealth, playerHealth); // Act Advance(T0.AddSeconds(4.4), runtime, threat, behavior, combatHealth, playerHealth); // Assert runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot); Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState); } [Fact] public void AdvanceAll_ShouldEnterRecover_WhenMeleeWindupCompletes() { // Arrange var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture(); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); Advance(T0.AddSeconds(3), runtime, threat, behavior, combatHealth, playerHealth); Advance(T0.AddSeconds(4.4), runtime, threat, behavior, combatHealth, playerHealth); // Act Advance(T0.AddSeconds(4.5), runtime, threat, behavior, combatHealth, playerHealth); // 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] public void AdvanceAll_ShouldRunFullMeleeCycle_ToSecondTelegraph() { // Arrange var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture(); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); Advance(T0.AddSeconds(3), runtime, threat, behavior, combatHealth, playerHealth); Advance(T0.AddSeconds(4.5), runtime, threat, behavior, combatHealth, playerHealth); // Act Advance(T0.AddSeconds(7.5), runtime, threat, behavior, combatHealth, playerHealth); // 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, combatHealth, playerHealth) = CreateFixture(); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); // Act Advance(T0.AddSeconds(7.5), runtime, threat, behavior, combatHealth, playerHealth, 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); } [Fact] public void AdvanceAll_ShouldReturnToIdle_WhenHolderClearedMidWindup() { // Arrange var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture(); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); Advance(T0.AddSeconds(3), runtime, threat, behavior, combatHealth, playerHealth); _ = threat.TryClearHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId); // Act Advance(T0.AddSeconds(3.5), runtime, threat, behavior, combatHealth, playerHealth); // 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, combatHealth, playerHealth) = CreateFixture(); SetHolder(threat, npcInstanceId); Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); // Act Advance(T0.AddSeconds(attackCooldownSeconds), runtime, threat, behavior, combatHealth, playerHealth); // 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_ShouldEnterRecover_AfterCatalogWindupForArchetype( string npcInstanceId, double attackCooldownSeconds, double telegraphWindupSeconds) { // Arrange var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture(); SetHolder(threat, npcInstanceId); Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); Advance(T0.AddSeconds(attackCooldownSeconds), runtime, threat, behavior, combatHealth, playerHealth); // Act Advance(T0.AddSeconds(attackCooldownSeconds + telegraphWindupSeconds), runtime, threat, behavior, combatHealth, playerHealth); // Assert runtime.TryGet(npcInstanceId, out var snapshot); Assert.Equal(NpcBehaviorState.Recover, snapshot.BehaviorState); Assert.Equal( T0.AddSeconds(attackCooldownSeconds + telegraphWindupSeconds), snapshot.PhaseStartedUtc); } [Fact] public void AdvanceAll_ShouldCapSimulatedDelta_PerAdvanceCall() { // Arrange var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture(); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); // Act Advance(T0.AddSeconds(10), runtime, threat, behavior, combatHealth, playerHealth, maxDeltaSeconds: 5); // Assert 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, combatHealth, playerHealth) = CreateFixture(); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); Advance(T0.AddSeconds(100), runtime, threat, behavior, combatHealth, playerHealth, maxDeltaSeconds: 5); // Act Advance(T0.AddSeconds(100.5), runtime, threat, behavior, combatHealth, playerHealth, 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); } [Fact] public void ResetAllPrototypeRows_ShouldClearLastAdvancedUtc_ForFreshAdvanceBaseline() { // Arrange var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture(); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); runtime.LastAdvancedUtc = T0.AddHours(1); NpcRuntimeOperations.ResetAllPrototypeRows(runtime); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); // Act Advance(T0.AddHours(1).AddSeconds(10), runtime, threat, behavior, combatHealth, playerHealth, 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); } [Fact] public void AdvanceAll_ShouldPreserveLastAdvancedUtc_WhenClockRegresses() { // Arrange var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture(); Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); runtime.LastAdvancedUtc = T0.AddSeconds(10); // Act Advance(T0.AddSeconds(5), runtime, threat, behavior, combatHealth, playerHealth); // Assert Assert.Equal(T0.AddSeconds(10), runtime.LastAdvancedUtc); } [Fact] public void AdvanceAll_ShouldApplyPlayerDamage_WhenMeleeWindupCompletes() { // Arrange var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture(); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); Advance(T0.AddSeconds(3), runtime, threat, behavior, combatHealth, playerHealth); Advance(T0.AddSeconds(4.4), runtime, threat, behavior, combatHealth, playerHealth); // Act Advance(T0.AddSeconds(4.5), runtime, threat, behavior, combatHealth, playerHealth); // Assert playerHealth.TryGet("dev-local-1", out var snapshot); Assert.Equal(85, snapshot.CurrentHp); } [Fact] public void AdvanceAll_ShouldNotApplyPlayerDamage_WhenHolderClearedBeforeWindupCompletes() { // Arrange var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture(); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); Advance(T0.AddSeconds(3), runtime, threat, behavior, combatHealth, playerHealth); _ = threat.TryClearHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId); // Act Advance(T0.AddSeconds(4.5), runtime, threat, behavior, combatHealth, playerHealth); // Assert playerHealth.TryGet("dev-local-1", out var snapshot); Assert.Equal(PlayerCombatHealthDefaults.MaxHp, snapshot.CurrentHp); runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var runtimeSnapshot); Assert.Equal(NpcBehaviorState.Idle, runtimeSnapshot.BehaviorState); } [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); } [Fact] public void TryStopOnTargetDefeat_ShouldClearAggroAndIdleRuntime() { // Arrange var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture(); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); Advance(T0.AddSeconds(3), runtime, threat, behavior, combatHealth, playerHealth); _ = combatHealth.TryApplyDamage(PrototypeNpcRegistry.PrototypeNpcMeleeId, 100, out _); // Act NpcRuntimeOperations.TryStopOnTargetDefeat( PrototypeNpcRegistry.PrototypeNpcMeleeId, combatHealth, threat, runtime); // Assert threat.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var threatRow); Assert.Null(threatRow.AggroHolderPlayerId); runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var runtimeRow); Assert.Equal(NpcBehaviorState.Idle, runtimeRow.BehaviorState); Assert.Null(runtimeRow.ActiveTelegraph); } [Fact] public void AdvanceAll_ShouldNotApplyPlayerDamage_WhenNpcTargetDefeated() { // Arrange var (runtime, threat, behavior, combatHealth, playerHealth) = CreateFixture(); SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId); Advance(T0, runtime, threat, behavior, combatHealth, playerHealth); Advance(T0.AddSeconds(3), runtime, threat, behavior, combatHealth, playerHealth); _ = combatHealth.TryApplyDamage(PrototypeNpcRegistry.PrototypeNpcMeleeId, 100, out _); NpcRuntimeOperations.TryStopOnTargetDefeat( PrototypeNpcRegistry.PrototypeNpcMeleeId, combatHealth, threat, runtime); // Act Advance(T0.AddSeconds(30), runtime, threat, behavior, combatHealth, playerHealth); // Assert playerHealth.TryGet("dev-local-1", out var snapshot); Assert.Equal(PlayerCombatHealthDefaults.MaxHp, snapshot.CurrentHp); } }