diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index e4c8acc17..c2f1c3b2f 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -81,7 +81,7 @@ jobs: run: dnf makecache --refresh && dnf install -y findutils libicu libdeflate-devel zstd libargon2-devel if: ${{ matrix.packageManager == 'dnf' }} - name: Install Prerequisites using apt - run: apt-get update -y && apt-get install -y curl libicu-dev libdeflate-dev zstd libargon2-dev + run: apt-get update -y && apt-get install -y curl libicu-dev libdeflate-dev zstd libargon2-dev tzdata if: ${{ matrix.packageManager == 'apt' }} - uses: actions/checkout@v4 with: diff --git a/Projects/Server/Main.cs b/Projects/Server/Main.cs index d5eae2b32..b1a76f014 100644 --- a/Projects/Server/Main.cs +++ b/Projects/Server/Main.cs @@ -102,7 +102,8 @@ public static class Core private static long _tickCount; - private static DateTime _now; + // Make this available to unit tests for mocking + internal static DateTime _now; public static long TickCount => _tickCount; diff --git a/Projects/Server/Server.csproj b/Projects/Server/Server.csproj index f7853ec8d..9142059c6 100644 --- a/Projects/Server/Server.csproj +++ b/Projects/Server/Server.csproj @@ -47,5 +47,8 @@ <_Parameter1>Server.Tests + + <_Parameter1>UOContent.Tests + diff --git a/Projects/Server/Utilities/Utility.cs b/Projects/Server/Utilities/Utility.cs index 9f61d7446..50dd43de7 100644 --- a/Projects/Server/Utilities/Utility.cs +++ b/Projects/Server/Utilities/Utility.cs @@ -1468,4 +1468,21 @@ public static partial class Utility table.Remove(key); table.Add(key, value); } + + public static DateTime LocalToUtc(this DateTime local, TimeZoneInfo tz) + { + if (tz.IsInvalidTime(local)) + { + // For hourly recurrence, just subtract the standard offset (simulate as if the time exists) + return DateTime.SpecifyKind(local - tz.BaseUtcOffset, DateTimeKind.Utc); + } + + if (tz.IsAmbiguousTime(local)) + { + var offsets = tz.GetAmbiguousTimeOffsets(local); + return DateTime.SpecifyKind(local - offsets[1], DateTimeKind.Utc); + } + + return DateTime.SpecifyKind(local - tz.GetUtcOffset(local), DateTimeKind.Utc); + } } diff --git a/Projects/UOContent.Tests/Tests/Engines/Events/EventSchedulerTests.cs b/Projects/UOContent.Tests/Tests/Engines/Events/EventSchedulerTests.cs new file mode 100644 index 000000000..fe5d0c280 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/Events/EventSchedulerTests.cs @@ -0,0 +1,280 @@ +using System; +using System.Collections.Generic; +using Server; +using Server.Engines.Events; +using Xunit; + +namespace UOContent.Tests; + +[Collection("Sequential Tests")] +public class EventSchedulerTests +{ + private static void Init() + { + Core._now = DateTime.UtcNow; + Timer.Init(0); + EventScheduler.Configure(); + } + + private static void Finish() + { + EventScheduler.Instance.Stop(); + } + + [Fact] + public void ScheduleEvent_ExecutesCallback_HappyPath() + { + Init(); + + try + { + + } + finally + { + bool called = false; + var evt = EventScheduler.Instance.ScheduleEvent(Core._now, () => called = true); + + Timer.Slice(8); + + Assert.True(called); + Assert.Equal(Core._now, evt.NextOccurrence); + } + + Finish(); + } + + [Theory] + [InlineData(2024, 3, 10, 2, 0, "America/New_York")] // DST spring forward gap (invalid) + [InlineData(2024, 11, 3, 1, 0, "America/New_York")] // DST fall back (ambiguous) + [InlineData(2024, 6, 1, 5, 0, "America/New_York")] // Normal time + public void HourlyRecurrence( + int year, int month, int day, int hour, int minute, string tzId) + { + Init(); + + try + { + var tz = TimeZoneInfo.FindSystemTimeZoneById(tzId); + var local = new DateTime(year, month, day, hour, minute, 0, DateTimeKind.Unspecified); + + // Set Core._now to just before the target hour + var beforeLocal = local.AddHours(-1); + Core._now = beforeLocal.LocalToUtc(tz); + + bool called = false; + var evt = EventScheduler.Instance.ScheduleEvent( + Core._now, + () => called = true, + EventScheduler.Hourly, + tz + ); + + // Advance to the target hour + Core._now = local.LocalToUtc(tz); + Assert.Equal(Core._now, evt.NextOccurrence); + Timer.Slice(8); + + // Hourly recurrence should always execute, even in DST gaps/ambiguous times + Assert.True(called); + Assert.Equal(Core._now.AddHours(1), evt.NextOccurrence); + } + finally + { + Finish(); + } + } + + [Theory] + [InlineData(2024, 3, 10, 2, 0, "America/New_York")] // DST spring forward gap + [InlineData(2024, 11, 3, 1, 30, "America/New_York")] // DST fall back + [InlineData(2024, 6, 2, 5, 0, "America/New_York")] // Normal time + public void MonthlyRecurrence( + int year, int month, int day, int hour, int minute, string tzId) + { + Init(); + + try + { + var tz = TimeZoneInfo.FindSystemTimeZoneById(tzId); + var local = new DateTime(year, month, day, hour, minute, 0, DateTimeKind.Unspecified); + + Core._now = local.AddDays(-1).LocalToUtc(tz); + + bool called = false; + var evt = EventScheduler.Instance.ScheduleEvent( + Core._now, + () => called = true, + EventScheduler.GetMonthlyRecurrence(day), + tz + ); + + Core._now = local.LocalToUtc(tz); + var invalidTime = tz.IsInvalidTime(local); + + // Invalid time ranges are not executed, so the occurrence is an additional month later + Assert.Equal(invalidTime ? local.AddMonths(1).LocalToUtc(tz) : Core._now, evt.NextOccurrence); + + Timer.Slice(8); + if (invalidTime) + { + Assert.False(called); + } + else + { + Assert.True(called); + } + } + finally + { + Finish(); + } + } + + [Theory] + [InlineData(2024, 11, 3, 1, 30, DayOfWeek.Sunday, OrdinalDayOccurrence.First, "America/New_York")] // DST fallback + [InlineData(2024, 3, 31, 0, 0, DayOfWeek.Sunday, OrdinalDayOccurrence.Last, "America/New_York")] // Last Sunday + [InlineData(2024, 3, 31, 0, 0, DayOfWeek.Sunday, OrdinalDayOccurrence.Fifth, "America/New_York")] // Fifth Sunday + [InlineData(2024, 4, 3, 0, 0, DayOfWeek.Sunday, OrdinalDayOccurrence.Fifth, "America/New_York")] // Fifth Sunday (Doesn't exist) + public void MonthlyOrdinalRecurrence( + int year, int month, int day, int hour, int minute, DayOfWeek dow, OrdinalDayOccurrence ordinal, string tzId) + { + Init(); + + try + { + var tz = TimeZoneInfo.FindSystemTimeZoneById(tzId); + var local = new DateTime(year, month, day, hour, minute, 0).AddDays(-1); + Core._now = local.LocalToUtc(tz); + + bool called = false; + var pattern = new MonthlyOrdinalRecurrencePattern(ordinal, dow); + var evt = EventScheduler.Instance.ScheduleEvent( + Core._now, + () => called = true, + pattern, + tz + ); + + var testTime = Core._now.AddDays(1); + + if (month == 4 && ordinal == OrdinalDayOccurrence.Fifth) + { + // If there is no fifth occurrence, NextOccurrence should be in the next month + var expectedNext = pattern.GetNextOccurrence(Core._now, tz); + Assert.Equal(expectedNext, evt.NextOccurrence); + Core._now = testTime; + Timer.Slice(8); + Assert.False(called); + } + else + { + Assert.Equal(testTime, evt.NextOccurrence); + Core._now = testTime; + Timer.Slice(8); + Assert.True(called); + } + } + finally + { + Finish(); + } + } + + [Fact] + public void ScheduleEvent_NullCallback_Throws() + { + Init(); + + try + { + Assert.Throws(() => + EventScheduler.Instance.ScheduleEvent(Core._now, null) + ); + } + finally + { + Finish(); + } + } + + [Fact] + public void AdvanceEvent_PastEndDate_DoesNotReschedule() + { + Init(); + + try + { + bool called = false; + var evt = new CallbackScheduledEvent(Core._now, Core._now, () => called = true); + + EventScheduler.Instance.ScheduleEvent(evt); + + Timer.Slice(8); + Assert.True(called); + + Assert.DoesNotContain( + evt, + typeof(EventScheduler) + .GetField( + "_schedule", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance + )! + .GetValue(EventScheduler.Instance) as IEnumerable ?? [] + ); + } + finally + { + Finish(); + } + } + + [Theory] + [InlineData("UTC")] // No DST adjustments + [InlineData("Asia/Kathmandu")] // Unusual offset (UTC+5:45) + public void LocalToUtc_SpecialTimeZones_HandlesCorrectly(string tzId) + { + Init(); + + try + { + var tz = TimeZoneInfo.FindSystemTimeZoneById(tzId); + var local = new DateTime(2024, 6, 1, 12, 0, 0, DateTimeKind.Unspecified); + var expected = TimeZoneInfo.ConvertTimeToUtc(local, tz); + + var actual = local.LocalToUtc(tz); + + Assert.Equal(expected, actual); + Assert.Equal(DateTimeKind.Utc, actual.Kind); + } + finally + { + Finish(); + } + } + + [Fact] + public void LocalToUtc_EdgeCases_BeforeAndAfterTransition() + { + Init(); + + try + { + var tz = TimeZoneInfo.FindSystemTimeZoneById("America/New_York"); + // 1:59 AM (just before spring forward) + var beforeSpring = new DateTime(2024, 3, 10, 1, 59, 0); + // 3:01 AM (just after spring forward) + var afterSpring = new DateTime(2024, 3, 10, 3, 1, 0); + + var beforeUtc = beforeSpring.LocalToUtc(tz); + var afterUtc = afterSpring.LocalToUtc(tz); + + // Should be 1 hour + 2 minutes apart in UTC (not 1 hour 2 minutes) + Assert.Equal(2, (afterUtc - beforeUtc).TotalMinutes); + } + finally + { + Finish(); + } + } +} diff --git a/Projects/UOContent/Engines/Events/BroadcastEvent.cs b/Projects/UOContent/Engines/Events/BroadcastEvent.cs deleted file mode 100644 index a900d263b..000000000 --- a/Projects/UOContent/Engines/Events/BroadcastEvent.cs +++ /dev/null @@ -1,30 +0,0 @@ -namespace Server.Engines.Events -{ - public class BroadcastEvent : IEvent - { - private readonly int _hue; - private readonly string _text; - - public BroadcastEvent(int hue, string text) - { - _hue = hue; - _text = text; - } - - public void OnEventScheduled() - { - World.Broadcast(_hue, true, _text); - } - - public static void Initialize() - { - /* - EventScheduler.Instance.ScheduleEvent(new BroadcastEvent(22, "Test Message Please Ignore 2min"), 0, 0, TimeSpan.FromMinutes(2.0)); - EventScheduler.Instance.ScheduleEvent(new BroadcastEvent(33, "Test Message Please Ignore 3min"), 0, 0, TimeSpan.FromMinutes(3.0)); - EventScheduler.Instance.ScheduleEvent(new BroadcastEvent(44, "Test Message Please Ignore 4min"), 0, 0, TimeSpan.FromMinutes(4.0)); - */ - } - - public override string ToString() => $"Broadcast: {_text}"; - } -} diff --git a/Projects/UOContent/Engines/Events/CallbackScheduledEvent.cs b/Projects/UOContent/Engines/Events/CallbackScheduledEvent.cs new file mode 100644 index 000000000..1034314f3 --- /dev/null +++ b/Projects/UOContent/Engines/Events/CallbackScheduledEvent.cs @@ -0,0 +1,28 @@ +using System; + +namespace Server.Engines.Events; + +public sealed class CallbackScheduledEvent : ScheduledEvent +{ + private readonly Action _callback; + + public CallbackScheduledEvent( + DateTime afterUtc, + Action callback, + IRecurrencePattern recurrencePattern = null, + TimeZoneInfo timeZone = null + ) : base(afterUtc, recurrencePattern, timeZone) => + _callback = callback ?? throw new ArgumentNullException(nameof(callback)); + + public CallbackScheduledEvent( + DateTime afterUtc, + DateTime endDate, + Action callback, + IRecurrencePattern recurrencePattern = null, + TimeZoneInfo timeZone = null + ) : base(afterUtc, endDate, recurrencePattern, timeZone) => + _callback = callback ?? throw new ArgumentNullException(nameof(callback)); + + + public override void OnEvent() => _callback(); +} diff --git a/Projects/UOContent/Engines/Events/CommonRecurrencePatterns.cs b/Projects/UOContent/Engines/Events/CommonRecurrencePatterns.cs new file mode 100644 index 000000000..5b13bb07e --- /dev/null +++ b/Projects/UOContent/Engines/Events/CommonRecurrencePatterns.cs @@ -0,0 +1,171 @@ +namespace Server.Engines.Events; + +using System; + +public class HourlyRecurrencePattern : IRecurrencePattern +{ + public int IntervalHours { get; } + + public HourlyRecurrencePattern(int intervalHours = 1) => IntervalHours = Math.Max(1, intervalHours); + + public DateTime GetNextOccurrence(DateTime afterUtc, TimeZoneInfo timeZone) => afterUtc.AddHours(IntervalHours); +} + +public class DailyRecurrencePattern : IRecurrencePattern +{ + public int IntervalDays { get; } + + public DailyRecurrencePattern(int intervalDays = 1) => IntervalDays = Math.Max(1, intervalDays); + + public DateTime GetNextOccurrence(DateTime afterUtc, TimeZoneInfo timeZone) => afterUtc.AddDays(IntervalDays); +} + +public class WeeklyRecurrencePattern : IRecurrencePattern +{ + public int IntervalWeeks { get; } + public DaysOfWeek DaysOfWeek { get; } + + public WeeklyRecurrencePattern(int intervalWeeks = 1, DaysOfWeek daysOfWeek = DaysOfWeek.None) + { + IntervalWeeks = Math.Max(1, intervalWeeks); + DaysOfWeek = daysOfWeek; + } + + public DateTime GetNextOccurrence(DateTime afterUtc, TimeZoneInfo timeZone) + { + var local = TimeZoneInfo.ConvertTimeFromUtc(afterUtc, timeZone); + var weekStart = local.Date.AddDays(1); + var daysOfWeek = DaysOfWeek; + + // Set the day of the week to whatever day it is now + if (daysOfWeek == DaysOfWeek.None) + { + daysOfWeek = (DaysOfWeek)(1 << (int)local.DayOfWeek); + } + + // Example: + // Recurrence is Monday, Wednesday, Friday - and today is Wednesday + // weekStart will be Thursday, and then we check every day for 7 days to find the next occurrence match. + for (int i = 0; i < 7; i++) + { + var candidate = weekStart.AddDays(i); + var candidateDay = (DaysOfWeek)(1 << (int)candidate.DayOfWeek); + + if ((daysOfWeek & candidateDay) != 0 && candidate > local && !timeZone.IsInvalidTime(candidate)) + { + return candidate.LocalToUtc(timeZone); + } + } + + return DateTime.MaxValue; + } +} + +public class MonthlyRecurrencePattern : IRecurrencePattern +{ + public int DayOfMonth { get; } + public int IntervalMonths { get; } + + public MonthlyRecurrencePattern(int dayOfMonth = -1, int intervalMonths = 1) + { + DayOfMonth = dayOfMonth != -1 ? Math.Clamp(dayOfMonth, 1, 31) : -1; + IntervalMonths = Math.Max(1, intervalMonths); + } + + public DateTime GetNextOccurrence(DateTime afterUtc, TimeZoneInfo timeZone) + { + var local = TimeZoneInfo.ConvertTimeFromUtc(afterUtc, timeZone); + var year = local.Year; + var month = local.Month; + var time = local.TimeOfDay; + var day = DayOfMonth == -1 ? local.Day : DayOfMonth; + + for (int i = 0; i < 100; i++) + { + var nextMonth = month + IntervalMonths * i; + var candidate = new DateTime(year, 1, 1) + .AddMonths(nextMonth - 1) + .AddDays(day - 1) + .Add(time); + + // Some months may not have that day of the month, if not, we skip to the next interval + if (candidate > local && candidate.Day == day && !timeZone.IsInvalidTime(candidate)) + { + return candidate.LocalToUtc(timeZone); + } + } + + return DateTime.MaxValue; + } +} + +public class MonthlyOrdinalRecurrencePattern : IRecurrencePattern +{ + public int IntervalMonths { get; } + public OrdinalDayOccurrence Ordinal { get; } + public DayOfWeek DayOfWeek { get; } + + public MonthlyOrdinalRecurrencePattern(OrdinalDayOccurrence ordinal, DayOfWeek dayOfWeek, int intervalMonths = 1) + { + IntervalMonths = Math.Max(1, intervalMonths); + Ordinal = ordinal; + DayOfWeek = dayOfWeek; + } + + public DateTime GetNextOccurrence(DateTime afterUtc, TimeZoneInfo timeZone) + { + var local = TimeZoneInfo.ConvertTimeFromUtc(afterUtc, timeZone); + var year = local.Year; + var month = local.Month; + + for (int i = 0; i < 100; i++) + { + var nextMonth = month + IntervalMonths * i; + var candidateYearOffset = Math.DivRem(nextMonth - 1, 12, out var candidateMonthOffset); + var candidateYear = year + candidateYearOffset; + var candidateMonth = candidateMonthOffset + 1; + + DateTime candidate; + if (Ordinal >= OrdinalDayOccurrence.First) + { + // Find the first day of the month + var firstOfMonth = new DateTime(candidateYear, candidateMonth, 1, local.Hour, local.Minute, local.Second); + + // Find the first occurrence of the desired day + int daysOffset = ((int)DayOfWeek - (int)firstOfMonth.DayOfWeek + 7) % 7; + if (daysOffset > 7) + { + daysOffset -= 7; + } + candidate = firstOfMonth.AddDays(daysOffset + 7 * (int)Ordinal); + + // If candidate is not in the same month, skip + if (candidate.Month != candidateMonth) + { + continue; + } + } + else + { + // Find the last day of the month + var daysInMonth = DateTime.DaysInMonth(candidateYear, candidateMonth); + var lastOfMonth = new DateTime(candidateYear, candidateMonth, daysInMonth, local.Hour, local.Minute, local.Second); + + // Find the last occurrence of the desired day + int daysOffset = (int)lastOfMonth.DayOfWeek - (int)DayOfWeek + 7; + if (daysOffset >= 7) + { + daysOffset -= 7; + } + candidate = lastOfMonth.AddDays(-daysOffset); + } + + if (candidate > local && !timeZone.IsInvalidTime(candidate)) + { + return candidate.LocalToUtc(timeZone); + } + } + + return DateTime.MaxValue; + } +} diff --git a/Projects/UOContent/Engines/Events/EventScheduler.cs b/Projects/UOContent/Engines/Events/EventScheduler.cs index 363bfa320..a5e04f55f 100644 --- a/Projects/UOContent/Engines/Events/EventScheduler.cs +++ b/Projects/UOContent/Engines/Events/EventScheduler.cs @@ -1,95 +1,215 @@ using System; using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; -namespace Server.Engines.Events +namespace Server.Engines.Events; + +public interface IRecurrencePattern { - public interface IEvent + /// + /// Get the next occurrence of the event. + /// DateTime of the next occurence in UTC or DateTime.MaxValue + /// + DateTime GetNextOccurrence(DateTime afterUtc, TimeZoneInfo timeZone); +} + +public enum OrdinalDayOccurrence { Last = -1, First, Second, Third, Fourth, Fifth } + +[Flags] +public enum DaysOfWeek : byte +{ + None = 0, + Sunday = 1, + Monday = 2, + Tuesday = 4, + Wednesday = 8, + Thursday = 16, + Friday = 32, + Saturday = 64, + EveryDay = Sunday | Monday | Tuesday | Wednesday | Thursday | Friday | Saturday +} + +public abstract class ScheduledEvent +{ + private static Serial _nextSerial = (Serial)1; + + // Tie breaker for sorted set + public Serial Serial { get; } + public IRecurrencePattern Recurrence { get; } + public TimeZoneInfo TimeZone { get; } + public DateTime EndDate { get; } + public DateTime NextOccurrence { get; private set; } + + public ScheduledEvent(DateTime startOn, TimeZoneInfo timeZone = null) + : this(startOn, startOn, null, timeZone) { - void OnEventScheduled(); } - public class EventScheduleEntry + public ScheduledEvent(DateTime afterUtc, IRecurrencePattern recurrence, TimeZoneInfo timeZone = null) + : this(afterUtc, DateTime.MaxValue, recurrence, timeZone) { - private readonly IEvent _event; - private TimeSpan _offset; - - public EventScheduleEntry(IEvent e, DateTime firstSpawn, TimeSpan interval, TimeSpan offset) - { - _offset = offset; - _event = e; - Interval = interval; - NextOccurrence = firstSpawn; - } - - public DateTime NextOccurrence { get; private set; } - public TimeSpan Interval { get; } - - public void Occur() - { - NextOccurrence += Interval; - - _event?.OnEventScheduled(); - } - - public override string ToString() => _event?.ToString(); } - public class EventScheduler : Timer + public ScheduledEvent( + DateTime afterUtc, + DateTime endDateUtc, + IRecurrencePattern recurrence, + TimeZoneInfo timeZone = null + ) { - private static EventScheduler _instance; - private readonly List _schedule = new(); + Serial = _nextSerial++; + Recurrence = recurrence; + TimeZone = timeZone ?? TimeZoneInfo.Utc; + NextOccurrence = recurrence?.GetNextOccurrence(afterUtc, TimeZone) ?? afterUtc; + EndDate = endDateUtc; + } - private EventScheduler() : base(TimeSpan.Zero, TimeSpan.FromSeconds(1.0)) + public bool Advance() + { + OnEvent(); + + var next = Recurrence?.GetNextOccurrence(NextOccurrence, TimeZone) ?? DateTime.MaxValue; + if (next == DateTime.MaxValue || next > EndDate) { + return false; } - public static EventScheduler Instance => _instance ??= new EventScheduler(); - public static List AvailableEvents { get; } = new(); + NextOccurrence = next; + return true; + } - public static void Initialize() + public abstract void OnEvent(); +} + +public class EventScheduler : Timer +{ + private readonly SortedSet _schedule = new(ScheduledEventComparer.Default); + + public static EventScheduler Instance { get; private set; } + + public static IRecurrencePattern Hourly => new HourlyRecurrencePattern(); + + public static IRecurrencePattern Daily => new DailyRecurrencePattern(); + + // Recur every week, on the same day/time as the first occurence + public static IRecurrencePattern Weekly => new WeeklyRecurrencePattern(); + + // Recur every two weeks, on the same day/time as the first occurence + public static IRecurrencePattern Biweekly => new WeeklyRecurrencePattern(2); + + public static IRecurrencePattern Monthly => new MonthlyRecurrencePattern(); + + public static IRecurrencePattern Yearly => new MonthlyRecurrencePattern(-1, 12); + + // For each of the days of the week + private static readonly Dictionary _monthlyRecurrenceByDay = []; + + public static IRecurrencePattern GetMonthlyRecurrence(int dayOfMonth) + { + ref var pattern = ref CollectionsMarshal.GetValueRefOrAddDefault(_monthlyRecurrenceByDay, dayOfMonth, out var exists); + if (!exists) { - Instance.Start(); + pattern = new MonthlyRecurrencePattern(dayOfMonth); } - public void ScheduleEvent(IEvent e, int hour, int min) - { - ScheduleEvent(e, hour, min, TimeSpan.FromDays(1.0)); - } + return pattern; + } - public void ScheduleEvent(IEvent e, int hour, int min, TimeSpan interval) - { - var now = Core.Now; - var firstRun = new DateTime(now.Year, now.Month, now.Day, hour, min, 0); + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ScheduledEvent HourlyAt(DateTime startOn, Action action, TimeZoneInfo timeZone = null) => + Instance.ScheduleEvent(startOn, action, Hourly, timeZone); - while (now > firstRun) + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ScheduledEvent DailyAt(DateTime startOn, Action action, TimeZoneInfo timeZone = null) => + Instance.ScheduleEvent(startOn, action, Daily, timeZone); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ScheduledEvent WeeklyAt(DateTime startOn, Action action, TimeZoneInfo timeZone = null) => + Instance.ScheduleEvent(startOn, action, Weekly, timeZone); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ScheduledEvent BiweeklyAt(DateTime startOn, Action action, TimeZoneInfo timeZone = null) => + Instance.ScheduleEvent(startOn, action, Biweekly, timeZone); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ScheduledEvent MonthlyAt(DateTime startOn, Action action, TimeZoneInfo timeZone = null) => + Instance.ScheduleEvent(startOn, action, GetMonthlyRecurrence(startOn.Day), timeZone); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ScheduledEvent YearlyAt(DateTime startOn, Action action, TimeZoneInfo timeZone = null) => + Instance.ScheduleEvent(startOn, action, GetMonthlyRecurrence(startOn.Day), timeZone); + + public static void Configure() + { + Instance ??= new EventScheduler(); + Instance.Start(); + } + + private EventScheduler() : base(TimeSpan.Zero, TimeSpan.FromSeconds(1.0)) + { + } + + public ScheduledEvent ScheduleEvent( + DateTime afterUtc, + Action callback, + IRecurrencePattern recurrencePattern = null, + TimeZoneInfo timeZone = null + ) + { + var scheduledEvent = new CallbackScheduledEvent(afterUtc, callback, recurrencePattern, timeZone); + ScheduleEvent(scheduledEvent); + return scheduledEvent; + } + + public void ScheduleEvent(ScheduledEvent e) => _schedule.Add(e); + + public void StopEvent(ScheduledEvent entry) => _schedule.Remove(entry); + + protected override void OnTick() + { + var now = Core.Now; + + while (_schedule.Count > 0) + { + var entry = _schedule.Min!; + if (entry.NextOccurrence > now) { - firstRun += interval; + break; } - ScheduleEvent( - new EventScheduleEntry(e, firstRun, interval, TimeSpan.FromHours(hour) + TimeSpan.FromMinutes(min)) - ); - } - - public void ScheduleEvent(EventScheduleEntry e) - { - _schedule.Add(e); - } - - public void RemoveEvent(EventScheduleEntry entry) - { _schedule.Remove(entry); - } - protected override void OnTick() - { - foreach (var entry in _schedule) + if (entry.Advance() && entry.NextOccurrence < DateTime.MaxValue) { - if (entry.NextOccurrence <= Core.Now) - { - entry.Occur(); - } + _schedule.Add(entry); } } } + + private sealed class ScheduledEventComparer : IComparer + { + public static readonly ScheduledEventComparer Default = new(); + + public int Compare(ScheduledEvent x, ScheduledEvent y) + { + if (x == null && y == null) + { + return 0; + } + + if (x == null) + { + return 1; + } + + if (y == null) + { + return -1; + } + + var next = x.NextOccurrence.CompareTo(y.NextOccurrence); + return next != 0 ? next : x.Serial.CompareTo(y.Serial); + } + } }