## Summary
Two small additive changes that an external content assembly (ModernSpawner) needs, as separable commits.
**1. `SkillEvents.SkillUsed`** (`Projects/UOContent/Skills/SkillEvents.cs`, namespace `Server.Misc`): a plain C# event `Action<Mobile, Skill, bool success>` raised once per skill attempt from each of the four `Mobile_SkillCheck*` handlers, with the handler's own result. Attempts the handler resolves without a roll (too difficult, no challenge) raise too, so a grandmaster's trivial success and a guaranteed combat roll are observable. Not raised when the mobile lacks the skill. Each handler keeps its logic in a private core method and raises on the way out, so there is exactly one raise per attempt and `CheckSkill` itself is unchanged.
- **Why a plain event and not a `[GeneratedEvent]`:** generated events are compile-time static dispatch inside the UOContent compilation, so a subscriber in another assembly cannot use `[OnEvent]`. Shape follows `HelpEvents`.
- **Why "used", not "gained":** this is the XmlSpawner skill-trigger semantic (it wrapped the same four handlers and passed their result as `success`; its grammar was `Skill[+/-]` for success-only or failure-only). Gains are already observable through the existing skill-change notification on `Mobile`.
- **Cost:** one delegate null-check per attempt when nothing is subscribed; no boxing, no closure, no allocation. The handlers sit on the combat swing path.
- **Exception contract:** subscriber exceptions propagate, matching `EventSink`/`HelpEvents`; no try/catch by design.
**2. `InternalsVisibleTo("ModernSpawner.Tests")`** on `Server.csproj`, beside the existing `Server.Tests`/`UOContent.Tests` entries, so an external test host can seed `Core._now` the way the engine's own test initializers do. Separable; a public test seam on `Core` would serve the same need without naming a downstream assembly.
## Open question
The payload is the `Skill` object plus a positional `bool`. A `readonly struct` args type passed `in` would leave room to add `chance` or the target later without breaking subscribers. Happy to change before merge.
## Test plan
- [x] `UOContent.Tests`: 4 tests — a rolled attempt raises once with the returned outcome; each short-circuit path (no challenge, too difficult, on both the direct and value-window handlers) raises with the handler's result; a direct `CheckSkill` call does not raise; no subscriber does not throw. Full suite green.
- [x] `Server` and `UOContent` build clean with `TreatWarningsAsErrors`.
- [ ] CI
8.4 KiB
ModernUO Event System
This document covers ModernUO's event system, including EventSink static events and the CodeGeneratedEvents system for custom entity events.
Overview
ModernUO provides two event mechanisms:
- EventSink: Static events for core game lifecycle (login, logout, death, speech, etc.)
- CodeGeneratedEvents: Attribute-based events on game entities (player login, creature death, etc.)
EventSink
Architecture
EventSink is a static partial class spread across multiple files in Projects/Server/Events/. Each event is defined as a public static event Action<T> with a corresponding InvokeXxx() method.
Subscribing to Events
Subscribe in your Configure() static method:
public static class MySystem
{
public static void Configure()
{
EventSink.Connected += OnPlayerConnected;
EventSink.Disconnected += OnPlayerDisconnected;
EventSink.Speech += OnSpeech;
EventSink.ServerStarted += OnServerStarted;
}
private static void OnPlayerConnected(Mobile m)
{
if (m is PlayerMobile pm)
pm.SendMessage("Welcome to the server!");
}
private static void OnPlayerDisconnected(Mobile m)
{
// Cleanup player state
}
private static void OnSpeech(SpeechEventArgs e)
{
if (e.Speech.InsensitiveContains("help"))
{
e.Mobile.SendMessage("Type [help for commands.");
e.Handled = true;
}
}
private static void OnServerStarted()
{
// Initialize after all systems loaded
}
}
Available Events
Server Lifecycle
| Event | Signature | When |
|---|---|---|
ServerStarted |
Action |
Server fully initialized |
Shutdown |
Action |
Server shutting down |
WorldLoad |
Action |
World loaded from saves |
WorldSave |
Action |
World save triggered |
WorldSavePostSnapshot |
Action<WorldSavePostSnapshotEventArgs> |
After save snapshot |
ServerCrashed |
Action<ServerCrashedEventArgs> |
Unhandled exception |
Player Connection
| Event | Signature | When |
|---|---|---|
Connected |
Action<Mobile> |
Player connected to server |
BeforeDisconnected |
Action<Mobile> |
About to disconnect |
Disconnected |
Action<Mobile> |
Player disconnected |
Logout |
Action<Mobile> |
Player logged out |
Account
| Event | Signature | When |
|---|---|---|
AccountLogin |
Action<AccountLoginEventArgs> |
Account login attempt |
Communication
| Event | Signature | When |
|---|---|---|
Speech |
Action<SpeechEventArgs> |
Player speaks |
PaperdollRequest |
Action<Mobile, Mobile> |
Paperdoll opened (beholder, beheld) |
Combat
| Event | Signature | When |
|---|---|---|
AggressiveAction |
Action<AggressiveActionEventArgs> |
Aggressive action taken |
Movement
| Event | Signature | When |
|---|---|---|
Movement |
Action<MovementEventArgs> |
Player moves |
Network
| Event | Signature | When |
|---|---|---|
SocketConnect |
Action<SocketConnectEventArgs> |
New socket connection |
EventArgs Classes
SpeechEventArgs
public class SpeechEventArgs
{
public Mobile Mobile { get; }
public string Speech { get; set; } // Can modify speech text
public MessageType Type { get; }
public int Hue { get; }
public int[] Keywords { get; }
public bool Handled { get; set; } // Set true to consume
public bool Blocked { get; set; } // Set true to block
public bool HasKeyword(int keyword);
}
AccountLoginEventArgs
public class AccountLoginEventArgs
{
public NetState State { get; }
public string Username { get; }
public string Password { get; }
public bool Accepted { get; set; } // Set false to reject
public ALRReason RejectReason { get; set; } // Reason for rejection
}
MovementEventArgs (Pooled)
public class MovementEventArgs
{
public Mobile Mobile { get; }
public Direction Direction { get; }
public bool Blocked { get; set; } // Set true to block movement
// Object pooling
public static MovementEventArgs Create(Mobile m, Direction dir);
public void Free(); // Return to pool
}
AggressiveActionEventArgs (Pooled)
public class AggressiveActionEventArgs
{
public Mobile Aggressed { get; }
public Mobile Aggressor { get; }
public bool Criminal { get; }
public static AggressiveActionEventArgs Create(Mobile aggressed, Mobile aggressor, bool criminal);
public void Free();
}
WorldSavePostSnapshotEventArgs
public class WorldSavePostSnapshotEventArgs
{
public string OldSavePath { get; }
public string NewSavePath { get; }
}
ServerCrashedEventArgs
public class ServerCrashedEventArgs
{
public Exception Exception { get; }
public bool Close { get; set; } // Set false to continue running
}
SocketConnectEventArgs
public class SocketConnectEventArgs
{
public IPAddress Address { get; }
public bool AllowConnection { get; set; } // Set false to reject
}
Creating Custom EventSink Events
Add to EventSink as a partial class:
// Projects/Server/Events/MyCustomEvent.cs
namespace Server;
public static partial class EventSink
{
public static event Action<Mobile, Item> ItemCrafted;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void InvokeItemCrafted(Mobile crafter, Item item) =>
ItemCrafted?.Invoke(crafter, item);
}
Then invoke from game code:
EventSink.InvokeItemCrafted(crafter, craftedItem);
CodeGeneratedEvents
For events on specific game entities, ModernUO uses source-generated events via the CodeGeneratedEvents package.
External reference: https://github.com/modernuo/CodeGeneratedEvents
Defining Generated Events
On the class that fires the event:
[GeneratedEvent(nameof(PlayerLoginEvent))]
public static partial void PlayerLoginEvent(PlayerMobile player);
Subscribing to Generated Events
On any class that handles the event:
[OnEvent(nameof(PlayerMobile.PlayerLoginEvent))]
public static void HandlePlayerLogin(PlayerMobile player)
{
// Handle the event
}
Known Generated Events
PlayerMobile.PlayerLoginEvent-- Player logs inPlayerMobile.PlayerDeathEvent-- Player diesBaseCreature.CreatureDeathEvent-- Creature dies
Static Content Events
Generated events dispatch statically inside UOContent; content that other assemblies must observe exposes a
plain static event instead (shape: Projects/UOContent/Engines/Help/HelpEvents.cs).
SkillEvents.SkillUsed--Action<Mobile, Skill, bool success>, raised once per skill attempt from the fourMobile_SkillCheck*handlers with the attempt's outcome, including attempts resolved without a roll (too difficult, no challenge). Not raised when the mobile lacks the skill. Fires for everyMobile.
Event Args Pooling Pattern
Some EventArgs use object pooling to avoid allocation in hot paths:
// System that fires the event:
var args = MovementEventArgs.Create(mobile, direction);
EventSink.InvokeMovement(args);
// Check args.Blocked after invocation
args.Free(); // Return to pool
This pattern is used for high-frequency events (movement, combat) to minimize GC pressure.
Best Practices
- Subscribe in
Configure()-- called automatically during startup - Check player type --
Connectedfires for all mobiles; cast toPlayerMobileif needed - Keep handlers fast -- they run on the game loop thread
- Use
Handled/Blocked-- on SpeechEventArgs to consume/block messages - Unsubscribe on disable -- if your system can be turned off, unsubscribe (
-=) to prevent leaks - Don't throw exceptions -- unhandled exceptions in event handlers can crash the server
Key File References
| File | Description |
|---|---|
Projects/Server/Events/EventSink.cs |
Core EventSink (partial) |
Projects/Server/Events/SpeechEvent.cs |
Speech event |
Projects/Server/Events/MovementEvent.cs |
Movement event (pooled) |
Projects/Server/Events/AggressiveActionEvent.cs |
Combat event (pooled) |
Projects/Server/Events/AccountLoginEvent.cs |
Account login |
Projects/Server/Events/EventSink.cs |
World save/load (WorldLoad, WorldSave, ServerStarted, Shutdown) |
Projects/Server/Events/SocketConnectionEvent.cs |
Socket connections |
Projects/Server/Events/ServerCrashedEvent.cs |
Crash handling |