diff --git a/src/DataModel/Configuration/MonsterDefinition.cs b/src/DataModel/Configuration/MonsterDefinition.cs index 59a1d7c..6263e55 100644 --- a/src/DataModel/Configuration/MonsterDefinition.cs +++ b/src/DataModel/Configuration/MonsterDefinition.cs @@ -165,6 +165,16 @@ public enum NpcWindow /// The dialog for the legacy quest system. /// LegacyQuest, + + /// + /// The castle siege gate NPC interaction window. + /// + CastleSiegeGateNpc, + + /// + /// The castle siege lever NPC interaction window. + /// + CastleSiegeLeverNpc, } /// diff --git a/src/GameLogic/CastleSiege/CastleSiegeContext.cs b/src/GameLogic/CastleSiege/CastleSiegeContext.cs index 1361fb9..67eec74 100644 --- a/src/GameLogic/CastleSiege/CastleSiegeContext.cs +++ b/src/GameLogic/CastleSiege/CastleSiegeContext.cs @@ -27,10 +27,12 @@ using MUnique.OpenMU.DataModel.Configuration; /// re-create under the same name) therefore can no longer transfer castle ownership to the wrong guild. Names /// are carried alongside purely for display and for the packets that send a name to the client. /// -/// Battle rule: attackers must destroy all castle defenses (gates + guardian statues) and then hold BOTH -/// Crown Switches at the same time — the switches are held by standing on them (evaluated per tick by the -/// plugin), and once both are held by one guild with the defenses down, that guild captures the throne. -/// The throne holder when the siege ends becomes the castle owner. +/// Battle rule: a guild takes the throne by holding BOTH Crown Switches at the same time. A switch is +/// operated by clicking it and then staying in its area: the operation needs +/// to complete, after which the switch counts as held +/// until its operator leaves. While one guild holds both switches the crown's shield drops for it, and its +/// guild master can start the crown hold to capture the throne. The throne can change hands as often as the +/// switches do; whoever holds it when the siege ends becomes the castle owner. /// public class CastleSiegeContext { @@ -38,7 +40,7 @@ public class CastleSiegeContext public static readonly short[] SwitchNumbers = { 217, 218 }; private readonly Dictionary _registeredGuilds = new(); - private readonly Dictionary _switchHolders = new() { { 217, null }, { 218, null } }; + private readonly Dictionary _switches = new() { { 217, null }, { 218, null } }; private DateTime _stateStartedUtc; private Guid? _occupier; private string? _occupierName; @@ -46,6 +48,7 @@ public class CastleSiegeContext private bool _dirty; private Guid? _crownHoldGuild; private DateTime? _crownHoldStartUtc; + private Guid? _crownHoldRequestedBy; private bool _lastShieldDown; /// Initializes a new instance of the class. @@ -270,27 +273,46 @@ public class CastleSiegeContext } /// - /// Returns the guild that currently holds BOTH crown switches while all castle defenses are down (so the - /// crown's shield is dropped for them), or null. Only meaningful during the siege. + /// Returns the guild which currently holds BOTH Crown Switches, so the crown's shield is dropped for it, + /// or null. Only meaningful during the siege. /// public Guid? GetShieldEligibleGuild() { - if (!this.IsSiegeRunning || this._defensesRemaining > 0) + if (!this.IsSiegeRunning) { return null; } - var holder = this._switchHolders[217]; - return holder is not null && holder == this._switchHolders[218] ? holder : null; + var first = this.GetHeldSwitchGuild(217); + return first is not null && first == this.GetHeldSwitchGuild(218) ? first : null; } /// - /// Advances the crown-hold capture. is the guild with both switches held - /// and no defenses left (shield down); is whether that guild's master is - /// standing on the crown. Captures the throne for the guild once it has held for . - /// Losing a switch or the master leaving the crown resets the hold (contestable until the siege ends). + /// Registers a guild master's intent to take the crown, which is what the crown hold waits for: standing + /// on the crown alone does nothing until its guild master clicked it. Ignored when the guild does not + /// hold both switches, so a click can never arm a hold the guild isn't entitled to. /// - /// The guild with both switches and no defenses, or null. + /// The requesting guild master's guild identifier. + /// if the request was accepted. + public bool RequestCrownHold(Guid guildId) + { + if (this.GetShieldEligibleGuild() != guildId || this._occupier == guildId) + { + return false; + } + + this._crownHoldRequestedBy = guildId; + return true; + } + + /// + /// Advances the crown-hold capture. is the guild holding both switches + /// (shield down) and is whether that guild's master stands on the crown. + /// The hold only runs after the master requested it via ; it captures the + /// throne once it ran for . Losing a switch or the master leaving the crown + /// resets the hold, and the crown has to be clicked again (contestable until the siege ends). + /// + /// The guild holding both switches, or null. /// That guild's name, for display. /// Whether that guild's master is on the crown. /// The current UTC time. @@ -303,21 +325,21 @@ public class CastleSiegeContext if (!this.IsSiegeRunning) { - this._crownHoldGuild = null; - this._crownHoldStartUtc = null; + this.ResetCrownHold(); return new CrownTickResult(false, shieldChanged, CrownEvent.None, null, null); } var wasHolding = this._crownHoldGuild is not null; - // The guild that already occupies the throne just holds it — no re-registration (avoids a capture loop). + // The guild that already occupies the throne just holds it - no re-registration (avoids a capture loop). // Only a DIFFERENT guild taking both switches can register/capture (contest). - var canCapture = eligibleGuild is not null && eligibleGuild != this._occupier; + var canCapture = eligibleGuild is not null + && eligibleGuild != this._occupier + && this._crownHoldRequestedBy == eligibleGuild; if (!canCapture || !masterHolding) { - this._crownHoldGuild = null; - this._crownHoldStartUtc = null; + this.ResetCrownHold(); return new CrownTickResult(shieldDown, shieldChanged, wasHolding ? CrownEvent.HoldReset : CrownEvent.None, null, null); } @@ -332,8 +354,7 @@ public class CastleSiegeContext { this._occupier = eligibleGuild; this._occupierName = eligibleGuildName; - this._crownHoldGuild = null; - this._crownHoldStartUtc = null; + this.ResetCrownHold(); this._dirty = true; return new CrownTickResult(shieldDown, shieldChanged, CrownEvent.Captured, eligibleGuild, eligibleGuildName); } @@ -392,56 +413,83 @@ public class CastleSiegeContext } } + /// Returns who is currently operating a Crown Switch, or . + /// The Crown Switch NPC number (217 or 218). + public CastleSiegeSwitchOperation? GetSwitchOperation(short switchNumber) + => this._switches.TryGetValue(switchNumber, out var operation) ? operation : null; + /// - /// Sets which guild is currently standing on (holding) a Crown Switch. Called every tick by the plugin - /// based on player positions. Pass null when no registered member stands on it. No-op outside the siege. + /// Starts operating a Crown Switch for a player who clicked it. A switch can only be operated by one + /// player at a time: while somebody else is on it, the click is refused and the caller is told who holds + /// it, which is what the client shows as "another siege team is running the crown switch". /// /// The Crown Switch NPC number (217 or 218). - /// The holding guild's persistent identifier, or null. - public void SetSwitchHolder(short switchNumber, Guid? guildId) + /// The clicking player's guild identifier. + /// The clicking player's guild name, for display. + /// The clicking player's object identifier on the map. + /// The clicking player's name, for display. + /// The switch NPC's object identifier on the map. + /// The current UTC time. + /// The outcome, and the current operation when the switch is taken. + public (CastleSiegeSwitchPush Result, CastleSiegeSwitchOperation? Operation) TryStartSwitchOperation( + short switchNumber, + Guid guildId, + string guildName, + ushort playerId, + string playerName, + ushort switchObjectId, + DateTime now) { - if (this.IsSiegeRunning && this._switchHolders.ContainsKey(switchNumber)) + if (!this.IsSiegeRunning || !this._switches.ContainsKey(switchNumber)) { - this._switchHolders[switchNumber] = guildId; + return (CastleSiegeSwitchPush.SiegeNotRunning, null); } + + if (this._switches[switchNumber] is { } current) + { + return current.PlayerId == playerId + ? (CastleSiegeSwitchPush.AlreadyYours, current) + : (CastleSiegeSwitchPush.TakenByOther, current); + } + + var operation = new CastleSiegeSwitchOperation(guildId, guildName, playerId, playerName, switchObjectId, now); + this._switches[switchNumber] = operation; + return (CastleSiegeSwitchPush.Started, operation); } /// - /// Attempts to capture the throne for a guild (called when a member registers at the Sinior/Crown NPC). - /// Succeeds only during the siege when the throne is free, all castle defenses are destroyed, and the - /// guild is currently holding BOTH Crown Switches (a member standing on each). + /// Advances one Crown Switch. The operation is dropped as soon as its player is gone from the switch's + /// area, and completes - which makes the switch count for the guild - once it ran . /// - /// The capturing guild's persistent identifier. - /// The capturing guild's name, for display. - /// Whether it succeeded and a human-readable reason/result message. - public (bool Success, string Reason) TryCaptureThrone(Guid guildId, string guildName) + /// The Crown Switch NPC number (217 or 218). + /// Whether the operating player is still in the switch's area. + /// The current UTC time. + /// How long operating the switch takes. + /// What happened to the switch in this tick, and the operation it happened to. + public (CastleSiegeSwitchEvent Event, CastleSiegeSwitchOperation? Operation) TickSwitch( + short switchNumber, + bool operatorPresent, + DateTime now, + TimeSpan pushDuration) { - if (!this.IsSiegeRunning) + if (!this._switches.TryGetValue(switchNumber, out var operation) || operation is null) { - return (false, "The siege is not running."); + return (CastleSiegeSwitchEvent.None, null); } - if (this._occupier is { } occupier) + if (!this.IsSiegeRunning || !operatorPresent) { - return (false, occupier == guildId - ? "Your guild already holds the throne." - : $"The throne is already held by '{this._occupierName ?? occupier.ToString()}'."); + this._switches[switchNumber] = null; + return (CastleSiegeSwitchEvent.Released, operation); } - if (this._defensesRemaining > 0) + if (!operation.IsHeld && now - operation.StartedUtc >= pushDuration) { - return (false, $"Destroy the castle defenses first ({this._defensesRemaining} remaining)."); + operation.MarkHeld(); + return (CastleSiegeSwitchEvent.Held, operation); } - if (this._switchHolders[217] != guildId || this._switchHolders[218] != guildId) - { - return (false, "Your guild must be holding BOTH Crown Switches at once (stand a member on each)."); - } - - this._occupier = guildId; - this._occupierName = guildName; - this._dirty = true; - return (true, "throne captured"); + return (CastleSiegeSwitchEvent.None, operation); } /// Returns a human-readable status summary for admin display. @@ -451,20 +499,29 @@ public class CastleSiegeContext + $"defenses={this._defensesRemaining}, throne={this._occupierName ?? "(none)"}, " + $"switch217={this.DescribeSwitch(217)}, switch218={this.DescribeSwitch(218)}"; + private Guid? GetHeldSwitchGuild(short switchNumber) + => this._switches[switchNumber] is { IsHeld: true } operation ? operation.GuildId : null; + private string DescribeSwitch(short switchNumber) - => this._switchHolders[switchNumber] is { } holder - ? (this._registeredGuilds.TryGetValue(holder, out var name) ? name : holder.ToString()) + => this._switches[switchNumber] is { } operation + ? $"{operation.GuildName}/{operation.PlayerName}{(operation.IsHeld ? string.Empty : " (pushing)")}" : "-"; + private void ResetCrownHold() + { + this._crownHoldGuild = null; + this._crownHoldStartUtc = null; + this._crownHoldRequestedBy = null; + } + private void ClearBattleState() { - this._switchHolders[217] = null; - this._switchHolders[218] = null; + this._switches[217] = null; + this._switches[218] = null; this._defensesRemaining = 0; this._occupier = null; this._occupierName = null; - this._crownHoldGuild = null; - this._crownHoldStartUtc = null; + this.ResetCrownHold(); this._lastShieldDown = false; } diff --git a/src/GameLogic/CastleSiege/CastleSiegeSettings.cs b/src/GameLogic/CastleSiege/CastleSiegeSettings.cs index a7bd6f4..5e98ca9 100644 --- a/src/GameLogic/CastleSiege/CastleSiegeSettings.cs +++ b/src/GameLogic/CastleSiege/CastleSiegeSettings.cs @@ -117,6 +117,12 @@ public class CastleSiegeSettings [Browsable(false)] public TimeSpan SiegeDuration { get; set; } = TimeSpan.FromMinutes(10); + /// + /// Gets or sets how many seconds a player has to operate a Crown Switch before it counts for their guild. + /// The player has to stay in the switch's area for that long, and keeps it until they leave. + /// + public int SwitchPushSeconds { get; set; } = 15; + // --- Persisted cycle bookkeeping (hidden from the AdminPanel) --- // Only the CURRENT state and when it started ride on the plugin's custom-configuration JSON. The castle // owner and the guild registrations live in real database tables, so they are not duplicated here. diff --git a/src/GameLogic/CastleSiege/CastleSiegeSwitchEvent.cs b/src/GameLogic/CastleSiege/CastleSiegeSwitchEvent.cs new file mode 100644 index 0000000..d0e14fa --- /dev/null +++ b/src/GameLogic/CastleSiege/CastleSiegeSwitchEvent.cs @@ -0,0 +1,20 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.CastleSiege; + +/// +/// What happened to a Crown Switch during one tick. +/// +public enum CastleSiegeSwitchEvent +{ + /// Nothing worth reporting. + None, + + /// The operation completed, so the switch now counts for the operator's guild. + Held, + + /// The operator left (or the siege ended), so the switch is free again. + Released, +} diff --git a/src/GameLogic/CastleSiege/CastleSiegeSwitchOperation.cs b/src/GameLogic/CastleSiege/CastleSiegeSwitchOperation.cs new file mode 100644 index 0000000..4c41fa0 --- /dev/null +++ b/src/GameLogic/CastleSiege/CastleSiegeSwitchOperation.cs @@ -0,0 +1,55 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.CastleSiege; + +/// +/// One player operating one Crown Switch. The player starts it by clicking the switch and keeps it by +/// staying in its area; the switch counts as held for the guild once the operation has run its time. +/// +public class CastleSiegeSwitchOperation +{ + /// Initializes a new instance of the class. + /// The operating player's guild identifier. + /// The operating player's guild name, for display. + /// The operating player's object identifier on the map. + /// The operating player's name, for display. + /// The switch NPC's object identifier on the map. + /// When the operation started (UTC). + public CastleSiegeSwitchOperation(Guid guildId, string guildName, ushort playerId, string playerName, ushort switchObjectId, DateTime startedUtc) + { + this.GuildId = guildId; + this.GuildName = guildName; + this.PlayerId = playerId; + this.PlayerName = playerName; + this.SwitchObjectId = switchObjectId; + this.StartedUtc = startedUtc; + } + + /// Gets the operating player's guild identifier. + public Guid GuildId { get; } + + /// Gets the operating player's guild name. + public string GuildName { get; } + + /// Gets the operating player's object identifier on the map. + public ushort PlayerId { get; } + + /// Gets the operating player's name. + public string PlayerName { get; } + + /// Gets the switch NPC's object identifier on the map, which the client's packets refer to. + public ushort SwitchObjectId { get; } + + /// Gets the point in time (UTC) when the operation started. + public DateTime StartedUtc { get; } + + /// + /// Gets a value indicating whether the operation ran its time, so the switch counts for the guild. + /// + public bool IsHeld { get; private set; } + + /// Marks the operation as completed, which makes the switch count for the guild. + internal void MarkHeld() => this.IsHeld = true; +} diff --git a/src/GameLogic/CastleSiege/CastleSiegeSwitchPush.cs b/src/GameLogic/CastleSiege/CastleSiegeSwitchPush.cs new file mode 100644 index 0000000..65353a2 --- /dev/null +++ b/src/GameLogic/CastleSiege/CastleSiegeSwitchPush.cs @@ -0,0 +1,23 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.CastleSiege; + +/// +/// The outcome of a player clicking a Crown Switch. +/// +public enum CastleSiegeSwitchPush +{ + /// The player started operating the switch. + Started, + + /// The player is already operating this switch. + AlreadyYours, + + /// Somebody else is operating this switch. + TakenByOther, + + /// The siege is not running, so the switches do nothing. + SiegeNotRunning, +} diff --git a/src/GameLogic/CastleSiege/CastleSiegeSwitchTalkPlugIn.cs b/src/GameLogic/CastleSiege/CastleSiegeSwitchTalkPlugIn.cs new file mode 100644 index 0000000..25f772d --- /dev/null +++ b/src/GameLogic/CastleSiege/CastleSiegeSwitchTalkPlugIn.cs @@ -0,0 +1,92 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.CastleSiege; + +using System.Runtime.InteropServices; +using MUnique.OpenMU.GameLogic.NPC; +using MUnique.OpenMU.GameLogic.PlugIns; +using MUnique.OpenMU.GameLogic.PlugIns.PeriodicTasks; +using MUnique.OpenMU.GameLogic.Views; +using MUnique.OpenMU.GameLogic.Views.CastleSiege; +using MUnique.OpenMU.Interfaces; +using MUnique.OpenMU.PlugIns; + +/// +/// Handles clicking a Crown Switch (NPC 217 / 218) on Valley of Loren. The click starts operating the +/// switch, which the client shows as a progress box; the switch counts for the guild once the operation +/// ran its time and stays theirs until the operating player leaves the switch's area. Only one player can +/// operate a switch at a time - anybody else clicking it is told that another team is on it. +/// +[Guid("CA5710A0-7A1B-4C2D-8E3F-000000000217")] +[PlugIn] +[Display(Name = "Castle Siege Crown Switch", Description = "Operates a Crown Switch (NPC 217/218) during the Castle Siege.")] +public class CastleSiegeSwitchTalkPlugIn : IPlayerTalkToNpcPlugIn +{ + /// + public async ValueTask PlayerTalksToNpcAsync(Player player, NonPlayerCharacter npc, NpcTalkEventArgs eventArgs) + { + if (!CastleSiegeContext.SwitchNumbers.Contains(npc.Definition.Number)) + { + return; + } + + // We drive the switch ourselves, so suppress the default "not implemented" message. + eventArgs.HasBeenHandled = true; + + var context = CastleSiegeEventPlugIn.TryGetContext(player.GameContext); + if (context is null) + { + await ShowAsync(player, "Castle Siege is not active on this server.").ConfigureAwait(false); + return; + } + + if (!context.IsSiegeRunning) + { + await ShowAsync(player, "The Crown Switches only work while the siege is running.").ConfigureAwait(false); + return; + } + + if (await CastleSiegeEventPlugIn.GetPersistentGuildAsync(player).ConfigureAwait(false) is not { } guild + || !context.IsRegistered(guild.Id)) + { + await ShowAsync(player, "Only members of a registered guild can operate the Crown Switches.").ConfigureAwait(false); + return; + } + + var (result, operation) = context.TryStartSwitchOperation( + npc.Definition.Number, + guild.Id, + guild.Name, + player.Id, + player.Name, + npc.Id, + DateTime.UtcNow); + + switch (result) + { + case CastleSiegeSwitchPush.Started: + // The info packet goes first: it is what makes every client allocate its switch table, which + // the "switch released" packet later reads without checking that it exists. + await CastleSiegeEventPlugIn.BroadcastSwitchInfoAsync(player.GameContext, npc.Id, operation).ConfigureAwait(false); + await player.InvokeViewPlugInAsync(p => p.SetCrownSwitchStateAsync(npc.Id, player.Id, 1)).ConfigureAwait(false); + break; + + case CastleSiegeSwitchPush.TakenByOther when operation is { } other: + // State 2 makes the client name the player who is already on it. + await player.InvokeViewPlugInAsync(p => p.SetCrownSwitchStateAsync(npc.Id, other.PlayerId, 2)).ConfigureAwait(false); + break; + + case CastleSiegeSwitchPush.AlreadyYours: + break; + + default: + await ShowAsync(player, "The Crown Switches only work while the siege is running.").ConfigureAwait(false); + break; + } + } + + private static ValueTask ShowAsync(Player player, string text) + => player.InvokeViewPlugInAsync(p => p.ShowMessageAsync(text, MessageType.BlueNormal)); +} diff --git a/src/GameLogic/CastleSiege/CastleSiegeThroneCaptureTalkPlugIn.cs b/src/GameLogic/CastleSiege/CastleSiegeThroneCaptureTalkPlugIn.cs index b6fca0b..5ea1a42 100644 --- a/src/GameLogic/CastleSiege/CastleSiegeThroneCaptureTalkPlugIn.cs +++ b/src/GameLogic/CastleSiege/CastleSiegeThroneCaptureTalkPlugIn.cs @@ -55,34 +55,46 @@ public class CastleSiegeThroneCaptureTalkPlugIn : IPlayerTalkToNpcPlugIn return; } - // The throne is taken by holding the Crown, not by talking here — give guidance based on the state. - await ShowAsync(player, DescribeThroneStep(context, guild.Id)).ConfigureAwait(false); + // Clicking the Crown as the guild master is what arms the capture: the hold then runs while they + // stay on it. Anybody else (or a master who isn't entitled yet) just gets told what is missing. + var isGuildMaster = player.GuildStatus?.Position == GuildPosition.GuildMaster; + if (isGuildMaster && context.RequestCrownHold(guild.Id)) + { + await ShowAsync(player, "Hold the Crown - do not step away until the seal is registered!").ConfigureAwait(false); + return; + } + + await ShowAsync(player, DescribeThroneStep(context, guild.Id, isGuildMaster)).ConfigureAwait(false); } - private static string DescribeThroneStep(CastleSiegeContext context, Guid guildId) + private static string DescribeThroneStep(CastleSiegeContext context, Guid guildId, bool isGuildMaster) { if (!context.IsSiegeRunning) { return "The siege is not running yet."; } - if (context.DefensesRemaining > 0) - { - return $"Destroy all castle gates first ({context.DefensesRemaining} remaining), then hold both Crown Switches."; - } - var eligible = context.GetShieldEligibleGuild(); if (eligible is null) { - return "All gates are down! Hold BOTH Crown Switches with your guild — the Crown's shield will drop."; + return context.DefensesRemaining > 0 + ? $"Hold BOTH Crown Switches with your guild to drop the Crown's shield ({context.DefensesRemaining} castle defenses still standing)." + : "Hold BOTH Crown Switches with your guild - the Crown's shield will drop."; } - if (eligible == guildId) + if (eligible != guildId) { - return "Your guild holds both switches and the shield is down — send your GUILD MASTER to hold the Crown to take the throne!"; + return "Another guild is holding both switches. Take a switch back to raise their shield."; } - return "Another guild is holding both switches. Take a switch back to raise their shield."; + if (context.OccupierGuildId == guildId) + { + return "Your guild already holds the throne - keep it until the siege ends."; + } + + return isGuildMaster + ? "Your guild holds both switches, but the Crown cannot be registered right now." + : "Your guild holds both switches and the shield is down - your GUILD MASTER has to click the Crown!"; } private static ValueTask ShowAsync(Player player, string text) diff --git a/src/GameLogic/PlugIns/ChatCommands/CastleSiegePhaseChatCommandPlugIn.cs b/src/GameLogic/PlugIns/ChatCommands/CastleSiegePhaseChatCommandPlugIn.cs index 588fff7..b288ae6 100644 --- a/src/GameLogic/PlugIns/ChatCommands/CastleSiegePhaseChatCommandPlugIn.cs +++ b/src/GameLogic/PlugIns/ChatCommands/CastleSiegePhaseChatCommandPlugIn.cs @@ -14,7 +14,7 @@ using MUnique.OpenMU.PlugIns; /// Forces a specific Castle Siege phase. GM only. Usage: /csphase Siege. [Guid("A1B2C3D4-0003-4E5F-9A0B-CA5710000003")] [PlugIn] -[Display(Name = "Castle Siege Phase", Description = "GM command: /csphase ")] +[Display(Name = "Castle Siege Phase", Description = "GM command: /csphase ")] [ChatCommandHelp(Command, CharacterStatus.GameMaster)] public class CastleSiegePhaseChatCommandPlugIn : IChatCommandPlugIn { diff --git a/src/GameLogic/PlugIns/PeriodicTasks/CastleSiegeEventPlugIn.cs b/src/GameLogic/PlugIns/PeriodicTasks/CastleSiegeEventPlugIn.cs index 486c222..731a37c 100644 --- a/src/GameLogic/PlugIns/PeriodicTasks/CastleSiegeEventPlugIn.cs +++ b/src/GameLogic/PlugIns/PeriodicTasks/CastleSiegeEventPlugIn.cs @@ -47,8 +47,27 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom private const int SwitchHoldRange = 3; private const int CrownHoldRange = 4; + /// How many ticks (the periodic task runs once per second) between two countdown broadcasts. + private const int SiegeStateBroadcastTicks = 10; + + /// How many ticks between two castle-flag broadcasts. + private const int CastleFlagBroadcastTicks = 15; + private static readonly ConcurrentDictionary Contexts = new(); + /// + /// The player whose client currently shows the crown registration panel, per game context. The panel is + /// opened for exactly one guild master, and it has to be closed for that same player - by the time the + /// hold breaks they are usually no longer on the crown, so they can't be found by position any more. + /// + private static readonly ConcurrentDictionary CrownHoldPlayers = new(); + + /// + /// Tick counters per game context, used to space out the periodic broadcasts. Counting ticks (instead of + /// matching a clock second) keeps a broadcast from being skipped when a tick runs late. + /// + private static readonly ConcurrentDictionary Counters = new(); + /// /// Maps the in-memory guild id (assigned by the guild server, not stable across restarts) to the guild's /// persistent identifier. Populated lazily; a miss costs one database lookup per guild per process. @@ -156,7 +175,7 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom // castle owner from the database so the hunting-map gate + castle flag rewards still work everywhere. if (!IsCastleSiegeServer(gameContext)) { - if (DateTime.UtcNow.Second % 15 == 0) + if (GetCounters(gameContext).NextCastleFlag()) { await LoadPersistedStateAsync(gameContext, context).ConfigureAwait(false); await this.BroadcastCastleFlagAsync(gameContext, context).ConfigureAwait(false); @@ -180,7 +199,7 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom } // Keep the owner guild's logo painted on the castle flags for anyone on the castle map (any state). - if (DateTime.UtcNow.Second % 15 == 0) + if (GetCounters(gameContext).NextCastleFlag()) { await this.BroadcastCastleFlagAsync(gameContext, context).ConfigureAwait(false); } @@ -281,8 +300,11 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom await WarpRegisteredMembersToSiegeAsync(gameContext, context).ConfigureAwait(false); break; case CastleSiegeState.End: - // Stop the on-map countdown for everyone still on the battle map. + // Stop the on-map countdown for everyone still on the battle map, and close the panels + // the siege opened - the battle state is dropped right after this, so whoever was + // operating a switch or holding the crown would keep a dead progress box on screen. await BroadcastSiegeStateAsync(gameContext, false, 0, 0).ConfigureAwait(false); + await CloseSiegePanelsAsync(gameContext, context).ConfigureAwait(false); break; case CastleSiegeState.Idle1 when context.OwnerGuildName is { } owner: await AnnounceAsync(gameContext, $"The Castle Siege has ended. The castle now belongs to the guild '{owner}'!").ConfigureAwait(false); @@ -389,6 +411,50 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom SpawnTrigger = SpawnTrigger.OnceAtEventStart, }; + private static BroadcastCounters GetCounters(IGameContext gameContext) + => Counters.GetOrAdd(gameContext, _ => new BroadcastCounters()); + + /// + /// Closes the client panels the siege opened: the crown registration panel of the master who was holding + /// it, and the switch progress box of whoever was operating a switch. Called when the siege ends, before + /// the battle state is dropped. + /// + private static async Task CloseSiegePanelsAsync(IGameContext gameContext, CastleSiegeContext context) + { + if (CrownHoldPlayers.TryRemove(gameContext, out var holdPlayer)) + { + await holdPlayer.InvokeViewPlugInAsync(p => p.SetCrownRegistAsync(2, 0)).ConfigureAwait(false); + } + + if (await gameContext.GetMapAsync(ValleyOfLorenMapNumber).ConfigureAwait(false) is not { } map) + { + return; + } + + foreach (var switchNumber in CastleSiegeContext.SwitchNumbers) + { + if (context.GetSwitchOperation(switchNumber) is { } operation) + { + await BroadcastSwitchInfoAsync(gameContext, operation.SwitchObjectId, null).ConfigureAwait(false); + await CloseSwitchBoxAsync(map, operation).ConfigureAwait(false); + } + } + } + + /// + /// Closes the switch progress box on the client of the player who was operating it. Object identifiers + /// are recycled when a player leaves, so the name is checked too - otherwise a newly connected player + /// could inherit the id and get a message box about a switch they never touched. + /// + private static async Task CloseSwitchBoxAsync(GameMap map, CastleSiegeSwitchOperation operation) + { + if (map.GetObject(operation.PlayerId) is Player player && player.Name == operation.PlayerName) + { + await player.InvokeViewPlugInAsync( + p => p.SetCrownSwitchStateAsync(operation.SwitchObjectId, operation.PlayerId, 0)).ConfigureAwait(false); + } + } + private static Point? GetNpcPosition(IGameContext gameContext, short monsterNumber) { var npc = GetDefinition(gameContext)?.NpcDefinitions @@ -405,32 +471,45 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom return; } - // Each Crown Switch is held by whichever registered guild currently has a member standing on it. + var now = DateTime.UtcNow; + var pushDuration = TimeSpan.FromSeconds(Math.Max(1, context.Configuration.SwitchPushSeconds)); + + // A Crown Switch belongs to the player who clicked it, for as long as they stay in its area. foreach (var switchNumber in CastleSiegeContext.SwitchNumbers) { - if (GetNpcPosition(gameContext, switchNumber) is not { } position) + if (context.GetSwitchOperation(switchNumber) is not { } operation) { continue; } - Guid? holder = null; - foreach (var player in map.GetAttackablesInRange(position, SwitchHoldRange).OfType()) + var stillOnIt = GetNpcPosition(gameContext, switchNumber) is { } position + && map.GetAttackablesInRange(position, SwitchHoldRange) + .OfType() + .Any(p => p.Id == operation.PlayerId && p.IsAlive); + + var (switchEvent, affected) = context.TickSwitch(switchNumber, stillOnIt, now, pushDuration); + if (affected is null) { - if (await GetPersistentGuildAsync(player).ConfigureAwait(false) is { } guild - && context.IsRegistered(guild.Id)) - { - holder = guild.Id; - break; - } + continue; } - context.SetSwitchHolder(switchNumber, holder); + if (switchEvent == CastleSiegeSwitchEvent.Held) + { + // Repeat the info so the HUD picks up the names (the client only stores them from the + // second packet on, because the first one allocates its table). + await BroadcastSwitchInfoAsync(gameContext, affected.SwitchObjectId, affected).ConfigureAwait(false); + } + else if (switchEvent == CastleSiegeSwitchEvent.Released) + { + await BroadcastSwitchInfoAsync(gameContext, affected.SwitchObjectId, null).ConfigureAwait(false); + + // Close the client's progress box of the player who left, if they are still around. + await CloseSwitchBoxAsync(map, affected).ConfigureAwait(false); + } } - var now = DateTime.UtcNow; - - // Crown-hold capture: when a guild holds both switches with every defense down, the crown shield - // drops; that guild's master then holds the crown for the configured time to take the throne. + // Crown-hold capture: while one guild holds both switches the crown shield drops for it, and its + // master captures the throne by clicking the crown and holding it for the configured time. var eligible = context.GetShieldEligibleGuild(); Player? masterPlayer = null; string? eligibleName = null; @@ -464,13 +543,25 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom switch (crown.Event) { case CrownEvent.HoldStarted when masterPlayer is not null: - // The registration panel is shown ONLY to the master taking the crown. + // The registration panel is shown ONLY to the master taking the crown. Remember them: + // the hold usually breaks BECAUSE they walked off the crown, and their client still has + // the panel open, so the cancel has to reach the player we started it for. + CrownHoldPlayers[gameContext] = masterPlayer; await masterPlayer.InvokeViewPlugInAsync(p => p.SetCrownRegistAsync(0, 0)).ConfigureAwait(false); break; - case CrownEvent.HoldReset when masterPlayer is not null: - await masterPlayer.InvokeViewPlugInAsync(p => p.SetCrownRegistAsync(2, 0)).ConfigureAwait(false); + case CrownEvent.HoldReset: + if (CrownHoldPlayers.TryRemove(gameContext, out var holdPlayer)) + { + await holdPlayer.InvokeViewPlugInAsync(p => p.SetCrownRegistAsync(2, 0)).ConfigureAwait(false); + } + break; case CrownEvent.Captured when crown.GuildName is { } captured: + if (CrownHoldPlayers.TryRemove(gameContext, out var capturingPlayer)) + { + await capturingPlayer.InvokeViewPlugInAsync(p => p.SetCrownRegistAsync(1, 0)).ConfigureAwait(false); + } + await ForEachOnBattleMapAsync(gameContext, p => p.AnnounceSealCapturedAsync(captured)).ConfigureAwait(false); await AnnounceAsync(gameContext, $"Guild '{captured}' has taken the Crown and now holds the throne!").ConfigureAwait(false); break; @@ -478,9 +569,10 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom break; } - // Keep the client's on-map countdown armed and in sync. Resend every 10s so players who just - // loaded the battle map pick it up, without visibly resetting the second-counter too often. - if ((int)(now - context.StateStartedUtc).TotalSeconds % 10 == 0) + // Keep the client's on-map countdown armed and in sync. Resend every 10 ticks so players who just + // loaded the battle map pick it up, without visibly resetting the second-counter too often. This + // counts ticks instead of matching a clock second, which a delayed tick would skip silently. + if (GetCounters(gameContext).NextSiegeState()) { var remaining = context.GetRemainingSiegeTime(now); var totalMinutes = (int)Math.Ceiling(remaining.TotalMinutes); @@ -514,6 +606,24 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom }); /// Invokes the Castle Siege status view for every player currently on the battle map. + /// + /// Tells everybody on the battle map who is operating a Crown Switch. Besides driving the client's HUD + /// list, this is what makes the client allocate its switch table, so it has to be sent before any + /// "switch released" packet reaches that client. + /// + /// The game context. + /// The switch NPC's object identifier. + /// The operation, or when the switch became free. + internal static ValueTask BroadcastSwitchInfoAsync(IGameContext gameContext, ushort switchObjectId, CastleSiegeSwitchOperation? operation) + => ForEachOnBattleMapAsync( + gameContext, + p => p.SetCrownSwitchInfoAsync( + switchObjectId, + operation is null ? (byte)0 : (byte)1, + (byte)CastleSiegeJoinSide.Attack1, + operation?.GuildName ?? string.Empty, + operation?.PlayerName ?? string.Empty)); + private static ValueTask ForEachOnBattleMapAsync(IGameContext gameContext, Func action) => gameContext.ForEachPlayerAsync(player => player.CurrentMap?.Definition.Number == ValleyOfLorenMapNumber @@ -693,4 +803,30 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom this._cachedFlagLogo = logo; return logo; } + + /// + /// Counts the ticks between the periodic broadcasts of one game context. + /// + private sealed class BroadcastCounters + { + private int _siegeState; + private int _castleFlag; + + /// Advances the countdown-broadcast counter and tells whether it is due. + public bool NextSiegeState() => Due(ref this._siegeState, SiegeStateBroadcastTicks); + + /// Advances the castle-flag-broadcast counter and tells whether it is due. + public bool NextCastleFlag() => Due(ref this._castleFlag, CastleFlagBroadcastTicks); + + private static bool Due(ref int counter, int period) + { + if (++counter < period) + { + return false; + } + + counter = 0; + return true; + } + } } diff --git a/src/GameLogic/Views/CastleSiege/ICastleSiegeStatusViewPlugIn.cs b/src/GameLogic/Views/CastleSiege/ICastleSiegeStatusViewPlugIn.cs index f99a79b..7050afe 100644 --- a/src/GameLogic/Views/CastleSiege/ICastleSiegeStatusViewPlugIn.cs +++ b/src/GameLogic/Views/CastleSiege/ICastleSiegeStatusViewPlugIn.cs @@ -49,4 +49,29 @@ public interface ICastleSiegeStatusViewPlugIn : IViewPlugIn /// /// The capturing guild's name (max 8 bytes). ValueTask AnnounceSealCapturedAsync(string guildName); + + /// + /// Sends the state of a Crown Switch (C1 B2 14): state 0 = released (the client closes its progress + /// box), 1 = this player is operating it (the client opens its hold progress box), 2 = somebody else + /// is already operating it. + /// + /// The Crown Switch NPC's object identifier. + /// The operating player's object identifier. + /// The switch state (0 released, 1 operated by this player, 2 operated by another). + ValueTask SetCrownSwitchStateAsync(ushort switchObjectId, ushort playerObjectId, byte state); + + /// + /// Sends who is operating a Crown Switch (C1 B2 20), which the client lists on the siege HUD. + /// + /// This has to reach a client BEFORE any with state 0: the client + /// allocates its switch table when this arrives, and its "switch released" handler reads that table + /// without checking whether it exists. + /// + /// + /// The Crown Switch NPC's object identifier. + /// 0 when nobody operates it, 1 while it is operated. + /// The operating side (see the castle siege join sides). + /// The operating guild's name (max 8 bytes), empty when free. + /// The operating player's name (max 10 bytes), empty when free. + ValueTask SetCrownSwitchInfoAsync(ushort switchObjectId, byte switchState, byte joinSide, string guildName, string playerName); } diff --git a/src/GameServer/RemoteView/CastleSiege/CastleSiegeStatusViewPlugIn.cs b/src/GameServer/RemoteView/CastleSiege/CastleSiegeStatusViewPlugIn.cs index 80b8e99..9cfc2d5 100644 --- a/src/GameServer/RemoteView/CastleSiege/CastleSiegeStatusViewPlugIn.cs +++ b/src/GameServer/RemoteView/CastleSiege/CastleSiegeStatusViewPlugIn.cs @@ -26,6 +26,17 @@ public class CastleSiegeStatusViewPlugIn : ICastleSiegeStatusViewPlugIn /// The player. public CastleSiegeStatusViewPlugIn(RemotePlayer player) => this._player = player; + private static void WriteName(string name, Span target) + { + if (name.Length == 0) + { + return; + } + + var bytes = System.Text.Encoding.UTF8.GetBytes(name); + bytes.AsSpan(0, Math.Min(target.Length - 1, bytes.Length)).CopyTo(target); + } + /// public async ValueTask SetBattleStateAsync(bool started) { @@ -153,6 +164,65 @@ public class CastleSiegeStatusViewPlugIn : ICastleSiegeStatusViewPlugIn await connection.SendAsync(WritePacket).ConfigureAwait(false); } + /// + public async ValueTask SetCrownSwitchStateAsync(ushort switchObjectId, ushort playerObjectId, byte state) + { + var connection = this._player.Connection; + if (connection is null) + { + return; + } + + int WritePacket() + { + // C1 09 B2 14 + var span = connection.Output.GetSpan(9)[..9]; + span.Clear(); + span[0] = 0xC1; + span[1] = 0x09; + span[2] = 0xB2; + span[3] = 0x14; + span[4] = (byte)(switchObjectId >> 8); + span[5] = (byte)switchObjectId; + span[6] = (byte)(playerObjectId >> 8); + span[7] = (byte)playerObjectId; + span[8] = state; + return span.Length; + } + + await connection.SendAsync(WritePacket).ConfigureAwait(false); + } + + /// + public async ValueTask SetCrownSwitchInfoAsync(ushort switchObjectId, byte switchState, byte joinSide, string guildName, string playerName) + { + var connection = this._player.Connection; + if (connection is null) + { + return; + } + + int WritePacket() + { + // C1 1B B2 20 + var span = connection.Output.GetSpan(27)[..27]; + span.Clear(); + span[0] = 0xC1; + span[1] = 0x1B; + span[2] = 0xB2; + span[3] = 0x20; + span[4] = (byte)(switchObjectId >> 8); + span[5] = (byte)switchObjectId; + span[6] = switchState; + span[7] = joinSide; + WriteName(guildName, span.Slice(8, 8)); + WriteName(playerName, span.Slice(16, 10)); + return span.Length; + } + + await connection.SendAsync(WritePacket).ConfigureAwait(false); + } + /// public async ValueTask AnnounceSealCapturedAsync(string guildName) { diff --git a/src/Persistence/Initialization/VersionSeasonSix/Events/CastleSiegeInitializer.cs b/src/Persistence/Initialization/VersionSeasonSix/Events/CastleSiegeInitializer.cs index 00e024a..ddee705 100644 --- a/src/Persistence/Initialization/VersionSeasonSix/Events/CastleSiegeInitializer.cs +++ b/src/Persistence/Initialization/VersionSeasonSix/Events/CastleSiegeInitializer.cs @@ -46,7 +46,7 @@ internal sealed class CastleSiegeInitializer : InitializerBase var configuration = this.Context.CreateNew(); configuration.Enabled = true; - configuration.CrownHoldTimeSeconds = 30; + configuration.CrownHoldTimeSeconds = 60; // The client's crown registration panel counts down from 60s. configuration.RegisterMinLevel = 200; configuration.RegisterMinMembers = 20; configuration.ParticipantRewardMinSeconds = 60; diff --git a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ApplyPendingUpdatesTool.cs b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ApplyPendingUpdatesTool.cs new file mode 100644 index 0000000..4ff0e5e --- /dev/null +++ b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/ApplyPendingUpdatesTool.cs @@ -0,0 +1,58 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Persistence.Initialization.Tests; + +using Microsoft.Extensions.Logging.Abstractions; +using MUnique.OpenMU.Interfaces; +using MUnique.OpenMU.Persistence.EntityFramework; +using MUnique.OpenMU.Persistence.EntityFramework.Json; +using MUnique.OpenMU.Persistence.Initialization.Updates; +using MUnique.OpenMU.PlugIns; + +/// +/// A manual tool, not a test: applies the pending configuration updates to the database configured in +/// ConnectionSettings.xml - the same thing the admin panel's "Updates" page does, for when there is +/// no browser at hand. It is so it never runs in a normal test pass. +/// +[TestFixture] +[Explicit("Writes to the configured database. Run it deliberately, not as part of the test suite.")] +internal class ApplyPendingUpdatesTool +{ + /// + /// Applies every configuration update which is not installed yet. + /// + [Test] + public async Task ApplyPendingUpdatesAsync() + { + // The server registers these at startup; without them the configuration JSON cannot be read back. + JsonConverterRegistry.RegisterConverter(new LocalizedStringJsonConverter()); + JsonConverterRegistry.RegisterConverter(new BinaryAsHexJsonConverter()); + + var loggerFactory = new NullLoggerFactory(); + var contextProvider = new PersistenceContextProvider(loggerFactory, null); + var plugInManager = new PlugInManager(null, loggerFactory, null, null); + plugInManager.DiscoverAndRegisterPlugIns(); + + var service = new DataUpdateService(contextProvider, plugInManager); + var pending = (await service.DetermineAvailableUpdatesAsync().ConfigureAwait(false)).ToList(); + TestContext.Out.WriteLine($"Pending updates: {pending.Count}"); + foreach (var update in pending) + { + TestContext.Out.WriteLine($" {(int)update.Version} - {update.Name}"); + } + + if (pending.Count == 0) + { + return; + } + + var progress = new Progress<(UpdateVersion CurrentUpdatingVersion, bool IsCompleted)>( + p => TestContext.Out.WriteLine($" applying {(int)p.CurrentUpdatingVersion} completed={p.IsCompleted}")); + await service.ApplyUpdatesAsync(pending, progress).ConfigureAwait(false); + + var left = await service.DetermineAvailableUpdatesAsync().ConfigureAwait(false); + Assert.That(left, Is.Empty, "all updates should be installed now"); + } +} diff --git a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/TestInitializationWithEfCore.cs b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/TestInitializationWithEfCore.cs index b7f40be..d0407c1 100644 --- a/tests/MUnique.OpenMU.Persistence.Initialization.Tests/TestInitializationWithEfCore.cs +++ b/tests/MUnique.OpenMU.Persistence.Initialization.Tests/TestInitializationWithEfCore.cs @@ -7,6 +7,7 @@ namespace MUnique.OpenMU.Persistence.Initialization.Tests; using Microsoft.Extensions.Logging.Abstractions; using MUnique.OpenMU.DataModel; using MUnique.OpenMU.DataModel.Configuration; +using MUnique.OpenMU.DataModel.Entities; using MUnique.OpenMU.GameLogic; using MUnique.OpenMU.Persistence.EntityFramework; using MUnique.OpenMU.Persistence.Initialization.Updates; @@ -91,6 +92,48 @@ internal class TestInitializationWithEfCore Assert.That(groups[0].PossibleItems.Single().Number, Is.EqualTo((short)14)); } + /// + /// Tests that the Castle Siege update writes its configuration into an existing Season 6 database, and + /// that applying it twice does not duplicate anything. This is the update which existing servers run to + /// get the castle: without it there is no Castle Siege configuration for the event to read. + /// + [Test] + public async Task TestSeason6CastleSiegeUpdatePlugInAsync() + { + var contextProvider = new InMemoryPersistenceContextProvider(); + var dataInitialization = new VersionSeasonSix.DataInitialization(contextProvider, new NullLoggerFactory()); + await dataInitialization.CreateInitialDataAsync(1, true).ConfigureAwait(false); + + using var context = contextProvider.CreateNewContext(); + var gameConfiguration = (await context.GetAsync().ConfigureAwait(false)).First(); + gameConfiguration.CastleSiegeConfiguration = null; + + var update = new AddCastleSiegeDataUpdatePlugIn(); + await update.ApplyUpdateAsync(context, gameConfiguration).ConfigureAwait(false); + await update.ApplyUpdateAsync(context, gameConfiguration).ConfigureAwait(false); + + var castleSiege = gameConfiguration.CastleSiegeConfiguration; + Assert.That(castleSiege, Is.Not.Null); + Assert.That(castleSiege!.Enabled, Is.True); + + // The client's crown registration panel counts down from 60 seconds, so the server has to match it. + Assert.That(castleSiege.CrownHoldTimeSeconds, Is.EqualTo(60)); + + // Both Crown Switches, the crown and the throne have to be there, or the siege cannot be finished. + var npcNumbers = castleSiege.NpcDefinitions.Select(n => n.MonsterDefinition?.Number).ToList(); + Assert.That(npcNumbers, Does.Contain((short)217), "Crown Switch 1"); + Assert.That(npcNumbers, Does.Contain((short)218), "Crown Switch 2"); + Assert.That(npcNumbers, Does.Contain((short)216), "Crown"); + + // Applying it twice must not double the NPC definitions. + Assert.That(npcNumbers.Count(n => n == 217), Is.EqualTo(1)); + Assert.That(npcNumbers.Count(n => n == 218), Is.EqualTo(1)); + + var data = (await context.GetAsync().ConfigureAwait(false)).ToList(); + Assert.That(data, Has.Count.EqualTo(1), "exactly one castle state row"); + Assert.That(data[0].IsOccupied, Is.False, "a fresh castle has no owner"); + } + /// /// Tests the data initialization using the in-memory persistence. /// diff --git a/tests/MUnique.OpenMU.Tests/CastleSiege/CastleSiegeContextTest.cs b/tests/MUnique.OpenMU.Tests/CastleSiege/CastleSiegeContextTest.cs index 66ef0ec..6ca1cf7 100644 --- a/tests/MUnique.OpenMU.Tests/CastleSiege/CastleSiegeContextTest.cs +++ b/tests/MUnique.OpenMU.Tests/CastleSiege/CastleSiegeContextTest.cs @@ -4,143 +4,208 @@ namespace MUnique.OpenMU.Tests.CastleSiege; +using MUnique.OpenMU.DataModel.Configuration; using MUnique.OpenMU.GameLogic.CastleSiege; /// -/// 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. /// [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 starts in the ownership (resting) phase. + /// Tests that a fresh context rests in the idle state. [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)); } - /// Tests that force-starting moves the state machine into registration. + /// 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.Phase, Is.EqualTo(CastleSiegePhase.Registration)); + Assert.That(ctx.State, Is.EqualTo(CastleSiegeState.RegisterGuild)); } - /// Tests that registration advances to preparation once its duration elapses. + /// Tests that registration advances to the preparation state once its duration elapses. [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)); } - /// Tests a full cycle: registration -> preparation -> siege -> settlement -> ownership. + /// Tests a full cycle: register -> ready -> start -> end -> end cycle -> idle. [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)); } - /// Tests that guilds can be registered (by name) during the registration phase. + /// Tests that guilds are collected by id during the registration state. [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")); } - /// Tests the full siege objective chain: destroy defenses, then hold both switches to capture the throne. + /// + /// 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.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")); } - /// Tests that two different guilds each holding one switch cannot capture the throne. + /// Tests that two different guilds each holding one switch keep the crown's shield up. [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); } - /// Tests that capturing the throne outside the siege phase is a no-op. + /// Tests that the switches don't work at all outside the running siege. [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); } - /// Tests that restoring persisted state sets phase/owner/registrations without raising PhaseChanged. + /// Tests that a switch belongs to the first player who clicked it, until they leave its area. [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)); + } + + /// 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.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"); } - /// Tests that the weekly auto-schedule (stored in config) only fires on a matching day/time window. + /// Tests that the weekly auto-schedule (stored in the settings) only fires on a matching day/time window. [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); } /// Tests that the remaining siege time counts down during the siege and is zero otherwise. [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)); } /// Tests that losing a switch mid-hold resets the crown-hold progress (contestable). [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); } /// 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 = 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 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),