ModernUO/dev-docs/events.md
Kamron Batman 309fcfeb27
feat(skills): SkillEvents.SkillUsed for cross-assembly subscribers; InternalsVisibleTo ModernSpawner.Tests (#2636)
## 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
2026-09-11 22:58:44 -07:00

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:

  1. EventSink: Static events for core game lifecycle (login, logout, death, speech, etc.)
  2. 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 in
  • PlayerMobile.PlayerDeathEvent -- Player dies
  • BaseCreature.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 four Mobile_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 every Mobile.

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

  1. Subscribe in Configure() -- called automatically during startup
  2. Check player type -- Connected fires for all mobiles; cast to PlayerMobile if needed
  3. Keep handlers fast -- they run on the game loop thread
  4. Use Handled/Blocked -- on SpeechEventArgs to consume/block messages
  5. Unsubscribe on disable -- if your system can be turned off, unsubscribe (-=) to prevent leaks
  6. 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