feat(castle-siege): operate the Crown Switches by clicking, capture the crown by holding it
Some checks failed
.NET Core / build (push) Has been cancelled

The switches used to be held by simply standing near them, and the crown captured
by standing near it - clicking a switch only produced the client's "not implemented
yet" message. This drives both from the original interaction instead:

- Clicking a Crown Switch starts an operation which completes after
  CastleSiegeSettings.SwitchPushSeconds (15) and keeps the switch for the guild
  until its operator leaves the switch's area. One player per switch; anybody else
  clicking it is told another team is on it (C1 B2 14 state 2).
- While one guild holds both switches the crown's shield drops for it, and its
  guild master captures the throne by CLICKING the crown and holding it for
  CrownHoldTimeSeconds - seeded to 60 now, to match the countdown the client's
  registration panel hardcodes. The throne stays contestable until the siege ends.
- The shield now depends on the switches alone, as in the original; the gates and
  statues remain what they always were, the obstacle in the way.

The switch info packet (C1 B2 20) is broadcast before any switch-state packet
because the client's "switch released" handler reads its switch table without
checking that it exists - that table is only allocated when the info packet
arrives, so the wrong order crashes the client.

Also fixed while in here:
- The crown registration panel could never be closed: the cancel was only sent
  while the master still stood on the crown, which is precisely when the hold does
  NOT break. The panel is now closed for the player it was opened for.
- A contested switch was decided by enumeration order.
- Panels opened by the siege are closed when it ends.
- TryCaptureThrone was dead code carrying a second, diverged rule set.
- /csphase advertised the pre-refactor phase names to the client.
- The periodic broadcasts keyed off "UtcNow.Second % n", which silently skips when
  a tick runs late; they count ticks now.

The unit tests never compiled against the refactored model - they are migrated to
the state machine and guild ids, and cover the new switch and crown rules. A new
test proves update 105 writes the configuration into an existing database.

ApplyPendingUpdatesTool applies pending configuration updates without the admin
panel; it is [Explicit], so it never runs in a normal test pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Acentech Dev
2026-08-04 21:44:12 +03:00
parent af46499279
commit f8e856c7c6
16 changed files with 893 additions and 206 deletions

View File

@@ -165,6 +165,16 @@ public enum NpcWindow
/// The dialog for the legacy quest system. /// The dialog for the legacy quest system.
/// </summary> /// </summary>
LegacyQuest, LegacyQuest,
/// <summary>
/// The castle siege gate NPC interaction window.
/// </summary>
CastleSiegeGateNpc,
/// <summary>
/// The castle siege lever NPC interaction window.
/// </summary>
CastleSiegeLeverNpc,
} }
/// <summary> /// <summary>

View File

@@ -27,10 +27,12 @@ using MUnique.OpenMU.DataModel.Configuration;
/// re-create under the same name) therefore can no longer transfer castle ownership to the wrong guild. Names /// re-create under the same name) therefore can no longer transfer castle ownership to the wrong guild. Names
/// are carried alongside purely for display and for the packets that send a name to the client. /// are carried alongside purely for display and for the packets that send a name to the client.
/// </para> /// </para>
/// Battle rule: attackers must destroy all castle defenses (gates + guardian statues) and then hold BOTH /// Battle rule: a guild takes the throne by holding BOTH Crown Switches at the same time. A switch is
/// Crown Switches at the same time — the switches are held by standing on them (evaluated per tick by the /// operated by clicking it and then staying in its area: the operation needs
/// plugin), and once both are held by one guild with the defenses down, that guild captures the throne. /// <see cref="CastleSiegeSettings.SwitchPushSeconds"/> to complete, after which the switch counts as held
/// The throne holder when the siege ends becomes the castle owner. /// until its operator leaves. While one guild holds both switches the crown's shield drops for it, and its
/// guild master can start the crown hold to capture the throne. The throne can change hands as often as the
/// switches do; whoever holds it when the siege ends becomes the castle owner.
/// </summary> /// </summary>
public class CastleSiegeContext public class CastleSiegeContext
{ {
@@ -38,7 +40,7 @@ public class CastleSiegeContext
public static readonly short[] SwitchNumbers = { 217, 218 }; public static readonly short[] SwitchNumbers = { 217, 218 };
private readonly Dictionary<Guid, string> _registeredGuilds = new(); private readonly Dictionary<Guid, string> _registeredGuilds = new();
private readonly Dictionary<short, Guid?> _switchHolders = new() { { 217, null }, { 218, null } }; private readonly Dictionary<short, CastleSiegeSwitchOperation?> _switches = new() { { 217, null }, { 218, null } };
private DateTime _stateStartedUtc; private DateTime _stateStartedUtc;
private Guid? _occupier; private Guid? _occupier;
private string? _occupierName; private string? _occupierName;
@@ -46,6 +48,7 @@ public class CastleSiegeContext
private bool _dirty; private bool _dirty;
private Guid? _crownHoldGuild; private Guid? _crownHoldGuild;
private DateTime? _crownHoldStartUtc; private DateTime? _crownHoldStartUtc;
private Guid? _crownHoldRequestedBy;
private bool _lastShieldDown; private bool _lastShieldDown;
/// <summary>Initializes a new instance of the <see cref="CastleSiegeContext"/> class.</summary> /// <summary>Initializes a new instance of the <see cref="CastleSiegeContext"/> class.</summary>
@@ -270,27 +273,46 @@ public class CastleSiegeContext
} }
/// <summary> /// <summary>
/// Returns the guild that currently holds BOTH crown switches while all castle defenses are down (so the /// Returns the guild which currently holds BOTH Crown Switches, so the crown's shield is dropped for it,
/// crown's shield is dropped for them), or null. Only meaningful during the siege. /// or null. Only meaningful during the siege.
/// </summary> /// </summary>
public Guid? GetShieldEligibleGuild() public Guid? GetShieldEligibleGuild()
{ {
if (!this.IsSiegeRunning || this._defensesRemaining > 0) if (!this.IsSiegeRunning)
{ {
return null; return null;
} }
var holder = this._switchHolders[217]; var first = this.GetHeldSwitchGuild(217);
return holder is not null && holder == this._switchHolders[218] ? holder : null; return first is not null && first == this.GetHeldSwitchGuild(218) ? first : null;
} }
/// <summary> /// <summary>
/// Advances the crown-hold capture. <paramref name="eligibleGuild"/> is the guild with both switches held /// Registers a guild master's intent to take the crown, which is what the crown hold waits for: standing
/// and no defenses left (shield down); <paramref name="masterHolding"/> is whether that guild's master is /// on the crown alone does nothing until its guild master clicked it. Ignored when the guild does not
/// standing on the crown. Captures the throne for the guild once it has held for <paramref name="holdDuration"/>. /// hold both switches, so a click can never arm a hold the guild isn't entitled to.
/// Losing a switch or the master leaving the crown resets the hold (contestable until the siege ends).
/// </summary> /// </summary>
/// <param name="eligibleGuild">The guild with both switches and no defenses, or null.</param> /// <param name="guildId">The requesting guild master's guild identifier.</param>
/// <returns><see langword="true"/> if the request was accepted.</returns>
public bool RequestCrownHold(Guid guildId)
{
if (this.GetShieldEligibleGuild() != guildId || this._occupier == guildId)
{
return false;
}
this._crownHoldRequestedBy = guildId;
return true;
}
/// <summary>
/// Advances the crown-hold capture. <paramref name="eligibleGuild"/> is the guild holding both switches
/// (shield down) and <paramref name="masterHolding"/> is whether that guild's master stands on the crown.
/// The hold only runs after the master requested it via <see cref="RequestCrownHold"/>; it captures the
/// throne once it ran for <paramref name="holdDuration"/>. Losing a switch or the master leaving the crown
/// resets the hold, and the crown has to be clicked again (contestable until the siege ends).
/// </summary>
/// <param name="eligibleGuild">The guild holding both switches, or null.</param>
/// <param name="eligibleGuildName">That guild's name, for display.</param> /// <param name="eligibleGuildName">That guild's name, for display.</param>
/// <param name="masterHolding">Whether that guild's master is on the crown.</param> /// <param name="masterHolding">Whether that guild's master is on the crown.</param>
/// <param name="now">The current UTC time.</param> /// <param name="now">The current UTC time.</param>
@@ -303,21 +325,21 @@ public class CastleSiegeContext
if (!this.IsSiegeRunning) if (!this.IsSiegeRunning)
{ {
this._crownHoldGuild = null; this.ResetCrownHold();
this._crownHoldStartUtc = null;
return new CrownTickResult(false, shieldChanged, CrownEvent.None, null, null); return new CrownTickResult(false, shieldChanged, CrownEvent.None, null, null);
} }
var wasHolding = this._crownHoldGuild is not null; var wasHolding = this._crownHoldGuild is not null;
// The guild that already occupies the throne just holds it no re-registration (avoids a capture loop). // The guild that already occupies the throne just holds it - no re-registration (avoids a capture loop).
// Only a DIFFERENT guild taking both switches can register/capture (contest). // Only a DIFFERENT guild taking both switches can register/capture (contest).
var canCapture = eligibleGuild is not null && eligibleGuild != this._occupier; var canCapture = eligibleGuild is not null
&& eligibleGuild != this._occupier
&& this._crownHoldRequestedBy == eligibleGuild;
if (!canCapture || !masterHolding) if (!canCapture || !masterHolding)
{ {
this._crownHoldGuild = null; this.ResetCrownHold();
this._crownHoldStartUtc = null;
return new CrownTickResult(shieldDown, shieldChanged, wasHolding ? CrownEvent.HoldReset : CrownEvent.None, null, null); return new CrownTickResult(shieldDown, shieldChanged, wasHolding ? CrownEvent.HoldReset : CrownEvent.None, null, null);
} }
@@ -332,8 +354,7 @@ public class CastleSiegeContext
{ {
this._occupier = eligibleGuild; this._occupier = eligibleGuild;
this._occupierName = eligibleGuildName; this._occupierName = eligibleGuildName;
this._crownHoldGuild = null; this.ResetCrownHold();
this._crownHoldStartUtc = null;
this._dirty = true; this._dirty = true;
return new CrownTickResult(shieldDown, shieldChanged, CrownEvent.Captured, eligibleGuild, eligibleGuildName); return new CrownTickResult(shieldDown, shieldChanged, CrownEvent.Captured, eligibleGuild, eligibleGuildName);
} }
@@ -392,56 +413,83 @@ public class CastleSiegeContext
} }
} }
/// <summary>Returns who is currently operating a Crown Switch, or <see langword="null"/>.</summary>
/// <param name="switchNumber">The Crown Switch NPC number (217 or 218).</param>
public CastleSiegeSwitchOperation? GetSwitchOperation(short switchNumber)
=> this._switches.TryGetValue(switchNumber, out var operation) ? operation : null;
/// <summary> /// <summary>
/// Sets which guild is currently standing on (holding) a Crown Switch. Called every tick by the plugin /// Starts operating a Crown Switch for a player who clicked it. A switch can only be operated by one
/// based on player positions. Pass <c>null</c> when no registered member stands on it. No-op outside the siege. /// player at a time: while somebody else is on it, the click is refused and the caller is told who holds
/// it, which is what the client shows as "another siege team is running the crown switch".
/// </summary> /// </summary>
/// <param name="switchNumber">The Crown Switch NPC number (217 or 218).</param> /// <param name="switchNumber">The Crown Switch NPC number (217 or 218).</param>
/// <param name="guildId">The holding guild's persistent identifier, or null.</param> /// <param name="guildId">The clicking player's guild identifier.</param>
public void SetSwitchHolder(short switchNumber, Guid? guildId) /// <param name="guildName">The clicking player's guild name, for display.</param>
/// <param name="playerId">The clicking player's object identifier on the map.</param>
/// <param name="playerName">The clicking player's name, for display.</param>
/// <param name="switchObjectId">The switch NPC's object identifier on the map.</param>
/// <param name="now">The current UTC time.</param>
/// <returns>The outcome, and the current operation when the switch is taken.</returns>
public (CastleSiegeSwitchPush Result, CastleSiegeSwitchOperation? Operation) TryStartSwitchOperation(
short switchNumber,
Guid guildId,
string guildName,
ushort playerId,
string playerName,
ushort switchObjectId,
DateTime now)
{ {
if (this.IsSiegeRunning && this._switchHolders.ContainsKey(switchNumber)) if (!this.IsSiegeRunning || !this._switches.ContainsKey(switchNumber))
{ {
this._switchHolders[switchNumber] = guildId; return (CastleSiegeSwitchPush.SiegeNotRunning, null);
} }
if (this._switches[switchNumber] is { } current)
{
return current.PlayerId == playerId
? (CastleSiegeSwitchPush.AlreadyYours, current)
: (CastleSiegeSwitchPush.TakenByOther, current);
}
var operation = new CastleSiegeSwitchOperation(guildId, guildName, playerId, playerName, switchObjectId, now);
this._switches[switchNumber] = operation;
return (CastleSiegeSwitchPush.Started, operation);
} }
/// <summary> /// <summary>
/// Attempts to capture the throne for a guild (called when a member registers at the Sinior/Crown NPC). /// Advances one Crown Switch. The operation is dropped as soon as its player is gone from the switch's
/// Succeeds only during the siege when the throne is free, all castle defenses are destroyed, and the /// area, and completes - which makes the switch count for the guild - once it ran <paramref name="pushDuration"/>.
/// guild is currently holding BOTH Crown Switches (a member standing on each).
/// </summary> /// </summary>
/// <param name="guildId">The capturing guild's persistent identifier.</param> /// <param name="switchNumber">The Crown Switch NPC number (217 or 218).</param>
/// <param name="guildName">The capturing guild's name, for display.</param> /// <param name="operatorPresent">Whether the operating player is still in the switch's area.</param>
/// <returns>Whether it succeeded and a human-readable reason/result message.</returns> /// <param name="now">The current UTC time.</param>
public (bool Success, string Reason) TryCaptureThrone(Guid guildId, string guildName) /// <param name="pushDuration">How long operating the switch takes.</param>
/// <returns>What happened to the switch in this tick, and the operation it happened to.</returns>
public (CastleSiegeSwitchEvent Event, CastleSiegeSwitchOperation? Operation) TickSwitch(
short switchNumber,
bool operatorPresent,
DateTime now,
TimeSpan pushDuration)
{ {
if (!this.IsSiegeRunning) if (!this._switches.TryGetValue(switchNumber, out var operation) || operation is null)
{ {
return (false, "The siege is not running."); return (CastleSiegeSwitchEvent.None, null);
} }
if (this._occupier is { } occupier) if (!this.IsSiegeRunning || !operatorPresent)
{ {
return (false, occupier == guildId this._switches[switchNumber] = null;
? "Your guild already holds the throne." return (CastleSiegeSwitchEvent.Released, operation);
: $"The throne is already held by '{this._occupierName ?? occupier.ToString()}'.");
} }
if (this._defensesRemaining > 0) if (!operation.IsHeld && now - operation.StartedUtc >= pushDuration)
{ {
return (false, $"Destroy the castle defenses first ({this._defensesRemaining} remaining)."); operation.MarkHeld();
return (CastleSiegeSwitchEvent.Held, operation);
} }
if (this._switchHolders[217] != guildId || this._switchHolders[218] != guildId) return (CastleSiegeSwitchEvent.None, operation);
{
return (false, "Your guild must be holding BOTH Crown Switches at once (stand a member on each).");
}
this._occupier = guildId;
this._occupierName = guildName;
this._dirty = true;
return (true, "throne captured");
} }
/// <summary>Returns a human-readable status summary for admin display.</summary> /// <summary>Returns a human-readable status summary for admin display.</summary>
@@ -451,20 +499,29 @@ public class CastleSiegeContext
+ $"defenses={this._defensesRemaining}, throne={this._occupierName ?? "(none)"}, " + $"defenses={this._defensesRemaining}, throne={this._occupierName ?? "(none)"}, "
+ $"switch217={this.DescribeSwitch(217)}, switch218={this.DescribeSwitch(218)}"; + $"switch217={this.DescribeSwitch(217)}, switch218={this.DescribeSwitch(218)}";
private Guid? GetHeldSwitchGuild(short switchNumber)
=> this._switches[switchNumber] is { IsHeld: true } operation ? operation.GuildId : null;
private string DescribeSwitch(short switchNumber) private string DescribeSwitch(short switchNumber)
=> this._switchHolders[switchNumber] is { } holder => this._switches[switchNumber] is { } operation
? (this._registeredGuilds.TryGetValue(holder, out var name) ? name : holder.ToString()) ? $"{operation.GuildName}/{operation.PlayerName}{(operation.IsHeld ? string.Empty : " (pushing)")}"
: "-"; : "-";
private void ResetCrownHold()
{
this._crownHoldGuild = null;
this._crownHoldStartUtc = null;
this._crownHoldRequestedBy = null;
}
private void ClearBattleState() private void ClearBattleState()
{ {
this._switchHolders[217] = null; this._switches[217] = null;
this._switchHolders[218] = null; this._switches[218] = null;
this._defensesRemaining = 0; this._defensesRemaining = 0;
this._occupier = null; this._occupier = null;
this._occupierName = null; this._occupierName = null;
this._crownHoldGuild = null; this.ResetCrownHold();
this._crownHoldStartUtc = null;
this._lastShieldDown = false; this._lastShieldDown = false;
} }

