// // Licensed under the MIT License. See LICENSE file in the project root for full license information. // namespace MUnique.OpenMU.Tests.CastleSiege; using MUnique.OpenMU.GameLogic.CastleSiege; /// /// Tests for the Castle Siege phase state machine (time-driven, injected clock). /// [TestFixture] public class CastleSiegeContextTest { private static readonly DateTime T0 = new(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc); /// Tests that a fresh context starts in the ownership (resting) phase. [Test] public void StartsInOwnership() { var ctx = new CastleSiegeContext(Config()); Assert.That(ctx.Phase, Is.EqualTo(CastleSiegePhase.Ownership)); } /// Tests that force-starting moves the state machine into registration. [Test] public async Task ForceStartMovesToRegistrationAsync() { var ctx = new CastleSiegeContext(Config()); await ctx.ForceStartRegistrationAsync(T0); Assert.That(ctx.Phase, Is.EqualTo(CastleSiegePhase.Registration)); } /// Tests that registration advances to preparation once its duration elapses. [Test] public async Task RegistrationAdvancesToPreparationAfterDurationAsync() { var ctx = new CastleSiegeContext(Config()); await ctx.ForceStartRegistrationAsync(T0); await ctx.TickAsync(T0.AddMinutes(4)); // still within registration Assert.That(ctx.Phase, Is.EqualTo(CastleSiegePhase.Registration)); await ctx.TickAsync(T0.AddMinutes(5)); // registration duration elapsed Assert.That(ctx.Phase, Is.EqualTo(CastleSiegePhase.Preparation)); } /// Tests a full cycle: registration -> preparation -> siege -> settlement -> ownership. [Test] public async Task FullCycleReturnsToOwnershipAsync() { var ctx = new CastleSiegeContext(Config()); await ctx.ForceStartRegistrationAsync(T0); await ctx.TickAsync(T0.AddMinutes(5)); // -> Preparation await ctx.TickAsync(T0.AddMinutes(7)); // +2 prep -> Siege Assert.That(ctx.Phase, Is.EqualTo(CastleSiegePhase.Siege)); await ctx.TickAsync(T0.AddMinutes(17)); // +10 siege -> Settlement await ctx.TickAsync(T0.AddMinutes(17)); // Settlement -> Ownership Assert.That(ctx.Phase, Is.EqualTo(CastleSiegePhase.Ownership)); } /// Tests that guilds can be registered (by name) during the registration phase. [Test] public async Task RegisterGuildCollectsNamesDuringRegistrationAsync() { var ctx = new CastleSiegeContext(Config()); await ctx.ForceStartRegistrationAsync(T0); ctx.RegisterGuild("Attackers"); Assert.That(ctx.RegisteredGuilds, Does.Contain("Attackers")); } /// Tests the full siege objective chain: destroy defenses, then hold both switches to capture the throne. [Test] public async Task FullSiegeObjectiveChainToOwnershipAsync() { var ctx = new CastleSiegeContext(Config()); await ctx.ForceStartRegistrationAsync(T0); await ctx.ForcePhaseAsync(CastleSiegePhase.Siege, T0); ctx.SetDefenseCount(2); // Both switches held but defenses still up -> no capture. ctx.SetSwitchHolder(217, "Attackers"); ctx.SetSwitchHolder(218, "Attackers"); Assert.That(ctx.TryCaptureThrone("Attackers").Success, Is.False); ctx.NotifyDefenseDestroyed(); ctx.NotifyDefenseDestroyed(); // Only one switch held -> still no capture. ctx.SetSwitchHolder(218, null); Assert.That(ctx.TryCaptureThrone("Attackers").Success, Is.False); // Both switches held by the same guild + defenses down -> capture at the Sinior/Crown. ctx.SetSwitchHolder(218, "Attackers"); Assert.That(ctx.TryCaptureThrone("Attackers").Success, Is.True); Assert.That(ctx.OccupierGuildName, Is.EqualTo("Attackers")); await ctx.TickAsync(T0.AddMinutes(20)); // Siege -> Settlement await ctx.TickAsync(T0.AddMinutes(20)); // Settlement -> Ownership Assert.That(ctx.OwnerGuildName, Is.EqualTo("Attackers")); } /// Tests that two different guilds each holding one switch cannot capture the throne. [Test] public async Task ThroneRequiresBothSwitchesBySameGuildAsync() { var ctx = new CastleSiegeContext(Config()); await ctx.ForceStartRegistrationAsync(T0); await ctx.ForcePhaseAsync(CastleSiegePhase.Siege, T0); ctx.SetDefenseCount(0); ctx.SetSwitchHolder(217, "A"); ctx.SetSwitchHolder(218, "B"); Assert.That(ctx.TryCaptureThrone("A").Success, Is.False); Assert.That(ctx.TryCaptureThrone("B").Success, Is.False); } /// Tests that capturing the throne outside the siege phase is a no-op. [Test] public void ThroneCaptureOutsideSiegeIsNoOp() { var ctx = new CastleSiegeContext(Config()); ctx.SetSwitchHolder(217, "A"); ctx.SetSwitchHolder(218, "A"); Assert.That(ctx.TryCaptureThrone("A").Success, Is.False); Assert.That(ctx.OccupierGuildName, Is.Null); } /// Tests that restoring persisted state sets phase/owner/registrations without raising PhaseChanged. [Test] public void RestoreStateSetsStateWithoutFiringPhaseChanged() { var ctx = new CastleSiegeContext(Config()); var phaseChangedFired = false; ctx.PhaseChanged += _ => phaseChangedFired = true; ctx.RestoreState("Winners", CastleSiegePhase.Ownership, T0, new[] { "Winners", "Losers" }); Assert.That(ctx.Phase, Is.EqualTo(CastleSiegePhase.Ownership)); Assert.That(ctx.OwnerGuildName, Is.EqualTo("Winners")); Assert.That(ctx.RegisteredGuilds, Is.EquivalentTo(new[] { "Winners", "Losers" })); Assert.That(phaseChangedFired, Is.False); Assert.That(ctx.ConsumeDirty(), Is.False, "restore must not mark the state dirty"); } /// Tests that state-changing operations flip the dirty flag, which ConsumeDirty reads-and-resets. [Test] public async Task DirtyFlagTracksPersistableChangesAsync() { var ctx = new CastleSiegeContext(Config()); Assert.That(ctx.ConsumeDirty(), Is.False, "a fresh context has nothing to persist"); await ctx.ForceStartRegistrationAsync(T0); // phase transition -> dirty Assert.That(ctx.ConsumeDirty(), Is.True); Assert.That(ctx.ConsumeDirty(), Is.False, "ConsumeDirty resets the flag"); ctx.RegisterGuild("Attackers"); // registration -> dirty Assert.That(ctx.ConsumeDirty(), Is.True); ctx.SetOwner("Attackers"); // owner change -> dirty Assert.That(ctx.ConsumeDirty(), Is.True); } private static CastleSiegeConfiguration Config() => new() { RegistrationDuration = TimeSpan.FromMinutes(5), PreparationDuration = TimeSpan.FromMinutes(2), SiegeDuration = TimeSpan.FromMinutes(10), }; }