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