feat(CS): authentic Crown-hold throne capture (S6 protocol) replacing Sinior-talk
Some checks failed
.NET Core / build (push) Has been cancelled

Break all gates + hold both switches (defenses down) -> the Crown shield drops
(C1 B2 16=0). The guild master then stands on the Crown (176,212) and holds for
CrownHoldDuration (default 60s, client shows a 60s countdown via C1 B2 15) to
capture; capture broadcasts C1 B2 18 + golden text and sets the occupier. Losing a
switch or leaving the crown resets the hold (contestable until the siege timer ends).
Sinior (223) is now informational guidance. +2 tests (14 total).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Acentech Dev
2026-07-15 15:04:10 +03:00
parent 4651ef232c
commit 3dd4881406
7 changed files with 322 additions and 8 deletions

View File

@@ -35,6 +35,12 @@ public class CastleSiegeConfiguration
/// <summary>Gets or sets how long the war (siege) period lasts.</summary>
public TimeSpan SiegeDuration { get; set; } = TimeSpan.FromMinutes(10);
/// <summary>
/// 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.
/// </summary>
public TimeSpan CrownHoldDuration { get; set; } = TimeSpan.FromSeconds(60);
/// <summary>
/// Gets or sets the registration fee (in zen) a guild master must pay to register the guild
/// for the siege. 0 disables the fee.

View File

@@ -23,6 +23,8 @@ public class CastleSiegeContext
private string? _occupier;
private int _defensesRemaining;
private bool _dirty;
private string? _crownHoldGuild;
private DateTime? _crownHoldStartUtc;
/// <summary>Initializes a new instance of the <see cref="CastleSiegeContext"/> class.</summary>
/// <param name="configuration">The cycle timing configuration.</param>
@@ -188,6 +190,69 @@ public class CastleSiegeContext
return remaining > TimeSpan.Zero ? remaining : TimeSpan.Zero;
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// Advances the crown-hold capture. <paramref name="eligibleGuild"/> is the guild with both switches held
/// and no defenses left (shield down); <paramref name="masterHolding"/> is whether that guild's master is
/// standing on the crown. Captures the throne for the guild once it has held for <paramref name="holdDuration"/>.
/// Losing a switch or the master leaving the crown resets the hold (contestable until the siege ends).
/// </summary>
/// <param name="eligibleGuild">The guild with both switches and no defenses, or null.</param>
/// <param name="masterHolding">Whether that guild's master is on the crown.</param>
/// <param name="now">The current UTC time.</param>
/// <param name="holdDuration">How long the master must hold to capture.</param>
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);
}
/// <summary>
/// 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;
}
}
/// <summary>The event produced by a single <see cref="CastleSiegeContext.TickCrownHold"/> call.</summary>
public enum CrownEvent
{
/// <summary>Nothing changed this tick.</summary>
None,
/// <summary>A guild master just started holding the crown (start the client countdown).</summary>
HoldStarted,
/// <summary>The crown was held long enough — the guild captured the throne.</summary>
Captured,
/// <summary>An in-progress hold was interrupted (switch lost or master left the crown).</summary>
HoldReset,
}
/// <summary>The result of a crown-hold tick: the shield state and any event that occurred.</summary>
/// <param name="ShieldDown">Whether the crown shield is currently down (both switches held, defenses cleared).</param>
/// <param name="Event">The event that occurred this tick.</param>
/// <param name="Guild">The guild the event refers to, if any.</param>
public readonly record struct CrownTickResult(bool ShieldDown, CrownEvent Event, string? Guild);

View File

@@ -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<IShowMessagePlugIn>(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)

View File

@@ -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<IGameContext, CastleSiegeContext> 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<Player>())
{
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
}
});
/// <summary>
/// Sends the crown shield state (each tick) and any crown-hold event (start/reset/capture) to every player
/// currently on the battle map.
/// </summary>
private static ValueTask BroadcastCrownAsync(IGameContext gameContext, CrownTickResult crown)
=> gameContext.ForEachPlayerAsync(async player =>
{
if (player.CurrentMap?.Definition.Number != ValleyOfLorenMapNumber)
{
return;
}
await player.InvokeViewPlugInAsync<ICastleSiegeStatusViewPlugIn>(p => p.SetCrownShieldAsync(crown.ShieldDown)).ConfigureAwait(false);
switch (crown.Event)
{
case CrownEvent.HoldStarted:
await player.InvokeViewPlugInAsync<ICastleSiegeStatusViewPlugIn>(p => p.SetCrownRegistAsync(0, 0)).ConfigureAwait(false);
break;
case CrownEvent.HoldReset:
await player.InvokeViewPlugInAsync<ICastleSiegeStatusViewPlugIn>(p => p.SetCrownRegistAsync(2, 0)).ConfigureAwait(false);
break;
case CrownEvent.Captured when crown.Guild is { } guild:
await player.InvokeViewPlugInAsync<ICastleSiegeStatusViewPlugIn>(p => p.AnnounceSealCapturedAsync(guild)).ConfigureAwait(false);
break;
default:
break;
}
});
private async ValueTask BroadcastCastleFlagAsync(IGameContext gameContext, CastleSiegeContext context)
{
try

View File

@@ -28,4 +28,25 @@ public interface ICastleSiegeStatusViewPlugIn : IViewPlugIn
/// </summary>
/// <param name="guildLogo">The owner guild's 32-byte logo/emblem.</param>
ValueTask SetCastleFlagAsync(ReadOnlyMemory<byte> guildLogo);
/// <summary>
/// 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.
/// </summary>
/// <param name="down">Whether the shield should be down.</param>
ValueTask SetCrownShieldAsync(bool down);
/// <summary>
/// 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.
/// </summary>
/// <param name="state">The registration state (0 progress, 1 success, 2 fail).</param>
/// <param name="accessMilliseconds">Accumulated hold time in milliseconds (client shows 60000 - this).</param>
ValueTask SetCrownRegistAsync(byte state, uint accessMilliseconds);
/// <summary>
/// Broadcasts that a guild registered the official seal / took the crown (C1 B2 18, state 1).
/// </summary>
/// <param name="guildName">The capturing guild's name (max 8 bytes).</param>
ValueTask AnnounceSealCapturedAsync(string guildName);
}

View File

@@ -101,4 +101,82 @@ public class CastleSiegeStatusViewPlugIn : ICastleSiegeStatusViewPlugIn
await connection.SendAsync(WritePacket).ConfigureAwait(false);
}
/// <inheritdoc />
public async ValueTask SetCrownShieldAsync(bool down)
{
var connection = this._player.Connection;
if (connection is null)
{
return;
}
int WritePacket()
{
// C1 0C B2 16 <state> <pad> <dword=0> ; 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);
}
/// <inheritdoc />
public async ValueTask SetCrownRegistAsync(byte state, uint accessMilliseconds)
{
var connection = this._player.Connection;
if (connection is null)
{
return;
}
int WritePacket()
{
// C1 0C B2 15 <state> <pad3> <accessMs:4 LE @offset 8>
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);
}
/// <inheritdoc />
public async ValueTask AnnounceSealCapturedAsync(string guildName)
{
var connection = this._player.Connection;
if (connection is null)
{
return;
}
int WritePacket()
{
// C1 0D B2 18 <state=1> <guildName[8] utf8>
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);
}
}