View File

@@ -117,6 +117,12 @@ public class CastleSiegeSettings
[Browsable(false)] [Browsable(false)]
public TimeSpan SiegeDuration { get; set; } = TimeSpan.FromMinutes(10); public TimeSpan SiegeDuration { get; set; } = TimeSpan.FromMinutes(10);
/// <summary>
/// Gets or sets how many seconds a player has to operate a Crown Switch before it counts for their guild.
/// The player has to stay in the switch's area for that long, and keeps it until they leave.
/// </summary>
public int SwitchPushSeconds { get; set; } = 15;
// --- Persisted cycle bookkeeping (hidden from the AdminPanel) --- // --- Persisted cycle bookkeeping (hidden from the AdminPanel) ---
// Only the CURRENT state and when it started ride on the plugin's custom-configuration JSON. The castle // Only the CURRENT state and when it started ride on the plugin's custom-configuration JSON. The castle
// owner and the guild registrations live in real database tables, so they are not duplicated here. // owner and the guild registrations live in real database tables, so they are not duplicated here.

View File

@@ -0,0 +1,20 @@
// <copyright file="CastleSiegeSwitchEvent.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.CastleSiege;
/// <summary>
/// What happened to a Crown Switch during one tick.
/// </summary>
public enum CastleSiegeSwitchEvent
{
/// <summary>Nothing worth reporting.</summary>
None,
/// <summary>The operation completed, so the switch now counts for the operator's guild.</summary>
Held,
/// <summary>The operator left (or the siege ended), so the switch is free again.</summary>
Released,
}

View File

@@ -0,0 +1,55 @@
// <copyright file="CastleSiegeSwitchOperation.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.CastleSiege;
/// <summary>
/// One player operating one Crown Switch. The player starts it by clicking the switch and keeps it by
/// staying in its area; the switch counts as held for the guild once the operation has run its time.
/// </summary>
public class CastleSiegeSwitchOperation
{
/// <summary>Initializes a new instance of the <see cref="CastleSiegeSwitchOperation"/> class.</summary>
/// <param name="guildId">The operating player's guild identifier.</param>
/// <param name="guildName">The operating player's guild name, for display.</param>
/// <param name="playerId">The operating player's object identifier on the map.</param>
/// <param name="playerName">The operating player's name, for display.</param>
/// <param name="switchObjectId">The switch NPC's object identifier on the map.</param>
/// <param name="startedUtc">When the operation started (UTC).</param>
public CastleSiegeSwitchOperation(Guid guildId, string guildName, ushort playerId, string playerName, ushort switchObjectId, DateTime startedUtc)
{
this.GuildId = guildId;
this.GuildName = guildName;
this.PlayerId = playerId;
this.PlayerName = playerName;
this.SwitchObjectId = switchObjectId;
this.StartedUtc = startedUtc;
}
/// <summary>Gets the operating player's guild identifier.</summary>
public Guid GuildId { get; }
/// <summary>Gets the operating player's guild name.</summary>
public string GuildName { get; }
/// <summary>Gets the operating player's object identifier on the map.</summary>
public ushort PlayerId { get; }
/// <summary>Gets the operating player's name.</summary>
public string PlayerName { get; }
/// <summary>Gets the switch NPC's object identifier on the map, which the client's packets refer to.</summary>
public ushort SwitchObjectId { get; }
/// <summary>Gets the point in time (UTC) when the operation started.</summary>
public DateTime StartedUtc { get; }
/// <summary>
/// Gets a value indicating whether the operation ran its time, so the switch counts for the guild.
/// </summary>
public bool IsHeld { get; private set; }
/// <summary>Marks the operation as completed, which makes the switch count for the guild.</summary>
internal void MarkHeld() => this.IsHeld = true;
}

