feat(CS): persist owner/phase/registrations across restarts via plugin config JSON
Some checks failed
.NET Core / build (push) Has been cancelled
Some checks failed
.NET Core / build (push) Has been cancelled
Castle Siege state was in-memory, so the castle owner (and thus the P4 hunting-map reward) reset on every server restart/redeploy. Now the context marks itself dirty on any persistable change (phase transition, registration, owner set); the plugin's periodic tick writes a snapshot into its own PlugInConfiguration CustomConfiguration (a JSON blob already stored in PostgreSQL - no schema migration) and restores it on startup. Battle state (defenses/switches/occupier) stays transient. +2 unit tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -31,6 +31,23 @@ public class CastleSiegeConfiguration
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public int RegistrationFee { get; set; } = 100000;
|
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.
|
||||||
|
|
||||||
|
/// <summary>Gets or sets the persisted castle owner guild name (null = unowned).</summary>
|
||||||
|
public string? PersistedOwnerGuildName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Gets or sets the persisted current phase, so the cycle resumes after a restart.</summary>
|
||||||
|
public CastleSiegePhase PersistedPhase { get; set; } = CastleSiegePhase.Ownership;
|
||||||
|
|
||||||
|
/// <summary>Gets or sets when the persisted phase started (UTC), or null if never persisted.</summary>
|
||||||
|
public DateTime? PersistedPhaseStartedUtc { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Gets or sets the persisted registered guild names for the current cycle.</summary>
|
||||||
|
public IList<string> PersistedRegisteredGuilds { get; set; } = new List<string>();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns true if <paramref name="now"/> falls within a 5-second window of any configured
|
/// Returns true if <paramref name="now"/> falls within a 5-second window of any configured
|
||||||
/// registration-open time.
|
/// registration-open time.
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ public class CastleSiegeContext
|
|||||||
private DateTime _phaseStartedUtc;
|
private DateTime _phaseStartedUtc;
|
||||||
private string? _occupier;
|
private string? _occupier;
|
||||||
private int _defensesRemaining;
|
private int _defensesRemaining;
|
||||||
|
private bool _dirty;
|
||||||
|
|
||||||
/// <summary>Initializes a new instance of the <see cref="CastleSiegeContext"/> class.</summary>
|
/// <summary>Initializes a new instance of the <see cref="CastleSiegeContext"/> class.</summary>
|
||||||
/// <param name="configuration">The cycle timing configuration.</param>
|
/// <param name="configuration">The cycle timing configuration.</param>
|
||||||
@@ -40,6 +41,9 @@ public class CastleSiegeContext
|
|||||||
/// <summary>Gets the current phase.</summary>
|
/// <summary>Gets the current phase.</summary>
|
||||||
public CastleSiegePhase Phase { get; private set; }
|
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>
|
/// <summary>Gets the current owner guild name, or null if unowned.</summary>
|
||||||
public string? OwnerGuildName { get; private set; }
|
public string? OwnerGuildName { get; private set; }
|
||||||
|
|
||||||
@@ -134,12 +138,50 @@ public class CastleSiegeContext
|
|||||||
&& !this._registeredGuilds.Contains(guildName))
|
&& !this._registeredGuilds.Contains(guildName))
|
||||||
{
|
{
|
||||||
this._registeredGuilds.Add(guildName);
|
this._registeredGuilds.Add(guildName);
|
||||||
|
this._dirty = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Admin: sets (or clears) the current owner guild name.</summary>
|
/// <summary>Admin: sets (or clears) the current owner guild name.</summary>
|
||||||
/// <param name="guildName">The owner guild name, or null to clear.</param>
|
/// <param name="guildName">The owner guild name, or null to clear.</param>
|
||||||
public void SetOwner(string? guildName) => this.OwnerGuildName = guildName;
|
public void SetOwner(string? guildName)
|
||||||
|
{
|
||||||
|
this.OwnerGuildName = guildName;
|
||||||
|
this._dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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>
|
/// <summary>Sets how many castle defenses (gates + guardian statues) exist this siege (when spawned).</summary>
|
||||||
/// <param name="count">The defense count.</param>
|
/// <param name="count">The defense count.</param>
|
||||||
@@ -222,6 +264,7 @@ public class CastleSiegeContext
|
|||||||
{
|
{
|
||||||
this.Phase = phase;
|
this.Phase = phase;
|
||||||
this._phaseStartedUtc = now;
|
this._phaseStartedUtc = now;
|
||||||
|
this._dirty = true;
|
||||||
this.PhaseChanged?.Invoke(phase);
|
this.PhaseChanged?.Invoke(phase);
|
||||||
return ValueTask.CompletedTask;
|
return ValueTask.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,7 +69,12 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom
|
|||||||
{
|
{
|
||||||
var context = Contexts.GetOrAdd(gameContext, gc =>
|
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.
|
// Announce phase changes to the whole server and, when the siege begins, warp registered members.
|
||||||
created.PhaseChanged += phase => _ = OnPhaseChangedAsync(gc, created, phase);
|
created.PhaseChanged += phase => _ = OnPhaseChangedAsync(gc, created, phase);
|
||||||
@@ -84,6 +89,12 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom
|
|||||||
{
|
{
|
||||||
await ProcessSiegeTickAsync(gameContext, context).ConfigureAwait(false);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -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<PlugInConfiguration>().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<CastleSiegeEventPlugIn>()
|
||||||
|
.LogError(ex, "Castle Siege: error while persisting state to the database.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static ValueTask AnnounceAsync(IGameContext gameContext, string message)
|
private static ValueTask AnnounceAsync(IGameContext gameContext, string message)
|
||||||
=> gameContext.ForEachPlayerAsync(player =>
|
=> gameContext.ForEachPlayerAsync(player =>
|
||||||
player.InvokeViewPlugInAsync<IShowMessagePlugIn>(p => p.ShowMessageAsync(message, MessageType.GoldenCenter)).AsTask());
|
player.InvokeViewPlugInAsync<IShowMessagePlugIn>(p => p.ShowMessageAsync(message, MessageType.GoldenCenter)).AsTask());
|
||||||
|
|||||||
@@ -123,6 +123,41 @@ public class CastleSiegeContextTest
|
|||||||
Assert.That(ctx.OccupierGuildName, Is.Null);
|
Assert.That(ctx.OccupierGuildName, Is.Null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Tests that restoring persisted state sets phase/owner/registrations without raising PhaseChanged.</summary>
|
||||||
|
[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");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Tests that state-changing operations flip the dirty flag, which ConsumeDirty reads-and-resets.</summary>
|
||||||
|
[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()
|
private static CastleSiegeConfiguration Config() => new()
|
||||||
{
|
{
|
||||||
RegistrationDuration = TimeSpan.FromMinutes(5),
|
RegistrationDuration = TimeSpan.FromMinutes(5),
|
||||||
|
|||||||
Reference in New Issue
Block a user