Nimbus plugins are .NET class libraries that reference `Nimbus.Api.dll`. They live in the `plugins/` directory next to the proxy executable and are loaded at startup. They can be hot-reloaded without restarting the proxy. ## Project setup Create a class library targeting .NET 10: ```xml net10.0 enable MyPlugin path/to/Nimbus.Api.dll ``` **Only reference `Nimbus.Api.dll`.** Do not reference `Nimbus.Proxy.dll` directly - its types are internal and the API surface can change between versions. ## Plugin manifest Place a `.plugin.json` file next to your DLL in the `plugins/` directory: ```json { "id": "my-plugin", "name": "My Plugin", "version": "1.0.0", "apiVersion": "0.1", "dependencies": [] } ``` | Field | Description | |---|---| | `id` | Unique plugin identifier. Used in `plugins.disabled` lists and log output. | | `name` | Display name shown by `nimctl plugins`. | | `version` | Plugin version string. | | `apiVersion` | Nimbus API version this plugin targets. Current version: `0.1`. | | `dependencies` | Plugin IDs that must be loaded before this one. | --- ## IPlugin ```csharp public interface IPlugin { string Name { get; } string Version { get; } void Initialize(IProxyApi api); void Shutdown(); } ``` - **`Initialize`** - called once on load, and again after every hot-reload. Subscribe to events here. - **`Shutdown`** - called before the plugin is unloaded (on proxy shutdown or before a hot-reload). Clean up resources here. Minimal plugin: ```csharp using Nimbus.Proxy; public sealed class MyPlugin : IPlugin { public string Name => "my-plugin"; public string Version => "1.0.0"; public void Initialize(IProxyApi api) { api.Events.Subscribe(e => { api.LogInfo($"connection from {e.Player.ClientRemote}"); return Task.CompletedTask; }); } public void Shutdown() { } } ``` --- ## IProxyApi The object passed to `Initialize`. Your main entry point to everything in the proxy. ```csharp public interface IProxyApi { EventBus Events { get; } IEnumerable Players { get; } IPlayer? TryGetPlayer(long sessionId); IPlayer? FindPlayerByUid(string uid); IPlayer? FindPlayerByName(string name); Task ResolveServerAsync(string serverId, CancellationToken ct); void LogInfo(string message); void LogWarn(string message); } ``` `ResolveServerAsync` checks the registry snapshot first, then falls back to the static `[servers]` pool. Use it to turn a server ID string into an `IServerInfo` you can pass to `TransferAsync`. --- ## IPlayer ```csharp public interface IPlayer { long Id { get; } // session number (increments per connect) string? Uid { get; } // VS player UID, available after Identification string? Name { get; } // player name, available after Identification string ClientRemote { get; } // real IP, captured at TCP accept time IServerInfo? CurrentServer { get; } // currently connected backend bool SupportsSeamlessTransfers { get; } // has Nimbus.Client installed Task TransferAsync(IServerInfo target, string? reason = null); Task TransferAsync(IServerInfo target, string mode, string? reason = null); void Disconnect(string? reason = null); } ``` `TransferAsync` returns `null` on success, or a short failure reason string on error. `mode` is `"redirect"` or `"seamless"`. `Uid` and `Name` are `null` until the proxy has parsed the VS `Identification` frame from the client, which happens very early in the session (usually within the first few milliseconds). For `PlayerConnectEvent`, they may still be null - use `ClientRemote` for IP-based decisions there. --- ## IServerInfo and ServerInfo ```csharp public interface IServerInfo { string ServerId { get; } string Host { get; } int Port { get; } } ``` Construct a target directly when you already know the address: ```csharp var target = new ServerInfo("hub", "127.0.0.1", 42421); await player.TransferAsync(target, "redirect", "sending to hub"); ``` Or resolve by ID: ```csharp var target = await api.ResolveServerAsync("hub", default); if (target != null) await player.TransferAsync(target); ``` --- ## Events Subscribe with `api.Events.Subscribe(handler)`. The handler receives the event object and returns a `Task`. Handlers run sequentially in subscription order. Exceptions are caught and logged - one bad handler cannot crash a session or block others. ### PlayerConnectEvent Fires when a new TCP connection is accepted, before any backend is contacted. ```csharp api.Events.Subscribe(e => { if (IsBanned(e.Player.ClientRemote)) e.Deny("You are banned from this server."); return Task.CompletedTask; }); ``` | Member | Description | |---|---| | `Player` | The new session. `Uid` and `Name` are null - Identification not parsed yet. Use `ClientRemote` for IP. | | `Deny(reason)` | Forges a VS disconnect packet and closes the connection before it reaches any backend. | | `IsDenied` | `true` if any handler called `Deny`. | --- ### PlayerChooseInitialServerEvent Fires after the backend pool is built, before any upstream socket opens. Override which backend the player lands on. ```csharp api.Events.Subscribe(async e => { if (IsVip(e.Player.Uid)) e.Target = await api.ResolveServerAsync("vip-world", default); }); ``` | Member | Description | |---|---| | `Player` | The session. `Uid` may be available if the first frame was an Identification packet. | | `Target` | Get or set the chosen backend. Set to `null` to let the default `try` list decide. | | `Cancel(reason)` | Cancel the connect entirely. Player receives a disconnect message. | | `IsCancelled` | `true` if cancelled. | --- ### ServerPreConnectEvent Fires before each upstream TCP connect attempt - on initial connect and before every transfer. ```csharp api.Events.Subscribe(e => { if (e.Target.ServerId == "dev" && !IsOperator(e.Player.Uid)) e.Cancel("That server is for staff only."); return Task.CompletedTask; }); ``` | Member | Description | |---|---| | `Player` | The session. | | `Original` | The original target as chosen by routing (read-only). | | `Target` | Get or set to reroute to a different backend. | | `Reason` | Why this connect is happening: `"initial connect"`, or whatever was passed to `TransferAsync`. | | `Cancel(reason)` | Abort this connect attempt. For initial connect this disconnects the player. For transfers it cancels the transfer. | --- ### ServerPostConnectEvent Fires after a successful upstream connection is established. Use for logging, counters, or post-connect actions. | Member | Description | |---|---| | `Player` | The session. | | `Server` | The backend just connected to. | | `Previous` | The previous backend, or `null` for an initial connect. | --- ### PlayerDisconnectEvent Fires when a session ends for any reason: player quit, kicked, transfer completed, network error. | Member | Description | |---|---| | `Player` | The session. | | `BytesC2S` | Total bytes sent client-to-server across the whole session. | | `BytesS2C` | Total bytes sent server-to-client. | --- ### ServerKickedEvent Fires when the backend drops a live session without the player or proxy initiating it. Covers backend crashes, admin kicks from the game server, and unexpected disconnects during play. | Member | Description | |---|---| | `Player` | The session. | | `Server` | The backend that closed the connection. | --- ### PlayerTransferredEvent Fires after a redirect or seamless transfer completes successfully from the proxy side. | Member | Description | |---|---| | `Player` | The session. | | `From` | The source backend, or `null` for an initial connect. | | `To` | The destination backend. | | `Mode` | `"redirect"` or `"seamless"`. | --- ## Hot reload behaviour When `nimctl reload` runs: 1. Every plugin's `Shutdown()` is called. 2. All event subscriptions are cleared from the event bus. 3. Each plugin's `AssemblyLoadContext` is unloaded (plugins use collectible contexts). 4. The GC runs to release Windows file locks on the DLLs. 5. Plugins are re-discovered and loaded fresh - `Initialize` is called again. Avoid storing state in static fields that you do not want to survive hot-reloads. Static fields persist across reloads because the process does not restart - only the plugin assembly context is rebuilt. --- ## Deploying a plugin Drop the DLL and manifest into the `plugins/` directory: ``` plugins/ MyPlugin.dll MyPlugin.plugin.json ``` Pick it up without restarting: ```bash nimctl reload ``` You should see: ``` 12:00:00 [nimbus] plugins: my-plugin, other-plugin ```