View File

@@ -0,0 +1,23 @@
// <copyright file="CastleSiegeSwitchPush.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.CastleSiege;
/// <summary>
/// The outcome of a player clicking a Crown Switch.
/// </summary>
public enum CastleSiegeSwitchPush
{
/// <summary>The player started operating the switch.</summary>
Started,
/// <summary>The player is already operating this switch.</summary>
AlreadyYours,
/// <summary>Somebody else is operating this switch.</summary>
TakenByOther,
/// <summary>The siege is not running, so the switches do nothing.</summary>
SiegeNotRunning,
}

View File

@@ -0,0 +1,92 @@
// <copyright file="CastleSiegeSwitchTalkPlugIn.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.CastleSiege;
using System.Runtime.InteropServices;
using MUnique.OpenMU.GameLogic.NPC;
using MUnique.OpenMU.GameLogic.PlugIns;
using MUnique.OpenMU.GameLogic.PlugIns.PeriodicTasks;
using MUnique.OpenMU.GameLogic.Views;
using MUnique.OpenMU.GameLogic.Views.CastleSiege;
using MUnique.OpenMU.Interfaces;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// Handles clicking a Crown Switch (NPC 217 / 218) on Valley of Loren. The click starts operating the
/// switch, which the client shows as a progress box; the switch counts for the guild once the operation
/// ran its time and stays theirs until the operating player leaves the switch's area. Only one player can
/// operate a switch at a time - anybody else clicking it is told that another team is on it.
/// </summary>
[Guid("CA5710A0-7A1B-4C2D-8E3F-000000000217")]
[PlugIn]
[Display(Name = "Castle Siege Crown Switch", Description = "Operates a Crown Switch (NPC 217/218) during the Castle Siege.")]
public class CastleSiegeSwitchTalkPlugIn : IPlayerTalkToNpcPlugIn
{
/// <inheritdoc />
public async ValueTask PlayerTalksToNpcAsync(Player player, NonPlayerCharacter npc, NpcTalkEventArgs eventArgs)
{
if (!CastleSiegeContext.SwitchNumbers.Contains(npc.Definition.Number))
{
return;
}
// We drive the switch ourselves, so suppress the default "not implemented" message.
eventArgs.HasBeenHandled = true;
var context = CastleSiegeEventPlugIn.TryGetContext(player.GameContext);
if (context is null)
{
await ShowAsync(player, "Castle Siege is not active on this server.").ConfigureAwait(false);
return;
}
if (!context.IsSiegeRunning)
{
await ShowAsync(player, "The Crown Switches only work while the siege is running.").ConfigureAwait(false);
return;
}
if (await CastleSiegeEventPlugIn.GetPersistentGuildAsync(player).ConfigureAwait(false) is not { } guild
|| !context.IsRegistered(guild.Id))
{
await ShowAsync(player, "Only members of a registered guild can operate the Crown Switches.").ConfigureAwait(false);
return;
}
var (result, operation) = context.TryStartSwitchOperation(
npc.Definition.Number,
guild.Id,
guild.Name,
player.Id,
player.Name,
npc.Id,
DateTime.UtcNow);
switch (result)
{
case CastleSiegeSwitchPush.Started:
// The info packet goes first: it is what makes every client allocate its switch table, which
// the "switch released" packet later reads without checking that it exists.
await CastleSiegeEventPlugIn.BroadcastSwitchInfoAsync(player.GameContext, npc.Id, operation).ConfigureAwait(false);
await player.InvokeViewPlugInAsync<ICastleSiegeStatusViewPlugIn>(p => p.SetCrownSwitchStateAsync(npc.Id, player.Id, 1)).ConfigureAwait(false);
break;
case CastleSiegeSwitchPush.TakenByOther when operation is { } other:
// State 2 makes the client name the player who is already on it.
await player.InvokeViewPlugInAsync<ICastleSiegeStatusViewPlugIn>(p => p.SetCrownSwitchStateAsync(npc.Id, other.PlayerId, 2)).ConfigureAwait(false);
break;
case CastleSiegeSwitchPush.AlreadyYours:
break;
default:
await ShowAsync(player, "The Crown Switches only work while the siege is running.").ConfigureAwait(false);
break;
}
}
private static ValueTask ShowAsync(Player player, string text)
=> player.InvokeViewPlugInAsync<IShowMessagePlugIn>(p => p.ShowMessageAsync(text, MessageType.BlueNormal));
}

View File

@@ -55,34 +55,46 @@ public class CastleSiegeThroneCaptureTalkPlugIn : IPlayerTalkToNpcPlugIn
return; return;
} }
// The throne is taken by holding the Crown, not by talking here — give guidance based on the state. // Clicking the Crown as the guild master is what arms the capture: the hold then runs while they
await ShowAsync(player, DescribeThroneStep(context, guild.Id)).ConfigureAwait(false); // stay on it. Anybody else (or a master who isn't entitled yet) just gets told what is missing.
var isGuildMaster = player.GuildStatus?.Position == GuildPosition.GuildMaster;
if (isGuildMaster && context.RequestCrownHold(guild.Id))
{
await ShowAsync(player, "Hold the Crown - do not step away until the seal is registered!").ConfigureAwait(false);
return;
} }
private static string DescribeThroneStep(CastleSiegeContext context, Guid guildId) await ShowAsync(player, DescribeThroneStep(context, guild.Id, isGuildMaster)).ConfigureAwait(false);
}
private static string DescribeThroneStep(CastleSiegeContext context, Guid guildId, bool isGuildMaster)
{ {
if (!context.IsSiegeRunning) if (!context.IsSiegeRunning)
{ {
return "The siege is not running yet."; return "The siege is not running yet.";
} }
if (context.DefensesRemaining > 0)
{
return $"Destroy all castle gates first ({context.DefensesRemaining} remaining), then hold both Crown Switches.";
}
var eligible = context.GetShieldEligibleGuild(); var eligible = context.GetShieldEligibleGuild();
if (eligible is null) if (eligible is null)
{ {
return "All gates are down! Hold BOTH Crown Switches with your guild — the Crown's shield will drop."; return context.DefensesRemaining > 0
? $"Hold BOTH Crown Switches with your guild to drop the Crown's shield ({context.DefensesRemaining} castle defenses still standing)."
: "Hold BOTH Crown Switches with your guild - the Crown's shield will drop.";
} }
if (eligible == guildId) if (eligible != guildId)
{ {
return "Your guild holds both switches and the shield is down — send your GUILD MASTER to hold the Crown to take the throne!"; return "Another guild is holding both switches. Take a switch back to raise their shield.";
} }
return "Another guild is holding both switches. Take a switch back to raise their shield."; if (context.OccupierGuildId == guildId)
{
return "Your guild already holds the throne - keep it until the siege ends.";
}
return isGuildMaster
? "Your guild holds both switches, but the Crown cannot be registered right now."
: "Your guild holds both switches and the shield is down - your GUILD MASTER has to click the Crown!";
} }
private static ValueTask ShowAsync(Player player, string text) private static ValueTask ShowAsync(Player player, string text)

View File

