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),