//
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
//
namespace MUnique.OpenMU.GameLogic.CastleSiege;
using MUnique.OpenMU.DataModel.Configuration;
///
/// In-memory Castle Siege state machine and battle contention.
/// Time is injected via method parameters so it can be tested deterministically.
///
/// The cycle uses the original Season 6 values, which are exactly the values
/// the game client expects (see CASTLESIEGE_STATE in the client's WSclient.h). AdaMu drives only
/// a subset of them, because it registers guilds directly and has no Mark of Lord step:
///
///
/// Idle1(0) -> RegisterGuild(1) -> Ready(6) -> Start(7) -> End(8) -> EndCycle(9) -> Idle1(0)
///
///
/// The skipped states (, ,
/// , ) keep their numbers so the
/// client stays compatible; the server simply never enters them.
///
///
/// Guilds are identified by their persistent , not by name. A guild rename (or a delete and
/// 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.
///
/// Battle rule: attackers must destroy all castle defenses (gates + guardian statues) and then hold BOTH
/// Crown Switches at the same time — the switches are held by standing on them (evaluated per tick by the
/// plugin), and once both are held by one guild with the defenses down, that guild captures the throne.
/// The throne holder when the siege ends becomes the castle owner.
///
public class CastleSiegeContext
{
/// The Crown Switch NPC numbers on Valley of Loren; both must be held to take the throne.
public static readonly short[] SwitchNumbers = { 217, 218 };
private readonly Dictionary _registeredGuilds = new();
private readonly Dictionary _switchHolders = new() { { 217, null }, { 218, null } };
private DateTime _stateStartedUtc;
private Guid? _occupier;
private string? _occupierName;
private int _defensesRemaining;
private bool _dirty;
private Guid? _crownHoldGuild;
private DateTime? _crownHoldStartUtc;
private bool _lastShieldDown;
/// Initializes a new instance of the class.
/// The cycle timing configuration.
public CastleSiegeContext(CastleSiegeSettings configuration)
{
this.Configuration = configuration;
this.State = CastleSiegeState.Idle1;
}
/// Raised after the state changes. Argument is the new state.
public event Action? StateChanged;
/// Gets the configuration (durations + schedule). Refreshed each tick from the live plugin config
/// so AdminPanel edits take effect without a restart.
public CastleSiegeSettings Configuration { get; private set; }
/// Gets the current state.
public CastleSiegeState State { get; private set; }
/// Gets the UTC time the current state started (used for persistence/restore).
public DateTime StateStartedUtc => this._stateStartedUtc;
/// Gets the persistent identifier of the owner guild, or if unowned.
public Guid? OwnerGuildId { get; private set; }
/// Gets the owner guild's name for display and client packets, or null.
public string? OwnerGuildName { get; private set; }
/// Gets the guild currently holding the throne during the siege, or null.
public Guid? OccupierGuildId => this._occupier;
/// Gets the throne holder's name for display and client packets, or null.
public string? OccupierGuildName => this._occupierName;
/// Gets the number of castle defenses (gates + statues) still standing; the throne needs 0.
public int DefensesRemaining => this._defensesRemaining;
/// Gets the persistent identifiers of the guilds registered for the current cycle.
public IReadOnlyCollection RegisteredGuildIds => this._registeredGuilds.Keys;
/// Gets the names of the guilds registered for the current cycle (display only).
public IReadOnlyCollection RegisteredGuildNames => this._registeredGuilds.Values;
/// Gets a value indicating whether the siege battle is currently running.
public bool IsSiegeRunning => this.State == CastleSiegeState.Start;
/// Points the context at the current (possibly AdminPanel-edited) plugin configuration.
/// The live configuration.
public void UpdateConfiguration(CastleSiegeSettings configuration) => this.Configuration = configuration;
/// Returns whether the given guild is registered for the current cycle.
/// The persistent guild identifier.
public bool IsRegistered(Guid guildId) => this._registeredGuilds.ContainsKey(guildId);
/// Advances the state machine based on the current time.
/// The current UTC time.
public ValueTask TickAsync(DateTime now)
{
switch (this.State)
{
case CastleSiegeState.Idle1:
if (this.Configuration.IsRegistrationOpenTime(now))
{
return this.ForceStartRegistrationAsync(now);
}
break;
case CastleSiegeState.RegisterGuild:
if (now >= this._stateStartedUtc + this.Configuration.RegistrationDuration)
{
return this.TransitionAsync(CastleSiegeState.Ready, now);
}
break;
case CastleSiegeState.Ready:
if (now >= this._stateStartedUtc + this.Configuration.PreparationDuration)
{
return this.TransitionAsync(CastleSiegeState.Start, now);
}
break;
case CastleSiegeState.Start:
if (now >= this._stateStartedUtc + this.Configuration.SiegeDuration)
{
return this.TransitionAsync(CastleSiegeState.End, now);
}
break;
case CastleSiegeState.End:
// Winner = guild holding the throne at siege end. If none captured, owner unchanged.
if (this._occupier is { } occupier)
{
this.SetOwner(occupier, this._occupierName);
}
this.ClearBattleState();
return this.TransitionAsync(CastleSiegeState.EndCycle, now);
case CastleSiegeState.EndCycle:
return this.TransitionAsync(CastleSiegeState.Idle1, now);
default:
break;
}
return ValueTask.CompletedTask;
}
/// Admin: forces the cycle into guild registration now (from any state).
/// The current UTC time.
public ValueTask ForceStartRegistrationAsync(DateTime now)
{
this._registeredGuilds.Clear();
this.ClearBattleState();
return this.TransitionAsync(CastleSiegeState.RegisterGuild, now);
}
/// Admin: forces a specific state now.
/// The target state.
/// The current UTC time.
public ValueTask ForceStateAsync(CastleSiegeState state, DateTime now)
=> this.TransitionAsync(state, now);
/// Admin: resets to the idle (resting) state and clears registrations/battle state.
/// The current UTC time.
public ValueTask ResetAsync(DateTime now)
{
this._registeredGuilds.Clear();
this.ClearBattleState();
return this.TransitionAsync(CastleSiegeState.Idle1, now);
}
/// Registers a guild for the current cycle. No-op outside the registration state.
/// The persistent guild identifier.
/// The guild name, for display.
public void RegisterGuild(Guid guildId, string guildName)
{
if (this.State == CastleSiegeState.RegisterGuild
&& this._registeredGuilds.TryAdd(guildId, guildName))
{
this._dirty = true;
}
}
/// Admin: sets (or clears) the current owner guild.
/// The owner guild identifier, or null to clear.
/// The owner guild name, or null.
public void SetOwner(Guid? guildId, string? guildName)
{
this.OwnerGuildId = guildId;
this.OwnerGuildName = guildId is null ? null : guildName;
this._dirty = true;
}
///
/// Mirrors the shared castle owner loaded from the database. Used on game servers that do NOT host the
/// siege, so their hunting-map gate and castle flag still reflect the current owner. Does not mark the
/// state dirty (these servers never persist).
///
/// The owner guild identifier, or null.
/// The owner guild name, or null.
public void SyncOwner(Guid? guildId, string? guildName)
{
this.OwnerGuildId = guildId;
this.OwnerGuildName = guildId is null ? null : guildName;
}
///
/// Sets the auto-open schedule (days of week + UTC time) into the configuration and marks the state
/// dirty for persistence. Empty days disables auto-start (manual only). The configuration is the single
/// source of truth, so this is equivalent to editing the plugin config in the AdminPanel.
///
/// The days of week registration should auto-open.
/// The UTC time of day registration should auto-open, or null to disable.
public void SetSchedule(IEnumerable days, TimeOnly? time)
{
var orderedDays = days.Distinct().OrderBy(d => d).ToList();
this.Configuration.RegistrationOpenDays = orderedDays;
this.Configuration.RegistrationOpenTimes = orderedDays.Count > 0 && time is { } t
? new List { t }
: new List();
this._dirty = true;
}
///
/// Returns how much time is left in the running siege battle (the on-map countdown value), or
/// when the siege is not currently running.
///
/// The current UTC time.
public TimeSpan GetRemainingSiegeTime(DateTime nowUtc)
{
if (!this.IsSiegeRunning)
{
return TimeSpan.Zero;
}
var remaining = (this._stateStartedUtc + this.Configuration.SiegeDuration) - nowUtc;
return remaining > TimeSpan.Zero ? remaining : TimeSpan.Zero;
}
///
/// Returns how much time is left in the current state, or when the state has
/// no duration (idle states wait for an admin command or the auto-open time).
///
/// The current UTC time.
public TimeSpan GetRemainingStateTime(DateTime nowUtc)
{
var duration = this.State switch
{
CastleSiegeState.RegisterGuild => this.Configuration.RegistrationDuration,
CastleSiegeState.Ready => this.Configuration.PreparationDuration,
CastleSiegeState.Start => this.Configuration.SiegeDuration,
_ => TimeSpan.Zero,
};
if (duration == TimeSpan.Zero)
{
return TimeSpan.Zero;
}
var remaining = (this._stateStartedUtc + duration) - nowUtc;
return remaining > TimeSpan.Zero ? remaining : TimeSpan.Zero;
}
///
/// Returns the guild that currently holds BOTH crown switches while all castle defenses are down (so the
/// crown's shield is dropped for them), or null. Only meaningful during the siege.
///
public Guid? GetShieldEligibleGuild()
{
if (!this.IsSiegeRunning || this._defensesRemaining > 0)
{
return null;
}
var holder = this._switchHolders[217];
return holder is not null && holder == this._switchHolders[218] ? holder : null;
}
///
/// Advances the crown-hold capture. is the guild with both switches held
/// and no defenses left (shield down); is whether that guild's master is
/// standing on the crown. Captures the throne for the guild once it has held for .
/// Losing a switch or the master leaving the crown resets the hold (contestable until the siege ends).
///
/// The guild with both switches and no defenses, or null.
/// That guild's name, for display.
/// Whether that guild's master is on the crown.
/// The current UTC time.
/// How long the master must hold to capture.
public CrownTickResult TickCrownHold(Guid? eligibleGuild, string? eligibleGuildName, bool masterHolding, DateTime now, TimeSpan holdDuration)
{
var shieldDown = this.IsSiegeRunning && eligibleGuild is not null;
var shieldChanged = shieldDown != this._lastShieldDown;
this._lastShieldDown = shieldDown;
if (!this.IsSiegeRunning)
{
this._crownHoldGuild = null;
this._crownHoldStartUtc = null;
return new CrownTickResult(false, shieldChanged, CrownEvent.None, null, 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).
// Only a DIFFERENT guild taking both switches can register/capture (contest).
var canCapture = eligibleGuild is not null && eligibleGuild != this._occupier;
if (!canCapture || !masterHolding)
{
this._crownHoldGuild = null;
this._crownHoldStartUtc = null;
return new CrownTickResult(shieldDown, shieldChanged, wasHolding ? CrownEvent.HoldReset : CrownEvent.None, null, null);
}
if (this._crownHoldGuild != eligibleGuild || this._crownHoldStartUtc is null)
{
this._crownHoldGuild = eligibleGuild;
this._crownHoldStartUtc = now;
return new CrownTickResult(shieldDown, shieldChanged, CrownEvent.HoldStarted, eligibleGuild, eligibleGuildName);
}
if (now - this._crownHoldStartUtc.Value >= holdDuration)
{
this._occupier = eligibleGuild;
this._occupierName = eligibleGuildName;
this._crownHoldGuild = null;
this._crownHoldStartUtc = null;
this._dirty = true;
return new CrownTickResult(shieldDown, shieldChanged, CrownEvent.Captured, eligibleGuild, eligibleGuildName);
}
return new CrownTickResult(shieldDown, shieldChanged, CrownEvent.None, eligibleGuild, eligibleGuildName);
}
///
/// 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.
///
public bool ConsumeDirty()
{
var dirty = this._dirty;
this._dirty = false;
return dirty;
}
///
/// Restores persisted state on startup (owner, state, state-start, registrations) directly, without
/// firing or marking the state dirty. Battle state stays cleared.
///
/// The persisted owner guild identifier, or null.
/// The persisted owner guild name, or null.
/// The persisted state.
/// When the persisted state started (UTC), or null to keep the default.
/// The persisted registrations (id to name), or null.
public void RestoreState(Guid? ownerGuildId, string? ownerGuildName, CastleSiegeState state, DateTime? stateStartedUtc, IEnumerable>? registeredGuilds)
{
this.OwnerGuildId = ownerGuildId;
this.OwnerGuildName = ownerGuildId is null ? null : ownerGuildName;
this.State = state;
this._stateStartedUtc = stateStartedUtc ?? this._stateStartedUtc;
this._registeredGuilds.Clear();
if (registeredGuilds is not null)
{
foreach (var registration in registeredGuilds)
{
this._registeredGuilds[registration.Key] = registration.Value;
}
}
this._dirty = false;
}
/// Sets how many castle defenses (gates + guardian statues) exist this siege (when spawned).
/// The defense count.
public void SetDefenseCount(int count) => this._defensesRemaining = count > 0 ? count : 0;
/// Notifies that a castle defense (gate/statue) was destroyed. Lowers the remaining count (min 0).
public void NotifyDefenseDestroyed()
{
if (this._defensesRemaining > 0)
{
this._defensesRemaining--;
}
}
///
/// Sets which guild is currently standing on (holding) a Crown Switch. Called every tick by the plugin
/// based on player positions. Pass null when no registered member stands on it. No-op outside the siege.
///
/// The Crown Switch NPC number (217 or 218).
/// The holding guild's persistent identifier, or null.
public void SetSwitchHolder(short switchNumber, Guid? guildId)
{
if (this.IsSiegeRunning && this._switchHolders.ContainsKey(switchNumber))
{
this._switchHolders[switchNumber] = guildId;
}
}
///
/// Attempts to capture the throne for a guild (called when a member registers at the Sinior/Crown NPC).
/// Succeeds only during the siege when the throne is free, all castle defenses are destroyed, and the
/// guild is currently holding BOTH Crown Switches (a member standing on each).
///
/// The capturing guild's persistent identifier.
/// The capturing guild's name, for display.
/// Whether it succeeded and a human-readable reason/result message.
public (bool Success, string Reason) TryCaptureThrone(Guid guildId, string guildName)
{
if (!this.IsSiegeRunning)
{
return (false, "The siege is not running.");
}
if (this._occupier is { } occupier)
{
return (false, occupier == guildId
? "Your guild already holds the throne."
: $"The throne is already held by '{this._occupierName ?? occupier.ToString()}'.");
}
if (this._defensesRemaining > 0)
{
return (false, $"Destroy the castle defenses first ({this._defensesRemaining} remaining).");
}
if (this._switchHolders[217] != guildId || this._switchHolders[218] != guildId)
{
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");
}
/// Returns a human-readable status summary for admin display.
public string GetStatusText()
=> $"CS state={this.State}({(int)this.State}), owner={this.OwnerGuildName ?? "(none)"}, "
+ $"registered={this._registeredGuilds.Count} [{string.Join(", ", this._registeredGuilds.Values)}], "
+ $"defenses={this._defensesRemaining}, throne={this._occupierName ?? "(none)"}, "
+ $"switch217={this.DescribeSwitch(217)}, switch218={this.DescribeSwitch(218)}";
private string DescribeSwitch(short switchNumber)
=> this._switchHolders[switchNumber] is { } holder
? (this._registeredGuilds.TryGetValue(holder, out var name) ? name : holder.ToString())
: "-";
private void ClearBattleState()
{
this._switchHolders[217] = null;
this._switchHolders[218] = null;
this._defensesRemaining = 0;
this._occupier = null;
this._occupierName = null;
this._crownHoldGuild = null;
this._crownHoldStartUtc = null;
this._lastShieldDown = false;
}
private ValueTask TransitionAsync(CastleSiegeState state, DateTime now)
{
this.State = state;
this._stateStartedUtc = now;
this._dirty = true;
this.StateChanged?.Invoke(state);
return ValueTask.CompletedTask;
}
}
/// The event produced by a single call.
public enum CrownEvent
{
/// Nothing changed this tick.
None,
/// A guild master just started holding the crown (start the client countdown).
HoldStarted,
/// The crown was held long enough — the guild captured the throne.
Captured,
/// An in-progress hold was interrupted (switch lost or master left the crown).
HoldReset,
}
/// The result of a crown-hold tick: the shield state and any event that occurred.
/// Whether the crown shield is currently down (both switches held, defenses cleared).
/// Whether the shield state changed this tick (only then should the client be told).
/// The event that occurred this tick.
/// The guild the event refers to, if any.
/// That guild's name, for display and client packets.
public readonly record struct CrownTickResult(bool ShieldDown, bool ShieldChanged, CrownEvent Event, Guid? GuildId, string? GuildName);