// // Licensed under the MIT License. See LICENSE file in the project root for full license information. // namespace MUnique.OpenMU.Tests.CastleSiege; using MUnique.OpenMU.DataModel.Configuration; using MUnique.OpenMU.GameLogic.CastleSiege; /// /// Tests for the Castle Siege state machine (time-driven, injected clock). The cycle runs through the /// original Season 6 state numbers the client knows: Idle1 -> RegisterGuild -> Ready -> Start -> End /// -> EndCycle -> Idle1. Guilds are identified by their persistent id, not by their (renameable) name. /// [TestFixture] public class CastleSiegeContextTest { private static readonly DateTime T0 = new(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc); private static readonly Guid GuildA = new("11111111-1111-1111-1111-111111111111"); private static readonly Guid GuildB = new("22222222-2222-2222-2222-222222222222"); /// Tests that a fresh context rests in the idle state. [Test] public void StartsInIdle() { var ctx = new CastleSiegeContext(Config()); Assert.That(ctx.State, Is.EqualTo(CastleSiegeState.Idle1)); } /// Tests that force-starting moves the state machine into guild registration. [Test] public async Task ForceStartMovesToRegistrationAsync() { var ctx = new CastleSiegeContext(Config()); await ctx.ForceStartRegistrationAsync(T0); Assert.That(ctx.State, Is.EqualTo(CastleSiegeState.RegisterGuild)); } /// Tests that registration advances to the preparation state once its duration elapses. [Test] public async Task RegistrationAdvancesToReadyAfterDurationAsync() { var ctx = new CastleSiegeContext(Config()); await ctx.ForceStartRegistrationAsync(T0); await ctx.TickAsync(T0.AddMinutes(4)); // still within registration Assert.That(ctx.State, Is.EqualTo(CastleSiegeState.RegisterGuild)); await ctx.TickAsync(T0.AddMinutes(5)); // registration duration elapsed Assert.That(ctx.State, Is.EqualTo(CastleSiegeState.Ready)); } /// Tests a full cycle: register -> ready -> start -> end -> end cycle -> idle. [Test] public async Task FullCycleReturnsToIdleAsync() { var ctx = new CastleSiegeContext(Config()); await ctx.ForceStartRegistrationAsync(T0); await ctx.TickAsync(T0.AddMinutes(5)); // -> Ready await ctx.TickAsync(T0.AddMinutes(7)); // +2 preparation -> Start Assert.That(ctx.State, Is.EqualTo(CastleSiegeState.Start)); await ctx.TickAsync(T0.AddMinutes(17)); // +10 siege -> End await ctx.TickAsync(T0.AddMinutes(17)); // End -> EndCycle await ctx.TickAsync(T0.AddMinutes(17)); // EndCycle -> Idle1 Assert.That(ctx.State, Is.EqualTo(CastleSiegeState.Idle1)); } /// Tests that guilds are collected by id during the registration state. [Test] public async Task RegisterGuildCollectsGuildsDuringRegistrationAsync() { var ctx = new CastleSiegeContext(Config()); await ctx.ForceStartRegistrationAsync(T0); ctx.RegisterGuild(GuildA, "Attackers"); Assert.That(ctx.IsRegistered(GuildA), Is.True); Assert.That(ctx.RegisteredGuildNames, Does.Contain("Attackers")); } /// /// Tests the full siege objective chain: operate both Crown Switches, which drops the crown's shield, /// then hold the crown to take the throne, which becomes the castle ownership when the siege ends. /// [Test] public async Task FullSiegeObjectiveChainToOwnershipAsync() { var ctx = new CastleSiegeContext(Config()); await ctx.ForceStartRegistrationAsync(T0); await ctx.ForceStateAsync(CastleSiegeState.Start, T0); var hold = TimeSpan.FromSeconds(60); // A switch which is still being operated does not count yet. StartSwitch(ctx, 217, GuildA, 1); StartSwitch(ctx, 218, GuildA, 2); Assert.That(ctx.GetShieldEligibleGuild(), Is.Null); CompleteSwitch(ctx, 217); Assert.That(ctx.GetShieldEligibleGuild(), Is.Null, "one completed switch is not enough"); CompleteSwitch(ctx, 218); Assert.That(ctx.GetShieldEligibleGuild(), Is.EqualTo(GuildA)); // The crown only starts counting after the guild master clicked it. Assert.That(ctx.TickCrownHold(GuildA, "Attackers", true, T0, hold).Event, Is.EqualTo(CrownEvent.None)); Assert.That(ctx.RequestCrownHold(GuildA), Is.True); ctx.TickCrownHold(GuildA, "Attackers", true, T0, hold); Assert.That(ctx.TickCrownHold(GuildA, "Attackers", true, T0.AddSeconds(60), hold).Event, Is.EqualTo(CrownEvent.Captured)); Assert.That(ctx.OccupierGuildId, Is.EqualTo(GuildA)); await ctx.TickAsync(T0.AddMinutes(20)); // siege time is up: Start -> End await ctx.TickAsync(T0.AddMinutes(20)); // End hands the castle to the guild on the throne Assert.That(ctx.OwnerGuildId, Is.EqualTo(GuildA)); Assert.That(ctx.OwnerGuildName, Is.EqualTo("Attackers")); } /// Tests that two different guilds each holding one switch keep the crown's shield up. [Test] public async Task ShieldRequiresBothSwitchesBySameGuildAsync() { var ctx = new CastleSiegeContext(Config()); await ctx.ForceStartRegistrationAsync(T0); await ctx.ForceStateAsync(CastleSiegeState.Start, T0); StartSwitch(ctx, 217, GuildA, 1); StartSwitch(ctx, 218, GuildB, 2); CompleteSwitch(ctx, 217); CompleteSwitch(ctx, 218); Assert.That(ctx.GetShieldEligibleGuild(), Is.Null); } /// Tests that the switches don't work at all outside the running siege. [Test] public void SwitchesDoNothingOutsideSiege() { var ctx = new CastleSiegeContext(Config()); var (result, _) = ctx.TryStartSwitchOperation(217, GuildA, "A", 1, "player", 100, T0); Assert.That(result, Is.EqualTo(CastleSiegeSwitchPush.SiegeNotRunning)); Assert.That(ctx.GetSwitchOperation(217), Is.Null); Assert.That(ctx.GetShieldEligibleGuild(), Is.Null); } /// Tests that a switch belongs to the first player who clicked it, until they leave its area. [Test] public async Task SwitchIsTakenByOnePlayerUntilTheyLeaveAsync() { var ctx = new CastleSiegeContext(Config()); await ctx.ForceStartRegistrationAsync(T0); await ctx.ForceStateAsync(CastleSiegeState.Start, T0); var push = TimeSpan.FromSeconds(15); var (first, _) = ctx.TryStartSwitchOperation(217, GuildA, "A", 1, "first", 100, T0); Assert.That(first, Is.EqualTo(CastleSiegeSwitchPush.Started)); // Somebody else clicking it is refused and learns who is on it. var (second, blocking) = ctx.TryStartSwitchOperation(217, GuildB, "B", 2, "second", 100, T0.AddSeconds(1)); Assert.That(second, Is.EqualTo(CastleSiegeSwitchPush.TakenByOther)); Assert.That(blocking?.PlayerId, Is.EqualTo(1)); // It only counts once the push ran its time. Assert.That(ctx.TickSwitch(217, true, T0.AddSeconds(14), push).Event, Is.EqualTo(CastleSiegeSwitchEvent.None)); Assert.That(ctx.GetSwitchOperation(217)!.IsHeld, Is.False); Assert.That(ctx.TickSwitch(217, true, T0.AddSeconds(15), push).Event, Is.EqualTo(CastleSiegeSwitchEvent.Held)); Assert.That(ctx.GetSwitchOperation(217)!.IsHeld, Is.True); // Leaving the area frees it for everybody. var (released, freed) = ctx.TickSwitch(217, false, T0.AddSeconds(20), push); Assert.That(released, Is.EqualTo(CastleSiegeSwitchEvent.Released)); Assert.That(freed?.PlayerId, Is.EqualTo(1)); Assert.That(ctx.GetSwitchOperation(217), Is.Null); var (afterRelease, _) = ctx.TryStartSwitchOperation(217, GuildB, "B", 2, "second", 100, T0.AddSeconds(21)); Assert.That(afterRelease, Is.EqualTo(CastleSiegeSwitchPush.Started)); } /// Tests that losing a switch while the crown is being held drops the guild's eligibility. [Test] public async Task LosingASwitchRaisesTheShieldAgainAsync() { var ctx = await SiegeWithSwitchesHeldAsync(GuildA); Assert.That(ctx.GetShieldEligibleGuild(), Is.EqualTo(GuildA)); ctx.TickSwitch(218, false, T0.AddSeconds(20), TimeSpan.FromSeconds(15)); Assert.That(ctx.GetShieldEligibleGuild(), Is.Null); } /// Tests that restoring persisted state sets state/owner/registrations without raising StateChanged. [Test] public void RestoreStateSetsStateWithoutFiringStateChanged() { var ctx = new CastleSiegeContext(Config()); var stateChangedFired = false; ctx.StateChanged += _ => stateChangedFired = true; ctx.RestoreState( GuildA, "Winners", CastleSiegeState.Idle1, T0, new[] { new KeyValuePair(GuildA, "Winners"), new KeyValuePair(GuildB, "Losers") }); Assert.That(ctx.State, Is.EqualTo(CastleSiegeState.Idle1)); Assert.That(ctx.OwnerGuildId, Is.EqualTo(GuildA)); Assert.That(ctx.OwnerGuildName, Is.EqualTo("Winners")); Assert.That(ctx.RegisteredGuildNames, Is.EquivalentTo(new[] { "Winners", "Losers" })); Assert.That(stateChangedFired, Is.False); Assert.That(ctx.ConsumeDirty(), Is.False, "restore must not mark the state dirty"); } /// Tests that the weekly auto-schedule (stored in the settings) only fires on a matching day/time window. [Test] public void ScheduleFiresOnMatchingDayAndTimeWindow() { var ctx = new CastleSiegeContext(Config()); ctx.SetSchedule(new[] { DayOfWeek.Sunday }, new TimeOnly(20, 0)); var config = ctx.Configuration; var sunday = new DateTime(2026, 1, 4, 20, 0, 2, DateTimeKind.Utc); // a Sunday, +2s into the window var sundayLate = new DateTime(2026, 1, 4, 20, 0, 30, DateTimeKind.Utc); // past the window var monday = new DateTime(2026, 1, 5, 20, 0, 2, DateTimeKind.Utc); // wrong day Assert.That(config.IsRegistrationOpenTime(sunday), Is.True); Assert.That(config.IsRegistrationOpenTime(sundayLate), Is.False); Assert.That(config.IsRegistrationOpenTime(monday), Is.False); Assert.That(ctx.ConsumeDirty(), Is.True, "SetSchedule marks the state dirty"); ctx.SetSchedule(Array.Empty(), null); // cleared -> never opens Assert.That(config.IsRegistrationOpenTime(sunday), Is.False); } /// 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); // state transition -> dirty Assert.That(ctx.ConsumeDirty(), Is.True); Assert.That(ctx.ConsumeDirty(), Is.False, "ConsumeDirty resets the flag"); ctx.RegisterGuild(GuildA, "Attackers"); // registration -> dirty Assert.That(ctx.ConsumeDirty(), Is.True); ctx.SetOwner(GuildA, "Attackers"); // owner change -> dirty Assert.That(ctx.ConsumeDirty(), Is.True); } /// Tests that the remaining siege time counts down during the siege and is zero otherwise. [Test] public async Task RemainingSiegeTimeReflectsSiegeStateAsync() { var ctx = new CastleSiegeContext(Config()); // 10 minute siege duration Assert.That(ctx.GetRemainingSiegeTime(T0), Is.EqualTo(TimeSpan.Zero), "no siege running -> zero"); await ctx.ForceStartRegistrationAsync(T0); await ctx.ForceStateAsync(CastleSiegeState.Start, T0); Assert.That(ctx.GetRemainingSiegeTime(T0.AddMinutes(3)), Is.EqualTo(TimeSpan.FromMinutes(7))); Assert.That(ctx.GetRemainingSiegeTime(T0.AddMinutes(15)), Is.EqualTo(TimeSpan.Zero), "past the end -> clamped to zero"); } /// Tests that a guild master holding the crown (both switches held, defenses down) captures after the hold duration. [Test] public async Task CrownHoldCapturesAfterHoldDurationAsync() { var ctx = await SiegeWithSwitchesHeldAsync(GuildA); var hold = TimeSpan.FromSeconds(60); Assert.That(ctx.GetShieldEligibleGuild(), Is.EqualTo(GuildA)); Assert.That(ctx.RequestCrownHold(GuildA), Is.True); Assert.That(ctx.TickCrownHold(GuildA, "Attackers", true, T0, hold).Event, Is.EqualTo(CrownEvent.HoldStarted)); Assert.That(ctx.TickCrownHold(GuildA, "Attackers", true, T0.AddSeconds(30), hold).Event, Is.EqualTo(CrownEvent.None)); Assert.That(ctx.OccupierGuildId, Is.Null); var captured = ctx.TickCrownHold(GuildA, "Attackers", true, T0.AddSeconds(60), hold); Assert.That(captured.Event, Is.EqualTo(CrownEvent.Captured)); Assert.That(captured.ShieldDown, Is.True); Assert.That(ctx.OccupierGuildId, Is.EqualTo(GuildA)); } /// Tests that losing a switch mid-hold resets the crown-hold progress (contestable). [Test] public async Task CrownHoldResetsWhenSwitchLostAsync() { var ctx = await SiegeWithSwitchesHeldAsync(GuildA); var hold = TimeSpan.FromSeconds(60); Assert.That(ctx.RequestCrownHold(GuildA), Is.True); Assert.That(ctx.TickCrownHold(GuildA, "A", true, T0, hold).Event, Is.EqualTo(CrownEvent.HoldStarted)); ctx.TickSwitch(218, false, T0.AddSeconds(5), TimeSpan.FromSeconds(15)); // lost a switch -> no longer eligible var reset = ctx.TickCrownHold(ctx.GetShieldEligibleGuild(), null, false, T0.AddSeconds(10), hold); Assert.That(reset.ShieldDown, Is.False); Assert.That(reset.Event, Is.EqualTo(CrownEvent.HoldReset)); Assert.That(ctx.OccupierGuildId, Is.Null); } /// Tests that the occupier can't re-capture its own throne, but a different guild can contest it. [Test] public async Task OccupierDoesNotRecaptureButAnotherGuildCanContestAsync() { var ctx = await SiegeWithSwitchesHeldAsync(GuildA); var hold = TimeSpan.FromSeconds(60); var push = TimeSpan.FromSeconds(15); ctx.RequestCrownHold(GuildA); ctx.TickCrownHold(GuildA, "A", true, T0, hold); Assert.That(ctx.TickCrownHold(GuildA, "A", true, T0.AddSeconds(60), hold).Event, Is.EqualTo(CrownEvent.Captured)); Assert.That(ctx.OccupierGuildId, Is.EqualTo(GuildA)); // A keeps holding - no re-registration loop, but the shield stays down (they hold it). Assert.That(ctx.RequestCrownHold(GuildA), Is.False, "the occupier cannot re-register its own throne"); var after = ctx.TickCrownHold(GuildA, "A", true, T0.AddSeconds(61), hold); Assert.That(after.Event, Is.EqualTo(CrownEvent.None)); Assert.That(after.ShieldDown, Is.True); // B takes both switches and can contest/capture. ctx.TickSwitch(217, false, T0.AddSeconds(61), push); ctx.TickSwitch(218, false, T0.AddSeconds(61), push); StartSwitch(ctx, 217, GuildB, 3); StartSwitch(ctx, 218, GuildB, 4); CompleteSwitch(ctx, 217); CompleteSwitch(ctx, 218); Assert.That(ctx.RequestCrownHold(GuildB), Is.True); Assert.That(ctx.TickCrownHold(GuildB, "B", true, T0.AddSeconds(62), hold).Event, Is.EqualTo(CrownEvent.HoldStarted)); Assert.That(ctx.TickCrownHold(GuildB, "B", true, T0.AddSeconds(122), hold).Event, Is.EqualTo(CrownEvent.Captured)); Assert.That(ctx.OccupierGuildId, Is.EqualTo(GuildB)); } private static async Task SiegeWithSwitchesHeldAsync(Guid guildId) { var ctx = new CastleSiegeContext(Config()); await ctx.ForceStartRegistrationAsync(T0); await ctx.ForceStateAsync(CastleSiegeState.Start, T0); StartSwitch(ctx, 217, guildId, 1); StartSwitch(ctx, 218, guildId, 2); CompleteSwitch(ctx, 217); CompleteSwitch(ctx, 218); return ctx; } private static void StartSwitch(CastleSiegeContext ctx, short switchNumber, Guid guildId, ushort playerId) => ctx.TryStartSwitchOperation(switchNumber, guildId, guildId.ToString()[..4], playerId, $"p{playerId}", (ushort)switchNumber, T0); private static void CompleteSwitch(CastleSiegeContext ctx, short switchNumber) => ctx.TickSwitch(switchNumber, true, T0.AddSeconds(30), TimeSpan.FromSeconds(15)); private static CastleSiegeSettings Config() => new() { RegistrationDuration = TimeSpan.FromMinutes(5), PreparationDuration = TimeSpan.FromMinutes(2), SiegeDuration = TimeSpan.FromMinutes(10), }; }