@@ -14,7 +14,7 @@ using MUnique.OpenMU.PlugIns;
/// <summary>Forces a specific Castle Siege phase. GM only. Usage: /csphase Siege.</summary> /// <summary>Forces a specific Castle Siege phase. GM only. Usage: /csphase Siege.</summary>
[Guid("A1B2C3D4-0003-4E5F-9A0B-CA5710000003")] [Guid("A1B2C3D4-0003-4E5F-9A0B-CA5710000003")]
[PlugIn] [PlugIn]
[Display(Name = "Castle Siege Phase", Description = "GM command: /csphase <Ownership|Registration|Preparation|Siege|Settlement>")] [Display(Name = "Castle Siege Phase", Description = "GM command: /csphase <Idle1|RegisterGuild|Ready|Start|End|EndCycle>")]
[ChatCommandHelp(Command, CharacterStatus.GameMaster)] [ChatCommandHelp(Command, CharacterStatus.GameMaster)]
public class CastleSiegePhaseChatCommandPlugIn : IChatCommandPlugIn public class CastleSiegePhaseChatCommandPlugIn : IChatCommandPlugIn
{ {

View File

@@ -47,8 +47,27 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom
private const int SwitchHoldRange = 3; private const int SwitchHoldRange = 3;
private const int CrownHoldRange = 4; private const int CrownHoldRange = 4;
/// <summary>How many ticks (the periodic task runs once per second) between two countdown broadcasts.</summary>
private const int SiegeStateBroadcastTicks = 10;
/// <summary>How many ticks between two castle-flag broadcasts.</summary>
private const int CastleFlagBroadcastTicks = 15;
private static readonly ConcurrentDictionary<IGameContext, CastleSiegeContext> Contexts = new(); private static readonly ConcurrentDictionary<IGameContext, CastleSiegeContext> Contexts = new();
/// <summary>
/// The player whose client currently shows the crown registration panel, per game context. The panel is
/// opened for exactly one guild master, and it has to be closed for that same player - by the time the
/// hold breaks they are usually no longer on the crown, so they can't be found by position any more.
/// </summary>
private static readonly ConcurrentDictionary<IGameContext, Player> CrownHoldPlayers = new();
/// <summary>
/// Tick counters per game context, used to space out the periodic broadcasts. Counting ticks (instead of
/// matching a clock second) keeps a broadcast from being skipped when a tick runs late.
/// </summary>
private static readonly ConcurrentDictionary<IGameContext, BroadcastCounters> Counters = new();
/// <summary> /// <summary>
/// Maps the in-memory guild id (assigned by the guild server, not stable across restarts) to the guild's /// Maps the in-memory guild id (assigned by the guild server, not stable across restarts) to the guild's
/// persistent identifier. Populated lazily; a miss costs one database lookup per guild per process. /// persistent identifier. Populated lazily; a miss costs one database lookup per guild per process.
@@ -156,7 +175,7 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom
// castle owner from the database so the hunting-map gate + castle flag rewards still work everywhere. // castle owner from the database so the hunting-map gate + castle flag rewards still work everywhere.
if (!IsCastleSiegeServer(gameContext)) if (!IsCastleSiegeServer(gameContext))
{ {
if (DateTime.UtcNow.Second % 15 == 0) if (GetCounters(gameContext).NextCastleFlag())
{ {
await LoadPersistedStateAsync(gameContext, context).ConfigureAwait(false); await LoadPersistedStateAsync(gameContext, context).ConfigureAwait(false);
await this.BroadcastCastleFlagAsync(gameContext, context).ConfigureAwait(false); await this.BroadcastCastleFlagAsync(gameContext, context).ConfigureAwait(false);
@@ -180,7 +199,7 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom
} }
// Keep the owner guild's logo painted on the castle flags for anyone on the castle map (any state). // Keep the owner guild's logo painted on the castle flags for anyone on the castle map (any state).
if (DateTime.UtcNow.Second % 15 == 0) if (GetCounters(gameContext).NextCastleFlag())
{ {
await this.BroadcastCastleFlagAsync(gameContext, context).ConfigureAwait(false); await this.BroadcastCastleFlagAsync(gameContext, context).ConfigureAwait(false);
} }
@@ -281,8 +300,11 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom
await WarpRegisteredMembersToSiegeAsync(gameContext, context).ConfigureAwait(false); await WarpRegisteredMembersToSiegeAsync(gameContext, context).ConfigureAwait(false);
break; break;
case CastleSiegeState.End: case CastleSiegeState.End:
// Stop the on-map countdown for everyone still on the battle map. // Stop the on-map countdown for everyone still on the battle map, and close the panels
// the siege opened - the battle state is dropped right after this, so whoever was
// operating a switch or holding the crown would keep a dead progress box on screen.
await BroadcastSiegeStateAsync(gameContext, false, 0, 0).ConfigureAwait(false); await BroadcastSiegeStateAsync(gameContext, false, 0, 0).ConfigureAwait(false);
await CloseSiegePanelsAsync(gameContext, context).ConfigureAwait(false);
break; break;
case CastleSiegeState.Idle1 when context.OwnerGuildName is { } owner: case CastleSiegeState.Idle1 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);
@@ -389,6 +411,50 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom
SpawnTrigger = SpawnTrigger.OnceAtEventStart, SpawnTrigger = SpawnTrigger.OnceAtEventStart,
}; };
private static BroadcastCounters GetCounters(IGameContext gameContext)
=> Counters.GetOrAdd(gameContext, _ => new BroadcastCounters());
/// <summary>
/// Closes the client panels the siege opened: the crown registration panel of the master who was holding
/// it, and the switch progress box of whoever was operating a switch. Called when the siege ends, before
/// the battle state is dropped.
/// </summary>
private static async Task CloseSiegePanelsAsync(IGameContext gameContext, CastleSiegeContext context)
{
if (CrownHoldPlayers.TryRemove(gameContext, out var holdPlayer))
{
await holdPlayer.InvokeViewPlugInAsync<ICastleSiegeStatusViewPlugIn>(p => p.SetCrownRegistAsync(2, 0)).ConfigureAwait(false);
}
if (await gameContext.GetMapAsync(ValleyOfLorenMapNumber).ConfigureAwait(false) is not { } map)
{
return;
}
foreach (var switchNumber in CastleSiegeContext.SwitchNumbers)
{
if (context.GetSwitchOperation(switchNumber) is { } operation)
{
await BroadcastSwitchInfoAsync(gameContext, operation.SwitchObjectId, null).ConfigureAwait(false);
await CloseSwitchBoxAsync(map, operation).ConfigureAwait(false);
}
}
}
/// <summary>
/// Closes the switch progress box on the client of the player who was operating it. Object identifiers
/// are recycled when a player leaves, so the name is checked too - otherwise a newly connected player
/// could inherit the id and get a message box about a switch they never touched.
/// </summary>
private static async Task CloseSwitchBoxAsync(GameMap map, CastleSiegeSwitchOperation operation)
{
if (map.GetObject(operation.PlayerId) is Player player && player.Name == operation.PlayerName)
{
await player.InvokeViewPlugInAsync<ICastleSiegeStatusViewPlugIn>(
p => p.SetCrownSwitchStateAsync(operation.SwitchObjectId, operation.PlayerId, 0)).ConfigureAwait(false);
}
}
private static Point? GetNpcPosition(IGameContext gameContext, short monsterNumber) private static Point? GetNpcPosition(IGameContext gameContext, short monsterNumber)
{ {
var npc = GetDefinition(gameContext)?.NpcDefinitions var npc = GetDefinition(gameContext)?.NpcDefinitions
@@ -405,32 +471,45 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom
return; return;
} }
// Each Crown Switch is held by whichever registered guild currently has a member standing on it. var now = DateTime.UtcNow;
var pushDuration = TimeSpan.FromSeconds(Math.Max(1, context.Configuration.SwitchPushSeconds));
// A Crown Switch belongs to the player who clicked it, for as long as they stay in its area.
foreach (var switchNumber in CastleSiegeContext.SwitchNumbers) foreach (var switchNumber in CastleSiegeContext.SwitchNumbers)
{ {
if (GetNpcPosition(gameContext, switchNumber) is not { } position) if (context.GetSwitchOperation(switchNumber) is not { } operation)
{ {
continue; continue;
} }
Guid? holder = null; var stillOnIt = GetNpcPosition(gameContext, switchNumber) is { } position
foreach (var player in map.GetAttackablesInRange(position, SwitchHoldRange).OfType<Player>()) && map.GetAttackablesInRange(position, SwitchHoldRange)
.OfType<Player>()
.Any(p => p.Id == operation.PlayerId && p.IsAlive);
var (switchEvent, affected) = context.TickSwitch(switchNumber, stillOnIt, now, pushDuration);
if (affected is null)
{ {
if (await GetPersistentGuildAsync(player).ConfigureAwait(false) is { } guild continue;
&& context.IsRegistered(guild.Id)) }
if (switchEvent == CastleSiegeSwitchEvent.Held)
{ {
holder = guild.Id; // Repeat the info so the HUD picks up the names (the client only stores them from the
break; // second packet on, because the first one allocates its table).
await BroadcastSwitchInfoAsync(gameContext, affected.SwitchObjectId, affected).ConfigureAwait(false);
}
else if (switchEvent == CastleSiegeSwitchEvent.Released)
{
await BroadcastSwitchInfoAsync(gameContext, affected.SwitchObjectId, null).ConfigureAwait(false);
// Close the client's progress box of the player who left, if they are still around.
await CloseSwitchBoxAsync(map, affected).ConfigureAwait(false);
} }
} }
context.SetSwitchHolder(switchNumber, holder); // Crown-hold capture: while one guild holds both switches the crown shield drops for it, and its
} // master captures the throne by clicking the crown and holding it for the configured time.
var now = DateTime.UtcNow;
// Crown-hold capture: when a guild holds both switches with every defense down, the crown shield
// drops; that guild's master then holds the crown for the configured time to take the throne.
var eligible = context.GetShieldEligibleGuild(); var eligible = context.GetShieldEligibleGuild();
Player? masterPlayer = null; Player? masterPlayer = null;
string? eligibleName = null; string? eligibleName = null;
@@ -464,13 +543,25 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom
switch (crown.Event) switch (crown.Event)
{ {
case CrownEvent.HoldStarted when masterPlayer is not null: case CrownEvent.HoldStarted when masterPlayer is not null:
// The registration panel is shown ONLY to the master taking the crown. // The registration panel is shown ONLY to the master taking the crown. Remember them:
// the hold usually breaks BECAUSE they walked off the crown, and their client still has
// the panel open, so the cancel has to reach the player we started it for.
CrownHoldPlayers[gameContext] = masterPlayer;
await masterPlayer.InvokeViewPlugInAsync<ICastleSiegeStatusViewPlugIn>(p => p.SetCrownRegistAsync(0, 0)).ConfigureAwait(false); await masterPlayer.InvokeViewPlugInAsync<ICastleSiegeStatusViewPlugIn>(p => p.SetCrownRegistAsync(0, 0)).ConfigureAwait(false);
break; break;
case CrownEvent.HoldReset when masterPlayer is not null: case CrownEvent.HoldReset:
await masterPlayer.InvokeViewPlugInAsync<ICastleSiegeStatusViewPlugIn>(p => p.SetCrownRegistAsync(2, 0)).ConfigureAwait(false); if (CrownHoldPlayers.TryRemove(gameContext, out var holdPlayer))
{
await holdPlayer.InvokeViewPlugInAsync<ICastleSiegeStatusViewPlugIn>(p => p.SetCrownRegistAsync(2, 0)).ConfigureAwait(false);
}
break; break;
case CrownEvent.Captured when crown.GuildName is { } captured: case CrownEvent.Captured when crown.GuildName is { } captured:
if (CrownHoldPlayers.TryRemove(gameContext, out var capturingPlayer))
{
await capturingPlayer.InvokeViewPlugInAsync<ICastleSiegeStatusViewPlugIn>(p => p.SetCrownRegistAsync(1, 0)).ConfigureAwait(false);
}
await ForEachOnBattleMapAsync(gameContext, p => p.AnnounceSealCapturedAsync(captured)).ConfigureAwait(false); await ForEachOnBattleMapAsync(gameContext, p => p.AnnounceSealCapturedAsync(captured)).ConfigureAwait(false);
await AnnounceAsync(gameContext, $"Guild '{captured}' has taken the Crown and now holds the throne!").ConfigureAwait(false); await AnnounceAsync(gameContext, $"Guild '{captured}' has taken the Crown and now holds the throne!").ConfigureAwait(false);
break; break;
@@ -478,9 +569,10 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom
break; break;
} }
// Keep the client's on-map countdown armed and in sync. Resend every 10s so players who just // Keep the client's on-map countdown armed and in sync. Resend every 10 ticks so players who just
// loaded the battle map pick it up, without visibly resetting the second-counter too often. // loaded the battle map pick it up, without visibly resetting the second-counter too often. This
if ((int)(now - context.StateStartedUtc).TotalSeconds % 10 == 0) // counts ticks instead of matching a clock second, which a delayed tick would skip silently.
if (GetCounters(gameContext).NextSiegeState())
{ {
var remaining = context.GetRemainingSiegeTime(now); var remaining = context.GetRemainingSiegeTime(now);
var totalMinutes = (int)Math.Ceiling(remaining.TotalMinutes); var totalMinutes = (int)Math.Ceiling(remaining.TotalMinutes);
@@ -514,6 +606,24 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom
}); });
/// <summary>Invokes the Castle Siege status view for every player currently on the battle map.</summary> /// <summary>Invokes the Castle Siege status view for every player currently on the battle map.</summary>
/// <summary>
/// Tells everybody on the battle map who is operating a Crown Switch. Besides driving the client's HUD
/// list, this is what makes the client allocate its switch table, so it has to be sent before any
/// "switch released" packet reaches that client.
/// </summary>
/// <param name="gameContext">The game context.</param>
/// <param name="switchObjectId">The switch NPC's object identifier.</param>
/// <param name="operation">The operation, or <see langword="null"/> when the switch became free.</param>
internal static ValueTask BroadcastSwitchInfoAsync(IGameContext gameContext, ushort switchObjectId, CastleSiegeSwitchOperation? operation)
=> ForEachOnBattleMapAsync(
gameContext,
p => p.SetCrownSwitchInfoAsync(
switchObjectId,
operation is null ? (byte)0 : (byte)1,
(byte)CastleSiegeJoinSide.Attack1,
operation?.GuildName ?? string.Empty,
operation?.PlayerName ?? string.Empty));
private static ValueTask ForEachOnBattleMapAsync(IGameContext gameContext, Func<ICastleSiegeStatusViewPlugIn, ValueTask> action) private static ValueTask ForEachOnBattleMapAsync(IGameContext gameContext, Func<ICastleSiegeStatusViewPlugIn, ValueTask> action)
=> gameContext.ForEachPlayerAsync(player => => gameContext.ForEachPlayerAsync(player =>
player.CurrentMap?.Definition.Number == ValleyOfLorenMapNumber player.CurrentMap?.Definition.Number == ValleyOfLorenMapNumber
@@ -693,4 +803,30 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom
this._cachedFlagLogo = logo; this._cachedFlagLogo = logo;
return logo; return logo;
} }
/// <summary>
/// Counts the ticks between the periodic broadcasts of one game context.
/// </summary>
private sealed class BroadcastCounters
{
private int _siegeState;
private int _castleFlag;
/// <summary>Advances the countdown-broadcast counter and tells whether it is due.</summary>
public bool NextSiegeState() => Due(ref this._siegeState, SiegeStateBroadcastTicks);
/// <summary>Advances the castle-flag-broadcast counter and tells whether it is due.</summary>
public bool NextCastleFlag() => Due(ref this._castleFlag, CastleFlagBroadcastTicks);
private static bool Due(ref int counter, int period)
{
if (++counter < period)
{
return false;
}
counter = 0;
return true;
}
}
} }

