diff --git a/src/GameLogic/CastleSiege/CastleSiegeConfiguration.cs b/src/GameLogic/CastleSiege/CastleSiegeConfiguration.cs index d969d8d..6e18241 100644 --- a/src/GameLogic/CastleSiege/CastleSiegeConfiguration.cs +++ b/src/GameLogic/CastleSiege/CastleSiegeConfiguration.cs @@ -31,6 +31,23 @@ public class CastleSiegeConfiguration /// public int RegistrationFee { get; set; } = 100000; + // --- Persisted runtime state (P4 persistence) --- + // These ride on the plugin's custom-configuration JSON (already stored in PostgreSQL), so the castle + // owner and the current cycle survive server restarts without a dedicated database table/migration. + // Written by CastleSiegeEventPlugIn whenever the state changes; read back on startup to restore it. + + /// Gets or sets the persisted castle owner guild name (null = unowned). + public string? PersistedOwnerGuildName { get; set; } + + /// Gets or sets the persisted current phase, so the cycle resumes after a restart. + public CastleSiegePhase PersistedPhase { get; set; } = CastleSiegePhase.Ownership; + + /// Gets or sets when the persisted phase started (UTC), or null if never persisted. + public DateTime? PersistedPhaseStartedUtc { get; set; } + + /// Gets or sets the persisted registered guild names for the current cycle. + public IList PersistedRegisteredGuilds { get; set; } = new List(); + /// /// Returns true if falls within a 5-second window of any configured /// registration-open time. diff --git a/src/GameLogic/CastleSiege/CastleSiegeContext.cs b/src/GameLogic/CastleSiege/CastleSiegeContext.cs index 23e776e..f23ff94 100644 --- a/src/GameLogic/CastleSiege/CastleSiegeContext.cs +++ b/src/GameLogic/CastleSiege/CastleSiegeContext.cs @@ -22,6 +22,7 @@ public class CastleSiegeContext private DateTime _phaseStartedUtc; private string? _occupier; private int _defensesRemaining; + private bool _dirty; /// Initializes a new instance of the class. /// The cycle timing configuration. @@ -40,6 +41,9 @@ public class CastleSiegeContext /// Gets the current phase. public CastleSiegePhase Phase { get; private set; } + /// Gets the UTC time the current phase started (used for persistence/restore). + public DateTime PhaseStartedUtc => this._phaseStartedUtc; + /// Gets the current owner guild name, or null if unowned. public string? OwnerGuildName { get; private set; } @@ -134,12 +138,50 @@ public class CastleSiegeContext && !this._registeredGuilds.Contains(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) => this.OwnerGuildName = guildName; + public void SetOwner(string? guildName) + { + this.OwnerGuildName = guildName; + this._dirty = true; + } + + /// + /// 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, phase, phase-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) + { + 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; + } /// Sets how many castle defenses (gates + guardian statues) exist this siege (when spawned). /// The defense count. @@ -222,6 +264,7 @@ public class CastleSiegeContext { this.Phase = phase; this._phaseStartedUtc = now; + this._dirty = true; this.PhaseChanged?.Invoke(phase); return ValueTask.CompletedTask; } diff --git a/src/GameLogic/PlugIns/PeriodicTasks/CastleSiegeEventPlugIn.cs b/src/GameLogic/PlugIns/PeriodicTasks/CastleSiegeEventPlugIn.cs index 3571ff1..a9b72ef 100644 --- a/src/GameLogic/PlugIns/PeriodicTasks/CastleSiegeEventPlugIn.cs +++ b/src/GameLogic/PlugIns/PeriodicTasks/CastleSiegeEventPlugIn.cs @@ -69,7 +69,12 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom { var context = Contexts.GetOrAdd(gameContext, gc => { - var created = new CastleSiegeContext(this.Configuration ?? new CastleSiegeConfiguration()); + var config = this.Configuration ?? new CastleSiegeConfiguration(); + var created = new CastleSiegeContext(config); + + // 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); // Announce phase changes to the whole server and, when the siege begins, warp registered members. created.PhaseChanged += phase => _ = OnPhaseChangedAsync(gc, created, phase); @@ -84,6 +89,12 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom { await ProcessSiegeTickAsync(gameContext, context).ConfigureAwait(false); } + + // Persist owner/phase/registrations to the database whenever they changed, so they survive a restart. + if (context.ConsumeDirty()) + { + await this.PersistStateAsync(gameContext, context).ConfigureAwait(false); + } } /// @@ -124,6 +135,41 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom } } + 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(); + + // Write the plugin's custom-configuration JSON row back to the database (durable across restarts). + var pluginTypeId = typeof(CastleSiegeEventPlugIn).GUID; + using var ctx = gameContext.PersistenceContextProvider.CreateNewContext(gameContext.Configuration); + var configurations = await ctx.GetAsync().ConfigureAwait(false); + var row = configurations.FirstOrDefault(c => c.TypeId == pluginTypeId); + if (row is null) + { + return; + } + + row.SetConfiguration(config, gameContext.PlugInManager.CustomConfigReferenceHandler); + await ctx.SaveChangesAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + gameContext.LoggerFactory.CreateLogger() + .LogError(ex, "Castle Siege: error while persisting state to the database."); + } + } + private static ValueTask AnnounceAsync(IGameContext gameContext, string message) => gameContext.ForEachPlayerAsync(player => player.InvokeViewPlugInAsync(p => p.ShowMessageAsync(message, MessageType.GoldenCenter)).AsTask()); diff --git a/tests/MUnique.OpenMU.Tests/CastleSiege/CastleSiegeContextTest.cs b/tests/MUnique.OpenMU.Tests/CastleSiege/CastleSiegeContextTest.cs index bb3cd06..e5b16d1 100644 --- a/tests/MUnique.OpenMU.Tests/CastleSiege/CastleSiegeContextTest.cs +++ b/tests/MUnique.OpenMU.Tests/CastleSiege/CastleSiegeContextTest.cs @@ -123,6 +123,41 @@ public class CastleSiegeContextTest Assert.That(ctx.OccupierGuildName, Is.Null); } + /// Tests that restoring persisted state sets phase/owner/registrations without raising PhaseChanged. + [Test] + public void RestoreStateSetsStateWithoutFiringPhaseChanged() + { + var ctx = new CastleSiegeContext(Config()); + var phaseChangedFired = false; + ctx.PhaseChanged += _ => phaseChangedFired = true; + + ctx.RestoreState("Winners", CastleSiegePhase.Ownership, T0, new[] { "Winners", "Losers" }); + + Assert.That(ctx.Phase, Is.EqualTo(CastleSiegePhase.Ownership)); + Assert.That(ctx.OwnerGuildName, Is.EqualTo("Winners")); + Assert.That(ctx.RegisteredGuilds, Is.EquivalentTo(new[] { "Winners", "Losers" })); + Assert.That(phaseChangedFired, Is.False); + Assert.That(ctx.ConsumeDirty(), Is.False, "restore must not mark the state dirty"); + } + + /// Tests that state-changing operations flip the dirty flag, which ConsumeDirty reads-and-resets. + [Test] + public async Task DirtyFlagTracksPersistableChangesAsync() + { + var ctx = new CastleSiegeContext(Config()); + Assert.That(ctx.ConsumeDirty(), Is.False, "a fresh context has nothing to persist"); + + await ctx.ForceStartRegistrationAsync(T0); // phase transition -> dirty + Assert.That(ctx.ConsumeDirty(), Is.True); + Assert.That(ctx.ConsumeDirty(), Is.False, "ConsumeDirty resets the flag"); + + ctx.RegisterGuild("Attackers"); // registration -> dirty + Assert.That(ctx.ConsumeDirty(), Is.True); + + ctx.SetOwner("Attackers"); // owner change -> dirty + Assert.That(ctx.ConsumeDirty(), Is.True); + } + private static CastleSiegeConfiguration Config() => new() { RegistrationDuration = TimeSpan.FromMinutes(5),