diff --git a/src/GameLogic/CastleSiege/CastleSiegeConfiguration.cs b/src/GameLogic/CastleSiege/CastleSiegeConfiguration.cs
index f19fdf0..092995c 100644
--- a/src/GameLogic/CastleSiege/CastleSiegeConfiguration.cs
+++ b/src/GameLogic/CastleSiege/CastleSiegeConfiguration.cs
@@ -35,6 +35,12 @@ public class CastleSiegeConfiguration
/// Gets or sets how long the war (siege) period lasts.
public TimeSpan SiegeDuration { get; set; } = TimeSpan.FromMinutes(10);
+ ///
+ /// Gets or sets how long the guild master must hold the Crown (with both switches held and all gates down)
+ /// to capture the throne. The client shows a 60-second countdown, so 60s matches the on-screen timer.
+ ///
+ public TimeSpan CrownHoldDuration { get; set; } = TimeSpan.FromSeconds(60);
+
///
/// Gets or sets the registration fee (in zen) a guild master must pay to register the guild
/// for the siege. 0 disables the fee.
diff --git a/src/GameLogic/CastleSiege/CastleSiegeContext.cs b/src/GameLogic/CastleSiege/CastleSiegeContext.cs
index 240a6be..61ff735 100644
--- a/src/GameLogic/CastleSiege/CastleSiegeContext.cs
+++ b/src/GameLogic/CastleSiege/CastleSiegeContext.cs
@@ -23,6 +23,8 @@ public class CastleSiegeContext
private string? _occupier;
private int _defensesRemaining;
private bool _dirty;
+ private string? _crownHoldGuild;
+ private DateTime? _crownHoldStartUtc;
/// Initializes a new instance of the class.
/// The cycle timing configuration.
@@ -188,6 +190,69 @@ public class CastleSiegeContext
return remaining > TimeSpan.Zero ? remaining : TimeSpan.Zero;
}
+ ///
+ /// Returns the guild that currently holds BOTH crown switches while all castle defenses are down (so the
+ /// crown's shield is dropped for them), or null. Only meaningful during the siege.
+ ///
+ public string? GetShieldEligibleGuild()
+ {
+ if (this.Phase != CastleSiegePhase.Siege || this._defensesRemaining > 0)
+ {
+ return null;
+ }
+
+ var holder = this._switchHolders[217];
+ return holder is not null && holder == this._switchHolders[218] ? holder : null;
+ }
+
+ ///
+ /// Advances the crown-hold capture. is the guild with both switches held
+ /// and no defenses left (shield down); is whether that guild's master is
+ /// standing on the crown. Captures the throne for the guild once it has held for .
+ /// Losing a switch or the master leaving the crown resets the hold (contestable until the siege ends).
+ ///
+ /// The guild with both switches and no defenses, or null.
+ /// Whether that guild's master is on the crown.
+ /// The current UTC time.
+ /// How long the master must hold to capture.
+ public CrownTickResult TickCrownHold(string? eligibleGuild, bool masterHolding, DateTime now, TimeSpan holdDuration)
+ {
+ if (this.Phase != CastleSiegePhase.Siege)
+ {
+ this._crownHoldGuild = null;
+ this._crownHoldStartUtc = null;
+ return new CrownTickResult(false, CrownEvent.None, null);
+ }
+
+ var shieldDown = eligibleGuild is not null;
+ var wasHolding = this._crownHoldGuild is not null;
+
+ if (eligibleGuild is null || !masterHolding)
+ {
+ this._crownHoldGuild = null;
+ this._crownHoldStartUtc = null;
+ return new CrownTickResult(shieldDown, wasHolding ? CrownEvent.HoldReset : CrownEvent.None, null);
+ }
+
+ if (this._crownHoldGuild != eligibleGuild || this._crownHoldStartUtc is null)
+ {
+ this._crownHoldGuild = eligibleGuild;
+ this._crownHoldStartUtc = now;
+ return new CrownTickResult(shieldDown, CrownEvent.HoldStarted, eligibleGuild);
+ }
+
+ if (now - this._crownHoldStartUtc.Value >= holdDuration)
+ {
+ this._occupier = eligibleGuild;
+ this._crownHoldGuild = null;
+ this._crownHoldStartUtc = null;
+ this._dirty = true;
+ return new CrownTickResult(shieldDown, CrownEvent.Captured, eligibleGuild);
+ }
+
+ return new CrownTickResult(shieldDown, CrownEvent.None, eligibleGuild);
+ }
+
///
/// 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.
@@ -296,6 +361,8 @@ public class CastleSiegeContext
this._switchHolders[218] = null;
this._defensesRemaining = 0;
this._occupier = null;
+ this._crownHoldGuild = null;
+ this._crownHoldStartUtc = null;
}
private ValueTask TransitionAsync(CastleSiegePhase phase, DateTime now)
@@ -307,3 +374,25 @@ public class CastleSiegeContext
return ValueTask.CompletedTask;
}
}
+
+/// The event produced by a single call.
+public enum CrownEvent
+{
+ /// Nothing changed this tick.
+ None,
+
+ /// A guild master just started holding the crown (start the client countdown).
+ HoldStarted,
+
+ /// The crown was held long enough — the guild captured the throne.
+ Captured,
+
+ /// An in-progress hold was interrupted (switch lost or master left the crown).
+ HoldReset,
+}
+
+/// The result of a crown-hold tick: the shield state and any event that occurred.
+/// Whether the crown shield is currently down (both switches held, defenses cleared).
+/// The event that occurred this tick.
+/// The guild the event refers to, if any.
+public readonly record struct CrownTickResult(bool ShieldDown, CrownEvent Event, string? Guild);
diff --git a/src/GameLogic/CastleSiege/CastleSiegeThroneCaptureTalkPlugIn.cs b/src/GameLogic/CastleSiege/CastleSiegeThroneCaptureTalkPlugIn.cs
index 2312bac..891af70 100644
--- a/src/GameLogic/CastleSiege/CastleSiegeThroneCaptureTalkPlugIn.cs
+++ b/src/GameLogic/CastleSiege/CastleSiegeThroneCaptureTalkPlugIn.cs
@@ -65,17 +65,34 @@ public class CastleSiegeThroneCaptureTalkPlugIn : IPlayerTalkToNpcPlugIn
return;
}
- var (success, reason) = context.TryCaptureThrone(guildName);
- if (success)
+ // The throne is taken by holding the Crown, not by talking here — give guidance based on the state.
+ await ShowAsync(player, DescribeThroneStep(context, guildName)).ConfigureAwait(false);
+ }
+
+ private static string DescribeThroneStep(CastleSiegeContext context, string guildName)
+ {
+ if (context.Phase != CastleSiegePhase.Siege)
{
- await player.GameContext.ForEachPlayerAsync(p =>
- p.InvokeViewPlugInAsync(v =>
- v.ShowMessageAsync($"Guild '{guildName}' has CAPTURED THE THRONE! They will win the castle if they hold it until the siege ends.", MessageType.GoldenCenter)).AsTask()).ConfigureAwait(false);
+ return "The siege is not running yet.";
}
- else
+
+ if (context.DefensesRemaining > 0)
{
- await ShowAsync(player, reason).ConfigureAwait(false);
+ return $"Destroy all castle gates first ({context.DefensesRemaining} remaining), then hold both Crown Switches.";
}
+
+ var eligible = context.GetShieldEligibleGuild();
+ if (eligible is null)
+ {
+ return "All gates are down! Hold BOTH Crown Switches with your guild — the Crown's shield will drop.";
+ }
+
+ if (eligible == guildName)
+ {
+ return "Your guild holds both switches and the shield is down — send your GUILD MASTER to hold the Crown to take the throne!";
+ }
+
+ return $"Guild '{eligible}' is holding both switches. Take a switch back to raise their shield.";
}
private static ValueTask ShowAsync(Player player, string text)
diff --git a/src/GameLogic/PlugIns/PeriodicTasks/CastleSiegeEventPlugIn.cs b/src/GameLogic/PlugIns/PeriodicTasks/CastleSiegeEventPlugIn.cs
index 94a06cb..5aeafb4 100644
--- a/src/GameLogic/PlugIns/PeriodicTasks/CastleSiegeEventPlugIn.cs
+++ b/src/GameLogic/PlugIns/PeriodicTasks/CastleSiegeEventPlugIn.cs
@@ -63,6 +63,10 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom
(218, 184, 195),
};
+ // The Crown (NPC 216) position on Valley of Loren — the guild master holds it here to capture the throne.
+ private static readonly Point CrownPosition = new(176, 212);
+ private const int CrownHoldRange = 4;
+
private static readonly ConcurrentDictionary Contexts = new();
private string? _cachedFlagOwner;
@@ -350,9 +354,35 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom
context.SetSwitchHolder(switchNumber, holder);
}
+ var now = DateTime.UtcNow;
+
+ // Crown-hold capture: when a guild holds both switches with every gate down, the crown shield drops;
+ // that guild's master then holds the crown for CrownHoldDuration to take the throne (contestable).
+ var eligible = context.GetShieldEligibleGuild();
+ var masterOnCrown = false;
+ if (eligible is not null)
+ {
+ foreach (var player in map.GetAttackablesInRange(CrownPosition, CrownHoldRange).OfType())
+ {
+ if (player.GuildStatus?.Position == GuildPosition.GuildMaster
+ && await GetGuildNameAsync(player).ConfigureAwait(false) == eligible)
+ {
+ masterOnCrown = true;
+ break;
+ }
+ }
+ }
+
+ var holdDuration = context.Configuration.CrownHoldDuration;
+ var crown = context.TickCrownHold(eligible, masterOnCrown, now, holdDuration);
+ await BroadcastCrownAsync(gameContext, crown).ConfigureAwait(false);
+ if (crown.Event == CrownEvent.Captured && crown.Guild is { } capturedGuild)
+ {
+ await AnnounceAsync(gameContext, $"Guild '{capturedGuild}' has taken the Crown and now holds the throne!").ConfigureAwait(false);
+ }
+
// 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);
@@ -386,6 +416,36 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom
}
});
+ ///
+ /// Sends the crown shield state (each tick) and any crown-hold event (start/reset/capture) to every player
+ /// currently on the battle map.
+ ///
+ private static ValueTask BroadcastCrownAsync(IGameContext gameContext, CrownTickResult crown)
+ => gameContext.ForEachPlayerAsync(async player =>
+ {
+ if (player.CurrentMap?.Definition.Number != ValleyOfLorenMapNumber)
+ {
+ return;
+ }
+
+ await player.InvokeViewPlugInAsync(p => p.SetCrownShieldAsync(crown.ShieldDown)).ConfigureAwait(false);
+
+ switch (crown.Event)
+ {
+ case CrownEvent.HoldStarted:
+ await player.InvokeViewPlugInAsync(p => p.SetCrownRegistAsync(0, 0)).ConfigureAwait(false);
+ break;
+ case CrownEvent.HoldReset:
+ await player.InvokeViewPlugInAsync(p => p.SetCrownRegistAsync(2, 0)).ConfigureAwait(false);
+ break;
+ case CrownEvent.Captured when crown.Guild is { } guild:
+ await player.InvokeViewPlugInAsync(p => p.AnnounceSealCapturedAsync(guild)).ConfigureAwait(false);
+ break;
+ default:
+ break;
+ }
+ });
+
private async ValueTask BroadcastCastleFlagAsync(IGameContext gameContext, CastleSiegeContext context)
{
try
diff --git a/src/GameLogic/Views/CastleSiege/ICastleSiegeStatusViewPlugIn.cs b/src/GameLogic/Views/CastleSiege/ICastleSiegeStatusViewPlugIn.cs
index 255b2c0..f99a79b 100644
--- a/src/GameLogic/Views/CastleSiege/ICastleSiegeStatusViewPlugIn.cs
+++ b/src/GameLogic/Views/CastleSiege/ICastleSiegeStatusViewPlugIn.cs
@@ -28,4 +28,25 @@ public interface ICastleSiegeStatusViewPlugIn : IViewPlugIn
///
/// The owner guild's 32-byte logo/emblem.
ValueTask SetCastleFlagAsync(ReadOnlyMemory guildLogo);
+
+ ///
+ /// Drops (down = true) or raises (down = false) the shield/barrier over the crown (C1 B2 16).
+ /// The crown is only clickable/capturable while the shield is down.
+ ///
+ /// Whether the shield should be down.
+ ValueTask SetCrownShieldAsync(bool down);
+
+ ///
+ /// Sends the crown-hold registration state (C1 B2 15): state 0 = started/in-progress (starts the client's
+ /// 60-second countdown), 1 = success, 2 = failed/reset.
+ ///
+ /// The registration state (0 progress, 1 success, 2 fail).
+ /// Accumulated hold time in milliseconds (client shows 60000 - this).
+ ValueTask SetCrownRegistAsync(byte state, uint accessMilliseconds);
+
+ ///
+ /// Broadcasts that a guild registered the official seal / took the crown (C1 B2 18, state 1).
+ ///
+ /// The capturing guild's name (max 8 bytes).
+ ValueTask AnnounceSealCapturedAsync(string guildName);
}
diff --git a/src/GameServer/RemoteView/CastleSiege/CastleSiegeStatusViewPlugIn.cs b/src/GameServer/RemoteView/CastleSiege/CastleSiegeStatusViewPlugIn.cs
index de2b2ba..80b8e99 100644
--- a/src/GameServer/RemoteView/CastleSiege/CastleSiegeStatusViewPlugIn.cs
+++ b/src/GameServer/RemoteView/CastleSiege/CastleSiegeStatusViewPlugIn.cs
@@ -101,4 +101,82 @@ public class CastleSiegeStatusViewPlugIn : ICastleSiegeStatusViewPlugIn
await connection.SendAsync(WritePacket).ConfigureAwait(false);
}
+
+ ///
+ public async ValueTask SetCrownShieldAsync(bool down)
+ {
+ var connection = this._player.Connection;
+ if (connection is null)
+ {
+ return;
+ }
+
+ int WritePacket()
+ {
+ // C1 0C B2 16 ; state 0 = shield down, 1 = shield up
+ var span = connection.Output.GetSpan(12)[..12];
+ span.Clear();
+ span[0] = 0xC1;
+ span[1] = 0x0C;
+ span[2] = 0xB2;
+ span[3] = 0x16;
+ span[4] = (byte)(down ? 0 : 1);
+ return span.Length;
+ }
+
+ await connection.SendAsync(WritePacket).ConfigureAwait(false);
+ }
+
+ ///
+ public async ValueTask SetCrownRegistAsync(byte state, uint accessMilliseconds)
+ {
+ var connection = this._player.Connection;
+ if (connection is null)
+ {
+ return;
+ }
+
+ int WritePacket()
+ {
+ // C1 0C B2 15
+ var span = connection.Output.GetSpan(12)[..12];
+ span.Clear();
+ span[0] = 0xC1;
+ span[1] = 0x0C;
+ span[2] = 0xB2;
+ span[3] = 0x15;
+ span[4] = state;
+ System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(span.Slice(8, 4), accessMilliseconds);
+ return span.Length;
+ }
+
+ await connection.SendAsync(WritePacket).ConfigureAwait(false);
+ }
+
+ ///
+ public async ValueTask AnnounceSealCapturedAsync(string guildName)
+ {
+ var connection = this._player.Connection;
+ if (connection is null)
+ {
+ return;
+ }
+
+ int WritePacket()
+ {
+ // C1 0D B2 18
+ var span = connection.Output.GetSpan(13)[..13];
+ span.Clear();
+ span[0] = 0xC1;
+ span[1] = 0x0D;
+ span[2] = 0xB2;
+ span[3] = 0x18;
+ span[4] = 1;
+ var bytes = System.Text.Encoding.UTF8.GetBytes(guildName);
+ bytes.AsSpan(0, Math.Min(8, bytes.Length)).CopyTo(span.Slice(5, 8));
+ 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 f4f7a08..ed9b4f3 100644
--- a/tests/MUnique.OpenMU.Tests/CastleSiege/CastleSiegeContextTest.cs
+++ b/tests/MUnique.OpenMU.Tests/CastleSiege/CastleSiegeContextTest.cs
@@ -193,6 +193,49 @@ public class CastleSiegeContextTest
Assert.That(ctx.GetRemainingSiegeTime(T0.AddMinutes(15)), Is.EqualTo(TimeSpan.Zero), "past the end -> clamped to zero");
}
+ /// Tests that a guild master holding the crown (both switches held, defenses down) captures after the hold duration.
+ [Test]
+ public async Task CrownHoldCapturesAfterHoldDurationAsync()
+ {
+ var ctx = new CastleSiegeContext(Config());
+ await ctx.ForceStartRegistrationAsync(T0);
+ await ctx.ForcePhaseAsync(CastleSiegePhase.Siege, T0);
+ ctx.SetDefenseCount(0);
+ ctx.SetSwitchHolder(217, "Attackers");
+ ctx.SetSwitchHolder(218, "Attackers");
+ Assert.That(ctx.GetShieldEligibleGuild(), Is.EqualTo("Attackers"));
+
+ var hold = TimeSpan.FromSeconds(60);
+ Assert.That(ctx.TickCrownHold("Attackers", true, T0, hold).Event, Is.EqualTo(CrownEvent.HoldStarted));
+ Assert.That(ctx.TickCrownHold("Attackers", true, T0.AddSeconds(30), hold).Event, Is.EqualTo(CrownEvent.None));
+ Assert.That(ctx.OccupierGuildName, Is.Null);
+
+ var captured = ctx.TickCrownHold("Attackers", true, T0.AddSeconds(60), hold);
+ Assert.That(captured.Event, Is.EqualTo(CrownEvent.Captured));
+ Assert.That(captured.ShieldDown, Is.True);
+ Assert.That(ctx.OccupierGuildName, Is.EqualTo("Attackers"));
+ }
+
+ /// Tests that losing a switch mid-hold resets the crown-hold progress (contestable).
+ [Test]
+ public async Task CrownHoldResetsWhenSwitchLostAsync()
+ {
+ var ctx = new CastleSiegeContext(Config());
+ await ctx.ForceStartRegistrationAsync(T0);
+ await ctx.ForcePhaseAsync(CastleSiegePhase.Siege, T0);
+ ctx.SetDefenseCount(0);
+ ctx.SetSwitchHolder(217, "A");
+ ctx.SetSwitchHolder(218, "A");
+
+ Assert.That(ctx.TickCrownHold("A", true, T0, TimeSpan.FromSeconds(60)).Event, Is.EqualTo(CrownEvent.HoldStarted));
+
+ ctx.SetSwitchHolder(218, null); // lost a switch -> no longer eligible
+ var reset = ctx.TickCrownHold(ctx.GetShieldEligibleGuild(), false, T0.AddSeconds(10), TimeSpan.FromSeconds(60));
+ Assert.That(reset.ShieldDown, Is.False);
+ Assert.That(reset.Event, Is.EqualTo(CrownEvent.HoldReset));
+ Assert.That(ctx.OccupierGuildName, Is.Null);
+ }
+
private static CastleSiegeConfiguration Config() => new()
{
RegistrationDuration = TimeSpan.FromMinutes(5),