feat(CS): persist owner/phase/registrations across restarts via plugin config JSON
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:
Acentech Dev
2026-07-15 12:17:34 +03:00
parent f03f6aa7ad
commit 9bdd57703f
4 changed files with 143 additions and 2 deletions

View File

@@ -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.

View File

@@ -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;
} }

View File

@@ -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());

View File

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