From ee72a7cbdd870670cd565ef6d89abc89f2cf7aa4 Mon Sep 17 00:00:00 2001
From: Zylkien <283095668+Zylkien@users.noreply.github.com>
Date: Wed, 26 Aug 2026 15:52:28 +0200
Subject: [PATCH 1/5] Implement Castle Siege Crown capture mechanics
Validate Crown and switch occupants, preserve interrupted capture progress,
and swap participant sides after successful captures.
Persist the final owner and reset the Castle Siege economy when ownership
changes. Add Crown, switch, capture-progress, and ownership remote views,
together with regression tests for mechanics and packet serialization.
---
.../CastleSiege/CastleSiegeContext.cs | 5 +
.../CastleSiege/CastleSiegeCrownMechanics.cs | 274 ++++++++
.../CastleSiege/CastleSiegePlugIn.cs | 21 +-
.../CastleSiege/CastleSiegeSwitchMechanics.cs | 90 +++
.../CastleSiegeCrownAccessState.cs | 26 +
.../CastleSiege/CastleSiegeSwitchInfo.cs | 22 +
.../ICastleSiegeCrownAccessStatePlugIn.cs | 21 +
.../ICastleSiegeCrownStatePlugIn.cs | 18 +
.../ICastleSiegeOwnershipChangePlugIn.cs | 18 +
.../ICastleSiegeSwitchInfoPlugIn.cs | 18 +
.../Properties/PlugInResources.Designer.cs | 72 ++
.../Properties/PlugInResources.resx | 24 +
.../CastleSiegeCrownAccessStatePlugIn.cs | 37 ++
.../CastleSiegeCrownStatePlugIn.cs | 33 +
.../CastleSiegeOwnershipChangePlugIn.cs | 34 +
.../CastleSiegeSwitchInfoPlugIn.cs | 38 ++
.../CastleSiegeCrownMechanicsTests.cs | 613 ++++++++++++++++++
.../CastleSiegeCrownRemoteViewTests.cs | 115 ++++
18 files changed, 1476 insertions(+), 3 deletions(-)
create mode 100644 src/GameLogic/CastleSiege/CastleSiegeCrownMechanics.cs
create mode 100644 src/GameLogic/CastleSiege/CastleSiegeSwitchMechanics.cs
create mode 100644 src/GameLogic/Views/CastleSiege/CastleSiegeCrownAccessState.cs
create mode 100644 src/GameLogic/Views/CastleSiege/CastleSiegeSwitchInfo.cs
create mode 100644 src/GameLogic/Views/CastleSiege/ICastleSiegeCrownAccessStatePlugIn.cs
create mode 100644 src/GameLogic/Views/CastleSiege/ICastleSiegeCrownStatePlugIn.cs
create mode 100644 src/GameLogic/Views/CastleSiege/ICastleSiegeOwnershipChangePlugIn.cs
create mode 100644 src/GameLogic/Views/CastleSiege/ICastleSiegeSwitchInfoPlugIn.cs
create mode 100644 src/GameServer/RemoteView/CastleSiege/CastleSiegeCrownAccessStatePlugIn.cs
create mode 100644 src/GameServer/RemoteView/CastleSiege/CastleSiegeCrownStatePlugIn.cs
create mode 100644 src/GameServer/RemoteView/CastleSiege/CastleSiegeOwnershipChangePlugIn.cs
create mode 100644 src/GameServer/RemoteView/CastleSiege/CastleSiegeSwitchInfoPlugIn.cs
create mode 100644 tests/MUnique.OpenMU.Tests/CastleSiegeCrownMechanicsTests.cs
create mode 100644 tests/MUnique.OpenMU.Tests/CastleSiegeCrownRemoteViewTests.cs
diff --git a/src/GameLogic/CastleSiege/CastleSiegeContext.cs b/src/GameLogic/CastleSiege/CastleSiegeContext.cs
index 6d0b94a9c..54eca362e 100644
--- a/src/GameLogic/CastleSiege/CastleSiegeContext.cs
+++ b/src/GameLogic/CastleSiege/CastleSiegeContext.cs
@@ -125,6 +125,11 @@ public CastleSiegeContext(IGameContext gameContext, CastleSiegeConfiguration con
///
public TimeSpan RemainingTime => this.GetRemainingTime(DateTime.UtcNow);
+ ///
+ /// Gets or sets the player whose active Crown attempt was announced to the client.
+ ///
+ internal Player? PreviousCrownUser { get; set; }
+
///
/// Gets a value indicating whether the context has been initialized.
///
diff --git a/src/GameLogic/CastleSiege/CastleSiegeCrownMechanics.cs b/src/GameLogic/CastleSiege/CastleSiegeCrownMechanics.cs
new file mode 100644
index 000000000..528c1387c
--- /dev/null
+++ b/src/GameLogic/CastleSiege/CastleSiegeCrownMechanics.cs
@@ -0,0 +1,274 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.CastleSiege;
+
+using MUnique.OpenMU.DataModel.Configuration;
+using MUnique.OpenMU.GameLogic.CastleSiege.NPC;
+using MUnique.OpenMU.GameLogic.Views.CastleSiege;
+
+///
+/// Implements Castle Siege Crown capture and ownership changes.
+///
+public static class CastleSiegeCrownMechanics
+{
+ private static readonly TimeSpan TickInterval = TimeSpan.FromSeconds(1);
+
+ ///
+ /// Checks whether the Crown and both switches are held by the same attacking side.
+ ///
+ /// The Castle Siege context.
+ /// A task that represents the asynchronous check operation.
+ public static async ValueTask CheckMiddleWinnerAsync(CastleSiegeContext context)
+ {
+ var crownUser = context.CrownUser;
+ var previousCrownUser = context.PreviousCrownUser;
+ if (previousCrownUser is not null
+ && !ReferenceEquals(previousCrownUser, crownUser))
+ {
+ await FailAttemptAsync(context, previousCrownUser).ConfigureAwait(false);
+ context.PreviousCrownUser = null;
+ }
+
+ var captureSide = GetCaptureSide(context, crownUser);
+ if (captureSide is null)
+ {
+ if (context.PreviousCrownUser is { } interruptedUser)
+ {
+ await FailAttemptAsync(context, interruptedUser).ConfigureAwait(false);
+ context.PreviousCrownUser = null;
+ }
+ else
+ {
+ CapAccumulatedTime(context);
+ }
+
+ return;
+ }
+
+ context.CrownAccumulatedTime += TickInterval;
+ context.PreviousCrownUser = crownUser;
+ await SendAccessStateAsync(
+ crownUser!,
+ CastleSiegeCrownAccessState.Attempt,
+ context.CrownAccumulatedTime)
+ .ConfigureAwait(false);
+
+ var requiredTime = TimeSpan.FromSeconds(context.Configuration.CrownHoldTimeSeconds);
+ if (context.CrownAccumulatedTime < requiredTime)
+ {
+ return;
+ }
+
+ await SendAccessStateAsync(
+ crownUser!,
+ CastleSiegeCrownAccessState.Success,
+ context.CrownAccumulatedTime)
+ .ConfigureAwait(false);
+ await ChangeWinnerGuildAsync(context, crownUser!, captureSide.Value).ConfigureAwait(false);
+ }
+
+ ///
+ /// Changes the intermediate owner after a successful Crown capture.
+ ///
+ /// The Castle Siege context.
+ /// The player who captured the Crown.
+ /// The attacking side which captured the Crown.
+ /// A task that represents the asynchronous ownership change.
+ public static async ValueTask ChangeWinnerGuildAsync(
+ CastleSiegeContext context,
+ Player crownUser,
+ CastleSiegeJoinSide capturingSide)
+ {
+ if (crownUser.GuildStatus is not { } guildStatus
+ || !context.FinalGuildList.TryGetValue(guildStatus.GuildId, out var capturingGuild)
+ || !IsAttackingSide(capturingSide)
+ || capturingGuild.Side != capturingSide)
+ {
+ throw new InvalidOperationException("The Crown winner is not a selected attacking guild.");
+ }
+
+ context.MiddleOwnerGuildId = guildStatus.GuildId;
+ foreach (var guild in context.FinalGuildList.Values)
+ {
+ if (guild.Side == capturingSide)
+ {
+ guild.Side = CastleSiegeJoinSide.Defense;
+ }
+ else if (guild.Side == CastleSiegeJoinSide.Defense)
+ {
+ guild.Side = capturingSide;
+ }
+ }
+
+ await context.SaveFinalGuildListAsync().ConfigureAwait(false);
+ await context.SetPlayerJoinSideAsync().ConfigureAwait(false);
+ await RespawnAttackersAsync(context).ConfigureAwait(false);
+
+ context.IsCrownAvailable = false;
+ foreach (var crown in context.ActiveNpcs
+ .Select(runtime => runtime.SpawnedInstance)
+ .OfType())
+ {
+ crown.State = CastleSiegeCrownState.Locked;
+ }
+
+ context.CrownAccumulatedTime = TimeSpan.Zero;
+ context.CrownUser = null;
+ context.PreviousCrownUser = null;
+ Array.Clear(context.SwitchUsers);
+ await BroadcastOwnershipAsync(context, capturingGuild.GuildName).ConfigureAwait(false);
+ }
+
+ ///
+ /// Applies and persists the final Castle Siege result.
+ ///
+ /// The Castle Siege context.
+ /// A task that represents the asynchronous result operation.
+ public static async ValueTask CheckResultAsync(CastleSiegeContext context)
+ {
+ CastleSiegeGuildParticipant? winner = null;
+ if (context.MiddleOwnerGuildId is { } middleOwnerGuildId)
+ {
+ winner = context.FinalGuildList.TryGetValue(middleOwnerGuildId, out var participant)
+ ? participant
+ : throw new InvalidOperationException("The intermediate Castle Siege owner is not in the selected guild list.");
+ }
+
+ if (winner is not null
+ && (!context.SiegeData.IsOccupied
+ || context.SiegeData.OwnerGuildId != winner.PersistentGuildId))
+ {
+ context.SiegeData.OwnerGuildId = winner.PersistentGuildId;
+ context.SiegeData.IsOccupied = true;
+ context.SiegeData.TaxChaos = 0;
+ context.SiegeData.TaxStore = 0;
+ context.SiegeData.TaxHunt = 0;
+ context.SiegeData.TributeMoney = 0;
+ }
+
+ await context.SaveOwnerAsync().ConfigureAwait(false);
+ var ownerName = winner?.GuildName
+ ?? await GetOwnerGuildNameAsync(context).ConfigureAwait(false)
+ ?? string.Empty;
+ await BroadcastOwnershipAsync(context, ownerName).ConfigureAwait(false);
+ }
+
+ private static CastleSiegeJoinSide? GetCaptureSide(CastleSiegeContext context, Player? crownUser)
+ {
+ if (crownUser is not { IsAlive: true, GuildStatus: not null }
+ || context.SwitchUsers[0] is not { IsAlive: true, GuildStatus: not null } firstSwitchUser
+ || context.SwitchUsers[1] is not { IsAlive: true, GuildStatus: not null } secondSwitchUser)
+ {
+ return null;
+ }
+
+ var crownSide = context.GetPlayerJoinSide(crownUser);
+ return IsAttackingSide(crownSide)
+ && context.GetPlayerJoinSide(firstSwitchUser) == crownSide
+ && context.GetPlayerJoinSide(secondSwitchUser) == crownSide
+ ? crownSide
+ : null;
+ }
+
+ private static bool IsAttackingSide(CastleSiegeJoinSide side)
+ {
+ return side is not CastleSiegeJoinSide.None and not CastleSiegeJoinSide.Defense;
+ }
+
+ private static async ValueTask FailAttemptAsync(CastleSiegeContext context, Player player)
+ {
+ CapAccumulatedTime(context);
+ await SendAccessStateAsync(
+ player,
+ CastleSiegeCrownAccessState.Fail,
+ context.CrownAccumulatedTime)
+ .ConfigureAwait(false);
+ }
+
+ private static void CapAccumulatedTime(CastleSiegeContext context)
+ {
+ var maximumSeconds = Math.Max(context.Configuration.CrownHoldTimeSeconds, 1) - 1;
+ var maximumTime = TimeSpan.FromSeconds(maximumSeconds);
+ if (context.CrownAccumulatedTime > maximumTime)
+ {
+ context.CrownAccumulatedTime = maximumTime;
+ }
+ }
+
+ private static ValueTask SendAccessStateAsync(
+ Player player,
+ CastleSiegeCrownAccessState state,
+ TimeSpan accumulatedTime)
+ {
+ return player.InvokeViewPlugInAsync(
+ plugIn => plugIn.ShowCrownAccessStateAsync(state, accumulatedTime));
+ }
+
+ private static async ValueTask RespawnAttackersAsync(CastleSiegeContext context)
+ {
+ if (context.Configuration.AttackRespawnArea is not { } respawnArea
+ || context.Configuration.CastleSiegeMapDefinition is not { } siegeMap)
+ {
+ return;
+ }
+
+ var respawnGate = new ExitGate
+ {
+ Map = siegeMap,
+ X1 = respawnArea.X1,
+ Y1 = respawnArea.Y1,
+ X2 = respawnArea.X2,
+ Y2 = respawnArea.Y2,
+ Direction = Direction.South,
+ };
+ foreach (var player in context.GetSiegePlayers())
+ {
+ if (IsAttackingSide(context.GetPlayerJoinSide(player)))
+ {
+ await player.RespawnAtAsync(respawnGate).ConfigureAwait(false);
+ }
+ }
+ }
+
+ private static async ValueTask GetOwnerGuildNameAsync(CastleSiegeContext context)
+ {
+ if (context.SiegeData.OwnerGuildId is not { } ownerGuildId)
+ {
+ return null;
+ }
+
+ var selectedOwner = context.FinalGuildList.Values
+ .FirstOrDefault(guild => guild.PersistentGuildId == ownerGuildId);
+ if (selectedOwner is not null)
+ {
+ return selectedOwner.GuildName;
+ }
+
+ if (context.GameContext is not IGameServerContext gameServerContext)
+ {
+ return null;
+ }
+
+ var runtimeGuildId = await gameServerContext.GuildServer
+ .GetGuildIdAsync(ownerGuildId)
+ .ConfigureAwait(false);
+ return runtimeGuildId == 0
+ ? null
+ : (await gameServerContext.GuildServer
+ .GetGuildAsync(runtimeGuildId)
+ .ConfigureAwait(false))
+ ?.Name;
+ }
+
+ private static async ValueTask BroadcastOwnershipAsync(CastleSiegeContext context, string guildName)
+ {
+ await context.ForEachSiegePlayerAsync(async player =>
+ {
+ await player.InvokeViewPlugInAsync(
+ plugIn => plugIn.ShowOwnershipChangeAsync(guildName))
+ .ConfigureAwait(false);
+ }).ConfigureAwait(false);
+ }
+}
diff --git a/src/GameLogic/CastleSiege/CastleSiegePlugIn.cs b/src/GameLogic/CastleSiege/CastleSiegePlugIn.cs
index f6b062760..3f937aa3f 100644
--- a/src/GameLogic/CastleSiege/CastleSiegePlugIn.cs
+++ b/src/GameLogic/CastleSiege/CastleSiegePlugIn.cs
@@ -257,7 +257,11 @@ private async ValueTask ChangeStateAsync(CastleSiegeContext context, CastleSiege
context.SetPeriod(period);
this.ConfigureNotifications(context, period.StartUtc);
await this.OnEnterStateAsync(context, false).ConfigureAwait(false);
- await context.SaveOwnerAsync().ConfigureAwait(false);
+ if (period.State != CastleSiegeState.End)
+ {
+ await context.SaveOwnerAsync().ConfigureAwait(false);
+ }
+
await this.BroadcastStateUpdateAsync(context).ConfigureAwait(false);
logger.LogInformation(
@@ -325,6 +329,11 @@ private async ValueTask OnEnterStateAsync(CastleSiegeContext context, bool isSta
context.ParticipantTracking.Clear();
}
+ context.CrownUser = null;
+ context.PreviousCrownUser = null;
+ Array.Clear(context.SwitchUsers);
+ context.CrownAccumulatedTime = TimeSpan.Zero;
+ context.IsCrownAvailable = false;
await context.NpcController.PrepareAsync().ConfigureAwait(false);
await context.NpcController.CloseGatesAsync().ConfigureAwait(false);
await context.NpcController.SpawnMachinesAsync().ConfigureAwait(false);
@@ -334,6 +343,7 @@ private async ValueTask OnEnterStateAsync(CastleSiegeContext context, bool isSta
context.NextParticipantUpdateUtc = utcNow + ParticipantUpdateInterval;
break;
case CastleSiegeState.End:
+ await CastleSiegeCrownMechanics.CheckResultAsync(context).ConfigureAwait(false);
if (!isStartup)
{
await CastleSiegeParticipantTracker.AwardRewardsAsync(context).ConfigureAwait(false);
@@ -353,6 +363,7 @@ private async ValueTask OnEnterStateAsync(CastleSiegeContext context, bool isSta
await context.NpcController.DespawnAllAsync().ConfigureAwait(false);
context.MiddleOwnerGuildId = null;
context.CrownUser = null;
+ context.PreviousCrownUser = null;
Array.Clear(context.SwitchUsers);
context.CrownAccumulatedTime = TimeSpan.Zero;
context.IsCrownAvailable = false;
@@ -394,13 +405,17 @@ await CastleSiegeParticipantTracker
ParticipantUpdateInterval);
}
+ if (context.CurrentState == CastleSiegeState.Start)
+ {
+ await CastleSiegeSwitchMechanics.SendSwitchInfoAsync(context).ConfigureAwait(false);
+ await CastleSiegeCrownMechanics.CheckMiddleWinnerAsync(context).ConfigureAwait(false);
+ }
+
if (!context.IsEventRunning && context.NextNpcSaveUtc <= utcNow)
{
await context.SaveNpcStatesAsync().ConfigureAwait(false);
context.NextNpcSaveUtc = utcNow + NpcSaveInterval;
}
-
- // Start-state Crown, switch and mini-map ticks are implemented by their dedicated phases.
}
private async ValueTask SendReadyCountdownIfDueAsync(CastleSiegeContext context, DateTime utcNow)
diff --git a/src/GameLogic/CastleSiege/CastleSiegeSwitchMechanics.cs b/src/GameLogic/CastleSiege/CastleSiegeSwitchMechanics.cs
new file mode 100644
index 000000000..345044e04
--- /dev/null
+++ b/src/GameLogic/CastleSiege/CastleSiegeSwitchMechanics.cs
@@ -0,0 +1,90 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.CastleSiege;
+
+using MUnique.OpenMU.DataModel.Configuration;
+using MUnique.OpenMU.GameLogic.CastleSiege.NPC;
+using MUnique.OpenMU.GameLogic.Views.CastleSiege;
+
+///
+/// Broadcasts Castle Siege Crown switch occupants and Crown availability.
+///
+public static class CastleSiegeSwitchMechanics
+{
+ ///
+ /// Sends the current switch and Crown state to all players on the Castle Siege map.
+ ///
+ /// The Castle Siege context.
+ /// A task that represents the asynchronous broadcast operation.
+ public static async ValueTask SendSwitchInfoAsync(CastleSiegeContext context)
+ {
+ var switchInfos = context.ActiveNpcs
+ .Select(runtime => runtime.SpawnedInstance)
+ .OfType()
+ .OrderBy(candidate => candidate.SwitchIndex)
+ .Select(siegeSwitch => CreateSwitchInfo(
+ context,
+ siegeSwitch,
+ context.SwitchUsers[siegeSwitch.SwitchIndex]))
+ .ToList();
+
+ context.IsCrownAvailable = AreSwitchesHeldBySameAttackingSide(context);
+ foreach (var crown in context.ActiveNpcs
+ .Select(runtime => runtime.SpawnedInstance)
+ .OfType())
+ {
+ crown.State = context.IsCrownAvailable
+ ? CastleSiegeCrownState.Idle
+ : CastleSiegeCrownState.Locked;
+ }
+
+ await context.ForEachSiegePlayerAsync(async player =>
+ {
+ foreach (var switchInfo in switchInfos)
+ {
+ await player.InvokeViewPlugInAsync(
+ plugIn => plugIn.ShowSwitchInfoAsync(switchInfo))
+ .ConfigureAwait(false);
+ }
+
+ await player.InvokeViewPlugInAsync(
+ plugIn => plugIn.ShowCrownStateAsync(context.IsCrownAvailable))
+ .ConfigureAwait(false);
+ }).ConfigureAwait(false);
+ }
+
+ private static CastleSiegeSwitchInfo CreateSwitchInfo(
+ CastleSiegeContext context,
+ CastleSiegeSwitch siegeSwitch,
+ Player? occupant)
+ {
+ var side = occupant is null
+ ? CastleSiegeJoinSide.None
+ : context.GetPlayerJoinSide(occupant);
+ var guildName = occupant?.GuildStatus is { } guildStatus
+ && context.FinalGuildList.TryGetValue(guildStatus.GuildId, out var participant)
+ ? participant.GuildName
+ : string.Empty;
+ return new(
+ siegeSwitch.Id,
+ occupant is not null,
+ side,
+ guildName,
+ occupant?.Name ?? string.Empty);
+ }
+
+ private static bool AreSwitchesHeldBySameAttackingSide(CastleSiegeContext context)
+ {
+ if (context.SwitchUsers[0] is not { IsAlive: true, GuildStatus: not null } firstSwitchUser
+ || context.SwitchUsers[1] is not { IsAlive: true, GuildStatus: not null } secondSwitchUser)
+ {
+ return false;
+ }
+
+ var firstSide = context.GetPlayerJoinSide(firstSwitchUser);
+ return firstSide is not CastleSiegeJoinSide.None and not CastleSiegeJoinSide.Defense
+ && context.GetPlayerJoinSide(secondSwitchUser) == firstSide;
+ }
+}
diff --git a/src/GameLogic/Views/CastleSiege/CastleSiegeCrownAccessState.cs b/src/GameLogic/Views/CastleSiege/CastleSiegeCrownAccessState.cs
new file mode 100644
index 000000000..a6d77a3e4
--- /dev/null
+++ b/src/GameLogic/Views/CastleSiege/CastleSiegeCrownAccessState.cs
@@ -0,0 +1,26 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.Views.CastleSiege;
+
+///
+/// Defines the state of a Castle Siege Crown capture attempt.
+///
+public enum CastleSiegeCrownAccessState : byte
+{
+ ///
+ /// The player is actively operating the Crown.
+ ///
+ Attempt = 0,
+
+ ///
+ /// The player captured the Crown successfully.
+ ///
+ Success = 1,
+
+ ///
+ /// The player's Crown operation was interrupted.
+ ///
+ Fail = 2,
+}
diff --git a/src/GameLogic/Views/CastleSiege/CastleSiegeSwitchInfo.cs b/src/GameLogic/Views/CastleSiege/CastleSiegeSwitchInfo.cs
new file mode 100644
index 000000000..60fba6e3a
--- /dev/null
+++ b/src/GameLogic/Views/CastleSiege/CastleSiegeSwitchInfo.cs
@@ -0,0 +1,22 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.Views.CastleSiege;
+
+using MUnique.OpenMU.DataModel.Configuration;
+
+///
+/// Describes the current occupant of a Castle Siege Crown switch.
+///
+/// The switch object's network identifier.
+/// Whether a player currently occupies the switch.
+/// The occupant's Castle Siege side.
+/// The occupant's guild name.
+/// The occupant's character name.
+public sealed record CastleSiegeSwitchInfo(
+ ushort ObjectId,
+ bool IsOccupied,
+ CastleSiegeJoinSide JoinSide,
+ string GuildName,
+ string CharacterName);
diff --git a/src/GameLogic/Views/CastleSiege/ICastleSiegeCrownAccessStatePlugIn.cs b/src/GameLogic/Views/CastleSiege/ICastleSiegeCrownAccessStatePlugIn.cs
new file mode 100644
index 000000000..bcfc692d3
--- /dev/null
+++ b/src/GameLogic/Views/CastleSiege/ICastleSiegeCrownAccessStatePlugIn.cs
@@ -0,0 +1,21 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.Views.CastleSiege;
+
+///
+/// A view which updates a player's Castle Siege Crown capture attempt.
+///
+public interface ICastleSiegeCrownAccessStatePlugIn : IViewPlugIn
+{
+ ///
+ /// Shows the Crown access state and accumulated capture time.
+ ///
+ /// The access state.
+ /// The accumulated capture time.
+ /// A task that represents the asynchronous show operation.
+ ValueTask ShowCrownAccessStateAsync(
+ CastleSiegeCrownAccessState state,
+ TimeSpan accumulatedTime);
+}
diff --git a/src/GameLogic/Views/CastleSiege/ICastleSiegeCrownStatePlugIn.cs b/src/GameLogic/Views/CastleSiege/ICastleSiegeCrownStatePlugIn.cs
new file mode 100644
index 000000000..28c878751
--- /dev/null
+++ b/src/GameLogic/Views/CastleSiege/ICastleSiegeCrownStatePlugIn.cs
@@ -0,0 +1,18 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.Views.CastleSiege;
+
+///
+/// A view which updates the Castle Siege Crown lock state.
+///
+public interface ICastleSiegeCrownStatePlugIn : IViewPlugIn
+{
+ ///
+ /// Shows whether the Crown is available for capture.
+ ///
+ /// Whether the Crown is unlocked.
+ /// A task that represents the asynchronous show operation.
+ ValueTask ShowCrownStateAsync(bool isAvailable);
+}
diff --git a/src/GameLogic/Views/CastleSiege/ICastleSiegeOwnershipChangePlugIn.cs b/src/GameLogic/Views/CastleSiege/ICastleSiegeOwnershipChangePlugIn.cs
new file mode 100644
index 000000000..1765502fc
--- /dev/null
+++ b/src/GameLogic/Views/CastleSiege/ICastleSiegeOwnershipChangePlugIn.cs
@@ -0,0 +1,18 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.Views.CastleSiege;
+
+///
+/// A view which announces the current Castle Siege owner.
+///
+public interface ICastleSiegeOwnershipChangePlugIn : IViewPlugIn
+{
+ ///
+ /// Shows the guild which owns the castle.
+ ///
+ /// The owner guild name, or an empty string when the castle has no owner.
+ /// A task that represents the asynchronous show operation.
+ ValueTask ShowOwnershipChangeAsync(string guildName);
+}
diff --git a/src/GameLogic/Views/CastleSiege/ICastleSiegeSwitchInfoPlugIn.cs b/src/GameLogic/Views/CastleSiege/ICastleSiegeSwitchInfoPlugIn.cs
new file mode 100644
index 000000000..2d150c0e9
--- /dev/null
+++ b/src/GameLogic/Views/CastleSiege/ICastleSiegeSwitchInfoPlugIn.cs
@@ -0,0 +1,18 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.Views.CastleSiege;
+
+///
+/// A view which updates a Castle Siege Crown switch occupant.
+///
+public interface ICastleSiegeSwitchInfoPlugIn : IViewPlugIn
+{
+ ///
+ /// Shows the current state of a Crown switch.
+ ///
+ /// The switch state.
+ /// A task that represents the asynchronous show operation.
+ ValueTask ShowSwitchInfoAsync(CastleSiegeSwitchInfo switchInfo);
+}
diff --git a/src/GameServer/Properties/PlugInResources.Designer.cs b/src/GameServer/Properties/PlugInResources.Designer.cs
index b85ec8fef..9919d2fab 100644
--- a/src/GameServer/Properties/PlugInResources.Designer.cs
+++ b/src/GameServer/Properties/PlugInResources.Designer.cs
@@ -6665,5 +6665,77 @@ public static string WhisperedChatMessageHandlerPlugIn_Name {
return ResourceManager.GetString("WhisperedChatMessageHandlerPlugIn_Name", resourceCulture);
}
}
+
+ ///
+ /// Looks up a localized string similar to Sends Castle Siege Crown capture progress to the game client..
+ ///
+ public static string CastleSiegeCrownAccessStatePlugIn_Description {
+ get {
+ return ResourceManager.GetString("CastleSiegeCrownAccessStatePlugIn_Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Castle Siege Crown Access State View.
+ ///
+ public static string CastleSiegeCrownAccessStatePlugIn_Name {
+ get {
+ return ResourceManager.GetString("CastleSiegeCrownAccessStatePlugIn_Name", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Sends the current Castle Siege Crown lock state to the game client..
+ ///
+ public static string CastleSiegeCrownStatePlugIn_Description {
+ get {
+ return ResourceManager.GetString("CastleSiegeCrownStatePlugIn_Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Castle Siege Crown State View.
+ ///
+ public static string CastleSiegeCrownStatePlugIn_Name {
+ get {
+ return ResourceManager.GetString("CastleSiegeCrownStatePlugIn_Name", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Sends Castle Siege ownership changes to the game client..
+ ///
+ public static string CastleSiegeOwnershipChangePlugIn_Description {
+ get {
+ return ResourceManager.GetString("CastleSiegeOwnershipChangePlugIn_Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Castle Siege Ownership Change View.
+ ///
+ public static string CastleSiegeOwnershipChangePlugIn_Name {
+ get {
+ return ResourceManager.GetString("CastleSiegeOwnershipChangePlugIn_Name", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Sends Castle Siege Crown switch occupancy information to the game client..
+ ///
+ public static string CastleSiegeSwitchInfoPlugIn_Description {
+ get {
+ return ResourceManager.GetString("CastleSiegeSwitchInfoPlugIn_Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Castle Siege Switch Information View.
+ ///
+ public static string CastleSiegeSwitchInfoPlugIn_Name {
+ get {
+ return ResourceManager.GetString("CastleSiegeSwitchInfoPlugIn_Name", resourceCulture);
+ }
+ }
}
}
diff --git a/src/GameServer/Properties/PlugInResources.resx b/src/GameServer/Properties/PlugInResources.resx
index 8bb166f6d..344788335 100644
--- a/src/GameServer/Properties/PlugInResources.resx
+++ b/src/GameServer/Properties/PlugInResources.resx
@@ -2319,4 +2319,28 @@
Handles the request for the list of chat commands which are available to the player.
+
+ Castle Siege Crown State View
+
+
+ Sends the current Castle Siege Crown lock state to the game client.
+
+
+ Castle Siege Crown Access State View
+
+
+ Sends Castle Siege Crown capture progress to the game client.
+
+
+ Castle Siege Switch Information View
+
+
+ Sends Castle Siege Crown switch occupancy information to the game client.
+
+
+ Castle Siege Ownership Change View
+
+
+ Sends Castle Siege ownership changes to the game client.
+
diff --git a/src/GameServer/RemoteView/CastleSiege/CastleSiegeCrownAccessStatePlugIn.cs b/src/GameServer/RemoteView/CastleSiege/CastleSiegeCrownAccessStatePlugIn.cs
new file mode 100644
index 000000000..2c7dcc659
--- /dev/null
+++ b/src/GameServer/RemoteView/CastleSiege/CastleSiegeCrownAccessStatePlugIn.cs
@@ -0,0 +1,37 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameServer.RemoteView.CastleSiege;
+
+using System.Runtime.InteropServices;
+using MUnique.OpenMU.GameLogic.Views.CastleSiege;
+using MUnique.OpenMU.Network.Packets.ServerToClient;
+using MUnique.OpenMU.PlugIns;
+using CrownAccessState = MUnique.OpenMU.GameLogic.Views.CastleSiege.CastleSiegeCrownAccessState;
+
+///
+/// The default implementation of the
+/// which forwards Crown capture progress to the game client.
+///
+[PlugIn]
+[Display(Name = nameof(PlugInResources.CastleSiegeCrownAccessStatePlugIn_Name), Description = nameof(PlugInResources.CastleSiegeCrownAccessStatePlugIn_Description), ResourceType = typeof(PlugInResources))]
+[Guid("9F13B1D3-70F6-47A1-AE2A-A3F1BCC35A6A")]
+public class CastleSiegeCrownAccessStatePlugIn : ICastleSiegeCrownAccessStatePlugIn
+{
+ private readonly RemotePlayer _player;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The player.
+ public CastleSiegeCrownAccessStatePlugIn(RemotePlayer player) => this._player = player;
+
+ ///
+ public ValueTask ShowCrownAccessStateAsync(
+ CrownAccessState state,
+ TimeSpan accumulatedTime)
+ => this._player.Connection?.SendCastleSiegeCrownAccessStateAsync(
+ (CastleSiegeCrownAccessStateType)state,
+ checked((uint)accumulatedTime.TotalMilliseconds)) ?? default;
+}
diff --git a/src/GameServer/RemoteView/CastleSiege/CastleSiegeCrownStatePlugIn.cs b/src/GameServer/RemoteView/CastleSiege/CastleSiegeCrownStatePlugIn.cs
new file mode 100644
index 000000000..caa39b975
--- /dev/null
+++ b/src/GameServer/RemoteView/CastleSiege/CastleSiegeCrownStatePlugIn.cs
@@ -0,0 +1,33 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameServer.RemoteView.CastleSiege;
+
+using System.Runtime.InteropServices;
+using MUnique.OpenMU.GameLogic.Views.CastleSiege;
+using MUnique.OpenMU.Network.Packets.ServerToClient;
+using MUnique.OpenMU.PlugIns;
+
+///
+/// The default implementation of the
+/// which forwards the Crown lock state to the game client.
+///
+[PlugIn]
+[Display(Name = nameof(PlugInResources.CastleSiegeCrownStatePlugIn_Name), Description = nameof(PlugInResources.CastleSiegeCrownStatePlugIn_Description), ResourceType = typeof(PlugInResources))]
+[Guid("50531428-FE54-458D-85D0-143DF1106D38")]
+public class CastleSiegeCrownStatePlugIn : ICastleSiegeCrownStatePlugIn
+{
+ private readonly RemotePlayer _player;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The player.
+ public CastleSiegeCrownStatePlugIn(RemotePlayer player) => this._player = player;
+
+ ///
+ public ValueTask ShowCrownStateAsync(bool isAvailable)
+ => this._player.Connection?.SendCastleSiegeCrownStateUpdateAsync(
+ isAvailable ? CastleSiegeCrownState.Accessible : CastleSiegeCrownState.Protected) ?? default;
+}
diff --git a/src/GameServer/RemoteView/CastleSiege/CastleSiegeOwnershipChangePlugIn.cs b/src/GameServer/RemoteView/CastleSiege/CastleSiegeOwnershipChangePlugIn.cs
new file mode 100644
index 000000000..9ec3520a5
--- /dev/null
+++ b/src/GameServer/RemoteView/CastleSiege/CastleSiegeOwnershipChangePlugIn.cs
@@ -0,0 +1,34 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameServer.RemoteView.CastleSiege;
+
+using System.Runtime.InteropServices;
+using MUnique.OpenMU.GameLogic.Views.CastleSiege;
+using MUnique.OpenMU.Network.Packets.ServerToClient;
+using MUnique.OpenMU.PlugIns;
+
+///
+/// The default implementation of the
+/// which announces the new castle owner to the game client.
+///
+[PlugIn]
+[Display(Name = nameof(PlugInResources.CastleSiegeOwnershipChangePlugIn_Name), Description = nameof(PlugInResources.CastleSiegeOwnershipChangePlugIn_Description), ResourceType = typeof(PlugInResources))]
+[Guid("11A4F0AF-7EA7-4534-8470-50EEF31BF464")]
+public class CastleSiegeOwnershipChangePlugIn : ICastleSiegeOwnershipChangePlugIn
+{
+ private readonly RemotePlayer _player;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The player.
+ public CastleSiegeOwnershipChangePlugIn(RemotePlayer player) => this._player = player;
+
+ ///
+ public ValueTask ShowOwnershipChangeAsync(string guildName)
+ => this._player.Connection?.SendCastleSiegeBattleProcessAsync(
+ CastleSiegeBattleProcessState.CrownRegistrationSucceeded,
+ guildName) ?? default;
+}
diff --git a/src/GameServer/RemoteView/CastleSiege/CastleSiegeSwitchInfoPlugIn.cs b/src/GameServer/RemoteView/CastleSiege/CastleSiegeSwitchInfoPlugIn.cs
new file mode 100644
index 000000000..61262d529
--- /dev/null
+++ b/src/GameServer/RemoteView/CastleSiege/CastleSiegeSwitchInfoPlugIn.cs
@@ -0,0 +1,38 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameServer.RemoteView.CastleSiege;
+
+using System.Runtime.InteropServices;
+using MUnique.OpenMU.GameLogic.Views.CastleSiege;
+using MUnique.OpenMU.Network.Packets.ServerToClient;
+using MUnique.OpenMU.PlugIns;
+using SwitchInfo = MUnique.OpenMU.GameLogic.Views.CastleSiege.CastleSiegeSwitchInfo;
+
+///
+/// The default implementation of the
+/// which forwards Crown-switch occupancy to the game client.
+///
+[PlugIn]
+[Display(Name = nameof(PlugInResources.CastleSiegeSwitchInfoPlugIn_Name), Description = nameof(PlugInResources.CastleSiegeSwitchInfoPlugIn_Description), ResourceType = typeof(PlugInResources))]
+[Guid("28A5C92F-D3CF-42D6-B0D6-77C1CDB0913F")]
+public class CastleSiegeSwitchInfoPlugIn : ICastleSiegeSwitchInfoPlugIn
+{
+ private readonly RemotePlayer _player;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The player.
+ public CastleSiegeSwitchInfoPlugIn(RemotePlayer player) => this._player = player;
+
+ ///
+ public ValueTask ShowSwitchInfoAsync(SwitchInfo switchInfo)
+ => this._player.Connection?.SendCastleSiegeSwitchInfoAsync(
+ switchInfo.ObjectId,
+ switchInfo.IsOccupied,
+ (CastleSiegeJoinSide)switchInfo.JoinSide,
+ switchInfo.GuildName,
+ switchInfo.CharacterName) ?? default;
+}
diff --git a/tests/MUnique.OpenMU.Tests/CastleSiegeCrownMechanicsTests.cs b/tests/MUnique.OpenMU.Tests/CastleSiegeCrownMechanicsTests.cs
new file mode 100644
index 000000000..8325684be
--- /dev/null
+++ b/tests/MUnique.OpenMU.Tests/CastleSiegeCrownMechanicsTests.cs
@@ -0,0 +1,613 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Tests;
+
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using MUnique.OpenMU.DataModel.Configuration;
+using MUnique.OpenMU.DataModel.Entities;
+using MUnique.OpenMU.GameLogic;
+using MUnique.OpenMU.GameLogic.Attributes;
+using MUnique.OpenMU.GameLogic.CastleSiege;
+using MUnique.OpenMU.GameLogic.CastleSiege.NPC;
+using MUnique.OpenMU.GameLogic.Views.CastleSiege;
+using MUnique.OpenMU.GameServer;
+using MUnique.OpenMU.Interfaces;
+using MUnique.OpenMU.Persistence.InMemory;
+using MUnique.OpenMU.PlugIns;
+using BasicModel = MUnique.OpenMU.Persistence.BasicModel;
+using RuntimeGuild = MUnique.OpenMU.Interfaces.Guild;
+
+///
+/// Tests Castle Siege Crown capture, switch state, and final ownership mechanics.
+///
+[TestFixture]
+public class CastleSiegeCrownMechanicsTests
+{
+ private const uint DefenseGuildId = 10;
+ private const uint AttackGuildId = 20;
+
+ ///
+ /// Verifies that an interrupted attempt keeps capped progress and reports the failure once.
+ ///
+ [Test]
+ public async ValueTask InterruptedCaptureKeepsCappedProgressAsync()
+ {
+ var fixture = await CreateFixtureAsync().ConfigureAwait(false);
+ var crownUser = await fixture.CreatePlayerAsync(
+ AttackGuildId,
+ "CrownUser",
+ CastleSiegeJoinSide.Attack1,
+ 60,
+ 60)
+ .ConfigureAwait(false);
+ var firstSwitchUser = await fixture.CreatePlayerAsync(
+ AttackGuildId,
+ "SwitchOne",
+ CastleSiegeJoinSide.Attack1,
+ 70,
+ 60)
+ .ConfigureAwait(false);
+ var secondSwitchUser = await fixture.CreatePlayerAsync(
+ AttackGuildId,
+ "SwitchTwo",
+ CastleSiegeJoinSide.Attack1,
+ 80,
+ 60)
+ .ConfigureAwait(false);
+ fixture.Context.CrownUser = crownUser;
+ fixture.Context.SwitchUsers[0] = firstSwitchUser;
+ fixture.Context.SwitchUsers[1] = secondSwitchUser;
+
+ await CastleSiegeCrownMechanics.CheckMiddleWinnerAsync(fixture.Context).ConfigureAwait(false);
+ fixture.Context.CrownAccumulatedTime = TimeSpan.FromSeconds(10);
+ fixture.Context.SwitchUsers[1] = null;
+ await CastleSiegeCrownMechanics.CheckMiddleWinnerAsync(fixture.Context).ConfigureAwait(false);
+ await CastleSiegeCrownMechanics.CheckMiddleWinnerAsync(fixture.Context).ConfigureAwait(false);
+
+ Assert.That(fixture.Context.CrownAccumulatedTime, Is.EqualTo(TimeSpan.FromSeconds(2)));
+ Mock.Get(crownUser.ViewPlugIns.GetPlugIn()!)
+ .Verify(
+ plugIn => plugIn.ShowCrownAccessStateAsync(
+ CastleSiegeCrownAccessState.Fail,
+ TimeSpan.FromSeconds(2)),
+ Times.Once);
+ }
+
+ ///
+ /// Verifies that only three alive and guilded players on the same attacking side can make progress.
+ ///
+ [Test]
+ public async ValueTask CaptureRequiresThreeEligiblePlayersOnSameAttackingSideAsync()
+ {
+ var fixture = await CreateFixtureAsync().ConfigureAwait(false);
+ var crownUser = await fixture.CreatePlayerAsync(
+ AttackGuildId,
+ "CrownUser",
+ CastleSiegeJoinSide.Attack1,
+ 60,
+ 60)
+ .ConfigureAwait(false);
+ var firstSwitchUser = await fixture.CreatePlayerAsync(
+ AttackGuildId,
+ "SwitchOne",
+ CastleSiegeJoinSide.Attack1,
+ 70,
+ 60)
+ .ConfigureAwait(false);
+ var secondSwitchUser = await fixture.CreatePlayerAsync(
+ AttackGuildId,
+ "SwitchTwo",
+ CastleSiegeJoinSide.Attack2,
+ 80,
+ 60)
+ .ConfigureAwait(false);
+ fixture.Context.CrownUser = crownUser;
+ fixture.Context.SwitchUsers[0] = firstSwitchUser;
+ fixture.Context.SwitchUsers[1] = secondSwitchUser;
+ await CastleSiegeCrownMechanics.CheckMiddleWinnerAsync(fixture.Context).ConfigureAwait(false);
+ Assert.That(fixture.Context.CrownAccumulatedTime, Is.EqualTo(TimeSpan.Zero), "Different attacking sides must not capture.");
+
+ fixture.Context.PlayerJoinSides[secondSwitchUser.SelectedCharacter!.Id] = CastleSiegeJoinSide.Attack1;
+ secondSwitchUser.IsAlive = false;
+ await CastleSiegeCrownMechanics.CheckMiddleWinnerAsync(fixture.Context).ConfigureAwait(false);
+ Assert.That(fixture.Context.CrownAccumulatedTime, Is.EqualTo(TimeSpan.Zero), "Dead switch users must not capture.");
+
+ secondSwitchUser.IsAlive = true;
+ secondSwitchUser.GuildStatus = null;
+ await CastleSiegeCrownMechanics.CheckMiddleWinnerAsync(fixture.Context).ConfigureAwait(false);
+ Assert.That(fixture.Context.CrownAccumulatedTime, Is.EqualTo(TimeSpan.Zero), "Guildless switch users must not capture.");
+
+ secondSwitchUser.GuildStatus = new GuildMemberStatus(AttackGuildId, GuildPosition.NormalMember);
+ fixture.Context.PlayerJoinSides[crownUser.SelectedCharacter!.Id] = CastleSiegeJoinSide.Defense;
+ fixture.Context.PlayerJoinSides[firstSwitchUser.SelectedCharacter!.Id] = CastleSiegeJoinSide.Defense;
+ fixture.Context.PlayerJoinSides[secondSwitchUser.SelectedCharacter.Id] = CastleSiegeJoinSide.Defense;
+ await CastleSiegeCrownMechanics.CheckMiddleWinnerAsync(fixture.Context).ConfigureAwait(false);
+ Assert.That(fixture.Context.CrownAccumulatedTime, Is.EqualTo(TimeSpan.Zero), "The defending side must not capture.");
+
+ fixture.Context.PlayerJoinSides[crownUser.SelectedCharacter.Id] = CastleSiegeJoinSide.Attack1;
+ fixture.Context.PlayerJoinSides[firstSwitchUser.SelectedCharacter.Id] = CastleSiegeJoinSide.Attack1;
+ fixture.Context.PlayerJoinSides[secondSwitchUser.SelectedCharacter.Id] = CastleSiegeJoinSide.Attack1;
+ await CastleSiegeCrownMechanics.CheckMiddleWinnerAsync(fixture.Context).ConfigureAwait(false);
+ Assert.That(fixture.Context.CrownAccumulatedTime, Is.EqualTo(TimeSpan.FromSeconds(1)));
+ }
+
+ ///
+ /// Verifies that changing the Crown user fails the previous attempt without resetting its progress.
+ ///
+ [Test]
+ public async ValueTask ChangedCrownUserContinuesCappedProgressAsync()
+ {
+ var fixture = await CreateFixtureAsync().ConfigureAwait(false);
+ var previousCrownUser = await fixture.CreatePlayerAsync(
+ AttackGuildId,
+ "PreviousUser",
+ CastleSiegeJoinSide.Attack1,
+ 60,
+ 60)
+ .ConfigureAwait(false);
+ var newCrownUser = await fixture.CreatePlayerAsync(
+ AttackGuildId,
+ "NewUser",
+ CastleSiegeJoinSide.Attack1,
+ 61,
+ 60)
+ .ConfigureAwait(false);
+ var firstSwitchUser = await fixture.CreatePlayerAsync(
+ AttackGuildId,
+ "SwitchOne",
+ CastleSiegeJoinSide.Attack1,
+ 70,
+ 60)
+ .ConfigureAwait(false);
+ var secondSwitchUser = await fixture.CreatePlayerAsync(
+ AttackGuildId,
+ "SwitchTwo",
+ CastleSiegeJoinSide.Attack1,
+ 80,
+ 60)
+ .ConfigureAwait(false);
+ fixture.Context.CrownUser = previousCrownUser;
+ fixture.Context.SwitchUsers[0] = firstSwitchUser;
+ fixture.Context.SwitchUsers[1] = secondSwitchUser;
+ await CastleSiegeCrownMechanics.CheckMiddleWinnerAsync(fixture.Context).ConfigureAwait(false);
+
+ fixture.Context.CrownUser = newCrownUser;
+ await CastleSiegeCrownMechanics.CheckMiddleWinnerAsync(fixture.Context).ConfigureAwait(false);
+
+ Assert.That(fixture.Context.CrownAccumulatedTime, Is.EqualTo(TimeSpan.FromSeconds(2)));
+ Mock.Get(previousCrownUser.ViewPlugIns.GetPlugIn()!)
+ .Verify(
+ plugIn => plugIn.ShowCrownAccessStateAsync(
+ CastleSiegeCrownAccessState.Fail,
+ TimeSpan.FromSeconds(1)),
+ Times.Once);
+ Mock.Get(newCrownUser.ViewPlugIns.GetPlugIn()!)
+ .Verify(
+ plugIn => plugIn.ShowCrownAccessStateAsync(
+ CastleSiegeCrownAccessState.Attempt,
+ TimeSpan.FromSeconds(2)),
+ Times.Once);
+ }
+
+ ///
+ /// Verifies a successful capture, side swap, participant update, respawn, and restart recovery.
+ ///
+ [Test]
+ public async ValueTask SuccessfulCaptureChangesIntermediateOwnerAndSwapsSidesAsync()
+ {
+ var fixture = await CreateFixtureAsync().ConfigureAwait(false);
+ var crownUser = await fixture.CreatePlayerAsync(
+ AttackGuildId,
+ "CrownUser",
+ CastleSiegeJoinSide.Attack1,
+ 60,
+ 60)
+ .ConfigureAwait(false);
+ var firstSwitchUser = await fixture.CreatePlayerAsync(
+ AttackGuildId,
+ "SwitchOne",
+ CastleSiegeJoinSide.Attack1,
+ 70,
+ 60)
+ .ConfigureAwait(false);
+ var secondSwitchUser = await fixture.CreatePlayerAsync(
+ AttackGuildId,
+ "SwitchTwo",
+ CastleSiegeJoinSide.Attack1,
+ 80,
+ 60)
+ .ConfigureAwait(false);
+ var formerDefender = await fixture.CreatePlayerAsync(
+ DefenseGuildId,
+ "Defender",
+ CastleSiegeJoinSide.Defense,
+ 200,
+ 200)
+ .ConfigureAwait(false);
+ fixture.Context.CrownUser = crownUser;
+ fixture.Context.SwitchUsers[0] = firstSwitchUser;
+ fixture.Context.SwitchUsers[1] = secondSwitchUser;
+ fixture.Context.IsCrownAvailable = true;
+
+ await CastleSiegeCrownMechanics.CheckMiddleWinnerAsync(fixture.Context).ConfigureAwait(false);
+ await CastleSiegeCrownMechanics.CheckMiddleWinnerAsync(fixture.Context).ConfigureAwait(false);
+ await CastleSiegeCrownMechanics.CheckMiddleWinnerAsync(fixture.Context).ConfigureAwait(false);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(fixture.Context.MiddleOwnerGuildId, Is.EqualTo(AttackGuildId));
+ Assert.That(fixture.Context.FinalGuildList[AttackGuildId].Side, Is.EqualTo(CastleSiegeJoinSide.Defense));
+ Assert.That(fixture.Context.FinalGuildList[DefenseGuildId].Side, Is.EqualTo(CastleSiegeJoinSide.Attack1));
+ Assert.That(fixture.Context.GetPlayerJoinSide(crownUser), Is.EqualTo(CastleSiegeJoinSide.Defense));
+ Assert.That(fixture.Context.GetPlayerJoinSide(formerDefender), Is.EqualTo(CastleSiegeJoinSide.Attack1));
+ Assert.That(fixture.Context.IsCrownAvailable, Is.False);
+ Assert.That(fixture.Context.CrownAccumulatedTime, Is.EqualTo(TimeSpan.Zero));
+ Assert.That(fixture.Context.CrownUser, Is.Null);
+ Assert.That(fixture.Context.SwitchUsers, Is.All.Null);
+ Assert.That(formerDefender.Position.X, Is.InRange((byte)35, (byte)40));
+ Assert.That(formerDefender.Position.Y, Is.InRange((byte)11, (byte)16));
+ });
+ Mock.Get(crownUser.ViewPlugIns.GetPlugIn()!)
+ .Verify(
+ plugIn => plugIn.ShowCrownAccessStateAsync(
+ CastleSiegeCrownAccessState.Success,
+ TimeSpan.FromSeconds(3)),
+ Times.Once);
+ Mock.Get(formerDefender.ViewPlugIns.GetPlugIn()!)
+ .Verify(
+ plugIn => plugIn.ShowOwnershipChangeAsync("Attackers"),
+ Times.Once);
+
+ var restartedContext = new CastleSiegeContext(fixture.GameServerContext, fixture.Configuration);
+ await restartedContext.InitializeAsync(fixture.InitializationTimeUtc).ConfigureAwait(false);
+ Assert.Multiple(() =>
+ {
+ Assert.That(restartedContext.FinalGuildList[AttackGuildId].Side, Is.EqualTo(CastleSiegeJoinSide.Defense));
+ Assert.That(restartedContext.FinalGuildList[DefenseGuildId].Side, Is.EqualTo(CastleSiegeJoinSide.Attack1));
+ Assert.That(restartedContext.MiddleOwnerGuildId, Is.EqualTo(AttackGuildId));
+ });
+ }
+
+ ///
+ /// Verifies switch occupant broadcasts and Crown lock-state calculation.
+ ///
+ [Test]
+ public async ValueTask SwitchInformationControlsCrownAvailabilityAsync()
+ {
+ var fixture = await CreateFixtureAsync().ConfigureAwait(false);
+ try
+ {
+ await fixture.Context.NpcController.PrepareAsync().ConfigureAwait(false);
+ var firstSwitch = fixture.Context.ActiveNpcs
+ .Select(runtime => runtime.SpawnedInstance)
+ .OfType()
+ .Single(siegeSwitch => siegeSwitch.SwitchIndex == 0);
+ var crown = fixture.Context.ActiveNpcs
+ .Select(runtime => runtime.SpawnedInstance)
+ .OfType()
+ .Single();
+ var firstSwitchUser = await fixture.CreatePlayerAsync(
+ AttackGuildId,
+ "SwitchOne",
+ CastleSiegeJoinSide.Attack1,
+ firstSwitch.Position.X,
+ firstSwitch.Position.Y)
+ .ConfigureAwait(false);
+ var secondSwitch = fixture.Context.ActiveNpcs
+ .Select(runtime => runtime.SpawnedInstance)
+ .OfType()
+ .Single(siegeSwitch => siegeSwitch.SwitchIndex == 1);
+ var secondSwitchUser = await fixture.CreatePlayerAsync(
+ AttackGuildId,
+ "SwitchTwo",
+ CastleSiegeJoinSide.Attack1,
+ secondSwitch.Position.X,
+ secondSwitch.Position.Y)
+ .ConfigureAwait(false);
+ fixture.Context.SwitchUsers[0] = firstSwitchUser;
+ fixture.Context.SwitchUsers[1] = secondSwitchUser;
+
+ await CastleSiegeSwitchMechanics.SendSwitchInfoAsync(fixture.Context).ConfigureAwait(false);
+ Assert.Multiple(() =>
+ {
+ Assert.That(fixture.Context.IsCrownAvailable, Is.True);
+ Assert.That(crown.State, Is.EqualTo(CastleSiegeCrownState.Idle));
+ });
+ Mock.Get(firstSwitchUser.ViewPlugIns.GetPlugIn()!)
+ .Verify(
+ plugIn => plugIn.ShowSwitchInfoAsync(
+ It.Is(info =>
+ info.ObjectId == firstSwitch.Id
+ && info.IsOccupied
+ && info.JoinSide == CastleSiegeJoinSide.Attack1
+ && info.GuildName == "Attackers"
+ && info.CharacterName == "SwitchOne")),
+ Times.Once);
+ Mock.Get(firstSwitchUser.ViewPlugIns.GetPlugIn()!)
+ .Verify(plugIn => plugIn.ShowCrownStateAsync(true), Times.Once);
+
+ fixture.Context.PlayerJoinSides[secondSwitchUser.SelectedCharacter!.Id] = CastleSiegeJoinSide.Attack2;
+ await CastleSiegeSwitchMechanics.SendSwitchInfoAsync(fixture.Context).ConfigureAwait(false);
+ Assert.Multiple(() =>
+ {
+ Assert.That(fixture.Context.IsCrownAvailable, Is.False);
+ Assert.That(crown.State, Is.EqualTo(CastleSiegeCrownState.Locked));
+ });
+ Mock.Get(firstSwitchUser.ViewPlugIns.GetPlugIn()!)
+ .Verify(plugIn => plugIn.ShowCrownStateAsync(false), Times.Once);
+ }
+ finally
+ {
+ await fixture.Context.NpcController.DespawnAllAsync().ConfigureAwait(false);
+ }
+ }
+
+ ///
+ /// Verifies that a new owner is persisted and the Castle Siege economy is reset.
+ ///
+ [Test]
+ public async ValueTask FinalResultPersistsWinnerAndResetsEconomyAsync()
+ {
+ var fixture = await CreateFixtureAsync().ConfigureAwait(false);
+ var observer = await fixture.CreatePlayerAsync(
+ AttackGuildId,
+ "Observer",
+ CastleSiegeJoinSide.Attack1,
+ 100,
+ 100)
+ .ConfigureAwait(false);
+ fixture.Context.MiddleOwnerGuildId = AttackGuildId;
+ fixture.Context.SiegeData.TaxChaos = 3;
+ fixture.Context.SiegeData.TaxStore = 3;
+ fixture.Context.SiegeData.TaxHunt = 300_000;
+ fixture.Context.SiegeData.TributeMoney = 6_000;
+ fixture.Context.SiegeData.IsHuntZoneEnabled = true;
+
+ await CastleSiegeCrownMechanics
+ .CheckResultAsync(fixture.Context)
+ .ConfigureAwait(false);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(fixture.Context.SiegeData.OwnerGuildId, Is.EqualTo(fixture.AttackPersistentGuildId));
+ Assert.That(fixture.Context.SiegeData.IsOccupied, Is.True);
+ Assert.That(fixture.Context.SiegeData.TaxChaos, Is.Zero);
+ Assert.That(fixture.Context.SiegeData.TaxStore, Is.Zero);
+ Assert.That(fixture.Context.SiegeData.TaxHunt, Is.Zero);
+ Assert.That(fixture.Context.SiegeData.TributeMoney, Is.Zero);
+ Assert.That(fixture.Context.SiegeData.IsHuntZoneEnabled, Is.True);
+ });
+ using (var persistenceContext = fixture.PersistenceContextProvider.CreateNewTypedContext(
+ typeof(CastleSiegeData),
+ false,
+ fixture.GameServerContext.Configuration))
+ {
+ var persistedData = (await persistenceContext.GetAsync().ConfigureAwait(false)).Single();
+ Assert.That(persistedData.OwnerGuildId, Is.EqualTo(fixture.AttackPersistentGuildId));
+ Assert.That(persistedData.TributeMoney, Is.Zero);
+ }
+
+ Mock.Get(observer.ViewPlugIns.GetPlugIn()!)
+ .Verify(
+ plugIn => plugIn.ShowOwnershipChangeAsync("Attackers"),
+ Times.Once);
+ }
+
+ ///
+ /// Verifies that the current owner and economy are retained when the Crown was not captured.
+ ///
+ [Test]
+ public async ValueTask FinalResultRetainsCurrentOwnerWithoutCaptureAsync()
+ {
+ var fixture = await CreateFixtureAsync().ConfigureAwait(false);
+ fixture.Context.MiddleOwnerGuildId = null;
+ fixture.Context.SiegeData.TaxChaos = 3;
+ fixture.Context.SiegeData.TaxStore = 2;
+ fixture.Context.SiegeData.TaxHunt = 100_000;
+ fixture.Context.SiegeData.TributeMoney = 6_000;
+
+ await CastleSiegeCrownMechanics
+ .CheckResultAsync(fixture.Context)
+ .ConfigureAwait(false);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(fixture.Context.SiegeData.OwnerGuildId, Is.EqualTo(fixture.DefensePersistentGuildId));
+ Assert.That(fixture.Context.SiegeData.TaxChaos, Is.EqualTo(3));
+ Assert.That(fixture.Context.SiegeData.TaxStore, Is.EqualTo(2));
+ Assert.That(fixture.Context.SiegeData.TaxHunt, Is.EqualTo(100_000));
+ Assert.That(fixture.Context.SiegeData.TributeMoney, Is.EqualTo(6_000));
+ });
+ }
+
+ private static async ValueTask CreateFixtureAsync()
+ {
+ var persistenceContextProvider = new InMemoryPersistenceContextProvider();
+ BasicModel.GameConfiguration gameConfiguration;
+ BasicModel.CastleSiegeConfiguration configuration;
+ BasicModel.GameMapDefinition siegeMap;
+ Guid defensePersistentGuildId;
+ Guid attackPersistentGuildId;
+ using (var persistenceContext = persistenceContextProvider.CreateNewContext())
+ {
+ gameConfiguration = persistenceContext.CreateNew();
+ var normalMap = persistenceContext.CreateNew();
+ normalMap.Number = 0;
+ normalMap.TerrainData = new byte[65_539];
+ gameConfiguration.Maps.Add(normalMap);
+
+ siegeMap = persistenceContext.CreateNew();
+ siegeMap.Number = 30;
+ siegeMap.TerrainData = new byte[65_539];
+ gameConfiguration.Maps.Add(siegeMap);
+
+ configuration = persistenceContext.CreateNew();
+ configuration.Enabled = true;
+ configuration.CastleSiegeMapDefinition = siegeMap;
+ configuration.CrownHoldTimeSeconds = 3;
+ configuration.AttackRespawnArea = persistenceContext.CreateNew();
+ configuration.AttackRespawnArea.X1 = 35;
+ configuration.AttackRespawnArea.Y1 = 11;
+ configuration.AttackRespawnArea.X2 = 40;
+ configuration.AttackRespawnArea.Y2 = 16;
+ configuration.StateSchedule.Add(new BasicModel.CastleSiegeStateScheduleEntry
+ {
+ State = CastleSiegeState.Ready,
+ DayOfWeek = DayOfWeek.Monday,
+ });
+ configuration.StateSchedule.Add(new BasicModel.CastleSiegeStateScheduleEntry
+ {
+ State = CastleSiegeState.Start,
+ DayOfWeek = DayOfWeek.Tuesday,
+ });
+ gameConfiguration.CastleSiegeConfiguration = configuration;
+
+ AddNpc(CastleSiegeCrown.MonsterNumber, 1, 60, 60);
+ AddNpc(CastleSiegeSwitch.FirstMonsterNumber, 1, 70, 60);
+ AddNpc(CastleSiegeSwitch.SecondMonsterNumber, 2, 80, 60);
+
+ var defenseGuild = persistenceContext.CreateNew();
+ defenseGuild.Name = "Defenders";
+ defensePersistentGuildId = defenseGuild.Id;
+ var attackGuild = persistenceContext.CreateNew();
+ attackGuild.Name = "Attackers";
+ attackPersistentGuildId = attackGuild.Id;
+ var siegeData = persistenceContext.CreateNew();
+ siegeData.OwnerGuildId = defensePersistentGuildId;
+ siegeData.IsOccupied = true;
+ await persistenceContext.SaveChangesAsync().ConfigureAwait(false);
+
+ void AddNpc(short monsterNumber, byte instanceId, byte x, byte y)
+ {
+ var monster = persistenceContext.CreateNew();
+ monster.Number = monsterNumber;
+ monster.ObjectKind = NpcObjectKind.PassiveNpc;
+ gameConfiguration.Monsters.Add(monster);
+ configuration.NpcDefinitions.Add(new BasicModel.CastleSiegeNpcDefinition
+ {
+ MonsterDefinition = monster,
+ InstanceId = instanceId,
+ SpawnX = x,
+ SpawnY = y,
+ Direction = Direction.South,
+ });
+ }
+ }
+
+ var guildServer = new Mock();
+ guildServer
+ .Setup(server => server.GetGuildIdAsync(defensePersistentGuildId))
+ .Returns(new ValueTask(DefenseGuildId));
+ guildServer
+ .Setup(server => server.GetGuildIdAsync(attackPersistentGuildId))
+ .Returns(new ValueTask(AttackGuildId));
+ guildServer
+ .Setup(server => server.GetGuildAsync(DefenseGuildId))
+ .Returns(new ValueTask(new RuntimeGuild { Name = "Defenders" }));
+ guildServer
+ .Setup(server => server.GetGuildAsync(AttackGuildId))
+ .Returns(new ValueTask(new RuntimeGuild { Name = "Attackers" }));
+
+ var plugInManager = new PlugInManager([], NullLoggerFactory.Instance, null, null);
+ var mapInitializer = new MapInitializer(
+ gameConfiguration,
+ new NullLogger(),
+ NullDropGenerator.Instance,
+ null);
+ var gameServerContext = new GameServerContext(
+ new BasicModel.GameServerDefinition
+ {
+ GameConfiguration = gameConfiguration,
+ ServerConfiguration = new BasicModel.GameServerConfiguration(),
+ },
+ guildServer.Object,
+ new Mock().Object,
+ new Mock().Object,
+ new Mock().Object,
+ persistenceContextProvider,
+ mapInitializer,
+ NullLoggerFactory.Instance,
+ plugInManager,
+ NullDropGenerator.Instance,
+ new ConfigurationChangeMediator());
+ mapInitializer.PlugInManager = gameServerContext.PlugInManager;
+ mapInitializer.PathFinderPool = gameServerContext.PathFinderPool;
+
+ var initializationTimeUtc = new DateTime(2026, 8, 3, 12, 0, 0, DateTimeKind.Utc);
+ var context = new CastleSiegeContext(gameServerContext, configuration);
+ await context.InitializeAsync(initializationTimeUtc).ConfigureAwait(false);
+ context.CurrentState = CastleSiegeState.Start;
+ context.FinalGuildList[DefenseGuildId] = new CastleSiegeGuildParticipant
+ {
+ GuildId = DefenseGuildId,
+ PersistentGuildId = defensePersistentGuildId,
+ GuildName = "Defenders",
+ Side = CastleSiegeJoinSide.Defense,
+ IsAllianceMaster = true,
+ };
+ context.FinalGuildList[AttackGuildId] = new CastleSiegeGuildParticipant
+ {
+ GuildId = AttackGuildId,
+ PersistentGuildId = attackPersistentGuildId,
+ GuildName = "Attackers",
+ Side = CastleSiegeJoinSide.Attack1,
+ IsAllianceMaster = true,
+ };
+ var map = await gameServerContext.GetMapAsync(30).ConfigureAwait(false)
+ ?? throw new InvalidOperationException("The Castle Siege test map could not be initialized.");
+ return new(
+ persistenceContextProvider,
+ configuration,
+ gameServerContext,
+ context,
+ map,
+ defensePersistentGuildId,
+ attackPersistentGuildId,
+ initializationTimeUtc);
+ }
+
+ private sealed record TestFixture(
+ InMemoryPersistenceContextProvider PersistenceContextProvider,
+ CastleSiegeConfiguration Configuration,
+ GameServerContext GameServerContext,
+ CastleSiegeContext Context,
+ GameMap SiegeMap,
+ Guid DefensePersistentGuildId,
+ Guid AttackPersistentGuildId,
+ DateTime InitializationTimeUtc)
+ {
+ ///
+ /// Creates and tracks a Castle Siege participant.
+ ///
+ internal async ValueTask CreatePlayerAsync(
+ uint guildId,
+ string characterName,
+ CastleSiegeJoinSide side,
+ byte x,
+ byte y)
+ {
+ var player = await PlayerTestHelper.CreatePlayerAsync(this.GameServerContext).ConfigureAwait(false);
+ player.SelectedCharacter!.Id = Guid.NewGuid();
+ player.SelectedCharacter.Name = characterName;
+ player.GuildStatus = new GuildMemberStatus(guildId, GuildPosition.NormalMember);
+ await this.GameServerContext.AddPlayerAsync(player).ConfigureAwait(false);
+ await player.WarpToAsync(new ExitGate
+ {
+ Map = this.Configuration.CastleSiegeMapDefinition,
+ X1 = x,
+ X2 = x,
+ Y1 = y,
+ Y2 = y,
+ Direction = Direction.South,
+ }).ConfigureAwait(false);
+ await player.ClientReadyAfterMapChangeAsync().ConfigureAwait(false);
+ player.IsAlive = true;
+ this.Context.TrackPlayer(player, this.SiegeMap);
+ this.Context.PlayerJoinSides[player.SelectedCharacter.Id] = side;
+ return player;
+ }
+ }
+}
diff --git a/tests/MUnique.OpenMU.Tests/CastleSiegeCrownRemoteViewTests.cs b/tests/MUnique.OpenMU.Tests/CastleSiegeCrownRemoteViewTests.cs
new file mode 100644
index 000000000..3b856467b
--- /dev/null
+++ b/tests/MUnique.OpenMU.Tests/CastleSiegeCrownRemoteViewTests.cs
@@ -0,0 +1,115 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Tests;
+
+using MUnique.OpenMU.DataModel.Configuration;
+using MUnique.OpenMU.GameLogic;
+using MUnique.OpenMU.GameServer.RemoteView.CastleSiege;
+using MUnique.OpenMU.Network.Packets.ServerToClient;
+using CrownAccessPacket = MUnique.OpenMU.Network.Packets.ServerToClient.CastleSiegeCrownAccessState;
+using CrownAccessState = MUnique.OpenMU.GameLogic.Views.CastleSiege.CastleSiegeCrownAccessState;
+using DataJoinSide = MUnique.OpenMU.DataModel.Configuration.CastleSiegeJoinSide;
+using SwitchInfo = MUnique.OpenMU.GameLogic.Views.CastleSiege.CastleSiegeSwitchInfo;
+using SwitchInfoPacket = MUnique.OpenMU.Network.Packets.ServerToClient.CastleSiegeSwitchInfo;
+
+///
+/// Tests Castle Siege Crown remote-view packet serialization.
+///
+[TestFixture]
+public class CastleSiegeCrownRemoteViewTests
+{
+ ///
+ /// Verifies that Crown notifications are ignored after the player disconnected.
+ ///
+ [Test]
+ public async ValueTask IgnoreCrownNotificationsAfterDisconnectAsync()
+ {
+ var (player, output) = CastleSiegeRemoteViewTestHelper.CreatePlayer();
+ await player.PlayerState.TryAdvanceToAsync(PlayerState.LoginScreen).ConfigureAwait(false);
+ await player.DisconnectAsync().ConfigureAwait(false);
+ var outputLengthAfterDisconnect = output.Length;
+ Assert.That(player.Connection, Is.Null);
+
+ await new CastleSiegeCrownStatePlugIn(player).ShowCrownStateAsync(true).ConfigureAwait(false);
+ await new CastleSiegeCrownAccessStatePlugIn(player)
+ .ShowCrownAccessStateAsync(CrownAccessState.Attempt, TimeSpan.Zero)
+ .ConfigureAwait(false);
+ await new CastleSiegeSwitchInfoPlugIn(player)
+ .ShowSwitchInfoAsync(new(1, false, DataJoinSide.None, string.Empty, string.Empty))
+ .ConfigureAwait(false);
+ await new CastleSiegeOwnershipChangePlugIn(player)
+ .ShowOwnershipChangeAsync("Owner")
+ .ConfigureAwait(false);
+
+ Assert.That(output.Length, Is.EqualTo(outputLengthAfterDisconnect));
+ }
+
+ ///
+ /// Verifies Crown, switch, and ownership notification packets.
+ ///
+ [Test]
+ public async ValueTask SerializeCrownAndSwitchNotificationsAsync()
+ {
+ var (player, output) = CastleSiegeRemoteViewTestHelper.CreatePlayer();
+
+ await new CastleSiegeCrownStatePlugIn(player)
+ .ShowCrownStateAsync(true)
+ .ConfigureAwait(false);
+ await new CastleSiegeCrownAccessStatePlugIn(player)
+ .ShowCrownAccessStateAsync(CrownAccessState.Success, TimeSpan.FromMilliseconds(12_345))
+ .ConfigureAwait(false);
+ await new CastleSiegeSwitchInfoPlugIn(player)
+ .ShowSwitchInfoAsync(
+ new SwitchInfo(
+ 0x1234,
+ true,
+ DataJoinSide.Attack2,
+ "Attackr",
+ "Attacker"))
+ .ConfigureAwait(false);
+ await new CastleSiegeOwnershipChangePlugIn(player)
+ .ShowOwnershipChangeAsync("Defender")
+ .ConfigureAwait(false);
+
+ var data = output.ToArray().AsMemory();
+ Assert.That(
+ data.Length,
+ Is.EqualTo(
+ CastleSiegeCrownStateUpdate.Length
+ + CrownAccessPacket.Length
+ + SwitchInfoPacket.Length
+ + CastleSiegeBattleProcess.Length));
+
+ var crownState = (CastleSiegeCrownStateUpdate)data[..CastleSiegeCrownStateUpdate.Length];
+ Assert.That(crownState.State, Is.EqualTo(CastleSiegeCrownState.Accessible));
+
+ var offset = CastleSiegeCrownStateUpdate.Length;
+ var crownAccess = (CrownAccessPacket)data.Slice(offset, CrownAccessPacket.Length);
+ Assert.Multiple(() =>
+ {
+ Assert.That(crownAccess.State, Is.EqualTo(CastleSiegeCrownAccessStateType.Succeeded));
+ Assert.That(crownAccess.AccumulatedTimeMs, Is.EqualTo(12_345));
+ });
+
+ offset += CrownAccessPacket.Length;
+ var switchInfo = (SwitchInfoPacket)data.Slice(offset, SwitchInfoPacket.Length);
+ Assert.Multiple(() =>
+ {
+ Assert.That(switchInfo.SwitchIndex, Is.EqualTo(0x1234));
+ Assert.That(switchInfo.IsOccupied, Is.True);
+ Assert.That(switchInfo.JoinSide, Is.EqualTo(MUnique.OpenMU.Network.Packets.ServerToClient.CastleSiegeJoinSide.Attack2));
+ Assert.That(switchInfo.GuildName, Is.EqualTo("Attackr"));
+ Assert.That(switchInfo.UserName, Is.EqualTo("Attacker"));
+ });
+
+ offset += SwitchInfoPacket.Length;
+ var ownership = (CastleSiegeBattleProcess)data.Slice(offset, CastleSiegeBattleProcess.Length);
+ Assert.Multiple(() =>
+ {
+ Assert.That(ownership.State, Is.EqualTo(CastleSiegeBattleProcessState.CrownRegistrationSucceeded));
+ Assert.That(ownership.GuildName, Is.EqualTo("Defender"));
+ });
+ }
+}
From 08334423a0f7bc7715ef4271716082e365f42a58 Mon Sep 17 00:00:00 2001
From: Zylkien <283095668+Zylkien@users.noreply.github.com>
Date: Wed, 26 Aug 2026 16:25:43 +0200
Subject: [PATCH 2/5] Refine Castle Siege side swap handling
Express the attacking and defending side updates as explicit guarded cases
so the control flow satisfies static analysis without changing the side-swap
behavior.
---
src/GameLogic/CastleSiege/CastleSiegeCrownMechanics.cs | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/src/GameLogic/CastleSiege/CastleSiegeCrownMechanics.cs b/src/GameLogic/CastleSiege/CastleSiegeCrownMechanics.cs
index 528c1387c..7b6e71d4e 100644
--- a/src/GameLogic/CastleSiege/CastleSiegeCrownMechanics.cs
+++ b/src/GameLogic/CastleSiege/CastleSiegeCrownMechanics.cs
@@ -95,8 +95,10 @@ public static async ValueTask ChangeWinnerGuildAsync(
if (guild.Side == capturingSide)
{
guild.Side = CastleSiegeJoinSide.Defense;
+ continue;
}
- else if (guild.Side == CastleSiegeJoinSide.Defense)
+
+ if (guild.Side == CastleSiegeJoinSide.Defense)
{
guild.Side = capturingSide;
}
From 739c0d080af0cd45165576bc8a513fa39876477e Mon Sep 17 00:00:00 2001
From: Zylkien <283095668+Zylkien@users.noreply.github.com>
Date: Wed, 26 Aug 2026 21:54:57 +0200
Subject: [PATCH 3/5] Address Castle Siege Crown review findings
Use elapsed wall time for Crown progress, persist intermediate ownership for restart recovery, and keep End-state cleanup resilient to stale guild data.
Broadcast switch and Crown state only on changes, synchronize players entering mid-battle, clear switch runtime state after capture, and add regression coverage for the reviewed edge cases.
---
.../CastleSiege/CastleSiegeContext.cs | 23 +++-
.../CastleSiege/CastleSiegeCrownMechanics.cs | 82 ++++++++----
.../CastleSiege/CastleSiegePlugIn.cs | 12 +-
.../CastleSiege/CastleSiegeSwitchMechanics.cs | 92 +++++++++++---
.../CastleSiegeCrownIntelligence.cs | 3 -
.../CastleSiegeCrownMechanicsTests.cs | 120 ++++++++++++++++--
.../CastleSiegeNpcTests.cs | 3 +-
7 files changed, 272 insertions(+), 63 deletions(-)
diff --git a/src/GameLogic/CastleSiege/CastleSiegeContext.cs b/src/GameLogic/CastleSiege/CastleSiegeContext.cs
index 54eca362e..f8ee4ff34 100644
--- a/src/GameLogic/CastleSiege/CastleSiegeContext.cs
+++ b/src/GameLogic/CastleSiege/CastleSiegeContext.cs
@@ -130,6 +130,21 @@ public CastleSiegeContext(IGameContext gameContext, CastleSiegeConfiguration con
///
internal Player? PreviousCrownUser { get; set; }
+ ///
+ /// Gets or sets the UTC time of the previous Crown progress update.
+ ///
+ internal DateTime LastCrownUpdateUtc { get; set; }
+
+ ///
+ /// Gets the switch information which was last broadcast to the siege map.
+ ///
+ internal CastleSiegeSwitchInfo?[] LastBroadcastSwitchInfos { get; } = new CastleSiegeSwitchInfo?[2];
+
+ ///
+ /// Gets or sets the Crown availability which was last broadcast to the siege map.
+ ///
+ internal bool? LastBroadcastCrownAvailability { get; set; }
+
///
/// Gets a value indicating whether the context has been initialized.
///
@@ -390,8 +405,12 @@ internal void UntrackPlayer(Player player)
internal void InitializeBattleOwner()
{
this.MiddleOwnerGuildId = this.FinalGuildList.Values
- .FirstOrDefault(guild => guild.Side == CastleSiegeJoinSide.Defense && guild.IsAllianceMaster)
- ?.GuildId;
+ .FirstOrDefault(guild => guild.Side == CastleSiegeJoinSide.Defense
+ && guild.PersistentGuildId == this.SiegeData.OwnerGuildId)
+ ?.GuildId
+ ?? this.FinalGuildList.Values
+ .FirstOrDefault(guild => guild.Side == CastleSiegeJoinSide.Defense && guild.IsAllianceMaster)
+ ?.GuildId;
}
///
diff --git a/src/GameLogic/CastleSiege/CastleSiegeCrownMechanics.cs b/src/GameLogic/CastleSiege/CastleSiegeCrownMechanics.cs
index 7b6e71d4e..67c154372 100644
--- a/src/GameLogic/CastleSiege/CastleSiegeCrownMechanics.cs
+++ b/src/GameLogic/CastleSiege/CastleSiegeCrownMechanics.cs
@@ -4,6 +4,7 @@
namespace MUnique.OpenMU.GameLogic.CastleSiege;
+using Microsoft.Extensions.Logging;
using MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.GameLogic.CastleSiege.NPC;
using MUnique.OpenMU.GameLogic.Views.CastleSiege;
@@ -13,15 +14,19 @@ namespace MUnique.OpenMU.GameLogic.CastleSiege;
///
public static class CastleSiegeCrownMechanics
{
- private static readonly TimeSpan TickInterval = TimeSpan.FromSeconds(1);
-
///
/// Checks whether the Crown and both switches are held by the same attacking side.
///
/// The Castle Siege context.
+ /// The current UTC time.
/// A task that represents the asynchronous check operation.
- public static async ValueTask CheckMiddleWinnerAsync(CastleSiegeContext context)
+ public static async ValueTask CheckMiddleWinnerAsync(CastleSiegeContext context, DateTime utcNow)
{
+ var elapsed = utcNow > context.LastCrownUpdateUtc
+ ? utcNow - context.LastCrownUpdateUtc
+ : TimeSpan.Zero;
+ context.LastCrownUpdateUtc = utcNow;
+
var crownUser = context.CrownUser;
var previousCrownUser = context.PreviousCrownUser;
if (previousCrownUser is not null
@@ -47,13 +52,16 @@ public static async ValueTask CheckMiddleWinnerAsync(CastleSiegeContext context)
return;
}
- context.CrownAccumulatedTime += TickInterval;
+ context.CrownAccumulatedTime += elapsed;
context.PreviousCrownUser = crownUser;
- await SendAccessStateAsync(
- crownUser!,
- CastleSiegeCrownAccessState.Attempt,
- context.CrownAccumulatedTime)
- .ConfigureAwait(false);
+ if (elapsed > TimeSpan.Zero)
+ {
+ await SendAccessStateAsync(
+ crownUser!,
+ CastleSiegeCrownAccessState.Attempt,
+ context.CrownAccumulatedTime)
+ .ConfigureAwait(false);
+ }
var requiredTime = TimeSpan.FromSeconds(context.Configuration.CrownHoldTimeSeconds);
if (context.CrownAccumulatedTime < requiredTime)
@@ -76,7 +84,7 @@ await SendAccessStateAsync(
/// The player who captured the Crown.
/// The attacking side which captured the Crown.
/// A task that represents the asynchronous ownership change.
- public static async ValueTask ChangeWinnerGuildAsync(
+ internal static async ValueTask ChangeWinnerGuildAsync(
CastleSiegeContext context,
Player crownUser,
CastleSiegeJoinSide capturingSide)
@@ -104,7 +112,9 @@ public static async ValueTask ChangeWinnerGuildAsync(
}
}
+ ApplyOwner(context, capturingGuild);
await context.SaveFinalGuildListAsync().ConfigureAwait(false);
+ await context.SaveOwnerAsync().ConfigureAwait(false);
await context.SetPlayerJoinSideAsync().ConfigureAwait(false);
await RespawnAttackersAsync(context).ConfigureAwait(false);
@@ -120,6 +130,13 @@ public static async ValueTask ChangeWinnerGuildAsync(
context.CrownUser = null;
context.PreviousCrownUser = null;
Array.Clear(context.SwitchUsers);
+ foreach (var siegeSwitch in context.ActiveNpcs
+ .Select(runtime => runtime.SpawnedInstance)
+ .OfType())
+ {
+ siegeSwitch.Occupant = null;
+ }
+
await BroadcastOwnershipAsync(context, capturingGuild.GuildName).ConfigureAwait(false);
}
@@ -128,26 +145,28 @@ public static async ValueTask ChangeWinnerGuildAsync(
///
/// The Castle Siege context.
/// A task that represents the asynchronous result operation.
- public static async ValueTask CheckResultAsync(CastleSiegeContext context)
+ internal static async ValueTask CheckResultAsync(CastleSiegeContext context)
{
CastleSiegeGuildParticipant? winner = null;
if (context.MiddleOwnerGuildId is { } middleOwnerGuildId)
{
- winner = context.FinalGuildList.TryGetValue(middleOwnerGuildId, out var participant)
- ? participant
- : throw new InvalidOperationException("The intermediate Castle Siege owner is not in the selected guild list.");
+ if (context.FinalGuildList.TryGetValue(middleOwnerGuildId, out var participant))
+ {
+ winner = participant;
+ }
+ else
+ {
+ context.GameContext.LoggerFactory
+ .CreateLogger(typeof(CastleSiegeCrownMechanics))
+ .LogWarning(
+ "The intermediate Castle Siege owner {guildId} is not in the selected guild list. The persisted owner is retained.",
+ middleOwnerGuildId);
+ }
}
- if (winner is not null
- && (!context.SiegeData.IsOccupied
- || context.SiegeData.OwnerGuildId != winner.PersistentGuildId))
+ if (winner is not null)
{
- context.SiegeData.OwnerGuildId = winner.PersistentGuildId;
- context.SiegeData.IsOccupied = true;
- context.SiegeData.TaxChaos = 0;
- context.SiegeData.TaxStore = 0;
- context.SiegeData.TaxHunt = 0;
- context.SiegeData.TributeMoney = 0;
+ ApplyOwner(context, winner);
}
await context.SaveOwnerAsync().ConfigureAwait(false);
@@ -191,6 +210,7 @@ await SendAccessStateAsync(
private static void CapAccumulatedTime(CastleSiegeContext context)
{
+ // Crown progress is shared across interrupted attempts and attacking sides by design.
var maximumSeconds = Math.Max(context.Configuration.CrownHoldTimeSeconds, 1) - 1;
var maximumTime = TimeSpan.FromSeconds(maximumSeconds);
if (context.CrownAccumulatedTime > maximumTime)
@@ -264,6 +284,22 @@ private static async ValueTask RespawnAttackersAsync(CastleSiegeContext context)
?.Name;
}
+ private static void ApplyOwner(CastleSiegeContext context, CastleSiegeGuildParticipant winner)
+ {
+ if (context.SiegeData.IsOccupied
+ && context.SiegeData.OwnerGuildId == winner.PersistentGuildId)
+ {
+ return;
+ }
+
+ context.SiegeData.OwnerGuildId = winner.PersistentGuildId;
+ context.SiegeData.IsOccupied = true;
+ context.SiegeData.TaxChaos = 0;
+ context.SiegeData.TaxStore = 0;
+ context.SiegeData.TaxHunt = 0;
+ context.SiegeData.TributeMoney = 0;
+ }
+
private static async ValueTask BroadcastOwnershipAsync(CastleSiegeContext context, string guildName)
{
await context.ForEachSiegePlayerAsync(async player =>
diff --git a/src/GameLogic/CastleSiege/CastleSiegePlugIn.cs b/src/GameLogic/CastleSiege/CastleSiegePlugIn.cs
index 3f937aa3f..7dd537afe 100644
--- a/src/GameLogic/CastleSiege/CastleSiegePlugIn.cs
+++ b/src/GameLogic/CastleSiege/CastleSiegePlugIn.cs
@@ -230,6 +230,10 @@ private async ValueTask SynchronizePlayerAsync(
context,
player,
this._timeProvider.GetUtcNow().UtcDateTime);
+ if (context.CurrentState == CastleSiegeState.Start)
+ {
+ await CastleSiegeSwitchMechanics.SynchronizePlayerAsync(context, player).ConfigureAwait(false);
+ }
}
}
@@ -334,11 +338,14 @@ private async ValueTask OnEnterStateAsync(CastleSiegeContext context, bool isSta
Array.Clear(context.SwitchUsers);
context.CrownAccumulatedTime = TimeSpan.Zero;
context.IsCrownAvailable = false;
+ Array.Clear(context.LastBroadcastSwitchInfos);
+ context.LastBroadcastCrownAvailability = null;
await context.NpcController.PrepareAsync().ConfigureAwait(false);
await context.NpcController.CloseGatesAsync().ConfigureAwait(false);
await context.NpcController.SpawnMachinesAsync().ConfigureAwait(false);
await context.SetPlayerJoinSideAsync().ConfigureAwait(false);
var utcNow = this._timeProvider.GetUtcNow().UtcDateTime;
+ context.LastCrownUpdateUtc = utcNow;
await CastleSiegeParticipantTracker.TrackAsync(context, utcNow).ConfigureAwait(false);
context.NextParticipantUpdateUtc = utcNow + ParticipantUpdateInterval;
break;
@@ -367,6 +374,9 @@ private async ValueTask OnEnterStateAsync(CastleSiegeContext context, bool isSta
Array.Clear(context.SwitchUsers);
context.CrownAccumulatedTime = TimeSpan.Zero;
context.IsCrownAvailable = false;
+ context.LastCrownUpdateUtc = DateTime.MinValue;
+ Array.Clear(context.LastBroadcastSwitchInfos);
+ context.LastBroadcastCrownAvailability = null;
context.NextParticipantUpdateUtc = DateTime.MaxValue;
break;
}
@@ -408,7 +418,7 @@ await CastleSiegeParticipantTracker
if (context.CurrentState == CastleSiegeState.Start)
{
await CastleSiegeSwitchMechanics.SendSwitchInfoAsync(context).ConfigureAwait(false);
- await CastleSiegeCrownMechanics.CheckMiddleWinnerAsync(context).ConfigureAwait(false);
+ await CastleSiegeCrownMechanics.CheckMiddleWinnerAsync(context, utcNow).ConfigureAwait(false);
}
if (!context.IsEventRunning && context.NextNpcSaveUtc <= utcNow)
diff --git a/src/GameLogic/CastleSiege/CastleSiegeSwitchMechanics.cs b/src/GameLogic/CastleSiege/CastleSiegeSwitchMechanics.cs
index 345044e04..246b8f674 100644
--- a/src/GameLogic/CastleSiege/CastleSiegeSwitchMechanics.cs
+++ b/src/GameLogic/CastleSiege/CastleSiegeSwitchMechanics.cs
@@ -20,39 +20,74 @@ public static class CastleSiegeSwitchMechanics
/// A task that represents the asynchronous broadcast operation.
public static async ValueTask SendSwitchInfoAsync(CastleSiegeContext context)
{
- var switchInfos = context.ActiveNpcs
- .Select(runtime => runtime.SpawnedInstance)
- .OfType()
- .OrderBy(candidate => candidate.SwitchIndex)
- .Select(siegeSwitch => CreateSwitchInfo(
- context,
- siegeSwitch,
- context.SwitchUsers[siegeSwitch.SwitchIndex]))
+ var switchInfos = CreateSwitchInfos(context);
+ var crownAvailability = UpdateCrownState(context);
+ var changedSwitches = switchInfos
+ .Where((switchInfo, index) => !Equals(switchInfo, context.LastBroadcastSwitchInfos[index]))
.ToList();
-
- context.IsCrownAvailable = AreSwitchesHeldBySameAttackingSide(context);
- foreach (var crown in context.ActiveNpcs
- .Select(runtime => runtime.SpawnedInstance)
- .OfType())
+ var crownStateChanged = context.LastBroadcastCrownAvailability != crownAvailability;
+ if (changedSwitches.Count == 0 && !crownStateChanged)
{
- crown.State = context.IsCrownAvailable
- ? CastleSiegeCrownState.Idle
- : CastleSiegeCrownState.Locked;
+ return;
}
await context.ForEachSiegePlayerAsync(async player =>
{
- foreach (var switchInfo in switchInfos)
+ foreach (var switchInfo in changedSwitches)
{
await player.InvokeViewPlugInAsync(
plugIn => plugIn.ShowSwitchInfoAsync(switchInfo))
.ConfigureAwait(false);
}
- await player.InvokeViewPlugInAsync(
- plugIn => plugIn.ShowCrownStateAsync(context.IsCrownAvailable))
- .ConfigureAwait(false);
+ if (crownStateChanged)
+ {
+ await player.InvokeViewPlugInAsync(
+ plugIn => plugIn.ShowCrownStateAsync(crownAvailability))
+ .ConfigureAwait(false);
+ }
}).ConfigureAwait(false);
+
+ for (var index = 0; index < switchInfos.Count; index++)
+ {
+ context.LastBroadcastSwitchInfos[index] = switchInfos[index];
+ }
+
+ context.LastBroadcastCrownAvailability = crownAvailability;
+ }
+
+ ///
+ /// Sends the current switch and Crown state to one player who entered the siege map.
+ ///
+ /// The Castle Siege context.
+ /// The player to synchronize.
+ /// A task that represents the asynchronous synchronization operation.
+ public static async ValueTask SynchronizePlayerAsync(CastleSiegeContext context, Player player)
+ {
+ foreach (var switchInfo in CreateSwitchInfos(context))
+ {
+ await player.InvokeViewPlugInAsync(
+ plugIn => plugIn.ShowSwitchInfoAsync(switchInfo))
+ .ConfigureAwait(false);
+ }
+
+ var crownAvailability = UpdateCrownState(context);
+ await player.InvokeViewPlugInAsync(
+ plugIn => plugIn.ShowCrownStateAsync(crownAvailability))
+ .ConfigureAwait(false);
+ }
+
+ private static List CreateSwitchInfos(CastleSiegeContext context)
+ {
+ return context.ActiveNpcs
+ .Select(runtime => runtime.SpawnedInstance)
+ .OfType()
+ .OrderBy(candidate => candidate.SwitchIndex)
+ .Select(siegeSwitch => CreateSwitchInfo(
+ context,
+ siegeSwitch,
+ context.SwitchUsers[siegeSwitch.SwitchIndex]))
+ .ToList();
}
private static CastleSiegeSwitchInfo CreateSwitchInfo(
@@ -67,6 +102,8 @@ private static CastleSiegeSwitchInfo CreateSwitchInfo(
&& context.FinalGuildList.TryGetValue(guildStatus.GuildId, out var participant)
? participant.GuildName
: string.Empty;
+
+ // MuMain resolves the legacy switch-index field through its visible-object table, so the object id is required.
return new(
siegeSwitch.Id,
occupant is not null,
@@ -75,6 +112,21 @@ private static CastleSiegeSwitchInfo CreateSwitchInfo(
occupant?.Name ?? string.Empty);
}
+ private static bool UpdateCrownState(CastleSiegeContext context)
+ {
+ context.IsCrownAvailable = AreSwitchesHeldBySameAttackingSide(context);
+ foreach (var crown in context.ActiveNpcs
+ .Select(runtime => runtime.SpawnedInstance)
+ .OfType())
+ {
+ crown.State = context.IsCrownAvailable
+ ? CastleSiegeCrownState.Idle
+ : CastleSiegeCrownState.Locked;
+ }
+
+ return context.IsCrownAvailable;
+ }
+
private static bool AreSwitchesHeldBySameAttackingSide(CastleSiegeContext context)
{
if (context.SwitchUsers[0] is not { IsAlive: true, GuildStatus: not null } firstSwitchUser
diff --git a/src/GameLogic/CastleSiege/Intelligence/CastleSiegeCrownIntelligence.cs b/src/GameLogic/CastleSiege/Intelligence/CastleSiegeCrownIntelligence.cs
index a052849e0..49ce875c7 100644
--- a/src/GameLogic/CastleSiege/Intelligence/CastleSiegeCrownIntelligence.cs
+++ b/src/GameLogic/CastleSiege/Intelligence/CastleSiegeCrownIntelligence.cs
@@ -68,9 +68,6 @@ public ValueTask TickAsync()
&& player.CurrentMap == crown.CurrentMap)
.MinBy(player => player.Id);
this._context.CrownUser = candidate;
- crown.State = this._context.IsCrownAvailable
- ? CastleSiegeCrownState.Idle
- : CastleSiegeCrownState.Locked;
return ValueTask.CompletedTask;
}
diff --git a/tests/MUnique.OpenMU.Tests/CastleSiegeCrownMechanicsTests.cs b/tests/MUnique.OpenMU.Tests/CastleSiegeCrownMechanicsTests.cs
index 8325684be..7c582eab0 100644
--- a/tests/MUnique.OpenMU.Tests/CastleSiegeCrownMechanicsTests.cs
+++ b/tests/MUnique.OpenMU.Tests/CastleSiegeCrownMechanicsTests.cs
@@ -61,11 +61,11 @@ public async ValueTask InterruptedCaptureKeepsCappedProgressAsync()
fixture.Context.SwitchUsers[0] = firstSwitchUser;
fixture.Context.SwitchUsers[1] = secondSwitchUser;
- await CastleSiegeCrownMechanics.CheckMiddleWinnerAsync(fixture.Context).ConfigureAwait(false);
+ await fixture.CheckCrownAsync().ConfigureAwait(false);
fixture.Context.CrownAccumulatedTime = TimeSpan.FromSeconds(10);
fixture.Context.SwitchUsers[1] = null;
- await CastleSiegeCrownMechanics.CheckMiddleWinnerAsync(fixture.Context).ConfigureAwait(false);
- await CastleSiegeCrownMechanics.CheckMiddleWinnerAsync(fixture.Context).ConfigureAwait(false);
+ await fixture.CheckCrownAsync().ConfigureAwait(false);
+ await fixture.CheckCrownAsync().ConfigureAwait(false);
Assert.That(fixture.Context.CrownAccumulatedTime, Is.EqualTo(TimeSpan.FromSeconds(2)));
Mock.Get(crownUser.ViewPlugIns.GetPlugIn()!)
@@ -107,30 +107,30 @@ public async ValueTask CaptureRequiresThreeEligiblePlayersOnSameAttackingSideAsy
fixture.Context.CrownUser = crownUser;
fixture.Context.SwitchUsers[0] = firstSwitchUser;
fixture.Context.SwitchUsers[1] = secondSwitchUser;
- await CastleSiegeCrownMechanics.CheckMiddleWinnerAsync(fixture.Context).ConfigureAwait(false);
+ await fixture.CheckCrownAsync().ConfigureAwait(false);
Assert.That(fixture.Context.CrownAccumulatedTime, Is.EqualTo(TimeSpan.Zero), "Different attacking sides must not capture.");
fixture.Context.PlayerJoinSides[secondSwitchUser.SelectedCharacter!.Id] = CastleSiegeJoinSide.Attack1;
secondSwitchUser.IsAlive = false;
- await CastleSiegeCrownMechanics.CheckMiddleWinnerAsync(fixture.Context).ConfigureAwait(false);
+ await fixture.CheckCrownAsync().ConfigureAwait(false);
Assert.That(fixture.Context.CrownAccumulatedTime, Is.EqualTo(TimeSpan.Zero), "Dead switch users must not capture.");
secondSwitchUser.IsAlive = true;
secondSwitchUser.GuildStatus = null;
- await CastleSiegeCrownMechanics.CheckMiddleWinnerAsync(fixture.Context).ConfigureAwait(false);
+ await fixture.CheckCrownAsync().ConfigureAwait(false);
Assert.That(fixture.Context.CrownAccumulatedTime, Is.EqualTo(TimeSpan.Zero), "Guildless switch users must not capture.");
secondSwitchUser.GuildStatus = new GuildMemberStatus(AttackGuildId, GuildPosition.NormalMember);
fixture.Context.PlayerJoinSides[crownUser.SelectedCharacter!.Id] = CastleSiegeJoinSide.Defense;
fixture.Context.PlayerJoinSides[firstSwitchUser.SelectedCharacter!.Id] = CastleSiegeJoinSide.Defense;
fixture.Context.PlayerJoinSides[secondSwitchUser.SelectedCharacter.Id] = CastleSiegeJoinSide.Defense;
- await CastleSiegeCrownMechanics.CheckMiddleWinnerAsync(fixture.Context).ConfigureAwait(false);
+ await fixture.CheckCrownAsync().ConfigureAwait(false);
Assert.That(fixture.Context.CrownAccumulatedTime, Is.EqualTo(TimeSpan.Zero), "The defending side must not capture.");
fixture.Context.PlayerJoinSides[crownUser.SelectedCharacter.Id] = CastleSiegeJoinSide.Attack1;
fixture.Context.PlayerJoinSides[firstSwitchUser.SelectedCharacter.Id] = CastleSiegeJoinSide.Attack1;
fixture.Context.PlayerJoinSides[secondSwitchUser.SelectedCharacter.Id] = CastleSiegeJoinSide.Attack1;
- await CastleSiegeCrownMechanics.CheckMiddleWinnerAsync(fixture.Context).ConfigureAwait(false);
+ await fixture.CheckCrownAsync().ConfigureAwait(false);
Assert.That(fixture.Context.CrownAccumulatedTime, Is.EqualTo(TimeSpan.FromSeconds(1)));
}
@@ -172,10 +172,10 @@ public async ValueTask ChangedCrownUserContinuesCappedProgressAsync()
fixture.Context.CrownUser = previousCrownUser;
fixture.Context.SwitchUsers[0] = firstSwitchUser;
fixture.Context.SwitchUsers[1] = secondSwitchUser;
- await CastleSiegeCrownMechanics.CheckMiddleWinnerAsync(fixture.Context).ConfigureAwait(false);
+ await fixture.CheckCrownAsync().ConfigureAwait(false);
fixture.Context.CrownUser = newCrownUser;
- await CastleSiegeCrownMechanics.CheckMiddleWinnerAsync(fixture.Context).ConfigureAwait(false);
+ await fixture.CheckCrownAsync().ConfigureAwait(false);
Assert.That(fixture.Context.CrownAccumulatedTime, Is.EqualTo(TimeSpan.FromSeconds(2)));
Mock.Get(previousCrownUser.ViewPlugIns.GetPlugIn()!)
@@ -231,10 +231,11 @@ public async ValueTask SuccessfulCaptureChangesIntermediateOwnerAndSwapsSidesAsy
fixture.Context.SwitchUsers[0] = firstSwitchUser;
fixture.Context.SwitchUsers[1] = secondSwitchUser;
fixture.Context.IsCrownAvailable = true;
+ fixture.Context.FinalGuildList[AttackGuildId].IsAllianceMaster = false;
- await CastleSiegeCrownMechanics.CheckMiddleWinnerAsync(fixture.Context).ConfigureAwait(false);
- await CastleSiegeCrownMechanics.CheckMiddleWinnerAsync(fixture.Context).ConfigureAwait(false);
- await CastleSiegeCrownMechanics.CheckMiddleWinnerAsync(fixture.Context).ConfigureAwait(false);
+ await fixture.CheckCrownAsync().ConfigureAwait(false);
+ await fixture.CheckCrownAsync().ConfigureAwait(false);
+ await fixture.CheckCrownAsync().ConfigureAwait(false);
Assert.Multiple(() =>
{
@@ -247,6 +248,7 @@ public async ValueTask SuccessfulCaptureChangesIntermediateOwnerAndSwapsSidesAsy
Assert.That(fixture.Context.CrownAccumulatedTime, Is.EqualTo(TimeSpan.Zero));
Assert.That(fixture.Context.CrownUser, Is.Null);
Assert.That(fixture.Context.SwitchUsers, Is.All.Null);
+ Assert.That(fixture.Context.SiegeData.OwnerGuildId, Is.EqualTo(fixture.AttackPersistentGuildId));
Assert.That(formerDefender.Position.X, Is.InRange((byte)35, (byte)40));
Assert.That(formerDefender.Position.Y, Is.InRange((byte)11, (byte)16));
});
@@ -261,6 +263,15 @@ public async ValueTask SuccessfulCaptureChangesIntermediateOwnerAndSwapsSidesAsy
plugIn => plugIn.ShowOwnershipChangeAsync("Attackers"),
Times.Once);
+ using (var persistenceContext = fixture.PersistenceContextProvider.CreateNewTypedContext(
+ typeof(CastleSiegeData),
+ false,
+ fixture.GameServerContext.Configuration))
+ {
+ var persistedData = (await persistenceContext.GetAsync().ConfigureAwait(false)).Single();
+ Assert.That(persistedData.OwnerGuildId, Is.EqualTo(fixture.AttackPersistentGuildId));
+ }
+
var restartedContext = new CastleSiegeContext(fixture.GameServerContext, fixture.Configuration);
await restartedContext.InitializeAsync(fixture.InitializationTimeUtc).ConfigureAwait(false);
Assert.Multiple(() =>
@@ -329,6 +340,27 @@ public async ValueTask SwitchInformationControlsCrownAvailabilityAsync()
Mock.Get(firstSwitchUser.ViewPlugIns.GetPlugIn()!)
.Verify(plugIn => plugIn.ShowCrownStateAsync(true), Times.Once);
+ await CastleSiegeSwitchMechanics.SendSwitchInfoAsync(fixture.Context).ConfigureAwait(false);
+ Mock.Get(firstSwitchUser.ViewPlugIns.GetPlugIn()!)
+ .Verify(plugIn => plugIn.ShowSwitchInfoAsync(It.IsAny()), Times.Exactly(2));
+ Mock.Get(firstSwitchUser.ViewPlugIns.GetPlugIn()!)
+ .Verify(plugIn => plugIn.ShowCrownStateAsync(true), Times.Once);
+
+ var enteringPlayer = await fixture.CreatePlayerAsync(
+ AttackGuildId,
+ "EnteringPlayer",
+ CastleSiegeJoinSide.Attack1,
+ 90,
+ 60)
+ .ConfigureAwait(false);
+ await CastleSiegeSwitchMechanics
+ .SynchronizePlayerAsync(fixture.Context, enteringPlayer)
+ .ConfigureAwait(false);
+ Mock.Get(enteringPlayer.ViewPlugIns.GetPlugIn()!)
+ .Verify(plugIn => plugIn.ShowSwitchInfoAsync(It.IsAny()), Times.Exactly(2));
+ Mock.Get(enteringPlayer.ViewPlugIns.GetPlugIn()!)
+ .Verify(plugIn => plugIn.ShowCrownStateAsync(true), Times.Once);
+
fixture.Context.PlayerJoinSides[secondSwitchUser.SelectedCharacter!.Id] = CastleSiegeJoinSide.Attack2;
await CastleSiegeSwitchMechanics.SendSwitchInfoAsync(fixture.Context).ConfigureAwait(false);
Assert.Multiple(() =>
@@ -338,6 +370,8 @@ public async ValueTask SwitchInformationControlsCrownAvailabilityAsync()
});
Mock.Get(firstSwitchUser.ViewPlugIns.GetPlugIn()!)
.Verify(plugIn => plugIn.ShowCrownStateAsync(false), Times.Once);
+ Mock.Get(firstSwitchUser.ViewPlugIns.GetPlugIn()!)
+ .Verify(plugIn => plugIn.ShowSwitchInfoAsync(It.IsAny()), Times.Exactly(3));
}
finally
{
@@ -423,6 +457,47 @@ await CastleSiegeCrownMechanics
});
}
+ ///
+ /// Verifies that an unresolved intermediate owner does not abort End-state processing.
+ ///
+ [Test]
+ public async ValueTask FinalResultRetainsPersistedOwnerWhenIntermediateOwnerIsMissingAsync()
+ {
+ var fixture = await CreateFixtureAsync().ConfigureAwait(false);
+ fixture.Context.MiddleOwnerGuildId = uint.MaxValue;
+
+ await CastleSiegeCrownMechanics.CheckResultAsync(fixture.Context).ConfigureAwait(false);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(fixture.Context.SiegeData.OwnerGuildId, Is.EqualTo(fixture.DefensePersistentGuildId));
+ Assert.That(fixture.Context.SiegeData.IsOccupied, Is.True);
+ });
+ }
+
+ ///
+ /// Verifies that Crown progress uses elapsed wall time when periodic ticks are delayed.
+ ///
+ [Test]
+ public async ValueTask CrownProgressUsesElapsedTimeAsync()
+ {
+ var fixture = await CreateFixtureAsync().ConfigureAwait(false);
+ var crownUser = await fixture.CreatePlayerAsync(AttackGuildId, "CrownUser", CastleSiegeJoinSide.Attack1, 60, 60).ConfigureAwait(false);
+ fixture.Context.CrownUser = crownUser;
+ fixture.Context.SwitchUsers[0] = await fixture.CreatePlayerAsync(AttackGuildId, "SwitchOne", CastleSiegeJoinSide.Attack1, 70, 60).ConfigureAwait(false);
+ fixture.Context.SwitchUsers[1] = await fixture.CreatePlayerAsync(AttackGuildId, "SwitchTwo", CastleSiegeJoinSide.Attack1, 80, 60).ConfigureAwait(false);
+
+ await fixture.CheckCrownAsync(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
+
+ Assert.That(fixture.Context.CrownAccumulatedTime, Is.EqualTo(TimeSpan.FromSeconds(2)));
+ Mock.Get(crownUser.ViewPlugIns.GetPlugIn()!)
+ .Verify(
+ plugIn => plugIn.ShowCrownAccessStateAsync(
+ CastleSiegeCrownAccessState.Attempt,
+ TimeSpan.FromSeconds(2)),
+ Times.Once);
+ }
+
private static async ValueTask CreateFixtureAsync()
{
var persistenceContextProvider = new InMemoryPersistenceContextProvider();
@@ -579,6 +654,8 @@ private sealed record TestFixture(
Guid AttackPersistentGuildId,
DateTime InitializationTimeUtc)
{
+ private DateTime? _crownTimeUtc;
+
///
/// Creates and tracks a Castle Siege participant.
///
@@ -609,5 +686,22 @@ await player.WarpToAsync(new ExitGate
this.Context.PlayerJoinSides[player.SelectedCharacter.Id] = side;
return player;
}
+
+ ///
+ /// Advances the Crown mechanics clock and executes one progress check.
+ ///
+ /// The elapsed time since the previous check.
+ /// A task that represents the asynchronous check.
+ internal ValueTask CheckCrownAsync(TimeSpan? elapsed = null)
+ {
+ if (this._crownTimeUtc is null)
+ {
+ this._crownTimeUtc = this.InitializationTimeUtc;
+ this.Context.LastCrownUpdateUtc = this.InitializationTimeUtc;
+ }
+
+ this._crownTimeUtc += elapsed ?? TimeSpan.FromSeconds(1);
+ return CastleSiegeCrownMechanics.CheckMiddleWinnerAsync(this.Context, this._crownTimeUtc.Value);
+ }
}
}
diff --git a/tests/MUnique.OpenMU.Tests/CastleSiegeNpcTests.cs b/tests/MUnique.OpenMU.Tests/CastleSiegeNpcTests.cs
index f29a6d8fc..a652f6a62 100644
--- a/tests/MUnique.OpenMU.Tests/CastleSiegeNpcTests.cs
+++ b/tests/MUnique.OpenMU.Tests/CastleSiegeNpcTests.cs
@@ -686,6 +686,7 @@ public async ValueTask RepairChargesFormulaAndBuyRespawnsDestroyedGateAsync()
///
/// Verifies Crown and switch proximity tracking without applying the later win-condition mechanics.
+ /// Crown availability remains owned by the switch mechanics.
///
[Test]
public async ValueTask CrownAndSwitchTrackNearbyAlivePlayerAsync()
@@ -734,7 +735,7 @@ await fixture.Player.WarpToAsync(new ExitGate
{
Assert.That(fixture.Context.CrownUser, Is.SameAs(fixture.Player));
Assert.That(fixture.Context.CrownAccumulatedTime, Is.EqualTo(TimeSpan.FromSeconds(12)));
- Assert.That(crown.State, Is.EqualTo(CastleSiegeCrownState.Idle));
+ Assert.That(crown.State, Is.EqualTo(CastleSiegeCrownState.Locked));
});
await fixture.Player.WarpToAsync(new ExitGate
From 73326a592666d3d9d1269857f70504eac67a295e Mon Sep 17 00:00:00 2001
From: Zylkien <283095668+Zylkien@users.noreply.github.com>
Date: Wed, 26 Aug 2026 22:44:19 +0200
Subject: [PATCH 4/5] Harden Castle Siege Crown state handling
Clamp Crown progress after delayed periodic ticks and track switch
broadcast snapshots by network object identifier. Document the legacy
switch-index packet fields and add regression coverage for delayed
updates, duplicate switch spawns, and economy handling after recapture.
---
...4-CastleSiegeCrownSwitchState_by-server.md | 2 +-
...1-B2-20-CastleSiegeSwitchInfo_by-server.md | 2 +-
.../CastleSiege/CastleSiegeContext.cs | 4 +-
.../CastleSiege/CastleSiegeCrownMechanics.cs | 7 ++
.../CastleSiege/CastleSiegePlugIn.cs | 4 +-
.../CastleSiege/CastleSiegeSwitchMechanics.cs | 8 +-
.../ServerToClient/ConnectionExtensions.cs | 4 +-
.../ServerToClient/ServerToClientPackets.cs | 4 +-
.../ServerToClient/ServerToClientPackets.xml | 2 +
.../ServerToClientPacketsRef.cs | 4 +-
.../CastleSiegeCrownMechanicsTests.cs | 111 ++++++++++++++++++
11 files changed, 137 insertions(+), 15 deletions(-)
diff --git a/docs/Packets/C1-B2-14-CastleSiegeCrownSwitchState_by-server.md b/docs/Packets/C1-B2-14-CastleSiegeCrownSwitchState_by-server.md
index 1c23b00cf..a0a26b426 100644
--- a/docs/Packets/C1-B2-14-CastleSiegeCrownSwitchState_by-server.md
+++ b/docs/Packets/C1-B2-14-CastleSiegeCrownSwitchState_by-server.md
@@ -16,7 +16,7 @@ The client updates the crown switch interaction state.
| 1 | 1 | Byte | 9 | Packet header - length of the packet |
| 2 | 1 | Byte | 0xB2 | Packet header - packet type identifier |
| 3 | 1 | Byte | 0x14 | Packet header - sub packet type identifier |
-| 4 | 2 | ShortBigEndian | | SwitchIndex |
+| 4 | 2 | ShortBigEndian | | SwitchIndex; The network object identifier of the Crown switch. |
| 6 | 2 | ShortBigEndian | | PlayerIndex |
| 8 | 1 | CastleSiegeCrownSwitchStateType | | State |
diff --git a/docs/Packets/C1-B2-20-CastleSiegeSwitchInfo_by-server.md b/docs/Packets/C1-B2-20-CastleSiegeSwitchInfo_by-server.md
index 61411e60d..2b0abbdef 100644
--- a/docs/Packets/C1-B2-20-CastleSiegeSwitchInfo_by-server.md
+++ b/docs/Packets/C1-B2-20-CastleSiegeSwitchInfo_by-server.md
@@ -16,7 +16,7 @@ The client updates the crown switch occupation display.
| 1 | 1 | Byte | 27 | Packet header - length of the packet |
| 2 | 1 | Byte | 0xB2 | Packet header - packet type identifier |
| 3 | 1 | Byte | 0x20 | Packet header - sub packet type identifier |
-| 4 | 2 | ShortBigEndian | | SwitchIndex |
+| 4 | 2 | ShortBigEndian | | SwitchIndex; The network object identifier of the Crown switch. |
| 6 | 1 | Boolean | | IsOccupied |
| 7 | 1 | CastleSiegeJoinSide | | JoinSide |
| 8 | 8 | String | | GuildName |
diff --git a/src/GameLogic/CastleSiege/CastleSiegeContext.cs b/src/GameLogic/CastleSiege/CastleSiegeContext.cs
index f8ee4ff34..6566975f5 100644
--- a/src/GameLogic/CastleSiege/CastleSiegeContext.cs
+++ b/src/GameLogic/CastleSiege/CastleSiegeContext.cs
@@ -136,9 +136,9 @@ public CastleSiegeContext(IGameContext gameContext, CastleSiegeConfiguration con
internal DateTime LastCrownUpdateUtc { get; set; }
///
- /// Gets the switch information which was last broadcast to the siege map.
+ /// Gets the switch information which was last broadcast to the siege map, keyed by network object identifier.
///
- internal CastleSiegeSwitchInfo?[] LastBroadcastSwitchInfos { get; } = new CastleSiegeSwitchInfo?[2];
+ internal Dictionary LastBroadcastSwitchInfos { get; } = [];
///
/// Gets or sets the Crown availability which was last broadcast to the siege map.
diff --git a/src/GameLogic/CastleSiege/CastleSiegeCrownMechanics.cs b/src/GameLogic/CastleSiege/CastleSiegeCrownMechanics.cs
index 67c154372..0dbf014f3 100644
--- a/src/GameLogic/CastleSiege/CastleSiegeCrownMechanics.cs
+++ b/src/GameLogic/CastleSiege/CastleSiegeCrownMechanics.cs
@@ -14,6 +14,8 @@ namespace MUnique.OpenMU.GameLogic.CastleSiege;
///
public static class CastleSiegeCrownMechanics
{
+ private static readonly TimeSpan MaximumProgressInterval = TimeSpan.FromSeconds(2);
+
///
/// Checks whether the Crown and both switches are held by the same attacking side.
///
@@ -25,6 +27,9 @@ public static async ValueTask CheckMiddleWinnerAsync(CastleSiegeContext context,
var elapsed = utcNow > context.LastCrownUpdateUtc
? utcNow - context.LastCrownUpdateUtc
: TimeSpan.Zero;
+ elapsed = elapsed > MaximumProgressInterval
+ ? MaximumProgressInterval
+ : elapsed;
context.LastCrownUpdateUtc = utcNow;
var crownUser = context.CrownUser;
@@ -292,6 +297,8 @@ private static void ApplyOwner(CastleSiegeContext context, CastleSiegeGuildParti
return;
}
+ // A successful seal changes the castle lord immediately. The previous ownership tenure's economy must not
+ // survive that handover, even when the former defender captures the Crown again before the battle ends.
context.SiegeData.OwnerGuildId = winner.PersistentGuildId;
context.SiegeData.IsOccupied = true;
context.SiegeData.TaxChaos = 0;
diff --git a/src/GameLogic/CastleSiege/CastleSiegePlugIn.cs b/src/GameLogic/CastleSiege/CastleSiegePlugIn.cs
index 7dd537afe..5bd73d98c 100644
--- a/src/GameLogic/CastleSiege/CastleSiegePlugIn.cs
+++ b/src/GameLogic/CastleSiege/CastleSiegePlugIn.cs
@@ -338,7 +338,7 @@ private async ValueTask OnEnterStateAsync(CastleSiegeContext context, bool isSta
Array.Clear(context.SwitchUsers);
context.CrownAccumulatedTime = TimeSpan.Zero;
context.IsCrownAvailable = false;
- Array.Clear(context.LastBroadcastSwitchInfos);
+ context.LastBroadcastSwitchInfos.Clear();
context.LastBroadcastCrownAvailability = null;
await context.NpcController.PrepareAsync().ConfigureAwait(false);
await context.NpcController.CloseGatesAsync().ConfigureAwait(false);
@@ -375,7 +375,7 @@ private async ValueTask OnEnterStateAsync(CastleSiegeContext context, bool isSta
context.CrownAccumulatedTime = TimeSpan.Zero;
context.IsCrownAvailable = false;
context.LastCrownUpdateUtc = DateTime.MinValue;
- Array.Clear(context.LastBroadcastSwitchInfos);
+ context.LastBroadcastSwitchInfos.Clear();
context.LastBroadcastCrownAvailability = null;
context.NextParticipantUpdateUtc = DateTime.MaxValue;
break;
diff --git a/src/GameLogic/CastleSiege/CastleSiegeSwitchMechanics.cs b/src/GameLogic/CastleSiege/CastleSiegeSwitchMechanics.cs
index 246b8f674..318975eac 100644
--- a/src/GameLogic/CastleSiege/CastleSiegeSwitchMechanics.cs
+++ b/src/GameLogic/CastleSiege/CastleSiegeSwitchMechanics.cs
@@ -23,7 +23,8 @@ public static async ValueTask SendSwitchInfoAsync(CastleSiegeContext context)
var switchInfos = CreateSwitchInfos(context);
var crownAvailability = UpdateCrownState(context);
var changedSwitches = switchInfos
- .Where((switchInfo, index) => !Equals(switchInfo, context.LastBroadcastSwitchInfos[index]))
+ .Where(switchInfo => !context.LastBroadcastSwitchInfos.TryGetValue(switchInfo.ObjectId, out var previous)
+ || !Equals(switchInfo, previous))
.ToList();
var crownStateChanged = context.LastBroadcastCrownAvailability != crownAvailability;
if (changedSwitches.Count == 0 && !crownStateChanged)
@@ -48,9 +49,10 @@ await player.InvokeViewPlugInAsync(
}
}).ConfigureAwait(false);
- for (var index = 0; index < switchInfos.Count; index++)
+ context.LastBroadcastSwitchInfos.Clear();
+ foreach (var switchInfo in switchInfos)
{
- context.LastBroadcastSwitchInfos[index] = switchInfos[index];
+ context.LastBroadcastSwitchInfos[switchInfo.ObjectId] = switchInfo;
}
context.LastBroadcastCrownAvailability = crownAvailability;
diff --git a/src/Network/Packets/ServerToClient/ConnectionExtensions.cs b/src/Network/Packets/ServerToClient/ConnectionExtensions.cs
index 4ccf3b3a5..94f2c8782 100644
--- a/src/Network/Packets/ServerToClient/ConnectionExtensions.cs
+++ b/src/Network/Packets/ServerToClient/ConnectionExtensions.cs
@@ -6814,7 +6814,7 @@ int WritePacket()
/// Sends a to this connection.
///
/// The connection.
- /// The switch index.
+ /// The network object identifier of the Crown switch.
/// The player index.
/// The state.
///
@@ -7142,7 +7142,7 @@ int WritePacket()
/// Sends a to this connection.
///
/// The connection.
- /// The switch index.
+ /// The network object identifier of the Crown switch.
/// The is occupied.
/// The join side.
/// The guild name.
diff --git a/src/Network/Packets/ServerToClient/ServerToClientPackets.cs b/src/Network/Packets/ServerToClient/ServerToClientPackets.cs
index 46045b7ee..136c06d1a 100644
--- a/src/Network/Packets/ServerToClient/ServerToClientPackets.cs
+++ b/src/Network/Packets/ServerToClient/ServerToClientPackets.cs
@@ -32683,7 +32683,7 @@ private CastleSiegeCrownSwitchState(Memory data, bool initialize)
public C1HeaderWithSubCode Header => new (this._data);
///
- /// Gets or sets the switch index.
+ /// Gets or sets the network object identifier of the Crown switch.
///
public ushort SwitchIndex
{
@@ -33719,7 +33719,7 @@ private CastleSiegeSwitchInfo(Memory data, bool initialize)
public C1HeaderWithSubCode Header => new (this._data);
///
- /// Gets or sets the switch index.
+ /// Gets or sets the network object identifier of the Crown switch.
///
public ushort SwitchIndex
{
diff --git a/src/Network/Packets/ServerToClient/ServerToClientPackets.xml b/src/Network/Packets/ServerToClient/ServerToClientPackets.xml
index f50746b65..8b9116dd7 100644
--- a/src/Network/Packets/ServerToClient/ServerToClientPackets.xml
+++ b/src/Network/Packets/ServerToClient/ServerToClientPackets.xml
@@ -11700,6 +11700,7 @@
4
ShortBigEndian
SwitchIndex
+ The network object identifier of the Crown switch.
6
@@ -11947,6 +11948,7 @@
4
ShortBigEndian
SwitchIndex
+ The network object identifier of the Crown switch.
6
diff --git a/src/Network/Packets/ServerToClient/ServerToClientPacketsRef.cs b/src/Network/Packets/ServerToClient/ServerToClientPacketsRef.cs
index 0481dd653..86f1cff55 100644
--- a/src/Network/Packets/ServerToClient/ServerToClientPacketsRef.cs
+++ b/src/Network/Packets/ServerToClient/ServerToClientPacketsRef.cs
@@ -31046,7 +31046,7 @@ private CastleSiegeCrownSwitchStateRef(Span data, bool initialize)
public C1HeaderWithSubCodeRef Header => new (this._data);
///
- /// Gets or sets the switch index.
+ /// Gets or sets the network object identifier of the Crown switch.
///
public ushort SwitchIndex
{
@@ -32082,7 +32082,7 @@ private CastleSiegeSwitchInfoRef(Span data, bool initialize)
public C1HeaderWithSubCodeRef Header => new (this._data);
///
- /// Gets or sets the switch index.
+ /// Gets or sets the network object identifier of the Crown switch.
///
public ushort SwitchIndex
{
diff --git a/tests/MUnique.OpenMU.Tests/CastleSiegeCrownMechanicsTests.cs b/tests/MUnique.OpenMU.Tests/CastleSiegeCrownMechanicsTests.cs
index 7c582eab0..f6ea027ba 100644
--- a/tests/MUnique.OpenMU.Tests/CastleSiegeCrownMechanicsTests.cs
+++ b/tests/MUnique.OpenMU.Tests/CastleSiegeCrownMechanicsTests.cs
@@ -379,6 +379,50 @@ await CastleSiegeSwitchMechanics
}
}
+ ///
+ /// Verifies that duplicate configured switch spawns do not overflow the broadcast snapshot.
+ ///
+ [Test]
+ public async ValueTask DuplicateSwitchSpawnsAreTrackedByObjectIdAsync()
+ {
+ var fixture = await CreateFixtureAsync().ConfigureAwait(false);
+ var firstSwitchDefinition = fixture.Configuration.NpcDefinitions
+ .Single(definition => definition.MonsterDefinition?.Number == CastleSiegeSwitch.FirstMonsterNumber);
+ fixture.Configuration.NpcDefinitions.Add(new BasicModel.CastleSiegeNpcDefinition
+ {
+ MonsterDefinition = firstSwitchDefinition.MonsterDefinition,
+ InstanceId = 3,
+ SpawnX = 71,
+ SpawnY = 60,
+ Direction = Direction.South,
+ });
+
+ try
+ {
+ await fixture.Context.NpcController.PrepareAsync().ConfigureAwait(false);
+ var observer = await fixture.CreatePlayerAsync(
+ AttackGuildId,
+ "Observer",
+ CastleSiegeJoinSide.Attack1,
+ 90,
+ 60)
+ .ConfigureAwait(false);
+
+ await CastleSiegeSwitchMechanics.SendSwitchInfoAsync(fixture.Context).ConfigureAwait(false);
+ await CastleSiegeSwitchMechanics.SendSwitchInfoAsync(fixture.Context).ConfigureAwait(false);
+
+ Mock.Get(observer.ViewPlugIns.GetPlugIn()!)
+ .Verify(
+ plugIn => plugIn.ShowSwitchInfoAsync(It.IsAny()),
+ Times.Exactly(3));
+ Assert.That(fixture.Context.LastBroadcastSwitchInfos, Has.Count.EqualTo(3));
+ }
+ finally
+ {
+ await fixture.Context.NpcController.DespawnAllAsync().ConfigureAwait(false);
+ }
+ }
+
///
/// Verifies that a new owner is persisted and the Castle Siege economy is reset.
///
@@ -475,6 +519,49 @@ public async ValueTask FinalResultRetainsPersistedOwnerWhenIntermediateOwnerIsMi
});
}
+ ///
+ /// Verifies that a completed ownership tenure's economy is not restored when its guild recaptures the Crown.
+ ///
+ [Test]
+ public async ValueTask RecaptureDoesNotRestorePreviousOwnershipEconomyAsync()
+ {
+ var fixture = await CreateFixtureAsync().ConfigureAwait(false);
+ var attacker = await fixture.CreatePlayerAsync(
+ AttackGuildId,
+ "Attacker",
+ CastleSiegeJoinSide.Attack1,
+ 60,
+ 60)
+ .ConfigureAwait(false);
+ var formerDefender = await fixture.CreatePlayerAsync(
+ DefenseGuildId,
+ "FormerDefender",
+ CastleSiegeJoinSide.Defense,
+ 61,
+ 60)
+ .ConfigureAwait(false);
+ fixture.Context.SiegeData.TaxChaos = 3;
+ fixture.Context.SiegeData.TaxStore = 2;
+ fixture.Context.SiegeData.TaxHunt = 100_000;
+ fixture.Context.SiegeData.TributeMoney = 6_000;
+
+ await CastleSiegeCrownMechanics
+ .ChangeWinnerGuildAsync(fixture.Context, attacker, CastleSiegeJoinSide.Attack1)
+ .ConfigureAwait(false);
+ await CastleSiegeCrownMechanics
+ .ChangeWinnerGuildAsync(fixture.Context, formerDefender, CastleSiegeJoinSide.Attack1)
+ .ConfigureAwait(false);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(fixture.Context.SiegeData.OwnerGuildId, Is.EqualTo(fixture.DefensePersistentGuildId));
+ Assert.That(fixture.Context.SiegeData.TaxChaos, Is.Zero);
+ Assert.That(fixture.Context.SiegeData.TaxStore, Is.Zero);
+ Assert.That(fixture.Context.SiegeData.TaxHunt, Is.Zero);
+ Assert.That(fixture.Context.SiegeData.TributeMoney, Is.Zero);
+ });
+ }
+
///
/// Verifies that Crown progress uses elapsed wall time when periodic ticks are delayed.
///
@@ -498,6 +585,30 @@ public async ValueTask CrownProgressUsesElapsedTimeAsync()
Times.Once);
}
+ ///
+ /// Verifies that a delayed periodic tick cannot satisfy the Crown hold duration in one update.
+ ///
+ [Test]
+ public async ValueTask CrownProgressClampsDelayedUpdatesAsync()
+ {
+ var fixture = await CreateFixtureAsync().ConfigureAwait(false);
+ var crownUser = await fixture.CreatePlayerAsync(AttackGuildId, "CrownUser", CastleSiegeJoinSide.Attack1, 60, 60).ConfigureAwait(false);
+ fixture.Context.CrownUser = crownUser;
+ fixture.Context.SwitchUsers[0] = await fixture.CreatePlayerAsync(AttackGuildId, "SwitchOne", CastleSiegeJoinSide.Attack1, 70, 60).ConfigureAwait(false);
+ fixture.Context.SwitchUsers[1] = await fixture.CreatePlayerAsync(AttackGuildId, "SwitchTwo", CastleSiegeJoinSide.Attack1, 80, 60).ConfigureAwait(false);
+
+ await fixture.CheckCrownAsync(TimeSpan.FromSeconds(30)).ConfigureAwait(false);
+
+ Assert.That(fixture.Context.CrownAccumulatedTime, Is.EqualTo(TimeSpan.FromSeconds(2)));
+ Assert.That(fixture.Context.SiegeData.OwnerGuildId, Is.EqualTo(fixture.DefensePersistentGuildId));
+ Mock.Get(crownUser.ViewPlugIns.GetPlugIn()!)
+ .Verify(
+ plugIn => plugIn.ShowCrownAccessStateAsync(
+ CastleSiegeCrownAccessState.Attempt,
+ TimeSpan.FromSeconds(2)),
+ Times.Once);
+ }
+
private static async ValueTask CreateFixtureAsync()
{
var persistenceContextProvider = new InMemoryPersistenceContextProvider();
From 0ac097a77008f7d6656d8ee715963bf76abd7a59 Mon Sep 17 00:00:00 2001
From: Zylkien <283095668+Zylkien@users.noreply.github.com>
Date: Thu, 27 Aug 2026 13:27:22 +0200
Subject: [PATCH 5/5] Harden Castle Siege NPC synchronization
Use locked NPC runtime snapshots during Crown and switch processing to prevent concurrent collection access.
Keep player-entry synchronization read-only, document the Crown progress clamp, and add regression coverage ensuring synchronization does not mutate shared battle state.
---
src/GameLogic/CastleSiege/CastleSiegeCrownMechanics.cs | 5 +++--
src/GameLogic/CastleSiege/CastleSiegeSwitchMechanics.cs | 6 +++---
.../MUnique.OpenMU.Tests/CastleSiegeCrownMechanicsTests.cs | 7 +++++++
3 files changed, 13 insertions(+), 5 deletions(-)
diff --git a/src/GameLogic/CastleSiege/CastleSiegeCrownMechanics.cs b/src/GameLogic/CastleSiege/CastleSiegeCrownMechanics.cs
index 0dbf014f3..de7afabf4 100644
--- a/src/GameLogic/CastleSiege/CastleSiegeCrownMechanics.cs
+++ b/src/GameLogic/CastleSiege/CastleSiegeCrownMechanics.cs
@@ -14,6 +14,7 @@ namespace MUnique.OpenMU.GameLogic.CastleSiege;
///
public static class CastleSiegeCrownMechanics
{
+ // GameContext executes periodic tasks once per second, so this permits at most two missed intervals.
private static readonly TimeSpan MaximumProgressInterval = TimeSpan.FromSeconds(2);
///
@@ -124,7 +125,7 @@ internal static async ValueTask ChangeWinnerGuildAsync(
await RespawnAttackersAsync(context).ConfigureAwait(false);
context.IsCrownAvailable = false;
- foreach (var crown in context.ActiveNpcs
+ foreach (var crown in context.NpcController.GetRuntimeSnapshot()
.Select(runtime => runtime.SpawnedInstance)
.OfType())
{
@@ -135,7 +136,7 @@ internal static async ValueTask ChangeWinnerGuildAsync(
context.CrownUser = null;
context.PreviousCrownUser = null;
Array.Clear(context.SwitchUsers);
- foreach (var siegeSwitch in context.ActiveNpcs
+ foreach (var siegeSwitch in context.NpcController.GetRuntimeSnapshot()
.Select(runtime => runtime.SpawnedInstance)
.OfType())
{
diff --git a/src/GameLogic/CastleSiege/CastleSiegeSwitchMechanics.cs b/src/GameLogic/CastleSiege/CastleSiegeSwitchMechanics.cs
index 318975eac..0a467a305 100644
--- a/src/GameLogic/CastleSiege/CastleSiegeSwitchMechanics.cs
+++ b/src/GameLogic/CastleSiege/CastleSiegeSwitchMechanics.cs
@@ -73,7 +73,7 @@ await player.InvokeViewPlugInAsync(
.ConfigureAwait(false);
}
- var crownAvailability = UpdateCrownState(context);
+ var crownAvailability = AreSwitchesHeldBySameAttackingSide(context);
await player.InvokeViewPlugInAsync(
plugIn => plugIn.ShowCrownStateAsync(crownAvailability))
.ConfigureAwait(false);
@@ -81,7 +81,7 @@ await player.InvokeViewPlugInAsync(
private static List CreateSwitchInfos(CastleSiegeContext context)
{
- return context.ActiveNpcs
+ return context.NpcController.GetRuntimeSnapshot()
.Select(runtime => runtime.SpawnedInstance)
.OfType()
.OrderBy(candidate => candidate.SwitchIndex)
@@ -117,7 +117,7 @@ private static CastleSiegeSwitchInfo CreateSwitchInfo(
private static bool UpdateCrownState(CastleSiegeContext context)
{
context.IsCrownAvailable = AreSwitchesHeldBySameAttackingSide(context);
- foreach (var crown in context.ActiveNpcs
+ foreach (var crown in context.NpcController.GetRuntimeSnapshot()
.Select(runtime => runtime.SpawnedInstance)
.OfType())
{
diff --git a/tests/MUnique.OpenMU.Tests/CastleSiegeCrownMechanicsTests.cs b/tests/MUnique.OpenMU.Tests/CastleSiegeCrownMechanicsTests.cs
index f6ea027ba..46640700d 100644
--- a/tests/MUnique.OpenMU.Tests/CastleSiegeCrownMechanicsTests.cs
+++ b/tests/MUnique.OpenMU.Tests/CastleSiegeCrownMechanicsTests.cs
@@ -353,6 +353,8 @@ public async ValueTask SwitchInformationControlsCrownAvailabilityAsync()
90,
60)
.ConfigureAwait(false);
+ fixture.Context.IsCrownAvailable = false;
+ crown.State = CastleSiegeCrownState.Locked;
await CastleSiegeSwitchMechanics
.SynchronizePlayerAsync(fixture.Context, enteringPlayer)
.ConfigureAwait(false);
@@ -360,6 +362,11 @@ await CastleSiegeSwitchMechanics
.Verify(plugIn => plugIn.ShowSwitchInfoAsync(It.IsAny()), Times.Exactly(2));
Mock.Get(enteringPlayer.ViewPlugIns.GetPlugIn()!)
.Verify(plugIn => plugIn.ShowCrownStateAsync(true), Times.Once);
+ Assert.Multiple(() =>
+ {
+ Assert.That(fixture.Context.IsCrownAvailable, Is.False);
+ Assert.That(crown.State, Is.EqualTo(CastleSiegeCrownState.Locked));
+ });
fixture.Context.PlayerJoinSides[secondSwitchUser.SelectedCharacter!.Id] = CastleSiegeJoinSide.Attack2;
await CastleSiegeSwitchMechanics.SendSwitchInfoAsync(fixture.Context).ConfigureAwait(false);