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
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:
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user