// // 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);