feat(CS): authentic Crown-hold throne capture (S6 protocol) replacing Sinior-talk
Some checks failed
.NET Core / build (push) Has been cancelled
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:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,6 +193,49 @@ public class CastleSiegeContextTest
|
||||
Assert.That(ctx.GetRemainingSiegeTime(T0.AddMinutes(15)), Is.EqualTo(TimeSpan.Zero), "past the end -> clamped to zero");
|
||||
}
|
||||
|
||||
/// <summary>Tests that a guild master holding the crown (both switches held, defenses down) captures after the hold duration.</summary>
|
||||
[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"));
|
||||
}
|
||||
|
||||
/// <summary>Tests that losing a switch mid-hold resets the crown-hold progress (contestable).</summary>
|
||||
[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),
|
||||
|
||||
Reference in New Issue
Block a user