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