Files
AdamuSw/tests/MUnique.OpenMU.Tests/CastleSiege/CastleSiegeContextTest.cs
Acentech Dev f8e856c7c6
Some checks failed
.NET Core / build (push) Has been cancelled
feat(castle-siege): operate the Crown Switches by clicking, capture the crown by holding it
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>
2026-08-04 21:44:12 +03:00

355 lines
17 KiB
C#

// <copyright file="CastleSiegeContextTest.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Tests.CastleSiege;
using MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.GameLogic.CastleSiege;
/// <summary>
/// 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 rests in the idle state.</summary>
[Test]
public void StartsInIdle()
{
var ctx = new CastleSiegeContext(Config());
Assert.That(ctx.State, Is.EqualTo(CastleSiegeState.Idle1));
}
/// <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.State, Is.EqualTo(CastleSiegeState.RegisterGuild));
}
/// <summary>Tests that registration advances to the preparation state once its duration elapses.</summary>
[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));
}
/// <summary>Tests a full cycle: register -> ready -> start -> end -> end cycle -> idle.</summary>
[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));
}
/// <summary>Tests that guilds are collected by id during the registration state.</summary>
[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"));
}
/// <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.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"));
}
/// <summary>Tests that two different guilds each holding one switch keep the crown's shield up.</summary>
[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);
}
/// <summary>Tests that the switches don't work at all outside the running siege.</summary>
[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);
}
/// <summary>Tests that a switch belongs to the first player who clicked it, until they leave its area.</summary>
[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));
}
/// <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.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 the settings) only fires on a matching day/time window.</summary>
[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<DayOfWeek>(), null); // cleared -> never opens
Assert.That(config.IsRegistrationOpenTime(sunday), Is.False);
}
/// <summary>Tests that state-changing operations flip the dirty flag, which ConsumeDirty reads-and-resets.</summary>
[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);
}
/// <summary>Tests that the remaining siege time counts down during the siege and is zero otherwise.</summary>
[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");
}
/// <summary>Tests that a guild master holding the crown (both switches held, defenses down) captures after the hold duration.</summary>
[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));
}
/// <summary>Tests that losing a switch mid-hold resets the crown-hold progress (contestable).</summary>
[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);
}
/// <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 = 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<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),
SiegeDuration = TimeSpan.FromMinutes(10),
};
}