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 6d0b94a9c..6566975f5 100644
--- a/src/GameLogic/CastleSiege/CastleSiegeContext.cs
+++ b/src/GameLogic/CastleSiege/CastleSiegeContext.cs
@@ -125,6 +125,26 @@ 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 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, keyed by network object identifier.
+ ///
+ internal Dictionary LastBroadcastSwitchInfos { get; } = [];
+
+ ///
+ /// 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.
///
@@ -385,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
new file mode 100644
index 000000000..de7afabf4
--- /dev/null
+++ b/src/GameLogic/CastleSiege/CastleSiegeCrownMechanics.cs
@@ -0,0 +1,320 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+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;
+
+///
+/// Implements Castle Siege Crown capture and ownership changes.
+///
+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);
+
+ ///
+ /// 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, DateTime utcNow)
+ {
+ var elapsed = utcNow > context.LastCrownUpdateUtc
+ ? utcNow - context.LastCrownUpdateUtc
+ : TimeSpan.Zero;
+ elapsed = elapsed > MaximumProgressInterval
+ ? MaximumProgressInterval
+ : elapsed;
+ context.LastCrownUpdateUtc = utcNow;
+
+ 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 += elapsed;
+ context.PreviousCrownUser = crownUser;
+ if (elapsed > TimeSpan.Zero)
+ {
+ 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.
+ internal 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;
+ continue;
+ }
+
+ if (guild.Side == CastleSiegeJoinSide.Defense)
+ {
+ guild.Side = capturingSide;
+ }
+ }
+
+ ApplyOwner(context, capturingGuild);
+ await context.SaveFinalGuildListAsync().ConfigureAwait(false);
+ await context.SaveOwnerAsync().ConfigureAwait(false);
+ await context.SetPlayerJoinSideAsync().ConfigureAwait(false);
+ await RespawnAttackersAsync(context).ConfigureAwait(false);
+
+ context.IsCrownAvailable = false;
+ foreach (var crown in context.NpcController.GetRuntimeSnapshot()
+ .Select(runtime => runtime.SpawnedInstance)
+ .OfType())
+ {
+ crown.State = CastleSiegeCrownState.Locked;
+ }
+
+ context.CrownAccumulatedTime = TimeSpan.Zero;
+ context.CrownUser = null;
+ context.PreviousCrownUser = null;
+ Array.Clear(context.SwitchUsers);
+ foreach (var siegeSwitch in context.NpcController.GetRuntimeSnapshot()
+ .Select(runtime => runtime.SpawnedInstance)
+ .OfType())
+ {
+ siegeSwitch.Occupant = null;
+ }
+
+ 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.
+ internal static async ValueTask CheckResultAsync(CastleSiegeContext context)
+ {
+ CastleSiegeGuildParticipant? winner = null;
+ if (context.MiddleOwnerGuildId is { } middleOwnerGuildId)
+ {
+ 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)
+ {
+ ApplyOwner(context, winner);
+ }
+
+ 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)
+ {
+ // 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)
+ {
+ 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 void ApplyOwner(CastleSiegeContext context, CastleSiegeGuildParticipant winner)
+ {
+ if (context.SiegeData.IsOccupied
+ && context.SiegeData.OwnerGuildId == winner.PersistentGuildId)
+ {
+ 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;
+ 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 =>
+ {
+ 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..5bd73d98c 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);
+ }
}
}
@@ -257,7 +261,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,15 +333,24 @@ 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;
+ context.LastBroadcastSwitchInfos.Clear();
+ 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;
case CastleSiegeState.End:
+ await CastleSiegeCrownMechanics.CheckResultAsync(context).ConfigureAwait(false);
if (!isStartup)
{
await CastleSiegeParticipantTracker.AwardRewardsAsync(context).ConfigureAwait(false);
@@ -353,9 +370,13 @@ 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;
+ context.LastCrownUpdateUtc = DateTime.MinValue;
+ context.LastBroadcastSwitchInfos.Clear();
+ context.LastBroadcastCrownAvailability = null;
context.NextParticipantUpdateUtc = DateTime.MaxValue;
break;
}
@@ -394,13 +415,17 @@ await CastleSiegeParticipantTracker
ParticipantUpdateInterval);
}
+ if (context.CurrentState == CastleSiegeState.Start)
+ {
+ await CastleSiegeSwitchMechanics.SendSwitchInfoAsync(context).ConfigureAwait(false);
+ await CastleSiegeCrownMechanics.CheckMiddleWinnerAsync(context, utcNow).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..0a467a305
--- /dev/null
+++ b/src/GameLogic/CastleSiege/CastleSiegeSwitchMechanics.cs
@@ -0,0 +1,144 @@
+//
+// 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 = CreateSwitchInfos(context);
+ var crownAvailability = UpdateCrownState(context);
+ var changedSwitches = switchInfos
+ .Where(switchInfo => !context.LastBroadcastSwitchInfos.TryGetValue(switchInfo.ObjectId, out var previous)
+ || !Equals(switchInfo, previous))
+ .ToList();
+ var crownStateChanged = context.LastBroadcastCrownAvailability != crownAvailability;
+ if (changedSwitches.Count == 0 && !crownStateChanged)
+ {
+ return;
+ }
+
+ await context.ForEachSiegePlayerAsync(async player =>
+ {
+ foreach (var switchInfo in changedSwitches)
+ {
+ await player.InvokeViewPlugInAsync(
+ plugIn => plugIn.ShowSwitchInfoAsync(switchInfo))
+ .ConfigureAwait(false);
+ }
+
+ if (crownStateChanged)
+ {
+ await player.InvokeViewPlugInAsync(
+ plugIn => plugIn.ShowCrownStateAsync(crownAvailability))
+ .ConfigureAwait(false);
+ }
+ }).ConfigureAwait(false);
+
+ context.LastBroadcastSwitchInfos.Clear();
+ foreach (var switchInfo in switchInfos)
+ {
+ context.LastBroadcastSwitchInfos[switchInfo.ObjectId] = switchInfo;
+ }
+
+ 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 = AreSwitchesHeldBySameAttackingSide(context);
+ await player.InvokeViewPlugInAsync(
+ plugIn => plugIn.ShowCrownStateAsync(crownAvailability))
+ .ConfigureAwait(false);
+ }
+
+ private static List CreateSwitchInfos(CastleSiegeContext context)
+ {
+ return context.NpcController.GetRuntimeSnapshot()
+ .Select(runtime => runtime.SpawnedInstance)
+ .OfType()
+ .OrderBy(candidate => candidate.SwitchIndex)
+ .Select(siegeSwitch => CreateSwitchInfo(
+ context,
+ siegeSwitch,
+ context.SwitchUsers[siegeSwitch.SwitchIndex]))
+ .ToList();
+ }
+
+ 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;
+
+ // 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,
+ side,
+ guildName,
+ occupant?.Name ?? string.Empty);
+ }
+
+ private static bool UpdateCrownState(CastleSiegeContext context)
+ {
+ context.IsCrownAvailable = AreSwitchesHeldBySameAttackingSide(context);
+ foreach (var crown in context.NpcController.GetRuntimeSnapshot()
+ .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
+ || 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/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/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/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
new file mode 100644
index 000000000..46640700d
--- /dev/null
+++ b/tests/MUnique.OpenMU.Tests/CastleSiegeCrownMechanicsTests.cs
@@ -0,0 +1,825 @@
+//
+// 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 fixture.CheckCrownAsync().ConfigureAwait(false);
+ fixture.Context.CrownAccumulatedTime = TimeSpan.FromSeconds(10);
+ fixture.Context.SwitchUsers[1] = null;
+ 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()!)
+ .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 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 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 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 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 fixture.CheckCrownAsync().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 fixture.CheckCrownAsync().ConfigureAwait(false);
+
+ fixture.Context.CrownUser = newCrownUser;
+ await fixture.CheckCrownAsync().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;
+ fixture.Context.FinalGuildList[AttackGuildId].IsAllianceMaster = false;
+
+ await fixture.CheckCrownAsync().ConfigureAwait(false);
+ await fixture.CheckCrownAsync().ConfigureAwait(false);
+ await fixture.CheckCrownAsync().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(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));
+ });
+ 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);
+
+ 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(() =>
+ {
+ 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);
+
+ 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);
+ fixture.Context.IsCrownAvailable = false;
+ crown.State = CastleSiegeCrownState.Locked;
+ 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);
+ 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);
+ 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);
+ Mock.Get(firstSwitchUser.ViewPlugIns.GetPlugIn()!)
+ .Verify(plugIn => plugIn.ShowSwitchInfoAsync(It.IsAny()), Times.Exactly(3));
+ }
+ finally
+ {
+ await fixture.Context.NpcController.DespawnAllAsync().ConfigureAwait(false);
+ }
+ }
+
+ ///
+ /// 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.
+ ///
+ [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));
+ });
+ }
+
+ ///
+ /// 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 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.
+ ///
+ [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);
+ }
+
+ ///
+ /// 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();
+ 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)
+ {
+ private DateTime? _crownTimeUtc;
+
+ ///
+ /// 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;
+ }
+
+ ///
+ /// 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/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"));
+ });
+ }
+}
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