feat(CS-P1): Castle Siege phase state machine (in-memory, time-injected) + tests
This commit is contained in:
44
src/GameLogic/CastleSiege/CastleSiegeConfiguration.cs
Normal file
44
src/GameLogic/CastleSiege/CastleSiegeConfiguration.cs
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
// <copyright file="CastleSiegeConfiguration.cs" company="MUnique">
|
||||||
|
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
|
||||||
|
// </copyright>
|
||||||
|
|
||||||
|
namespace MUnique.OpenMU.GameLogic.CastleSiege;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configuration for the Castle Siege cycle timings.
|
||||||
|
/// Rides on the plugin custom-configuration system (no dedicated database table in P1).
|
||||||
|
/// </summary>
|
||||||
|
public class CastleSiegeConfiguration
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the times of day at which a new cycle opens registration.
|
||||||
|
/// Empty by default; admins start cycles manually via chat command in P1.
|
||||||
|
/// </summary>
|
||||||
|
public IList<TimeOnly> RegistrationOpenTimes { get; set; } = new List<TimeOnly>();
|
||||||
|
|
||||||
|
/// <summary>Gets or sets how long the registration phase lasts.</summary>
|
||||||
|
public TimeSpan RegistrationDuration { get; set; } = TimeSpan.FromMinutes(5);
|
||||||
|
|
||||||
|
/// <summary>Gets or sets how long the preparation phase lasts.</summary>
|
||||||
|
public TimeSpan PreparationDuration { get; set; } = TimeSpan.FromMinutes(2);
|
||||||
|
|
||||||
|
/// <summary>Gets or sets how long the siege phase lasts.</summary>
|
||||||
|
public TimeSpan SiegeDuration { get; set; } = TimeSpan.FromMinutes(10);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true if <paramref name="now"/> falls within a 5-second window of any configured
|
||||||
|
/// registration-open time.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="now">The current UTC time.</param>
|
||||||
|
public bool IsRegistrationOpenTime(DateTime now)
|
||||||
|
{
|
||||||
|
if (this.RegistrationOpenTimes.Count == 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var nowTime = TimeOnly.FromDateTime(now);
|
||||||
|
var earlier = nowTime.Add(TimeSpan.FromSeconds(-5));
|
||||||
|
return this.RegistrationOpenTimes.Any(p => p.IsBetween(earlier, nowTime));
|
||||||
|
}
|
||||||
|
}
|
||||||
132
src/GameLogic/CastleSiege/CastleSiegeContext.cs
Normal file
132
src/GameLogic/CastleSiege/CastleSiegeContext.cs
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
// <copyright file="CastleSiegeContext.cs" company="MUnique">
|
||||||
|
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
|
||||||
|
// </copyright>
|
||||||
|
|
||||||
|
namespace MUnique.OpenMU.GameLogic.CastleSiege;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// In-memory Castle Siege phase state machine (P1 skeleton: no battle/persistence).
|
||||||
|
/// Time is injected via method parameters so it can be tested deterministically.
|
||||||
|
/// </summary>
|
||||||
|
public class CastleSiegeContext
|
||||||
|
{
|
||||||
|
private readonly List<string> _registeredGuilds = new();
|
||||||
|
private DateTime _phaseStartedUtc;
|
||||||
|
|
||||||
|
/// <summary>Initializes a new instance of the <see cref="CastleSiegeContext"/> class.</summary>
|
||||||
|
/// <param name="configuration">The cycle timing configuration.</param>
|
||||||
|
public CastleSiegeContext(CastleSiegeConfiguration configuration)
|
||||||
|
{
|
||||||
|
this.Configuration = configuration;
|
||||||
|
this.Phase = CastleSiegePhase.Ownership;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Raised after the phase changes. Argument is the new phase.</summary>
|
||||||
|
public event Action<CastleSiegePhase>? PhaseChanged;
|
||||||
|
|
||||||
|
/// <summary>Gets the configuration.</summary>
|
||||||
|
public CastleSiegeConfiguration Configuration { get; }
|
||||||
|
|
||||||
|
/// <summary>Gets the current phase.</summary>
|
||||||
|
public CastleSiegePhase Phase { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Gets the current owner guild name, or null if unowned.</summary>
|
||||||
|
public string? OwnerGuildName { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Gets the guild names registered for the current cycle.</summary>
|
||||||
|
public IReadOnlyList<string> RegisteredGuilds => this._registeredGuilds;
|
||||||
|
|
||||||
|
/// <summary>Advances the state machine based on the current time.</summary>
|
||||||
|
/// <param name="now">The current UTC time.</param>
|
||||||
|
public ValueTask TickAsync(DateTime now)
|
||||||
|
{
|
||||||
|
switch (this.Phase)
|
||||||
|
{
|
||||||
|
case CastleSiegePhase.Ownership:
|
||||||
|
if (this.Configuration.IsRegistrationOpenTime(now))
|
||||||
|
{
|
||||||
|
return this.ForceStartRegistrationAsync(now);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
case CastleSiegePhase.Registration:
|
||||||
|
if (now >= this._phaseStartedUtc + this.Configuration.RegistrationDuration)
|
||||||
|
{
|
||||||
|
return this.TransitionAsync(CastleSiegePhase.Preparation, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
case CastleSiegePhase.Preparation:
|
||||||
|
if (now >= this._phaseStartedUtc + this.Configuration.PreparationDuration)
|
||||||
|
{
|
||||||
|
return this.TransitionAsync(CastleSiegePhase.Siege, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
case CastleSiegePhase.Siege:
|
||||||
|
if (now >= this._phaseStartedUtc + this.Configuration.SiegeDuration)
|
||||||
|
{
|
||||||
|
return this.TransitionAsync(CastleSiegePhase.Settlement, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
case CastleSiegePhase.Settlement:
|
||||||
|
// P1: no battle -> no winner determination yet. Settle immediately back to ownership.
|
||||||
|
return this.TransitionAsync(CastleSiegePhase.Ownership, now);
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Admin: forces the cycle into registration now (from any phase).</summary>
|
||||||
|
/// <param name="now">The current UTC time.</param>
|
||||||
|
public ValueTask ForceStartRegistrationAsync(DateTime now)
|
||||||
|
{
|
||||||
|
this._registeredGuilds.Clear();
|
||||||
|
return this.TransitionAsync(CastleSiegePhase.Registration, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Admin: forces a specific phase now.</summary>
|
||||||
|
/// <param name="phase">The target phase.</param>
|
||||||
|
/// <param name="now">The current UTC time.</param>
|
||||||
|
public ValueTask ForcePhaseAsync(CastleSiegePhase phase, DateTime now)
|
||||||
|
=> this.TransitionAsync(phase, now);
|
||||||
|
|
||||||
|
/// <summary>Admin: resets to the ownership (resting) phase and clears registrations.</summary>
|
||||||
|
/// <param name="now">The current UTC time.</param>
|
||||||
|
public ValueTask ResetAsync(DateTime now)
|
||||||
|
{
|
||||||
|
this._registeredGuilds.Clear();
|
||||||
|
return this.TransitionAsync(CastleSiegePhase.Ownership, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Registers a guild (by name) for the current cycle. No-op outside registration.</summary>
|
||||||
|
/// <param name="guildName">The guild name.</param>
|
||||||
|
public void RegisterGuild(string guildName)
|
||||||
|
{
|
||||||
|
if (this.Phase == CastleSiegePhase.Registration
|
||||||
|
&& !this._registeredGuilds.Contains(guildName))
|
||||||
|
{
|
||||||
|
this._registeredGuilds.Add(guildName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Admin: sets (or clears) the current owner guild name.</summary>
|
||||||
|
/// <param name="guildName">The owner guild name, or null to clear.</param>
|
||||||
|
public void SetOwner(string? guildName) => this.OwnerGuildName = guildName;
|
||||||
|
|
||||||
|
/// <summary>Returns a human-readable status summary for admin display.</summary>
|
||||||
|
public string GetStatusText()
|
||||||
|
=> $"CS phase={this.Phase}, owner={this.OwnerGuildName ?? "(none)"}, "
|
||||||
|
+ $"registered={this._registeredGuilds.Count} [{string.Join(", ", this._registeredGuilds)}]";
|
||||||
|
|
||||||
|
private ValueTask TransitionAsync(CastleSiegePhase phase, DateTime now)
|
||||||
|
{
|
||||||
|
this.Phase = phase;
|
||||||
|
this._phaseStartedUtc = now;
|
||||||
|
this.PhaseChanged?.Invoke(phase);
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
26
src/GameLogic/CastleSiege/CastleSiegePhase.cs
Normal file
26
src/GameLogic/CastleSiege/CastleSiegePhase.cs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
// <copyright file="CastleSiegePhase.cs" company="MUnique">
|
||||||
|
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
|
||||||
|
// </copyright>
|
||||||
|
|
||||||
|
namespace MUnique.OpenMU.GameLogic.CastleSiege;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The phases of a Castle Siege cycle.
|
||||||
|
/// </summary>
|
||||||
|
public enum CastleSiegePhase
|
||||||
|
{
|
||||||
|
/// <summary>Resting phase: castle is (un)owned, waiting for the next registration window.</summary>
|
||||||
|
Ownership,
|
||||||
|
|
||||||
|
/// <summary>Guilds can register to attack.</summary>
|
||||||
|
Registration,
|
||||||
|
|
||||||
|
/// <summary>Registration closed; defenders prepare before the siege starts.</summary>
|
||||||
|
Preparation,
|
||||||
|
|
||||||
|
/// <summary>The siege battle is running.</summary>
|
||||||
|
Siege,
|
||||||
|
|
||||||
|
/// <summary>Siege ended; determining the new owner.</summary>
|
||||||
|
Settlement,
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// <copyright file="CastleSiegeContextTest.cs" company="MUnique">
|
||||||
|
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
|
||||||
|
// </copyright>
|
||||||
|
|
||||||
|
namespace MUnique.OpenMU.Tests.CastleSiege;
|
||||||
|
|
||||||
|
using MUnique.OpenMU.GameLogic.CastleSiege;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for the Castle Siege phase state machine (time-driven, injected clock).
|
||||||
|
/// </summary>
|
||||||
|
[TestFixture]
|
||||||
|
public class CastleSiegeContextTest
|
||||||
|
{
|
||||||
|
private static readonly DateTime T0 = new(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
/// <summary>Tests that a fresh context starts in the ownership (resting) phase.</summary>
|
||||||
|
[Test]
|
||||||
|
public void StartsInOwnership()
|
||||||
|
{
|
||||||
|
var ctx = new CastleSiegeContext(Config());
|
||||||
|
Assert.That(ctx.Phase, Is.EqualTo(CastleSiegePhase.Ownership));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Tests that force-starting moves the state machine into registration.</summary>
|
||||||
|
[Test]
|
||||||
|
public async Task ForceStartMovesToRegistrationAsync()
|
||||||
|
{
|
||||||
|
var ctx = new CastleSiegeContext(Config());
|
||||||
|
await ctx.ForceStartRegistrationAsync(T0);
|
||||||
|
Assert.That(ctx.Phase, Is.EqualTo(CastleSiegePhase.Registration));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Tests that registration advances to preparation once its duration elapses.</summary>
|
||||||
|
[Test]
|
||||||
|
public async Task RegistrationAdvancesToPreparationAfterDurationAsync()
|
||||||
|
{
|
||||||
|
var ctx = new CastleSiegeContext(Config());
|
||||||
|
await ctx.ForceStartRegistrationAsync(T0);
|
||||||
|
await ctx.TickAsync(T0.AddMinutes(4)); // still within registration
|
||||||
|
Assert.That(ctx.Phase, Is.EqualTo(CastleSiegePhase.Registration));
|
||||||
|
await ctx.TickAsync(T0.AddMinutes(5)); // registration duration elapsed
|
||||||
|
Assert.That(ctx.Phase, Is.EqualTo(CastleSiegePhase.Preparation));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Tests a full cycle: registration -> preparation -> siege -> settlement -> ownership.</summary>
|
||||||
|
[Test]
|
||||||
|
public async Task FullCycleReturnsToOwnershipAsync()
|
||||||
|
{
|
||||||
|
var ctx = new CastleSiegeContext(Config());
|
||||||
|
await ctx.ForceStartRegistrationAsync(T0);
|
||||||
|
await ctx.TickAsync(T0.AddMinutes(5)); // -> Preparation
|
||||||
|
await ctx.TickAsync(T0.AddMinutes(7)); // +2 prep -> Siege
|
||||||
|
Assert.That(ctx.Phase, Is.EqualTo(CastleSiegePhase.Siege));
|
||||||
|
await ctx.TickAsync(T0.AddMinutes(17)); // +10 siege -> Settlement
|
||||||
|
await ctx.TickAsync(T0.AddMinutes(17)); // Settlement -> Ownership
|
||||||
|
Assert.That(ctx.Phase, Is.EqualTo(CastleSiegePhase.Ownership));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Tests that guilds can be registered (by name) during the registration phase.</summary>
|
||||||
|
[Test]
|
||||||
|
public async Task RegisterGuildCollectsNamesDuringRegistrationAsync()
|
||||||
|
{
|
||||||
|
var ctx = new CastleSiegeContext(Config());
|
||||||
|
await ctx.ForceStartRegistrationAsync(T0);
|
||||||
|
ctx.RegisterGuild("Attackers");
|
||||||
|
Assert.That(ctx.RegisteredGuilds, Does.Contain("Attackers"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CastleSiegeConfiguration Config() => new()
|
||||||
|
{
|
||||||
|
RegistrationDuration = TimeSpan.FromMinutes(5),
|
||||||
|
PreparationDuration = TimeSpan.FromMinutes(2),
|
||||||
|
SiegeDuration = TimeSpan.FromMinutes(10),
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user