From f8e856c7c6f066a5cb0b325fa297b0839920007b Mon Sep 17 00:00:00 2001 From: Acentech Dev Date: Tue, 4 Aug 2026 21:44:12 +0300 Subject: [PATCH] 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) --- .../Configuration/MonsterDefinition.cs | 10 + .../CastleSiege/CastleSiegeContext.cs | 175 ++++++---- .../CastleSiege/CastleSiegeSettings.cs | 6 + .../CastleSiege/CastleSiegeSwitchEvent.cs | 20 ++ .../CastleSiege/CastleSiegeSwitchOperation.cs | 55 ++++ .../CastleSiege/CastleSiegeSwitchPush.cs | 23 ++ .../CastleSiegeSwitchTalkPlugIn.cs | 92 ++++++ .../CastleSiegeThroneCaptureTalkPlugIn.cs | 36 ++- .../CastleSiegePhaseChatCommandPlugIn.cs | 2 +- .../PeriodicTasks/CastleSiegeEventPlugIn.cs | 184 +++++++++-- .../ICastleSiegeStatusViewPlugIn.cs | 25 ++ .../CastleSiegeStatusViewPlugIn.cs | 70 ++++ .../Events/CastleSiegeInitializer.cs | 2 +- .../ApplyPendingUpdatesTool.cs | 58 ++++ .../TestInitializationWithEfCore.cs | 43 +++ .../CastleSiege/CastleSiegeContextTest.cs | 298 +++++++++++------- 16 files changed, 893 insertions(+), 206 deletions(-) create mode 100644 src/GameLogic/CastleSiege/CastleSiegeSwitchEvent.cs create mode 100644 src/GameLogic/CastleSiege/CastleSiegeSwitchOperation.cs create mode 100644 src/GameLogic/CastleSiege/CastleSiegeSwitchPush.cs create mode 100644 src/GameLogic/CastleSiege/CastleSiegeSwitchTalkPlugIn.cs create mode 100644 tests/MUnique.OpenMU.Persistence.Initialization.Tests/ApplyPendingUpdatesTool.cs 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),