3 Commits

Author SHA1 Message Date
Acentech Dev
268d40acd1 feat(CS-P1): GM chat commands (/csstatus /csstart /csphase /cssetowner /csreset)
Some checks failed
.NET Core / build (push) Has been cancelled
2026-07-14 22:44:46 +03:00
Acentech Dev
6999a780f3 feat(CS-P1): periodic Castle Siege plugin (per-context tick + custom config) 2026-07-14 22:41:46 +03:00
Acentech Dev
73de807bef feat(CS-P1): Castle Siege phase state machine (in-memory, time-injected) + tests 2026-07-14 22:40:24 +03:00
10 changed files with 544 additions and 0 deletions

View 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));
}
}

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

View 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,
}

View File

@@ -0,0 +1,49 @@
// <copyright file="CastleSiegePhaseChatCommandPlugIn.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.PlugIns.ChatCommands;
using System.Runtime.InteropServices;
using MUnique.OpenMU.GameLogic.CastleSiege;
using MUnique.OpenMU.GameLogic.PlugIns.PeriodicTasks;
using MUnique.OpenMU.GameLogic.Views;
using MUnique.OpenMU.Interfaces;
using MUnique.OpenMU.PlugIns;
/// <summary>Forces a specific Castle Siege phase. GM only. Usage: /csphase Siege.</summary>
[Guid("A1B2C3D4-0003-4E5F-9A0B-CA5710000003")]
[PlugIn]
[Display(Name = "Castle Siege Phase", Description = "GM command: /csphase <Ownership|Registration|Preparation|Siege|Settlement>")]
[ChatCommandHelp(Command, CharacterStatus.GameMaster)]
public class CastleSiegePhaseChatCommandPlugIn : IChatCommandPlugIn
{
private const string Command = "/csphase";
/// <inheritdoc />
public string Key => Command;
/// <inheritdoc/>
public CharacterStatus MinCharacterStatusRequirement => CharacterStatus.GameMaster;
/// <inheritdoc />
public async ValueTask HandleCommandAsync(Player player, string command)
{
var parts = command.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length < 2 || !Enum.TryParse<CastleSiegePhase>(parts[1], true, out var phase))
{
await player.InvokeViewPlugInAsync<IShowMessagePlugIn>(p => p.ShowMessageAsync("Usage: /csphase <Ownership|Registration|Preparation|Siege|Settlement>", MessageType.BlueNormal)).ConfigureAwait(false);
return;
}
var context = CastleSiegeEventPlugIn.TryGetContext(player.GameContext);
if (context is null)
{
await player.InvokeViewPlugInAsync<IShowMessagePlugIn>(p => p.ShowMessageAsync("Castle Siege plugin not active.", MessageType.BlueNormal)).ConfigureAwait(false);
return;
}
await context.ForcePhaseAsync(phase, DateTime.UtcNow).ConfigureAwait(false);
await player.InvokeViewPlugInAsync<IShowMessagePlugIn>(p => p.ShowMessageAsync($"Castle Siege: phase set to {phase}.", MessageType.BlueNormal)).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,41 @@
// <copyright file="CastleSiegeResetChatCommandPlugIn.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.PlugIns.ChatCommands;
using System.Runtime.InteropServices;
using MUnique.OpenMU.GameLogic.PlugIns.PeriodicTasks;
using MUnique.OpenMU.GameLogic.Views;
using MUnique.OpenMU.Interfaces;
using MUnique.OpenMU.PlugIns;
/// <summary>Resets Castle Siege to the ownership phase and clears registrations. GM only.</summary>
[Guid("A1B2C3D4-0005-4E5F-9A0B-CA5710000005")]
[PlugIn]
[Display(Name = "Castle Siege Reset", Description = "GM command: /csreset")]
[ChatCommandHelp(Command, CharacterStatus.GameMaster)]
public class CastleSiegeResetChatCommandPlugIn : IChatCommandPlugIn
{
private const string Command = "/csreset";
/// <inheritdoc />
public string Key => Command;
/// <inheritdoc/>
public CharacterStatus MinCharacterStatusRequirement => CharacterStatus.GameMaster;
/// <inheritdoc />
public async ValueTask HandleCommandAsync(Player player, string command)
{
var context = CastleSiegeEventPlugIn.TryGetContext(player.GameContext);
if (context is null)
{
await player.InvokeViewPlugInAsync<IShowMessagePlugIn>(p => p.ShowMessageAsync("Castle Siege plugin not active.", MessageType.BlueNormal)).ConfigureAwait(false);
return;
}
await context.ResetAsync(DateTime.UtcNow).ConfigureAwait(false);
await player.InvokeViewPlugInAsync<IShowMessagePlugIn>(p => p.ShowMessageAsync("Castle Siege reset to ownership phase.", MessageType.BlueNormal)).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,44 @@
// <copyright file="CastleSiegeSetOwnerChatCommandPlugIn.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.PlugIns.ChatCommands;
using System.Runtime.InteropServices;
using MUnique.OpenMU.GameLogic.PlugIns.PeriodicTasks;
using MUnique.OpenMU.GameLogic.Views;
using MUnique.OpenMU.Interfaces;
using MUnique.OpenMU.PlugIns;
/// <summary>Sets the Castle Siege owner guild by name. GM only. Usage: /cssetowner GuildName (empty clears).</summary>
[Guid("A1B2C3D4-0004-4E5F-9A0B-CA5710000004")]
[PlugIn]
[Display(Name = "Castle Siege Set Owner", Description = "GM command: /cssetowner <guildName>")]
[ChatCommandHelp(Command, CharacterStatus.GameMaster)]
public class CastleSiegeSetOwnerChatCommandPlugIn : IChatCommandPlugIn
{
private const string Command = "/cssetowner";
/// <inheritdoc />
public string Key => Command;
/// <inheritdoc/>
public CharacterStatus MinCharacterStatusRequirement => CharacterStatus.GameMaster;
/// <inheritdoc />
public async ValueTask HandleCommandAsync(Player player, string command)
{
var parts = command.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
var owner = parts.Length >= 2 ? parts[1].Trim() : null;
var context = CastleSiegeEventPlugIn.TryGetContext(player.GameContext);
if (context is null)
{
await player.InvokeViewPlugInAsync<IShowMessagePlugIn>(p => p.ShowMessageAsync("Castle Siege plugin not active.", MessageType.BlueNormal)).ConfigureAwait(false);
return;
}
context.SetOwner(string.IsNullOrWhiteSpace(owner) ? null : owner);
await player.InvokeViewPlugInAsync<IShowMessagePlugIn>(p => p.ShowMessageAsync($"Castle Siege owner set to {owner ?? "(none)"}.", MessageType.BlueNormal)).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,41 @@
// <copyright file="CastleSiegeStartChatCommandPlugIn.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.PlugIns.ChatCommands;
using System.Runtime.InteropServices;
using MUnique.OpenMU.GameLogic.PlugIns.PeriodicTasks;
using MUnique.OpenMU.GameLogic.Views;
using MUnique.OpenMU.Interfaces;
using MUnique.OpenMU.PlugIns;
/// <summary>Forces the Castle Siege into the registration phase. GM only.</summary>
[Guid("A1B2C3D4-0002-4E5F-9A0B-CA5710000002")]
[PlugIn]
[Display(Name = "Castle Siege Start", Description = "GM command: /csstart")]
[ChatCommandHelp(Command, CharacterStatus.GameMaster)]
public class CastleSiegeStartChatCommandPlugIn : IChatCommandPlugIn
{
private const string Command = "/csstart";
/// <inheritdoc />
public string Key => Command;
/// <inheritdoc/>
public CharacterStatus MinCharacterStatusRequirement => CharacterStatus.GameMaster;
/// <inheritdoc />
public async ValueTask HandleCommandAsync(Player player, string command)
{
var context = CastleSiegeEventPlugIn.TryGetContext(player.GameContext);
if (context is null)
{
await player.InvokeViewPlugInAsync<IShowMessagePlugIn>(p => p.ShowMessageAsync("Castle Siege plugin not active.", MessageType.BlueNormal)).ConfigureAwait(false);
return;
}
await context.ForceStartRegistrationAsync(DateTime.UtcNow).ConfigureAwait(false);
await player.InvokeViewPlugInAsync<IShowMessagePlugIn>(p => p.ShowMessageAsync("Castle Siege: registration started.", MessageType.BlueNormal)).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,35 @@
// <copyright file="CastleSiegeStatusChatCommandPlugIn.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.PlugIns.ChatCommands;
using System.Runtime.InteropServices;
using MUnique.OpenMU.GameLogic.PlugIns.PeriodicTasks;
using MUnique.OpenMU.GameLogic.Views;
using MUnique.OpenMU.Interfaces;
using MUnique.OpenMU.PlugIns;
/// <summary>Shows the current Castle Siege status. GM only.</summary>
[Guid("A1B2C3D4-0001-4E5F-9A0B-CA5710000001")]
[PlugIn]
[Display(Name = "Castle Siege Status", Description = "GM command: /csstatus")]
[ChatCommandHelp(Command, CharacterStatus.GameMaster)]
public class CastleSiegeStatusChatCommandPlugIn : IChatCommandPlugIn
{
private const string Command = "/csstatus";
/// <inheritdoc />
public string Key => Command;
/// <inheritdoc/>
public CharacterStatus MinCharacterStatusRequirement => CharacterStatus.GameMaster;
/// <inheritdoc />
public async ValueTask HandleCommandAsync(Player player, string command)
{
var context = CastleSiegeEventPlugIn.TryGetContext(player.GameContext);
var text = context?.GetStatusText() ?? "Castle Siege plugin not active.";
await player.InvokeViewPlugInAsync<IShowMessagePlugIn>(p => p.ShowMessageAsync(text, MessageType.BlueNormal)).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,56 @@
// <copyright file="CastleSiegeEventPlugIn.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.PlugIns.PeriodicTasks;
using System.Collections.Concurrent;
using System.Runtime.InteropServices;
using MUnique.OpenMU.GameLogic.CastleSiege;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// Drives the Castle Siege phase state machine: ticks it every second and carries its configuration.
/// State is per-<see cref="IGameContext"/> and kept in memory (P1: no persistence).
/// </summary>
[PlugIn]
[Display(Name = nameof(CastleSiegeEventPlugIn), Description = "Castle Siege event (P1 skeleton: phase state machine + scheduling).")]
[Guid("6E2C8B41-9A4D-4C2E-9E7B-1F2A3B4C5D60")]
public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustomConfiguration<CastleSiegeConfiguration>, ISupportDefaultCustomConfiguration
{
private static readonly ConcurrentDictionary<IGameContext, CastleSiegeContext> Contexts = new();
/// <inheritdoc />
public CastleSiegeConfiguration? Configuration { get; set; }
/// <summary>
/// Gets the Castle Siege context for a game context, if the periodic tick has initialized it.
/// </summary>
/// <param name="gameContext">The game context.</param>
/// <returns>The context, or <c>null</c> if not yet initialized.</returns>
/// <remarks>Static so GM chat commands can reach the state machine without a plugin instance
/// (<c>GetKnownPlugInsOf</c> returns Types, not instances). The tick runs every second, so the
/// context exists within ~1s of server start.</remarks>
public static CastleSiegeContext? TryGetContext(IGameContext gameContext)
=> Contexts.TryGetValue(gameContext, out var context) ? context : null;
/// <inheritdoc />
public object CreateDefaultConfig() => new CastleSiegeConfiguration();
/// <inheritdoc />
public async ValueTask ExecuteTaskAsync(GameContext gameContext)
{
var context = Contexts.GetOrAdd(gameContext, _ => new CastleSiegeContext(this.Configuration ?? new CastleSiegeConfiguration()));
await context.TickAsync(DateTime.UtcNow).ConfigureAwait(false);
}
/// <inheritdoc />
public void ForceStart()
{
// Force-start applies to every active game context's siege.
foreach (var context in Contexts.Values)
{
_ = context.ForceStartRegistrationAsync(DateTime.UtcNow);
}
}
}

View File

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