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),