View File

@@ -49,4 +49,29 @@ public interface ICastleSiegeStatusViewPlugIn : IViewPlugIn
/// </summary> /// </summary>
/// <param name="guildName">The capturing guild's name (max 8 bytes).</param> /// <param name="guildName">The capturing guild's name (max 8 bytes).</param>
ValueTask AnnounceSealCapturedAsync(string guildName); ValueTask AnnounceSealCapturedAsync(string guildName);
/// <summary>
/// Sends the state of a Crown Switch (C1 B2 14): state 0 = released (the client closes its progress
/// box), 1 = this player is operating it (the client opens its hold progress box), 2 = somebody else
/// is already operating it.
/// </summary>
/// <param name="switchObjectId">The Crown Switch NPC's object identifier.</param>
/// <param name="playerObjectId">The operating player's object identifier.</param>
/// <param name="state">The switch state (0 released, 1 operated by this player, 2 operated by another).</param>
ValueTask SetCrownSwitchStateAsync(ushort switchObjectId, ushort playerObjectId, byte state);
/// <summary>
/// Sends who is operating a Crown Switch (C1 B2 20), which the client lists on the siege HUD.
/// <para>
/// This has to reach a client BEFORE any <see cref="SetCrownSwitchStateAsync"/> with state 0: the client
/// allocates its switch table when this arrives, and its "switch released" handler reads that table
/// without checking whether it exists.
/// </para>
/// </summary>
/// <param name="switchObjectId">The Crown Switch NPC's object identifier.</param>
/// <param name="switchState">0 when nobody operates it, 1 while it is operated.</param>
/// <param name="joinSide">The operating side (see the castle siege join sides).</param>
/// <param name="guildName">The operating guild's name (max 8 bytes), empty when free.</param>
/// <param name="playerName">The operating player's name (max 10 bytes), empty when free.</param>
ValueTask SetCrownSwitchInfoAsync(ushort switchObjectId, byte switchState, byte joinSide, string guildName, string playerName);
} }

View File

@@ -26,6 +26,17 @@ public class CastleSiegeStatusViewPlugIn : ICastleSiegeStatusViewPlugIn
/// <param name="player">The player.</param> /// <param name="player">The player.</param>
public CastleSiegeStatusViewPlugIn(RemotePlayer player) => this._player = player; public CastleSiegeStatusViewPlugIn(RemotePlayer player) => this._player = player;
private static void WriteName(string name, Span<byte> target)
{
if (name.Length == 0)
{
return;
}
var bytes = System.Text.Encoding.UTF8.GetBytes(name);
bytes.AsSpan(0, Math.Min(target.Length - 1, bytes.Length)).CopyTo(target);
}
/// <inheritdoc /> /// <inheritdoc />
public async ValueTask SetBattleStateAsync(bool started) public async ValueTask SetBattleStateAsync(bool started)
{ {
@@ -153,6 +164,65 @@ public class CastleSiegeStatusViewPlugIn : ICastleSiegeStatusViewPlugIn
await connection.SendAsync(WritePacket).ConfigureAwait(false); await connection.SendAsync(WritePacket).ConfigureAwait(false);
} }
/// <inheritdoc />
public async ValueTask SetCrownSwitchStateAsync(ushort switchObjectId, ushort playerObjectId, byte state)
{
var connection = this._player.Connection;
if (connection is null)
{
return;
}
int WritePacket()
{
// C1 09 B2 14 <switchId:2 BE> <playerId:2 BE> <state>
var span = connection.Output.GetSpan(9)[..9];
span.Clear();
span[0] = 0xC1;
span[1] = 0x09;
span[2] = 0xB2;
span[3] = 0x14;
span[4] = (byte)(switchObjectId >> 8);
span[5] = (byte)switchObjectId;
span[6] = (byte)(playerObjectId >> 8);
span[7] = (byte)playerObjectId;
span[8] = state;
return span.Length;
}
await connection.SendAsync(WritePacket).ConfigureAwait(false);
}
/// <inheritdoc />
public async ValueTask SetCrownSwitchInfoAsync(ushort switchObjectId, byte switchState, byte joinSide, string guildName, string playerName)
{
var connection = this._player.Connection;
if (connection is null)
{
return;
}
int WritePacket()
{
// C1 1B B2 20 <switchId:2 BE> <switchState> <joinSide> <guildName[8]> <playerName[11]>
var span = connection.Output.GetSpan(27)[..27];
span.Clear();
span[0] = 0xC1;
span[1] = 0x1B;
span[2] = 0xB2;
span[3] = 0x20;
span[4] = (byte)(switchObjectId >> 8);
span[5] = (byte)switchObjectId;
span[6] = switchState;
span[7] = joinSide;
WriteName(guildName, span.Slice(8, 8));
WriteName(playerName, span.Slice(16, 10));
return span.Length;
}
await connection.SendAsync(WritePacket).ConfigureAwait(false);
}
/// <inheritdoc /> /// <inheritdoc />
public async ValueTask AnnounceSealCapturedAsync(string guildName) public async ValueTask AnnounceSealCapturedAsync(string guildName)
{ {

View File

@@ -46,7 +46,7 @@ internal sealed class CastleSiegeInitializer : InitializerBase
var configuration = this.Context.CreateNew<CastleSiegeConfiguration>(); var configuration = this.Context.CreateNew<CastleSiegeConfiguration>();
configuration.Enabled = true; configuration.Enabled = true;
configuration.CrownHoldTimeSeconds = 30; configuration.CrownHoldTimeSeconds = 60; // The client's crown registration panel counts down from 60s.
configuration.RegisterMinLevel = 200; configuration.RegisterMinLevel = 200;
configuration.RegisterMinMembers = 20; configuration.RegisterMinMembers = 20;
configuration.ParticipantRewardMinSeconds = 60; configuration.ParticipantRewardMinSeconds = 60;

View File

@@ -0,0 +1,58 @@
// <copyright file="ApplyPendingUpdatesTool.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Persistence.Initialization.Tests;
using Microsoft.Extensions.Logging.Abstractions;
using MUnique.OpenMU.Interfaces;
using MUnique.OpenMU.Persistence.EntityFramework;
using MUnique.OpenMU.Persistence.EntityFramework.Json;
using MUnique.OpenMU.Persistence.Initialization.Updates;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// A manual tool, not a test: applies the pending configuration updates to the database configured in
/// <c>ConnectionSettings.xml</c> - the same thing the admin panel's "Updates" page does, for when there is
/// no browser at hand. It is <see cref="ExplicitAttribute"/> so it never runs in a normal test pass.
/// </summary>
[TestFixture]
[Explicit("Writes to the configured database. Run it deliberately, not as part of the test suite.")]
internal class ApplyPendingUpdatesTool
{
/// <summary>
/// Applies every configuration update which is not installed yet.
/// </summary>
[Test]
public async Task ApplyPendingUpdatesAsync()
{
// The server registers these at startup; without them the configuration JSON cannot be read back.
JsonConverterRegistry.RegisterConverter(new LocalizedStringJsonConverter());
JsonConverterRegistry.RegisterConverter(new BinaryAsHexJsonConverter());
var loggerFactory = new NullLoggerFactory();
var contextProvider = new PersistenceContextProvider(loggerFactory, null);
var plugInManager = new PlugInManager(null, loggerFactory, null, null);
plugInManager.DiscoverAndRegisterPlugIns();
var service = new DataUpdateService(contextProvider, plugInManager);
var pending = (await service.DetermineAvailableUpdatesAsync().ConfigureAwait(false)).ToList();
TestContext.Out.WriteLine($"Pending updates: {pending.Count}");
foreach (var update in pending)
{
TestContext.Out.WriteLine($" {(int)update.Version} - {update.Name}");
}
if (pending.Count == 0)
{
return;
}
var progress = new Progress<(UpdateVersion CurrentUpdatingVersion, bool IsCompleted)>(
p => TestContext.Out.WriteLine($" applying {(int)p.CurrentUpdatingVersion} completed={p.IsCompleted}"));
await service.ApplyUpdatesAsync(pending, progress).ConfigureAwait(false);
var left = await service.DetermineAvailableUpdatesAsync().ConfigureAwait(false);
Assert.That(left, Is.Empty, "all updates should be installed now");
}
}

View File

@@ -7,6 +7,7 @@ namespace MUnique.OpenMU.Persistence.Initialization.Tests;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using MUnique.OpenMU.DataModel; using MUnique.OpenMU.DataModel;
using MUnique.OpenMU.DataModel.Configuration; using MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.DataModel.Entities;
using MUnique.OpenMU.GameLogic; using MUnique.OpenMU.GameLogic;
using MUnique.OpenMU.Persistence.EntityFramework; using MUnique.OpenMU.Persistence.EntityFramework;
using MUnique.OpenMU.Persistence.Initialization.Updates; using MUnique.OpenMU.Persistence.Initialization.Updates;
@@ -91,6 +92,48 @@ internal class TestInitializationWithEfCore
Assert.That(groups[0].PossibleItems.Single().Number, Is.EqualTo((short)14)); Assert.That(groups[0].PossibleItems.Single().Number, Is.EqualTo((short)14));
} }
/// <summary>
/// Tests that the Castle Siege update writes its configuration into an existing Season 6 database, and
/// that applying it twice does not duplicate anything. This is the update which existing servers run to
/// get the castle: without it there is no Castle Siege configuration for the event to read.
/// </summary>
[Test]
public async Task TestSeason6CastleSiegeUpdatePlugInAsync()
{
var contextProvider = new InMemoryPersistenceContextProvider();
var dataInitialization = new VersionSeasonSix.DataInitialization(contextProvider, new NullLoggerFactory());
await dataInitialization.CreateInitialDataAsync(1, true).ConfigureAwait(false);
using var context = contextProvider.CreateNewContext();
var gameConfiguration = (await context.GetAsync<GameConfiguration>().ConfigureAwait(false)).First();
gameConfiguration.CastleSiegeConfiguration = null;
var update = new AddCastleSiegeDataUpdatePlugIn();
await update.ApplyUpdateAsync(context, gameConfiguration).ConfigureAwait(false);
await update.ApplyUpdateAsync(context, gameConfiguration).ConfigureAwait(false);
var castleSiege = gameConfiguration.CastleSiegeConfiguration;
Assert.That(castleSiege, Is.Not.Null);
Assert.That(castleSiege!.Enabled, Is.True);
// The client's crown registration panel counts down from 60 seconds, so the server has to match it.
Assert.That(castleSiege.CrownHoldTimeSeconds, Is.EqualTo(60));
// Both Crown Switches, the crown and the throne have to be there, or the siege cannot be finished.
var npcNumbers = castleSiege.NpcDefinitions.Select(n => n.MonsterDefinition?.Number).ToList();
Assert.That(npcNumbers, Does.Contain((short)217), "Crown Switch 1");
Assert.That(npcNumbers, Does.Contain((short)218), "Crown Switch 2");
Assert.That(npcNumbers, Does.Contain((short)216), "Crown");
// Applying it twice must not double the NPC definitions.
Assert.That(npcNumbers.Count(n => n == 217), Is.EqualTo(1));
Assert.That(npcNumbers.Count(n => n == 218), Is.EqualTo(1));
var data = (await context.GetAsync<CastleSiegeData>().ConfigureAwait(false)).ToList();
Assert.That(data, Has.Count.EqualTo(1), "exactly one castle state row");
Assert.That(data[0].IsOccupied, Is.False, "a fresh castle has no owner");
}
/// <summary> /// <summary>
/// Tests the data initialization using the in-memory persistence. /// Tests the data initialization using the in-memory persistence.
/// </summary> /// </summary>

