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 :#
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 yearIsBetween(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:
- The
TimeOnlycomponent (hour/minute for recurrence) - The starting reference date
- 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 |