feat(castle-siege): operate the Crown Switches by clicking, capture the crown by holding it
Some checks failed
.NET Core / build (push) Has been cancelled

The switches used to be held by simply standing near them, and the crown captured
by standing near it - clicking a switch only produced the client's "not implemented
yet" message. This drives both from the original interaction instead:

- Clicking a Crown Switch starts an operation which completes after
  CastleSiegeSettings.SwitchPushSeconds (15) and keeps the switch for the guild
  until its operator leaves the switch's area. One player per switch; anybody else
  clicking it is told another team is on it (C1 B2 14 state 2).
- While one guild holds both switches the crown's shield drops for it, and its
  guild master captures the throne by CLICKING the crown and holding it for
  CrownHoldTimeSeconds - seeded to 60 now, to match the countdown the client's
  registration panel hardcodes. The throne stays contestable until the siege ends.
- The shield now depends on the switches alone, as in the original; the gates and
  statues remain what they always were, the obstacle in the way.

The switch info packet (C1 B2 20) is broadcast before any switch-state packet
because the client's "switch released" handler reads its switch table without
checking that it exists - that table is only allocated when the info packet
arrives, so the wrong order crashes the client.

Also fixed while in here:
- The crown registration panel could never be closed: the cancel was only sent
  while the master still stood on the crown, which is precisely when the hold does
  NOT break. The panel is now closed for the player it was opened for.
- A contested switch was decided by enumeration order.
- Panels opened by the siege are closed when it ends.
- TryCaptureThrone was dead code carrying a second, diverged rule set.
- /csphase advertised the pre-refactor phase names to the client.
- The periodic broadcasts keyed off "UtcNow.Second % n", which silently skips when
  a tick runs late; they count ticks now.

The unit tests never compiled against the refactored model - they are migrated to
the state machine and guild ids, and cover the new switch and crown rules. A new
test proves update 105 writes the configuration into an existing database.

ApplyPendingUpdatesTool applies pending configuration updates without the admin
panel; it is [Explicit], so it never runs in a normal test pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Acentech Dev
2026-08-04 21:44:12 +03:00
parent af46499279
commit f8e856c7c6
16 changed files with 893 additions and 206 deletions

View File

