Files
AdamuSw/tests/MUnique.OpenMU.Tests/CastleSiege/CastleSiegeContextTest.cs
Acentech Dev 3dd4881406
Some checks failed
.NET Core / build (push) Has been cancelled
feat(CS): authentic Crown-hold throne capture (S6 protocol) replacing Sinior-talk
Break all gates + hold both switches (defenses down) -> the Crown shield drops
(C1 B2 16=0). The guild master then stands on the Crown (176,212) and holds for
CrownHoldDuration (default 60s, client shows a 60s countdown via C1 B2 15) to
capture; capture broadcasts C1 B2 18 + golden text and sets the occupier. Losing a
switch or leaving the crown resets the hold (contestable until the siege timer ends).
Sinior (223) is now informational guidance. +2 tests (14 total).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 15:04:10 +03:00

246 lines
11 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.GameLogic.CastleSiege;
/// <summary>
/// Tests for the Castle Siege phase state machine (time-driven, injected clock).
/// </summary>
[TestFixture]
public class CastleSiegeContextTest
{
private static readonly DateTime T0 = new(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc);
/// <summary>Tests that a fresh context starts in the ownership (resting) phase.</summary>
[Test]
public void StartsInOwnership()
{
var ctx = new CastleSiegeContext(Config());
Assert.That(ctx.Phase, Is.EqualTo(CastleSiegePhase.Ownership));
}
/// <summary>Tests that force-starting moves the state machine into registration.</summary>
[Test]
public async Task ForceStartMovesToRegistrationAsync()
{
var ctx = new CastleSiegeContext(Config());
await ctx.ForceStartRegistrationAsync(T0);
Assert.That(ctx.Phase, Is.EqualTo(CastleSiegePhase.Registration));
}
/// <summary>Tests that registration advances to preparation once its duration elapses.</summary>
[Test]
public async Task RegistrationAdvancesToPreparationAfterDurationAsync()
{
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));
await ctx.TickAsync(T0.AddMinutes(5)); // registration duration elapsed
Assert.That(ctx.Phase, Is.EqualTo(CastleSiegePhase.Preparation));
}
/// <summary>Tests a full cycle: registration -> preparation -> siege -> settlement -> ownership.</summary>
[Test]
public async Task FullCycleReturnsToOwnershipAsync()
{
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));
}
/// <summary>Tests that guilds can be registered (by name) during the registration phase.</summary>
[Test]
public async Task RegisterGuildCollectsNamesDuringRegistrationAsync()
{
var ctx = new CastleSiegeContext(Config());
await ctx.ForceStartRegistrationAsync(T0);
ctx.RegisterGuild("Attackers");
Assert.That(ctx.RegisteredGuilds, Does.Contain("Attackers"));
}
/// <summary>Tests the full siege objective chain: destroy defenses, then hold both switches to capture the throne.</summary>
[Test]
public async Task FullSiegeObjectiveChainToOwnershipAsync()
{
var ctx = new CastleSiegeContext(Config());
await ctx.ForceStartRegistrationAsync(T0);
await ctx.ForcePhaseAsync(CastleSiegePhase.Siege, T0);
ctx.SetDefenseCount(2);
// 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);
ctx.NotifyDefenseDestroyed();
ctx.NotifyDefenseDestroyed();
// Only one switch held -> still no capture.
ctx.SetSwitchHolder(218, null);
Assert.That(ctx.TryCaptureThrone("Attackers").Success, Is.False);
// 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"));
await ctx.TickAsync(T0.AddMinutes(20)); // Siege -> Settlement
await ctx.TickAsync(T0.AddMinutes(20)); // Settlement -> Ownership
Assert.That(ctx.OwnerGuildName, Is.EqualTo("Attackers"));
}
/// <summary>Tests that two different guilds each holding one switch cannot capture the throne.</summary>
[Test]
public async Task ThroneRequiresBothSwitchesBySameGuildAsync()
{
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);
}
/// <summary>Tests that capturing the throne outside the siege phase is a no-op.</summary>
[Test]
public void ThroneCaptureOutsideSiegeIsNoOp()
{
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);
}
/// <summary>Tests that restoring persisted state sets phase/owner/registrations without raising PhaseChanged.</summary>
[Test]
public void RestoreStateSetsStateWithoutFiringPhaseChanged()
{
var ctx = new CastleSiegeContext(Config());
var phaseChangedFired = false;
ctx.PhaseChanged += _ => phaseChangedFired = true;
ctx.RestoreState("Winners", CastleSiegePhase.Ownership, T0, new[] { "Winners", "Losers" });
Assert.That(ctx.Phase, Is.EqualTo(CastleSiegePhase.Ownership));
Assert.That(ctx.OwnerGuildName, Is.EqualTo("Winners"));
Assert.That(ctx.RegisteredGuilds, Is.EquivalentTo(new[] { "Winners", "Losers" }));
Assert.That(phaseChangedFired, 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>
[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 5s 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); // phase transition -> dirty
Assert.That(ctx.ConsumeDirty(), Is.True);
Assert.That(ctx.ConsumeDirty(), Is.False, "ConsumeDirty resets the flag");
ctx.RegisterGuild("Attackers"); // registration -> dirty
Assert.That(ctx.ConsumeDirty(), Is.True);
ctx.SetOwner("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()
{
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);
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 = 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 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(captured.Event, Is.EqualTo(CrownEvent.Captured));
Assert.That(captured.ShieldDown, Is.True);
Assert.That(ctx.OccupierGuildName, Is.EqualTo("Attackers"));
}
/// <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");
Assert.That(ctx.TickCrownHold("A", true, T0, TimeSpan.FromSeconds(60)).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));
Assert.That(reset.ShieldDown, Is.False);
Assert.That(reset.Event, Is.EqualTo(CrownEvent.HoldReset));
Assert.That(ctx.OccupierGuildName, Is.Null);
}
private static CastleSiegeConfiguration Config() => new()
{
RegistrationDuration = TimeSpan.FromMinutes(5),
PreparationDuration = TimeSpan.FromMinutes(2),
SiegeDuration = TimeSpan.FromMinutes(10),
};
}