View File

@@ -4,143 +4,208 @@
namespace MUnique.OpenMU.Tests.CastleSiege; namespace MUnique.OpenMU.Tests.CastleSiege;
using MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.GameLogic.CastleSiege; using MUnique.OpenMU.GameLogic.CastleSiege;
/// <summary> /// <summary>
/// Tests for the Castle Siege phase state machine (time-driven, injected clock). /// Tests for the Castle Siege state machine (time-driven, injected clock). The cycle runs through the
/// original Season 6 state numbers the client knows: Idle1 -> RegisterGuild -> Ready -> Start -> End
/// -> EndCycle -> Idle1. Guilds are identified by their persistent id, not by their (renameable) name.
/// </summary> /// </summary>
[TestFixture] [TestFixture]
public class CastleSiegeContextTest public class CastleSiegeContextTest
{ {
private static readonly DateTime T0 = new(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc); private static readonly DateTime T0 = new(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc);
private static readonly Guid GuildA = new("11111111-1111-1111-1111-111111111111");
private static readonly Guid GuildB = new("22222222-2222-2222-2222-222222222222");
/// <summary>Tests that a fresh context starts in the ownership (resting) phase.</summary> /// <summary>Tests that a fresh context rests in the idle state.</summary>
[Test] [Test]
public void StartsInOwnership() public void StartsInIdle()
{ {
var ctx = new CastleSiegeContext(Config()); var ctx = new CastleSiegeContext(Config());
Assert.That(ctx.Phase, Is.EqualTo(CastleSiegePhase.Ownership)); Assert.That(ctx.State, Is.EqualTo(CastleSiegeState.Idle1));
} }
/// <summary>Tests that force-starting moves the state machine into registration.</summary> /// <summary>Tests that force-starting moves the state machine into guild registration.</summary>
[Test] [Test]
public async Task ForceStartMovesToRegistrationAsync() public async Task ForceStartMovesToRegistrationAsync()
{ {
var ctx = new CastleSiegeContext(Config()); var ctx = new CastleSiegeContext(Config());
await ctx.ForceStartRegistrationAsync(T0); await ctx.ForceStartRegistrationAsync(T0);
Assert.That(ctx.Phase, Is.EqualTo(CastleSiegePhase.Registration)); Assert.That(ctx.State, Is.EqualTo(CastleSiegeState.RegisterGuild));
} }
/// <summary>Tests that registration advances to preparation once its duration elapses.</summary> /// <summary>Tests that registration advances to the preparation state once its duration elapses.</summary>
[Test] [Test]
public async Task RegistrationAdvancesToPreparationAfterDurationAsync() public async Task RegistrationAdvancesToReadyAfterDurationAsync()
{ {
var ctx = new CastleSiegeContext(Config()); var ctx = new CastleSiegeContext(Config());
await ctx.ForceStartRegistrationAsync(T0); await ctx.ForceStartRegistrationAsync(T0);
await ctx.TickAsync(T0.AddMinutes(4)); // still within registration await ctx.TickAsync(T0.AddMinutes(4)); // still within registration
Assert.That(ctx.Phase, Is.EqualTo(CastleSiegePhase.Registration)); Assert.That(ctx.State, Is.EqualTo(CastleSiegeState.RegisterGuild));
await ctx.TickAsync(T0.AddMinutes(5)); // registration duration elapsed await ctx.TickAsync(T0.AddMinutes(5)); // registration duration elapsed
Assert.That(ctx.Phase, Is.EqualTo(CastleSiegePhase.Preparation)); Assert.That(ctx.State, Is.EqualTo(CastleSiegeState.Ready));
} }
/// <summary>Tests a full cycle: registration -> preparation -> siege -> settlement -> ownership.</summary> /// <summary>Tests a full cycle: register -> ready -> start -> end -> end cycle -> idle.</summary>
[Test] [Test]
public async Task FullCycleReturnsToOwnershipAsync() public async Task FullCycleReturnsToIdleAsync()
{ {
var ctx = new CastleSiegeContext(Config()); var ctx = new CastleSiegeContext(Config());
await ctx.ForceStartRegistrationAsync(T0); await ctx.ForceStartRegistrationAsync(T0);
await ctx.TickAsync(T0.AddMinutes(5)); // -> Preparation await ctx.TickAsync(T0.AddMinutes(5)); // -> Ready
await ctx.TickAsync(T0.AddMinutes(7)); // +2 prep -> Siege await ctx.TickAsync(T0.AddMinutes(7)); // +2 preparation -> Start
Assert.That(ctx.Phase, Is.EqualTo(CastleSiegePhase.Siege)); Assert.That(ctx.State, Is.EqualTo(CastleSiegeState.Start));
await ctx.TickAsync(T0.AddMinutes(17)); // +10 siege -> Settlement await ctx.TickAsync(T0.AddMinutes(17)); // +10 siege -> End
await ctx.TickAsync(T0.AddMinutes(17)); // Settlement -> Ownership await ctx.TickAsync(T0.AddMinutes(17)); // End -> EndCycle
Assert.That(ctx.Phase, Is.EqualTo(CastleSiegePhase.Ownership)); await ctx.TickAsync(T0.AddMinutes(17)); // EndCycle -> Idle1
Assert.That(ctx.State, Is.EqualTo(CastleSiegeState.Idle1));
} }
/// <summary>Tests that guilds can be registered (by name) during the registration phase.</summary> /// <summary>Tests that guilds are collected by id during the registration state.</summary>
[Test] [Test]
public async Task RegisterGuildCollectsNamesDuringRegistrationAsync() public async Task RegisterGuildCollectsGuildsDuringRegistrationAsync()
{ {
var ctx = new CastleSiegeContext(Config()); var ctx = new CastleSiegeContext(Config());
await ctx.ForceStartRegistrationAsync(T0); await ctx.ForceStartRegistrationAsync(T0);
ctx.RegisterGuild("Attackers"); ctx.RegisterGuild(GuildA, "Attackers");
Assert.That(ctx.RegisteredGuilds, Does.Contain("Attackers"));
Assert.That(ctx.IsRegistered(GuildA), Is.True);
Assert.That(ctx.RegisteredGuildNames, Does.Contain("Attackers"));
} }
/// <summary>Tests the full siege objective chain: destroy defenses, then hold both switches to capture the throne.</summary> /// <summary>
/// Tests the full siege objective chain: operate both Crown Switches, which drops the crown's shield,
/// then hold the crown to take the throne, which becomes the castle ownership when the siege ends.
/// </summary>
[Test] [Test]
public async Task FullSiegeObjectiveChainToOwnershipAsync() public async Task FullSiegeObjectiveChainToOwnershipAsync()
{ {
var ctx = new CastleSiegeContext(Config()); var ctx = new CastleSiegeContext(Config());
await ctx.ForceStartRegistrationAsync(T0); await ctx.ForceStartRegistrationAsync(T0);
await ctx.ForcePhaseAsync(CastleSiegePhase.Siege, T0); await ctx.ForceStateAsync(CastleSiegeState.Start, T0);
ctx.SetDefenseCount(2); var hold = TimeSpan.FromSeconds(60);
// Both switches held but defenses still up -> no capture. // A switch which is still being operated does not count yet.
ctx.SetSwitchHolder(217, "Attackers"); StartSwitch(ctx, 217, GuildA, 1);
ctx.SetSwitchHolder(218, "Attackers"); StartSwitch(ctx, 218, GuildA, 2);
Assert.That(ctx.TryCaptureThrone("Attackers").Success, Is.False); Assert.That(ctx.GetShieldEligibleGuild(), Is.Null);
ctx.NotifyDefenseDestroyed(); CompleteSwitch(ctx, 217);
ctx.NotifyDefenseDestroyed(); Assert.That(ctx.GetShieldEligibleGuild(), Is.Null, "one completed switch is not enough");
// Only one switch held -> still no capture. CompleteSwitch(ctx, 218);
ctx.SetSwitchHolder(218, null); Assert.That(ctx.GetShieldEligibleGuild(), Is.EqualTo(GuildA));
Assert.That(ctx.TryCaptureThrone("Attackers").Success, Is.False);
// Both switches held by the same guild + defenses down -> capture at the Sinior/Crown. // The crown only starts counting after the guild master clicked it.
ctx.SetSwitchHolder(218, "Attackers"); Assert.That(ctx.TickCrownHold(GuildA, "Attackers", true, T0, hold).Event, Is.EqualTo(CrownEvent.None));
Assert.That(ctx.TryCaptureThrone("Attackers").Success, Is.True); Assert.That(ctx.RequestCrownHold(GuildA), Is.True);
Assert.That(ctx.OccupierGuildName, Is.EqualTo("Attackers")); ctx.TickCrownHold(GuildA, "Attackers", true, T0, hold);
Assert.That(ctx.TickCrownHold(GuildA, "Attackers", true, T0.AddSeconds(60), hold).Event, Is.EqualTo(CrownEvent.Captured));
Assert.That(ctx.OccupierGuildId, Is.EqualTo(GuildA));
await ctx.TickAsync(T0.AddMinutes(20)); // Siege -> Settlement await ctx.TickAsync(T0.AddMinutes(20)); // siege time is up: Start -> End
await ctx.TickAsync(T0.AddMinutes(20)); // Settlement -> Ownership await ctx.TickAsync(T0.AddMinutes(20)); // End hands the castle to the guild on the throne
Assert.That(ctx.OwnerGuildId, Is.EqualTo(GuildA));
Assert.That(ctx.OwnerGuildName, Is.EqualTo("Attackers")); Assert.That(ctx.OwnerGuildName, Is.EqualTo("Attackers"));
} }
/// <summary>Tests that two different guilds each holding one switch cannot capture the throne.</summary> /// <summary>Tests that two different guilds each holding one switch keep the crown's shield up.</summary>
[Test] [Test]
public async Task ThroneRequiresBothSwitchesBySameGuildAsync() public async Task ShieldRequiresBothSwitchesBySameGuildAsync()
{ {
var ctx = new CastleSiegeContext(Config()); var ctx = new CastleSiegeContext(Config());
await ctx.ForceStartRegistrationAsync(T0); await ctx.ForceStartRegistrationAsync(T0);
await ctx.ForcePhaseAsync(CastleSiegePhase.Siege, T0); await ctx.ForceStateAsync(CastleSiegeState.Start, T0);
ctx.SetDefenseCount(0); StartSwitch(ctx, 217, GuildA, 1);
ctx.SetSwitchHolder(217, "A"); StartSwitch(ctx, 218, GuildB, 2);
ctx.SetSwitchHolder(218, "B"); CompleteSwitch(ctx, 217);
Assert.That(ctx.TryCaptureThrone("A").Success, Is.False); CompleteSwitch(ctx, 218);
Assert.That(ctx.TryCaptureThrone("B").Success, Is.False);
Assert.That(ctx.GetShieldEligibleGuild(), Is.Null);
} }
/// <summary>Tests that capturing the throne outside the siege phase is a no-op.</summary> /// <summary>Tests that the switches don't work at all outside the running siege.</summary>
[Test] [Test]
public void ThroneCaptureOutsideSiegeIsNoOp() public void SwitchesDoNothingOutsideSiege()
{ {
var ctx = new CastleSiegeContext(Config()); var ctx = new CastleSiegeContext(Config());
ctx.SetSwitchHolder(217, "A"); var (result, _) = ctx.TryStartSwitchOperation(217, GuildA, "A", 1, "player", 100, T0);
ctx.SetSwitchHolder(218, "A");
Assert.That(ctx.TryCaptureThrone("A").Success, Is.False); Assert.That(result, Is.EqualTo(CastleSiegeSwitchPush.SiegeNotRunning));
Assert.That(ctx.OccupierGuildName, Is.Null); Assert.That(ctx.GetSwitchOperation(217), Is.Null);
Assert.That(ctx.GetShieldEligibleGuild(), Is.Null);
} }
/// <summary>Tests that restoring persisted state sets phase/owner/registrations without raising PhaseChanged.</summary> /// <summary>Tests that a switch belongs to the first player who clicked it, until they leave its area.</summary>
[Test] [Test]
public void RestoreStateSetsStateWithoutFiringPhaseChanged() public async Task SwitchIsTakenByOnePlayerUntilTheyLeaveAsync()
{ {
var ctx = new CastleSiegeContext(Config()); var ctx = new CastleSiegeContext(Config());
var phaseChangedFired = false; await ctx.ForceStartRegistrationAsync(T0);
ctx.PhaseChanged += _ => phaseChangedFired = true; await ctx.ForceStateAsync(CastleSiegeState.Start, T0);
var push = TimeSpan.FromSeconds(15);
ctx.RestoreState("Winners", CastleSiegePhase.Ownership, T0, new[] { "Winners", "Losers" }); var (first, _) = ctx.TryStartSwitchOperation(217, GuildA, "A", 1, "first", 100, T0);
Assert.That(first, Is.EqualTo(CastleSiegeSwitchPush.Started));
Assert.That(ctx.Phase, Is.EqualTo(CastleSiegePhase.Ownership)); // Somebody else clicking it is refused and learns who is on it.
var (second, blocking) = ctx.TryStartSwitchOperation(217, GuildB, "B", 2, "second", 100, T0.AddSeconds(1));
Assert.That(second, Is.EqualTo(CastleSiegeSwitchPush.TakenByOther));
Assert.That(blocking?.PlayerId, Is.EqualTo(1));
// It only counts once the push ran its time.
Assert.That(ctx.TickSwitch(217, true, T0.AddSeconds(14), push).Event, Is.EqualTo(CastleSiegeSwitchEvent.None));
Assert.That(ctx.GetSwitchOperation(217)!.IsHeld, Is.False);
Assert.That(ctx.TickSwitch(217, true, T0.AddSeconds(15), push).Event, Is.EqualTo(CastleSiegeSwitchEvent.Held));
Assert.That(ctx.GetSwitchOperation(217)!.IsHeld, Is.True);
// Leaving the area frees it for everybody.
var (released, freed) = ctx.TickSwitch(217, false, T0.AddSeconds(20), push);
Assert.That(released, Is.EqualTo(CastleSiegeSwitchEvent.Released));
Assert.That(freed?.PlayerId, Is.EqualTo(1));
Assert.That(ctx.GetSwitchOperation(217), Is.Null);
var (afterRelease, _) = ctx.TryStartSwitchOperation(217, GuildB, "B", 2, "second", 100, T0.AddSeconds(21));
Assert.That(afterRelease, Is.EqualTo(CastleSiegeSwitchPush.Started));
}
/// <summary>Tests that losing a switch while the crown is being held drops the guild's eligibility.</summary>
[Test]
public async Task LosingASwitchRaisesTheShieldAgainAsync()
{
var ctx = await SiegeWithSwitchesHeldAsync(GuildA);
Assert.That(ctx.GetShieldEligibleGuild(), Is.EqualTo(GuildA));
ctx.TickSwitch(218, false, T0.AddSeconds(20), TimeSpan.FromSeconds(15));
Assert.That(ctx.GetShieldEligibleGuild(), Is.Null);
}
/// <summary>Tests that restoring persisted state sets state/owner/registrations without raising StateChanged.</summary>
[Test]
public void RestoreStateSetsStateWithoutFiringStateChanged()
{
var ctx = new CastleSiegeContext(Config());
var stateChangedFired = false;
ctx.StateChanged += _ => stateChangedFired = true;
ctx.RestoreState(
GuildA,
"Winners",
CastleSiegeState.Idle1,
T0,
new[] { new KeyValuePair<Guid, string>(GuildA, "Winners"), new KeyValuePair<Guid, string>(GuildB, "Losers") });
Assert.That(ctx.State, Is.EqualTo(CastleSiegeState.Idle1));
Assert.That(ctx.OwnerGuildId, Is.EqualTo(GuildA));
Assert.That(ctx.OwnerGuildName, Is.EqualTo("Winners")); Assert.That(ctx.OwnerGuildName, Is.EqualTo("Winners"));
Assert.That(ctx.RegisteredGuilds, Is.EquivalentTo(new[] { "Winners", "Losers" })); Assert.That(ctx.RegisteredGuildNames, Is.EquivalentTo(new[] { "Winners", "Losers" }));
Assert.That(phaseChangedFired, Is.False); Assert.That(stateChangedFired, Is.False);
Assert.That(ctx.ConsumeDirty(), Is.False, "restore must not mark the state dirty"); Assert.That(ctx.ConsumeDirty(), Is.False, "restore must not mark the state dirty");
} }
/// <summary>Tests that the weekly auto-schedule (stored in config) only fires on a matching day/time window.</summary> /// <summary>Tests that the weekly auto-schedule (stored in the settings) only fires on a matching day/time window.</summary>
[Test] [Test]
public void ScheduleFiresOnMatchingDayAndTimeWindow() public void ScheduleFiresOnMatchingDayAndTimeWindow()
{ {
@@ -149,7 +214,7 @@ public class CastleSiegeContextTest
var config = ctx.Configuration; var config = ctx.Configuration;
var sunday = new DateTime(2026, 1, 4, 20, 0, 2, DateTimeKind.Utc); // a Sunday, +2s into the window var sunday = new DateTime(2026, 1, 4, 20, 0, 2, DateTimeKind.Utc); // a Sunday, +2s into the window
var sundayLate = new DateTime(2026, 1, 4, 20, 0, 30, DateTimeKind.Utc); // past the 5s window var sundayLate = new DateTime(2026, 1, 4, 20, 0, 30, DateTimeKind.Utc); // past the window
var monday = new DateTime(2026, 1, 5, 20, 0, 2, DateTimeKind.Utc); // wrong day var monday = new DateTime(2026, 1, 5, 20, 0, 2, DateTimeKind.Utc); // wrong day
Assert.That(config.IsRegistrationOpenTime(sunday), Is.True); Assert.That(config.IsRegistrationOpenTime(sunday), Is.True);
@@ -168,26 +233,26 @@ public class CastleSiegeContextTest
var ctx = new CastleSiegeContext(Config()); var ctx = new CastleSiegeContext(Config());
Assert.That(ctx.ConsumeDirty(), Is.False, "a fresh context has nothing to persist"); Assert.That(ctx.ConsumeDirty(), Is.False, "a fresh context has nothing to persist");
await ctx.ForceStartRegistrationAsync(T0); // phase transition -> dirty await ctx.ForceStartRegistrationAsync(T0); // state transition -> dirty
Assert.That(ctx.ConsumeDirty(), Is.True); Assert.That(ctx.ConsumeDirty(), Is.True);
Assert.That(ctx.ConsumeDirty(), Is.False, "ConsumeDirty resets the flag"); Assert.That(ctx.ConsumeDirty(), Is.False, "ConsumeDirty resets the flag");
ctx.RegisterGuild("Attackers"); // registration -> dirty ctx.RegisterGuild(GuildA, "Attackers"); // registration -> dirty
Assert.That(ctx.ConsumeDirty(), Is.True); Assert.That(ctx.ConsumeDirty(), Is.True);
ctx.SetOwner("Attackers"); // owner change -> dirty ctx.SetOwner(GuildA, "Attackers"); // owner change -> dirty
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> /// <summary>Tests that the remaining siege time counts down during the siege and is zero otherwise.</summary>
[Test] [Test]
public async Task RemainingSiegeTimeReflectsSiegePhaseAsync() public async Task RemainingSiegeTimeReflectsSiegeStateAsync()
{ {
var ctx = new CastleSiegeContext(Config()); // 10 minute siege duration var ctx = new CastleSiegeContext(Config()); // 10 minute siege duration
Assert.That(ctx.GetRemainingSiegeTime(T0), Is.EqualTo(TimeSpan.Zero), "no siege running -> zero"); Assert.That(ctx.GetRemainingSiegeTime(T0), Is.EqualTo(TimeSpan.Zero), "no siege running -> zero");
await ctx.ForceStartRegistrationAsync(T0); await ctx.ForceStartRegistrationAsync(T0);
await ctx.ForcePhaseAsync(CastleSiegePhase.Siege, T0); await ctx.ForceStateAsync(CastleSiegeState.Start, T0);
Assert.That(ctx.GetRemainingSiegeTime(T0.AddMinutes(3)), Is.EqualTo(TimeSpan.FromMinutes(7))); 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"); Assert.That(ctx.GetRemainingSiegeTime(T0.AddMinutes(15)), Is.EqualTo(TimeSpan.Zero), "past the end -> clamped to zero");
@@ -197,75 +262,90 @@ public class CastleSiegeContextTest
[Test] [Test]
public async Task CrownHoldCapturesAfterHoldDurationAsync() public async Task CrownHoldCapturesAfterHoldDurationAsync()
{ {
var ctx = new CastleSiegeContext(Config()); var ctx = await SiegeWithSwitchesHeldAsync(GuildA);
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); 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(ctx.GetShieldEligibleGuild(), Is.EqualTo(GuildA));
Assert.That(ctx.RequestCrownHold(GuildA), Is.True);
Assert.That(ctx.TickCrownHold(GuildA, "Attackers", true, T0, hold).Event, Is.EqualTo(CrownEvent.HoldStarted));
Assert.That(ctx.TickCrownHold(GuildA, "Attackers", true, T0.AddSeconds(30), hold).Event, Is.EqualTo(CrownEvent.None));
Assert.That(ctx.OccupierGuildId, Is.Null);
var captured = ctx.TickCrownHold(GuildA, "Attackers", true, T0.AddSeconds(60), hold);
Assert.That(captured.Event, Is.EqualTo(CrownEvent.Captured)); Assert.That(captured.Event, Is.EqualTo(CrownEvent.Captured));
Assert.That(captured.ShieldDown, Is.True); Assert.That(captured.ShieldDown, Is.True);
Assert.That(ctx.OccupierGuildName, Is.EqualTo("Attackers")); Assert.That(ctx.OccupierGuildId, Is.EqualTo(GuildA));
} }
/// <summary>Tests that losing a switch mid-hold resets the crown-hold progress (contestable).</summary> /// <summary>Tests that losing a switch mid-hold resets the crown-hold progress (contestable).</summary>
[Test] [Test]
public async Task CrownHoldResetsWhenSwitchLostAsync() public async Task CrownHoldResetsWhenSwitchLostAsync()
{ {
var ctx = new CastleSiegeContext(Config()); var ctx = await SiegeWithSwitchesHeldAsync(GuildA);
await ctx.ForceStartRegistrationAsync(T0); var hold = TimeSpan.FromSeconds(60);
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)); Assert.That(ctx.RequestCrownHold(GuildA), Is.True);
Assert.That(ctx.TickCrownHold(GuildA, "A", true, T0, hold).Event, Is.EqualTo(CrownEvent.HoldStarted));
ctx.SetSwitchHolder(218, null); // lost a switch -> no longer eligible ctx.TickSwitch(218, false, T0.AddSeconds(5), TimeSpan.FromSeconds(15)); // lost a switch -> no longer eligible
var reset = ctx.TickCrownHold(ctx.GetShieldEligibleGuild(), false, T0.AddSeconds(10), TimeSpan.FromSeconds(60)); var reset = ctx.TickCrownHold(ctx.GetShieldEligibleGuild(), null, false, T0.AddSeconds(10), hold);
Assert.That(reset.ShieldDown, Is.False); Assert.That(reset.ShieldDown, Is.False);
Assert.That(reset.Event, Is.EqualTo(CrownEvent.HoldReset)); Assert.That(reset.Event, Is.EqualTo(CrownEvent.HoldReset));
Assert.That(ctx.OccupierGuildName, Is.Null); Assert.That(ctx.OccupierGuildId, Is.Null);
} }
/// <summary>Tests that the occupier can't re-capture its own throne, but a different guild can contest it.</summary> /// <summary>Tests that the occupier can't re-capture its own throne, but a different guild can contest it.</summary>
[Test] [Test]
public async Task OccupierDoesNotRecaptureButAnotherGuildCanContestAsync() public async Task OccupierDoesNotRecaptureButAnotherGuildCanContestAsync()
{ {
var ctx = new CastleSiegeContext(Config()); var ctx = await SiegeWithSwitchesHeldAsync(GuildA);
await ctx.ForceStartRegistrationAsync(T0);
await ctx.ForcePhaseAsync(CastleSiegePhase.Siege, T0);
ctx.SetDefenseCount(0);
var hold = TimeSpan.FromSeconds(60); var hold = TimeSpan.FromSeconds(60);
var push = TimeSpan.FromSeconds(15);
ctx.SetSwitchHolder(217, "A"); ctx.RequestCrownHold(GuildA);
ctx.SetSwitchHolder(218, "A"); ctx.TickCrownHold(GuildA, "A", true, T0, hold);
ctx.TickCrownHold("A", true, T0, hold); Assert.That(ctx.TickCrownHold(GuildA, "A", true, T0.AddSeconds(60), hold).Event, Is.EqualTo(CrownEvent.Captured));
Assert.That(ctx.TickCrownHold("A", true, T0.AddSeconds(60), hold).Event, Is.EqualTo(CrownEvent.Captured)); Assert.That(ctx.OccupierGuildId, Is.EqualTo(GuildA));
Assert.That(ctx.OccupierGuildName, Is.EqualTo("A"));
// A keeps holding no re-registration loop, but the shield stays down (they hold it). // A keeps holding - no re-registration loop, but the shield stays down (they hold it).
var after = ctx.TickCrownHold("A", true, T0.AddSeconds(61), hold); Assert.That(ctx.RequestCrownHold(GuildA), Is.False, "the occupier cannot re-register its own throne");
var after = ctx.TickCrownHold(GuildA, "A", true, T0.AddSeconds(61), hold);
Assert.That(after.Event, Is.EqualTo(CrownEvent.None)); Assert.That(after.Event, Is.EqualTo(CrownEvent.None));
Assert.That(after.ShieldDown, Is.True); Assert.That(after.ShieldDown, Is.True);
// B takes both switches and can contest/capture. // B takes both switches and can contest/capture.
ctx.SetSwitchHolder(217, "B"); ctx.TickSwitch(217, false, T0.AddSeconds(61), push);
ctx.SetSwitchHolder(218, "B"); ctx.TickSwitch(218, false, T0.AddSeconds(61), push);
Assert.That(ctx.TickCrownHold("B", true, T0.AddSeconds(62), hold).Event, Is.EqualTo(CrownEvent.HoldStarted)); StartSwitch(ctx, 217, GuildB, 3);
Assert.That(ctx.TickCrownHold("B", true, T0.AddSeconds(122), hold).Event, Is.EqualTo(CrownEvent.Captured)); StartSwitch(ctx, 218, GuildB, 4);
Assert.That(ctx.OccupierGuildName, Is.EqualTo("B")); CompleteSwitch(ctx, 217);
CompleteSwitch(ctx, 218);
Assert.That(ctx.RequestCrownHold(GuildB), Is.True);
Assert.That(ctx.TickCrownHold(GuildB, "B", true, T0.AddSeconds(62), hold).Event, Is.EqualTo(CrownEvent.HoldStarted));
Assert.That(ctx.TickCrownHold(GuildB, "B", true, T0.AddSeconds(122), hold).Event, Is.EqualTo(CrownEvent.Captured));
Assert.That(ctx.OccupierGuildId, Is.EqualTo(GuildB));
} }
private static CastleSiegeConfiguration Config() => new() private static async Task<CastleSiegeContext> SiegeWithSwitchesHeldAsync(Guid guildId)
{
var ctx = new CastleSiegeContext(Config());
await ctx.ForceStartRegistrationAsync(T0);
await ctx.ForceStateAsync(CastleSiegeState.Start, T0);
StartSwitch(ctx, 217, guildId, 1);
StartSwitch(ctx, 218, guildId, 2);
CompleteSwitch(ctx, 217);
CompleteSwitch(ctx, 218);
return ctx;
}
private static void StartSwitch(CastleSiegeContext ctx, short switchNumber, Guid guildId, ushort playerId)
=> ctx.TryStartSwitchOperation(switchNumber, guildId, guildId.ToString()[..4], playerId, $"p{playerId}", (ushort)switchNumber, T0);
private static void CompleteSwitch(CastleSiegeContext ctx, short switchNumber)
=> ctx.TickSwitch(switchNumber, true, T0.AddSeconds(30), TimeSpan.FromSeconds(15));
private static CastleSiegeSettings Config() => new()
{ {
RegistrationDuration = TimeSpan.FromMinutes(5), RegistrationDuration = TimeSpan.FromMinutes(5),
PreparationDuration = TimeSpan.FromMinutes(2), PreparationDuration = TimeSpan.FromMinutes(2),