Files
Rust-Plugins/overlay/oxide/plugins/RunicGateway.cs
wtclaude 0a7ad570f7
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in -1m29s
fix(bridge): refuse world commands until the world has loaded
The link starts in Init, before the save loads. The phase 13a walk restarted
the rig mid-run: the website saw the new boot id at once, asked world.owned
into an empty world, and the plugin — finding no entity behind any net id —
pruned three live crates from its own registry. They stayed in the world,
owned by nothing, and a revert would have refused them.

world.owned, world.revert, world.monuments and every world write now answer
`not-ready` until OnServerInitialized, zones are not re-created before it,
and server.hello carries `worldReady` so the website can hold its reconcile
until the world it would be asking about exists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-24 02:16:03 -05:00

7282 lines
280 KiB
C#

using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using Newtonsoft.Json;
using Oxide.Core;
using Oxide.Core.Plugins;
namespace Oxide.Plugins
{
/// <summary>
/// The Runic Gateway bridge. Dials out to a rust-link sidecar on loopback and speaks
/// newline-delimited JSON over it: events out, commands in.
///
/// <para>
/// Threading contract, which everything later in this plugin depends on:
/// </para>
/// <list type="bullet">
/// <item><description>
/// <see cref="Emit"/> is called from the server's main thread. It formats nothing,
/// blocks on nothing, and touches no socket. It enqueues and returns. A slow, wedged,
/// or absent sidecar cannot stall the game.
/// </description></item>
/// <item><description>
/// One link thread owns the socket. It connects, drains the queue, and reconnects with
/// backoff. A single writer keeps event ordering intact.
/// </description></item>
/// <item><description>
/// A reader thread parses inbound lines and hands each to the main thread via
/// <c>Interface.Oxide.NextTick</c>. The reader touches no Unity object, no BasePlayer
/// and no ConVar — every one of those is main-thread-only, and reading them from here
/// is the kind of bug that presents as a crash somewhere else entirely.
/// </description></item>
/// </list>
///
/// <para>
/// The outbound queue is bounded. On overflow the oldest record is dropped and counted,
/// because telemetry is worth less than the server's memory.
/// </para>
///
/// <para>
/// <b>Loopback is the trust boundary.</b> There is no token on this link: the plugin and
/// the sidecar share a host, and the sidecar binds 127.0.0.1. Pointing <c>Host</c> at
/// anything routable puts an unauthenticated command channel on the network.
/// </para>
/// </summary>
[Info("Runic Gateway", "RunicGateway", "0.1.0")]
[Description("Bridges this Rust server to a Runic Gateway website through a rust-link sidecar.")]
internal class RunicGateway : RustPlugin
{
/// <summary>
/// Wire-protocol version. Declared in three places that must agree: here,
/// <c>PROTOCOL_VERSION</c> in the sidecar, and <c>module.json</c> in Module-Rust. The
/// overlay's <c>overlay.toml</c> carries this same number so the installer can refuse
/// to pair a sidecar and an overlay that disagree.
/// </summary>
private const int ProtocolVersion = 9;
/// <summary>
/// Loopback reconnects are cheap, so the ceiling is low. A sidecar restart should cost
/// a few seconds of buffering, not half a minute.
/// </summary>
private const int MaxBackoffMs = 5000;
/// <summary>The longest line accepted from the sidecar. Over-long lines are discarded.</summary>
private const int MaxInboundLineBytes = 1024 * 1024;
/// <summary>
/// How long to wait for the TCP connect itself before giving up and backing off.
///
/// <para>
/// It exists because <c>TcpClient.Connect</c> is blocking and has no timeout of its
/// own, and the OS default is tens of seconds. A host that REFUSES answers instantly —
/// which is every loopback test, and why this was invisible for two phases — but a host
/// that <b>drops</b> does not answer at all: a firewall, a wrong address, a machine
/// that is off. The link thread then sits in the connect, deaf to everything, and
/// <c>Unload</c> waits out its whole join on the game's MAIN THREAD.
/// </para>
///
/// <para>
/// Generous against any real network and short against a hang. Loopback, which is the
/// supported deployment, connects in well under a millisecond.
/// </para>
/// </summary>
private const int ConnectTimeoutMs = 3000;
/// <summary>
/// How often the boards are re-sent (PROTOCOL.md §8.3) and the per-player tallies are
/// flushed (§8.6). One cadence serves both because both answer the same question —
/// "what has this server been doing lately" — and two timers would only be two things
/// to get out of step.
/// </summary>
private const float BoardCadenceSeconds = 60f;
/// <summary>
/// The side of one map grid cell, in world units. Rust's own grid, as every community
/// map and every server console uses it.
/// </summary>
private const float GridCellSize = 146.3f;
private static readonly ConcurrentQueue<string> Outbound = new ConcurrentQueue<string>();
private static readonly AutoResetEvent Wake = new AutoResetEvent(false);
/// <summary>
/// Set once, by <c>Unload</c>, and never reset. It is what lets a thread blocked on a
/// CONNECT give up immediately instead of at the end of its timeout.
///
/// <para>
/// It cannot be <see cref="Wake"/>: that handle also means "the queue has something in
/// it", which <c>Emit</c> signals constantly, so waiting on it during a connect would
/// abandon the attempt every time a hook fired. Two meanings, two handles.
/// </para>
/// </summary>
private readonly ManualResetEvent _stopping = new ManualResetEvent(false);
private PluginConfig _config;
private Thread _link;
private volatile bool _running;
private volatile bool _connected;
/// <summary>Set when the peer goes away, so the writer stops trying.</summary>
private volatile bool _dead;
/// <summary>
/// Whether the "cannot reach the sidecar" line has been printed for this load. It is
/// printed once, not per attempt: the retry runs every few seconds for ever.
/// </summary>
private volatile bool _loggedConnectFailure;
/// <summary>
/// Incremented per connection attempt. A reader from a previous connection must not be
/// able to mark a newer one dead — <c>reader.Join</c> can time out, and the stale
/// thread's finally block would otherwise tear down the connection that replaced it.
/// </summary>
private int _epoch;
private int _depth;
private long _sent, _dropped, _received, _connects, _writeErrors;
/// <summary>
/// Identifies this run of the <b>server process</b>. It is how the website tells a game
/// restart apart from a sidecar reconnect — the same link comes back either way, and
/// only one of the two means the world it is describing started over.
///
/// <para>
/// It must therefore be stable across a <b>plugin</b> reload, which a fresh
/// <c>Guid</c> at <c>Init</c> is not: <c>oxide.reload</c> would announce a brand new
/// boot to a website that is about to reconcile its whole ledger on the strength of it,
/// while the world it describes never moved. The process's own start time is the only
/// value here that changes exactly when the thing it names changes.
/// </para>
/// </summary>
private string _bootId;
/// <summary>
/// The current wipe, as <c>w-yyyyMMddTHHmmssZ</c>, derived from the save's creation
/// time. Null until the server has saved at least once — a server with no save has no
/// wipe, and PROTOCOL.md §8.2 says that is reported by the field being **absent**
/// rather than by an empty string that will sort beside every other empty string for
/// ever.
///
/// <para>
/// Cached rather than read per frame, and re-read on <c>OnNewSave</c>, which is the
/// only moment it can change. A wipe is a fact about the world; nothing about a
/// reconnect, a plugin reload or a restart moves it.
/// </para>
/// </summary>
private string _wipeId;
/// <summary>
/// Per-player counters waiting to be flushed as a <c>player.tally</c> frame. Main
/// thread only — every writer is a game hook and the flush runs on a timer, which is
/// also the main thread, so this needs no lock and must never acquire one.
/// </summary>
private readonly Dictionary<ulong, Tally> _tallies = new Dictionary<ulong, Tally>();
/// <summary>
/// When each connected player connected, in epoch milliseconds, so a disconnect can
/// report a session length. Main thread only.
///
/// <para>
/// A player already on the server when this plugin loaded has no entry, and
/// <c>sessionSec</c> is then <b>absent</b> from their disconnect rather than zero: an
/// unknown session is not a zero-length one, and summing the zeros would under-report
/// playtime quietly and for ever.
/// </para>
/// </summary>
private readonly Dictionary<ulong, long> _connectedAt = new Dictionary<ulong, long>();
/// <summary>
/// Every hook name this plugin declares, and whether it has fired at least once.
///
/// <para>
/// Both frameworks bind hooks by <b>name and arity, through reflection, with no
/// compile-time check</b>: a hook Facepunch renames on a Thursday simply stops being
/// called, and a hook one framework's catalogue does not list may or may not exist.
/// Neither failure logs anything anywhere. <c>rg.hooks</c> reads this dictionary, which
/// makes the plugin its own answer to both questions on whichever framework it is
/// actually running under — and a cheaper answer than either catalogue.
/// </para>
/// </summary>
private readonly Dictionary<string, long> _hookCounts = new Dictionary<string, long>();
/// <summary>
/// The repeating boards/tally timer.
///
/// <para>
/// Typed as the plugin-facing <c>Oxide.Plugins.Timer</c>, which is what
/// <c>timer.Every</c> hands back — <b>not</b> <c>Oxide.Core.Libraries.Timer.TimerInstance</c>,
/// which is the library's own type and does not convert. Both frameworks would destroy
/// this timer for us when the plugin unloads; holding the handle is what lets
/// <c>Unload</c> stop it before the link thread is joined rather than after.
/// </para>
/// </summary>
private Timer _cadence;
// ---- lifecycle ----
/// <summary>
/// Every hook this plugin declares. Seeded into <c>_hookCounts</c> at zero so that
/// <c>rg.hooks</c> can report a hook that has <b>never</b> fired — which is the whole
/// question, and which a dictionary that only learns names as they arrive can never
/// answer.
/// </summary>
private static readonly string[] ExpectedHooks =
{
"OnServerInitialized", "OnServerShutdown", "OnNewSave",
"OnPlayerConnected", "OnPlayerDisconnected", "OnPlayerRespawned",
"OnPlayerDeath", "OnPlayerChat", "OnDispenserGather", "OnEntityDeath",
"OnPlayerReported", "OnUserBanned", "OnUserUnbanned",
"CanUserLogin", "OnUserApproved",
"OnUserPermissionGranted", "OnUserPermissionRevoked",
"OnUserGroupAdded", "OnUserGroupRemoved",
"OnGroupPermissionGranted", "OnGroupPermissionRevoked",
"OnPluginLoaded", "OnPluginUnloaded",
"OnClanCreated", "OnClanDisbanded", "OnClanMemberAdded",
"OnClanMemberLeft", "OnClanMemberKicked", "OnClanColorChanged"
};
private void Init()
{
foreach (string hook in ExpectedHooks)
_hookCounts[hook] = 0L;
_bootId = ResolveBootId();
_wipeId = ResolveWipeId();
_running = true;
// Expired link codes are dropped on a timer, not on lookup: an unconfirmed code is
// never looked up, so nothing else would ever remove one (protocol 3).
timer.Every(60f, PurgeExpiredCodes);
_link = new Thread(LinkLoop)
{
Name = "RunicGateway Link",
IsBackground = true
};
_link.Start();
}
private void Unload()
{
// Attempted before `_running` goes false, because `Emit` refuses to enqueue once it
// does — but be honest about what this achieves: the writer stops on the same flag a
// few lines below and the queue is cleared after the join, so a frame enqueued here
// only survives if the writer happens to be mid-drain. It usually is not.
//
// That is left as it is rather than fixed with a drain-before-exit, because draining
// means waiting on a socket from `Unload`, which runs on the MAIN THREAD — the exact
// stall phase 1 removed when a 1.9-second reload showed up as
// `Calling 'Unload' took 1918ms`. A wedged peer would make it worse than the bug.
//
// What is actually lost is under a minute of one player's gathering, and only on a
// manual plugin reload: a real shutdown flushes at `OnServerShutdown`, and a player
// leaving flushes at their disconnect.
try
{
FlushTallies();
}
catch (Exception ex)
{
Puts($"could not flush tallies on unload: {ex.Message}");
}
if (_cadence != null)
{
_cadence.Destroy();
_cadence = null;
}
if (_clansBoardSoon != null)
{
_clansBoardSoon.Destroy();
_clansBoardSoon = null;
}
// A half-drained permission sync is abandoned rather than finished, and the site is
// told nothing — which is right: the report would describe a set that was only partly
// applied, and the next sync re-derives the whole answer from the store anyway. What
// must not happen is the timer outliving the plugin and writing during a reload.
_permJob = null;
StopPermTimer();
// A config write waiting on a reload dies here too, and its files stay as written.
// That is the honest outcome: the rollback exists to undo an edit that took a plugin
// down, and this plugin being unloaded is not evidence of either — it is usually THIS
// plugin being reloaded by somebody. The caller times out and re-reads.
StopConfigWatch();
_configJob = null;
// Holds are NOT given back on unload. The file keeps them, and the next load re-arms
// them: a plugin reload is not the end of an event (§27.3).
StopLeaseTimer();
// Zones are not erased here by us — ZoneManager erases them itself as this plugin
// unloads — and they are NOT forgotten: the next load re-creates them (D92).
StopWorldTimer();
_running = false;
_stopping.Set();
Wake.Set();
Thread t = _link;
if (t != null && !t.Join(TimeSpan.FromSeconds(2.0)))
Puts("link thread did not stop cleanly");
_link = null;
_connected = false;
_tallies.Clear();
// A reload leaves the static queue holding lines addressed to a connection that no
// longer exists. Clearing it is the difference between a reload and a slow leak.
string discard;
while (Outbound.TryDequeue(out discard))
{
}
Interlocked.Exchange(ref _depth, 0);
}
// ---- configuration ----
private class PluginConfig
{
/// <summary>Where the sidecar listens. Loopback is the trust boundary; see the class docs.</summary>
public string Host = "127.0.0.1";
public int Port = 7799;
/// <summary>
/// How many outbound lines may wait for a wedged sidecar before the oldest is
/// dropped. Sized in lines rather than bytes because that is what is cheap to
/// count; the inbound cap is what bounds a single line's size.
/// </summary>
public int QueueCap = 5000;
/// <summary>
/// This server's stable identity across wipes and restarts, as the website knows
/// it. It is <b>not</b> derived from the hostname: an operator renames a server for
/// a season and the site must not lose its history for it.
/// </summary>
public string ServerId = "main";
/// <summary>
/// Whether the website may borrow values on this server for events (protocol 8,
/// D76). <b>Off until an operator turns it on.</b> Letting staff moderate from a
/// screen they are looking at, and letting a schedule change the world at four in
/// the morning, are different consents, and one switch cannot honestly express
/// both. It gates taking a lease only: listing and giving back always work, so
/// turning it off never strands a value somebody already borrowed.
/// </summary>
public bool EventsEnabled = false;
/// <summary>
/// The most crates one event step may place (protocol 9, D95). An operator may
/// lower it; <see cref="WorldHardMaxCrates"/> is the ceiling it cannot be raised
/// past. A step over the bound is refused, never trimmed to fit.
/// </summary>
public int EventsMaxCrates = WorldHardMaxCrates;
/// <summary>The most NPCs one event step may place (D95). Lowerable, like the above.</summary>
public int EventsMaxNpcs = WorldHardMaxNpcs;
}
protected override void LoadDefaultConfig()
{
Config.WriteObject(new PluginConfig(), true);
}
private void LoadConfigValues()
{
_config = Config.ReadObject<PluginConfig>() ?? new PluginConfig();
if (string.IsNullOrEmpty(_config.Host))
_config.Host = "127.0.0.1";
if (_config.Port <= 0 || _config.Port > 65535)
_config.Port = 7799;
// A cap of zero would drop every line on the way in and report a healthy link doing
// nothing, which is the worst failure available here.
if (_config.QueueCap < 16)
_config.QueueCap = 16;
if (string.IsNullOrEmpty(_config.ServerId))
_config.ServerId = "main";
// A config written before protocol 8 has no `EventsEnabled`, and a key that is not in
// the file is a key the website's config editor cannot show an operator. Written back
// once, with its default, so the switch is visible before anybody needs it.
// The two bounds are clamped to their ceilings rather than refused: a config asking
// for MORE than the plugin will ever place is an operator's typo, and the ceiling is
// what the website mirrors (D95).
_config.EventsMaxCrates = Math.Max(0, Math.Min(_config.EventsMaxCrates, WorldHardMaxCrates));
_config.EventsMaxNpcs = Math.Max(0, Math.Min(_config.EventsMaxNpcs, WorldHardMaxNpcs));
if (Config["EventsEnabled"] == null || Config["EventsMaxCrates"] == null || Config["EventsMaxNpcs"] == null)
Config.WriteObject(_config, true);
}
private void Loaded()
{
LoadConfigValues();
Puts($"protocol {ProtocolVersion}, serverId '{_config.ServerId}', sidecar {_config.Host}:{_config.Port}");
// Here rather than at `OnServerInitialized`: the link thread starts in `Init`, so a
// lease command can arrive before the world has finished loading, and it must find
// the holds already in place rather than have them loaded over it afterwards.
LoadLeases();
LoadWorld();
}
// ---- the outbound queue ----
/// <summary>
/// Main thread. Non-blocking. <paramref name="line"/> must already be a complete JSON
/// object with no embedded newline; the newline is appended by the writer as the frame
/// delimiter.
/// </summary>
private void Emit(string line)
{
if (line == null || !_running)
return;
int cap = _config != null ? _config.QueueCap : 5000;
// Drop-oldest. Bound first, then enqueue, so the queue can transiently sit one over the
// cap but never grows without limit.
while (Volatile.Read(ref _depth) >= cap)
{
string discard;
if (!Outbound.TryDequeue(out discard))
break;
Interlocked.Decrement(ref _depth);
Interlocked.Increment(ref _dropped);
}
Outbound.Enqueue(line);
Interlocked.Increment(ref _depth);
Wake.Set();
}
private void EmitFrame(Dictionary<string, object> frame)
{
try
{
Emit(JsonConvert.SerializeObject(frame, Formatting.None));
}
catch (Exception ex)
{
// A frame that cannot be serialized must not take the caller down with it — the
// caller is usually a game hook.
Puts($"could not serialize frame: {ex.Message}");
}
}
// ---- the link ----
private void LinkLoop()
{
int backoffMs = 500;
while (_running)
{
TcpClient client = null;
Thread reader = null;
int epoch = Interlocked.Increment(ref _epoch);
try
{
string host = _config != null ? _config.Host : "127.0.0.1";
int port = _config != null ? _config.Port : 7799;
client = new TcpClient();
client.NoDelay = true;
Connect(client, host, port);
NetworkStream stream = client.GetStream();
stream.WriteTimeout = 5000; // a wedged peer must surface as an error, not a hang
_dead = false;
_connected = true;
_loggedConnectFailure = false; // so a LATER outage is reported again
backoffMs = 500;
Interlocked.Increment(ref _connects);
Puts($"connected to {host}:{port}");
// Building the boards reads the world, so it must happen on the main thread.
// They are sent on every connect rather than once at startup: the sidecar
// restarts independently of the game, and anything it needs up front has to be
// re-sent per connection.
Interface.Oxide.NextTick(SendBoards);
NetworkStream localStream = stream;
reader = new Thread(() => ReadLoop(localStream, epoch))
{
Name = "RunicGateway Reader",
IsBackground = true
};
reader.Start();
WriteLoop(stream);
}
catch (Exception ex)
{
// Socket exception messages carry EMBEDDED newlines on Windows — the refusal
// text and the endpoint arrive on separate lines — so trimming the ends is not
// enough to keep the Oxide log one entry per line.
string reason = Flatten(ex.Message);
if (_connected)
{
Puts($"link error: {reason}");
}
else if (!_loggedConnectFailure)
{
// The FIRST failure to connect, and only the first: a sidecar that is not
// running yet is normal at boot and would otherwise spam the console every
// few seconds, but a wrong Host or Port is otherwise silent for ever.
_loggedConnectFailure = true;
Puts($"cannot reach the sidecar: {reason} — retrying quietly until it answers");
}
}
finally
{
// Captured before it is cleared: a connection that ended because the READER saw
// EOF leaves the writer to exit cleanly, so nothing throws and the catch above
// never runs. Without this the link going down is invisible in the game console,
// which is the one place an operator looks first.
bool wasConnected = _connected;
_connected = false;
_dead = true;
try
{
if (client != null)
client.Close();
}
catch
{
// Closing a socket that is already gone is not news.
}
if (reader != null)
reader.Join(TimeSpan.FromSeconds(1.0));
if (wasConnected)
Puts("sidecar link lost; reconnecting");
}
if (!_running)
break;
// Nothing is listening yet, or the sidecar restarted. Both are normal.
//
// `Wake`, not `Thread.Sleep`: Unload runs on the MAIN thread and joins this one, so
// an uninterruptible sleep here is a stall of up to MaxBackoffMs on every reload —
// Oxide reports it as `Calling 'Unload' took 1918ms`, and the server is frozen for
// that whole time. Unload sets `_running` false and signals `Wake`, so waiting on
// the handle turns that stall into an immediate return.
Wake.WaitOne(backoffMs);
backoffMs = Math.Min(backoffMs * 2, MaxBackoffMs);
}
_connected = false;
}
/// <summary>
/// Connects with a bound, and with a way out.
///
/// <para>
/// The wait ends on whichever comes first: the connect completing, the plugin being
/// unloaded, or <see cref="ConnectTimeoutMs"/>. Without the middle one a reload against
/// an unreachable sidecar costs the game's main thread the whole join timeout —
/// observed as <c>hook 'Unload' took longer than 100ms [2002ms]</c> beside
/// <c>link thread did not stop cleanly</c>, which is phase 1's stall arriving by a
/// different road.
/// </para>
/// </summary>
private void Connect(TcpClient client, string host, int port)
{
IAsyncResult pending = client.BeginConnect(host, port, null, null);
int signalled = WaitHandle.WaitAny(
new WaitHandle[] { pending.AsyncWaitHandle, _stopping }, ConnectTimeoutMs);
if (signalled != 0)
{
// Closing the client is what abandons the in-flight connect; the socket would
// otherwise stay open behind an IAsyncResult nobody is going to end.
try
{
client.Close();
}
catch
{
}
throw new System.IO.IOException(
signalled == 1
? "unloading"
: $"connect to {host}:{port} timed out after {ConnectTimeoutMs}ms");
}
// Throws the real socket error — a refusal, an unknown host — which is what the caller
// logs. Only reached when the connect actually completed or failed on its own.
client.EndConnect(pending);
}
private void WriteLoop(NetworkStream stream)
{
while (_running && !_dead)
{
string line;
if (!Outbound.TryDequeue(out line))
{
Wake.WaitOne(250);
continue;
}
Interlocked.Decrement(ref _depth);
try
{
byte[] bytes = Encoding.UTF8.GetBytes(line + "\n");
stream.Write(bytes, 0, bytes.Length);
Interlocked.Increment(ref _sent);
}
catch (Exception)
{
// The record is already off the queue. Count it and let the outer loop
// reconnect; re-queueing risks an unbounded retry storm against a dead peer.
Interlocked.Increment(ref _writeErrors);
_dead = true;
throw;
}
}
}
private void ReadLoop(NetworkStream stream, int epoch)
{
byte[] buffer = new byte[8192];
StringBuilder line = new StringBuilder(512);
bool discarding = false;
try
{
while (_running && !_dead)
{
int read = stream.Read(buffer, 0, buffer.Length);
if (read <= 0)
break; // clean EOF: the sidecar closed. Normal.
for (int i = 0; i < read; i++)
{
char c = (char)buffer[i];
if (c == '\n')
{
if (discarding)
{
// The tail of a line already given up on. Swallowing it here is
// what stops the remainder being parsed as a line of its own.
discarding = false;
}
else
{
Dispatch(line.ToString());
}
line.Length = 0;
}
else if (c != '\r')
{
if (discarding)
continue;
line.Append(c);
if (line.Length > MaxInboundLineBytes)
{
Puts("inbound line over the cap; discarding to the next newline");
line.Length = 0;
discarding = true;
}
}
}
}
}
catch (System.IO.IOException)
{
// Expected when the peer vanishes mid-read.
}
catch (ObjectDisposedException)
{
// Expected when Unload closes the socket under us.
}
catch (Exception ex)
{
Puts($"reader error: {ex.Message}");
}
finally
{
// Only tear down the connection this reader actually owned.
if (Volatile.Read(ref _epoch) == epoch)
{
_dead = true;
Wake.Set(); // let the writer notice and fall through to reconnect
}
}
}
/// <summary>
/// Reader thread. Marshals to the main thread; nothing that touches the game happens
/// here.
/// </summary>
private void Dispatch(string line)
{
if (string.IsNullOrEmpty(line))
return;
Interlocked.Increment(ref _received);
Interface.Oxide.NextTick(() =>
{
try
{
HandleCommand(line);
}
catch (Exception ex)
{
// A malformed command must never escape into a game code path.
Puts($"inbound handler threw: {ex}");
}
});
}
// ---- commands ----
/// <summary>Main thread. Every world read below is safe only because of that.</summary>
private void HandleCommand(string line)
{
Dictionary<string, object> cmd;
try
{
cmd = JsonConvert.DeserializeObject<Dictionary<string, object>>(line);
}
catch (Exception ex)
{
Puts($"unparseable command: {ex.Message}");
return;
}
if (cmd == null)
return;
object verbObj;
if (!cmd.TryGetValue("cmd", out verbObj) || verbObj == null)
return;
string verb = verbObj.ToString();
object reqIdObj;
string reqId = cmd.TryGetValue("reqId", out reqIdObj) && reqIdObj != null
? reqIdObj.ToString()
: null;
switch (verb)
{
case "ping":
// `control`: neither kept nor forwarded. It exists to move the sidecar's
// last-seen clock on a server nobody is playing on.
EmitFrame(Frame("pong", "control"));
break;
case "server.status":
Dictionary<string, object> status = Frame("server.status", "reply");
Merge(status, ServerBody());
// The correlation id is echoed verbatim, and only when one was supplied. A
// reply that invents one would be routed to nobody; a reply that omits one the
// caller sent would leave that caller waiting out its whole timeout.
if (reqId != null)
status["reqId"] = reqId;
EmitFrame(status);
break;
case "link.confirm":
// The first command the WEBSITE originates (protocol 3). Everything before it
// was the game talking, or the sidecar asking the game to say something it
// already knew.
HandleLinkConfirm(cmd, reqId);
break;
case "perm.catalogue":
// Protocol 4. What this server's loaded plugins have registered — the option
// source the website's authoring form offers, so a grant can only be written
// against a name that will actually resolve.
HandlePermCatalogue(reqId);
break;
case "perm.sync":
// The first command that WRITES to the game (protocol 4, R2). Everything
// before it read, or spent a code the game itself had minted.
HandlePermSync(cmd, reqId);
break;
case "config.list":
// Protocol 5 (R18). What settings files exist on this host, and which plugins
// are loaded to reload them. A description of the tree, never its contents.
HandleConfigList(reqId);
break;
case "config.read":
HandleConfigRead(cmd, reqId);
break;
case "config.write":
// The first command that writes to the game HOST's filesystem, which is the
// most powerful thing the website can do to a server.
HandleConfigWrite(cmd, reqId);
break;
case "lease.list":
// Protocol 8 (PLAN.md §27). What this server lends, what it holds now, and
// every hold in force — one read serving the website's `read()` and
// `inForce()` alike.
HandleLeaseList(cmd, reqId);
break;
case "lease.apply":
HandleLeaseApply(cmd, reqId);
break;
case "lease.release":
HandleLeaseRelease(cmd, reqId);
break;
case "world.monuments":
// Protocol 9 (PLAN.md §28). The monuments this wipe's map has, for the
// authoring form — and the same vocabulary a step's location is written in.
HandleWorldMonuments(reqId);
break;
case "world.zone":
HandleWorldZone(cmd, reqId);
break;
case "world.place":
HandleWorldPlace(cmd, reqId);
break;
case "world.revert":
HandleWorldRevert(cmd, reqId);
break;
case "world.owned":
HandleWorldOwned(cmd, reqId);
break;
default:
Puts($"unknown command '{verb}'");
break;
}
}
// ---- frames ----
/// <summary>
/// Builds the envelope every outbound frame shares (PROTOCOL.md §8.1). Nothing may
/// emit a frame it did not get from here.
///
/// <para>
/// <paramref name="type"/> is what the sidecar files on — <c>event</c> is appended to
/// history, <c>snapshot</c> replaces a board, <c>reply</c> is routed by
/// <c>reqId</c>, <c>control</c> is neither kept nor forwarded. It is the reason the
/// sidecar needs no arm per kind and the reason the catalogue can grow for free.
/// </para>
///
/// <para>
/// <c>wipeId</c> is <b>omitted</b> when the server has never saved. A server with no
/// save has no wipe, and the difference between an absent field and an empty string is
/// the difference between a fact and a row that sorts beside every other empty string
/// for ever.
/// </para>
/// </summary>
private Dictionary<string, object> Frame(string kind, string type)
{
var frame = new Dictionary<string, object>
{
{ "kind", kind },
{ "type", type },
{ "t", NowMs() },
{ "serverId", _config != null ? _config.ServerId : "main" }
};
// Resolved lazily while it is still unknown, which covers the case above on a
// framework that never fires `OnServerInitialized` — and costs one static property
// read per frame only until the answer exists.
if (string.IsNullOrEmpty(_wipeId))
_wipeId = ResolveWipeId();
if (!string.IsNullOrEmpty(_wipeId))
frame["wipeId"] = _wipeId;
return frame;
}
/// <summary>
/// Main thread. Both boards, in the order a reader wants them: what the server is,
/// then who is on it.
///
/// <para>
/// Sent on every connect and every cadence tick. A board is current state with exactly
/// one producer, re-sent rather than reconstructed — which is what makes "a restarted
/// sidecar is fully populated within one connection" true by construction instead of
/// by hoping nothing was in flight.
/// </para>
/// </summary>
private void SendBoards()
{
SendHello();
SendPlayersBoard();
// Last, and fenced: the clan board reads the game's clan database, which is the one
// board here built from something other than a list in memory. A failure there must
// cost the clans and nothing else — the two boards above are what every page renders.
try
{
SendClansBoard();
}
catch (Exception ex)
{
Puts($"could not build the clans board: {Flatten(ex.Message)}");
}
}
/// <summary>Main thread.</summary>
private void SendHello()
{
Dictionary<string, object> hello = Frame("server.hello", "snapshot");
Merge(hello, ServerBody());
hello["protocol"] = ProtocolVersion;
hello["bootId"] = _bootId;
hello["plugin"] = Version.ToString();
// The website compares boot ids to decide when to reconcile the events ledger, and
// must not do it until the world it would be asking about exists (§28.6).
hello["worldReady"] = _worldReady;
EmitFrame(hello);
}
/// <summary>
/// Main thread. Who is connected right now, as the board the far side reconciles
/// against — the connect and disconnect events carry the story in between.
/// </summary>
private void SendPlayersBoard()
{
var players = new List<object>();
if (BasePlayer.activePlayerList != null)
{
foreach (BasePlayer player in BasePlayer.activePlayerList)
{
if (player == null)
continue;
var row = new Dictionary<string, object>
{
{ "steamId", player.UserIDString },
{ "name", player.displayName },
{ "sleeping", player.IsSleeping() }
};
long since;
if (_connectedAt.TryGetValue(player.userID, out since))
row["connectedAt"] = since;
players.Add(row);
}
}
Dictionary<string, object> frame = Frame("players.online", "snapshot");
frame["count"] = players.Count;
frame["players"] = players;
EmitFrame(frame);
}
/// <summary>
/// Main thread. The shared body of <c>server.hello</c> and <c>server.status</c> — the
/// two frames differ in what wraps them, not in what they say about the server, and
/// letting them drift is how a site ends up showing two different player counts.
/// </summary>
private Dictionary<string, object> ServerBody()
{
var status = new Dictionary<string, object>
{
{ "hostname", ConVar.Server.hostname },
{ "description", ConVar.Server.description },
{ "level", ConVar.Server.level },
{ "seed", ConVar.Server.seed },
{ "worldSize", ConVar.Server.worldsize },
{ "maxPlayers", ConVar.Server.maxplayers },
{ "players", BasePlayer.activePlayerList != null ? BasePlayer.activePlayerList.Count : 0 },
{ "joining", ServerMgr.Instance != null ? ServerMgr.Instance.connectionQueue.Joining : 0 },
{ "queued", ServerMgr.Instance != null ? ServerMgr.Instance.connectionQueue.Queued : 0 },
{ "uptimeSec", (int)UnityEngine.Time.realtimeSinceStartup }
};
// The instant `wipeId` is derived from, kept in its own right: the id is for grouping
// rows and this is for showing a date, and deriving one back from the other is the
// kind of parsing nobody should have to do twice.
try
{
status["saveCreatedAt"] = SaveRestore.SaveCreatedTime.ToUniversalTime()
.ToString("yyyy-MM-ddTHH:mm:ssZ", System.Globalization.CultureInfo.InvariantCulture);
}
catch (Exception)
{
// A server that has never saved has no created time. Absent, not zero.
}
return status;
}
/// <summary>
/// Puts an IP address onto a frame, or leaves it off.
///
/// <para>
/// The game does not always have one. A console <c>banid</c> against an offline id
/// produces the literal string <b><c>"0"</c></b> rather than an address or a null —
/// observed on a live server, not guessed — and a database column full of "0" is worse
/// than one full of nulls, because "0" survives every is-it-missing test a reader
/// writes and then fails whatever parses it.
/// </para>
///
/// <para>
/// Absent, not zero. The same rule `saveCreatedAt` and `sessionSec` follow.
/// </para>
/// </summary>
private static void PutAddress(Dictionary<string, object> frame, string ip)
{
if (string.IsNullOrEmpty(ip) || ip == "0" || ip == "0.0.0.0")
return;
frame["ip"] = ip;
}
private static void Merge(Dictionary<string, object> into, Dictionary<string, object> from)
{
foreach (KeyValuePair<string, object> pair in from)
into[pair.Key] = pair.Value;
}
/// <summary>
/// Describes who or what killed somebody, onto <paramref name="frame"/>.
///
/// <para>
/// Four outcomes, and collapsing any two of them loses something a killfeed needs:
/// <c>player</c> (a real kill, and the only one that belongs on a leaderboard),
/// <c>self</c>, <c>npc</c> and <c>environment</c> — falling, drowning, a helicopter's
/// fire, or the world simply deciding. `HitInfo` may legitimately be null, which is
/// the environment case and not an error.
/// </para>
/// </summary>
private static void DescribeAttacker(
Dictionary<string, object> frame, BasePlayer victim, HitInfo info)
{
BaseEntity initiator = info != null ? info.Initiator : null;
BasePlayer attacker = info != null ? info.InitiatorPlayer : null;
string weapon = null;
if (info != null)
{
if (info.WeaponPrefab != null)
weapon = info.WeaponPrefab.ShortPrefabName;
else if (info.Weapon != null)
weapon = info.Weapon.ShortPrefabName;
}
if (weapon != null)
frame["weapon"] = weapon;
if (initiator == null)
{
frame["attackerType"] = "environment";
return;
}
if (attacker != null && attacker.userID == victim.userID)
{
frame["attackerType"] = "self";
return;
}
if (attacker != null && !attacker.IsNpc)
{
frame["attackerType"] = "player";
frame["attackerId"] = attacker.UserIDString;
frame["attackerName"] = attacker.displayName;
}
else
{
frame["attackerType"] = "npc";
frame["attackerName"] = initiator.ShortPrefabName;
}
float distance = info != null ? info.ProjectileDistance : 0f;
if (distance <= 0f && initiator.transform != null && victim.transform != null)
distance = UnityEngine.Vector3.Distance(
initiator.transform.position, victim.transform.position);
if (distance > 0f)
frame["distance"] = (float)Math.Round(distance, 1);
}
// ---- the map grid ----
/// <summary>
/// An entity's position as Rust's own map reference (<c>H7</c>), or null if it has no
/// position to read. A reference, never a coordinate: a grid cell is 146.3 units
/// across, which is precise enough to say where a fight happened and far too coarse to
/// walk to somebody's door with.
/// </summary>
private static string GridOf(BaseEntity entity)
{
if (entity == null || entity.transform == null)
return null;
return Grid(entity.transform.position);
}
private static string Grid(UnityEngine.Vector3 position)
{
float size = ConVar.Server.worldsize;
if (size <= 0f)
return null;
float half = size / 2f;
int columns = (int)Math.Ceiling(size / GridCellSize);
int x = (int)((position.x + half) / GridCellSize);
// Rows are numbered from the NORTH edge downwards, which is why this is a subtraction
// and not the same expression as the column. Getting it wrong mirrors every grid on
// the map and is invisible without a second opinion.
int z = (int)((half - position.z) / GridCellSize);
x = Clamp(x, 0, columns - 1);
z = Clamp(z, 0, columns - 1);
return Column(x) + z.ToString(System.Globalization.CultureInfo.InvariantCulture);
}
private static int Clamp(int value, int low, int high)
{
if (high < low)
return low;
return value < low ? low : (value > high ? high : value);
}
/// <summary>A column index as Rust spells it: 0 is A, 25 is Z, 26 is AA.</summary>
private static string Column(int index)
{
string name = string.Empty;
int n = index + 1;
while (n > 0)
{
int remainder = (n - 1) % 26;
name = (char)('A' + remainder) + name;
n = (n - 1) / 26;
}
return name;
}
// ---- identity of the world ----
/// <summary>
/// The current wipe, as <c>w-yyyyMMddTHHmmssZ</c>, or null if this server has never
/// saved.
///
/// <para>
/// Protocol 1 left this to the website. Protocol 2 derives it here because by now
/// three components store rows that need it, and the plugin is the only one of the
/// three that can read the value — the other two would be re-deriving something they
/// were already told, and two derivations of one fact eventually disagree about a
/// boundary.
/// </para>
/// </summary>
private static string ResolveWipeId()
{
try
{
DateTime created = SaveRestore.SaveCreatedTime.ToUniversalTime();
// A server that has never saved reports the bottom of the calendar. That is not a
// wipe in 0001, it is the absence of one.
if (created.Year < 2000)
return null;
return "w-" + created.ToString(
"yyyyMMdd'T'HHmmss'Z'", System.Globalization.CultureInfo.InvariantCulture);
}
catch (Exception)
{
return null;
}
}
/// <summary>
/// Records that a hook fired. See <c>_hookCounts</c> for why this exists at all.
/// </summary>
private void MarkHook(string name)
{
long count;
_hookCounts.TryGetValue(name, out count);
_hookCounts[name] = count + 1;
}
// ---- the read path ----
//
// Every hook below obeys three rules, and each of them has a failure behind it.
//
// 1. **A read-path hook never vetoes.** Four of these are documented by uMod as
// "returning a non-null value overrides default behavior" — a bridge that returned
// something by accident would cancel a death, swallow a player's wood or refuse a
// login, on somebody's production server at 3am. So they are declared `void`: both
// frameworks take the method's return value, and a void method has none. The rule is
// enforced by the signature rather than by remembering to write `return null`, which
// is the only version of it that survives a year of edits.
//
// 2. **A hook that can fire more than once a second per player is a counter, not an
// event.** `OnDispenserGather` fires on every swing at a tree. It accumulates into
// `_tallies` and leaves as one `player.tally` frame a minute (PROTOCOL.md §8.6).
//
// 3. **Every hook marks itself.** `MarkHook` is what makes `rg.hooks` able to say which
// of these actually fire on the framework this server is running — the standing
// answer both to Facepunch renaming a hook and to a framework catalogue that does not
// list one.
//
// All of it is the main thread, which is what makes reading `BasePlayer`, `ConVar` and a
// transform legal here and illegal in the reader thread.
private void OnServerInitialized(bool initial)
{
MarkHook("OnServerInitialized");
// Re-read the wipe, and this is not belt-and-braces: at a real server
// start `Init` runs BEFORE the save is loaded, so `SaveCreatedTime` is not yet
// meaningful and the id resolved there is null for the entire session. This hook is
// the first moment the world is actually there.
//
// It was invisible on a development rig for the reason such things usually are: a
// hot-reloaded plugin reads an already-loaded world and gets the right answer every
// time. It took a server that BOOTED with the plugin installed — which is every real
// one — to show `wipeId=none` on a server with a save sitting on disk.
_wipeId = ResolveWipeId();
// Only now is the wipe known, so only now can what an earlier map owned be dropped;
// and this hook also fires on a hotload, which is exactly when ZoneManager has just
// erased every zone the previous load of this plugin owned (D92).
_worldReady = true;
DropWorldFromOtherWipes();
RecreateZones("load");
// Started here rather than in Init: this hook fires both at the end of server startup
// and immediately on a hotload, so it is the one place that runs exactly once per
// load with the world available either way.
if (_cadence == null)
_cadence = timer.Every(BoardCadenceSeconds, Cadence);
// Say the world is ready now rather than at the next tick: the website holds its
// reconcile until it hears this.
if (_connected)
SendHello();
Dictionary<string, object> frame = Frame("server.initialized", "event");
frame["initial"] = initial;
EmitFrame(frame);
}
private void OnServerShutdown()
{
MarkHook("OnServerShutdown");
FlushTallies();
EmitFrame(Frame("server.shutdown", "event"));
}
/// <summary>
/// A new save is the only moment a wipe id can change, so it is the only place that
/// re-reads it. The frame carries both ids because "which wipe did this replace" is
/// the question a site asks when it draws the boundary, and it is unanswerable
/// afterwards.
/// </summary>
private void OnNewSave(string filename)
{
MarkHook("OnNewSave");
string previous = _wipeId;
_wipeId = ResolveWipeId();
// Built after the re-read, so `wipeId` on the envelope is the NEW wipe: this frame
// belongs to the world it opens, not to the one it closes.
Dictionary<string, object> frame = Frame("server.wipe", "event");
if (!string.IsNullOrEmpty(previous))
frame["previousWipeId"] = previous;
EmitFrame(frame);
// Nothing from the old world is still true. The tallies are one such piece of state
// this plugin holds.
_tallies.Clear();
_connectedAt.Clear();
// Nor is anything an event placed: the old map, and everything on it, is gone.
DropWorldFromOtherWipes();
Puts($"new save; wipe is now {(_wipeId ?? "unknown")}");
}
private void OnPlayerConnected(BasePlayer player)
{
MarkHook("OnPlayerConnected");
if (player == null)
return;
_connectedAt[player.userID] = NowMs();
Dictionary<string, object> frame = Frame("player.connected", "event");
frame["steamId"] = player.UserIDString;
frame["name"] = player.displayName;
EmitFrame(frame);
}
private void OnPlayerDisconnected(BasePlayer player, string reason)
{
MarkHook("OnPlayerDisconnected");
if (player == null)
return;
// Before the frame: a session's last minute of gathering is worth more than the order
// of two lines, and after this point there is nothing to attribute it to.
FlushTally(player.userID);
Dictionary<string, object> frame = Frame("player.disconnected", "event");
frame["steamId"] = player.UserIDString;
frame["name"] = player.displayName;
if (!string.IsNullOrEmpty(reason))
frame["reason"] = Flatten(reason);
long since;
if (_connectedAt.TryGetValue(player.userID, out since))
{
frame["sessionSec"] = (int)((NowMs() - since) / 1000L);
_connectedAt.Remove(player.userID);
}
// A player who was already on when this plugin loaded has no recorded connect, so
// `sessionSec` is ABSENT rather than zero: a session of unknown length is not a
// session of no length, and summing zeros silently under-reports playtime for ever.
EmitFrame(frame);
}
private void OnPlayerRespawned(BasePlayer player)
{
MarkHook("OnPlayerRespawned");
if (player == null)
return;
Dictionary<string, object> frame = Frame("player.respawned", "event");
frame["steamId"] = player.UserIDString;
frame["name"] = player.displayName;
EmitFrame(frame);
}
/// <summary>
/// Void, and deliberately: uMod documents a non-null return here as overriding the
/// death itself. See rule 1 at the top of this region.
/// </summary>
private void OnPlayerDeath(BasePlayer player, HitInfo info)
{
MarkHook("OnPlayerDeath");
if (player == null || player.IsNpc)
return;
Dictionary<string, object> frame = Frame("player.death", "event");
frame["steamId"] = player.UserIDString;
frame["name"] = player.displayName;
frame["sleeping"] = player.IsSleeping();
string grid = GridOf(player);
if (grid != null)
frame["grid"] = grid;
DescribeAttacker(frame, player, info);
EmitFrame(frame);
}
/// <summary>
/// Void, and deliberately: a non-null return here overrides chat delivery — the bridge
/// would be silently eating messages.
/// </summary>
private void OnPlayerChat(BasePlayer player, string message, ConVar.Chat.ChatChannel channel)
{
MarkHook("OnPlayerChat");
if (player == null || string.IsNullOrEmpty(message))
return;
Dictionary<string, object> frame = Frame("player.chat", "event");
frame["steamId"] = player.UserIDString;
frame["name"] = player.displayName;
frame["channel"] = channel.ToString();
// Flattened for the same reason every other string on this wire is: one frame is one
// line, and a chat message is the one field a player controls the bytes of.
frame["message"] = Flatten(message);
EmitFrame(frame);
}
/// <summary>
/// Void, and deliberately: a non-null return here replaces what the player receives.
/// This hook is a counter — see rule 2.
/// </summary>
private void OnDispenserGather(ResourceDispenser dispenser, BasePlayer player, Item item)
{
MarkHook("OnDispenserGather");
if (player == null || player.IsNpc || item == null || item.info == null)
return;
TallyFor(player).Gather(item.info.shortname, item.amount);
}
/// <summary>
/// Everything that dies which is not a player death: NPC kills, which are a counter,
/// and raided structures, which are an event with a `staff` class because a
/// structure's grid is where somebody lives.
///
/// <para>
/// The type checks are ordered cheapest-first and return early, because this hook
/// fires for every tree, barrel and animal on the map.
/// </para>
/// </summary>
private void OnEntityDeath(BaseCombatEntity entity, HitInfo info)
{
MarkHook("OnEntityDeath");
if (entity == null)
return;
BasePlayer attacker = info != null ? info.InitiatorPlayer : null;
bool byPlayer = attacker != null && !attacker.IsNpc;
// Nothing below is interesting unless a real player did it. That single test discards
// decay, despawns and the world killing itself, which is the bulk of this hook.
if (!byPlayer)
return;
BasePlayer victimPlayer = entity as BasePlayer;
if (victimPlayer != null)
{
// A real player's death is OnPlayerDeath's, not ours — emitting here too would
// double every kill in the feed. Only the NPC kind is ours to count.
if (victimPlayer.IsNpc)
TallyFor(attacker).NpcKills++;
return;
}
if (entity is BaseNpc)
{
TallyFor(attacker).NpcKills++;
return;
}
// Protocol 7 (PROTOCOL.md §13): a raid is the loss of anything a base is made of, not
// only a building block. A door is NOT a BuildingBlock — both derive from
// StabilityEntity as siblings — so until protocol 7 the most common raid there is, two
// charges on a door, reached nobody.
string structure = StructureKind(entity);
// `OwnerID == 0` is the world's own: a monument's doors and walls. Destroying those is
// not a raid on anybody, and emitting them would fill the staff feed with loot runs.
if (structure == null || entity.OwnerID == 0UL)
return;
// The tally's meaning is unchanged since protocol 2 — building blocks only — so a
// leaderboard column does not quietly change what it counts in the middle of a wipe.
if (structure == "block")
TallyFor(attacker).Structures++;
Dictionary<string, object> frame = Frame("entity.destroyed", "event");
frame["ownerId"] = entity.OwnerID.ToString();
frame["prefab"] = entity.ShortPrefabName;
frame["structure"] = structure;
// From `userID`, not `UserIDString`. The string is filled in when a player connects,
// loads as a sleeper, or is an engine bot — and at no other time, so a player another
// plugin spawned carries none. The website skips the raid alert when the attacker is
// authorised on the cupboard (PLAN.md D59), and a null here would quietly defeat that.
frame["attackerId"] = ((ulong)attacker.userID).ToString();
frame["attackerName"] = attacker.displayName;
string grid = GridOf(entity);
if (grid != null)
frame["grid"] = grid;
AddAuthorization(frame, entity);
EmitFrame(frame);
}
/// <summary>
/// The most people one <c>entity.destroyed</c> frame names as authorised. A cupboard
/// has no hard cap on vanilla, and an unbounded list is an unbounded line on the game
/// link; 64 is several full teams, and a frame cut short says so.
/// </summary>
private const int MaxAuthorizedOnFrame = 64;
/// <summary>
/// Which of the four things a base is made of this entity is, or null for anything else.
/// Ordered by how often each dies, which is also cheapest-first.
/// </summary>
private static string StructureKind(BaseEntity entity)
{
if (entity is BuildingBlock)
return "block";
// External gates are Doors too, which is right: a gate is a door in a wall.
if (entity is Door)
return "door";
if (entity is SimpleBuildingBlock)
return "wall";
if (entity is BuildingPrivlidge)
return "cupboard";
return null;
}
/// <summary>
/// Who lives here, by the game's own definition: everyone authorised on the building's
/// tool cupboard. Both fields are ABSENT when there is no cupboard, which is a different
/// answer from an empty list — "nobody is authorised" and "there is nothing to be
/// authorised on" — and the module alerts nobody in either case (PLAN.md D67).
///
/// <para>
/// <c>authorizedPlayers</c> is the set, and nothing else is. The cupboard also holds
/// <c>recentGroupMembers</c>, which LOOKS like residents and is not: it counts code-lock
/// users towards group upkeep and prunes itself on a timer.
/// </para>
///
/// <para>
/// The lookup resolves through the entity's building, which is cheap. An external wall
/// has no building and falls back to a sphere query on this thread — acceptable because
/// it runs only after every cheap filter above has passed, at the rate walls fall rather
/// than the rate this hook fires.
/// </para>
/// </summary>
private static void AddAuthorization(Dictionary<string, object> frame, BaseEntity entity)
{
// A cupboard being destroyed still holds its list: OnEntityDeath runs before the kill.
BuildingPrivlidge cupboard = entity as BuildingPrivlidge ?? entity.GetBuildingPrivilege();
if (cupboard == null || cupboard.net == null || cupboard.authorizedPlayers == null)
return;
HashSet<ulong> online = new HashSet<ulong>();
if (BasePlayer.activePlayerList != null)
{
foreach (BasePlayer player in BasePlayer.activePlayerList)
{
if (player != null)
online.Add(player.userID);
}
}
List<object> authorized = new List<object>();
foreach (ulong steamId in cupboard.authorizedPlayers)
{
if (authorized.Count >= MaxAuthorizedOnFrame)
{
frame["authorizedTruncated"] = true;
break;
}
authorized.Add(new Dictionary<string, object>
{
{ "steamId", steamId.ToString() },
{ "online", online.Contains(steamId) },
});
}
frame["buildingId"] = cupboard.net.ID.Value.ToString();
frame["authorized"] = authorized;
}
private void OnPlayerReported(
BasePlayer reporter, string targetName, string targetId,
string subject, string message, string type)
{
MarkHook("OnPlayerReported");
Dictionary<string, object> frame = Frame("player.reported", "event");
if (reporter != null)
{
frame["reporterId"] = reporter.UserIDString;
frame["reporterName"] = reporter.displayName;
}
frame["targetId"] = targetId;
frame["targetName"] = targetName;
frame["subject"] = Flatten(subject);
frame["message"] = Flatten(message);
frame["reportType"] = type;
EmitFrame(frame);
}
private void OnUserBanned(string name, string id, string ipAddress, string reason)
{
MarkHook("OnUserBanned");
Dictionary<string, object> frame = Frame("player.banned", "event");
frame["steamId"] = id;
frame["name"] = name;
frame["reason"] = Flatten(reason);
PutAddress(frame, ipAddress);
EmitFrame(frame);
}
private void OnUserUnbanned(string name, string id, string ipAddress)
{
MarkHook("OnUserUnbanned");
Dictionary<string, object> frame = Frame("player.unbanned", "event");
frame["steamId"] = id;
frame["name"] = name;
PutAddress(frame, ipAddress);
EmitFrame(frame);
}
/// <summary>
/// Void, and this is the most important void in the file: a non-null return here
/// **kicks the player**, with the returned string as the reason. The bridge is here to
/// watch logins, never to decide one.
///
/// <para>
/// It fires on every connection attempt, approved or not — so it is an attempt, not a
/// denial. uMod publishes no <c>OnUserRejected</c>, and the only way to observe a
/// refusal from this hook is to be the thing refusing. A denial is therefore the
/// ABSENCE of a matching <c>player.approved</c>, decided by whoever reads the two
/// frames later; PROTOCOL.md §8.8 records that as a correction to the trigger
/// catalogue.
/// </para>
/// </summary>
private void CanUserLogin(string name, string id, string ipAddress)
{
MarkHook("CanUserLogin");
Dictionary<string, object> frame = Frame("player.login.attempt", "event");
frame["steamId"] = id;
frame["name"] = name;
PutAddress(frame, ipAddress);
EmitFrame(frame);
}
private void OnUserApproved(string name, string id, string ipAddress)
{
MarkHook("OnUserApproved");
Dictionary<string, object> frame = Frame("player.approved", "event");
frame["steamId"] = id;
frame["name"] = name;
PutAddress(frame, ipAddress);
EmitFrame(frame);
}
// ---- clans (protocol 6) ----
//
// Rust's FIRST-PARTY clan system, which the website turns into core's Teams (R5,
// PROTOCOL.md §12). Not the uMod Clans plugin: that is a separate system that never
// touches `ClanManager` (D47), and this region reads nothing of it except whether it is
// loaded, so the website can warn that its clans are not Teams.
//
// One board and five events, the same split as presence. The events carry the story —
// founded, joined, left, removed, disbanded — and the board is the reconciliation point
// that corrects anything missed within a minute. Leadership travels ONLY on the board,
// because the game raises no hook when somebody is promoted or demoted (§12.3).
//
// Three facts about the game shape everything below, and all three were read from its
// assemblies rather than from a catalogue:
//
// 1. **There is no "list every clan" call.** The backend offers get-by-id and
// get-by-member; the only listing is the clan leaderboard, and the game clamps that
// to 100 clans by score. That clamp is the board's ceiling (D55), and a board at it
// says `truncated` rather than pretending to be the whole answer.
//
// 2. **A clan id is not an identity.** The database file's version number is
// hard-coded, so a game update that bumps it starts a fresh file whose ids restart at
// 1. Every frame therefore carries the clan's creation time beside its id, and the
// pair is what the website keys a Team on (D52).
//
// 3. **The local backend is synchronous behind an async signature.** Every `ValueTask`
// it returns has already completed, which is what makes reading it from a hook
// legal. A task that has NOT completed means some other backend, and it is refused
// rather than waited on — waiting would be the main thread blocking on a database.
/// <summary>
/// The most clans one board can carry, and it is the GAME's number: the clan leaderboard
/// clamps its limit to 10..100. Asking for more returns 100.
/// </summary>
private const int MaxClansOnBoard = 100;
/// <summary>
/// How many bytes of clan rows one board may carry. Well inside the sidecar's 1 MiB line
/// cap, which discards an over-long line outright: a board that silently never arrived
/// would look like a server with no clans. 100 clans of 100 members each is about a
/// megabyte, so the cap is reachable, and a board that meets it says so.
/// </summary>
private const int ClanBoardBudgetBytes = 768 * 1024;
/// <summary>
/// How long after a membership change the clan board is re-sent. The event reaches the
/// sidecar at once; the roster that follows it waits for the game to refresh its own
/// cached clan, which `OnClanMemberAdded` fires before.
/// </summary>
private const float ClanBoardSettleSeconds = 3f;
/// <summary>A pending early re-send of the clan board, so a burst of changes sends one.</summary>
private Timer _clansBoardSoon;
/// <summary>
/// Main thread. Every clan on this server, with its roster and roles, as the board the
/// website reconciles Teams against.
///
/// <para>
/// The board is ALWAYS sent, including when there is nothing to read — with
/// <c>supported: false</c> and a reason. Saying nothing would leave the website holding
/// the last board it saw and no way to know it had stopped being true.
/// </para>
/// </summary>
private void SendClansBoard()
{
Dictionary<string, object> frame = Frame("clans", "snapshot");
var clans = new List<object>();
frame["enabled"] = ConVar.Clan.enabled;
frame["umodClans"] = plugins.Find("Clans") != null;
frame["clans"] = clans;
frame["count"] = 0;
frame["truncated"] = false;
// Switched off by the operator. That is an authoritative answer — this server has no
// clans — and different from "could not read them", so it is supported and empty.
if (!ConVar.Clan.enabled)
{
frame["supported"] = true;
EmitFrame(frame);
return;
}
IClanBackend backend = ClanManager.ServerInstance != null ? ClanManager.ServerInstance.Backend : null;
if (backend == null)
{
frame["supported"] = false;
frame["reason"] = "the game's clan backend has not started yet";
EmitFrame(frame);
return;
}
frame["backend"] = backend.GetType().Name;
// A Nexus server keeps its clans on Facepunch's side of the Nexus, behind a backend
// whose calls do not complete on the spot. Refused with a reason rather than guessed
// at (D55): half-reading it would be a board that is wrong in a way nobody can see.
if (!(backend is LocalClanBackend))
{
frame["supported"] = false;
frame["reason"] = $"this server's clans are held by a {backend.GetType().Name}, which the bridge does not read";
EmitFrame(frame);
return;
}
System.Threading.Tasks.ValueTask<ClanValueResult<List<ClanLeaderboardEntry>>> listing = backend.GetLeaderboard(MaxClansOnBoard);
if (!listing.IsCompleted || !listing.Result.IsSuccess)
{
frame["supported"] = false;
frame["reason"] = listing.IsCompleted
? $"the game's clan list could not be read ({listing.Result.Result})"
: "the game's clan list did not answer at once";
EmitFrame(frame);
return;
}
List<ClanLeaderboardEntry> entries = listing.Result.Value;
// At the clamp there may be more clans than the game will list. It cannot be told
// apart from exactly 100, so it is reported as possibly more — the honest direction,
// since the website then only ADDS Teams and never removes one on this answer.
bool truncated = entries.Count >= MaxClansOnBoard;
int bytes = 0;
foreach (ClanLeaderboardEntry entry in entries)
{
IClan clan = ReadClan(backend, entry.ClanId);
if (clan == null)
continue;
Dictionary<string, object> row = DescribeClan(clan);
int size = Encoding.UTF8.GetByteCount(JsonConvert.SerializeObject(row, Formatting.None));
if (bytes + size > ClanBoardBudgetBytes)
{
truncated = true;
break;
}
bytes += size;
clans.Add(row);
}
frame["supported"] = true;
frame["truncated"] = truncated;
frame["count"] = clans.Count;
EmitFrame(frame);
}
/// <summary>
/// Main thread. One clan by id, from the game's cache if it is there and from its
/// database if not, or null. Never waits: see fact 3 at the top of this region.
/// </summary>
private static IClan ReadClan(IClanBackend backend, long clanId)
{
IClan clan;
if (backend.TryGet(clanId, out clan) && clan != null)
return clan;
System.Threading.Tasks.ValueTask<ClanValueResult<IClan>> read = backend.Get(clanId);
if (!read.IsCompleted || !read.Result.IsSuccess)
return null;
return read.Result.Value;
}
/// <summary>
/// Main thread. A clan as the board carries it.
///
/// <para>
/// A member's <c>LastSeen</c> is left off on purpose. It says when somebody was last on
/// the server, which is presence, and the website's rule is that nothing names who is on
/// by default (PLAN.md §23). Not sending it is simpler than trusting every reader to
/// withhold it.
/// </para>
/// </summary>
private Dictionary<string, object> DescribeClan(IClan clan)
{
var roles = new Dictionary<int, ClanRole>();
if (clan.Roles != null)
{
foreach (ClanRole role in clan.Roles)
roles[role.RoleId] = role;
}
var members = new List<object>();
if (clan.Members != null)
{
foreach (ClanMember member in clan.Members)
{
var row = new Dictionary<string, object>
{
{ "steamId", member.SteamId.ToString() },
{ "joinedMs", member.Joined }
};
ClanRole role;
// A member whose role id matches no role is left without a rank rather than
// given one: "not known" must never read as "leads this clan", and rank 1 is
// exactly what a default of zero-plus-one would produce.
if (roles.TryGetValue(member.RoleId, out role))
{
row["rank"] = role.Rank;
row["role"] = role.Name;
}
string name = PlayerName(member.SteamId);
if (!string.IsNullOrEmpty(name))
row["name"] = name;
members.Add(row);
}
}
UnityEngine.Color32 color = clan.Color;
return new Dictionary<string, object>
{
{ "clanId", clan.ClanId },
{ "createdMs", clan.Created },
{ "name", clan.Name },
{ "color", $"#{color.r:x2}{color.g:x2}{color.b:x2}" },
{ "score", clan.Score },
{ "maxMembers", clan.MaxMemberCount },
{ "members", members }
};
}
/// <summary>
/// Main thread. A player's name, whether or not they are on, or null. The framework
/// remembers everybody who has ever joined, which is what lets an offline clan member
/// have a name on a roster.
/// </summary>
private string PlayerName(ulong steamId)
{
BasePlayer player = BasePlayer.FindByID(steamId) ?? BasePlayer.FindSleeping(steamId);
if (player != null)
return player.displayName;
Oxide.Core.Libraries.Covalence.IPlayer known = covalence.Players.FindPlayerById(steamId.ToString());
return known != null ? known.Name : null;
}
/// <summary>
/// Main thread. The envelope every clan event shares: which clan, identified the way
/// the website keys it (D52), and its name so a feed line can say something without a
/// lookup.
/// </summary>
private Dictionary<string, object> ClanFrame(string kind, IClan clan)
{
Dictionary<string, object> frame = Frame(kind, "event");
frame["clanId"] = clan.ClanId;
frame["createdMs"] = clan.Created;
frame["clanName"] = clan.Name;
return frame;
}
/// <summary>
/// Main thread. Puts a player onto a clan event as <c>steamId</c> and <c>name</c> — the
/// same two fields every player event carries, so the website's player table learns the
/// name the same way it does from a connect.
/// </summary>
private void PutPlayer(Dictionary<string, object> frame, string idKey, string nameKey, ulong steamId)
{
if (steamId == 0UL)
return;
frame[idKey] = steamId.ToString();
string name = PlayerName(steamId);
if (!string.IsNullOrEmpty(name))
frame[nameKey] = name;
}
/// <summary>
/// Main thread. Re-sends the clan board shortly, once, however many changes arrive in
/// the meantime. The cadence would get there within a minute anyway; this is what makes
/// a roster follow the event that changed it.
/// </summary>
private void ScheduleClansBoard()
{
if (_clansBoardSoon != null)
return;
_clansBoardSoon = timer.Once(ClanBoardSettleSeconds, () =>
{
_clansBoardSoon = null;
if (!_connected)
return;
try
{
SendClansBoard();
}
catch (Exception ex)
{
Puts($"could not build the clans board: {Flatten(ex.Message)}");
}
});
}
/// <summary>
/// The founder's own membership raises no <c>OnClanMemberAdded</c> — the game adds them
/// inside the creation — so this event carries them, and the website counts them in.
/// </summary>
private void OnClanCreated(LocalClan clan, ulong steamId)
{
MarkHook("OnClanCreated");
if (clan == null)
return;
Dictionary<string, object> frame = ClanFrame("clan.created", clan);
PutPlayer(frame, "steamId", "name", steamId);
EmitFrame(frame);
ScheduleClansBoard();
}
/// <summary>
/// Typed exactly, and that matters here more than anywhere: the uMod Clans plugin raises
/// a hook with this SAME NAME and arity — <c>OnClanDisbanded(string, List&lt;ulong&gt;)</c>
/// — about its own, unrelated clans. Both frameworks match a call to a method by its
/// argument types, so a loosely typed signature here would file the plugin's clans as
/// the game's.
/// </summary>
private void OnClanDisbanded(LocalClan clan, ulong steamId)
{
MarkHook("OnClanDisbanded");
if (clan == null)
return;
Dictionary<string, object> frame = ClanFrame("clan.disbanded", clan);
PutPlayer(frame, "steamId", "name", steamId);
// Protocol 7: who was in it. The website tells a disbanded clan's members, and by the
// time it reads this frame the next board may already have removed the roster it would
// otherwise ask — the board is re-sent seconds after this, and after an outage it is
// applied before the backlog. The game has deleted the clan from its database by now,
// and walked `Members` to drop each one's membership, but it never empties the list
// itself — so it is still whole here, bounded by the clan's own member limit.
if (clan.Members != null)
{
List<object> members = new List<object>();
foreach (ClanMember member in clan.Members)
members.Add(member.SteamId.ToString());
frame["members"] = members;
}
EmitFrame(frame);
ScheduleClansBoard();
}
/// <summary>
/// The one clan hook that hands over a bare id rather than a clan: the game raises it
/// from inside its database layer, before the cached clan has been refreshed. The id is
/// enough to find the clan and its creation time; if even that fails, the frame goes
/// without <c>createdMs</c> and the website matches it on the id alone.
/// </summary>
private void OnClanMemberAdded(long clanId, ulong steamId)
{
MarkHook("OnClanMemberAdded");
IClanBackend backend = ClanManager.ServerInstance != null ? ClanManager.ServerInstance.Backend : null;
IClan clan = backend != null ? ReadClan(backend, clanId) : null;
Dictionary<string, object> frame;
if (clan != null)
{
frame = ClanFrame("clan.member.added", clan);
}
else
{
frame = Frame("clan.member.added", "event");
frame["clanId"] = clanId;
}
PutPlayer(frame, "steamId", "name", steamId);
EmitFrame(frame);
ScheduleClansBoard();
}
private void OnClanMemberLeft(LocalClan clan, ulong steamId)
{
MarkHook("OnClanMemberLeft");
if (clan == null)
return;
Dictionary<string, object> frame = ClanFrame("clan.member.left", clan);
PutPlayer(frame, "steamId", "name", steamId);
EmitFrame(frame);
ScheduleClansBoard();
}
private void OnClanMemberKicked(LocalClan clan, ulong steamId, ulong bySteamId)
{
MarkHook("OnClanMemberKicked");
if (clan == null)
return;
Dictionary<string, object> frame = ClanFrame("clan.member.kicked", clan);
PutPlayer(frame, "steamId", "name", steamId);
PutPlayer(frame, "bySteamId", "byName", bySteamId);
EmitFrame(frame);
ScheduleClansBoard();
}
/// <summary>
/// Not an event: a colour is a property of the clan, so it travels on the board. The
/// hook only brings the next board forward.
/// </summary>
private void OnClanColorChanged(LocalClan clan, UnityEngine.Color32 color, ulong steamId)
{
MarkHook("OnClanColorChanged");
ScheduleClansBoard();
}
// ---- tallies ----
/// <summary>
/// One player's counters between two flushes. Main thread only; no lock, ever.
/// </summary>
private class Tally
{
public readonly Dictionary<string, int> Gathered = new Dictionary<string, int>();
public int NpcKills;
public int Structures;
public string Name;
public void Gather(string shortname, int amount)
{
if (string.IsNullOrEmpty(shortname) || amount <= 0)
return;
int have;
Gathered.TryGetValue(shortname, out have);
Gathered[shortname] = have + amount;
}
public bool IsEmpty()
{
return Gathered.Count == 0 && NpcKills == 0 && Structures == 0;
}
}
private Tally TallyFor(BasePlayer player)
{
Tally tally;
if (!_tallies.TryGetValue(player.userID, out tally))
{
tally = new Tally();
_tallies[player.userID] = tally;
}
tally.Name = player.displayName;
return tally;
}
/// <summary>
/// Emits one <c>player.tally</c> per player with anything to report, and clears what it
/// emitted.
///
/// <para>
/// A tally frame is a <b>delta, not a running total</b>: it says what happened since
/// the last flush, so the far side sums rather than diffs. A missed frame then costs
/// one interval instead of corrupting the whole series, which is the failure mode a
/// running total has over a link that is allowed to drop frames (and this one is —
/// the outbound queue is drop-oldest by design).
/// </para>
/// </summary>
private void FlushTallies()
{
if (_tallies.Count == 0)
return;
// Materialised, because emitting mutates nothing here but clearing does, and iterating
// a dictionary while clearing it is a different bug on every runtime.
var ids = new List<ulong>(_tallies.Keys);
foreach (ulong id in ids)
FlushTally(id);
}
private void FlushTally(ulong userId)
{
Tally tally;
if (!_tallies.TryGetValue(userId, out tally))
return;
_tallies.Remove(userId);
if (tally.IsEmpty())
return;
Dictionary<string, object> frame = Frame("player.tally", "event");
frame["steamId"] = userId.ToString();
if (!string.IsNullOrEmpty(tally.Name))
frame["name"] = tally.Name;
if (tally.Gathered.Count > 0)
frame["gathered"] = tally.Gathered;
if (tally.NpcKills > 0)
frame["npcKills"] = tally.NpcKills;
if (tally.Structures > 0)
frame["structures"] = tally.Structures;
EmitFrame(frame);
}
/// <summary>
/// The one repeating job: re-send the boards and flush the tallies.
///
/// <para>
/// It does nothing at all while the link is down. That is not an optimisation — the
/// outbound queue is drop-oldest, so a disconnected server would spend its whole
/// outage pushing minute-old boards over the top of the events that actually matter.
/// A reconnect re-sends the boards anyway, which is the whole point of a board.
/// </para>
/// </summary>
private void Cadence()
{
if (!_connected)
return;
FlushTallies();
SendBoards();
}
/// <summary>
/// Collapses every run of whitespace <b>and control characters</b> to one space, so an
/// exception message stays one log entry.
///
/// <para>
/// The control half is not defensive padding. Mono's <c>SocketException.Message</c> on
/// Windows comes back from a fixed-size OS buffer and is <b>NUL-padded</b> — a couple
/// of hundred <c>\0</c> bytes sitting in the middle of the sentence. <c>\0</c> is not
/// whitespace, so <c>Trim</c> does not touch it and neither does a whitespace-only
/// collapse; the line looks like it contains a huge run of spaces and no amount of
/// trimming removes it.
/// </para>
/// </summary>
private static string Flatten(string text)
{
if (string.IsNullOrEmpty(text))
return string.Empty;
var sb = new StringBuilder(text.Length);
bool lastWasSpace = false;
foreach (char c in text)
{
if (char.IsWhiteSpace(c) || char.IsControl(c))
{
if (!lastWasSpace && sb.Length > 0)
sb.Append(' ');
lastWasSpace = true;
}
else
{
sb.Append(c);
lastWasSpace = false;
}
}
return sb.ToString().TrimEnd();
}
/// <summary>
/// The server process's start instant, as <c>boot-yyyyMMddTHHmmssZ</c>.
///
/// <para>
/// <c>Process.StartTime</c> is exact and identical on every read, which is what makes
/// the id survive a plugin reload. The fallback derives the same instant from Unity's
/// monotonic clock, and is only reached if the platform refuses the process query; it
/// is rounded to the second deliberately, because the subtraction jitters in the last
/// decimal and an id that changes on rounding is worse than one a second off.
/// </para>
/// </summary>
private static string ResolveBootId()
{
DateTime startUtc;
try
{
startUtc = System.Diagnostics.Process.GetCurrentProcess().StartTime.ToUniversalTime();
}
catch (Exception)
{
double up = UnityEngine.Time.realtimeSinceStartup;
startUtc = DateTime.UtcNow.AddSeconds(-Math.Round(up));
}
startUtc = new DateTime(
startUtc.Year, startUtc.Month, startUtc.Day,
startUtc.Hour, startUtc.Minute, startUtc.Second,
DateTimeKind.Utc);
return "boot-" + startUtc.ToString(
"yyyyMMdd'T'HHmmss'Z'", System.Globalization.CultureInfo.InvariantCulture);
}
private static long NowMs()
{
return ToUnixMs(DateTime.UtcNow);
}
// ---- account linking ----
/// <summary>
/// The alphabet a link code is drawn from.
///
/// <para>
/// <b>No O, 0, I or 1.</b> A player reads this code off their screen and types it into
/// a browser, often on a phone, sometimes reading it aloud to somebody else. Those four
/// glyphs are the ones that get transcribed wrong, and a code that fails for a reason
/// the player cannot see is worse than a longer one.
/// </para>
/// </summary>
private const string CodeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
private const int CodeLength = 6;
/// <summary>
/// How long a code is good for. Short on purpose: R1 made this an authorization path,
/// not a convenience — the site becomes the author of who may do what in game, so a
/// code that lingers is a privilege waiting to be picked up.
/// </summary>
private static readonly TimeSpan CodeTtl = TimeSpan.FromMinutes(5);
/// <summary>How long a player must wait between <c>/link</c> requests.</summary>
private static readonly TimeSpan RequestCooldown = TimeSpan.FromSeconds(30);
/// <summary>
/// An unconfirmed code, and the Steam identity it will bind.
///
/// <para>
/// <b>These live in memory and nowhere else</b>, which is the same call the UO bridge
/// made and it is the right one for the same reason: the secret exists only on the
/// server that minted it, for five minutes, and nothing writes it to disk. A plugin
/// reload drops every pending code — and phase 7b's config editor reloads plugins
/// routinely — but the cost of that is a player typing <c>/link</c> again, which is
/// cheaper than a credential in a second process.
/// </para>
/// </summary>
private sealed class PendingLink
{
public string SteamId;
public string Name;
public DateTime Expires;
}
/// <summary>code -> pending link. Main thread only, like every world read here.</summary>
private readonly Dictionary<string, PendingLink> _codes =
new Dictionary<string, PendingLink>(StringComparer.OrdinalIgnoreCase);
/// <summary>steam id -> when they last asked, so <c>/link</c> cannot be spammed.</summary>
private readonly Dictionary<string, DateTime> _lastLinkRequest =
new Dictionary<string, DateTime>(StringComparer.Ordinal);
private readonly System.Random _codeRandom = new System.Random();
/// <summary>
/// <c>/link</c> — mint a one-time code for the caller and tell the sidecar about it.
///
/// <para>
/// <b>Chat, not console, and the reply is private.</b> Both frameworks consume a
/// <c>/</c> command rather than broadcasting it, and <c>SendReply</c> addresses one
/// player — so neither the request nor the code reaches anybody else's chat. That is
/// load-bearing rather than polish: a code read off a stream is a code somebody else
/// can spend.
/// </para>
///
/// <para>
/// The plugin does <b>not</b> record who is linked. The website is the author of
/// record for that (R2), and a second copy here would be a second thing to keep in
/// step across a wipe. So there is no "you are already linked" check on this path —
/// the site refuses a Steam id it already holds, where it can say which account holds
/// it.
/// </para>
/// </summary>
[ChatCommand("link")]
private void CmdLinkChat(BasePlayer player, string command, string[] args)
{
if (player == null)
return;
string steamId = player.UserIDString;
if (string.IsNullOrEmpty(steamId))
return;
DateTime last;
if (_lastLinkRequest.TryGetValue(steamId, out last) && DateTime.UtcNow - last < RequestCooldown)
{
SendReply(player, "Please wait a moment before asking for another link code.");
return;
}
// One outstanding code per player: drop any earlier one so only the newest works.
// Without this a player who ran /link three times leaves three live codes behind.
DropCodesFor(steamId);
string code = MintCode();
_codes[code] = new PendingLink
{
SteamId = steamId,
Name = player.displayName,
Expires = DateTime.UtcNow + CodeTtl
};
_lastLinkRequest[steamId] = DateTime.UtcNow;
Dictionary<string, object> frame = Frame("account.link.requested", "event");
frame["steamId"] = steamId;
frame["name"] = player.displayName;
frame["ttlSec"] = (long)CodeTtl.TotalSeconds;
// **The code is NOT in this frame, and that is the point.** The event exists so an
// operator can see that linking is being used and so the site can rate-limit a player
// who is fishing; it is not how the code travels. The code travels through the player,
// which is what makes typing it proof that they are the one who asked.
EmitFrame(frame);
SendReply(player, "Your link code is <color=#e0b34d>" + code + "</color>.");
SendReply(
player,
"Enter it on the website within " + (int)CodeTtl.TotalMinutes +
" minutes to link this Steam account. It works once.");
}
/// <summary>
/// <c>/unlink</c> — ask the website to sever this Steam id's link.
///
/// <para>
/// <b>It emits rather than deletes</b>, because the plugin holds no link to delete. The
/// site owns the record and applies this like any other event off the feed.
/// </para>
///
/// <para>
/// It exists because the site <b>refuses</b> to move a Steam id that another account
/// already holds. Without a way out, a player who linked the wrong account while logged
/// in as it would need staff; with this they walk to a computer they are already sitting
/// at. The authority is the Steam id itself — whoever is connected as it is who it is.
/// </para>
/// </summary>
[ChatCommand("unlink")]
private void CmdUnlinkChat(BasePlayer player, string command, string[] args)
{
if (player == null)
return;
string steamId = player.UserIDString;
if (string.IsNullOrEmpty(steamId))
return;
DropCodesFor(steamId);
Dictionary<string, object> frame = Frame("account.unlinked", "event");
frame["steamId"] = steamId;
frame["name"] = player.displayName;
frame["origin"] = "in-game";
EmitFrame(frame);
SendReply(
player,
"Asked the website to unlink this Steam account. If it was linked, it is not now.");
}
/// <summary>
/// Inbound <c>link.confirm</c> — the website has been given a code and wants to know
/// whose it is.
///
/// <para>
/// <b>This is the first command the website originates</b> in this bridge; everything
/// before protocol 3 was the game talking. The reply carries the Steam id and nothing
/// the caller did not already prove — there is no session here, no account creation,
/// and no write to the game.
/// </para>
///
/// <para>
/// A code is consumed on the <b>first</b> lookup whether or not it turns out to be
/// expired, so a code cannot be probed twice. That is why the removal happens before
/// the expiry check rather than after it.
/// </para>
/// </summary>
private void HandleLinkConfirm(Dictionary<string, object> cmd, string reqId)
{
object codeObj;
string code = cmd.TryGetValue("code", out codeObj) && codeObj != null
? codeObj.ToString().Trim()
: null;
if (string.IsNullOrEmpty(code))
{
EmitLinkError(reqId, "malformed");
return;
}
PendingLink pending;
if (!_codes.TryGetValue(code, out pending))
{
EmitLinkError(reqId, "unknown");
return;
}
_codes.Remove(code);
if (DateTime.UtcNow > pending.Expires)
{
EmitLinkError(reqId, "expired");
return;
}
Dictionary<string, object> ok = Frame("link.ok", "reply");
ok["steamId"] = pending.SteamId;
ok["name"] = pending.Name;
if (reqId != null)
ok["reqId"] = reqId;
EmitFrame(ok);
}
/// <summary>
/// A refusal, with a reason the website can turn into a sentence.
///
/// <para>
/// <c>unknown</c> and <c>expired</c> are deliberately <b>separate</b> here and
/// deliberately <b>the same</b> to the player: an operator reading a log wants to know
/// whether codes are being guessed or merely going stale, and a stranger typing codes
/// must not learn which of the two they hit.
/// </para>
/// </summary>
private void EmitLinkError(string reqId, string reason)
{
Dictionary<string, object> frame = Frame("link.error", "reply");
frame["reason"] = reason;
if (reqId != null)
frame["reqId"] = reqId;
EmitFrame(frame);
}
/// <summary>Drops every outstanding code belonging to one Steam id.</summary>
private void DropCodesFor(string steamId)
{
List<string> stale = null;
foreach (KeyValuePair<string, PendingLink> entry in _codes)
{
if (entry.Value != null && entry.Value.SteamId == steamId)
{
if (stale == null)
stale = new List<string>();
stale.Add(entry.Key);
}
}
if (stale == null)
return;
foreach (string key in stale)
_codes.Remove(key);
}
/// <summary>
/// Drops expired codes and stale cooldowns.
///
/// <para>
/// Run on a timer rather than only on lookup, because an unconfirmed code is never
/// looked up — a player who runs <c>/link</c> and wanders off leaves an entry that
/// nothing else would ever remove.
/// </para>
/// </summary>
private void PurgeExpiredCodes()
{
DateTime now = DateTime.UtcNow;
List<string> dead = null;
foreach (KeyValuePair<string, PendingLink> entry in _codes)
{
if (entry.Value == null || now > entry.Value.Expires)
{
if (dead == null)
dead = new List<string>();
dead.Add(entry.Key);
}
}
if (dead != null)
{
foreach (string key in dead)
_codes.Remove(key);
}
List<string> cold = null;
foreach (KeyValuePair<string, DateTime> entry in _lastLinkRequest)
{
if (now - entry.Value > RequestCooldown)
{
if (cold == null)
cold = new List<string>();
cold.Add(entry.Key);
}
}
if (cold == null)
return;
foreach (string key in cold)
_lastLinkRequest.Remove(key);
}
/// <summary>
/// A code from <see cref="CodeAlphabet"/>, retried until it does not collide.
///
/// <para>
/// <c>System.Random</c> rather than a cryptographic source, and the reason it is
/// defensible is the shape of the guess rather than the shape of the generator: a code
/// lives five minutes, is consumed on first lookup, and the website rate-limits the
/// confirm endpoint. An attacker who could predict the sequence would still have to
/// reach a code before the player does.
/// </para>
/// </summary>
private string MintCode()
{
for (int attempt = 0; attempt < 16; attempt++)
{
var chars = new char[CodeLength];
for (int i = 0; i < CodeLength; i++)
chars[i] = CodeAlphabet[_codeRandom.Next(CodeAlphabet.Length)];
string candidate = new string(chars);
if (!_codes.ContainsKey(candidate))
return candidate;
}
// Sixteen collisions in a row against a table this small is not bit-space exhaustion,
// it is something being wrong. A code that cannot collide is better than a loop that
// might not end.
return Guid.NewGuid().ToString("N").Substring(0, CodeLength).ToUpperInvariant();
}
// ---- site-owned permissions (protocol 4) ----
//
// R2: the website is the author of record for groups and grants, and this framework's own
// permission store is an ENFORCEMENT CACHE. Every third-party plugin honours a site grant
// with no adapter, because they all already call `UserHasPermission`.
//
// ── One verb, and the plugin does the diffing (D32) ───────────────────────
//
// The site sends the whole set it authors for this server; this plugin compares it against
// the live store and writes only what differs. It is the same shape as a board, pointed the
// other way: one producer, re-sent on connect, reconciled by the receiver.
//
// The alternative — shipping this server's permission store to the website and diffing
// there — was rejected for two reasons. The store is the bigger of the two sets and would
// cross the wire constantly, and a website holding a copy of it has a second source of
// truth that is stale the moment it lands.
//
// ── The site must not be able to stall the game, either ───────────────────
//
// This is the first command whose work is UNBOUNDED: a community with two thousand linked
// players has thousands of grants in one frame, and applying them in the tick the frame
// arrives is a freeze the operator will blame on the game. So a sync is compiled into a
// list of single-store operations and drained a few hundred at a time on a timer, and the
// report goes back when the last one lands.
//
// That is the same rule the outbound queue lives under (§5), applied to the inbound half:
// **nothing the far side sends may cost the main thread an unbounded amount of work.**
//
// ── What it reports, and what it will not do ──────────────────────────────
//
// • A permission this server has not REGISTERED is reported `unresolved` and skipped.
// `GrantUserPermission` silently no-ops for an unregistered name — void, no throw, no
// log (PLAN.md §12.2 rule 1) — so without the pre-check a re-push into a server missing
// one plugin is a silent partial. Registering the name here instead would fabricate a
// permission the operator never installed (D33).
// • A holder the site does not author is reported `foreign` and never touched (D31).
// Somebody typing `oxide.grant` during an incident is drift, not an error, and the
// website offers an operator two answers to it.
// • A group member who has never connected is reported `pending`. The store has no user
// record to put in a group yet (§12.2 rule 4), and the membership lands on their first
// connection — a direct grant would have reached them immediately, and pretending the
// two are the same thing is how a site promises access it did not give.
/// <summary>
/// How many store operations one timer step performs.
///
/// <para>
/// Each is a dictionary lookup and, at most, one write to an in-memory structure the
/// framework flushes on its own schedule — microseconds. Two hundred of them is well
/// under a frame at 60fps, and it drains ten thousand operations in about two and a
/// half seconds, which is inside the sidecar's reply timeout.
/// </para>
/// </summary>
private const int PermOpsPerStep = 200;
/// <summary>How often the sync drain runs while a job is in flight.</summary>
private const float PermStepSeconds = 0.05f;
/// <summary>
/// The ceiling on one sync, in operations.
///
/// <para>
/// A frame larger than this is refused whole rather than applied for a while and then
/// abandoned. The website has its own limit below this one, so reaching it means
/// something is wrong rather than something is big — and a half-applied permission set
/// is the state nobody can reason about.
/// </para>
/// </summary>
private const int PermMaxOps = 20000;
/// <summary>The operations a compiled sync is made of. One store question each.</summary>
private enum PermOpKind
{
EnsureGroup,
EnsureGroupPermission,
EnsureMember,
EnsureGrant,
RetireGroupPermission,
RetireMember,
RetireGrant,
RetireGroup,
ScanPermission,
ScanGroup
}
private struct PermOp
{
public PermOpKind Kind;
/// <summary>A Steam id, or a group name — whichever the op is about.</summary>
public string Subject;
/// <summary>A permission name, a group name, or null.</summary>
public string Object;
/// <summary>Only <see cref="PermOpKind.EnsureGroup"/> uses these.</summary>
public string Title;
public int Rank;
}
/// <summary>
/// One sync in flight. There is at most one: a second arriving while this one drains is
/// answered `busy` rather than interleaved, because two writers reconciling the same
/// store against two different desired sets is a race with no correct outcome.
/// </summary>
private sealed class PermSyncJob
{
public string ReqId;
public string SetId;
public List<PermOp> Ops = new List<PermOp>();
public int Next;
public int Grants;
public int Revokes;
public int GroupsCreated;
public int GroupsRemoved;
public int GroupPermissions;
public int GroupPermissionsRemoved;
public int Members;
public int MembersRemoved;
public int AlreadyCorrect;
public int Absent;
public readonly List<string> Unresolved = new List<string>();
public readonly List<object> Foreign = new List<object>();
public readonly List<string> Pending = new List<string>();
/// <summary>
/// Grants the store did not hold after being told to (<c>subject:permission</c>).
/// Reported rather than counted, so the site can never again be told a grant
/// landed that did not (D85).
/// </summary>
public readonly List<string> NotLanded = new List<string>();
/// <summary>
/// Group permissions an event lease holds (protocol 8). Neither applied to nor
/// reported foreign; what the site asked for is kept on the hold instead.
/// </summary>
public readonly List<object> Leased = new List<object>();
public readonly HashSet<string> LeasedSeen = new HashSet<string>();
/// <summary>Permission names already reported unresolved, so each is reported once.</summary>
public readonly HashSet<string> UnresolvedSeen = new HashSet<string>();
/// <summary>Who the site says should hold each managed permission, for the foreign scan.</summary>
public readonly Dictionary<string, HashSet<string>> DesiredHolders =
new Dictionary<string, HashSet<string>>();
/// <summary>Who the site says is in each managed group, and what it should carry.</summary>
public readonly Dictionary<string, HashSet<string>> DesiredMembers =
new Dictionary<string, HashSet<string>>();
public readonly Dictionary<string, HashSet<string>> DesiredGroupPermissions =
new Dictionary<string, HashSet<string>>();
}
private PermSyncJob _permJob;
private Timer _permTimer;
/// <summary>
/// True while this plugin is writing to the permission store.
///
/// <para>
/// The framework raises <c>OnUserPermissionGranted</c> and friends for every write,
/// including ours — so without this flag every sync would announce itself as drift and
/// the website would chase its own tail. It is set around each drain step rather than
/// for the whole job, so a write by somebody else BETWEEN steps is still seen.
/// </para>
/// </summary>
private bool _applyingPermissions;
/// <summary>The last report, for <c>rg.perms</c>. Diagnostics only.</summary>
private string _lastPermSummary = "none yet";
/// <summary>
/// Main thread. Everything this server knows about permissions and groups — the option
/// source the website's authoring form is built from (D33).
///
/// <para>
/// It is a live read rather than anything cached, because the answer changes when an
/// operator loads a plugin and the whole point of asking is to offer names that will
/// actually resolve.
/// </para>
/// </summary>
private void HandlePermCatalogue(string reqId)
{
Dictionary<string, object> reply = Frame("perm.catalogue", "reply");
var permissions = new List<object>();
string[] registered = permission.GetPermissions();
if (registered != null)
{
foreach (string name in registered)
{
if (!string.IsNullOrEmpty(name))
permissions.Add(name);
}
}
var groups = new List<object>();
string[] groupNames = permission.GetGroups();
if (groupNames != null)
{
foreach (string name in groupNames)
{
if (string.IsNullOrEmpty(name))
continue;
var row = new Dictionary<string, object>
{
{ "name", name },
{ "title", permission.GetGroupTitle(name) },
{ "rank", permission.GetGroupRank(name) }
};
var granted = new List<object>();
string[] groupPerms = permission.GetGroupPermissions(name, false);
if (groupPerms != null)
{
foreach (string perm in groupPerms)
{
if (!string.IsNullOrEmpty(perm))
granted.Add(perm);
}
}
row["permissions"] = granted;
groups.Add(row);
}
}
reply["permissions"] = permissions;
reply["groups"] = groups;
if (reqId != null)
reply["reqId"] = reqId;
EmitFrame(reply);
}
/// <summary>
/// Main thread. Compiles a desired set into operations and starts draining it.
///
/// <para>
/// Nothing is written here. Compiling is pure bookkeeping over the frame, so a
/// malformed or enormous sync is refused before it has touched the store — there is no
/// state to unwind because none was made.
/// </para>
/// </summary>
private void HandlePermSync(Dictionary<string, object> cmd, string reqId)
{
if (_permJob != null)
{
EmitPermError(reqId, "busy");
return;
}
var job = new PermSyncJob
{
ReqId = reqId,
SetId = StringField(cmd, "setId")
};
// ── The desired set ────────────────────────────────────────────────
//
// Order matters and it is not cosmetic: a group has to exist before anything can be
// put in it, and a permission has to be on the group before a member inherits it.
foreach (Dictionary<string, object> group in Rows(cmd, "groups"))
{
string name = Lower(StringField(group, "name"));
if (string.IsNullOrEmpty(name))
continue;
job.Ops.Add(new PermOp
{
Kind = PermOpKind.EnsureGroup,
Subject = name,
Title = StringField(group, "title") ?? name,
Rank = IntField(group, "rank")
});
HashSet<string> wanted = Bucket(job.DesiredGroupPermissions, name);
foreach (string perm in Strings(group, "permissions"))
{
string lowered = Lower(perm);
if (string.IsNullOrEmpty(lowered))
continue;
wanted.Add(lowered);
job.Ops.Add(new PermOp
{
Kind = PermOpKind.EnsureGroupPermission,
Subject = name,
Object = lowered
});
}
foreach (string member in Strings(group, "members"))
{
if (string.IsNullOrEmpty(member))
continue;
Bucket(job.DesiredMembers, name).Add(member);
job.Ops.Add(new PermOp
{
Kind = PermOpKind.EnsureMember,
Subject = member,
Object = name
});
}
// A group with no members still has to be scanned: the interesting case is a
// member the site did not put there.
Bucket(job.DesiredMembers, name);
}
foreach (Dictionary<string, object> grant in Rows(cmd, "grants"))
{
string steamId = StringField(grant, "steamId");
if (string.IsNullOrEmpty(steamId))
continue;
foreach (string perm in Strings(grant, "permissions"))
{
string lowered = Lower(perm);
if (string.IsNullOrEmpty(lowered))
continue;
Bucket(job.DesiredHolders, lowered).Add(steamId);
job.Ops.Add(new PermOp
{
Kind = PermOpKind.EnsureGrant,
Subject = steamId,
Object = lowered
});
}
}
// ── What the site has withdrawn ────────────────────────────────────
//
// The site remembers what it put here, so a grant it has since removed arrives as an
// explicit retirement. It cannot be inferred from the desired set: a name that is not
// in it is either something the site withdrew or something a human granted, and those
// two have opposite correct answers (D31).
foreach (Dictionary<string, object> row in Rows(cmd, "retire"))
{
string kind = StringField(row, "kind");
string subject = StringField(row, "subject");
string target = Lower(StringField(row, "object"));
if (string.IsNullOrEmpty(kind) || string.IsNullOrEmpty(subject))
continue;
switch (kind)
{
case "grant":
job.Ops.Add(new PermOp { Kind = PermOpKind.RetireGrant, Subject = subject, Object = target });
break;
case "member":
job.Ops.Add(new PermOp { Kind = PermOpKind.RetireMember, Subject = subject, Object = target });
break;
case "group-permission":
job.Ops.Add(new PermOp { Kind = PermOpKind.RetireGroupPermission, Subject = Lower(subject), Object = target });
break;
case "group":
job.Ops.Add(new PermOp { Kind = PermOpKind.RetireGroup, Subject = Lower(subject) });
break;
}
}
// ── The namespace the site claims ──────────────────────────────────
//
// Foreign holders can only be found for names the site says it manages. A permission it
// does not claim is somebody else's business entirely and is never enumerated — which
// also bounds this scan by the site's own set rather than by the size of the store.
foreach (string perm in Strings(cmd, "managed"))
{
string lowered = Lower(perm);
if (string.IsNullOrEmpty(lowered))
continue;
Bucket(job.DesiredHolders, lowered);
job.Ops.Add(new PermOp { Kind = PermOpKind.ScanPermission, Subject = lowered });
}
foreach (string group in job.DesiredMembers.Keys)
job.Ops.Add(new PermOp { Kind = PermOpKind.ScanGroup, Subject = group });
if (job.Ops.Count > PermMaxOps)
{
EmitPermError(reqId, "too-large");
Puts($"permission sync refused: {job.Ops.Count} operations exceeds {PermMaxOps}");
return;
}
_permJob = job;
_permTimer = timer.Every(PermStepSeconds, StepPermSync);
// Run the first slice now rather than waiting out a tick: a small sync — which is every
// ordinary one — then finishes inside the call the website is waiting on.
StepPermSync();
}
/// <summary>
/// Main thread. Drains up to <see cref="PermOpsPerStep"/> operations and, on the last
/// one, sends the report.
/// </summary>
private void StepPermSync()
{
PermSyncJob job = _permJob;
if (job == null)
{
StopPermTimer();
return;
}
_applyingPermissions = true;
try
{
int budget = PermOpsPerStep;
while (budget-- > 0 && job.Next < job.Ops.Count)
{
PermOp op = job.Ops[job.Next++];
try
{
Apply(job, op);
}
catch (Exception ex)
{
// One bad operation must not abandon the set. The report still goes back,
// and the next sync retries what this one could not do.
Puts($"permission op {op.Kind} on '{op.Subject}' threw: {ex.Message}");
}
}
}
finally
{
_applyingPermissions = false;
}
if (job.Next < job.Ops.Count)
return;
_permJob = null;
StopPermTimer();
SendPermReport(job);
}
private void StopPermTimer()
{
if (_permTimer == null)
return;
_permTimer.Destroy();
_permTimer = null;
}
/// <summary>Main thread. One store question, and at most one store write.</summary>
private void Apply(PermSyncJob job, PermOp op)
{
switch (op.Kind)
{
case PermOpKind.EnsureGroup:
if (permission.GroupExists(op.Subject))
{
job.AlreadyCorrect++;
}
else
{
permission.CreateGroup(op.Subject, op.Title, op.Rank);
job.GroupsCreated++;
}
break;
case PermOpKind.EnsureGroupPermission:
if (!Registered(job, op.Object))
break;
if (DeferToLease(job, op, true))
break;
if (permission.GroupHasPermission(op.Subject, op.Object))
{
job.AlreadyCorrect++;
break;
}
// **`null`, not `this`, as the owner (D85).** With an owner, Oxide's grant
// first asks `PermissionExists(perm, owner)` — whether THAT plugin registered
// the name — and returns without a word when it did not. Every permission the
// site grants belongs to some other plugin, so from phase 7 until phase 13 this
// call did nothing on Oxide and was counted as applied (PLAN.md §27.6). The
// read-back is what makes the count true: only a grant the store now holds
// is reported as one.
permission.GrantGroupPermission(op.Subject, op.Object, null);
if (permission.GroupHasPermission(op.Subject, op.Object))
job.GroupPermissions++;
else
job.NotLanded.Add(op.Subject + ":" + op.Object);
break;
case PermOpKind.EnsureMember:
if (permission.UserHasGroup(op.Subject, op.Object))
{
job.AlreadyCorrect++;
break;
}
permission.AddUserGroup(op.Subject, op.Object);
// Read back, because this is the call that can do nothing at all. A player the
// store has never seen has no user record to put in a group (§12.2 rule 4), and
// the API says so by returning void and changing nothing.
if (permission.UserHasGroup(op.Subject, op.Object))
job.Members++;
else
job.Pending.Add(op.Subject + ":" + op.Object);
break;
case PermOpKind.EnsureGrant:
if (!Registered(job, op.Object))
break;
if (HoldsDirectly(op.Subject, op.Object))
{
job.AlreadyCorrect++;
break;
}
// The same owner trap as the group grant above (D85), and the same read-back.
permission.GrantUserPermission(op.Subject, op.Object, null);
if (HoldsDirectly(op.Subject, op.Object))
job.Grants++;
else
job.NotLanded.Add(op.Subject + ":" + op.Object);
break;
case PermOpKind.RetireGrant:
if (HoldsDirectly(op.Subject, op.Object))
{
permission.RevokeUserPermission(op.Subject, op.Object);
job.Revokes++;
}
else
{
job.Absent++;
}
break;
case PermOpKind.RetireMember:
if (permission.UserHasGroup(op.Subject, op.Object))
{
permission.RemoveUserGroup(op.Subject, op.Object);
job.MembersRemoved++;
}
else
{
job.Absent++;
}
break;
case PermOpKind.RetireGroupPermission:
if (DeferToLease(job, op, false))
break;
if (permission.GroupHasPermission(op.Subject, op.Object))
{
permission.RevokeGroupPermission(op.Subject, op.Object);
job.GroupPermissionsRemoved++;
}
else
{
job.Absent++;
}
break;
case PermOpKind.RetireGroup:
if (permission.GroupExists(op.Subject))
{
permission.RemoveGroup(op.Subject);
job.GroupsRemoved++;
}
else
{
job.Absent++;
}
break;
case PermOpKind.ScanPermission:
ScanPermission(job, op.Subject);
break;
case PermOpKind.ScanGroup:
ScanGroup(job, op.Subject);
break;
}
}
/// <summary>
/// Everybody holding a managed permission DIRECTLY, minus everybody the site says
/// should — the rest is a hand edit, and it is reported rather than undone.
/// </summary>
private void ScanPermission(PermSyncJob job, string perm)
{
if (!permission.PermissionExists(perm))
return;
HashSet<string> desired;
if (!job.DesiredHolders.TryGetValue(perm, out desired))
desired = new HashSet<string>();
string[] holders = permission.GetPermissionUsers(perm);
if (holders == null)
return;
foreach (string entry in holders)
{
string id = UserId(entry);
if (string.IsNullOrEmpty(id) || desired.Contains(id))
continue;
job.Foreign.Add(new Dictionary<string, object>
{
{ "kind", "grant" },
{ "subject", id },
{ "object", perm }
});
}
}
/// <summary>
/// The same question about a managed group, asked twice: who is in it that the site did
/// not put there, and what does it carry that the site did not put on it.
/// </summary>
private void ScanGroup(PermSyncJob job, string group)
{
if (!permission.GroupExists(group))
return;
HashSet<string> members;
if (!job.DesiredMembers.TryGetValue(group, out members))
members = new HashSet<string>();
string[] present = permission.GetUsersInGroup(group);
if (present != null)
{
foreach (string entry in present)
{
string id = UserId(entry);
if (string.IsNullOrEmpty(id) || members.Contains(id))
continue;
job.Foreign.Add(new Dictionary<string, object>
{
{ "kind", "member" },
{ "subject", id },
{ "object", group }
});
}
}
HashSet<string> wanted;
if (!job.DesiredGroupPermissions.TryGetValue(group, out wanted))
wanted = new HashSet<string>();
string[] carried = permission.GetGroupPermissions(group, false);
if (carried == null)
return;
foreach (string perm in carried)
{
string lowered = Lower(perm);
if (string.IsNullOrEmpty(lowered) || wanted.Contains(lowered))
continue;
// A pair an event has borrowed is not somebody's console grant (§27.3).
if (LeasedGroupPermission(group, lowered) != null)
{
NoteLeased(job, group, lowered);
continue;
}
job.Foreign.Add(new Dictionary<string, object>
{
{ "kind", "group-permission" },
{ "subject", group },
{ "object", lowered }
});
}
}
/// <summary>
/// A sync op about a pair an event lease holds. The store is left to the lease; what
/// the site wants is recorded on the hold and becomes the value the lease gives back
/// (§27.3). Returns whether it deferred.
/// </summary>
private bool DeferToLease(PermSyncJob job, PermOp op, bool wants)
{
LeaseHold hold = LeasedGroupPermission(op.Subject, op.Object);
if (hold == null)
return false;
if (hold.SiteWants != wants)
{
hold.SiteWants = wants;
SaveLeases();
}
NoteLeased(job, op.Subject, op.Object);
return true;
}
private static void NoteLeased(PermSyncJob job, string group, string perm)
{
string id = Lower(group) + "/" + Lower(perm);
if (!job.LeasedSeen.Add(id))
return;
job.Leased.Add(new Dictionary<string, object>
{
{ "kind", "group-permission" },
{ "subject", Lower(group) },
{ "object", Lower(perm) }
});
}
/// <summary>
/// Whether this server has a plugin that registered the name, recording the answer once
/// per sync when it has not.
///
/// <para>
/// This is the pre-check PLAN.md §12.2 rule 1 exists for. Without it the grant below
/// would return void, throw nothing, log nothing and do nothing, and the website would
/// record a privilege it never gave.
/// </para>
/// </summary>
private bool Registered(PermSyncJob job, string perm)
{
if (string.IsNullOrEmpty(perm))
return false;
if (permission.PermissionExists(perm))
return true;
if (job.UnresolvedSeen.Add(perm))
job.Unresolved.Add(perm);
return false;
}
/// <summary>
/// Whether a player holds a permission through a grant of their own, as opposed to
/// through a group.
///
/// <para>
/// <c>UserHasPermission</c> cannot answer this — it is true for a member of a group
/// that carries the permission, so using it would make every direct grant look already
/// applied the moment a group covered it, and a revoke look already done.
/// <c>GetPermissionUsers</c> reads the user's own set, which is the question.
/// </para>
/// </summary>
private bool HoldsDirectly(string steamId, string perm)
{
string[] holders = permission.GetPermissionUsers(perm);
if (holders == null)
return false;
foreach (string entry in holders)
{
if (UserId(entry) == steamId)
return true;
}
return false;
}
/// <summary>
/// The id out of a store listing entry.
///
/// <para>
/// Both <c>GetPermissionUsers</c> and <c>GetUsersInGroup</c> answer with the id and the
/// player's last known name stuck together — <c>76561198…(Wanderer)</c> — and the
/// spacing differs between the two calls and between the frameworks. So the id is
/// everything before the first bracket, and a name containing one cannot confuse it
/// because only the first is looked for.
/// </para>
/// </summary>
private static string UserId(string entry)
{
if (string.IsNullOrEmpty(entry))
return null;
int bracket = entry.IndexOf('(');
string id = bracket >= 0 ? entry.Substring(0, bracket) : entry;
return id.Trim();
}
private void SendPermReport(PermSyncJob job)
{
var applied = new Dictionary<string, object>
{
{ "grants", job.Grants },
{ "revokes", job.Revokes },
{ "groupsCreated", job.GroupsCreated },
{ "groupsRemoved", job.GroupsRemoved },
{ "groupPermissions", job.GroupPermissions },
{ "groupPermissionsRemoved", job.GroupPermissionsRemoved },
{ "members", job.Members },
{ "membersRemoved", job.MembersRemoved }
};
Dictionary<string, object> report = Frame("perm.report", "reply");
report["applied"] = applied;
report["alreadyCorrect"] = job.AlreadyCorrect;
report["absent"] = job.Absent;
report["unresolved"] = job.Unresolved;
report["foreign"] = job.Foreign;
report["leased"] = job.Leased;
report["pending"] = job.Pending;
report["notLanded"] = job.NotLanded;
report["operations"] = job.Ops.Count;
if (!string.IsNullOrEmpty(job.SetId))
report["setId"] = job.SetId;
if (job.ReqId != null)
report["reqId"] = job.ReqId;
_lastPermSummary =
$"ops={job.Ops.Count} grants={job.Grants} revokes={job.Revokes} " +
$"groups={job.GroupsCreated} members={job.Members} same={job.AlreadyCorrect} " +
$"unresolved={job.Unresolved.Count} foreign={job.Foreign.Count} " +
$"pending={job.Pending.Count} notLanded={job.NotLanded.Count} at={DateTime.UtcNow:HH:mm:ss}Z";
EmitFrame(report);
}
/// <summary>A refusal of the whole sync, with a reason the website turns into a sentence.</summary>
private void EmitPermError(string reqId, string reason)
{
Dictionary<string, object> frame = Frame("perm.error", "reply");
frame["reason"] = reason;
if (reqId != null)
frame["reqId"] = reqId;
EmitFrame(frame);
}
// ---- drift, as it happens ----
//
// Both frameworks raise a hook for every permission write, ours included. They are NOT the
// drift record — the sync report is, because only the site's desired set can say whether a
// grant is foreign. What they are is a reason to reconcile SOON: an operator typing
// `oxide.grant` at the console shows up on the website in seconds instead of at the next
// scheduled audit, and the authoritative answer still comes from a sync.
//
// That division is what makes them safe to trust at exactly the weight they deserve. A hook
// that stops firing on a framework upgrade costs latency, not correctness.
private void OnUserPermissionGranted(string id, string permName)
{
MarkHook("OnUserPermissionGranted");
EmitPermDrift("granted", id, permName, null);
}
private void OnUserPermissionRevoked(string id, string permName)
{
MarkHook("OnUserPermissionRevoked");
EmitPermDrift("revoked", id, permName, null);
}
private void OnUserGroupAdded(string id, string groupName)
{
MarkHook("OnUserGroupAdded");
EmitPermDrift("group-added", id, null, groupName);
}
private void OnUserGroupRemoved(string id, string groupName)
{
MarkHook("OnUserGroupRemoved");
EmitPermDrift("group-removed", id, null, groupName);
}
private void OnGroupPermissionGranted(string name, string permName)
{
MarkHook("OnGroupPermissionGranted");
EmitPermDrift("group-permission-granted", null, permName, name);
}
private void OnGroupPermissionRevoked(string name, string permName)
{
MarkHook("OnGroupPermissionRevoked");
EmitPermDrift("group-permission-revoked", null, permName, name);
}
// ---- the reload, watched ----
//
// These two are why an automatic rollback is possible at all (R18): a reload either
// produces `OnPluginLoaded` for the target or it does not, and the second case is a plugin
// that is DOWN because of an edit the website just made.
//
// They are deliberately not a general plugin-lifecycle feed. Nothing is emitted from
// either: a busy server loads and unloads plugins for reasons that have nothing to do with
// us, and a frame per event would be noise on the wire and a trigger nobody asked for.
private void OnPluginLoaded(Plugin plugin)
{
MarkHook("OnPluginLoaded");
// ZoneManager keeps temporary zones in memory, so a reload of it loses every zone an
// event opened. They are put back from this plugin's own record (D92).
if (plugin != null && plugin != this && plugin.Name == "ZoneManager")
NextTick(() => RecreateZones("ZoneManager reloaded"));
ConfigWriteJob job = _configJob;
if (job == null || plugin == null)
return;
if (string.Equals(plugin.Name, job.Reload, StringComparison.OrdinalIgnoreCase))
ConfigReloadArrived();
}
private void OnPluginUnloaded(Plugin plugin)
{
MarkHook("OnPluginUnloaded");
ConfigWriteJob job = _configJob;
if (job == null || plugin == null)
return;
// Only ever used to word the failure. "It unloaded and did not come back" is a
// compile error in the edited file; "it never reloaded" is a framework that did not
// act on the request at all, and those send an operator to different places.
if (string.Equals(plugin.Name, job.Reload, StringComparison.OrdinalIgnoreCase))
job.Unloaded = true;
}
/// <summary>
/// Main thread. Says that the store changed under us, and by how much it is worth
/// caring.
///
/// <para>
/// Staff class (§8.5): it names a Steam id beside a privilege, which is a fact about a
/// person's standing rather than about what happened on the server.
/// </para>
/// </summary>
private void EmitPermDrift(string action, string steamId, string perm, string group)
{
// Our own writes raise these hooks too. Suppressing them here rather than filtering on
// the website is the only version that works: the site cannot tell its own grant from
// a human's by looking at one, which is the entire reason drift needs a desired set.
if (_applyingPermissions)
return;
Dictionary<string, object> frame = Frame("perm.drift", "event");
frame["action"] = action;
if (!string.IsNullOrEmpty(steamId))
frame["steamId"] = steamId;
if (!string.IsNullOrEmpty(perm))
frame["permission"] = perm;
if (!string.IsNullOrEmpty(group))
frame["group"] = group;
EmitFrame(frame);
}
// ---- protocol 5: plugin configuration ----
//
// R18. An admin edits a loaded plugin's settings on the website and the plugin reloads.
// Three request/reply verbs: `config.list` describes the tree, `config.read` returns one
// file, `config.write` replaces a set and reloads whatever owns it.
//
// Four properties live on this side because only the game host can hold them:
//
// * **The roots come from the framework, never from a literal.** `ConfigDirectory` is
// `oxide/config` on Oxide, `carbon/configs` on Carbon, and neither on a server whose
// operator moved it (`CARBON.md` §3). That amendment was proven the best way it could
// have been: this plugin's own config landed in both places, from this same file.
// * **`DataDirectory` is never walked.** It holds live state — kit cooldowns, zone
// definitions — and both frameworks' permission stores, which is R2's mirror one
// directory over. A settings editor that strayed there would be editing phase 7
// underneath itself.
// * **A reload is observable.** `OnPluginLoaded` is a real hook, so "did that work" has
// an answer rather than an assumption — and an answer is the whole reason an automatic
// rollback is possible at all.
// * **The site composes the bytes; this plugin writes them.** It receives whole file
// text, not a key and a value, because JavaScript cannot tell `1` from `1.0` and Oxide
// deserializes into typed C# classes (D35/D36). Everything this end does to a document
// is check that it parses.
/// <summary>How deep the walk goes before it stops trying to describe a tree.</summary>
private const int ConfigMaxDepth = 6;
/// <summary>How many files the walk will report.</summary>
private const int ConfigMaxFiles = 500;
/// <summary>
/// The largest file this plugin will read or write.
///
/// <para>
/// A file past it is reported and marked un-editable <b>with the reason</b>, never
/// omitted: an operator who cannot find a file they know exists goes looking for a bug
/// in the bridge, and one who can see why it was refused does not.
/// </para>
/// </summary>
private const int ConfigMaxFileBytes = 256 * 1024;
/// <summary>Files one write may touch. A plugin can own several; ten of them is not a tree.</summary>
private const int ConfigMaxWriteFiles = 10;
/// <summary>
/// How long a reload has to produce <c>OnPluginLoaded</c> before the write is undone.
///
/// <para>
/// <b>It is under half the sidecar's RPC timeout, and that is arithmetic rather than
/// taste.</b> The worst path through one write is two of these windows — wait for the
/// edit's reload, give up, restore, wait for the restore's reload — with a caller
/// holding a socket for the whole of it (<c>REPLY_TIMEOUT</c>, 10s, rust-link
/// <c>sidecar/src/rpc.rs</c>). A window that does not leave room for both makes the
/// rollback report arrive after the only thing waiting to hear it has given up, which
/// is the one outcome worse than no rollback: the plugin is fine and nobody knows.
/// </para>
/// </summary>
private const float ConfigReloadWindowSeconds = 4f;
/// <summary>How much of the newest log file is read back when a reload does not arrive.</summary>
private const int ConfigLogTailBytes = 8 * 1024;
/// <summary>One file, resolved and checked, on its way to disk.</summary>
private sealed class StagedConfigWrite
{
public string RelPath;
public string FullPath;
public string Text;
/// <summary>What was there before, held for the rollback that may never be needed.</summary>
public string Original;
public bool Written;
}
/// <summary>
/// A write that has landed and is waiting to hear whether the plugin came back.
///
/// <para>
/// At most one exists at a time. A second write arriving mid-window is refused rather
/// than queued: two overlapping rollbacks over intersecting file sets can restore a
/// file to a state that was never on disk.
/// </para>
/// </summary>
private sealed class ConfigWriteJob
{
public string ReqId;
public string Reload;
public List<StagedConfigWrite> Staged;
public Timer Watch;
/// <summary>Whether the target was seen leaving. It separates "never started" from "did not come back".</summary>
public bool Unloaded;
}
private ConfigWriteJob _configJob;
private string _lastConfigSummary = "none yet";
/// <summary>Whether this is a Carbon host. Resolved once, by looking, never by compiling twice (R19).</summary>
private static bool? _carbon;
private void HandleConfigList(string reqId)
{
string root = ConfigRoot();
if (string.IsNullOrEmpty(root) || !Directory.Exists(root))
{
EmitConfigError(reqId, "no-root", "this framework reports no configuration directory");
return;
}
var files = new List<object>();
bool truncated = false;
try
{
WalkConfig(root, root, 0, files, ref truncated);
}
catch (Exception ex)
{
EmitConfigError(reqId, "unreadable", ex.Message);
return;
}
Dictionary<string, object> reply = Frame("config.catalogue", "reply");
reply["root"] = root;
reply["files"] = files;
reply["plugins"] = LoadedPlugins();
reply["truncated"] = truncated;
// Named rather than inferred, so the website's locked-key guard (D38) is written
// against what this plugin calls itself instead of against a filename somebody may
// rename. The file carrying the link is not a file the site may break from a form.
reply["self"] = Name;
reply["limits"] = new Dictionary<string, object>
{
{ "depth", ConfigMaxDepth },
{ "files", ConfigMaxFiles },
{ "fileBytes", ConfigMaxFileBytes },
{ "writeFiles", ConfigMaxWriteFiles }
};
if (reqId != null)
reply["reqId"] = reqId;
EmitFrame(reply);
}
/// <summary>
/// Main thread, and deliberately cheap: it reads directory entries, never file
/// contents. A tree of 500 files costs 500 stat calls here; hashing all of them would
/// cost up to 128 MB of reads in one frame, which is exactly the unbounded work §10.5
/// forbids. A file's version is therefore produced by <c>config.read</c>, on the one
/// file somebody actually opened.
/// </summary>
private void WalkConfig(string root, string dir, int depth, List<object> into, ref bool truncated)
{
if (depth > ConfigMaxDepth)
{
truncated = true;
return;
}
string[] entries;
try
{
entries = Directory.GetFiles(dir, "*.json");
}
catch (Exception)
{
truncated = true;
return;
}
Array.Sort(entries, StringComparer.OrdinalIgnoreCase);
foreach (string full in entries)
{
if (into.Count >= ConfigMaxFiles)
{
truncated = true;
return;
}
FileInfo info;
try
{
info = new FileInfo(full);
}
catch (Exception)
{
continue;
}
// A link is not followed even when it points somewhere legitimate. Resolving one
// is how a tree that looks bounded turns out not to be, and how a path that
// validates as "under the root" writes somewhere else.
if ((info.Attributes & FileAttributes.ReparsePoint) != 0)
continue;
string rel = RelativeConfigPath(root, full);
var row = new Dictionary<string, object>
{
{ "path", rel },
{ "bytes", info.Length },
{ "modified", ToUnixMs(info.LastWriteTimeUtc) },
{ "plugin", PluginGuess(rel) }
};
if (info.Length > ConfigMaxFileBytes)
{
row["editable"] = false;
row["reason"] = "larger than this bridge will carry";
}
else
{
row["editable"] = true;
}
into.Add(row);
}
string[] dirs;
try
{
dirs = Directory.GetDirectories(dir);
}
catch (Exception)
{
truncated = true;
return;
}
Array.Sort(dirs, StringComparer.OrdinalIgnoreCase);
foreach (string sub in dirs)
{
if (into.Count >= ConfigMaxFiles)
{
truncated = true;
return;
}
try
{
if ((new DirectoryInfo(sub).Attributes & FileAttributes.ReparsePoint) != 0)
continue;
}
catch (Exception)
{
continue;
}
WalkConfig(root, sub, depth + 1, into, ref truncated);
}
}
private void HandleConfigRead(Dictionary<string, object> cmd, string reqId)
{
string rel = StringField(cmd, "path");
string full;
string refusal;
if (!ResolveConfigPath(rel, out full, out refusal))
{
EmitConfigError(reqId, "path", refusal);
return;
}
if (!File.Exists(full))
{
EmitConfigError(reqId, "missing", "no such configuration file");
return;
}
string text;
long bytes;
DateTime modified;
try
{
var info = new FileInfo(full);
bytes = info.Length;
modified = info.LastWriteTimeUtc;
if (bytes > ConfigMaxFileBytes)
{
EmitConfigError(reqId, "too-large", "larger than this bridge will carry");
return;
}
text = File.ReadAllText(full, Encoding.UTF8);
}
catch (Exception ex)
{
EmitConfigError(reqId, "unreadable", ex.Message);
return;
}
Dictionary<string, object> reply = Frame("config.file", "reply");
reply["path"] = rel;
reply["text"] = text;
reply["version"] = VersionOf(text);
reply["bytes"] = bytes;
reply["modified"] = ToUnixMs(modified);
reply["plugin"] = PluginGuess(rel);
if (reqId != null)
reply["reqId"] = reqId;
EmitFrame(reply);
}
/// <summary>
/// Main thread. Replaces a set of files and reloads what owns them.
///
/// <para>
/// Everything is resolved, version-checked and parsed <b>before the first byte is
/// written</b> — the same posture as <c>perm.sync</c>, and for the same reason: a
/// refusal that has touched nothing has nothing to unwind. What cannot be checked in
/// advance is whether the plugin will come back, which is what the window and the
/// rollback are for.
/// </para>
/// </summary>
private void HandleConfigWrite(Dictionary<string, object> cmd, string reqId)
{
if (_configJob != null)
{
EmitConfigError(reqId, "busy", "another configuration write is still waiting for its reload");
return;
}
var rows = new List<Dictionary<string, object>>(Rows(cmd, "files"));
if (rows.Count == 0)
{
EmitConfigError(reqId, "empty", "a write must name at least one file");
return;
}
if (rows.Count > ConfigMaxWriteFiles)
{
EmitConfigError(reqId, "too-many", $"a write may touch at most {ConfigMaxWriteFiles} files");
return;
}
string reload = StringField(cmd, "reload");
// The site may not reload the bridge. The reload would unload this plugin, close the
// link the answer travels on, and leave a rollback with nothing watching it — so the
// one failure this whole mechanism exists to prevent would be the one it could not
// report. Our own settings are still editable (D38); they take effect the next time
// somebody reloads this plugin deliberately.
if (!string.IsNullOrEmpty(reload) && reload.Equals(Name, StringComparison.OrdinalIgnoreCase))
{
EmitConfigError(reqId, "reload-self",
"this bridge will not reload itself: the reload would close the link carrying the answer");
return;
}
var staged = new List<StagedConfigWrite>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (Dictionary<string, object> row in rows)
{
string rel = StringField(row, "path");
string version = StringField(row, "version");
string text = StringField(row, "text");
string full;
string refusal;
if (!ResolveConfigPath(rel, out full, out refusal))
{
EmitConfigError(reqId, "path", refusal);
return;
}
if (!seen.Add(full))
{
EmitConfigError(reqId, "duplicate", $"'{rel}' is named twice in one write");
return;
}
if (text == null)
{
EmitConfigError(reqId, "empty", $"'{rel}' carries no text");
return;
}
if (Encoding.UTF8.GetByteCount(text) > ConfigMaxFileBytes)
{
EmitConfigError(reqId, "too-large", $"'{rel}' is larger than this bridge will carry");
return;
}
// A config file is edited, never created. A plugin writes its own defaults, and a
// file this walk has never seen is not a settings file somebody forgot — it is a
// path we have no reason to believe in.
if (!File.Exists(full))
{
EmitConfigError(reqId, "missing", $"'{rel}' does not exist");
return;
}
string current;
try
{
current = File.ReadAllText(full, Encoding.UTF8);
}
catch (Exception ex)
{
EmitConfigError(reqId, "unreadable", $"'{rel}': {ex.Message}");
return;
}
string actual = VersionOf(current);
if (version != actual)
{
Dictionary<string, object> conflict = Frame("config.error", "reply");
conflict["reason"] = "conflict";
conflict["detail"] = $"'{rel}' changed on disk since it was read";
conflict["path"] = rel;
conflict["version"] = actual;
if (reqId != null)
conflict["reqId"] = reqId;
EmitFrame(conflict);
return;
}
try
{
Newtonsoft.Json.Linq.JToken.Parse(text);
}
catch (Exception ex)
{
EmitConfigError(reqId, "invalid", $"'{rel}' is not valid JSON: {ex.Message}");
return;
}
staged.Add(new StagedConfigWrite
{
RelPath = rel,
FullPath = full,
Text = text,
Original = current
});
}
foreach (StagedConfigWrite file in staged)
{
try
{
BackupConfig(file);
File.WriteAllText(file.FullPath, file.Text, Utf8NoBom);
file.Written = true;
}
catch (Exception ex)
{
// Half a set on disk is the state nothing can reason about. Put back whatever
// did land before answering.
RestoreConfig(staged);
EmitConfigError(reqId, "unwritable", $"'{file.RelPath}': {ex.Message}");
return;
}
}
// This plugin never reloads itself (§21.3), so a change to its own settings would
// otherwise wait for somebody's next manual reload — and `EventsEnabled` is the first
// of them an operator is MEANT to change from the website (protocol 8, D76). It is
// re-read here instead. Host, Port and ServerId are locked on the website's side and
// still apply on the next reload, because changing them live would mean tearing down
// the link this answer is about to travel on.
bool ownApplied = ApplyOwnSettings(staged);
if (string.IsNullOrEmpty(reload))
{
SendConfigReport(reqId, staged, false, false,
ownApplied
? "EventsEnabled took effect at once; the bridge's other settings apply on its next reload"
: "no reload was asked for",
null);
return;
}
if (plugins.Find(reload) == null)
{
// The file is written and the plugin is not loaded, so there is nothing to reload
// and nothing to roll back from: the change applies when it is next loaded. Said
// plainly, because "saved" and "in effect" are different claims.
SendConfigReport(reqId, staged, false, false, $"'{reload}' is not loaded", null);
return;
}
_configJob = new ConfigWriteJob
{
ReqId = reqId,
Reload = reload,
Staged = staged
};
_configJob.Watch = timer.Once(ConfigReloadWindowSeconds, ConfigReloadTimedOut);
if (!ReloadPluginByName(reload))
{
StopConfigWatch();
_configJob = null;
RestoreConfig(staged);
EmitConfigError(reqId, "reload-failed", $"this framework refused to reload '{reload}'");
}
}
/// <summary>Main thread. The reload arrived: the plugin is back, so the write stands.</summary>
private void ConfigReloadArrived()
{
ConfigWriteJob job = _configJob;
if (job == null)
return;
StopConfigWatch();
_configJob = null;
SendConfigReport(job.ReqId, job.Staged, true, false, null, null);
}
/// <summary>
/// Main thread. The window closed with no <c>OnPluginLoaded</c>, which means the plugin
/// is down and the edit is why. Put every file back, reload again, and say so.
///
/// <para>
/// This is the feature's real content. Without it, R18 is a web form that takes a
/// required plugin off a production server one typo at a time.
/// </para>
/// </summary>
private void ConfigReloadTimedOut()
{
ConfigWriteJob job = _configJob;
if (job == null)
return;
_configJob = null;
StopConfigWatch();
RestoreConfig(job.Staged);
string reason = job.Unloaded
? $"'{job.Reload}' unloaded and did not come back within {ConfigReloadWindowSeconds:0}s"
: $"'{job.Reload}' did not reload within {ConfigReloadWindowSeconds:0}s";
// Fire and forget: the restore is on disk either way, and waiting for a second window
// would spend the caller's whole RPC budget to learn something the operator can see.
ReloadPluginByName(job.Reload);
SendConfigReport(job.ReqId, job.Staged, false, true, reason, LogTail());
}
private void StopConfigWatch()
{
ConfigWriteJob job = _configJob;
if (job == null || job.Watch == null)
return;
job.Watch.Destroy();
job.Watch = null;
}
/// <summary>
/// Main thread. What happened, and what is on disk now.
///
/// <para>
/// The versions are re-read rather than assumed, because both frameworks merge missing
/// defaults into a config on load and save it back — so the file after a successful
/// reload is regularly not the file we wrote, and a site holding our version would
/// conflict with itself on the next save.
/// </para>
/// </summary>
private void SendConfigReport(
string reqId,
List<StagedConfigWrite> staged,
bool reloaded,
bool rolledBack,
string reason,
string log)
{
var files = new List<object>();
foreach (StagedConfigWrite file in staged)
{
var row = new Dictionary<string, object> { { "path", file.RelPath } };
try
{
string now = File.ReadAllText(file.FullPath, Encoding.UTF8);
row["version"] = VersionOf(now);
row["bytes"] = Encoding.UTF8.GetByteCount(now);
row["rewritten"] = !rolledBack && now != file.Text;
}
catch (Exception)
{
row["version"] = null;
}
files.Add(row);
}
Dictionary<string, object> report = Frame("config.report", "reply");
report["ok"] = !rolledBack;
report["reloaded"] = reloaded;
report["rolledBack"] = rolledBack;
report["files"] = files;
if (!string.IsNullOrEmpty(reason))
report["reason"] = reason;
if (!string.IsNullOrEmpty(log))
report["log"] = log;
if (reqId != null)
report["reqId"] = reqId;
_lastConfigSummary =
$"files={staged.Count} reloaded={reloaded} rolledBack={rolledBack} " +
$"at={DateTime.UtcNow:HH:mm:ss}Z" +
(string.IsNullOrEmpty(reason) ? "" : $" reason={reason}");
EmitFrame(report);
}
private void EmitConfigError(string reqId, string reason, string detail)
{
Dictionary<string, object> frame = Frame("config.error", "reply");
frame["reason"] = reason;
if (!string.IsNullOrEmpty(detail))
frame["detail"] = detail;
if (reqId != null)
frame["reqId"] = reqId;
EmitFrame(frame);
}
/// <summary>Main thread. Puts every file that landed back the way it was.</summary>
private void RestoreConfig(List<StagedConfigWrite> staged)
{
foreach (StagedConfigWrite file in staged)
{
if (!file.Written)
continue;
try
{
File.WriteAllText(file.FullPath, file.Original, Utf8NoBom);
}
catch (Exception ex)
{
// Nothing else can be done from here, and it must be loud: the file on disk is
// now the edit that took a plugin down, and the operator is the only one who
// can fix it.
Puts($"could not restore '{file.RelPath}': {ex.Message}");
}
}
}
/// <summary>
/// Main thread. If a write touched this plugin's own config, take the one setting that
/// is safe to change live — <c>EventsEnabled</c> — from what was written. Returns
/// whether it did.
/// </summary>
private bool ApplyOwnSettings(List<StagedConfigWrite> staged)
{
string own;
try
{
own = Path.GetFullPath(Path.Combine(Interface.Oxide.ConfigDirectory, Name + ".json"));
}
catch (Exception)
{
return false;
}
foreach (StagedConfigWrite file in staged)
{
string full;
try
{
full = Path.GetFullPath(file.FullPath);
}
catch (Exception)
{
continue;
}
if (!string.Equals(full, own, StringComparison.OrdinalIgnoreCase))
continue;
try
{
var root = Newtonsoft.Json.Linq.JObject.Parse(file.Text);
Newtonsoft.Json.Linq.JToken flag = root["EventsEnabled"];
if (flag == null || flag.Type != Newtonsoft.Json.Linq.JTokenType.Boolean || _config == null)
return false;
bool now = (bool)flag;
if (_config.EventsEnabled != now)
Puts($"EventsEnabled is now {now} (set from the website)");
_config.EventsEnabled = now;
return true;
}
catch (Exception ex)
{
Puts($"could not re-read own settings: {Flatten(ex.Message)}");
return false;
}
}
return false;
}
/// <summary>
/// Keeps a copy of a file about to be overwritten, and the nine before it.
///
/// <para>
/// Under <c>DataDirectory</c>, which is this plugin writing its own state — not the
/// editor reaching into the tree it must never walk. The rollback itself does not
/// depend on these files; it holds the original in memory. They are for the operator
/// who wants last Tuesday's config back.
/// </para>
/// </summary>
private void BackupConfig(StagedConfigWrite file)
{
string dir = Path.Combine(Interface.Oxide.DataDirectory, Name, "config-backups");
Directory.CreateDirectory(dir);
string flat = file.RelPath.Replace('/', '_');
string stamp = DateTime.UtcNow.ToString("yyyyMMdd-HHmmss");
File.WriteAllText(Path.Combine(dir, $"{flat}.{stamp}.bak"), file.Original, Utf8NoBom);
string[] kept;
try
{
kept = Directory.GetFiles(dir, $"{flat}.*.bak");
}
catch (Exception)
{
return;
}
if (kept.Length <= 10)
return;
Array.Sort(kept, StringComparer.Ordinal);
for (int i = 0; i < kept.Length - 10; i++)
{
try
{
File.Delete(kept[i]);
}
catch (Exception)
{
}
}
}
/// <summary>
/// The tail of the newest log file, read only when a reload failed.
///
/// <para>
/// A rollback that says "it did not come back" and nothing else sends the operator to a
/// console to find the line that says why. This carries the line instead. Bounded, and
/// paid for only on the failure path.
/// </para>
/// </summary>
private string LogTail()
{
try
{
string dir = Interface.Oxide.LogDirectory;
if (string.IsNullOrEmpty(dir) || !Directory.Exists(dir))
return null;
string[] logs = Directory.GetFiles(dir, "*.txt");
if (logs.Length == 0)
return null;
string newest = null;
DateTime newestAt = DateTime.MinValue;
foreach (string candidate in logs)
{
DateTime at = File.GetLastWriteTimeUtc(candidate);
if (at <= newestAt)
continue;
newest = candidate;
newestAt = at;
}
if (newest == null)
return null;
using (var stream = new FileStream(newest, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
{
int take = (int)Math.Min(stream.Length, ConfigLogTailBytes);
stream.Seek(-take, SeekOrigin.End);
var buffer = new byte[take];
int read = stream.Read(buffer, 0, take);
return Encoding.UTF8.GetString(buffer, 0, read);
}
}
catch (Exception)
{
return null;
}
}
/// <summary>
/// Reloads a plugin through the framework rather than by composing a console command.
///
/// <para>
/// The console is the one place the two frameworks genuinely differ — Carbon's commands
/// are <c>c.</c>-prefixed and an alias for the Oxide names is opt-in — and a wrong
/// prefix on Carbon prints nothing at all, so it looks exactly like a command that
/// worked (`CARBON.md` §5). Reflection rather than <c>#if CARBON</c>, because one
/// byte-identical source file on both frameworks is the property R19 proved and is
/// worth keeping.
/// </para>
/// </summary>
private bool ReloadPluginByName(string name)
{
try
{
System.Reflection.MethodInfo reload = Interface.Oxide.GetType()
.GetMethod("ReloadPlugin", new[] { typeof(string) });
if (reload != null)
{
reload.Invoke(Interface.Oxide, new object[] { name });
return true;
}
}
catch (Exception ex)
{
Puts($"framework reload of '{name}' threw: {ex.Message}");
}
try
{
covalence.Server.Command(IsCarbon() ? "c.reload" : "oxide.reload", name);
return true;
}
catch (Exception ex)
{
Puts($"console reload of '{name}' threw: {ex.Message}");
return false;
}
}
private static bool IsCarbon()
{
if (_carbon.HasValue)
return _carbon.Value;
bool found = false;
try
{
foreach (System.Reflection.Assembly loaded in AppDomain.CurrentDomain.GetAssemblies())
{
string assembly = loaded.GetName().Name;
if (assembly == null)
continue;
if (assembly == "Carbon" || assembly.StartsWith("Carbon.", StringComparison.Ordinal))
{
found = true;
break;
}
}
}
catch (Exception)
{
}
_carbon = found;
return found;
}
private static string ConfigRoot()
{
try
{
return Interface.Oxide.ConfigDirectory;
}
catch (Exception)
{
return null;
}
}
/// <summary>
/// Turns a path from a web form into a path on this host, or refuses it with a reason.
///
/// <para>
/// Phase 7 addressed things by name; this phase addresses them by <b>path</b>, which is
/// exactly the change that introduces this bug class. Four refusals, and the last is the
/// one that matters: a resolved path must genuinely sit under the config root, checked
/// after canonicalisation rather than before.
/// </para>
/// </summary>
private static bool ResolveConfigPath(string rel, out string full, out string refusal)
{
full = null;
refusal = null;
if (string.IsNullOrEmpty(rel))
{
refusal = "no path";
return false;
}
if (rel.IndexOf('\0') >= 0 || rel.IndexOf("..", StringComparison.Ordinal) >= 0)
{
refusal = "a path may not step outside the configuration directory";
return false;
}
if (rel.StartsWith("/", StringComparison.Ordinal)
|| rel.StartsWith("\\", StringComparison.Ordinal)
|| Path.IsPathRooted(rel)
|| rel.IndexOf(':') >= 0)
{
refusal = "a path must be relative to the configuration directory";
return false;
}
if (!rel.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
{
refusal = "only .json files are configuration";
return false;
}
string root = ConfigRoot();
if (string.IsNullOrEmpty(root))
{
refusal = "this framework reports no configuration directory";
return false;
}
string rooted;
try
{
rooted = Path.GetFullPath(root);
full = Path.GetFullPath(Path.Combine(rooted, rel.Replace('/', Path.DirectorySeparatorChar)));
}
catch (Exception ex)
{
refusal = ex.Message;
full = null;
return false;
}
string prefix = rooted.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal)
? rooted
: rooted + Path.DirectorySeparatorChar;
if (!full.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{
refusal = "that path is not inside the configuration directory";
full = null;
return false;
}
try
{
var info = new FileInfo(full);
if (info.Exists && (info.Attributes & FileAttributes.ReparsePoint) != 0)
{
refusal = "that file is a link, and a link is not followed";
full = null;
return false;
}
}
catch (Exception)
{
}
return true;
}
private static string RelativeConfigPath(string root, string full)
{
string prefix = root.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal)
? root
: root + Path.DirectorySeparatorChar;
string rel = full.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)
? full.Substring(prefix.Length)
: Path.GetFileName(full);
return rel.Replace(Path.DirectorySeparatorChar, '/').Replace('\\', '/');
}
/// <summary>
/// Which plugin a file probably belongs to: the folder for a nested file, the file's own
/// name otherwise.
///
/// <para>
/// <b>A guess, and it is labelled one all the way to the form.</b> A folder name is
/// convention, not contract. Infer it silently and the failure is the nastiest available
/// here: we reload the wrong plugin, watch <c>OnPluginLoaded</c> fire for <i>it</i>, and
/// report success while the plugin that was actually edited never re-read a thing.
/// </para>
/// </summary>
private static string PluginGuess(string rel)
{
int slash = rel.IndexOf('/');
return slash > 0
? rel.Substring(0, slash)
: Path.GetFileNameWithoutExtension(rel);
}
private List<object> LoadedPlugins()
{
var rows = new List<object>();
Plugin[] all;
try
{
all = plugins.GetAll();
}
catch (Exception)
{
return rows;
}
if (all == null)
return rows;
foreach (Plugin loaded in all)
{
if (loaded == null || string.IsNullOrEmpty(loaded.Name))
continue;
rows.Add(new Dictionary<string, object>
{
{ "name", loaded.Name },
{ "title", loaded.Title },
{ "version", loaded.Version.ToString() },
{ "author", loaded.Author }
});
}
return rows;
}
/// <summary>
/// A file's identity for optimistic concurrency: its length and an FNV-1a hash of its
/// text.
///
/// <para>
/// Deliberately not a cryptographic digest. Nothing here is a security claim — the site
/// never computes one of these, it only echoes back the one it was given — and
/// <c>System.Security.Cryptography</c> is a namespace whose availability under a plugin
/// compiler is one more thing that would have to be true on two frameworks.
/// </para>
/// </summary>
private static string VersionOf(string text)
{
byte[] bytes = Encoding.UTF8.GetBytes(text ?? string.Empty);
ulong hash = 14695981039346656037UL;
foreach (byte b in bytes)
{
hash ^= b;
hash *= 1099511628211UL;
}
return $"{bytes.Length:x}-{hash:x16}";
}
/// <summary>UTF-8 with no byte-order mark, which is what both frameworks write.</summary>
private static readonly UTF8Encoding Utf8NoBom = new UTF8Encoding(false);
private static long ToUnixMs(DateTime utc)
{
return (long)(utc - new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)).TotalMilliseconds;
}
// ---- leases (protocol 8, PLAN.md §27) ----
//
// An event does not edit the world. It borrows a value and gives it back. The website
// decides WHEN (core's `core.lease` reads the baseline, reserves the target and schedules
// the restore); this plugin decides WHETHER, holds the deadline, and does the writing.
//
// Four rules, each the answer to a way the obvious version goes wrong:
//
// • **The deadline lives here.** A hold arms a deadline and the plugin restores the
// baseline when it passes, whether or not the website is ever heard from again. Core's
// restore is the normal path; this is the fail-safe that makes an unattended change at
// 04:00 defensible. `holdMs` is authoritative and `untilMs` is display only — an
// absolute time computed on the website and honoured here is a deadline measured
// against two clocks.
// • **Release is compare-and-set, never a blind write.** A value that is no longer what
// the lease applied was moved by somebody on purpose: answer `lease.drifted` with what
// is there now and leave it alone.
// • **Values cross as text and compare parsed.** `1` and `1.0` are one number and two
// strings; a drift check on formatted numbers blames an innocent operator.
// • **A hold longer than the ceiling is refused, never clamped.** A clamp gives the
// website a shorter lease than it believes it has, and the website is the half that
// schedules the restore.
//
// And one that is Rust's own (§27.1): **a convar hold is memory-only and a restart is a
// free restore, but a group permission is persisted by the framework.** So holds are
// written to a file under this plugin's data directory with the boot they were taken
// under, and on load a convar hold from an earlier boot is dropped (the restart already
// gave it back) while a permission hold is re-armed — or restored at once, if its
// deadline passed while the server was down.
/// <summary>
/// The longest a lease may be held: seven days (D77). The website declares the same
/// ceiling; this one is independent of it and exists for the case where the website
/// is wrong.
/// </summary>
private const long LeaseMaxHoldMs = 7L * 24L * 60L * 60L * 1000L;
/// <summary>How often held deadlines are checked. A lease ends within a second of its time.</summary>
private const float LeaseCheckSeconds = 1f;
/// <summary>The wire key of the one lease that is not a convar.</summary>
private const string GroupPermissionKey = "group.permission";
/// <summary>
/// One convar the website may borrow, and the bounds this plugin holds it to. The
/// website's declaration carries the same bounds so a bad value is refused on a form;
/// these are what is true when the website is wrong.
/// </summary>
private sealed class LeaseKey
{
public string Key;
public string Family;
public double Min;
public double Max;
public LeaseKey(string key, string family, double min, double max)
{
Key = key;
Family = family;
Min = min;
Max = max;
}
}
/// <summary>
/// Every convar a lease may name (D74: the game's own convars, nothing Carbon-only).
///
/// <para>
/// An allowlist rather than "any convar", because the catalogue is a promise: a key
/// here has been set, observed changing something in a running game, and restored. A
/// value the server reads once at boot applies cleanly, reads back cleanly and does
/// nothing, and nothing but a live walk can tell the two apart (§9).
/// </para>
/// </summary>
private static readonly LeaseKey[] LeaseKeys =
{
new LeaseKey("decay.scale", "decay", 0, 10),
// Every population is per square kilometre — vehicles too, whatever the game's own
// help text says ("active on the server"): on a 9 km² map a value of 3 is a target of
// 27. Each was walked: set, seen moving `spawn.report`'s target, and given back.
new LeaseKey("bear.population", "population", 0, 50),
new LeaseKey("polarbear.population", "population", 0, 50),
new LeaseKey("boar.population", "population", 0, 50),
new LeaseKey("chicken.population", "population", 0, 50),
new LeaseKey("stag.population", "population", 0, 50),
new LeaseKey("wolf2.population", "population", 0, 50),
new LeaseKey("ridablehorse.population", "population", 0, 50),
new LeaseKey("crocodile.population", "population", 0, 50),
new LeaseKey("panther.population", "population", 0, 50),
new LeaseKey("tiger.population", "population", 0, 50),
new LeaseKey("snakehazard.population", "population", 0, 50),
new LeaseKey("minicopter.population", "population", 0, 50),
new LeaseKey("scraptransporthelicopter.population", "population", 0, 50),
new LeaseKey("hotairballoon.population", "population", 0, 50),
new LeaseKey("modularcar.population", "population", 0, 50),
new LeaseKey("motorrowboat.population", "population", 0, 50),
new LeaseKey("rhib.rhibpopulation", "population", 0, 50),
new LeaseKey("traincar.population", "population", 0, 50),
// The population system's own scalars. Only the two MINIMUMS: the game scales between
// min and max by how full the server is, so on a rig with nobody on it the effective
// rate and density ARE the minimums, and `spawn.scalars` showed both moving. The two
// maximums only matter with players online, which no walk has had — so they are not
// lent until one does (§9: a key that cannot be seen doing something is not advertised).
new LeaseKey("spawn.min_rate", "spawn", 0, 10),
new LeaseKey("spawn.min_density", "spawn", 0, 10)
};
/// <summary>
/// One value this plugin is holding. Persisted as it is, so every field is public and
/// plain.
/// </summary>
private sealed class LeaseHold
{
public string Key;
/// <summary>Null for a convar; <c>group/permission</c> for a group permission.</summary>
public string Target;
public string Baseline;
public string Applied;
/// <summary>When the hold ends, on this host's clock: taken time plus <c>holdMs</c>.</summary>
public long DeadlineMs;
/// <summary>The website's absolute deadline, for display. Never compared against.</summary>
public long UntilMs;
public string RunId;
public string BootId;
public long TakenMs;
/// <summary>
/// What the website's permission set said about this pair while the lease held it:
/// null for nothing, else whether the site wants it granted. A group permission
/// held by a lease belongs to the lease until it ends; a sync that arrives in the
/// meantime is recorded here and honoured at release instead of being applied
/// under the lease's feet (§27.3).
/// </summary>
public bool? SiteWants;
}
/// <summary>Holds by <see cref="LeaseSlot"/>. Main thread only.</summary>
private Dictionary<string, LeaseHold> _leases = new Dictionary<string, LeaseHold>();
/// <summary>Convars by lowered full name, resolved once. Null until first needed.</summary>
private Dictionary<string, ConsoleSystem.Command> _convars;
private Timer _leaseTimer;
/// <summary>The last thing a lease did, for <c>rg.leases</c>. Diagnostics only.</summary>
private string _lastLeaseSummary = "none yet";
private static string LeaseSlot(string key, string target)
{
return string.IsNullOrEmpty(target) ? key : key + "#" + target;
}
private static LeaseKey FindLeaseKey(string key)
{
foreach (LeaseKey k in LeaseKeys)
{
if (string.Equals(k.Key, key, StringComparison.OrdinalIgnoreCase))
return k;
}
return null;
}
/// <summary>
/// Main thread. The game's convar, or null when this build has no such convar or it
/// is one the plugin refuses to hold.
///
/// <para>
/// <b>A <c>Saved</c> convar is refused</b> (§27.1). The game writes those to
/// <c>serverauto.cfg</c>, so a hold on one would survive the restart that is
/// supposed to end it — and a lease that outlives its own fail-safe is the one thing
/// a lease exists to prevent. None of the allowlist is <c>Saved</c> today; this is
/// what keeps a Facepunch change to that from becoming our bug silently.
/// </para>
/// </summary>
private ConsoleSystem.Command LeaseConvar(string key, out string refusal)
{
refusal = null;
if (_convars == null)
{
_convars = new Dictionary<string, ConsoleSystem.Command>(StringComparer.OrdinalIgnoreCase);
ConsoleSystem.Command[] all = ConsoleSystem.Index.All;
if (all != null)
{
foreach (ConsoleSystem.Command c in all)
{
if (c != null && c.Variable && !string.IsNullOrEmpty(c.FullName))
_convars[c.FullName] = c;
}
}
}
ConsoleSystem.Command command;
if (!_convars.TryGetValue(key, out command) || command == null || command.SetOveride == null)
{
refusal = $"this server has no convar '{key}'";
return null;
}
if (command.Saved)
{
refusal = $"'{key}' is saved to serverauto.cfg, so a restart would not give it back";
return null;
}
return command;
}
private static bool TryParseNumber(string text, out double value)
{
return double.TryParse(
text, System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out value);
}
private static bool TryParseFlag(string text, out bool value)
{
value = false;
if (text == null)
return false;
string t = text.Trim().ToLowerInvariant();
if (t == "true" || t == "1")
{
value = true;
return true;
}
if (t == "false" || t == "0")
return true;
return false;
}
/// <summary>
/// Whether two values are the same value, parsed. A convar reads back through
/// <c>float.ToString()</c>, so the tolerance is a float's, not a double's.
/// </summary>
private static bool SameLeaseValue(string key, string a, string b)
{
if (key == GroupPermissionKey)
{
bool x, y;
return TryParseFlag(a, out x) && TryParseFlag(b, out y) && x == y;
}
double p, q;
if (!TryParseNumber(a, out p) || !TryParseNumber(b, out q))
return string.Equals(a, b, StringComparison.Ordinal);
return Math.Abs(p - q) <= 1e-4 * Math.Max(1.0, Math.Max(Math.Abs(p), Math.Abs(q)));
}
/// <summary>
/// Splits a group-permission target at its LAST slash. A permission name never
/// contains one; a group name is a free-form string an operator typed, so it is the
/// half allowed to.
/// </summary>
private static bool SplitGroupTarget(string target, out string group, out string perm)
{
group = null;
perm = null;
if (string.IsNullOrEmpty(target))
return false;
int slash = target.LastIndexOf('/');
if (slash <= 0 || slash >= target.Length - 1)
return false;
group = Lower(target.Substring(0, slash));
perm = Lower(target.Substring(slash + 1));
return !string.IsNullOrEmpty(group) && !string.IsNullOrEmpty(perm);
}
/// <summary>
/// Main thread. The value a key holds right now, as text — or null with a reason.
/// <paramref name="gone"/> is set when the target itself no longer exists, which a
/// release treats as nothing left to give back rather than as a failure.
/// </summary>
private string ReadLeaseValue(string key, string target, out string refusal, out bool gone)
{
gone = false;
if (key == GroupPermissionKey)
{
string group, perm;
if (!SplitGroupTarget(target, out group, out perm))
{
refusal = "a group permission is named as group/permission";
return null;
}
if (!permission.GroupExists(group))
{
gone = true;
refusal = $"there is no group '{group}' on this server";
return null;
}
refusal = null;
return permission.GroupHasPermission(group, perm) ? "true" : "false";
}
if (FindLeaseKey(key) == null)
{
refusal = $"'{key}' is not a value this server lends";
return null;
}
ConsoleSystem.Command command = LeaseConvar(key, out refusal);
return command == null ? null : command.String;
}
/// <summary>
/// Main thread. Writes a value, then reads it back — the read-back is the answer, not
/// the call. Returns null on success, or why not.
/// </summary>
private string WriteLeaseValue(string key, string target, string value)
{
if (key == GroupPermissionKey)
{
string group, perm;
if (!SplitGroupTarget(target, out group, out perm))
return "a group permission is named as group/permission";
bool grant;
if (!TryParseFlag(value, out grant))
return $"'{value}' is not true or false";
// Our own write raises OnGroupPermissionGranted/Revoked. Without this the site
// would be told a human had just edited the store, and it would reconcile against
// the lease's own change (§27.3).
_applyingPermissions = true;
try
{
// **`null`, not `this`, as the owner.** With an owner, Oxide's grant first asks
// `PermissionExists(perm, owner)` — whether THAT plugin registered the name —
// and returns without a word when it did not. Every permission a lease borrows
// belongs to some other plugin, so `this` made every grant a silent no-op. The
// read-back below is what caught it.
if (grant)
permission.GrantGroupPermission(group, perm, null);
else
permission.RevokeGroupPermission(group, perm);
}
finally
{
_applyingPermissions = false;
}
if (permission.GroupHasPermission(group, perm) != grant)
return $"the store did not {(grant ? "grant" : "revoke")} '{perm}' on '{group}'";
return null;
}
string refusal;
ConsoleSystem.Command command = LeaseConvar(key, out refusal);
if (command == null)
return refusal;
command.Set(value);
if (!SameLeaseValue(key, command.String, value))
return $"'{key}' reads back as {command.String} after being set to {value}";
return null;
}
/// <summary>
/// Main thread. Protocol 8's catalogue: every key this server lends and what it holds
/// now, and every hold in force.
///
/// <para>
/// Narrowed by <c>key</c> (and <c>target</c>) when asked, because a group permission
/// has no single current value — it is one per pair — and a caller asking about one
/// pair wants that pair's row, not the store.
/// </para>
/// </summary>
private void HandleLeaseList(Dictionary<string, object> cmd, string reqId)
{
string onlyKey = Lower(StringField(cmd, "key"));
string onlyTarget = StringField(cmd, "target");
var rows = new List<object>();
foreach (LeaseKey k in LeaseKeys)
{
if (!string.IsNullOrEmpty(onlyKey) && onlyKey != k.Key)
continue;
rows.Add(LeaseRow(k.Key, k.Family, k.Min, k.Max, null));
}
if (onlyKey == GroupPermissionKey && !string.IsNullOrEmpty(onlyTarget))
rows.Add(LeaseRow(GroupPermissionKey, "group", 0, 1, onlyTarget));
var holds = new List<object>();
foreach (LeaseHold h in _leases.Values)
holds.Add(HoldBody(h));
Dictionary<string, object> reply = Frame("lease.list", "reply");
reply["eventsEnabled"] = _config != null && _config.EventsEnabled;
reply["maxHoldMs"] = LeaseMaxHoldMs;
reply["leases"] = rows;
reply["holds"] = holds;
if (reqId != null)
reply["reqId"] = reqId;
EmitFrame(reply);
}
private Dictionary<string, object> LeaseRow(string key, string family, double min, double max, string target)
{
var row = new Dictionary<string, object>
{
{ "key", key },
{ "family", family }
};
if (key != GroupPermissionKey)
{
row["min"] = min;
row["max"] = max;
}
else
{
row["target"] = target;
}
string refusal;
bool gone;
string current = ReadLeaseValue(key, target, out refusal, out gone);
if (current != null)
row["current"] = current;
else
row["unreadable"] = refusal;
if (gone)
row["targetGone"] = true;
LeaseHold hold;
bool held = _leases.TryGetValue(LeaseSlot(key, target), out hold);
row["held"] = held;
if (held)
{
row["baseline"] = hold.Baseline;
row["applied"] = hold.Applied;
row["untilMs"] = hold.UntilMs;
row["runId"] = hold.RunId;
}
return row;
}
private static Dictionary<string, object> HoldBody(LeaseHold h)
{
var body = new Dictionary<string, object>
{
{ "key", h.Key },
{ "baseline", h.Baseline },
{ "applied", h.Applied },
{ "untilMs", h.UntilMs },
{ "remainingMs", Math.Max(0L, h.DeadlineMs - NowMs()) }
};
if (!string.IsNullOrEmpty(h.Target))
body["target"] = h.Target;
if (!string.IsNullOrEmpty(h.RunId))
body["runId"] = h.RunId;
return body;
}
/// <summary>
/// Main thread. Take a value for a while.
///
/// <para>
/// <b>A key this plugin already holds keeps its ORIGINAL baseline.</b> Core reserves
/// a target before it applies, so a second holder is refused on the website's side;
/// a second apply arriving here means the first one's answer was lost, and core is
/// trying again. The value to give back at the end is still what was there before
/// anybody borrowed it, not the first attempt's value.
/// </para>
/// </summary>
private void HandleLeaseApply(Dictionary<string, object> cmd, string reqId)
{
string key = Lower(StringField(cmd, "key"));
string target = key == GroupPermissionKey ? StringField(cmd, "target") : null;
string family = Lower(StringField(cmd, "family"));
string value = StringField(cmd, "value");
long holdMs = LongField(cmd, "holdMs");
long untilMs = LongField(cmd, "untilMs");
if (_config == null || !_config.EventsEnabled)
{
EmitLeaseError(reqId, "events-disabled",
"events are switched off on this server — set EventsEnabled in the RunicGateway plugin config");
return;
}
if (string.IsNullOrEmpty(key) || value == null)
{
EmitLeaseError(reqId, "malformed", "a lease names a key and a value");
return;
}
if (holdMs <= 0)
{
EmitLeaseError(reqId, "malformed", "a lease is held for a positive number of milliseconds");
return;
}
if (holdMs > LeaseMaxHoldMs)
{
EmitLeaseError(reqId, "too-long",
$"this server holds a lease for at most {LeaseMaxHoldMs / 60000L} minutes, not {holdMs / 60000L}");
return;
}
if (key == GroupPermissionKey)
{
bool flag;
if (!TryParseFlag(value, out flag))
{
EmitLeaseError(reqId, "out-of-range", $"'{value}' is not true or false");
return;
}
string group, perm;
if (!SplitGroupTarget(target, out group, out perm))
{
EmitLeaseError(reqId, "malformed", "a group permission is named as group/permission");
return;
}
// The same pre-check the permission mirror makes (§12.2 rule 1): a grant of a
// name nothing registered returns void and does nothing, and the lease would then
// record a hold on a value that never changed.
if (!permission.PermissionExists(perm))
{
EmitLeaseError(reqId, "unresolved", $"no loaded plugin on this server registers '{perm}'");
return;
}
target = group + "/" + perm;
value = flag ? "true" : "false";
}
else
{
LeaseKey k = FindLeaseKey(key);
if (k == null)
{
EmitLeaseError(reqId, "unknown-key", $"'{key}' is not a value this server lends");
return;
}
if (!string.IsNullOrEmpty(family) && family != k.Family)
{
EmitLeaseError(reqId, "unknown-key", $"'{key}' is not a {family} value");
return;
}
double number;
if (!TryParseNumber(value, out number) || number < k.Min || number > k.Max)
{
EmitLeaseError(reqId, "out-of-range", $"{key} takes {k.Min} to {k.Max}, not {value}");
return;
}
}
string slot = LeaseSlot(key, target);
LeaseHold existing;
_leases.TryGetValue(slot, out existing);
string refusal;
bool gone;
string before = ReadLeaseValue(key, target, out refusal, out gone);
if (before == null)
{
EmitLeaseError(reqId, gone ? "target-gone" : "unreadable", refusal);
return;
}
string failed = WriteLeaseValue(key, target, value);
if (failed != null)
{
// Put back what was there; a half-applied lease is not a state to leave behind.
WriteLeaseValue(key, target, before);
EmitLeaseError(reqId, "refused", failed);
return;
}
long now = NowMs();
var hold = new LeaseHold
{
Key = key,
Target = target,
Baseline = existing != null ? existing.Baseline : before,
Applied = value,
DeadlineMs = now + holdMs,
UntilMs = untilMs > 0 ? untilMs : now + holdMs,
RunId = StringField(cmd, "runId"),
BootId = _bootId,
TakenMs = now,
SiteWants = existing != null ? existing.SiteWants : null
};
_leases[slot] = hold;
SaveLeases();
EnsureLeaseTimer();
_lastLeaseSummary = $"applied {slot}={value} (baseline {hold.Baseline}) for {holdMs / 1000L}s";
Puts($"lease {_lastLeaseSummary}");
Dictionary<string, object> reply = Frame("lease.ok", "reply");
reply["key"] = key;
if (!string.IsNullOrEmpty(target))
reply["target"] = target;
reply["baseline"] = hold.Baseline;
reply["applied"] = hold.Applied;
reply["untilMs"] = hold.UntilMs;
if (reqId != null)
reply["reqId"] = reqId;
EmitFrame(reply);
}
/// <summary>
/// Main thread. Give a value back — compare-and-set.
///
/// <para>
/// The comparison is against what the LEASE applied, from this plugin's own record
/// when it has one, else from the website's <c>expected</c>. The record is missing
/// when the deadline already fired, or when a restart dropped a convar hold; both of
/// those have already given the value back, and the compare then finds baseline there
/// and answers success without writing anything.
/// </para>
/// </summary>
private void HandleLeaseRelease(Dictionary<string, object> cmd, string reqId)
{
string key = Lower(StringField(cmd, "key"));
string target = StringField(cmd, "target");
if (key == GroupPermissionKey)
{
string group, perm;
if (SplitGroupTarget(target, out group, out perm))
target = group + "/" + perm;
}
else
{
target = null;
}
if (string.IsNullOrEmpty(key))
{
EmitLeaseError(reqId, "malformed", "a release names a key");
return;
}
LeaseHold hold;
_leases.TryGetValue(LeaseSlot(key, target), out hold);
string expected = hold != null ? hold.Applied : StringField(cmd, "expected");
string baseline = hold != null ? hold.Baseline : StringField(cmd, "baseline");
Dictionary<string, object> outcome = Restore(key, target, expected, baseline, hold, "release");
if (reqId != null)
outcome["reqId"] = reqId;
EmitFrame(outcome);
}
/// <summary>
/// Main thread. The one place a value is given back, by a release or a deadline. The
/// hold is ended either way — a drifted value is left alone, but the lease is over.
/// </summary>
private Dictionary<string, object> Restore(
string key, string target, string expected, string baseline, LeaseHold hold, string why)
{
string slot = LeaseSlot(key, target);
// A group permission the site asked for during the hold is given to the site, not
// back to the baseline: the lease borrowed the pair, the site owns what it becomes.
string restoreTo = baseline;
if (hold != null && hold.SiteWants.HasValue)
restoreTo = hold.SiteWants.Value ? "true" : "false";
string refusal;
bool gone;
string current = ReadLeaseValue(key, target, out refusal, out gone);
if (current == null)
{
if (gone)
{
// The group was deleted mid-hold: nothing is owed and nothing is out there.
_leases.Remove(slot);
SaveLeases();
_lastLeaseSummary = $"{why} {slot}: target gone";
Dictionary<string, object> goneFrame = Frame("lease.ok", "reply");
goneFrame["key"] = key;
goneFrame["targetGone"] = true;
if (!string.IsNullOrEmpty(target))
goneFrame["target"] = target;
return goneFrame;
}
// The value cannot be read, so it cannot be compared. The hold stays: the next
// release, or the next deadline check, tries again.
Dictionary<string, object> error = Frame("lease.error", "reply");
error["reason"] = "unreadable";
error["message"] = refusal;
return error;
}
if (restoreTo != null && SameLeaseValue(key, current, restoreTo))
{
// Already given back — by a deadline, a restart, or somebody who restored it by
// hand. Nothing to write, and it is a success.
_leases.Remove(slot);
SaveLeases();
_lastLeaseSummary = $"{why} {slot}: already {current}";
return RestoredFrame(key, target, current);
}
if (expected == null || !SameLeaseValue(key, current, expected))
{
_leases.Remove(slot);
SaveLeases();
_lastLeaseSummary = $"{why} {slot}: drifted to {current}, left alone";
Puts($"lease {_lastLeaseSummary}");
Dictionary<string, object> drifted = Frame("lease.drifted", "reply");
drifted["key"] = key;
if (!string.IsNullOrEmpty(target))
drifted["target"] = target;
drifted["current"] = current;
drifted["expected"] = expected;
return drifted;
}
string failed = restoreTo == null ? "no baseline to restore" : WriteLeaseValue(key, target, restoreTo);
if (failed != null)
{
Dictionary<string, object> error = Frame("lease.error", "reply");
error["reason"] = "refused";
error["message"] = failed;
return error;
}
_leases.Remove(slot);
SaveLeases();
_lastLeaseSummary = $"{why} {slot}: restored {restoreTo}";
Puts($"lease {_lastLeaseSummary}");
return RestoredFrame(key, target, restoreTo);
}
private Dictionary<string, object> RestoredFrame(string key, string target, string value)
{
Dictionary<string, object> ok = Frame("lease.ok", "reply");
ok["key"] = key;
if (!string.IsNullOrEmpty(target))
ok["target"] = target;
ok["restored"] = value;
return ok;
}
/// <summary>
/// Main thread, every second while anything is held. The fail-safe: a deadline that
/// passes gives the value back without anybody asking, and says so.
/// </summary>
private void CheckLeaseDeadlines()
{
if (_leases.Count == 0)
{
StopLeaseTimer();
return;
}
long now = NowMs();
List<LeaseHold> due = null;
foreach (LeaseHold h in _leases.Values)
{
if (h.DeadlineMs <= now)
{
if (due == null)
due = new List<LeaseHold>();
due.Add(h);
}
}
if (due == null)
return;
foreach (LeaseHold h in due)
Expire(h);
}
private void Expire(LeaseHold hold)
{
Dictionary<string, object> outcome = Restore(
hold.Key, hold.Target, hold.Applied, hold.Baseline, hold, "expired");
string kind = StringField(outcome, "kind");
// An unreadable value keeps the hold; the next tick tries again rather than
// announcing an expiry that did not happen.
if (kind == "lease.error")
return;
Dictionary<string, object> frame = Frame("lease.expired", "event");
frame["key"] = hold.Key;
if (!string.IsNullOrEmpty(hold.Target))
frame["target"] = hold.Target;
if (!string.IsNullOrEmpty(hold.RunId))
frame["runId"] = hold.RunId;
frame["drifted"] = kind == "lease.drifted";
object current;
if (outcome.TryGetValue("current", out current))
frame["current"] = current;
EmitFrame(frame);
}
private void EnsureLeaseTimer()
{
if (_leaseTimer == null && _leases.Count > 0)
_leaseTimer = timer.Every(LeaseCheckSeconds, CheckLeaseDeadlines);
}
private void StopLeaseTimer()
{
if (_leaseTimer != null)
{
_leaseTimer.Destroy();
_leaseTimer = null;
}
}
private void EmitLeaseError(string reqId, string reason, string message)
{
Dictionary<string, object> frame = Frame("lease.error", "reply");
frame["reason"] = reason;
if (!string.IsNullOrEmpty(message))
frame["message"] = message;
if (reqId != null)
frame["reqId"] = reqId;
_lastLeaseSummary = $"refused ({reason}): {message}";
EmitFrame(frame);
}
private string LeasesPath()
{
return Path.Combine(Interface.Oxide.DataDirectory, Name, "leases.json");
}
/// <summary>
/// Main thread. Written on every change, because the whole point of the file is the
/// crash nobody saw coming. It is a handful of rows.
/// </summary>
private void SaveLeases()
{
try
{
string path = LeasesPath();
Directory.CreateDirectory(Path.GetDirectoryName(path));
var rows = new List<LeaseHold>(_leases.Values);
string tmp = path + ".tmp";
File.WriteAllText(tmp, JsonConvert.SerializeObject(rows, Formatting.Indented), Utf8NoBom);
if (File.Exists(path))
File.Delete(path);
File.Move(tmp, path);
}
catch (Exception ex)
{
Puts($"could not save leases: {Flatten(ex.Message)}");
}
}
/// <summary>
/// Main thread, once per load, with the world available. Re-arms what is still owed.
///
/// <para>
/// A convar hold taken under an earlier BOOT is dropped: the restart put the game's
/// own value back, and re-applying it would resurrect a lease the restart ended. A
/// convar hold from THIS boot survives a plugin reload — the value is still in the
/// game's memory, and a config save from the website must not end an event. A group
/// permission hold is re-armed whatever the boot, because the framework kept it.
/// </para>
/// </summary>
private void LoadLeases()
{
_leases = new Dictionary<string, LeaseHold>();
List<LeaseHold> rows = null;
try
{
string path = LeasesPath();
if (File.Exists(path))
rows = JsonConvert.DeserializeObject<List<LeaseHold>>(File.ReadAllText(path));
}
catch (Exception ex)
{
Puts($"could not read leases: {Flatten(ex.Message)}");
}
if (rows == null)
return;
int dropped = 0;
foreach (LeaseHold h in rows)
{
if (h == null || string.IsNullOrEmpty(h.Key))
continue;
if (h.Key != GroupPermissionKey && h.BootId != _bootId)
{
dropped++;
continue;
}
_leases[LeaseSlot(h.Key, h.Target)] = h;
}
if (dropped > 0)
SaveLeases();
Puts($"leases: {_leases.Count} held, {dropped} ended by a restart");
// Due now or later; the first tick restores anything whose deadline passed while the
// server was down.
EnsureLeaseTimer();
}
/// <summary>
/// Whether a lease holds this group permission, for the permission mirror. A pair a
/// lease has borrowed is the lease's until it ends: a sync neither applies to it nor
/// reports it foreign.
/// </summary>
private LeaseHold LeasedGroupPermission(string group, string perm)
{
LeaseHold hold;
return _leases.TryGetValue(LeaseSlot(GroupPermissionKey, Lower(group) + "/" + Lower(perm)), out hold)
? hold
: null;
}
/// <summary>What every hold is doing, from the server console.</summary>
[ConsoleCommand("rg.leases")]
private void CmdLeases(ConsoleSystem.Arg arg)
{
var lines = new List<string>
{
$"protocol={ProtocolVersion} eventsEnabled={(_config != null && _config.EventsEnabled)} " +
$"held={_leases.Count} bootId={_bootId}"
};
foreach (LeaseHold h in _leases.Values)
{
lines.Add(
$" {LeaseSlot(h.Key, h.Target)} applied={h.Applied} baseline={h.Baseline} " +
$"remaining={Math.Max(0L, h.DeadlineMs - NowMs()) / 1000L}s run={h.RunId ?? "-"}");
}
lines.Add($"last: {_lastLeaseSummary}");
arg.ReplyWith(string.Join(Environment.NewLine, lines.ToArray()));
}
// ---- the world verbs (protocol 9, PLAN.md §28) ----
//
// Two things an event makes that did not exist — a zone, and a crate or NPC placed in the
// world — and one registry that remembers which run made what. Four rules shape all of it,
// and each came from the rig rather than from a document (§28.1):
//
// * **A restart is not proof a placed thing is gone.** Crates are saved by the game and
// come back with the same net id; NPCs are not saved and do not. So nothing here is
// inferred from a boot id. `world.owned` LOOKS, by net id, every time.
// * **A wipe is proof everything is gone.** The registry is dropped whole on a new save
// and at load when it names another wipe.
// * **ZoneManager forgets.** It holds temporary zones in memory and erases every zone a
// plugin owns when that plugin unloads — so an `oxide.reload` of this bridge deletes
// every event zone mid-run. The registry is the record, and zones are put back from it
// on every load and every ZoneManager reload (D92).
// * **There is no at-most-once store on this bridge.** The registry is keyed by the
// website's idempotency key instead: a repeat answers with the ids the first call made
// and places nothing, which is the whole of what the key is for.
//
// What may be erased is decided by the registry and never by ZoneManager, whose
// `EraseTemporaryZone` deletes an operator's own hand-made zone and returns true (§12.4).
/// <summary>The ceiling on crates per step. <c>EventsMaxCrates</c> may lower it, never raise it.</summary>
private const int WorldHardMaxCrates = 25;
/// <summary>The ceiling on NPCs per step (D95).</summary>
private const int WorldHardMaxNpcs = 20;
/// <summary>How far a group may be scattered around its point, in metres.</summary>
private const float WorldMaxSpread = 50f;
/// <summary>How far from a monument's centre a step may put its point, in metres.</summary>
private const float WorldMaxOffset = 150f;
private const float ZoneMinRadius = 5f;
private const float ZoneMaxRadius = 150f;
/// <summary>
/// The longest a zone may stand: seven days, the lease ceiling (D96). A zone is priced
/// by its minutes, so it has a deadline, and the deadline is kept HERE — a zone never
/// outlives its event even when the website that opened it has gone.
/// </summary>
private const long ZoneMaxHoldMs = 7L * 24L * 60L * 60L * 1000L;
private const string WorldKindZone = "zone";
private const string WorldKindCrate = "crate";
private const string WorldKindNpc = "npc";
/// <summary>One thing this plugin will place, by the key the website names it with.</summary>
private sealed class WorldPrefab
{
public readonly string Key;
public readonly string Kind;
public readonly string Path;
public readonly string Label;
public WorldPrefab(string key, string kind, string path, string label)
{
Key = key;
Kind = kind;
Path = path;
Label = label;
}
}
/// <summary>
/// The allowlist (D88), and the only one: the sidecar forwards and the website offers
/// what this answers. Crates and NPCs, never vehicles — a player can take a vehicle, and
/// a revert would kill something a person may be sitting in. Every path here was
/// spawned on the rig and reported its type before it was listed (§28.1).
/// </summary>
private static readonly WorldPrefab[] WorldPrefabs =
{
new WorldPrefab("crate.basic", WorldKindCrate, "assets/bundled/prefabs/radtown/crate_basic.prefab", "Basic crate"),
new WorldPrefab("crate.normal", WorldKindCrate, "assets/bundled/prefabs/radtown/crate_normal.prefab", "Military crate"),
new WorldPrefab("crate.normal2", WorldKindCrate, "assets/bundled/prefabs/radtown/crate_normal_2.prefab", "Crate"),
new WorldPrefab("crate.elite", WorldKindCrate, "assets/bundled/prefabs/radtown/crate_elite.prefab", "Elite crate"),
new WorldPrefab("crate.tools", WorldKindCrate, "assets/bundled/prefabs/radtown/crate_tools.prefab", "Tool box"),
new WorldPrefab("crate.hackable", WorldKindCrate, "assets/prefabs/deployable/chinooklockedcrate/codelockedhackablecrate.prefab", "Locked crate (hackable)"),
new WorldPrefab("supply.drop", WorldKindCrate, "assets/prefabs/misc/supply drop/supply_drop.prefab", "Supply drop"),
new WorldPrefab("barrel.loot", WorldKindCrate, "assets/bundled/prefabs/radtown/loot_barrel_1.prefab", "Loot barrel"),
new WorldPrefab("npc.scientist", WorldKindNpc, "assets/rust.ai/agents/npcplayer/humannpc/scientist/scientistnpc_roam.prefab", "Scientist"),
new WorldPrefab("npc.scientist.heavy", WorldKindNpc, "assets/rust.ai/agents/npcplayer/humannpc/scientist/scientistnpc_heavy.prefab", "Heavy scientist"),
new WorldPrefab("npc.scientist.tethered", WorldKindNpc, "assets/rust.ai/agents/npcplayer/humannpc/scientist/scientistnpc_roamtethered.prefab", "Scientist (stays put)"),
new WorldPrefab("npc.bandit.guard", WorldKindNpc, "assets/rust.ai/agents/npcplayer/humannpc/banditguard/npc_bandit_guard.prefab", "Bandit guard")
};
/// <summary>One thing an event made. Persisted as it is, so every field is public and plain.</summary>
private sealed class WorldEntry
{
/// <summary>The net id for an entity, as text; the zone id for a zone.</summary>
public string Id;
public string Kind;
/// <summary>The allowlist key, for a crate or an NPC.</summary>
public string Prefab;
public string RunId;
/// <summary>The website's idempotency key for the step that made it.</summary>
public string Key;
public float X, Y, Z;
/// <summary>A zone's radius and name, for re-creating it.</summary>
public float Radius;
public string Name;
/// <summary>When a zone is erased without being asked, on this host's clock (D96). Zero for an entity.</summary>
public long DeadlineMs;
public string BootId;
public string WipeId;
public long TakenMs;
}
/// <summary>Everything events have made on this map, by id. Main thread only.</summary>
private Dictionary<string, WorldEntry> _world = new Dictionary<string, WorldEntry>();
/// <summary>A counter for zone ids, so two zones one run opens in one tick never collide.</summary>
private int _zoneSerial;
/// <summary>
/// Whether the world is loaded for THIS load of the plugin — set at
/// <c>OnServerInitialized</c>, which fires at the end of a real start and at once on a
/// hotload. Until then every world command is refused <c>not-ready</c>.
///
/// <para>
/// Found by the phase 13a walk, and it is the worst kind of wrong: the link starts in
/// <c>Init</c>, BEFORE the save loads, so a website that saw the new boot id asked
/// <c>world.owned</c> into an empty world — and the plugin, finding no entity behind
/// any net id, pruned three live crates from its own registry. They stayed in the
/// world owned by nothing, and a revert would have refused them. An empty world at
/// boot is not an answer; it is the question arriving too early.
/// </para>
/// </summary>
private bool _worldReady;
private string _lastWorldSummary = "none yet";
private Timer _worldTimer;
private static WorldPrefab FindWorldPrefab(string key)
{
foreach (WorldPrefab p in WorldPrefabs)
{
if (string.Equals(p.Key, key, StringComparison.OrdinalIgnoreCase))
return p;
}
return null;
}
private Plugin ZoneManagerPlugin()
{
return plugins.Find("ZoneManager");
}
/// <summary>
/// The map's monuments in the one order every caller agrees on: grouped by prefab short
/// name, then by position. <c>#n</c> in a value counts within its group, so it names the
/// same monument every time it is asked on this map (D93).
/// </summary>
private static List<KeyValuePair<string, List<MonumentInfo>>> MonumentGroups()
{
var byKind = new SortedDictionary<string, List<MonumentInfo>>(StringComparer.Ordinal);
List<MonumentInfo> all = TerrainMeta.Path != null ? TerrainMeta.Path.Monuments : null;
if (all != null)
{
foreach (MonumentInfo m in all)
{
if (m == null || string.IsNullOrEmpty(m.name))
continue;
string kind = Path.GetFileNameWithoutExtension(m.name).ToLowerInvariant();
List<MonumentInfo> list;
if (!byKind.TryGetValue(kind, out list))
{
list = new List<MonumentInfo>();
byKind[kind] = list;
}
list.Add(m);
}
}
var groups = new List<KeyValuePair<string, List<MonumentInfo>>>();
foreach (KeyValuePair<string, List<MonumentInfo>> kv in byKind)
{
kv.Value.Sort((a, b) =>
{
int cx = a.transform.position.x.CompareTo(b.transform.position.x);
return cx != 0 ? cx : a.transform.position.z.CompareTo(b.transform.position.z);
});
groups.Add(kv);
}
return groups;
}
/// <summary>
/// Main thread. A monument value — <c>kind</c> or <c>kind#n</c> — to the monument it
/// names on this map, or null with a reason. A bare kind that repeats is its first
/// instance, so a step written against a map where it was unique still means something
/// on one where it is not.
/// </summary>
private static MonumentInfo ResolveMonument(string value, out string refusal)
{
refusal = null;
string text = Lower(value);
if (string.IsNullOrEmpty(text))
{
refusal = "no monument was named";
return null;
}
int instance = 1;
string kind = text;
int hash = text.LastIndexOf('#');
if (hash > 0)
{
kind = text.Substring(0, hash);
if (!int.TryParse(text.Substring(hash + 1), out instance) || instance < 1)
{
refusal = $"'{value}' is not a monument, followed by an instance number";
return null;
}
}
foreach (KeyValuePair<string, List<MonumentInfo>> kv in MonumentGroups())
{
if (kv.Key != kind)
continue;
if (instance > kv.Value.Count)
{
refusal = $"this map has {kv.Value.Count} {kind}, so there is no #{instance}";
return null;
}
return kv.Value[instance - 1];
}
refusal = $"this map has no {kind}";
return null;
}
private static double? DoubleField(Dictionary<string, object> row, string key)
{
object value;
if (row == null || !row.TryGetValue(key, out value) || value == null)
return null;
double parsed;
return double.TryParse(
value.ToString(), System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out parsed) && !double.IsNaN(parsed) && !double.IsInfinity(parsed)
? parsed
: (double?)null;
}
/// <summary>
/// Main thread. Where a step puts its thing: a monument (plus an offset), or raw world
/// coordinates — exactly one of the two (D87). Returns false with a reason and the
/// refusal code that goes with it.
/// </summary>
private bool ResolveWorldPoint(Dictionary<string, object> cmd, out UnityEngine.Vector3 point, out string reason, out string refusal)
{
point = UnityEngine.Vector3.zero;
reason = null;
refusal = null;
string monument = StringField(cmd, "monument");
double? x = DoubleField(cmd, "x");
double? z = DoubleField(cmd, "z");
double? y = DoubleField(cmd, "y");
bool byMonument = !string.IsNullOrEmpty(monument);
bool byCoords = x.HasValue || z.HasValue;
if (byMonument == byCoords)
{
reason = "malformed";
refusal = "a location is a monument or x and z, and exactly one of them";
return false;
}
if (byMonument)
{
MonumentInfo m = ResolveMonument(monument, out refusal);
if (m == null)
{
reason = "no-monument";
return false;
}
double dx = DoubleField(cmd, "offsetX") ?? 0.0;
double dz = DoubleField(cmd, "offsetZ") ?? 0.0;
if (Math.Sqrt(dx * dx + dz * dz) > WorldMaxOffset)
{
reason = "out-of-range";
refusal = $"an offset from a monument is at most {WorldMaxOffset} m";
return false;
}
point = m.transform.position + new UnityEngine.Vector3((float)dx, 0f, (float)dz);
point.y = TerrainMeta.HeightMap.GetHeight(point);
return true;
}
if (!x.HasValue || !z.HasValue)
{
reason = "malformed";
refusal = "coordinates need both x and z";
return false;
}
float half = ConVar.Server.worldsize / 2f;
if (Math.Abs(x.Value) > half || Math.Abs(z.Value) > half)
{
reason = "off-map";
refusal = $"({x.Value:0}, {z.Value:0}) is off this map, which runs from -{half:0} to {half:0}";
return false;
}
point = new UnityEngine.Vector3((float)x.Value, 0f, (float)z.Value);
point.y = y.HasValue ? (float)y.Value : TerrainMeta.HeightMap.GetHeight(point);
return true;
}
/// <summary>Main thread. Refuses a world command until the world is loaded (see <c>_worldReady</c>).</summary>
private bool WorldReady(string reqId)
{
if (_worldReady)
return true;
EmitWorldError(reqId, "not-ready",
"the server is still loading its world; ask again once it has started");
return false;
}
private bool WorldWritesAllowed(string reqId)
{
if (!WorldReady(reqId))
return false;
if (_config != null && _config.EventsEnabled)
return true;
// One consent for everything an event does to this world unattended (D94). Giving
// back, listing and asking what is still out there are never behind it.
EmitWorldError(reqId, "events-disabled",
"events are switched off on this server — set EventsEnabled in the RunicGateway plugin config");
return false;
}
/// <summary>
/// Main thread. The ids a key already made, or null. A repeat of a key the registry has
/// seen is a retry whose first answer was lost, and it is answered with the first
/// call's ids rather than a second set of things (§28.1).
/// </summary>
private List<WorldEntry> EntriesForKey(string key)
{
if (string.IsNullOrEmpty(key))
return null;
List<WorldEntry> found = null;
foreach (WorldEntry e in _world.Values)
{
if (e.Key != key)
continue;
if (found == null)
found = new List<WorldEntry>();
found.Add(e);
}
return found;
}
private void HandleWorldMonuments(string reqId)
{
if (!WorldReady(reqId))
return;
var rows = new List<object>();
foreach (KeyValuePair<string, List<MonumentInfo>> kv in MonumentGroups())
{
for (int i = 0; i < kv.Value.Count; i++)
{
MonumentInfo m = kv.Value[i];
UnityEngine.Vector3 p = m.transform.position;
string phrase = m.displayPhrase != null ? m.displayPhrase.english : null;
var row = new Dictionary<string, object>
{
{ "value", kv.Value.Count > 1 ? kv.Key + "#" + (i + 1) : kv.Key },
{ "kind", kv.Key },
{ "instance", i + 1 },
{ "of", kv.Value.Count },
{ "label", string.IsNullOrEmpty(phrase) ? kv.Key : phrase },
{ "x", Math.Round(p.x, 1) },
{ "z", Math.Round(p.z, 1) },
{ "grid", Grid(p) }
};
rows.Add(row);
}
}
Dictionary<string, object> reply = Frame("world.monuments", "reply");
reply["worldSize"] = ConVar.Server.worldsize;
reply["monuments"] = rows;
reply["prefabs"] = WorldPrefabRows();
reply["eventsEnabled"] = _config != null && _config.EventsEnabled;
reply["maxCrates"] = _config != null ? _config.EventsMaxCrates : WorldHardMaxCrates;
reply["maxNpcs"] = _config != null ? _config.EventsMaxNpcs : WorldHardMaxNpcs;
reply["zoneManager"] = ZoneManagerPlugin() != null;
if (reqId != null)
reply["reqId"] = reqId;
EmitFrame(reply);
}
private static List<object> WorldPrefabRows()
{
var rows = new List<object>();
foreach (WorldPrefab p in WorldPrefabs)
{
rows.Add(new Dictionary<string, object>
{
{ "key", p.Key },
{ "kind", p.Kind },
{ "label", p.Label }
});
}
return rows;
}
/// <summary>Main thread. Open a zone an event owns.</summary>
private void HandleWorldZone(Dictionary<string, object> cmd, string reqId)
{
if (!WorldWritesAllowed(reqId))
return;
string key = StringField(cmd, "key");
string runId = StringField(cmd, "runId");
List<WorldEntry> repeat = EntriesForKey(key);
if (repeat != null)
{
EmitWorldPlaced(reqId, repeat, true);
return;
}
if (string.IsNullOrEmpty(runId))
{
EmitWorldError(reqId, "malformed", "a zone is opened for a run");
return;
}
Plugin zm = ZoneManagerPlugin();
if (zm == null)
{
EmitWorldError(reqId, "zonemanager-missing", "ZoneManager is not loaded on this server");
return;
}
double radius = DoubleField(cmd, "radius") ?? 0.0;
if (radius < ZoneMinRadius || radius > ZoneMaxRadius)
{
EmitWorldError(reqId, "out-of-range",
$"a zone's radius is {ZoneMinRadius} to {ZoneMaxRadius} m, not {radius}");
return;
}
UnityEngine.Vector3 point;
string reason, refusal;
if (!ResolveWorldPoint(cmd, out point, out reason, out refusal))
{
EmitWorldError(reqId, reason, refusal);
return;
}
long holdMs = LongField(cmd, "holdMs");
if (holdMs <= 0 || holdMs > ZoneMaxHoldMs)
{
EmitWorldError(reqId, "out-of-range",
$"a zone stands for 1 minute to {ZoneMaxHoldMs / 60000L} minutes, not {holdMs / 60000L}");
return;
}
string name = StringField(cmd, "name");
if (string.IsNullOrEmpty(name))
name = "Event zone";
if (name.Length > 64)
name = name.Substring(0, 64);
var entry = new WorldEntry
{
Id = $"rg-{ShortRun(runId)}-{NowMs() % 100000000L}-{++_zoneSerial}",
Kind = WorldKindZone,
RunId = runId,
Key = key,
X = point.x,
Y = point.y,
Z = point.z,
Radius = (float)radius,
Name = name,
DeadlineMs = NowMs() + holdMs,
BootId = _bootId,
WipeId = _wipeId,
TakenMs = NowMs()
};
if (!CreateZone(zm, entry))
{
EmitWorldError(reqId, "refused", "ZoneManager did not create the zone");
return;
}
_world[entry.Id] = entry;
SaveWorld();
EnsureWorldTimer();
_lastWorldSummary = $"zone {entry.Id} r={radius:0} for {holdMs / 60000L} min, run {runId}";
Puts($"world: {_lastWorldSummary}");
EmitWorldPlaced(reqId, new List<WorldEntry> { entry }, false);
}
private static string ShortRun(string runId)
{
var chars = new StringBuilder();
foreach (char c in runId ?? "")
{
if (char.IsLetterOrDigit(c))
chars.Append(c);
if (chars.Length >= 12)
break;
}
return chars.Length > 0 ? chars.ToString() : "run";
}
/// <summary>
/// Main thread. Creates, or re-creates, one zone from its record, and says whether
/// ZoneManager now has it. Our plugin is the owner, so ZoneManager itself refuses to
/// let another plugin's zone be overwritten under the same id.
/// </summary>
private bool CreateZone(Plugin zm, WorldEntry entry)
{
string[] args =
{
"name", entry.Name ?? "Event zone",
"radius", entry.Radius.ToString(System.Globalization.CultureInfo.InvariantCulture)
};
object created = zm.Call("CreateOrUpdateTemporaryZone", this, entry.Id, args, new UnityEngine.Vector3(entry.X, entry.Y, entry.Z));
return created is bool && (bool)created && zm.Call("CheckZoneID", entry.Id) != null;
}
/// <summary>
/// Main thread. Puts back every zone the registry holds that ZoneManager does not (D92).
/// Called on this plugin's load, on ZoneManager's, and from <c>world.owned</c>.
/// </summary>
private void RecreateZones(string why)
{
// At a real start ZoneManager loads before the world does and initialises its own
// zones at OnTerrainInitialized; ours wait for OnServerInitialized, which calls here.
if (!_worldReady)
return;
Plugin zm = ZoneManagerPlugin();
if (zm == null)
return;
int restored = 0, failed = 0;
foreach (WorldEntry e in _world.Values)
{
// A zone past its deadline is the timer's to end, not this loop's to revive.
if (e.Kind != WorldKindZone || ZoneExpired(e) || zm.Call("CheckZoneID", e.Id) != null)
continue;
if (CreateZone(zm, e))
restored++;
else
failed++;
}
if (restored > 0 || failed > 0)
{
_lastWorldSummary = $"zones after {why}: {restored} re-created, {failed} failed";
Puts($"world: {_lastWorldSummary}");
}
}
/// <summary>Main thread. Place crates or NPCs an event owns, one registry entry per net id.</summary>
private void HandleWorldPlace(Dictionary<string, object> cmd, string reqId)
{
if (!WorldWritesAllowed(reqId))
return;
string key = StringField(cmd, "key");
string runId = StringField(cmd, "runId");
List<WorldEntry> repeat = EntriesForKey(key);
if (repeat != null)
{
EmitWorldPlaced(reqId, repeat, true);
return;
}
if (string.IsNullOrEmpty(runId))
{
EmitWorldError(reqId, "malformed", "a placement is made for a run");
return;
}
WorldPrefab prefab = FindWorldPrefab(StringField(cmd, "prefab"));
if (prefab == null)
{
EmitWorldError(reqId, "unknown-prefab",
$"'{StringField(cmd, "prefab")}' is not something this server places for events");
return;
}
int count = IntField(cmd, "count");
int max = prefab.Kind == WorldKindNpc
? (_config != null ? _config.EventsMaxNpcs : WorldHardMaxNpcs)
: (_config != null ? _config.EventsMaxCrates : WorldHardMaxCrates);
if (count < 1 || count > max)
{
EmitWorldError(reqId, "out-of-range",
$"this server places 1 to {max} {(prefab.Kind == WorldKindNpc ? "NPCs" : "crates")} at a time, not {count}");
return;
}
double spread = DoubleField(cmd, "spread") ?? (count > 1 ? 10.0 : 0.0);
if (spread < 0 || spread > WorldMaxSpread)
{
EmitWorldError(reqId, "out-of-range", $"a scatter is 0 to {WorldMaxSpread} m, not {spread}");
return;
}
UnityEngine.Vector3 center;
string reason, refusal;
if (!ResolveWorldPoint(cmd, out center, out reason, out refusal))
{
EmitWorldError(reqId, reason, refusal);
return;
}
bool explicitY = DoubleField(cmd, "y").HasValue;
var made = new List<WorldEntry>();
for (int i = 0; i < count; i++)
{
UnityEngine.Vector3 at = center;
if (count > 1)
{
UnityEngine.Vector2 jitter = UnityEngine.Random.insideUnitCircle * (float)spread;
at += new UnityEngine.Vector3(jitter.x, 0f, jitter.y);
if (!explicitY)
at.y = TerrainMeta.HeightMap.GetHeight(at);
}
BaseEntity entity = GameManager.server.CreateEntity(prefab.Path, at);
if (entity == null)
{
// All or nothing: a group half of which exists is not what the step asked for,
// and the half that does exist has no record yet to be given back through.
foreach (WorldEntry e in made)
KillEntity(e.Id);
EmitWorldError(reqId, "refused", $"the game could not create {prefab.Label}");
return;
}
entity.Spawn();
made.Add(new WorldEntry
{
Id = entity.net.ID.Value.ToString(System.Globalization.CultureInfo.InvariantCulture),
Kind = prefab.Kind,
Prefab = prefab.Key,
RunId = runId,
Key = key,
X = at.x,
Y = at.y,
Z = at.z,
BootId = _bootId,
WipeId = _wipeId,
TakenMs = NowMs()
});
}
foreach (WorldEntry e in made)
_world[e.Id] = e;
SaveWorld();
_lastWorldSummary = $"placed {count} {prefab.Key} for run {runId}";
Puts($"world: {_lastWorldSummary}");
EmitWorldPlaced(reqId, made, false);
}
private static BaseNetworkable FindEntity(string id)
{
ulong value;
if (!ulong.TryParse(id, out value))
return null;
BaseNetworkable found = BaseNetworkable.serverEntities.Find(new NetworkableId(value));
return found != null && !found.IsDestroyed ? found : null;
}
/// <summary>Main thread. Whether the entity was there to kill; either way it is not now.</summary>
private static bool KillEntity(string id)
{
BaseNetworkable found = FindEntity(id);
if (found == null)
return false;
found.Kill();
return true;
}
/// <summary>
/// Main thread. Give back what a run owns. Never behind <c>EventsEnabled</c>: switching
/// events off must not strand anything already placed (D94).
///
/// <para>
/// Named ids first; else everything under the key (core lost the answer to a dispatch
/// and knows only its key); else everything the run owns. An id already gone is a
/// success. <b>An id the registry does not hold for this run is refused</b> — this
/// bridge erases only what it made, whatever ZoneManager would allow (§12.4).
/// </para>
/// </summary>
private void HandleWorldRevert(Dictionary<string, object> cmd, string reqId)
{
// Before the world loads, every entity looks gone, and a revert would forget things
// that are about to exist again.
if (!WorldReady(reqId))
return;
string runId = StringField(cmd, "runId");
string key = StringField(cmd, "key");
var ids = new List<string>(Strings(cmd, "ids"));
if (string.IsNullOrEmpty(runId))
{
EmitWorldError(reqId, "malformed", "a revert names the run whose things to give back");
return;
}
var targets = new List<WorldEntry>();
var refused = new List<object>();
var gone = new List<object>();
Plugin zm = ZoneManagerPlugin();
if (ids.Count > 0)
{
foreach (string id in ids)
{
WorldEntry e;
if (_world.TryGetValue(id, out e))
{
if (e.RunId == runId)
targets.Add(e);
else
refused.Add(id);
continue;
}
// Not on record. That is either something a wipe or a prune already took —
// gone, which is a success — or something this bridge never made. Only a
// thing that is actually THERE can be the second, and only that is refused.
bool exists = id.StartsWith("rg-", StringComparison.Ordinal)
? zm != null && zm.Call("CheckZoneID", id) != null
: FindEntity(id) != null;
(exists ? refused : gone).Add(id);
}
}
else
{
foreach (WorldEntry e in _world.Values)
{
if (e.RunId == runId && (string.IsNullOrEmpty(key) || e.Key == key))
targets.Add(e);
}
}
var removed = new List<object>();
foreach (WorldEntry e in targets)
{
bool wasThere;
if (e.Kind == WorldKindZone)
{
// A zone ZoneManager does not have (unloaded, or already erased) is gone —
// it lives in memory only — and its record is all there is left to remove.
wasThere = zm != null && zm.Call("CheckZoneID", e.Id) != null;
if (wasThere)
zm.Call("EraseTemporaryZone", this, e.Id);
}
else
{
wasThere = KillEntity(e.Id);
}
_world.Remove(e.Id);
(wasThere ? removed : gone).Add(e.Id);
}
if (targets.Count > 0)
SaveWorld();
_lastWorldSummary = $"revert run {runId}: {removed.Count} removed, {gone.Count} already gone, {refused.Count} refused";
Puts($"world: {_lastWorldSummary}");
Dictionary<string, object> reply = Frame("world.ok", "reply");
reply["removed"] = removed;
reply["gone"] = gone;
reply["refused"] = refused;
if (reqId != null)
reply["reqId"] = reqId;
EmitFrame(reply);
}
/// <summary>
/// Main thread. What the world still holds of what events made — looked at, never
/// inferred (§28.1). An entity that is gone is pruned as the walk passes it. A zone is
/// put back if ZoneManager has lost it; while ZoneManager is not loaded a zone is
/// reported held, because "I cannot ask" is not "it is gone", and its load will put
/// the zone back.
/// </summary>
private void HandleWorldOwned(Dictionary<string, object> cmd, string reqId)
{
if (!WorldReady(reqId))
return;
string runId = StringField(cmd, "runId");
RecreateZones("owned");
Plugin zm = ZoneManagerPlugin();
var rows = new List<object>();
List<string> pruned = null;
foreach (WorldEntry e in _world.Values)
{
if (!string.IsNullOrEmpty(runId) && e.RunId != runId)
continue;
bool held;
if (e.Kind == WorldKindZone)
held = zm == null || zm.Call("CheckZoneID", e.Id) != null;
else
held = FindEntity(e.Id) != null;
if (!held)
{
if (pruned == null)
pruned = new List<string>();
pruned.Add(e.Id);
continue;
}
rows.Add(WorldRow(e));
}
if (pruned != null)
{
foreach (string id in pruned)
_world.Remove(id);
SaveWorld();
}
Dictionary<string, object> reply = Frame("world.owned", "reply");
reply["owned"] = rows;
reply["pruned"] = pruned != null ? pruned.Count : 0;
if (reqId != null)
reply["reqId"] = reqId;
EmitFrame(reply);
}
private static Dictionary<string, object> WorldRow(WorldEntry e)
{
var row = new Dictionary<string, object>
{
{ "id", e.Id },
{ "kind", e.Kind },
{ "runId", e.RunId },
{ "x", Math.Round(e.X, 1) },
{ "y", Math.Round(e.Y, 1) },
{ "z", Math.Round(e.Z, 1) }
};
if (!string.IsNullOrEmpty(e.Prefab))
row["prefab"] = e.Prefab;
if (e.Kind == WorldKindZone)
{
row["radius"] = e.Radius;
row["name"] = e.Name;
row["remainingMs"] = Math.Max(0L, e.DeadlineMs - NowMs());
}
return row;
}
private void EmitWorldPlaced(string reqId, List<WorldEntry> entries, bool repeat)
{
var placed = new List<object>();
foreach (WorldEntry e in entries)
placed.Add(WorldRow(e));
Dictionary<string, object> reply = Frame("world.ok", "reply");
reply["placed"] = placed;
reply["repeat"] = repeat;
if (reqId != null)
reply["reqId"] = reqId;
EmitFrame(reply);
}
private void EmitWorldError(string reqId, string reason, string message)
{
Dictionary<string, object> frame = Frame("world.error", "reply");
frame["reason"] = reason;
if (!string.IsNullOrEmpty(message))
frame["message"] = message;
if (reqId != null)
frame["reqId"] = reqId;
_lastWorldSummary = $"refused ({reason}): {message}";
EmitFrame(frame);
}
private string WorldPath()
{
return Path.Combine(Interface.Oxide.DataDirectory, Name, "world.json");
}
/// <summary>Main thread. Written on every change, like the lease holds and for the same reason.</summary>
private void SaveWorld()
{
try
{
string path = WorldPath();
Directory.CreateDirectory(Path.GetDirectoryName(path));
var rows = new List<WorldEntry>(_world.Values);
string tmp = path + ".tmp";
File.WriteAllText(tmp, JsonConvert.SerializeObject(rows, Formatting.Indented), Utf8NoBom);
if (File.Exists(path))
File.Delete(path);
File.Move(tmp, path);
}
catch (Exception ex)
{
Puts($"could not save the world registry: {Flatten(ex.Message)}");
}
}
/// <summary>
/// Main thread, once per load. Reads the registry and nothing more: whether each thing is
/// still out there is asked when somebody wants to know, because at a real server start
/// this runs before the save has loaded and every entity would look gone.
/// </summary>
private void LoadWorld()
{
_world = new Dictionary<string, WorldEntry>();
List<WorldEntry> rows = null;
try
{
string path = WorldPath();
if (File.Exists(path))
rows = JsonConvert.DeserializeObject<List<WorldEntry>>(File.ReadAllText(path));
}
catch (Exception ex)
{
Puts($"could not read the world registry: {Flatten(ex.Message)}");
}
if (rows == null)
return;
foreach (WorldEntry e in rows)
{
if (e != null && !string.IsNullOrEmpty(e.Id))
_world[e.Id] = e;
}
Puts($"world: {_world.Count} things events made are on record");
// Due now or later; the first tick ends any zone whose deadline passed while the
// server was down, before anything could re-create it.
EnsureWorldTimer();
}
private static bool ZoneExpired(WorldEntry e)
{
return e.Kind == WorldKindZone && e.DeadlineMs > 0 && e.DeadlineMs <= NowMs();
}
private void EnsureWorldTimer()
{
if (_worldTimer != null)
return;
foreach (WorldEntry e in _world.Values)
{
if (e.Kind == WorldKindZone)
{
_worldTimer = timer.Every(LeaseCheckSeconds, CheckZoneDeadlines);
return;
}
}
}
private void StopWorldTimer()
{
if (_worldTimer != null)
{
_worldTimer.Destroy();
_worldTimer = null;
}
}
/// <summary>
/// Main thread, every second while a zone stands. The fail-safe D96 bought: a zone whose
/// time is up is erased without anybody asking, and says so — the same promise a lease
/// makes, for the same reason.
/// </summary>
private void CheckZoneDeadlines()
{
List<WorldEntry> due = null;
bool anyZone = false;
foreach (WorldEntry e in _world.Values)
{
if (e.Kind != WorldKindZone)
continue;
anyZone = true;
if (!ZoneExpired(e))
continue;
if (due == null)
due = new List<WorldEntry>();
due.Add(e);
}
if (!anyZone)
{
StopWorldTimer();
return;
}
if (due == null)
return;
Plugin zm = ZoneManagerPlugin();
foreach (WorldEntry e in due)
{
if (zm != null && zm.Call("CheckZoneID", e.Id) != null)
zm.Call("EraseTemporaryZone", this, e.Id);
_world.Remove(e.Id);
Dictionary<string, object> frame = Frame("world.expired", "event");
frame["id"] = e.Id;
frame["kind"] = e.Kind;
if (!string.IsNullOrEmpty(e.RunId))
frame["runId"] = e.RunId;
EmitFrame(frame);
}
SaveWorld();
_lastWorldSummary = $"{due.Count} zone(s) reached their deadline and were erased";
Puts($"world: {_lastWorldSummary}");
}
/// <summary>
/// Main thread. Drops everything recorded against another wipe — that map, and all of
/// it, is gone. Only callable once the wipe is known, which a real server start does not
/// know until <c>OnServerInitialized</c>.
/// </summary>
private void DropWorldFromOtherWipes()
{
if (string.IsNullOrEmpty(_wipeId) || _world.Count == 0)
return;
var stale = new List<string>();
foreach (WorldEntry e in _world.Values)
{
if (!string.IsNullOrEmpty(e.WipeId) && e.WipeId != _wipeId)
stale.Add(e.Id);
}
if (stale.Count == 0)
return;
foreach (string id in stale)
_world.Remove(id);
SaveWorld();
_lastWorldSummary = $"a new wipe dropped {stale.Count} things from the old map";
Puts($"world: {_lastWorldSummary}");
}
/// <summary>What events have made on this map, from the server console.</summary>
[ConsoleCommand("rg.world")]
private void CmdWorld(ConsoleSystem.Arg arg)
{
int zones = 0, crates = 0, npcs = 0;
foreach (WorldEntry e in _world.Values)
{
if (e.Kind == WorldKindZone)
zones++;
else if (e.Kind == WorldKindNpc)
npcs++;
else
crates++;
}
arg.ReplyWith(
$"protocol={ProtocolVersion} eventsEnabled={(_config != null && _config.EventsEnabled)} " +
$"zones={zones} crates={crates} npcs={npcs} zoneManager={(ZoneManagerPlugin() != null)} " +
$"wipeId={(_wipeId ?? "none")}" + Environment.NewLine +
$"last: {_lastWorldSummary}");
}
// ---- reading a command's fields ----
//
// `JsonConvert` hands back `object`, and every field below arrives as one. These are the
// three shapes this protocol uses, read defensively: a malformed field is a missing field,
// never an exception on the main thread.
private static string StringField(Dictionary<string, object> row, string key)
{
object value;
return row != null && row.TryGetValue(key, out value) && value != null
? value.ToString()
: null;
}
private static int IntField(Dictionary<string, object> row, string key)
{
object value;
if (row == null || !row.TryGetValue(key, out value) || value == null)
return 0;
int parsed;
return int.TryParse(value.ToString(), out parsed) ? parsed : 0;
}
private static long LongField(Dictionary<string, object> row, string key)
{
object value;
if (row == null || !row.TryGetValue(key, out value) || value == null)
return 0L;
long parsed;
return long.TryParse(value.ToString(), out parsed) ? parsed : 0L;
}
/// <summary>The objects in an array field, skipping anything that is not one.</summary>
private static IEnumerable<Dictionary<string, object>> Rows(Dictionary<string, object> row, string key)
{
object value;
if (row == null || !row.TryGetValue(key, out value))
yield break;
var list = value as Newtonsoft.Json.Linq.JArray;
if (list == null)
yield break;
foreach (Newtonsoft.Json.Linq.JToken item in list)
{
var obj = item as Newtonsoft.Json.Linq.JObject;
if (obj == null)
continue;
yield return obj.ToObject<Dictionary<string, object>>();
}
}
/// <summary>The strings in an array field.</summary>
private static IEnumerable<string> Strings(Dictionary<string, object> row, string key)
{
object value;
if (row == null || !row.TryGetValue(key, out value))
yield break;
var list = value as Newtonsoft.Json.Linq.JArray;
if (list == null)
yield break;
foreach (Newtonsoft.Json.Linq.JToken item in list)
{
if (item == null || item.Type == Newtonsoft.Json.Linq.JTokenType.Null)
continue;
yield return item.ToString();
}
}
/// <summary>
/// Permission and group names are compared case-insensitively because both frameworks
/// store them lowered. Comparing what the site sent against what the store holds
/// without this makes `Kits.VIP` and `kits.vip` two different privileges, one of which
/// is granted for ever and never found again.
/// </summary>
private static string Lower(string value)
{
return value == null ? null : value.Trim().ToLowerInvariant();
}
private static HashSet<string> Bucket(Dictionary<string, HashSet<string>> map, string key)
{
HashSet<string> bucket;
if (!map.TryGetValue(key, out bucket))
{
bucket = new HashSet<string>();
map[key] = bucket;
}
return bucket;
}
// ---- diagnostics ----
/// <summary>
/// The link's own counters, from the server console. This is how an operator answers
/// "is the bridge working" without reading a log, and it is the first thing to ask for
/// when the website says a server is offline.
/// </summary>
[ConsoleCommand("rg.link")]
private void CmdLink(ConsoleSystem.Arg arg)
{
arg.ReplyWith(
$"protocol={ProtocolVersion} serverId={(_config != null ? _config.ServerId : "?")} " +
$"connected={_connected} depth={Volatile.Read(ref _depth)} " +
$"sent={Interlocked.Read(ref _sent)} dropped={Interlocked.Read(ref _dropped)} " +
$"received={Interlocked.Read(ref _received)} connects={Interlocked.Read(ref _connects)} " +
$"writeErrors={Interlocked.Read(ref _writeErrors)} bootId={_bootId} " +
$"wipeId={(_wipeId ?? "none")}");
}
/// <summary>
/// What the last permission sync did, from the server console.
///
/// <para>
/// It is the fastest way to tell the two silent failures apart on a rig: a sync that
/// reported `unresolved` names granted nothing because no loaded plugin registered
/// them, and a sync that reported `pending` members could not put somebody in a group
/// because the store has never seen them. Both look like "the website says they have
/// it and the game disagrees" from the other end.
/// </para>
/// </summary>
[ConsoleCommand("rg.perms")]
private void CmdPerms(ConsoleSystem.Arg arg)
{
string[] groups = permission.GetGroups();
string[] registered = permission.GetPermissions();
arg.ReplyWith(
$"protocol={ProtocolVersion} registered={(registered != null ? registered.Length : 0)} " +
$"groups={(groups != null ? groups.Length : 0)} " +
$"running={(_permJob != null)}" + Environment.NewLine +
$"last sync: {_lastPermSummary}");
}
/// <summary>
/// Where this framework keeps configuration, and what the last write from the website
/// did.
///
/// <para>
/// The root is the interesting half. It is <c>oxide/config</c> on one rig and
/// <c>carbon/configs</c> on the other, from this same source file, and an operator
/// wondering why the website is offering a tree they do not recognise is usually
/// looking at a server whose directories were moved.
/// </para>
/// </summary>
[ConsoleCommand("rg.config")]
private void CmdConfig(ConsoleSystem.Arg arg)
{
string root = ConfigRoot();
arg.ReplyWith(
$"protocol={ProtocolVersion} framework={(IsCarbon() ? "carbon" : "oxide")} " +
$"root={(string.IsNullOrEmpty(root) ? "(none)" : root)}" + Environment.NewLine +
$"waiting={(_configJob != null ? _configJob.Reload : "no")}" + Environment.NewLine +
$"last write: {_lastConfigSummary}");
}
/// <summary>
/// Which of this plugin's hooks have actually fired, and how often.
///
/// <para>
/// Both frameworks bind hooks by name and arity through reflection, with no
/// compile-time check and no warning when a name matches nothing. So a hook Facepunch
/// renames on a Thursday, and a hook one framework's catalogue does not list, both
/// present identically: silence. This command is the standing answer to both, on
/// whichever framework the server is actually running — and it outranks either
/// catalogue, because it is a measurement rather than a document.
/// </para>
///
/// <para>
/// A zero is not automatically a fault: <c>OnNewSave</c> fires on wipe day and
/// <c>OnUserBanned</c> only when somebody is banned. It is a fault when the thing it
/// names has demonstrably happened.
/// </para>
/// </summary>
[ConsoleCommand("rg.hooks")]
private void CmdHooks(ConsoleSystem.Arg arg)
{
var fired = new List<string>();
var silent = new List<string>();
foreach (string hook in ExpectedHooks)
{
long count;
_hookCounts.TryGetValue(hook, out count);
if (count > 0L)
fired.Add($"{hook}={count}");
else
silent.Add(hook);
}
string firedText = fired.Count > 0 ? string.Join(" ", fired.ToArray()) : "(none)";
string silentText = silent.Count > 0 ? string.Join(" ", silent.ToArray()) : "(none)";
arg.ReplyWith(
$"protocol={ProtocolVersion} hooks={ExpectedHooks.Length} " +
$"fired={fired.Count} silent={silent.Count}" + Environment.NewLine +
$"fired: {firedText}" + Environment.NewLine +
$"silent: {silentText}");
}
}
}