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