diff --git a/Projects/UOContent.Tests/Tests/Engines/Events/EventSchedulerTests.cs b/Projects/UOContent.Tests/Tests/Engines/Events/EventSchedulerTests.cs index 5a7718c17..22a1ff65e 100644 --- a/Projects/UOContent.Tests/Tests/Engines/Events/EventSchedulerTests.cs +++ b/Projects/UOContent.Tests/Tests/Engines/Events/EventSchedulerTests.cs @@ -81,10 +81,11 @@ public class EventSchedulerTests () => called = true ); + Assert.Equal(Core._now, evt.NextOccurrence); Timer.Slice(8); Assert.True(called); - Assert.Equal(Core._now, evt.NextOccurrence); + Assert.Equal(DateTime.MaxValue, evt.NextOccurrence); evt.Cancel(); } diff --git a/Projects/UOContent/Engines/Events/EventScheduler.cs b/Projects/UOContent/Engines/Events/EventScheduler.cs index 6656f829d..8d442fe20 100644 --- a/Projects/UOContent/Engines/Events/EventScheduler.cs +++ b/Projects/UOContent/Engines/Events/EventScheduler.cs @@ -31,60 +31,6 @@ public enum DaysOfWeek : byte EveryDay = Sunday | Monday | Tuesday | Wednesday | Thursday | Friday | Saturday } -public abstract class ScheduledEvent -{ - public IRecurrencePattern Recurrence { get; } - public TimeZoneInfo TimeZone { get; } - public TimeOnly Time { get; } - public DateTime EndDate { get; } - public DateTime NextOccurrence { get; private set; } - public bool Cancelled { get; private set; } - - public ScheduledEvent(DateTime startOn, TimeZoneInfo timeZone = null) - : this(startOn, startOn, TimeOnly.FromDateTime(startOn), null, timeZone) - { - } - - public ScheduledEvent(DateTime startAfter, TimeOnly time, IRecurrencePattern recurrence, TimeZoneInfo timeZone = null) - : this(startAfter, DateTime.MaxValue, time, recurrence, timeZone) - { - } - - public ScheduledEvent( - DateTime startAfter, - DateTime endOn, - TimeOnly time, - IRecurrencePattern recurrence, - TimeZoneInfo timeZone = null - ) - { - Time = time; - Recurrence = recurrence; - TimeZone = timeZone ?? TimeZoneInfo.Utc; - - var afterUtc = startAfter.Kind == DateTimeKind.Utc ? startAfter : startAfter.LocalToUtc(TimeZone); - NextOccurrence = recurrence?.GetNextOccurrence(afterUtc, time, TimeZone) ?? afterUtc; - EndDate = endOn == DateTime.MaxValue || endOn.Kind == DateTimeKind.Utc ? endOn : endOn.LocalToUtc(TimeZone); - } - - public void Cancel() => Cancelled = true; - - public DateTime Advance() - { - OnEvent(); - - var next = Recurrence?.GetNextOccurrence(NextOccurrence, Time, TimeZone) ?? DateTime.MaxValue; - if (next == DateTime.MaxValue || next > EndDate) - { - return DateTime.MaxValue; - } - - return NextOccurrence = next; - } - - public abstract void OnEvent(); -} - public class EventScheduler : Timer { private static readonly ILogger logger = LogFactory.GetLogger(typeof(EventScheduler)); diff --git a/Projects/UOContent/Engines/Events/MonthDay.cs b/Projects/UOContent/Engines/Events/MonthDay.cs new file mode 100644 index 000000000..e4b4e7e34 --- /dev/null +++ b/Projects/UOContent/Engines/Events/MonthDay.cs @@ -0,0 +1,66 @@ +using System; + +namespace Server.Engines.Events; + +public record struct MonthDay : IComparable +{ + public byte Month { get; } + public byte Day { get; } + + public MonthDay(int year, int month, int day) + { + if (month is < 1 or > 12) + { + throw new ArgumentOutOfRangeException(nameof(month), "Month must be between 1 and 12."); + } + + var daysInMonth = DateTime.DaysInMonth(year, month); + + if (day < 1 || day > daysInMonth) + { + throw new ArgumentOutOfRangeException(nameof(day), $"Day must be between 1 and {daysInMonth} for month {month}."); + } + + Month = (byte)month; + Day = (byte)day; + } + + public int CompareTo(MonthDay other) + { + var monthComparison = Month.CompareTo(other.Month); + return monthComparison != 0 ? monthComparison : Day.CompareTo(other.Day); + } + + public static bool operator <(MonthDay left, MonthDay right) => left.CompareTo(right) < 0; + + public static bool operator >(MonthDay left, MonthDay right) => left.CompareTo(right) > 0; + + public static bool operator <=(MonthDay left, MonthDay right) => left.CompareTo(right) <= 0; + + public static bool operator >=(MonthDay left, MonthDay right) => left.CompareTo(right) >= 0; +} + +public static class MonthDayExtensions +{ + public static bool IsBetween(this DateTime dateTime, MonthDay start, MonthDay end) + { + var checkMonth = dateTime.Month; + var checkDay = dateTime.Day; + + var startMonth = start.Month; + var startDay = start.Day; + var endMonth = end.Month; + var endDay = end.Day; + + var isAfterStart = checkMonth > startMonth || checkMonth == startMonth && checkDay >= startDay; + var isBeforeEnd = checkMonth < endMonth || checkMonth == endMonth && checkDay <= endDay; + + if (startMonth < endMonth || startMonth == endMonth && startDay <= endDay) + { + return isAfterStart && isBeforeEnd; + } + + // Complex case: spans year boundary (e.g., Nov 15 - Feb 15) + return isAfterStart || isBeforeEnd; + } +} diff --git a/Projects/UOContent/Engines/Events/ScheduledEvent.cs b/Projects/UOContent/Engines/Events/ScheduledEvent.cs new file mode 100644 index 000000000..082017e0e --- /dev/null +++ b/Projects/UOContent/Engines/Events/ScheduledEvent.cs @@ -0,0 +1,60 @@ +using System; + +namespace Server.Engines.Events; + +public abstract class ScheduledEvent +{ + public IRecurrencePattern Recurrence { get; } + public TimeZoneInfo TimeZone { get; } + public TimeOnly Time { get; } + public DateTime EndDate { get; } + public DateTime NextOccurrence { get; protected set; } + public bool Cancelled { get; private set; } + + public ScheduledEvent(DateTime startOn, TimeZoneInfo timeZone = null) + : this(startOn, startOn, TimeOnly.FromDateTime(startOn), null, timeZone) + { + } + + public ScheduledEvent(DateTime startAfter, TimeOnly time, IRecurrencePattern recurrence, TimeZoneInfo timeZone = null) + : this(startAfter, DateTime.MaxValue, time, recurrence, timeZone) + { + } + + public ScheduledEvent( + DateTime startAfter, + DateTime endOn, + TimeOnly time, + IRecurrencePattern recurrence, + TimeZoneInfo timeZone = null + ) + { + Time = time; + Recurrence = recurrence; + TimeZone = timeZone ?? TimeZoneInfo.Utc; + EndDate = endOn == DateTime.MaxValue || endOn.Kind == DateTimeKind.Utc ? endOn : endOn.LocalToUtc(TimeZone); + + var afterUtc = startAfter.Kind == DateTimeKind.Utc ? startAfter : startAfter.LocalToUtc(TimeZone); + + var next = GetOccurrence(afterUtc); + // For the first occurrence, we should set it to the startAfter date if we have no recurrence. + NextOccurrence = next == DateTime.MaxValue ? afterUtc : next; + } + + protected virtual DateTime GetOccurrence(DateTime after) + { + var next = Recurrence?.GetNextOccurrence(after, Time, TimeZone) ?? DateTime.MaxValue; + return next >= EndDate ? DateTime.MaxValue : next; + } + + public void Cancel() => Cancelled = true; + + public virtual DateTime Advance() + { + OnEvent(); + + return NextOccurrence = GetOccurrence(NextOccurrence); + } + + public abstract void OnEvent(); +} diff --git a/Projects/UOContent/Engines/Events/YearlyCallbackScheduledEvent.cs b/Projects/UOContent/Engines/Events/YearlyCallbackScheduledEvent.cs new file mode 100644 index 000000000..75036d226 --- /dev/null +++ b/Projects/UOContent/Engines/Events/YearlyCallbackScheduledEvent.cs @@ -0,0 +1,33 @@ +using System; + +namespace Server.Engines.Events; + +public class YearlyCallbackScheduledEvent : YearlyScheduledEvent +{ + private readonly Action _callback; + + protected YearlyCallbackScheduledEvent( + DateTime startAfter, + TimeOnly time, + MonthDay yearlyStart, + MonthDay yearlyEnd, + Action callback, + IRecurrencePattern recurrence, + TimeZoneInfo timeZone = null + ) : this(startAfter, DateTime.MaxValue, time, yearlyStart, yearlyEnd, callback, recurrence, timeZone) + { + } + + protected YearlyCallbackScheduledEvent( + DateTime startAfter, + DateTime endOn, + TimeOnly time, + MonthDay yearlyStart, + MonthDay yearlyEnd, + Action callback, + IRecurrencePattern recurrence, + TimeZoneInfo timeZone = null + ) : base(startAfter, endOn, time, yearlyStart, yearlyEnd, recurrence, timeZone) => _callback = callback; + + public override void OnEvent() => _callback(); +} diff --git a/Projects/UOContent/Engines/Events/YearlyScheduledEvent.cs b/Projects/UOContent/Engines/Events/YearlyScheduledEvent.cs new file mode 100644 index 000000000..d29356839 --- /dev/null +++ b/Projects/UOContent/Engines/Events/YearlyScheduledEvent.cs @@ -0,0 +1,71 @@ +using System; + +namespace Server.Engines.Events; + +public abstract class YearlyScheduledEvent : ScheduledEvent +{ + public MonthDay YearlyStart { get; } + + public MonthDay YearlyEnd { get; } + + protected YearlyScheduledEvent( + DateTime startAfter, + TimeOnly time, + MonthDay yearlyStart, + MonthDay yearlyEnd, + IRecurrencePattern recurrence, + TimeZoneInfo timeZone = null + ) : this(startAfter, DateTime.MaxValue, time, yearlyStart, yearlyEnd, recurrence, timeZone) + { + } + + protected YearlyScheduledEvent( + DateTime startAfter, + DateTime endOn, + TimeOnly time, + MonthDay yearlyStart, + MonthDay yearlyEnd, + IRecurrencePattern recurrence, + TimeZoneInfo timeZone = null + ) : base(startAfter, endOn, time, recurrence, timeZone) + { + YearlyStart = yearlyStart; + YearlyEnd = yearlyEnd; + } + + protected override DateTime GetOccurrence(DateTime after) + { + var next = base.GetOccurrence(after); + + if (next == DateTime.MaxValue) + { + return DateTime.MaxValue; + } + + var localNext = TimeZoneInfo.ConvertTimeFromUtc(next, TimeZone); + + if (localNext.IsBetween(YearlyStart, YearlyEnd)) + { + return next; + } + + int yearToUse; + + // If we're after the end of this year's range but before the start of next year's range + if (YearlyStart > YearlyEnd && localNext.Month > YearlyEnd.Month) + { + // We're in the same calendar year, targeting this year's start + yearToUse = localNext.Year; + } + else + { + // Either we're in a non-spanning range, or we're in the early part of next year + // In either case, we need to advance to the next year's start + yearToUse = localNext.Year + 1; + } + + var nextYearStartUtc = new DateTime(yearToUse, YearlyStart.Month, YearlyStart.Day).LocalToUtc(TimeZone); + + return Recurrence!.GetNextOccurrence(nextYearStartUtc, Time, TimeZone); + } +}