feat(CS): on-map siege countdown timer (S6 client protocol C1 B2 17/1E)
Some checks failed
.NET Core / build (push) Has been cancelled
Some checks failed
.NET Core / build (push) Has been cancelled
Client already renders the siege-map countdown but never received the packets. New ICastleSiegeStatusViewPlugIn + RemoteView impl send the raw S6 packets: C1 B2 17 (battle start/stop flag, arms the countdown) and C1 B2 1E (remaining hour/minute). CastleSiegeEventPlugIn broadcasts them to players on the battle map every 10s during Siege and sends stop at Settlement. No client changes. +1 test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -193,6 +193,22 @@ public class CastleSiegeContext
|
|||||||
return scheduleTime <= nowTime && nowTime <= windowEnd;
|
return scheduleTime <= nowTime && nowTime <= windowEnd;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns how much time is left in the running siege battle (the on-map countdown value), or
|
||||||
|
/// <see cref="TimeSpan.Zero"/> when the siege is not currently running.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="nowUtc">The current UTC time.</param>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns whether the persistable state changed since the last call, resetting the flag.
|
/// 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.
|
/// Called each tick by the plugin to decide whether to write state to the database.
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ using System.Runtime.InteropServices;
|
|||||||
using MUnique.OpenMU.GameLogic.CastleSiege;
|
using MUnique.OpenMU.GameLogic.CastleSiege;
|
||||||
using MUnique.OpenMU.GameLogic.NPC;
|
using MUnique.OpenMU.GameLogic.NPC;
|
||||||
using MUnique.OpenMU.GameLogic.Views;
|
using MUnique.OpenMU.GameLogic.Views;
|
||||||
|
using MUnique.OpenMU.GameLogic.Views.CastleSiege;
|
||||||
using MUnique.OpenMU.Interfaces;
|
using MUnique.OpenMU.Interfaces;
|
||||||
using MUnique.OpenMU.Pathfinding;
|
using MUnique.OpenMU.Pathfinding;
|
||||||
using MUnique.OpenMU.Persistence;
|
using MUnique.OpenMU.Persistence;
|
||||||
@@ -122,6 +123,10 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom
|
|||||||
await SpawnCastleDefensesAsync(gameContext, context).ConfigureAwait(false);
|
await SpawnCastleDefensesAsync(gameContext, context).ConfigureAwait(false);
|
||||||
await WarpRegisteredMembersToSiegeAsync(gameContext, context).ConfigureAwait(false);
|
await WarpRegisteredMembersToSiegeAsync(gameContext, context).ConfigureAwait(false);
|
||||||
break;
|
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:
|
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);
|
await AnnounceAsync(gameContext, $"The Castle Siege has ended. The castle now belongs to the guild '{owner}'!").ConfigureAwait(false);
|
||||||
break;
|
break;
|
||||||
@@ -293,6 +298,16 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom
|
|||||||
|
|
||||||
context.SetSwitchHolder(switchNumber, holder);
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -301,6 +316,25 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sends the Castle Siege countdown state to every player currently on the battle map (Valley of Loren).
|
||||||
|
/// When <paramref name="started"/> is true it also (re)seeds the remaining hour/minute.
|
||||||
|
/// </summary>
|
||||||
|
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<ICastleSiegeStatusViewPlugIn>(p => p.SetBattleStateAsync(started)).ConfigureAwait(false);
|
||||||
|
if (started)
|
||||||
|
{
|
||||||
|
await player.InvokeViewPlugInAsync<ICastleSiegeStatusViewPlugIn>(p => p.SetTimerAsync(hour, minute)).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
private static async Task WarpRegisteredMembersToSiegeAsync(IGameContext gameContext, CastleSiegeContext context)
|
private static async Task WarpRegisteredMembersToSiegeAsync(IGameContext gameContext, CastleSiegeContext context)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
// <copyright file="ICastleSiegeStatusViewPlugIn.cs" company="MUnique">
|
||||||
|
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
|
||||||
|
// </copyright>
|
||||||
|
|
||||||
|
namespace MUnique.OpenMU.GameLogic.Views.CastleSiege;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
public interface ICastleSiegeStatusViewPlugIn : IViewPlugIn
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Sends the siege battle start/stop flag (C1 B2 17). The client only ticks its countdown while this is set.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="started">Whether the siege battle is running.</param>
|
||||||
|
ValueTask SetBattleStateAsync(bool started);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sends the remaining siege time as hour/minute (C1 B2 1E) to (re)seed the countdown.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="hour">The remaining hours.</param>
|
||||||
|
/// <param name="minute">The remaining minutes.</param>
|
||||||
|
ValueTask SetTimerAsync(byte hour, byte minute);
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
// <copyright file="CastleSiegeStatusViewPlugIn.cs" company="MUnique">
|
||||||
|
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
|
||||||
|
// </copyright>
|
||||||
|
|
||||||
|
namespace MUnique.OpenMU.GameServer.RemoteView.CastleSiege;
|
||||||
|
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using MUnique.OpenMU.GameLogic.Views.CastleSiege;
|
||||||
|
using MUnique.OpenMU.Network;
|
||||||
|
using MUnique.OpenMU.PlugIns;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The default implementation of <see cref="ICastleSiegeStatusViewPlugIn"/> which sends the Season 6
|
||||||
|
/// Castle Siege countdown packets straight to the game client as raw bytes (no packet-struct exists yet).
|
||||||
|
/// </summary>
|
||||||
|
[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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="CastleSiegeStatusViewPlugIn"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="player">The player.</param>
|
||||||
|
public CastleSiegeStatusViewPlugIn(RemotePlayer player) => this._player = player;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async ValueTask SetBattleStateAsync(bool started)
|
||||||
|
{
|
||||||
|
var connection = this._player.Connection;
|
||||||
|
if (connection is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int WritePacket()
|
||||||
|
{
|
||||||
|
// C1 09 B2 17 <started> <crownAccessTime:4 bytes = 0>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async ValueTask SetTimerAsync(byte hour, byte minute)
|
||||||
|
{
|
||||||
|
var connection = this._player.Connection;
|
||||||
|
if (connection is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int WritePacket()
|
||||||
|
{
|
||||||
|
// C1 06 B2 1E <hour> <minute>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -179,6 +179,20 @@ public class CastleSiegeContextTest
|
|||||||
Assert.That(ctx.ConsumeDirty(), Is.True);
|
Assert.That(ctx.ConsumeDirty(), Is.True);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Tests that the remaining siege time counts down during the siege and is zero otherwise.</summary>
|
||||||
|
[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()
|
private static CastleSiegeConfiguration Config() => new()
|
||||||
{
|
{
|
||||||
RegistrationDuration = TimeSpan.FromMinutes(5),
|
RegistrationDuration = TimeSpan.FromMinutes(5),
|
||||||
|
|||||||
Reference in New Issue
Block a user