From 73de807bef21662dc68447678a9ead3b90029cf4 Mon Sep 17 00:00:00 2001 From: Acentech Dev Date: Tue, 14 Jul 2026 22:40:24 +0300 Subject: [PATCH] feat(CS-P1): Castle Siege phase state machine (in-memory, time-injected) + tests --- .../CastleSiege/CastleSiegeConfiguration.cs | 44 ++++++ .../CastleSiege/CastleSiegeContext.cs | 132 ++++++++++++++++++ src/GameLogic/CastleSiege/CastleSiegePhase.cs | 26 ++++ .../CastleSiege/CastleSiegeContextTest.cs | 76 ++++++++++ 4 files changed, 278 insertions(+) create mode 100644 src/GameLogic/CastleSiege/CastleSiegeConfiguration.cs create mode 100644 src/GameLogic/CastleSiege/CastleSiegeContext.cs create mode 100644 src/GameLogic/CastleSiege/CastleSiegePhase.cs create mode 100644 tests/MUnique.OpenMU.Tests/CastleSiege/CastleSiegeContextTest.cs diff --git a/src/GameLogic/CastleSiege/CastleSiegeConfiguration.cs b/src/GameLogic/CastleSiege/CastleSiegeConfiguration.cs new file mode 100644 index 0000000..62a874a --- /dev/null +++ b/src/GameLogic/CastleSiege/CastleSiegeConfiguration.cs @@ -0,0 +1,44 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.CastleSiege; + +/// +/// Configuration for the Castle Siege cycle timings. +/// Rides on the plugin custom-configuration system (no dedicated database table in P1). +/// +public class CastleSiegeConfiguration +{ + /// + /// 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. + /// + public IList RegistrationOpenTimes { get; set; } = new List(); + + /// Gets or sets how long the registration phase lasts. + public TimeSpan RegistrationDuration { get; set; } = TimeSpan.FromMinutes(5); + + /// Gets or sets how long the preparation phase lasts. + public TimeSpan PreparationDuration { get; set; } = TimeSpan.FromMinutes(2); + + /// Gets or sets how long the siege phase lasts. + public TimeSpan SiegeDuration { get; set; } = TimeSpan.FromMinutes(10); + + /// + /// Returns true if falls within a 5-second window of any configured + /// registration-open time. + /// + /// The current UTC time. + 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)); + } +} diff --git a/src/GameLogic/CastleSiege/CastleSiegeContext.cs b/src/GameLogic/CastleSiege/CastleSiegeContext.cs new file mode 100644 index 0000000..ff72647 --- /dev/null +++ b/src/GameLogic/CastleSiege/CastleSiegeContext.cs @@ -0,0 +1,132 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.CastleSiege; + +/// +/// In-memory Castle Siege phase state machine (P1 skeleton: no battle/persistence). +/// Time is injected via method parameters so it can be tested deterministically. +/// +public class CastleSiegeContext +{ + private readonly List _registeredGuilds = new(); + private DateTime _phaseStartedUtc; + + /// Initializes a new instance of the class. + /// The cycle timing configuration. + public CastleSiegeContext(CastleSiegeConfiguration configuration) + { + this.Configuration = configuration; + this.Phase = CastleSiegePhase.Ownership; + } + + /// Raised after the phase changes. Argument is the new phase. + public event Action? PhaseChanged; + + /// Gets the configuration. + public CastleSiegeConfiguration Configuration { get; } + + /// Gets the current phase. + public CastleSiegePhase Phase { get; private set; } + + /// Gets the current owner guild name, or null if unowned. + public string? OwnerGuildName { get; private set; } + + /// Gets the guild names registered for the current cycle. + public IReadOnlyList RegisteredGuilds => this._registeredGuilds; + + /// Advances the state machine based on the current time. + /// The current UTC time. + 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; + } + + /// Admin: forces the cycle into registration now (from any phase). + /// The current UTC time. + public ValueTask ForceStartRegistrationAsync(DateTime now) + { + this._registeredGuilds.Clear(); + return this.TransitionAsync(CastleSiegePhase.Registration, now); + } + + /// Admin: forces a specific phase now. + /// The target phase. + /// The current UTC time. + public ValueTask ForcePhaseAsync(CastleSiegePhase phase, DateTime now) + => this.TransitionAsync(phase, now); + + /// Admin: resets to the ownership (resting) phase and clears registrations. + /// The current UTC time. + public ValueTask ResetAsync(DateTime now) + { + this._registeredGuilds.Clear(); + return this.TransitionAsync(CastleSiegePhase.Ownership, now); + } + + /// Registers a guild (by name) for the current cycle. No-op outside registration. + /// The guild name. + public void RegisterGuild(string guildName) + { + if (this.Phase == CastleSiegePhase.Registration + && !this._registeredGuilds.Contains(guildName)) + { + this._registeredGuilds.Add(guildName); + } + } + + /// 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; + + /// Returns a human-readable status summary for admin display. + 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; + } +} diff --git a/src/GameLogic/CastleSiege/CastleSiegePhase.cs b/src/GameLogic/CastleSiege/CastleSiegePhase.cs new file mode 100644 index 0000000..0317868 --- /dev/null +++ b/src/GameLogic/CastleSiege/CastleSiegePhase.cs @@ -0,0 +1,26 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.CastleSiege; + +/// +/// The phases of a Castle Siege cycle. +/// +public enum CastleSiegePhase +{ + /// Resting phase: castle is (un)owned, waiting for the next registration window. + Ownership, + + /// Guilds can register to attack. + Registration, + + /// Registration closed; defenders prepare before the siege starts. + Preparation, + + /// The siege battle is running. + Siege, + + /// Siege ended; determining the new owner. + Settlement, +} diff --git a/tests/MUnique.OpenMU.Tests/CastleSiege/CastleSiegeContextTest.cs b/tests/MUnique.OpenMU.Tests/CastleSiege/CastleSiegeContextTest.cs new file mode 100644 index 0000000..a03311d --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/CastleSiege/CastleSiegeContextTest.cs @@ -0,0 +1,76 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Tests.CastleSiege; + +using MUnique.OpenMU.GameLogic.CastleSiege; + +/// +/// Tests for the Castle Siege phase state machine (time-driven, injected clock). +/// +[TestFixture] +public class CastleSiegeContextTest +{ + private static readonly DateTime T0 = new(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc); + + /// Tests that a fresh context starts in the ownership (resting) phase. + [Test] + public void StartsInOwnership() + { + var ctx = new CastleSiegeContext(Config()); + Assert.That(ctx.Phase, Is.EqualTo(CastleSiegePhase.Ownership)); + } + + /// Tests that force-starting moves the state machine into registration. + [Test] + public async Task ForceStartMovesToRegistrationAsync() + { + var ctx = new CastleSiegeContext(Config()); + await ctx.ForceStartRegistrationAsync(T0); + Assert.That(ctx.Phase, Is.EqualTo(CastleSiegePhase.Registration)); + } + + /// Tests that registration advances to preparation once its duration elapses. + [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)); + } + + /// Tests a full cycle: registration -> preparation -> siege -> settlement -> ownership. + [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)); + } + + /// Tests that guilds can be registered (by name) during the registration phase. + [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), + }; +}