diff --git a/src/GameLogic/CastleSiege/CastleSiegeConfiguration.cs b/src/GameLogic/CastleSiege/CastleSiegeConfiguration.cs index 6e18241..18952e4 100644 --- a/src/GameLogic/CastleSiege/CastleSiegeConfiguration.cs +++ b/src/GameLogic/CastleSiege/CastleSiegeConfiguration.cs @@ -48,6 +48,12 @@ public class CastleSiegeConfiguration /// Gets or sets the persisted registered guild names for the current cycle. public IList PersistedRegisteredGuilds { get; set; } = new List(); + /// Gets or sets the persisted auto-schedule days of week (empty = manual start only). + public IList PersistedScheduleDays { get; set; } = new List(); + + /// Gets or sets the persisted auto-schedule UTC time of day, or null if unscheduled. + public TimeOnly? PersistedScheduleTime { get; set; } + /// /// Returns true if falls within a 5-second window of any configured /// registration-open time. diff --git a/src/GameLogic/CastleSiege/CastleSiegeContext.cs b/src/GameLogic/CastleSiege/CastleSiegeContext.cs index f23ff94..840576f 100644 --- a/src/GameLogic/CastleSiege/CastleSiegeContext.cs +++ b/src/GameLogic/CastleSiege/CastleSiegeContext.cs @@ -23,6 +23,8 @@ public class CastleSiegeContext private string? _occupier; private int _defensesRemaining; private bool _dirty; + private List _scheduleDays = new(); + private TimeOnly? _scheduleTime; /// Initializes a new instance of the class. /// The cycle timing configuration. @@ -56,6 +58,12 @@ public class CastleSiegeContext /// Gets the guild names registered for the current cycle. public IReadOnlyList RegisteredGuilds => this._registeredGuilds; + /// Gets the days of week the siege auto-opens registration (empty = manual start only). + public IReadOnlyList ScheduleDays => this._scheduleDays; + + /// Gets the UTC time of day registration auto-opens on scheduled days, or null if unscheduled. + public TimeOnly? ScheduleTime => this._scheduleTime; + /// Advances the state machine based on the current time. /// The current UTC time. public ValueTask TickAsync(DateTime now) @@ -63,7 +71,7 @@ public class CastleSiegeContext switch (this.Phase) { case CastleSiegePhase.Ownership: - if (this.Configuration.IsRegistrationOpenTime(now)) + if (this.ShouldOpenRegistration(now)) { return this.ForceStartRegistrationAsync(now); } @@ -150,6 +158,41 @@ public class CastleSiegeContext this._dirty = true; } + /// + /// Sets the weekly auto-schedule (days of week + UTC time) and marks the state dirty for persistence. + /// Empty days disables auto-start (manual only). + /// + /// The days of week registration should auto-open. + /// The UTC time of day registration should auto-open, or null to disable. + public void SetSchedule(IEnumerable days, TimeOnly? time) + { + this._scheduleDays = days.Distinct().OrderBy(d => d).ToList(); + this._scheduleTime = this._scheduleDays.Count > 0 ? time : null; + this._dirty = true; + } + + /// + /// Returns whether registration should auto-open now: a scheduled day matches and the current UTC time + /// falls within a 5-second window at/after the scheduled time. Only meaningful in the ownership phase. + /// + /// The current UTC time. + public bool ShouldOpenRegistration(DateTime nowUtc) + { + if (this._scheduleTime is not { } scheduleTime || this._scheduleDays.Count == 0) + { + return false; + } + + if (!this._scheduleDays.Contains(nowUtc.DayOfWeek)) + { + return false; + } + + var nowTime = TimeOnly.FromDateTime(nowUtc); + var windowEnd = scheduleTime.Add(TimeSpan.FromSeconds(5)); + return scheduleTime <= nowTime && nowTime <= windowEnd; + } + /// /// 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. @@ -169,7 +212,9 @@ public class CastleSiegeContext /// The persisted phase. /// When the persisted phase started (UTC), or null to keep the default. /// The persisted registered guild names, or null. - public void RestoreState(string? owner, CastleSiegePhase phase, DateTime? phaseStartedUtc, IEnumerable? registeredGuilds) + /// The persisted auto-schedule days of week, or null. + /// The persisted auto-schedule UTC time, or null. + public void RestoreState(string? owner, CastleSiegePhase phase, DateTime? phaseStartedUtc, IEnumerable? registeredGuilds, IEnumerable? scheduleDays, TimeOnly? scheduleTime) { this.OwnerGuildName = owner; this.Phase = phase; @@ -180,6 +225,8 @@ public class CastleSiegeContext this._registeredGuilds.AddRange(registeredGuilds); } + this._scheduleDays = scheduleDays?.Distinct().OrderBy(d => d).ToList() ?? new List(); + this._scheduleTime = this._scheduleDays.Count > 0 ? scheduleTime : null; this._dirty = false; } diff --git a/src/GameLogic/PlugIns/ChatCommands/CastleSiegeScheduleChatCommandPlugIn.cs b/src/GameLogic/PlugIns/ChatCommands/CastleSiegeScheduleChatCommandPlugIn.cs new file mode 100644 index 0000000..04998e6 --- /dev/null +++ b/src/GameLogic/PlugIns/ChatCommands/CastleSiegeScheduleChatCommandPlugIn.cs @@ -0,0 +1,101 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.PlugIns.ChatCommands; + +using System.Globalization; +using System.Linq; +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; + +/// +/// Sets the Castle Siege weekly auto-schedule. GM only. Times are UTC. +/// Usage: /csschedule (show) | /csschedule Sunday 20:00 | /csschedule Sunday,Wednesday 20:00 | /csschedule clear. +/// +[Guid("A1B2C3D4-0006-4E5F-9A0B-CA5710000006")] +[PlugIn] +[Display(Name = "Castle Siege Schedule", Description = "GM command: /csschedule [ | clear]")] +[ChatCommandHelp(Command, CharacterStatus.GameMaster)] +public class CastleSiegeScheduleChatCommandPlugIn : IChatCommandPlugIn +{ + private const string Command = "/csschedule"; + + /// + public string Key => Command; + + /// + public CharacterStatus MinCharacterStatusRequirement => CharacterStatus.GameMaster; + + /// + public async ValueTask HandleCommandAsync(Player player, string command) + { + var context = CastleSiegeEventPlugIn.TryGetContext(player.GameContext); + if (context is null) + { + await ShowAsync(player, "Castle Siege plugin not active.").ConfigureAwait(false); + return; + } + + var parts = command.Split(' ', StringSplitOptions.RemoveEmptyEntries); + + // "/csschedule" -> show current schedule. + if (parts.Length == 1) + { + await ShowAsync(player, Describe(context)).ConfigureAwait(false); + return; + } + + // "/csschedule clear" -> disable auto-start. + if (parts.Length == 2 && string.Equals(parts[1], "clear", StringComparison.OrdinalIgnoreCase)) + { + context.SetSchedule(Array.Empty(), null); + await ShowAsync(player, "Castle Siege auto-schedule cleared (manual start only).").ConfigureAwait(false); + return; + } + + if (parts.Length < 3) + { + await ShowAsync(player, "Usage: /csschedule | /csschedule clear").ConfigureAwait(false); + return; + } + + var days = new List(); + foreach (var token in parts[1].Split(',', StringSplitOptions.RemoveEmptyEntries)) + { + if (!Enum.TryParse(token, true, out var day)) + { + await ShowAsync(player, $"Unknown day '{token}'. Use e.g. Sunday, Monday, Tuesday...").ConfigureAwait(false); + return; + } + + days.Add(day); + } + + if (!TimeOnly.TryParse(parts[2], CultureInfo.InvariantCulture, out var time)) + { + await ShowAsync(player, $"Invalid time '{parts[2]}'. Use 24h HH:mm (UTC).").ConfigureAwait(false); + return; + } + + context.SetSchedule(days, time); + await ShowAsync(player, $"{Describe(context)} Server UTC now: {DateTime.UtcNow:ddd HH:mm}.").ConfigureAwait(false); + } + + private static string Describe(CastleSiegeContext context) + { + if (context.ScheduleTime is not { } time || context.ScheduleDays.Count == 0) + { + return "Castle Siege auto-schedule: (none). Set with /csschedule ."; + } + + return $"Castle Siege auto-schedule: {string.Join(",", context.ScheduleDays)} at {time:HH:mm} UTC."; + } + + private static ValueTask ShowAsync(Player player, string text) + => player.InvokeViewPlugInAsync(p => p.ShowMessageAsync(text, MessageType.BlueNormal)); +} diff --git a/src/GameLogic/PlugIns/PeriodicTasks/CastleSiegeEventPlugIn.cs b/src/GameLogic/PlugIns/PeriodicTasks/CastleSiegeEventPlugIn.cs index 0c7ea92..4e14581 100644 --- a/src/GameLogic/PlugIns/PeriodicTasks/CastleSiegeEventPlugIn.cs +++ b/src/GameLogic/PlugIns/PeriodicTasks/CastleSiegeEventPlugIn.cs @@ -75,7 +75,7 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom // 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); + created.RestoreState(config.PersistedOwnerGuildName, config.PersistedPhase, config.PersistedPhaseStartedUtc, config.PersistedRegisteredGuilds, config.PersistedScheduleDays, config.PersistedScheduleTime); // Announce phase changes to the whole server and, when the siege begins, warp registered members. created.PhaseChanged += phase => _ = OnPhaseChangedAsync(gc, created, phase); @@ -150,6 +150,8 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom config.PersistedPhase = context.Phase; config.PersistedPhaseStartedUtc = context.PhaseStartedUtc; config.PersistedRegisteredGuilds = context.RegisteredGuilds.ToList(); + config.PersistedScheduleDays = context.ScheduleDays.ToList(); + config.PersistedScheduleTime = context.ScheduleTime; // Find our plugin-configuration row via the in-memory config graph to get its id. var pluginTypeId = typeof(CastleSiegeEventPlugIn).GUID; diff --git a/tests/MUnique.OpenMU.Tests/CastleSiege/CastleSiegeContextTest.cs b/tests/MUnique.OpenMU.Tests/CastleSiege/CastleSiegeContextTest.cs index e5b16d1..de31a8d 100644 --- a/tests/MUnique.OpenMU.Tests/CastleSiege/CastleSiegeContextTest.cs +++ b/tests/MUnique.OpenMU.Tests/CastleSiege/CastleSiegeContextTest.cs @@ -131,15 +131,36 @@ public class CastleSiegeContextTest var phaseChangedFired = false; ctx.PhaseChanged += _ => phaseChangedFired = true; - ctx.RestoreState("Winners", CastleSiegePhase.Ownership, T0, new[] { "Winners", "Losers" }); + ctx.RestoreState("Winners", CastleSiegePhase.Ownership, T0, new[] { "Winners", "Losers" }, new[] { DayOfWeek.Sunday }, new TimeOnly(20, 0)); 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(ctx.ScheduleDays, Is.EquivalentTo(new[] { DayOfWeek.Sunday })); + Assert.That(ctx.ScheduleTime, Is.EqualTo(new TimeOnly(20, 0))); Assert.That(phaseChangedFired, Is.False); Assert.That(ctx.ConsumeDirty(), Is.False, "restore must not mark the state dirty"); } + /// Tests that the weekly auto-schedule only opens registration on a matching day within the time window. + [Test] + public void ShouldOpenRegistrationMatchesDayAndTimeWindow() + { + var ctx = new CastleSiegeContext(Config()); + ctx.SetSchedule(new[] { DayOfWeek.Sunday }, new TimeOnly(20, 0)); + + var sunday = new DateTime(2026, 1, 4, 20, 0, 2, DateTimeKind.Utc); // a Sunday, +2s into the window + var sundayLate = new DateTime(2026, 1, 4, 20, 0, 30, DateTimeKind.Utc); // past the 5s window + var monday = new DateTime(2026, 1, 5, 20, 0, 2, DateTimeKind.Utc); // wrong day + + Assert.That(ctx.ShouldOpenRegistration(sunday), Is.True); + Assert.That(ctx.ShouldOpenRegistration(sundayLate), Is.False); + Assert.That(ctx.ShouldOpenRegistration(monday), Is.False); + + ctx.SetSchedule(Array.Empty(), null); // cleared -> never opens + Assert.That(ctx.ShouldOpenRegistration(sunday), Is.False); + } + /// Tests that state-changing operations flip the dirty flag, which ConsumeDirty reads-and-resets. [Test] public async Task DirtyFlagTracksPersistableChangesAsync()