diff --git a/src/GameLogic/CastleSiege/CastleSiegeContext.cs b/src/GameLogic/CastleSiege/CastleSiegeContext.cs index 840576f..6846f54 100644 --- a/src/GameLogic/CastleSiege/CastleSiegeContext.cs +++ b/src/GameLogic/CastleSiege/CastleSiegeContext.cs @@ -193,6 +193,22 @@ public class CastleSiegeContext return scheduleTime <= nowTime && nowTime <= windowEnd; } + /// + /// Returns how much time is left in the running siege battle (the on-map countdown value), or + /// when the siege is not currently running. + /// + /// The current UTC time. + public TimeSpan GetRemainingSiegeTime(DateTime nowUtc) + { + if (this.Phase != CastleSiegePhase.Siege) + { + return TimeSpan.Zero; + } + + var remaining = (this._phaseStartedUtc + this.Configuration.SiegeDuration) - nowUtc; + return remaining > TimeSpan.Zero ? remaining : TimeSpan.Zero; + } + /// /// Returns whether the persistable state changed since the last call, resetting the flag. /// Called each tick by the plugin to decide whether to write state to the database. diff --git a/src/GameLogic/PlugIns/PeriodicTasks/CastleSiegeEventPlugIn.cs b/src/GameLogic/PlugIns/PeriodicTasks/CastleSiegeEventPlugIn.cs index 4e14581..b048fa0 100644 --- a/src/GameLogic/PlugIns/PeriodicTasks/CastleSiegeEventPlugIn.cs +++ b/src/GameLogic/PlugIns/PeriodicTasks/CastleSiegeEventPlugIn.cs @@ -10,6 +10,7 @@ using System.Runtime.InteropServices; using MUnique.OpenMU.GameLogic.CastleSiege; using MUnique.OpenMU.GameLogic.NPC; using MUnique.OpenMU.GameLogic.Views; +using MUnique.OpenMU.GameLogic.Views.CastleSiege; using MUnique.OpenMU.Interfaces; using MUnique.OpenMU.Pathfinding; using MUnique.OpenMU.Persistence; @@ -122,6 +123,10 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom await SpawnCastleDefensesAsync(gameContext, context).ConfigureAwait(false); await WarpRegisteredMembersToSiegeAsync(gameContext, context).ConfigureAwait(false); break; + case CastleSiegePhase.Settlement: + // Stop the on-map countdown for everyone still on the battle map. + await BroadcastSiegeStateAsync(gameContext, false, 0, 0).ConfigureAwait(false); + break; case CastleSiegePhase.Ownership when context.OwnerGuildName is { } owner: await AnnounceAsync(gameContext, $"The Castle Siege has ended. The castle now belongs to the guild '{owner}'!").ConfigureAwait(false); break; @@ -293,6 +298,16 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom context.SetSwitchHolder(switchNumber, holder); } + + // Keep the client's on-map countdown armed and in sync. Resend every 10s so players who just + // loaded the battle map pick it up, without visibly resetting the second-counter too often. + var now = DateTime.UtcNow; + if ((int)(now - context.PhaseStartedUtc).TotalSeconds % 10 == 0) + { + var remaining = context.GetRemainingSiegeTime(now); + var totalMinutes = (int)Math.Ceiling(remaining.TotalMinutes); + await BroadcastSiegeStateAsync(gameContext, true, (byte)(totalMinutes / 60), (byte)(totalMinutes % 60)).ConfigureAwait(false); + } } catch (Exception ex) { @@ -301,6 +316,25 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom } } + /// + /// Sends the Castle Siege countdown state to every player currently on the battle map (Valley of Loren). + /// When is true it also (re)seeds the remaining hour/minute. + /// + private static ValueTask BroadcastSiegeStateAsync(IGameContext gameContext, bool started, byte hour, byte minute) + => gameContext.ForEachPlayerAsync(async player => + { + if (player.CurrentMap?.Definition.Number != ValleyOfLorenMapNumber) + { + return; + } + + await player.InvokeViewPlugInAsync(p => p.SetBattleStateAsync(started)).ConfigureAwait(false); + if (started) + { + await player.InvokeViewPlugInAsync(p => p.SetTimerAsync(hour, minute)).ConfigureAwait(false); + } + }); + private static async Task WarpRegisteredMembersToSiegeAsync(IGameContext gameContext, CastleSiegeContext context) { try diff --git a/src/GameLogic/Views/CastleSiege/ICastleSiegeStatusViewPlugIn.cs b/src/GameLogic/Views/CastleSiege/ICastleSiegeStatusViewPlugIn.cs new file mode 100644 index 0000000..e27173e --- /dev/null +++ b/src/GameLogic/Views/CastleSiege/ICastleSiegeStatusViewPlugIn.cs @@ -0,0 +1,25 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.Views.CastleSiege; + +/// +/// Interface of a view which drives the client's on-map Castle Siege countdown (Season 6 protocol +/// C1 B2 17 = battle start/stop flag, C1 B2 1E = remaining hour/minute). +/// +public interface ICastleSiegeStatusViewPlugIn : IViewPlugIn +{ + /// + /// Sends the siege battle start/stop flag (C1 B2 17). The client only ticks its countdown while this is set. + /// + /// Whether the siege battle is running. + ValueTask SetBattleStateAsync(bool started); + + /// + /// Sends the remaining siege time as hour/minute (C1 B2 1E) to (re)seed the countdown. + /// + /// The remaining hours. + /// The remaining minutes. + ValueTask SetTimerAsync(byte hour, byte minute); +} diff --git a/src/GameServer/RemoteView/CastleSiege/CastleSiegeStatusViewPlugIn.cs b/src/GameServer/RemoteView/CastleSiege/CastleSiegeStatusViewPlugIn.cs new file mode 100644 index 0000000..4787cc4 --- /dev/null +++ b/src/GameServer/RemoteView/CastleSiege/CastleSiegeStatusViewPlugIn.cs @@ -0,0 +1,78 @@ +// +// 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; +using MUnique.OpenMU.PlugIns; + +/// +/// The default implementation of which sends the Season 6 +/// Castle Siege countdown packets straight to the game client as raw bytes (no packet-struct exists yet). +/// +[PlugIn] +[Display(Name = "Castle Siege Status View", Description = "Sends the Castle Siege on-map countdown/state packets (C1 B2 17 / C1 B2 1E).")] +[Guid("CA5710A0-7A1B-4C2D-8E3F-0000000017E0")] +public class CastleSiegeStatusViewPlugIn : ICastleSiegeStatusViewPlugIn +{ + private readonly RemotePlayer _player; + + /// + /// Initializes a new instance of the class. + /// + /// The player. + public CastleSiegeStatusViewPlugIn(RemotePlayer player) => this._player = player; + + /// + public async ValueTask SetBattleStateAsync(bool started) + { + var connection = this._player.Connection; + if (connection is null) + { + return; + } + + int WritePacket() + { + // C1 09 B2 17 + var span = connection.Output.GetSpan(9)[..9]; + span.Clear(); + span[0] = 0xC1; + span[1] = 0x09; + span[2] = 0xB2; + span[3] = 0x17; + span[4] = (byte)(started ? 1 : 0); + return span.Length; + } + + await connection.SendAsync(WritePacket).ConfigureAwait(false); + } + + /// + public async ValueTask SetTimerAsync(byte hour, byte minute) + { + var connection = this._player.Connection; + if (connection is null) + { + return; + } + + int WritePacket() + { + // C1 06 B2 1E + var span = connection.Output.GetSpan(6)[..6]; + span[0] = 0xC1; + span[1] = 0x06; + span[2] = 0xB2; + span[3] = 0x1E; + span[4] = hour; + span[5] = minute; + return span.Length; + } + + await connection.SendAsync(WritePacket).ConfigureAwait(false); + } +} diff --git a/tests/MUnique.OpenMU.Tests/CastleSiege/CastleSiegeContextTest.cs b/tests/MUnique.OpenMU.Tests/CastleSiege/CastleSiegeContextTest.cs index de31a8d..4836fce 100644 --- a/tests/MUnique.OpenMU.Tests/CastleSiege/CastleSiegeContextTest.cs +++ b/tests/MUnique.OpenMU.Tests/CastleSiege/CastleSiegeContextTest.cs @@ -179,6 +179,20 @@ public class CastleSiegeContextTest Assert.That(ctx.ConsumeDirty(), Is.True); } + /// Tests that the remaining siege time counts down during the siege and is zero otherwise. + [Test] + public async Task RemainingSiegeTimeReflectsSiegePhaseAsync() + { + var ctx = new CastleSiegeContext(Config()); // 10 minute siege duration + Assert.That(ctx.GetRemainingSiegeTime(T0), Is.EqualTo(TimeSpan.Zero), "no siege running -> zero"); + + await ctx.ForceStartRegistrationAsync(T0); + await ctx.ForcePhaseAsync(CastleSiegePhase.Siege, T0); + + Assert.That(ctx.GetRemainingSiegeTime(T0.AddMinutes(3)), Is.EqualTo(TimeSpan.FromMinutes(7))); + Assert.That(ctx.GetRemainingSiegeTime(T0.AddMinutes(15)), Is.EqualTo(TimeSpan.Zero), "past the end -> clamped to zero"); + } + private static CastleSiegeConfiguration Config() => new() { RegistrationDuration = TimeSpan.FromMinutes(5),