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