Some checks failed
.NET Core / build (push) Has been cancelled
With multiple game servers each has its own map instances, so the siege ran independently on all of them - a guild could win uncontested on an empty server's Valley of Loren. Now the siege (tick/spawn/battle/registration) runs only on the server whose Id == config.CastleSiegeServerId (AdminPanel-editable). Other servers skip the siege and just mirror the shared castle owner from config so the hunting-map gate + castle flag rewards still work everywhere. The Guardsman on non-siege servers tells players which server to switch to.
419 lines
18 KiB
C#
419 lines
18 KiB
C#
// <copyright file="CastleSiegeContext.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>
|
|
/// In-memory Castle Siege phase state machine and battle contention (P3).
|
|
/// Time is injected via method parameters so it can be tested deterministically.
|
|
/// 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.
|
|
/// </summary>
|
|
public class CastleSiegeContext
|
|
{
|
|
/// <summary>The Crown Switch NPC numbers on Valley of Loren; both must be held to take the throne.</summary>
|
|
public static readonly short[] SwitchNumbers = { 217, 218 };
|
|
|
|
private readonly List<string> _registeredGuilds = new();
|
|
private readonly Dictionary<short, string?> _switchHolders = new() { { 217, null }, { 218, null } };
|
|
private DateTime _phaseStartedUtc;
|
|
private string? _occupier;
|
|
private int _defensesRemaining;
|
|
private bool _dirty;
|
|
private string? _crownHoldGuild;
|
|
private DateTime? _crownHoldStartUtc;
|
|
private bool _lastShieldDown;
|
|
|
|
/// <summary>Initializes a new instance of the <see cref="CastleSiegeContext"/> class.</summary>
|
|
/// <param name="configuration">The cycle timing configuration.</param>
|
|
public CastleSiegeContext(CastleSiegeConfiguration configuration)
|
|
{
|
|
this.Configuration = configuration;
|
|
this.Phase = CastleSiegePhase.Ownership;
|
|
}
|
|
|
|
/// <summary>Raised after the phase changes. Argument is the new phase.</summary>
|
|
public event Action<CastleSiegePhase>? PhaseChanged;
|
|
|
|
/// <summary>Gets the configuration (durations + schedule). Refreshed each tick from the live plugin config
|
|
/// so AdminPanel edits take effect without a restart.</summary>
|
|
public CastleSiegeConfiguration Configuration { get; private set; }
|
|
|
|
/// <summary>Points the context at the current (possibly AdminPanel-edited) plugin configuration.</summary>
|
|
/// <param name="configuration">The live configuration.</param>
|
|
public void UpdateConfiguration(CastleSiegeConfiguration configuration) => this.Configuration = configuration;
|
|
|
|
/// <summary>Gets the current phase.</summary>
|
|
public CastleSiegePhase Phase { get; private set; }
|
|
|
|
/// <summary>Gets the UTC time the current phase started (used for persistence/restore).</summary>
|
|
public DateTime PhaseStartedUtc => this._phaseStartedUtc;
|
|
|
|
/// <summary>Gets the current owner guild name, or null if unowned.</summary>
|
|
public string? OwnerGuildName { get; private set; }
|
|
|
|
/// <summary>Gets the guild currently holding the throne during the siege (P3), or null.</summary>
|
|
public string? OccupierGuildName => this._occupier;
|
|
|
|
/// <summary>Gets the number of castle defenses (gates + statues) still standing; the throne needs 0.</summary>
|
|
public int DefensesRemaining => this._defensesRemaining;
|
|
|
|
/// <summary>Gets the guild names registered for the current cycle.</summary>
|
|
public IReadOnlyList<string> RegisteredGuilds => this._registeredGuilds;
|
|
|
|
/// <summary>Advances the state machine based on the current time.</summary>
|
|
/// <param name="now">The current UTC time.</param>
|
|
public ValueTask TickAsync(DateTime now)
|
|
{
|
|
switch (this.Phase)
|
|
{
|
|
case CastleSiegePhase.Ownership:
|
|
if (this.Configuration.IsRegistrationOpenTime(now))
|
|
{
|
|
return this.ForceStartRegistrationAsync(now);
|
|
}
|
|
|
|
break;
|
|
case CastleSiegePhase.Registration:
|
|
if (now >= this._phaseStartedUtc + this.Configuration.RegistrationDuration)
|
|
{
|
|
return this.TransitionAsync(CastleSiegePhase.Preparation, now);
|
|
}
|
|
|
|
break;
|
|
case CastleSiegePhase.Preparation:
|
|
if (now >= this._phaseStartedUtc + this.Configuration.PreparationDuration)
|
|
{
|
|
return this.TransitionAsync(CastleSiegePhase.Siege, now);
|
|
}
|
|
|
|
break;
|
|
case CastleSiegePhase.Siege:
|
|
if (now >= this._phaseStartedUtc + this.Configuration.SiegeDuration)
|
|
{
|
|
return this.TransitionAsync(CastleSiegePhase.Settlement, now);
|
|
}
|
|
|
|
break;
|
|
case CastleSiegePhase.Settlement:
|
|
// Winner = guild holding the throne at siege end. If none captured, owner unchanged.
|
|
if (this._occupier is { } occupier)
|
|
{
|
|
this.SetOwner(occupier);
|
|
}
|
|
|
|
this.ClearBattleState();
|
|
return this.TransitionAsync(CastleSiegePhase.Ownership, now);
|
|
default:
|
|
break;
|
|
}
|
|
|
|
return ValueTask.CompletedTask;
|
|
}
|
|
|
|
/// <summary>Admin: forces the cycle into registration now (from any phase).</summary>
|
|
/// <param name="now">The current UTC time.</param>
|
|
public ValueTask ForceStartRegistrationAsync(DateTime now)
|
|
{
|
|
this._registeredGuilds.Clear();
|
|
this.ClearBattleState();
|
|
return this.TransitionAsync(CastleSiegePhase.Registration, now);
|
|
}
|
|
|
|
/// <summary>Admin: forces a specific phase now.</summary>
|
|
/// <param name="phase">The target phase.</param>
|
|
/// <param name="now">The current UTC time.</param>
|
|
public ValueTask ForcePhaseAsync(CastleSiegePhase phase, DateTime now)
|
|
=> this.TransitionAsync(phase, now);
|
|
|
|
/// <summary>Admin: resets to the ownership (resting) phase and clears registrations/battle state.</summary>
|
|
/// <param name="now">The current UTC time.</param>
|
|
public ValueTask ResetAsync(DateTime now)
|
|
{
|
|
this._registeredGuilds.Clear();
|
|
this.ClearBattleState();
|
|
return this.TransitionAsync(CastleSiegePhase.Ownership, now);
|
|
}
|
|
|
|
/// <summary>Registers a guild (by name) for the current cycle. No-op outside registration.</summary>
|
|
/// <param name="guildName">The guild name.</param>
|
|
public void RegisterGuild(string guildName)
|
|
{
|
|
if (this.Phase == CastleSiegePhase.Registration
|
|
&& !this._registeredGuilds.Contains(guildName))
|
|
{
|
|
this._registeredGuilds.Add(guildName);
|
|
this._dirty = true;
|
|
}
|
|
}
|
|
|
|
/// <summary>Admin: sets (or clears) the current owner guild name.</summary>
|
|
/// <param name="guildName">The owner guild name, or null to clear.</param>
|
|
public void SetOwner(string? guildName)
|
|
{
|
|
this.OwnerGuildName = guildName;
|
|
this._dirty = true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Mirrors the shared castle owner from the configuration. 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).
|
|
/// </summary>
|
|
public void SyncOwnerFromConfig()
|
|
{
|
|
this.OwnerGuildName = this.Configuration.PersistedOwnerGuildName;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sets the weekly auto-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.
|
|
/// </summary>
|
|
/// <param name="days">The days of week registration should auto-open.</param>
|
|
/// <param name="time">The UTC time of day registration should auto-open, or null to disable.</param>
|
|
public void SetSchedule(IEnumerable<DayOfWeek> 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<TimeOnly> { t }
|
|
: new List<TimeOnly>();
|
|
this._dirty = true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns how much time is left in the running siege battle (the on-map countdown value), or
|
|
/// <see cref="TimeSpan.Zero"/> when the siege is not currently running.
|
|
/// </summary>
|
|
/// <param name="nowUtc">The current UTC time.</param>
|
|
public TimeSpan GetRemainingSiegeTime(DateTime nowUtc)
|
|
{
|
|
if (this.Phase != CastleSiegePhase.Siege)
|
|
{
|
|
return TimeSpan.Zero;
|
|
}
|
|
|
|
var remaining = (this._phaseStartedUtc + this.Configuration.SiegeDuration) - nowUtc;
|
|
return remaining > TimeSpan.Zero ? remaining : TimeSpan.Zero;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the guild that currently holds BOTH crown switches while all castle defenses are down (so the
|
|
/// crown's shield is dropped for them), or null. Only meaningful during the siege.
|
|
/// </summary>
|
|
public string? GetShieldEligibleGuild()
|
|
{
|
|
if (this.Phase != CastleSiegePhase.Siege || this._defensesRemaining > 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var holder = this._switchHolders[217];
|
|
return holder is not null && holder == this._switchHolders[218] ? holder : null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Advances the crown-hold capture. <paramref name="eligibleGuild"/> is the guild with both switches held
|
|
/// and no defenses left (shield down); <paramref name="masterHolding"/> is whether that guild's master is
|
|
/// standing on the crown. Captures the throne for the guild once it has held for <paramref name="holdDuration"/>.
|
|
/// Losing a switch or the master leaving the crown resets the hold (contestable until the siege ends).
|
|
/// </summary>
|
|
/// <param name="eligibleGuild">The guild with both switches and no defenses, or null.</param>
|
|
/// <param name="masterHolding">Whether that guild's master is on the crown.</param>
|
|
/// <param name="now">The current UTC time.</param>
|
|
/// <param name="holdDuration">How long the master must hold to capture.</param>
|
|
public CrownTickResult TickCrownHold(string? eligibleGuild, bool masterHolding, DateTime now, TimeSpan holdDuration)
|
|
{
|
|
var shieldDown = this.Phase == CastleSiegePhase.Siege && eligibleGuild is not null;
|
|
var shieldChanged = shieldDown != this._lastShieldDown;
|
|
this._lastShieldDown = shieldDown;
|
|
|
|
if (this.Phase != CastleSiegePhase.Siege)
|
|
{
|
|
this._crownHoldGuild = null;
|
|
this._crownHoldStartUtc = null;
|
|
return new CrownTickResult(false, shieldChanged, CrownEvent.None, 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);
|
|
}
|
|
|
|
if (this._crownHoldGuild != eligibleGuild || this._crownHoldStartUtc is null)
|
|
{
|
|
this._crownHoldGuild = eligibleGuild;
|
|
this._crownHoldStartUtc = now;
|
|
return new CrownTickResult(shieldDown, shieldChanged, CrownEvent.HoldStarted, eligibleGuild);
|
|
}
|
|
|
|
if (now - this._crownHoldStartUtc.Value >= holdDuration)
|
|
{
|
|
this._occupier = eligibleGuild;
|
|
this._crownHoldGuild = null;
|
|
this._crownHoldStartUtc = null;
|
|
this._dirty = true;
|
|
return new CrownTickResult(shieldDown, shieldChanged, CrownEvent.Captured, eligibleGuild);
|
|
}
|
|
|
|
return new CrownTickResult(shieldDown, shieldChanged, CrownEvent.None, eligibleGuild);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns whether the persistable state changed since the last call, resetting the flag.
|
|
/// Called each tick by the plugin to decide whether to write state to the database.
|
|
/// </summary>
|
|
public bool ConsumeDirty()
|
|
{
|
|
var dirty = this._dirty;
|
|
this._dirty = false;
|
|
return dirty;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Restores persisted state on startup (owner, phase, phase-start, registrations) directly, without
|
|
/// firing <see cref="PhaseChanged"/> or marking the state dirty. Battle state stays cleared.
|
|
/// </summary>
|
|
/// <param name="owner">The persisted owner guild name, or null.</param>
|
|
/// <param name="phase">The persisted phase.</param>
|
|
/// <param name="phaseStartedUtc">When the persisted phase started (UTC), or null to keep the default.</param>
|
|
/// <param name="registeredGuilds">The persisted registered guild names, or null.</param>
|
|
public void RestoreState(string? owner, CastleSiegePhase phase, DateTime? phaseStartedUtc, IEnumerable<string>? registeredGuilds)
|
|
{
|
|
this.OwnerGuildName = owner;
|
|
this.Phase = phase;
|
|
this._phaseStartedUtc = phaseStartedUtc ?? this._phaseStartedUtc;
|
|
this._registeredGuilds.Clear();
|
|
if (registeredGuilds is not null)
|
|
{
|
|
this._registeredGuilds.AddRange(registeredGuilds);
|
|
}
|
|
|
|
this._dirty = false;
|
|
}
|
|
|
|
/// <summary>Sets how many castle defenses (gates + guardian statues) exist this siege (when spawned).</summary>
|
|
/// <param name="count">The defense count.</param>
|
|
public void SetDefenseCount(int count) => this._defensesRemaining = count > 0 ? count : 0;
|
|
|
|
/// <summary>Notifies that a castle defense (gate/statue) was destroyed. Lowers the remaining count (min 0).</summary>
|
|
public void NotifyDefenseDestroyed()
|
|
{
|
|
if (this._defensesRemaining > 0)
|
|
{
|
|
this._defensesRemaining--;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sets which guild is currently standing on (holding) a Crown Switch. Called every tick by the plugin
|
|
/// based on player positions. Pass <c>null</c> when no registered member stands on it. No-op outside the siege.
|
|
/// </summary>
|
|
/// <param name="switchNumber">The Crown Switch NPC number (217 or 218).</param>
|
|
/// <param name="guildName">The holding guild's name, or null.</param>
|
|
public void SetSwitchHolder(short switchNumber, string? guildName)
|
|
{
|
|
if (this.Phase == CastleSiegePhase.Siege && this._switchHolders.ContainsKey(switchNumber))
|
|
{
|
|
this._switchHolders[switchNumber] = guildName;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
/// <param name="guildName">The capturing guild's name.</param>
|
|
/// <returns>Whether it succeeded and a human-readable reason/result message.</returns>
|
|
public (bool Success, string Reason) TryCaptureThrone(string guildName)
|
|
{
|
|
if (this.Phase != CastleSiegePhase.Siege)
|
|
{
|
|
return (false, "The siege is not running.");
|
|
}
|
|
|
|
if (this._occupier is not null)
|
|
{
|
|
return (false, this._occupier == guildName
|
|
? "Your guild already holds the throne."
|
|
: $"The throne is already held by '{this._occupier}'.");
|
|
}
|
|
|
|
if (this._defensesRemaining > 0)
|
|
{
|
|
return (false, $"Destroy the castle defenses first ({this._defensesRemaining} remaining).");
|
|
}
|
|
|
|
if (this._switchHolders[217] != guildName || this._switchHolders[218] != guildName)
|
|
{
|
|
return (false, "Your guild must be holding BOTH Crown Switches at once (stand a member on each).");
|
|
}
|
|
|
|
this._occupier = guildName;
|
|
return (true, "throne captured");
|
|
}
|
|
|
|
/// <summary>Returns a human-readable status summary for admin display.</summary>
|
|
public string GetStatusText()
|
|
=> $"CS phase={this.Phase}, owner={this.OwnerGuildName ?? "(none)"}, "
|
|
+ $"registered={this._registeredGuilds.Count} [{string.Join(", ", this._registeredGuilds)}], "
|
|
+ $"defenses={this._defensesRemaining}, throne={this._occupier ?? "(none)"}, "
|
|
+ $"switch217={this._switchHolders[217] ?? "-"}, switch218={this._switchHolders[218] ?? "-"}";
|
|
|
|
private void ClearBattleState()
|
|
{
|
|
this._switchHolders[217] = null;
|
|
this._switchHolders[218] = null;
|
|
this._defensesRemaining = 0;
|
|
this._occupier = null;
|
|
this._crownHoldGuild = null;
|
|
this._crownHoldStartUtc = null;
|
|
this._lastShieldDown = false;
|
|
}
|
|
|
|
private ValueTask TransitionAsync(CastleSiegePhase phase, DateTime now)
|
|
{
|
|
this.Phase = phase;
|
|
this._phaseStartedUtc = now;
|
|
this._dirty = true;
|
|
this.PhaseChanged?.Invoke(phase);
|
|
return ValueTask.CompletedTask;
|
|
}
|
|
}
|
|
|
|
/// <summary>The event produced by a single <see cref="CastleSiegeContext.TickCrownHold"/> call.</summary>
|
|
public enum CrownEvent
|
|
{
|
|
/// <summary>Nothing changed this tick.</summary>
|
|
None,
|
|
|
|
/// <summary>A guild master just started holding the crown (start the client countdown).</summary>
|
|
HoldStarted,
|
|
|
|
/// <summary>The crown was held long enough — the guild captured the throne.</summary>
|
|
Captured,
|
|
|
|
/// <summary>An in-progress hold was interrupted (switch lost or master left the crown).</summary>
|
|
HoldReset,
|
|
}
|
|
|
|
/// <summary>The result of a crown-hold tick: the shield state and any event that occurred.</summary>
|
|
/// <param name="ShieldDown">Whether the crown shield is currently down (both switches held, defenses cleared).</param>
|
|
/// <param name="ShieldChanged">Whether the shield state changed this tick (only then should the client be told).</param>
|
|
/// <param name="Event">The event that occurred this tick.</param>
|
|
/// <param name="Guild">The guild the event refers to, if any.</param>
|
|
public readonly record struct CrownTickResult(bool ShieldDown, bool ShieldChanged, CrownEvent Event, string? Guild);
|