diff --git a/src/GameLogic/CastleSiege/CastleSiegeContext.cs b/src/GameLogic/CastleSiege/CastleSiegeContext.cs index 4a5ef2e..1361fb9 100644 --- a/src/GameLogic/CastleSiege/CastleSiegeContext.cs +++ b/src/GameLogic/CastleSiege/CastleSiegeContext.cs @@ -4,9 +4,29 @@ namespace MUnique.OpenMU.GameLogic.CastleSiege; +using MUnique.OpenMU.DataModel.Configuration; + /// -/// In-memory Castle Siege phase state machine and battle contention (P3). +/// 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. @@ -17,96 +37,115 @@ 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 List _registeredGuilds = new(); - private readonly Dictionary _switchHolders = new() { { 217, null }, { 218, null } }; - private DateTime _phaseStartedUtc; - private string? _occupier; + 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 string? _crownHoldGuild; + private Guid? _crownHoldGuild; private DateTime? _crownHoldStartUtc; private bool _lastShieldDown; /// Initializes a new instance of the class. /// The cycle timing configuration. - public CastleSiegeContext(CastleSiegeConfiguration configuration) + public CastleSiegeContext(CastleSiegeSettings configuration) { this.Configuration = configuration; - this.Phase = CastleSiegePhase.Ownership; + this.State = CastleSiegeState.Idle1; } - /// Raised after the phase changes. Argument is the new phase. - public event Action? PhaseChanged; + /// 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 CastleSiegeConfiguration Configuration { get; private set; } + public CastleSiegeSettings Configuration { get; private set; } - /// Points the context at the current (possibly AdminPanel-edited) plugin configuration. - /// The live configuration. - public void UpdateConfiguration(CastleSiegeConfiguration configuration) => this.Configuration = configuration; + /// Gets the current state. + public CastleSiegeState State { get; private set; } - /// Gets the current phase. - public CastleSiegePhase Phase { get; private set; } + /// Gets the UTC time the current state started (used for persistence/restore). + public DateTime StateStartedUtc => this._stateStartedUtc; - /// Gets the UTC time the current phase started (used for persistence/restore). - public DateTime PhaseStartedUtc => this._phaseStartedUtc; + /// Gets the persistent identifier of the owner guild, or if unowned. + public Guid? OwnerGuildId { get; private set; } - /// Gets the current owner guild name, or null if unowned. + /// 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 (P3), or null. - public string? OccupierGuildName => this._occupier; + /// 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 guild names registered for the current cycle. - public IReadOnlyList RegisteredGuilds => this._registeredGuilds; + /// 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.Phase) + switch (this.State) { - case CastleSiegePhase.Ownership: + case CastleSiegeState.Idle1: if (this.Configuration.IsRegistrationOpenTime(now)) { return this.ForceStartRegistrationAsync(now); } break; - case CastleSiegePhase.Registration: - if (now >= this._phaseStartedUtc + this.Configuration.RegistrationDuration) + case CastleSiegeState.RegisterGuild: + if (now >= this._stateStartedUtc + this.Configuration.RegistrationDuration) { - return this.TransitionAsync(CastleSiegePhase.Preparation, now); + return this.TransitionAsync(CastleSiegeState.Ready, now); } break; - case CastleSiegePhase.Preparation: - if (now >= this._phaseStartedUtc + this.Configuration.PreparationDuration) + case CastleSiegeState.Ready: + if (now >= this._stateStartedUtc + this.Configuration.PreparationDuration) { - return this.TransitionAsync(CastleSiegePhase.Siege, now); + return this.TransitionAsync(CastleSiegeState.Start, now); } break; - case CastleSiegePhase.Siege: - if (now >= this._phaseStartedUtc + this.Configuration.SiegeDuration) + case CastleSiegeState.Start: + if (now >= this._stateStartedUtc + this.Configuration.SiegeDuration) { - return this.TransitionAsync(CastleSiegePhase.Settlement, now); + return this.TransitionAsync(CastleSiegeState.End, now); } break; - case CastleSiegePhase.Settlement: + 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.SetOwner(occupier, this._occupierName); } this.ClearBattleState(); - return this.TransitionAsync(CastleSiegePhase.Ownership, now); + return this.TransitionAsync(CastleSiegeState.EndCycle, now); + case CastleSiegeState.EndCycle: + return this.TransitionAsync(CastleSiegeState.Idle1, now); default: break; } @@ -114,62 +153,67 @@ public class CastleSiegeContext return ValueTask.CompletedTask; } - /// Admin: forces the cycle into registration now (from any phase). + /// 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(CastleSiegePhase.Registration, now); + return this.TransitionAsync(CastleSiegeState.RegisterGuild, now); } - /// Admin: forces a specific phase now. - /// The target phase. + /// Admin: forces a specific state now. + /// The target state. /// The current UTC time. - public ValueTask ForcePhaseAsync(CastleSiegePhase phase, DateTime now) - => this.TransitionAsync(phase, now); + public ValueTask ForceStateAsync(CastleSiegeState state, DateTime now) + => this.TransitionAsync(state, now); - /// Admin: resets to the ownership (resting) phase and clears registrations/battle state. + /// 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(CastleSiegePhase.Ownership, now); + return this.TransitionAsync(CastleSiegeState.Idle1, now); } - /// Registers a guild (by name) for the current cycle. No-op outside registration. - /// The guild name. - public void RegisterGuild(string guildName) + /// 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.Phase == CastleSiegePhase.Registration - && !this._registeredGuilds.Contains(guildName)) + if (this.State == CastleSiegeState.RegisterGuild + && this._registeredGuilds.TryAdd(guildId, guildName)) { - this._registeredGuilds.Add(guildName); this._dirty = true; } } - /// Admin: sets (or clears) the current owner guild name. - /// The owner guild name, or null to clear. - public void SetOwner(string? guildName) + /// 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.OwnerGuildName = guildName; + this.OwnerGuildId = guildId; + this.OwnerGuildName = guildId is null ? null : guildName; this._dirty = true; } /// - /// Mirrors the shared castle owner from the configuration. Used on game servers that do NOT host the + /// 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). /// - public void SyncOwnerFromConfig() + /// The owner guild identifier, or null. + /// The owner guild name, or null. + public void SyncOwner(Guid? guildId, string? guildName) { - this.OwnerGuildName = this.Configuration.PersistedOwnerGuildName; + this.OwnerGuildId = guildId; + this.OwnerGuildName = guildId is null ? null : guildName; } /// - /// Sets the weekly auto-schedule (days of week + UTC time) into the configuration and marks the state + /// 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. /// @@ -192,12 +236,36 @@ public class CastleSiegeContext /// The current UTC time. public TimeSpan GetRemainingSiegeTime(DateTime nowUtc) { - if (this.Phase != CastleSiegePhase.Siege) + if (!this.IsSiegeRunning) { return TimeSpan.Zero; } - var remaining = (this._phaseStartedUtc + this.Configuration.SiegeDuration) - nowUtc; + 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; } @@ -205,9 +273,9 @@ public class CastleSiegeContext /// 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 string? GetShieldEligibleGuild() + public Guid? GetShieldEligibleGuild() { - if (this.Phase != CastleSiegePhase.Siege || this._defensesRemaining > 0) + if (!this.IsSiegeRunning || this._defensesRemaining > 0) { return null; } @@ -223,20 +291,21 @@ public class CastleSiegeContext /// 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(string? eligibleGuild, bool masterHolding, DateTime now, TimeSpan holdDuration) + public CrownTickResult TickCrownHold(Guid? eligibleGuild, string? eligibleGuildName, bool masterHolding, DateTime now, TimeSpan holdDuration) { - var shieldDown = this.Phase == CastleSiegePhase.Siege && eligibleGuild is not null; + var shieldDown = this.IsSiegeRunning && eligibleGuild is not null; var shieldChanged = shieldDown != this._lastShieldDown; this._lastShieldDown = shieldDown; - if (this.Phase != CastleSiegePhase.Siege) + if (!this.IsSiegeRunning) { this._crownHoldGuild = null; this._crownHoldStartUtc = null; - return new CrownTickResult(false, shieldChanged, CrownEvent.None, null); + return new CrownTickResult(false, shieldChanged, CrownEvent.None, null, null); } var wasHolding = this._crownHoldGuild is not null; @@ -249,26 +318,27 @@ public class CastleSiegeContext { this._crownHoldGuild = null; this._crownHoldStartUtc = null; - return new CrownTickResult(shieldDown, shieldChanged, wasHolding ? CrownEvent.HoldReset : CrownEvent.None, 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); + 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); + return new CrownTickResult(shieldDown, shieldChanged, CrownEvent.Captured, eligibleGuild, eligibleGuildName); } - return new CrownTickResult(shieldDown, shieldChanged, CrownEvent.None, eligibleGuild); + return new CrownTickResult(shieldDown, shieldChanged, CrownEvent.None, eligibleGuild, eligibleGuildName); } /// @@ -283,22 +353,27 @@ public class CastleSiegeContext } /// - /// Restores persisted state on startup (owner, phase, phase-start, registrations) directly, without - /// firing or marking the state dirty. Battle state stays cleared. + /// 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 name, or null. - /// The persisted phase. - /// When the persisted phase started (UTC), or null to keep the default. - /// The persisted registered guild names, or null. - public void RestoreState(string? owner, CastleSiegePhase phase, DateTime? phaseStartedUtc, IEnumerable? registeredGuilds) + /// 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.OwnerGuildName = owner; - this.Phase = phase; - this._phaseStartedUtc = phaseStartedUtc ?? this._phaseStartedUtc; + 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) { - this._registeredGuilds.AddRange(registeredGuilds); + foreach (var registration in registeredGuilds) + { + this._registeredGuilds[registration.Key] = registration.Value; + } } this._dirty = false; @@ -322,12 +397,12 @@ public class CastleSiegeContext /// 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 name, or null. - public void SetSwitchHolder(short switchNumber, string? guildName) + /// The holding guild's persistent identifier, or null. + public void SetSwitchHolder(short switchNumber, Guid? guildId) { - if (this.Phase == CastleSiegePhase.Siege && this._switchHolders.ContainsKey(switchNumber)) + if (this.IsSiegeRunning && this._switchHolders.ContainsKey(switchNumber)) { - this._switchHolders[switchNumber] = guildName; + this._switchHolders[switchNumber] = guildId; } } @@ -336,20 +411,21 @@ public class CastleSiegeContext /// 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 name. + /// 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(string guildName) + public (bool Success, string Reason) TryCaptureThrone(Guid guildId, string guildName) { - if (this.Phase != CastleSiegePhase.Siege) + if (!this.IsSiegeRunning) { return (false, "The siege is not running."); } - if (this._occupier is not null) + if (this._occupier is { } occupier) { - return (false, this._occupier == guildName + return (false, occupier == guildId ? "Your guild already holds the throne." - : $"The throne is already held by '{this._occupier}'."); + : $"The throne is already held by '{this._occupierName ?? occupier.ToString()}'."); } if (this._defensesRemaining > 0) @@ -357,21 +433,28 @@ public class CastleSiegeContext return (false, $"Destroy the castle defenses first ({this._defensesRemaining} remaining)."); } - if (this._switchHolders[217] != guildName || this._switchHolders[218] != guildName) + 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 = guildName; + 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 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] ?? "-"}"; + => $"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() { @@ -379,17 +462,18 @@ public class CastleSiegeContext 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(CastleSiegePhase phase, DateTime now) + private ValueTask TransitionAsync(CastleSiegeState state, DateTime now) { - this.Phase = phase; - this._phaseStartedUtc = now; + this.State = state; + this._stateStartedUtc = now; this._dirty = true; - this.PhaseChanged?.Invoke(phase); + this.StateChanged?.Invoke(state); return ValueTask.CompletedTask; } } @@ -414,5 +498,6 @@ public enum CrownEvent /// 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. -public readonly record struct CrownTickResult(bool ShieldDown, bool ShieldChanged, CrownEvent Event, string? Guild); +/// 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); diff --git a/src/GameLogic/CastleSiege/CastleSiegeGuardsmanTalkPlugIn.cs b/src/GameLogic/CastleSiege/CastleSiegeGuardsmanTalkPlugIn.cs index a9e5a48..c212ac7 100644 --- a/src/GameLogic/CastleSiege/CastleSiegeGuardsmanTalkPlugIn.cs +++ b/src/GameLogic/CastleSiege/CastleSiegeGuardsmanTalkPlugIn.cs @@ -49,7 +49,7 @@ public class CastleSiegeGuardsmanTalkPlugIn : IPlayerTalkToNpcPlugIn return; } - if (context.Phase != CastleSiegePhase.Registration) + if (context.State != CastleSiegeState.RegisterGuild) { await ShowAsync(player, "Castle Siege registration is not open right now.").ConfigureAwait(false); return; @@ -61,17 +61,15 @@ public class CastleSiegeGuardsmanTalkPlugIn : IPlayerTalkToNpcPlugIn return; } - var guildName = guildStatus.GuildId.ToString(); - if (player.GameContext is IGameServerContext serverContext) + // Registrations are keyed on the guild's persistent id, so a later rename cannot detach them. + if (await CastleSiegeEventPlugIn.GetPersistentGuildAsync(player).ConfigureAwait(false) is not { } guild) { - var guild = await serverContext.GuildServer.GetGuildAsync(guildStatus.GuildId).ConfigureAwait(false); - if (guild?.Name is { Length: > 0 } name) - { - guildName = name; - } + await ShowAsync(player, "Your guild could not be resolved. Please try again in a moment.").ConfigureAwait(false); + return; } - if (context.RegisteredGuilds.Contains(guildName)) + var guildName = guild.Name; + if (context.IsRegistered(guild.Id)) { await ShowAsync(player, $"Your guild '{guildName}' is already registered for the Castle Siege.").ConfigureAwait(false); return; @@ -84,7 +82,7 @@ public class CastleSiegeGuardsmanTalkPlugIn : IPlayerTalkToNpcPlugIn return; } - context.RegisterGuild(guildName); + context.RegisterGuild(guild.Id, guildName); await ShowAsync(player, fee > 0 ? $"Your guild '{guildName}' is registered for the Castle Siege. ({fee} zen paid)" : $"Your guild '{guildName}' is registered for the Castle Siege.").ConfigureAwait(false); diff --git a/src/GameLogic/CastleSiege/CastleSiegePhase.cs b/src/GameLogic/CastleSiege/CastleSiegePhase.cs deleted file mode 100644 index 0317868..0000000 --- a/src/GameLogic/CastleSiege/CastleSiegePhase.cs +++ /dev/null @@ -1,26 +0,0 @@ -// -// Licensed under the MIT License. See LICENSE file in the project root for full license information. -// - -namespace MUnique.OpenMU.GameLogic.CastleSiege; - -/// -/// The phases of a Castle Siege cycle. -/// -public enum CastleSiegePhase -{ - /// Resting phase: castle is (un)owned, waiting for the next registration window. - Ownership, - - /// Guilds can register to attack. - Registration, - - /// Registration closed; defenders prepare before the siege starts. - Preparation, - - /// The siege battle is running. - Siege, - - /// Siege ended; determining the new owner. - Settlement, -} diff --git a/src/GameLogic/CastleSiege/CastleSiegeConfiguration.cs b/src/GameLogic/CastleSiege/CastleSiegeSettings.cs similarity index 72% rename from src/GameLogic/CastleSiege/CastleSiegeConfiguration.cs rename to src/GameLogic/CastleSiege/CastleSiegeSettings.cs index dfe54a0..a7bd6f4 100644 --- a/src/GameLogic/CastleSiege/CastleSiegeConfiguration.cs +++ b/src/GameLogic/CastleSiege/CastleSiegeSettings.cs @@ -1,4 +1,4 @@ -// +// // Licensed under the MIT License. See LICENSE file in the project root for full license information. // @@ -7,15 +7,31 @@ namespace MUnique.OpenMU.GameLogic.CastleSiege; using System.ComponentModel; using System.Linq; using System.Text.Json.Serialization; +using MUnique.OpenMU.DataModel.Configuration; /// -/// Configuration for the Castle Siege cycle. Rides on the plugin custom-configuration system (no dedicated -/// database table). A cycle runs: Ownership -> Registration -> Preparation -> Siege(war) -> Settlement, and -/// auto-starts when the current day/time matches + . -/// The AdminPanel-friendly properties (OpenDays checkboxes, minute/second durations) are proxies over the -/// runtime fields, which are hidden from the editor to keep the form clean. +/// AdaMu operational settings for the Castle Siege cycle. Rides on the plugin custom-configuration system, so +/// it is editable in the AdminPanel and needs no dedicated database table. +/// +/// This is deliberately separate from , which is +/// the upstream, database-backed configuration holding the NPC/zone/upgrade definitions and the crown hold +/// time. Keeping AdaMu's operational knobs out of that entity means upstream schema changes apply cleanly and +/// no hand-editing of the generated persistence code is needed. +/// +/// +/// What lives where: +/// +/// Castle owner and guild registrations: database (CastleSiegeData, CastleSiegeGuildRegistration). +/// NPC/zone/upgrade definitions and crown hold time: database (GameConfiguration.CastleSiegeConfiguration). +/// Cycle durations, registration fee, designated server and the current state: here. +/// +/// +/// A cycle runs Idle1 -> RegisterGuild -> Ready -> Start -> End -> EndCycle -> Idle1, and auto-starts when the +/// current day/time matches + . +/// The AdminPanel-friendly properties (OpenDays checkboxes, minute durations) are proxies over the runtime +/// fields, which are hidden from the editor to keep the form clean. /// -public class CastleSiegeConfiguration +public class CastleSiegeSettings { /// /// Gets or sets the days of week on which registration auto-opens (UTC). None = every day (still needs a @@ -42,7 +58,7 @@ public class CastleSiegeConfiguration /// /// Gets or sets the times of day (UTC) at which a new cycle opens registration. - /// Empty = no auto-start (admins start cycles manually via the chat command). + /// Empty = no auto-start (admins start cycles manually via the chat command or the AdminPanel). /// public IList RegistrationOpenTimes { get; set; } = new List(); @@ -70,17 +86,6 @@ public class CastleSiegeConfiguration set => this.SiegeDuration = TimeSpan.FromMinutes(Math.Max(1, value)); } - /// - /// Gets or sets how long the guild master must hold the Crown to capture the throne, in seconds. - /// The client shows a 60-second countdown, so 60 matches the on-screen timer. - /// - [JsonIgnore] - public int CrownHoldSeconds - { - get => (int)this.CrownHoldDuration.TotalSeconds; - set => this.CrownHoldDuration = TimeSpan.FromSeconds(Math.Max(1, value)); - } - /// /// Gets or sets the registration fee (in zen) a guild master must pay to register the guild /// for the siege. 0 disables the fee. @@ -112,29 +117,17 @@ public class CastleSiegeConfiguration [Browsable(false)] public TimeSpan SiegeDuration { get; set; } = TimeSpan.FromMinutes(10); - /// Gets or sets how long the guild master must hold the Crown to capture the throne. - [Browsable(false)] - public TimeSpan CrownHoldDuration { get; set; } = TimeSpan.FromSeconds(60); + // --- 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 + // owner and the guild registrations live in real database tables, so they are not duplicated here. - // --- Persisted runtime state (hidden from the AdminPanel) --- - // These ride on the plugin's custom-configuration JSON (stored in PostgreSQL), so the castle owner and the - // current cycle survive server restarts. Written by CastleSiegeEventPlugIn; restored on startup. - - /// Gets or sets the persisted castle owner guild name (null = unowned). + /// Gets or sets the persisted current state, so the cycle resumes after a restart. [Browsable(false)] - public string? PersistedOwnerGuildName { get; set; } + public CastleSiegeState PersistedState { get; set; } = CastleSiegeState.Idle1; - /// Gets or sets the persisted current phase, so the cycle resumes after a restart. + /// Gets or sets when the persisted state started (UTC), or null if never persisted. [Browsable(false)] - public CastleSiegePhase PersistedPhase { get; set; } = CastleSiegePhase.Ownership; - - /// Gets or sets when the persisted phase started (UTC), or null if never persisted. - [Browsable(false)] - public DateTime? PersistedPhaseStartedUtc { get; set; } - - /// Gets or sets the persisted registered guild names for the current cycle. - [Browsable(false)] - public IList PersistedRegisteredGuilds { get; set; } = new List(); + public DateTime? PersistedStateStartedUtc { get; set; } /// /// Returns true if (UTC) matches a scheduled registration-open day and falls diff --git a/src/GameLogic/CastleSiege/CastleSiegeThroneCaptureTalkPlugIn.cs b/src/GameLogic/CastleSiege/CastleSiegeThroneCaptureTalkPlugIn.cs index 891af70..b6fca0b 100644 --- a/src/GameLogic/CastleSiege/CastleSiegeThroneCaptureTalkPlugIn.cs +++ b/src/GameLogic/CastleSiege/CastleSiegeThroneCaptureTalkPlugIn.cs @@ -43,35 +43,25 @@ public class CastleSiegeThroneCaptureTalkPlugIn : IPlayerTalkToNpcPlugIn return; } - if (player.GuildStatus is not { } guildStatus) + if (await CastleSiegeEventPlugIn.GetPersistentGuildAsync(player).ConfigureAwait(false) is not { } guild) { await ShowAsync(player, "Only members of a registered guild can take the throne.").ConfigureAwait(false); return; } - var guildName = guildStatus.GuildId.ToString(); - if (player.GameContext is IGameServerContext serverContext) - { - var guild = await serverContext.GuildServer.GetGuildAsync(guildStatus.GuildId).ConfigureAwait(false); - if (guild?.Name is { Length: > 0 } name) - { - guildName = name; - } - } - - if (!context.RegisteredGuilds.Contains(guildName)) + if (!context.IsRegistered(guild.Id)) { await ShowAsync(player, "Your guild is not registered for this Castle Siege.").ConfigureAwait(false); return; } // The throne is taken by holding the Crown, not by talking here — give guidance based on the state. - await ShowAsync(player, DescribeThroneStep(context, guildName)).ConfigureAwait(false); + await ShowAsync(player, DescribeThroneStep(context, guild.Id)).ConfigureAwait(false); } - private static string DescribeThroneStep(CastleSiegeContext context, string guildName) + private static string DescribeThroneStep(CastleSiegeContext context, Guid guildId) { - if (context.Phase != CastleSiegePhase.Siege) + if (!context.IsSiegeRunning) { return "The siege is not running yet."; } @@ -87,12 +77,12 @@ public class CastleSiegeThroneCaptureTalkPlugIn : IPlayerTalkToNpcPlugIn return "All gates are down! Hold BOTH Crown Switches with your guild — the Crown's shield will drop."; } - if (eligible == guildName) + 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 $"Guild '{eligible}' 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."; } private static ValueTask ShowAsync(Player player, string text) diff --git a/src/GameLogic/Player.cs b/src/GameLogic/Player.cs index 64a7da6..5b9928b 100644 --- a/src/GameLogic/Player.cs +++ b/src/GameLogic/Player.cs @@ -717,7 +717,7 @@ public class Player : AsyncDisposable, IBucketMapObserver, IAttackable, IAttacke // ADAMU-CUSTOM: Castle Siege PvP gate — true while the siege phase runs on Valley of Loren (map 30). private bool IsCastleSiegeBattleActive() => this.CurrentMap?.Definition.Number == 30 - && PlugIns.PeriodicTasks.CastleSiegeEventPlugIn.TryGetContext(this.GameContext)?.Phase == CastleSiege.CastleSiegePhase.Siege; + && PlugIns.PeriodicTasks.CastleSiegeEventPlugIn.TryGetContext(this.GameContext)?.IsSiegeRunning == true; /// public async ValueTask AttackByAsync(IAttacker attacker, SkillEntry? skill, bool isCombo, double damageFactor = 1.0, bool? isFinalStreakHit = null) diff --git a/src/GameLogic/PlugIns/ChatCommands/CastleSiegePhaseChatCommandPlugIn.cs b/src/GameLogic/PlugIns/ChatCommands/CastleSiegePhaseChatCommandPlugIn.cs index 3ea4095..588fff7 100644 --- a/src/GameLogic/PlugIns/ChatCommands/CastleSiegePhaseChatCommandPlugIn.cs +++ b/src/GameLogic/PlugIns/ChatCommands/CastleSiegePhaseChatCommandPlugIn.cs @@ -30,9 +30,11 @@ public class CastleSiegePhaseChatCommandPlugIn : IChatCommandPlugIn public async ValueTask HandleCommandAsync(Player player, string command) { var parts = command.Split(' ', StringSplitOptions.RemoveEmptyEntries); - if (parts.Length < 2 || !Enum.TryParse(parts[1], true, out var phase)) + // The cycle uses the original Season 6 state names the client knows. AdaMu drives only this subset; + // Idle2, RegisterMark, Idle3 and Notify exist for client compatibility but are never entered. + if (parts.Length < 2 || !Enum.TryParse(parts[1], true, out var phase)) { - await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync("Usage: /csphase ", MessageType.BlueNormal)).ConfigureAwait(false); + await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync("Usage: /csphase ", MessageType.BlueNormal)).ConfigureAwait(false); return; } @@ -43,7 +45,7 @@ public class CastleSiegePhaseChatCommandPlugIn : IChatCommandPlugIn return; } - await context.ForcePhaseAsync(phase, DateTime.UtcNow).ConfigureAwait(false); + await context.ForceStateAsync(phase, DateTime.UtcNow).ConfigureAwait(false); await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync($"Castle Siege: phase set to {phase}.", MessageType.BlueNormal)).ConfigureAwait(false); } } diff --git a/src/GameLogic/PlugIns/ChatCommands/CastleSiegeSetOwnerChatCommandPlugIn.cs b/src/GameLogic/PlugIns/ChatCommands/CastleSiegeSetOwnerChatCommandPlugIn.cs index 5f1e4e6..e389b93 100644 --- a/src/GameLogic/PlugIns/ChatCommands/CastleSiegeSetOwnerChatCommandPlugIn.cs +++ b/src/GameLogic/PlugIns/ChatCommands/CastleSiegeSetOwnerChatCommandPlugIn.cs @@ -38,7 +38,22 @@ public class CastleSiegeSetOwnerChatCommandPlugIn : IChatCommandPlugIn return; } - context.SetOwner(string.IsNullOrWhiteSpace(owner) ? null : owner); - await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync($"Castle Siege owner set to {owner ?? "(none)"}.", MessageType.BlueNormal)).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(owner)) + { + context.SetOwner(null, null); + await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync("Castle Siege owner cleared.", MessageType.BlueNormal)).ConfigureAwait(false); + return; + } + + // Ownership is stored by the guild's persistent id, so the name given here is resolved once. + var guildId = await CastleSiegeEventPlugIn.ResolveGuildIdByNameAsync(player.GameContext, owner).ConfigureAwait(false); + if (guildId is not { } id) + { + await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync($"No guild named '{owner}' was found.", MessageType.BlueNormal)).ConfigureAwait(false); + return; + } + + context.SetOwner(id, owner); + await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync($"Castle Siege owner set to {owner}.", MessageType.BlueNormal)).ConfigureAwait(false); } } diff --git a/src/GameLogic/PlugIns/PeriodicTasks/CastleSiegeEventPlugIn.cs b/src/GameLogic/PlugIns/PeriodicTasks/CastleSiegeEventPlugIn.cs index 93c65d3..486c222 100644 --- a/src/GameLogic/PlugIns/PeriodicTasks/CastleSiegeEventPlugIn.cs +++ b/src/GameLogic/PlugIns/PeriodicTasks/CastleSiegeEventPlugIn.cs @@ -7,6 +7,7 @@ namespace MUnique.OpenMU.GameLogic.PlugIns.PeriodicTasks; using System.Collections.Concurrent; using System.Linq; using System.Runtime.InteropServices; +using MUnique.OpenMU.DataModel.Configuration; using MUnique.OpenMU.GameLogic.CastleSiege; using MUnique.OpenMU.GameLogic.NPC; using MUnique.OpenMU.GameLogic.Views; @@ -15,65 +16,50 @@ using MUnique.OpenMU.Interfaces; using MUnique.OpenMU.Pathfinding; using MUnique.OpenMU.Persistence; using MUnique.OpenMU.PlugIns; +using CastleSiegeDefinition = MUnique.OpenMU.DataModel.Configuration.CastleSiegeConfiguration; /// -/// Drives the Castle Siege phase state machine: ticks it every second and carries its configuration. -/// State is per- and kept in memory (P1: no persistence). -/// When the siege phase starts (P3), warps registered guild members to the Valley of Loren battle map. +/// Drives the Castle Siege state machine: ticks it every second and carries its operational settings. +/// +/// The cycle uses the original Season 6 state numbers the client expects. The castle owner and the guild +/// registrations are persisted in real database tables ( and +/// ) and keyed by the guild's persistent , so a +/// guild rename can no longer move castle ownership to the wrong guild. Only the current state and when it +/// started ride on the plugin's custom-configuration JSON. +/// +/// +/// Castle NPCs (gates, statues, catapults, the crown and its switches) are read from +/// , which the CastleSiegeInitializer seeds, instead +/// of being hard-coded here. +/// +/// When the siege starts, registered guild members are warped to the Valley of Loren battle map. /// [PlugIn] -[Display(Name = nameof(CastleSiegeEventPlugIn), Description = "Castle Siege event (phase state machine, scheduling, siege warp).")] +[Display(Name = nameof(CastleSiegeEventPlugIn), Description = "Castle Siege event (state machine, scheduling, siege warp).")] [Guid("6E2C8B41-9A4D-4C2E-9E7B-1F2A3B4C5D60")] -public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustomConfiguration, ISupportDefaultCustomConfiguration +public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustomConfiguration, ISupportDefaultCustomConfiguration { private const ushort ValleyOfLorenMapNumber = 30; - private const short CastleGateNumber = 277; // client MONSTER_CASTLE_GATE1 -> renders a real gate + blocks terrain until broken - private const short GateTemplateNumber = 131; // BloodCastle "Castle Gate" destructible (has HP) — HP template for 277 + private const short GateTemplateNumber = 131; // BloodCastle "Castle Gate" destructible (has HP) — HP template for the CS gates. + private const short CrownNumber = 216; + private const short CatapultAttackNumber = 221; // client MONSTER_SLINGSHOT_ATTACK + private const short CatapultDefenseNumber = 222; // client MONSTER_SLINGSHOT_DEFENSE private const int SwitchHoldRange = 3; - - // Castle defenses spawned at siege start — the 6 REAL castle gates (client g_byGateLocation). - // The client renders them closed and blocks the terrain until each is broken. All must fall before the throne. - private static readonly (short Number, byte X, byte Y)[] DefenseSpawns = - { - (CastleGateNumber, 67, 114), - (CastleGateNumber, 93, 114), - (CastleGateNumber, 119, 114), - (CastleGateNumber, 81, 161), - (CastleGateNumber, 107, 161), - (CastleGateNumber, 93, 204), - }; - - // Siege weapons spawned at siege start purely for war atmosphere. The client renders monster 221/222 as - // catapults (attacker/defender). They are NOT counted as defenses (destroying them doesn't open the throne). - private const short CatapultAttackNumber = 221; // client MONSTER_SLINGSHOT_ATTACK - private const short CatapultDefenseNumber = 222; // client MONSTER_SLINGSHOT_DEFENSE - private static readonly (short Number, byte X, byte Y)[] CatapultSpawns = - { - (CatapultDefenseNumber, 80, 140), - (CatapultDefenseNumber, 110, 140), - (CatapultDefenseNumber, 93, 178), - (CatapultAttackNumber, 74, 100), - (CatapultAttackNumber, 112, 100), - }; - - // Crown Switch positions on Valley of Loren (from the map init) — held by standing on them: (number, x, y). - private static readonly (short SwitchNumber, byte X, byte Y)[] SwitchPositions = - { - (217, 167, 194), - (218, 184, 195), - }; - - // The Crown (NPC 216) position on Valley of Loren — the guild master holds it here to capture the throne. - private static readonly Point CrownPosition = new(176, 212); private const int CrownHoldRange = 4; private static readonly ConcurrentDictionary Contexts = new(); - private string? _cachedFlagOwner; + /// + /// 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. + /// + private static readonly ConcurrentDictionary PersistentGuildIds = new(); + + private Guid? _cachedFlagOwner; private byte[]? _cachedFlagLogo; /// - public CastleSiegeConfiguration? Configuration { get; set; } + public CastleSiegeSettings? Configuration { get; set; } /// /// Gets the Castle Siege context for a game context, if the periodic tick has initialized it. @@ -99,41 +85,80 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom return serverContext.Id == context.Configuration.CastleSiegeServerId; } + /// + /// Resolves the persistent identifier of the player's guild, or when the player is + /// not in a guild or the guild cannot be resolved. + /// + /// + /// deliberately carries no id: the guild server assigns short ids in memory + /// only. The persistent is therefore resolved through the guild name and cached, which + /// avoids adding a method to that upstream would keep changing. + /// + /// The player. + public static async ValueTask<(Guid Id, string Name)?> GetPersistentGuildAsync(Player player) + { + if (player.GuildStatus is not { } guildStatus + || player.GameContext is not IGameServerContext serverContext) + { + return null; + } + + var guild = await serverContext.GuildServer.GetGuildAsync(guildStatus.GuildId).ConfigureAwait(false); + if (guild?.Name is not { Length: > 0 } name) + { + return null; + } + + if (PersistentGuildIds.TryGetValue(guildStatus.GuildId, out var cached)) + { + return (cached, name); + } + + var resolved = await ResolveGuildIdByNameAsync(player.GameContext, name).ConfigureAwait(false); + if (resolved is not { } id) + { + return null; + } + + PersistentGuildIds[guildStatus.GuildId] = id; + return (id, name); + } + /// - public object CreateDefaultConfig() => new CastleSiegeConfiguration(); + public object CreateDefaultConfig() => new CastleSiegeSettings(); /// public async ValueTask ExecuteTaskAsync(GameContext gameContext) { var context = Contexts.GetOrAdd(gameContext, gc => { - var config = this.Configuration ?? new CastleSiegeConfiguration(); - var created = new CastleSiegeContext(config); + var settings = this.Configuration ?? new CastleSiegeSettings(); + var created = new CastleSiegeContext(settings); - // Restore state persisted before the last restart (owner/phase/registrations) BEFORE subscribing, - // so restoring doesn't announce phases or re-spawn defenses. - created.RestoreState(config.PersistedOwnerGuildName, config.PersistedPhase, config.PersistedPhaseStartedUtc, config.PersistedRegisteredGuilds); + // Restore the cycle bookkeeping BEFORE subscribing, so restoring doesn't announce states or + // re-spawn defenses. The owner and registrations are loaded from the database right after. + created.RestoreState(null, null, settings.PersistedState, settings.PersistedStateStartedUtc, null); + _ = LoadPersistedStateAsync(gc, created); - // Announce phase changes to the whole server and, when the siege begins, warp registered members. - created.PhaseChanged += phase => _ = OnPhaseChangedAsync(gc, created, phase); + created.StateChanged += state => _ = OnStateChangedAsync(gc, created, state); return created; }); - // Point the context at the current (possibly AdminPanel-edited) config so schedule/durations are live. - if (this.Configuration is { } liveConfig) + // Point the context at the current (possibly AdminPanel-edited) settings so schedule/durations are live. + if (this.Configuration is { } liveSettings) { - context.UpdateConfiguration(liveConfig); + context.UpdateConfiguration(liveSettings); } // With multiple game servers each has its own map instances, so the siege must run on ONE designated // server (CastleSiegeServerId). Other servers skip the siege entirely — they only mirror the shared - // castle owner from the config 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)) { - context.SyncOwnerFromConfig(); if (DateTime.UtcNow.Second % 15 == 0) { + await LoadPersistedStateAsync(gameContext, context).ConfigureAwait(false); await this.BroadcastCastleFlagAsync(gameContext, context).ConfigureAwait(false); } @@ -143,18 +168,18 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom await context.TickAsync(DateTime.UtcNow).ConfigureAwait(false); // During the siege, evaluate the Crown Switches (held by standing on them) and the throne capture every tick. - if (context.Phase == CastleSiegePhase.Siege) + if (context.IsSiegeRunning) { await ProcessSiegeTickAsync(gameContext, context).ConfigureAwait(false); } - // Persist owner/phase/registrations to the database whenever they changed, so they survive a restart. + // Persist owner/state/registrations whenever they changed, so they survive a restart. if (context.ConsumeDirty()) { await this.PersistStateAsync(gameContext, context).ConfigureAwait(false); } - // Keep the owner guild's logo painted on the castle flags for anyone on the castle map (any phase). + // 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) { await this.BroadcastCastleFlagAsync(gameContext, context).ConfigureAwait(false); @@ -171,25 +196,95 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom } } - private static async Task OnPhaseChangedAsync(IGameContext gameContext, CastleSiegeContext context, CastleSiegePhase phase) + /// Gets the seeded Castle Siege definition, or when it was not initialized. + /// The game context. + private static CastleSiegeDefinition? GetDefinition(IGameContext gameContext) + => gameContext.Configuration.CastleSiegeConfiguration; + + /// Resolves a guild's persistent identifier from its name, or null when there is no such guild. + /// The game context. + /// The guild name. + internal static async ValueTask ResolveGuildIdByNameAsync(IGameContext gameContext, string guildName) { try { - switch (phase) + using var context = gameContext.PersistenceContextProvider.CreateNewTypedContext(typeof(DataModel.Entities.Guild), false, gameContext.Configuration); + var guilds = await context.GetAsync().ConfigureAwait(false); + return guilds.FirstOrDefault(guild => guild.Name == guildName)?.Id; + } + catch (Exception ex) + { + gameContext.LoggerFactory.CreateLogger() + .LogError(ex, "Castle Siege: could not resolve the persistent id of guild '{guildName}'.", guildName); + return null; + } + } + + private static async ValueTask ResolveGuildNameByIdAsync(IGameContext gameContext, Guid guildId) + { + try + { + using var context = gameContext.PersistenceContextProvider.CreateNewTypedContext(typeof(DataModel.Entities.Guild), false, gameContext.Configuration); + var guild = await context.GetByIdAsync(guildId).ConfigureAwait(false); + return guild?.Name; + } + catch (Exception ex) + { + gameContext.LoggerFactory.CreateLogger() + .LogError(ex, "Castle Siege: could not resolve the name of guild {guildId}.", guildId); + return null; + } + } + + /// Loads the persisted castle owner and guild registrations from the database into the context. + private static async ValueTask LoadPersistedStateAsync(IGameContext gameContext, CastleSiegeContext context) + { + try + { + using var dataContext = gameContext.PersistenceContextProvider.CreateNewTypedContext(typeof(CastleSiegeData), false, gameContext.Configuration); + var data = (await dataContext.GetAsync().ConfigureAwait(false)).FirstOrDefault(); + + Guid? ownerId = data?.IsOccupied == true ? data.OwnerGuildId : null; + var ownerName = ownerId is { } id ? await ResolveGuildNameByIdAsync(gameContext, id).ConfigureAwait(false) : null; + + using var registrationContext = gameContext.PersistenceContextProvider.CreateNewTypedContext(typeof(CastleSiegeGuildRegistration), false, gameContext.Configuration); + var registrations = await registrationContext.GetAsync().ConfigureAwait(false); + + var restored = new List>(); + foreach (var registration in registrations) { - case CastleSiegePhase.Registration: + var name = await ResolveGuildNameByIdAsync(gameContext, registration.GuildId).ConfigureAwait(false); + restored.Add(new KeyValuePair(registration.GuildId, name ?? registration.GuildId.ToString())); + } + + context.RestoreState(ownerId, ownerName, context.State, context.StateStartedUtc, restored); + } + catch (Exception ex) + { + gameContext.LoggerFactory.CreateLogger() + .LogError(ex, "Castle Siege: error while loading the persisted state."); + } + } + + private static async Task OnStateChangedAsync(IGameContext gameContext, CastleSiegeContext context, CastleSiegeState state) + { + try + { + switch (state) + { + case CastleSiegeState.RegisterGuild: await AnnounceAsync(gameContext, "Castle Siege registration is now open! Guild masters, register at the Guardsman in the Valley of Loren.").ConfigureAwait(false); break; - case CastleSiegePhase.Siege: + case CastleSiegeState.Start: await AnnounceAsync(gameContext, "The Castle Siege has begun! Break the castle gates and guardian statues, then hold BOTH Crown Switches to take the throne!").ConfigureAwait(false); await SpawnCastleDefensesAsync(gameContext, context).ConfigureAwait(false); await WarpRegisteredMembersToSiegeAsync(gameContext, context).ConfigureAwait(false); break; - case CastleSiegePhase.Settlement: + case CastleSiegeState.End: // Stop the on-map countdown for everyone still on the battle map. await BroadcastSiegeStateAsync(gameContext, false, 0, 0).ConfigureAwait(false); break; - case CastleSiegePhase.Ownership 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); break; default: @@ -199,52 +294,7 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom catch (Exception ex) { gameContext.LoggerFactory.CreateLogger() - .LogError(ex, "Castle Siege: error handling phase change to {phase}.", phase); - } - } - - private async ValueTask PersistStateAsync(GameContext gameContext, CastleSiegeContext context) - { - try - { - if (this.Configuration is not { } config) - { - return; - } - - // Snapshot the live context into the persisted config fields. - config.PersistedOwnerGuildName = context.OwnerGuildName; - config.PersistedPhase = context.Phase; - config.PersistedPhaseStartedUtc = context.PhaseStartedUtc; - config.PersistedRegisteredGuilds = context.RegisteredGuilds.ToList(); - - // Find our plugin-configuration row via the in-memory config graph to get its id. - var pluginTypeId = typeof(CastleSiegeEventPlugIn).GUID; - var inMemory = gameContext.Configuration.PlugInConfigurations.FirstOrDefault(c => c.TypeId == pluginTypeId); - if (inMemory is null) - { - return; - } - - // Load a fresh, change-tracked copy of that row in its own (non-caching) context, rewrite its - // custom-configuration JSON, and save — this is what actually persists to PostgreSQL. - using var ctx = gameContext.PersistenceContextProvider.CreateNewTypedContext(typeof(PlugInConfiguration), false, gameContext.Configuration); - var row = await ctx.GetByIdAsync(inMemory.GetId()).ConfigureAwait(false); - if (row is null) - { - return; - } - - row.SetConfiguration(config, gameContext.PlugInManager.CustomConfigReferenceHandler); - await ctx.SaveChangesAsync().ConfigureAwait(false); - - gameContext.LoggerFactory.CreateLogger() - .LogInformation("Castle Siege: persisted state (owner={owner}, phase={phase}).", config.PersistedOwnerGuildName ?? "(none)", config.PersistedPhase); - } - catch (Exception ex) - { - gameContext.LoggerFactory.CreateLogger() - .LogError(ex, "Castle Siege: error while persisting state to the database."); + .LogError(ex, "Castle Siege: error handling state change to {state}.", state); } } @@ -269,52 +319,36 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom } } + /// + /// Spawns the castle defenses from the seeded NPC definitions. Definitions flagged + /// are the breakable defenses (gates and + /// guardian statues) and are counted towards the throne; the catapults are pure war atmosphere. + /// private static async Task SpawnCastleDefensesAsync(IGameContext gameContext, CastleSiegeContext context) { try { - if (gameContext is not GameContext concrete) - { - context.SetDefenseCount(0); - return; - } - - var map = await gameContext.GetMapAsync(ValleyOfLorenMapNumber).ConfigureAwait(false); - if (map is null) + if (gameContext is not GameContext concrete + || GetDefinition(gameContext) is not { } definition + || await gameContext.GetMapAsync(ValleyOfLorenMapNumber).ConfigureAwait(false) is not { } map) { context.SetDefenseCount(0); return; } + var template = gameContext.Configuration.Monsters.FirstOrDefault(m => m.Number == GateTemplateNumber); var spawned = 0; - for (var i = 0; i < DefenseSpawns.Length; i++) + var index = 0; + foreach (var npc in definition.NpcDefinitions.Where(n => n.IsPersistedToDatabase && n.MonsterDefinition is not null)) { - var spawn = DefenseSpawns[i]; - var definition = gameContext.Configuration.Monsters.FirstOrDefault(m => m.Number == spawn.Number); - if (definition is null) - { - continue; - } - - // The stock CS gate (277) definition has no HP and isn't destructible; make it breakable + // The stock CS gate/statue definitions have no HP and aren't destructible; make them breakable // by borrowing the HP/attributes of a working destructible (BloodCastle gate 131). - var template = gameContext.Configuration.Monsters.FirstOrDefault(m => m.Number == GateTemplateNumber); - EnsureDestructible(definition, template); + EnsureDestructible(npc.MonsterDefinition!, template); - var spawnArea = new MonsterSpawnArea - { - MonsterDefinition = definition, - Quantity = 1, - X1 = spawn.X, - X2 = spawn.X, - Y1 = spawn.Y, - Y2 = spawn.Y, - Direction = Direction.South, - SpawnTrigger = SpawnTrigger.OnceAtEventStart, - }; - - var npc = await concrete.MapInitializer.InitializeSpawnAsync(1000 + i, map, spawnArea).ConfigureAwait(false); - if (npc is AttackableNpcBase attackable) + var monster = await concrete.MapInitializer + .InitializeSpawnAsync(1000 + index++, map, CreateSpawnArea(npc)) + .ConfigureAwait(false); + if (monster is AttackableNpcBase attackable) { attackable.Died += (_, _) => context.NotifyDefenseDestroyed(); spawned++; @@ -323,29 +357,12 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom context.SetDefenseCount(spawned); - // Spawn the catapults (siege weapons) for war atmosphere — not counted as defenses. - for (var i = 0; i < CatapultSpawns.Length; i++) + index = 0; + foreach (var npc in definition.NpcDefinitions.Where(IsCatapult)) { - var spawn = CatapultSpawns[i]; - var definition = gameContext.Configuration.Monsters.FirstOrDefault(m => m.Number == spawn.Number); - if (definition is null) - { - continue; - } - - var spawnArea = new MonsterSpawnArea - { - MonsterDefinition = definition, - Quantity = 1, - X1 = spawn.X, - X2 = spawn.X, - Y1 = spawn.Y, - Y2 = spawn.Y, - Direction = Direction.South, - SpawnTrigger = SpawnTrigger.OnceAtEventStart, - }; - - await concrete.MapInitializer.InitializeSpawnAsync(2000 + i, map, spawnArea).ConfigureAwait(false); + await concrete.MapInitializer + .InitializeSpawnAsync(2000 + index++, map, CreateSpawnArea(npc)) + .ConfigureAwait(false); } } catch (Exception ex) @@ -356,27 +373,53 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom } } + private static bool IsCatapult(CastleSiegeNpcDefinition npc) + => npc.MonsterDefinition is { } monster + && (monster.Number == CatapultAttackNumber || monster.Number == CatapultDefenseNumber); + + private static MonsterSpawnArea CreateSpawnArea(CastleSiegeNpcDefinition npc) => new() + { + MonsterDefinition = npc.MonsterDefinition, + Quantity = 1, + X1 = npc.SpawnX, + X2 = npc.SpawnX, + Y1 = npc.SpawnY, + Y2 = npc.SpawnY, + Direction = npc.Direction, + SpawnTrigger = SpawnTrigger.OnceAtEventStart, + }; + + private static Point? GetNpcPosition(IGameContext gameContext, short monsterNumber) + { + var npc = GetDefinition(gameContext)?.NpcDefinitions + .FirstOrDefault(n => n.MonsterDefinition?.Number == monsterNumber); + return npc is null ? null : new Point(npc.SpawnX, npc.SpawnY); + } + private static async Task ProcessSiegeTickAsync(IGameContext gameContext, CastleSiegeContext context) { try { - var map = await gameContext.GetMapAsync(ValleyOfLorenMapNumber).ConfigureAwait(false); - if (map is null) + if (await gameContext.GetMapAsync(ValleyOfLorenMapNumber).ConfigureAwait(false) is not { } map) { return; } // Each Crown Switch is held by whichever registered guild currently has a member standing on it. - foreach (var (switchNumber, x, y) in SwitchPositions) + foreach (var switchNumber in CastleSiegeContext.SwitchNumbers) { - string? holder = null; - var nearby = map.GetAttackablesInRange(new Point(x, y), SwitchHoldRange).OfType(); - foreach (var player in nearby) + if (GetNpcPosition(gameContext, switchNumber) is not { } position) { - var guildName = await GetGuildNameAsync(player).ConfigureAwait(false); - if (guildName is not null && context.RegisteredGuilds.Contains(guildName)) + continue; + } + + Guid? holder = null; + foreach (var player in map.GetAttackablesInRange(position, SwitchHoldRange).OfType()) + { + if (await GetPersistentGuildAsync(player).ConfigureAwait(false) is { } guild + && context.IsRegistered(guild.Id)) { - holder = guildName; + holder = guild.Id; break; } } @@ -386,24 +429,31 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom var now = DateTime.UtcNow; - // Crown-hold capture: when a guild holds both switches with every gate down, the crown shield drops; - // that guild's master then holds the crown for CrownHoldDuration to take the throne (contestable). + // 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(); Player? masterPlayer = null; - if (eligible is not null) + string? eligibleName = null; + if (eligible is { } eligibleId && GetNpcPosition(gameContext, CrownNumber) is { } crownPosition) { - foreach (var player in map.GetAttackablesInRange(CrownPosition, CrownHoldRange).OfType()) + foreach (var player in map.GetAttackablesInRange(crownPosition, CrownHoldRange).OfType()) { - if (player.GuildStatus?.Position == GuildPosition.GuildMaster - && await GetGuildNameAsync(player).ConfigureAwait(false) == eligible) + if (player.GuildStatus?.Position != GuildPosition.GuildMaster) + { + continue; + } + + if (await GetPersistentGuildAsync(player).ConfigureAwait(false) is { } guild && guild.Id == eligibleId) { masterPlayer = player; + eligibleName = guild.Name; break; } } } - var crown = context.TickCrownHold(eligible, masterPlayer is not null, now, context.Configuration.CrownHoldDuration); + var holdDuration = TimeSpan.FromSeconds(GetDefinition(gameContext)?.CrownHoldTimeSeconds ?? 60); + var crown = context.TickCrownHold(eligible, eligibleName, masterPlayer is not null, now, holdDuration); // Shield drop/raise -> everyone on the battle map, but only when it flips (the packet pops a modal). if (crown.ShieldChanged) @@ -414,13 +464,13 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom switch (crown.Event) { case CrownEvent.HoldStarted when masterPlayer is not null: - // The 60-second registration panel is shown ONLY to the master taking the crown. + // The registration panel is shown ONLY to the master taking the crown. await masterPlayer.InvokeViewPlugInAsync(p => p.SetCrownRegistAsync(0, 0)).ConfigureAwait(false); break; case CrownEvent.HoldReset when masterPlayer is not null: await masterPlayer.InvokeViewPlugInAsync(p => p.SetCrownRegistAsync(2, 0)).ConfigureAwait(false); break; - case CrownEvent.Captured when crown.Guild is { } captured: + case CrownEvent.Captured when crown.GuildName is { } captured: 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); break; @@ -430,7 +480,7 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom // Keep the client's on-map countdown armed and in sync. Resend every 10s so players who just // loaded the battle map pick it up, without visibly resetting the second-counter too often. - if ((int)(now - context.PhaseStartedUtc).TotalSeconds % 10 == 0) + if ((int)(now - context.StateStartedUtc).TotalSeconds % 10 == 0) { var remaining = context.GetRemainingSiegeTime(now); var totalMinutes = (int)Math.Ceiling(remaining.TotalMinutes); @@ -470,16 +520,131 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom ? player.InvokeViewPlugInAsync(action).AsTask() : Task.CompletedTask); - private async ValueTask BroadcastCastleFlagAsync(IGameContext gameContext, CastleSiegeContext context) + private static async Task WarpRegisteredMembersToSiegeAsync(IGameContext gameContext, CastleSiegeContext context) { try { - if (context.OwnerGuildName is not { Length: > 0 } owner) + var map = await gameContext.GetMapAsync(ValleyOfLorenMapNumber).ConfigureAwait(false); + if (map?.SafeZoneSpawnGate is not { } gate) { return; } - var logo = await this.GetOwnerLogoAsync(gameContext, owner).ConfigureAwait(false); + await gameContext.ForEachPlayerAsync(async player => + { + if (await GetPersistentGuildAsync(player).ConfigureAwait(false) is { } guild + && context.IsRegistered(guild.Id)) + { + await player.WarpToAsync(gate).ConfigureAwait(false); + } + }).ConfigureAwait(false); + } + catch (Exception ex) + { + gameContext.LoggerFactory.CreateLogger() + .LogError(ex, "Castle Siege: error while warping registered members to the battle map."); + } + } + + /// + /// Writes the castle owner and the guild registrations to their database tables, and the cycle + /// bookkeeping (current state and when it started) to the plugin's custom-configuration JSON. + /// + private async ValueTask PersistStateAsync(GameContext gameContext, CastleSiegeContext context) + { + try + { + await this.PersistOwnerAsync(gameContext, context).ConfigureAwait(false); + await PersistRegistrationsAsync(gameContext, context).ConfigureAwait(false); + await this.PersistCycleBookkeepingAsync(gameContext, context).ConfigureAwait(false); + } + catch (Exception ex) + { + gameContext.LoggerFactory.CreateLogger() + .LogError(ex, "Castle Siege: error while persisting state to the database."); + } + } + + private async ValueTask PersistOwnerAsync(GameContext gameContext, CastleSiegeContext context) + { + using var dataContext = gameContext.PersistenceContextProvider.CreateNewTypedContext(typeof(CastleSiegeData), false, gameContext.Configuration); + var data = (await dataContext.GetAsync().ConfigureAwait(false)).FirstOrDefault() + ?? dataContext.CreateNew(); + + data.OwnerGuildId = context.OwnerGuildId; + data.IsOccupied = context.OwnerGuildId is not null; + await dataContext.SaveChangesAsync().ConfigureAwait(false); + + gameContext.LoggerFactory.CreateLogger() + .LogInformation( + "Castle Siege: persisted owner={owner} state={state}.", + context.OwnerGuildName ?? "(none)", + context.State); + } + + private static async ValueTask PersistRegistrationsAsync(GameContext gameContext, CastleSiegeContext context) + { + using var registrationContext = gameContext.PersistenceContextProvider.CreateNewTypedContext(typeof(CastleSiegeGuildRegistration), false, gameContext.Configuration); + var existing = (await registrationContext.GetAsync().ConfigureAwait(false)).ToList(); + var wanted = context.RegisteredGuildIds.ToHashSet(); + + foreach (var registration in existing) + { + if (!wanted.Remove(registration.GuildId)) + { + await registrationContext.DeleteAsync(registration).ConfigureAwait(false); + } + } + + foreach (var guildId in wanted) + { + var registration = registrationContext.CreateNew(); + registration.GuildId = guildId; + } + + await registrationContext.SaveChangesAsync().ConfigureAwait(false); + } + + private async ValueTask PersistCycleBookkeepingAsync(GameContext gameContext, CastleSiegeContext context) + { + if (this.Configuration is not { } settings) + { + return; + } + + settings.PersistedState = context.State; + settings.PersistedStateStartedUtc = context.StateStartedUtc; + + // Find our plugin-configuration row via the in-memory config graph to get its id, then rewrite its + // custom-configuration JSON in its own (non-caching) context. + var pluginTypeId = typeof(CastleSiegeEventPlugIn).GUID; + var inMemory = gameContext.Configuration.PlugInConfigurations.FirstOrDefault(c => c.TypeId == pluginTypeId); + if (inMemory is null) + { + return; + } + + using var ctx = gameContext.PersistenceContextProvider.CreateNewTypedContext(typeof(PlugInConfiguration), false, gameContext.Configuration); + var row = await ctx.GetByIdAsync(inMemory.GetId()).ConfigureAwait(false); + if (row is null) + { + return; + } + + row.SetConfiguration(settings, gameContext.PlugInManager.CustomConfigReferenceHandler); + await ctx.SaveChangesAsync().ConfigureAwait(false); + } + + private async ValueTask BroadcastCastleFlagAsync(IGameContext gameContext, CastleSiegeContext context) + { + try + { + if (context.OwnerGuildId is not { } owner) + { + return; + } + + var logo = await this.GetOwnerLogoAsync(gameContext, owner, context.OwnerGuildName).ConfigureAwait(false); if (logo is null) { return; @@ -500,77 +665,32 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom } } - private async ValueTask GetOwnerLogoAsync(IGameContext gameContext, string ownerName) + private async ValueTask GetOwnerLogoAsync(IGameContext gameContext, Guid ownerId, string? ownerName) { - if (this._cachedFlagOwner == ownerName && this._cachedFlagLogo is not null) + if (this._cachedFlagOwner == ownerId && this._cachedFlagLogo is not null) { return this._cachedFlagLogo; } - if (gameContext is not IGameServerContext serverContext) + if (gameContext is not IGameServerContext serverContext || ownerName is not { Length: > 0 }) { return null; } - var guildId = await serverContext.GuildServer.GetGuildIdByNameAsync(ownerName).ConfigureAwait(false); - if (guildId == 0) + var shortGuildId = await serverContext.GuildServer.GetGuildIdByNameAsync(ownerName).ConfigureAwait(false); + if (shortGuildId == 0) { return null; } - var guild = await serverContext.GuildServer.GetGuildAsync(guildId).ConfigureAwait(false); + var guild = await serverContext.GuildServer.GetGuildAsync(shortGuildId).ConfigureAwait(false); if (guild?.Logo is not { Length: > 0 } logo) { return null; } - this._cachedFlagOwner = ownerName; + this._cachedFlagOwner = ownerId; this._cachedFlagLogo = logo; return logo; } - - private static async Task WarpRegisteredMembersToSiegeAsync(IGameContext gameContext, CastleSiegeContext context) - { - try - { - var map = await gameContext.GetMapAsync(ValleyOfLorenMapNumber).ConfigureAwait(false); - if (map?.SafeZoneSpawnGate is not { } gate) - { - return; - } - - await gameContext.ForEachPlayerAsync(async player => - { - var guildName = await GetGuildNameAsync(player).ConfigureAwait(false); - if (guildName is not null && context.RegisteredGuilds.Contains(guildName)) - { - await player.WarpToAsync(gate).ConfigureAwait(false); - } - }).ConfigureAwait(false); - } - catch (Exception ex) - { - gameContext.LoggerFactory.CreateLogger() - .LogError(ex, "Castle Siege: error while warping registered members to the battle map."); - } - } - - private static async ValueTask GetGuildNameAsync(Player player) - { - if (player.GuildStatus is not { } guildStatus) - { - return null; - } - - if (player.GameContext is IGameServerContext serverContext) - { - var guild = await serverContext.GuildServer.GetGuildAsync(guildStatus.GuildId).ConfigureAwait(false); - if (guild?.Name is { Length: > 0 } name) - { - return name; - } - } - - return guildStatus.GuildId.ToString(); - } }