@@ -4,143 +4,208 @@
namespace MUnique.OpenMU.Tests.CastleSiege;
using MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.GameLogic.CastleSiege;
/// <summary>
/// Tests for the Castle Siege phase state machine (time-driven, injected clock).
/// 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.
/// </summary>
[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");
/// <summary>Tests that a fresh context starts in the ownership (resting) phase.</summary>
/// <summary>Tests that a fresh context rests in the idle state.</summary>
[Test]
public void StartsInOwnership()
public void StartsInIdle()
{
var ctx = new CastleSiegeContext(Config());
Assert.That(ctx.Phase, Is.EqualTo(CastleSiegePhase.Ownership));
Assert.That(ctx.State, Is.EqualTo(CastleSiegeState.Idle1));
}
/// <summary>Tests that force-starting moves the state machine into registration.</summary>
/// <summary>Tests that force-starting moves the state machine into guild registration.</summary>
[Test]
public async Task ForceStartMovesToRegistrationAsync()
{
var ctx = new CastleSiegeContext(Config());
await ctx.ForceStartRegistrationAsync(T0);
Assert.That(ctx.Phase, Is.EqualTo(CastleSiegePhase.Registration));
Assert.That(ctx.State, Is.EqualTo(CastleSiegeState.RegisterGuild));
}
/// <summary>Tests that registration advances to preparation once its duration elapses.</summary>
/// <summary>Tests that registration advances to the preparation state once its duration elapses.</summary>
[Test]
public async Task RegistrationAdvancesToPreparationAfterDurationAsync()
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.Phase, Is.EqualTo(CastleSiegePhase.Registration));
Assert.That(ctx.State, Is.EqualTo(CastleSiegeState.RegisterGuild));
await ctx.TickAsync(T0.AddMinutes(5)); // registration duration elapsed
Assert.That(ctx.Phase, Is.EqualTo(CastleSiegePhase.Preparation));
Assert.That(ctx.State, Is.EqualTo(CastleSiegeState.Ready));
}
/// <summary>Tests a full cycle: registration -> preparation -> siege -> settlement -> ownership.</summary>
/// <summary>Tests a full cycle: register -> ready -> start -> end -> end cycle -> idle.</summary>
[Test]
public async Task FullCycleReturnsToOwnershipAsync()
public async Task FullCycleReturnsToIdleAsync()
{
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));
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));
}
/// <summary>Tests that guilds can be registered (by name) during the registration phase.</summary>
/// <summary>Tests that guilds are collected by id during the registration state.</summary>
[Test]
public async Task RegisterGuildCollectsNamesDuringRegistrationAsync()
public async Task RegisterGuildCollectsGuildsDuringRegistrationAsync()
{
var ctx = new CastleSiegeContext(Config());
await ctx.ForceStartRegistrationAsync(T0);
ctx.RegisterGuild("Attackers");
Assert.That(ctx.RegisteredGuilds, Does.Contain("Attackers"));
ctx.RegisterGuild(GuildA, "Attackers");
Assert.That(ctx.IsRegistered(GuildA), Is.True);
Assert.That(ctx.RegisteredGuildNames, Does.Contain("Attackers"));
}
/// <summary>Tests the full siege objective chain: destroy defenses, then hold both switches to capture the throne.</summary>
/// <summary>
/// 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.
/// </summary>
[Test]
public async Task FullSiegeObjectiveChainToOwnershipAsync()
{
var ctx = new CastleSiegeContext(Config());
await ctx.ForceStartRegistrationAsync(T0);
await ctx.ForcePhaseAsync(CastleSiegePhase.Siege, T0);
ctx.SetDefenseCount(2);
await ctx.ForceStateAsync(CastleSiegeState.Start, T0);
var hold = TimeSpan.FromSeconds(60);
// 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);
// 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);
ctx.NotifyDefenseDestroyed();
ctx.NotifyDefenseDestroyed();
CompleteSwitch(ctx, 217);
Assert.That(ctx.GetShieldEligibleGuild(), Is.Null, "one completed switch is not enough");
// Only one switch held -> still no capture.
ctx.SetSwitchHolder(218, null);
Assert.That(ctx.TryCaptureThrone("Attackers").Success, Is.False);
CompleteSwitch(ctx, 218);
Assert.That(ctx.GetShieldEligibleGuild(), Is.EqualTo(GuildA));
// 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"));
// 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 -> Settlement
await ctx.TickAsync(T0.AddMinutes(20)); // Settlement -> Ownership
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"));
}
/// <summary>Tests that two different guilds each holding one switch cannot capture the throne.</summary>
/// <summary>Tests that two different guilds each holding one switch keep the crown's shield up.</summary>
[Test]
public async Task ThroneRequiresBothSwitchesBySameGuildAsync()
public async Task ShieldRequiresBothSwitchesBySameGuildAsync()
{
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);
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);
}
/// <summary>Tests that capturing the throne outside the siege phase is a no-op.</summary>
/// <summary>Tests that the switches don't work at all outside the running siege.</summary>
[Test]
public void ThroneCaptureOutsideSiegeIsNoOp()
public void SwitchesDoNothingOutsideSiege()
{
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);
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);
}
/// <summary>Tests that restoring persisted state sets phase/owner/registrations without raising PhaseChanged.</summary>
/// <summary>Tests that a switch belongs to the first player who clicked it, until they leave its area.</summary>
[Test]
public void RestoreStateSetsStateWithoutFiringPhaseChanged()
public async Task SwitchIsTakenByOnePlayerUntilTheyLeaveAsync()
{
var ctx = new CastleSiegeContext(Config());
var phaseChangedFired = false;
ctx.PhaseChanged += _ => phaseChangedFired = true;
await ctx.ForceStartRegistrationAsync(T0);
await ctx.ForceStateAsync(CastleSiegeState.Start, T0);
var push = TimeSpan.FromSeconds(15);
ctx.RestoreState("Winners", CastleSiegePhase.Ownership, T0, new[] { "Winners", "Losers" });
var (first, _) = ctx.TryStartSwitchOperation(217, GuildA, "A", 1, "first", 100, T0);
Assert.That(first, Is.EqualTo(CastleSiegeSwitchPush.Started));
Assert.That(ctx.Phase, Is.EqualTo(CastleSiegePhase.Ownership));
// 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));
}
/// <summary>Tests that losing a switch while the crown is being held drops the guild's eligibility.</summary>
[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);
}
/// <summary>Tests that restoring persisted state sets state/owner/registrations without raising StateChanged.</summary>
[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<Guid, string>(GuildA, "Winners"), new KeyValuePair<Guid, string>(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.RegisteredGuilds, Is.EquivalentTo(new[] { "Winners", "Losers" }));
Assert.That(phaseChangedFired, Is.False);
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");
}
/// <summary>Tests that the weekly auto-schedule (stored in config) only fires on a matching day/time window.</summary>
/// <summary>Tests that the weekly auto-schedule (stored in the settings) only fires on a matching day/time window.</summary>
[Test]
public void ScheduleFiresOnMatchingDayAndTimeWindow()
{
@@ -149,7 +214,7 @@ public class CastleSiegeContextTest
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 5s 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);
@@ -168,26 +233,26 @@ public class CastleSiegeContextTest
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
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("Attackers"); // registration -> dirty
ctx.RegisterGuild(GuildA, "Attackers"); // registration -> dirty
Assert.That(ctx.ConsumeDirty(), Is.True);
ctx.SetOwner("Attackers"); // owner change -> dirty
ctx.SetOwner(GuildA, "Attackers"); // owner change -> dirty
Assert.That(ctx.ConsumeDirty(), Is.True);
}
/// <summary>Tests that the remaining siege time counts down during the siege and is zero otherwise.</summary>
[Test]
public async Task RemainingSiegeTimeReflectsSiegePhaseAsync()
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.ForcePhaseAsync(CastleSiegePhase.Siege, 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");
@@ -197,75 +262,90 @@ public class CastleSiegeContextTest
[Test]
public async Task CrownHoldCapturesAfterHoldDurationAsync()
{
var ctx = new CastleSiegeContext(Config());
await ctx.ForceStartRegistrationAsync(T0);
await ctx.ForcePhaseAsync(CastleSiegePhase.Siege, T0);
ctx.SetDefenseCount(0);
ctx.SetSwitchHolder(217, "Attackers");
ctx.SetSwitchHolder(218, "Attackers");
Assert.That(ctx.GetShieldEligibleGuild(), Is.EqualTo("Attackers"));
var ctx = await SiegeWithSwitchesHeldAsync(GuildA);
var hold = TimeSpan.FromSeconds(60);
Assert.That(ctx.TickCrownHold("Attackers", true, T0, hold).Event, Is.EqualTo(CrownEvent.HoldStarted));
Assert.That(ctx.TickCrownHold("Attackers", true, T0.AddSeconds(30), hold).Event, Is.EqualTo(CrownEvent.None));
Assert.That(ctx.OccupierGuildName, Is.Null);
var captured = ctx.TickCrownHold("Attackers", true, T0.AddSeconds(60), hold);
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.OccupierGuildName, Is.EqualTo("Attackers"));
Assert.That(ctx.OccupierGuildId, Is.EqualTo(GuildA));
}
/// <summary>Tests that losing a switch mid-hold resets the crown-hold progress (contestable).</summary>
[Test]
public async Task CrownHoldResetsWhenSwitchLostAsync()
{
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, "A");
var ctx = await SiegeWithSwitchesHeldAsync(GuildA);
var hold = TimeSpan.FromSeconds(60);
Assert.That(ctx.TickCrownHold("A", true, T0, TimeSpan.FromSeconds(60)).Event, Is.EqualTo(CrownEvent.HoldStarted));
Assert.That(ctx.RequestCrownHold(GuildA), Is.True);
Assert.That(ctx.TickCrownHold(GuildA, "A", true, T0, hold).Event, Is.EqualTo(CrownEvent.HoldStarted));
ctx.SetSwitchHolder(218, null); // lost a switch -> no longer eligible
var reset = ctx.TickCrownHold(ctx.GetShieldEligibleGuild(), false, T0.AddSeconds(10), TimeSpan.FromSeconds(60));
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.OccupierGuildName, Is.Null);
Assert.That(ctx.OccupierGuildId, Is.Null);
}
/// <summary>Tests that the occupier can't re-capture its own throne, but a different guild can contest it.</summary>
[Test]
public async Task OccupierDoesNotRecaptureButAnotherGuildCanContestAsync()
{
var ctx = new CastleSiegeContext(Config());
await ctx.ForceStartRegistrationAsync(T0);
await ctx.ForcePhaseAsync(CastleSiegePhase.Siege, T0);
ctx.SetDefenseCount(0);
var ctx = await SiegeWithSwitchesHeldAsync(GuildA);
var hold = TimeSpan.FromSeconds(60);
var push = TimeSpan.FromSeconds(15);
ctx.SetSwitchHolder(217, "A");
ctx.SetSwitchHolder(218, "A");
ctx.TickCrownHold("A", true, T0, hold);
Assert.That(ctx.TickCrownHold("A", true, T0.AddSeconds(60), hold).Event, Is.EqualTo(CrownEvent.Captured));
Assert.That(ctx.OccupierGuildName, Is.EqualTo("A"));
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).
var after = ctx.TickCrownHold("A", true, T0.AddSeconds(61), hold);
// 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.SetSwitchHolder(217, "B");
ctx.SetSwitchHolder(218, "B");
Assert.That(ctx.TickCrownHold("B", true, T0.AddSeconds(62), hold).Event, Is.EqualTo(CrownEvent.HoldStarted));
Assert.That(ctx.TickCrownHold("B", true, T0.AddSeconds(122), hold).Event, Is.EqualTo(CrownEvent.Captured));
Assert.That(ctx.OccupierGuildName, Is.EqualTo("B"));
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 CastleSiegeConfiguration Config() => new()
private static async Task<CastleSiegeContext> 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),