neon-sprawl/server/NeonSprawl.Server.Tests/Game/Npc/NpcRuntimeOperationsTests.cs

410 lines
18 KiB
C#

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