ModernUO/dev-docs/event-scheduler.md
Kamron Batman 1391c563fe
chore: Adds AI instructions and SKILLs for ModernUO codebase (#2347)
Summary

  - Adds CLAUDE.md at repo root with 14 terse code audit rules (always loaded, low token cost)
  - Adds pointer files for other AI tools: AGENTS.md (Codex), GEMINI.md, .github/COPILOT-INSTRUCTIONS.md (Copilot), .cursorrules (Cursor) — all redirect to CLAUDE.md as single source of truth
  - Gitignores /.claude so personal AI config isn't distributed
  - Moves Claude skills to dev-docs/claude-skills/ (opt-in, not auto-loaded)
  - Adds 14 dev-docs covering codebase conventions

  Code Audit Rules (in CLAUDE.md)

  1. LINQ tiered rules (Tier 1 free, Tier 2 warm, Tier 3 forbidden)
  2. No Console.WriteLine — use LogFactory.GetLogger()
  3. No concurrency primitives in game code
  4. No World.Mobiles/World.Items iteration
  5. Clean up refs in OnDelete()/OnAfterDelete()
  6. Cancel timers in OnDelete()/OnAfterDelete()
  7. STArrayPool<T>.Shared not ArrayPool<T>.Shared
  8. PooledRefList<T> not new List<T>() on hot paths
  9. Serialization: partial class, [Constructible], no serialized TimerExecutionToken
  10. No Task.Run/new Thread() in game code
  11. Never assume era — ask which expansion
  12. _camelCase fields, PascalCase properties/methods
  13. No empty gumps — use DisplayTo() pattern
  14. PropertyList string literals must be {} holes, cliloc-as-argument uses :#
2026-03-01 11:42:19 -08:00

12 KiB

ModernUO EventScheduler System

This document covers ModernUO's wall-clock and calendar-based event scheduling system. For game-tick timers (sub-second precision, combat ticks, decay, etc.), see dev-docs/timers.md.

Overview

The EventScheduler provides wall-clock scheduling — firing events at real-world times, dates, and calendar patterns. It runs as a 1-second Timer on the game loop and uses a PriorityQueue ordered by next UTC occurrence.

Timer vs EventScheduler

Aspect Timer.StartTimer EventScheduler
Clock basis Game-tick (8ms wheel) Wall-clock (1s poll)
Precision 8ms 1 second
Use case Combat ticks, decay, delays Holidays, daily resets, scheduled maintenance
Recurrence Fixed interval Calendar patterns (hourly, daily, weekly, monthly, yearly)
Timezone N/A Full TimeZoneInfo + DST handling
Seasonal windows No Yes (YearlyScheduledEvent)

Rule of thumb: If the event must happen "at 9:00 AM EST every Monday" or "during October through November each year," use EventScheduler. If it must happen "5 seconds from now" or "every 2 seconds," use Timer.

Architecture

EventScheduler is a singleton Timer that ticks every second. Internally it holds a PriorityQueue<BaseScheduledEvent, DateTime> sorted by NextOccurrence (UTC). Each tick it dequeues and fires all events whose time has passed, then each event self-re-enqueues for its next occurrence.

EventScheduler (Timer, 1s tick)
  └─ PriorityQueue<BaseScheduledEvent, DateTime>
       ├─ CallbackScheduledEvent (fires Action)
       ├─ YearlyCallbackScheduledEvent (fires Action within seasonal window)
       └─ Your custom subclass

Class Hierarchy

BaseScheduledEvent              (abstract: Schedule, Cancel, Advance, OnEvent)
  └─ ScheduledEvent             (adds IRecurrencePattern, TimeOnly, EndDate)
       ├─ CallbackScheduledEvent      (sealed: fires an Action callback)
       └─ YearlyScheduledEvent        (abstract: adds MonthDay start/end seasonal window)
            └─ YearlyCallbackScheduledEvent  (fires an Action within seasonal window)

BaseScheduledEvent

The abstract root. Key members:

Member Description
TimeZone TimeZoneInfo — defaults to UTC
NextOccurrence DateTime (UTC) of the next fire time
Cancelled bool — set by Cancel()
Scheduler Back-reference to the owning EventScheduler
Schedule(startAfter, timeZone?) Schedule on the shared instance
Schedule(scheduler, startAfter, timeZone?) Schedule on a specific scheduler
Cancel() Cancel and unschedule the event
Advance() Called by the scheduler — fires OnEvent(), then re-schedules
OnEvent() Abstract — your event logic goes here

ScheduledEvent

Extends BaseScheduledEvent with recurrence support:

Member Description
Recurrence IRecurrencePattern — determines when the event recurs
Time TimeOnly — the time-of-day component for recurrence calculation
EndDate DateTime — stop recurring after this date (default: never)

CallbackScheduledEvent

Sealed concrete class. Wraps an Action callback:

var evt = new CallbackScheduledEvent(new TimeOnly(9, 0), myAction, EventScheduler.Daily);
evt.Schedule(DateTime.UtcNow);

Most users should use the static convenience methods on EventScheduler instead of constructing directly.

YearlyScheduledEvent

Abstract. Adds a seasonal window defined by MonthDay start and end:

Member Description
YearlyStart MonthDay — first day of the active window
YearlyEnd MonthDay — last day of the active window

The event only fires when the next occurrence falls within the [YearlyStart, YearlyEnd] range. If it falls outside, the scheduler fast-forwards to the next year's window start. Supports year-boundary wrapping (e.g., Nov 15 through Feb 15).

YearlyCallbackScheduledEvent

Concrete version of YearlyScheduledEvent that fires an Action:

var halloween = new YearlyCallbackScheduledEvent(
    new TimeOnly(0, 0),
    new MonthDay(2025, 10, 1),   // Oct 1
    new MonthDay(2025, 11, 1),   // Nov 1
    SpawnHalloweenContent,
    EventScheduler.Daily
);
halloween.Schedule(DateTime.UtcNow, easternTimeZone);

Recurrence Patterns

All patterns implement IRecurrencePattern:

public interface IRecurrencePattern
{
    DateTime GetNextOccurrence(DateTime afterUtc, TimeOnly time, TimeZoneInfo timeZone);
}

Built-In Patterns

Pattern Static accessor Behavior
HourlyRecurrencePattern EventScheduler.Hourly Every N hours (default 1) at the same minute
DailyRecurrencePattern EventScheduler.Daily Every N days (default 1) at the specified time
WeeklyRecurrencePattern EventScheduler.Weekly Every N weeks (default 1), with optional AllowedDays and AllowedMonths filters
WeeklyRecurrencePattern(2) EventScheduler.Biweekly Every 2 weeks
MonthlyRecurrencePattern EventScheduler.Monthly Every N months (default 1) on a specific day-of-month
MonthlyRecurrencePattern(-1, 12) EventScheduler.Yearly Every 12 months (yearly)
MonthlyOrdinalRecurrencePattern (construct directly) E.g., "second Tuesday of every month" or "last Friday"

MonthlyOrdinalRecurrencePattern

For patterns like "the third Wednesday of every month":

// Second Tuesday of every month
var pattern = new MonthlyOrdinalRecurrencePattern(
    OrdinalDayOccurrence.Second,
    DayOfWeek.Tuesday
);

// Last Friday of every month
var pattern = new MonthlyOrdinalRecurrencePattern(
    OrdinalDayOccurrence.Last,
    DayOfWeek.Friday
);

The OrdinalDayOccurrence enum:

Value Meaning
Last (-1) Last occurrence in the month
First (0) First occurrence
Second (1) Second occurrence
Third (2) Third occurrence
Fourth (3) Fourth occurrence
Fifth (4) Fifth occurrence (skipped if doesn't exist)

WeeklyRecurrencePattern with Filters

// Every week on Monday and Wednesday, only in January through March
var pattern = new WeeklyRecurrencePattern(
    intervalWeeks: 1,
    allowedMonths: AllowedMonths.January | AllowedMonths.February | AllowedMonths.March,
    allowedDays: AllowedDays.Monday | AllowedDays.Wednesday
);

Supporting Types

AllowedDays (Flags Enum)

[Flags]
public enum AllowedDays : byte
{
    None, Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, All
}

Extension: DayOfWeek.Monday.ToDaysOfWeek()AllowedDays.Monday

AllowedMonths (Flags Enum)

[Flags]
public enum AllowedMonths
{
    None, January, February, ..., December, All
}

MonthDay (Record Struct)

Represents a month/day pair without a year. Used for seasonal window boundaries:

var oct1 = new MonthDay(2025, 10, 1);   // Year is used only for day-count validation
var nov1 = new MonthDay(2025, 11, 1);

Extension method DateTime.IsBetween(MonthDay start, MonthDay end) handles year-boundary wrapping:

  • IsBetween(Oct 1, Nov 1) — standard range within one year
  • IsBetween(Nov 15, Feb 15) — wraps across year boundary

Static Convenience Methods

EventScheduler provides static methods that create, schedule, and return a ScheduledEvent:

// All methods accept: DateTime startOn, Action action, TimeZoneInfo timeZone = null

EventScheduler.HourlyAt(startOn, action, timeZone);
EventScheduler.DailyAt(startOn, action, timeZone);
EventScheduler.WeeklyAt(startOn, action, timeZone);
EventScheduler.BiweeklyAt(startOn, action, timeZone);
EventScheduler.MonthlyAt(startOn, action, timeZone);
EventScheduler.YearlyAt(startOn, action, timeZone);

The startOn parameter determines:

  1. The TimeOnly component (hour/minute for recurrence)
  2. The starting reference date
  3. For MonthlyAt/YearlyAt, the day-of-month

Example:

// Fire at 6:00 AM Eastern every day, starting tomorrow
var eastern = TimeZoneInfo.FindSystemTimeZoneById("Eastern Standard Time");
var tomorrow6am = new DateTime(2025, 6, 15, 6, 0, 0);
EventScheduler.DailyAt(tomorrow6am, ResetDailyQuests, eastern);

Timezone & DST Handling

The system fully supports timezones via TimeZoneInfo:

  • Default: UTC if no timezone specified
  • DST-safe: Uses LocalToUtc() extension which handles:
    • Ambiguous times (fall-back): Uses the later offset (standard time)
    • Invalid times (spring-forward): Recurrence patterns skip invalid candidates
  • Conversion: All internal scheduling uses UTC; local time is only for calculating the next occurrence
// Extension method in Server namespace (Utility.cs)
public static DateTime LocalToUtc(this DateTime local, TimeZoneInfo tz)

Always specify a timezone when your event needs to fire at a local time. Omitting it defaults to UTC.

Custom Event Classes

For complex logic, inherit from ScheduledEvent or YearlyScheduledEvent:

public class WeekendBonusEvent : ScheduledEvent
{
    public WeekendBonusEvent()
        : base(
            new TimeOnly(18, 0),    // 6:00 PM
            new WeeklyRecurrencePattern(
                intervalWeeks: 1,
                allowedDays: AllowedDays.Friday | AllowedDays.Saturday
            ))
    {
    }

    public override void OnEvent()
    {
        // Enable weekend bonus XP
        BonusSystem.ActivateWeekendBonus();
    }
}

// Usage:
var evt = new WeekendBonusEvent();
evt.Schedule(DateTime.UtcNow, easternTimeZone);

Yearly Seasonal Custom Event

public class HalloweenSpawnEvent : YearlyScheduledEvent
{
    protected HalloweenSpawnEvent()
        : base(
            new TimeOnly(0, 0),
            new MonthDay(2025, 10, 15),   // Oct 15
            new MonthDay(2025, 11, 1),    // Nov 1
            EventScheduler.Daily)
    {
    }

    public override void OnEvent()
    {
        // Spawn Halloween creatures daily during the window
        HalloweenSystem.SpawnCreatures();
    }
}

Cancellation

Call Cancel() on any BaseScheduledEvent to remove it from the scheduler:

private BaseScheduledEvent _dailyReset;

public void StartDailyResets()
{
    var tomorrow = new DateTime(2025, 6, 15, 0, 0, 0);
    _dailyReset = EventScheduler.DailyAt(tomorrow, ResetDaily, easternTz);
}

public void StopDailyResets()
{
    _dailyReset?.Cancel();
    _dailyReset = null;
}

Common Mistakes

Mistake Problem Fix
Using Timer for calendar events Drifts with server restarts, no timezone support Use EventScheduler
Forgetting timezone Event fires at UTC instead of local time Always pass TimeZoneInfo for local-time events
Not cancelling on cleanup Event keeps firing after system disabled Call Cancel() in cleanup/shutdown
Using EventScheduler for sub-second timing 1-second granularity too coarse Use Timer.StartTimer instead
Constructing MonthDay with invalid days Throws ArgumentOutOfRangeException Check DateTime.DaysInMonth for the given month

Key File References

File Description
Projects/UOContent/Engines/Events/EventScheduler.cs Singleton scheduler, PriorityQueue, static factory methods
Projects/UOContent/Engines/Events/BaseScheduledEvent.cs Abstract base: Schedule, Cancel, Advance, OnEvent
Projects/UOContent/Engines/Events/ScheduledEvent.cs Adds IRecurrencePattern, TimeOnly, EndDate
Projects/UOContent/Engines/Events/CallbackScheduledEvent.cs Sealed Action-callback concrete class
Projects/UOContent/Engines/Events/YearlyScheduledEvent.cs Seasonal window with MonthDay start/end
Projects/UOContent/Engines/Events/YearlyCallbackScheduledEvent.cs Yearly seasonal + Action callback
Projects/UOContent/Engines/Events/CommonRecurrencePatterns.cs All IRecurrencePattern implementations
Projects/UOContent/Engines/Events/AllowedDays.cs Flags enum for day-of-week filtering
Projects/UOContent/Engines/Events/AllowedMonths.cs Flags enum for month filtering
Projects/UOContent/Engines/Events/MonthDay.cs Record struct + IsBetween extension
Projects/Server/Utilities/Utility.cs LocalToUtc() extension method