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