Files
Rust-Plugins/overlay/oxide/plugins/RunicGateway.cs
wtclaude e5f04e0c0c fix(bridge): Rust's animals and heals count toward titles again (runicnpc 9d)
- Animal kills: Rust's animals are now Rust.Ai.Gen2.BaseNPC2 (stag, boar, wolf,
  bears, big cats, crocodile, farm animals, critters, sea life), not BaseNpc, so
  no animal kill counted as an animal or an NPC kill. They count as both again;
  ScientistNPC2, the one person among them, counts as a human NPC kill.
- Heals: OnHealingItemUse is (tool, fromPlayer, toTarget). The bridge took two
  parameters, received the healer as the target, and dropped every heal as
  healing yourself. It now reads the target, and one act of help (a syringe
  revive raises both hooks, the heal first) counts once whichever hook comes
  first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-07 13:41:57 -05:00

13675 lines
537 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 = 13;
/// <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>
/// How long a link must stay up before its drop counts as an outage rather than a
/// refusal. The backoff resets only past it: a sidecar that closes every connection at
/// once — one refusing a plugin that names another server (D155), or one crash-looping —
/// would otherwise be redialled twice a second for ever, rebuilding the boards on the
/// main thread and printing two console lines each time.
/// </summary>
private const int StableLinkMs = 10000;
/// <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;
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",
"OnDispenserBonus", "OnCollectiblePickup", "OnGrowableGathered",
"OnPlayerReported", "OnUserBanned", "OnUserUnbanned",
"CanUserLogin", "OnUserApproved",
"OnUserPermissionGranted", "OnUserPermissionRevoked",
"OnUserGroupAdded", "OnUserGroupRemoved",
"OnGroupPermissionGranted", "OnGroupPermissionRevoked",
"OnPluginLoaded", "OnPluginUnloaded",
"OnClanCreated", "OnClanDisbanded", "OnClanMemberAdded",
"OnClanMemberLeft", "OnClanMemberKicked", "OnClanColorChanged",
"OnKitRedeemed",
"OnEntitySpawned", "OnEntityKill",
"OnEntityTakeDamage", "OnItemCraftFinished", "OnEntityBuilt", "OnStructureRepair",
"OnHealingItemUse", "OnPlayerRevive", "OnRocketLaunched",
"OnExplosiveThrown", "OnExplosiveDropped", "OnMissionSucceeded",
"OnEnterZone", "OnExitZone", "OnRgDomesReady",
"OnRunicNpcDied", "OnRunicNpcHealth", "OnRunicNpcPlacementChanged",
"OnRunicNpcBossSpawned", "OnRunicNpcBossPhase", "OnRunicNpcBossDied"
};
private void Init()
{
foreach (string hook in ExpectedHooks)
_hookCounts[hook] = 0L;
_bootId = ResolveBootId();
_wipeId = ResolveWipeId();
// Before the link thread, not in `Loaded`: both frameworks call `Init` first, and a
// thread started here with `_config` still null dials the 7799 fallback. With one
// server per host that is its own sidecar by coincidence; with two it is the OTHER
// server's, whose sidecar then files this server's history under that one's id
// (found by the phase 18 walk, D154).
LoadConfigValues();
_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();
// The same for an inventory being built: the site's request times out and it asks
// again from page 0, which is the only way it ever reads one.
_inventoryJob = null;
_inventory = null;
StopInventoryTimer();
// 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. No `config.outcome` follows; the website calls a
// write it never heard about lost once twice `ceilingMs` has passed, 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();
// A tally is kept across a reload — it records who was there, and the next load
// carries on counting — so what it has counted since the last minute's save is written
// now rather than lost.
StopTallyTimer();
if (_talliesDirty)
SaveTallies();
_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;
/// <summary>
/// The most people one reward step may name (protocol 10, §29.5). The website reads
/// it and refuses a step over it; it is never trimmed to fit.
/// </summary>
public int EventsMaxRecipients = RecipientsHardMax;
/// <summary>The most tallies that may count at once on this server (§29.5).</summary>
public int EventsMaxTallies = TallyHardMax;
/// <summary>The longest line an event or a news post may say in chat, in characters.</summary>
public int EventsMaxChat = ChatHardMax;
/// <summary>
/// The most offline sleepers one map answer carries (protocol 11). Over it, the layer
/// answers the first this many and says it was cut short.
/// </summary>
public int MapMaxSleepers = MapDefaultMax;
/// <summary>The most tool cupboards and vending machines one map answer carries.</summary>
public int MapMaxBases = MapDefaultMax;
}
/// <summary>
/// Runs only when this plugin has no config file yet — so it is the one moment a server id
/// can arrive from outside the file (D150). A Pterodactyl egg cannot write a framework's
/// config directory (Carbon lets an operator move it), but it can set an environment
/// variable, and `RUSTLINK_SERVER_ID` is the one the sidecar already reads for its
/// cross-check. After this write the file is canonical and the website's lock applies,
/// so changing the variable later changes nothing — which is what keeps a server's
/// history attached to one id.
/// </summary>
protected override void LoadDefaultConfig()
{
var config = new PluginConfig();
string fromEnv = Environment.GetEnvironmentVariable("RUSTLINK_SERVER_ID");
if (!string.IsNullOrEmpty(fromEnv))
{
if (IsValidServerId(fromEnv))
config.ServerId = fromEnv;
else
PrintWarning($"RUSTLINK_SERVER_ID '{fromEnv}' is not a valid server id " +
$"(lowercase letters, digits and '-', 1-64, not starting with '-'); " +
$"the new config keeps '{config.ServerId}'");
}
Config.WriteObject(config, true);
}
/// <summary>The website's rule for a server id, `^[a-z0-9][a-z0-9-]{0,63}$`, without a Regex.</summary>
private static bool IsValidServerId(string id)
{
if (string.IsNullOrEmpty(id) || id.Length > 64 || id[0] == '-')
return false;
foreach (char c in id)
{
bool ok = (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-';
if (!ok)
return false;
}
return 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));
_config.EventsMaxRecipients = Math.Max(0, Math.Min(_config.EventsMaxRecipients, RecipientsHardMax));
_config.EventsMaxTallies = Math.Max(0, Math.Min(_config.EventsMaxTallies, TallyHardMax));
_config.EventsMaxChat = Math.Max(0, Math.Min(_config.EventsMaxChat, ChatHardMax));
_config.MapMaxSleepers = Math.Max(0, _config.MapMaxSleepers);
_config.MapMaxBases = Math.Max(0, _config.MapMaxBases);
if (Config["EventsEnabled"] == null || Config["EventsMaxCrates"] == null || Config["EventsMaxNpcs"] == null ||
Config["EventsMaxRecipients"] == null || Config["EventsMaxTallies"] == null || Config["EventsMaxChat"] == null ||
Config["MapMaxSleepers"] == null || Config["MapMaxBases"] == null)
Config.WriteObject(_config, true);
}
private void Loaded()
{
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();
LoadRewards();
}
// ---- 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;
System.Diagnostics.Stopwatch upFor = 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
upFor = System.Diagnostics.Stopwatch.StartNew();
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;
// A link that held is an outage: start again from the shortest wait. One that was
// closed on arrival keeps doubling (see StableLinkMs).
if (upFor != null && upFor.ElapsedMilliseconds >= StableLinkMs)
backoffMs = 500;
// Nothing is listening yet, or the sidecar restarted. Both are normal.
//
// A handle, 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 `_stopping`, so waiting on it turns that stall into
// an immediate return.
//
// `_stopping`, not `Wake`, for the reason `_stopping` gives: `Emit` signals `Wake`
// on every frame, so a backoff waited on it lasted until the next hook fired. That
// was invisible while a failed connect was silent; against a sidecar that refuses
// (D155) the phase 18 walk measured some 250 redials a second, each printing two
// console lines and rebuilding the boards.
_stopping.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;
}
}
}
/// <summary>
/// Reader thread. Splits the stream into lines as <b>bytes</b> and decodes each finished
/// line as UTF-8.
///
/// <para>
/// Bytes, not characters, because a character can straddle two reads: the first half of
/// an em dash at the end of one buffer and the rest at the start of the next. Splitting
/// on the byte is safe where splitting the text is not — UTF-8 never uses <c>0x0A</c>
/// inside a multi-byte sequence, so a newline byte is always a newline — and decoding
/// the whole line at once is what keeps the halves together. The old loop cast each byte
/// to a <c>char</c>, which is Latin-1, and turned every non-ASCII character the website
/// sent into two or three wrong ones: announcements, titles, kit names, and the text of
/// every configuration file saved from the site (F15).
/// </para>
/// </summary>
private void ReadLoop(NetworkStream stream, int epoch)
{
byte[] buffer = new byte[8192];
var line = new MemoryStream(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++)
{
byte b = buffer[i];
if (b == (byte)'\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(Encoding.UTF8.GetString(line.GetBuffer(), 0, (int)line.Length));
}
line.SetLength(0);
}
else if (b != (byte)'\r')
{
if (discarding)
continue;
line.WriteByte(b);
if (line.Length > MaxInboundLineBytes)
{
Puts("inbound line over the cap; discarding to the next newline");
line.SetLength(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 "perm.inventory":
// Protocol 13 (D160). The whole store, owners included, in pages — what the
// site reads before every sync now that it owns all of it.
HandlePermInventory(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;
case "tally.open":
// Protocol 10 (PLAN.md §29). Who takes part in a run, counted by the game.
HandleTallyOpen(cmd, reqId);
break;
case "tally.snapshot":
HandleTallySnapshot(cmd, reqId);
break;
case "tally.close":
HandleTallyClose(cmd, reqId);
break;
case "kits.list":
HandleKitsList(reqId);
break;
case "chat.say":
HandleChatSay(cmd, reqId);
break;
case "titles.set":
// Protocol 12. The chat titles players have earned, as one whole set that
// replaces the last (PLAN.md §33).
HandleTitlesSet(cmd, reqId);
break;
case "map.info":
// Protocol 11 (PLAN.md §30). What this map is and where its picture comes from.
HandleMapInfo(reqId);
break;
case "map.fetch":
HandleMapFetch(cmd, reqId);
break;
case "map.render":
HandleMapRender(cmd, reqId);
break;
case "map.live":
HandleMapLive(reqId);
break;
case "npc.profiles":
// Protocol 13, RunicNPC (docs/runicnpc/PLAN.md stage 4). The server's profiles,
// read before the site's first push so it can adopt them (D244).
HandleNpcProfiles(reqId);
break;
case "npc.profiles.set":
HandleNpcProfilesSet(cmd, reqId);
break;
case "npc.placements":
HandleNpcPlacements(reqId);
break;
case "npc.placement":
// One change to one placement, by `op` (D245, D246).
HandleNpcPlacement(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;
// The ZoneManager helper (D182), only where there is a ZoneManager for it to help.
// `patched` is the whole fix; anything else means this plugin scores its zones by
// position and ZoneManager's flags miss somebody already inside a new zone.
if (ZoneManagerPlugin() != null)
{
var helper = new Dictionary<string, object> { { "state", ZoneHelperState() } };
Plugin zones = ZoneHelperPlugin();
if (zones != null)
{
helper["version"] = zones.Version.ToString();
if (zones.Call("StateReason") is string reason)
helper["reason"] = reason;
}
hello["zoneHelper"] = helper;
// D211: ZoneManager's own flag list, for the site's zone presets (§3.1). In hello
// rather than every status frame, because it changes only when ZoneManager does,
// and a ZoneManager load sends hello again.
var zoneManager = new Dictionary<string, object> { { "version", ZoneManagerPlugin().Version.ToString() } };
List<string> flags = ZoneFlagNames(ZoneManagerPlugin());
if (flags != null)
zoneManager["flags"] = flags;
hello["zoneManager"] = zoneManager;
}
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", SteamIdOf(player) },
{ "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.
}
// Protocol 12: which of the optional mods this server has, so Admin → Rust can say why
// a title or a popup is not showing before anybody goes looking (§33.2).
status["integrations"] = IntegrationsBody();
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 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 = WeaponOf(info);
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"] = SteamIdOf(attacker);
frame["attackerName"] = attacker.displayName;
}
else
{
frame["attackerType"] = "npc";
frame["attackerName"] = initiator.ShortPrefabName;
// One of RunicNPC's: its own name, as the victim's death screen shows it, and its
// profile. Beside `attackerName`, not over it, so a reader that knows only prefab
// names still reads a scientist (runicnpc PLAN.md §1.4, stage 4).
string profile = RunicNpcProfileOf(initiator);
if (profile != null && attacker != null)
{
frame["attackerNpc"] = attacker.displayName;
frame["attackerProfile"] = profile;
}
}
float distance = DistanceOf(info, victim);
if (distance > 0f)
frame["distance"] = (float)Math.Round(distance, 1);
}
/// <summary>
/// The killing blow's weapon as the killfeed names it: the held entity's short prefab
/// name (<c>bow_hunting.entity</c>), or what was thrown or placed. Null when the hit
/// carries neither. The tally's weapon kills (§5.3) read this same name, so the lists
/// the site keeps are lists of what the killfeed already shows.
/// </summary>
private static string WeaponOf(HitInfo info)
{
if (info == null)
return null;
if (info.WeaponPrefab != null)
return info.WeaponPrefab.ShortPrefabName;
return info.Weapon != null ? info.Weapon.ShortPrefabName : null;
}
/// <summary>
/// How far the killing blow travelled, in metres: the projectile's own distance, or the
/// gap between the two when there was no projectile. 0 when neither can be read.
/// </summary>
private static float DistanceOf(HitInfo info, BaseEntity victim)
{
if (info == null)
return 0f;
float distance = info.ProjectileDistance;
BaseEntity initiator = info.Initiator;
if (distance <= 0f && initiator != null && initiator.transform != null
&& victim != null && victim.transform != null)
distance = UnityEngine.Vector3.Distance(
initiator.transform.position, victim.transform.position);
return distance;
}
// ---- 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 about 150 m
/// 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);
}
/// <summary>
/// The game's own label for a position, from <c>MapHelper</c> — the function the in-game
/// map draws its grid with (D119).
///
/// <para>
/// Phases 3 to 13 computed this here from a 146.3 m cell, the number community tools
/// quote. It is wrong: the game fits a whole number of cells to the world, so a 3000 map
/// is 20 cells of exactly 150 m, and the constant put every row below the first a row
/// too far south (the ferry terminal read O17 where the game says O16). Nobody could
/// see it without a second opinion, and the map's grid was the second opinion.
/// </para>
/// </summary>
private static string Grid(UnityEngine.Vector3 position)
{
if (ConVar.Server.worldsize <= 0)
return null;
try
{
return MapHelper.PositionToString(position);
}
catch (Exception)
{
return null;
}
}
// ---- 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;
SeedRegisteredPerms();
DropWorldFromOtherWipes();
RecreateZones("load");
// The map's sets are seeded here for the same reason: a hotload hears no spawns.
SeedMapSets();
// The titles this plugin last held, if they are this wipe's (D144), and then the getter
// handed to BetterChat, if it is here. When it loads later, or reloads and forgets us,
// OnPluginLoaded does this again (§33.1).
LoadTitles();
RegisterTitles("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();
if (_talliesDirty)
SaveTallies();
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();
// A run's tally is NOT dropped: it records people, not the map, and a zone it counted
// in simply stops gaining anybody. Kit credits are kept too — unless Kits is about to
// clear its use counts, when the ones moved into them go with it (§29.3).
ResetCreditsForWipe();
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"] = SteamIdOf(player);
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"] = SteamIdOf(player);
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"] = SteamIdOf(player);
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;
// A tally's player kill (D98): the last hit, never your own.
BasePlayer killer = info != null ? info.InitiatorPlayer : null;
if (killer != null && !killer.IsNpc && (ulong)killer.userID != (ulong)player.userID)
{
CreditKill(killer, true);
// The kill itself is `player.death`'s, counted by the site from this frame; the
// tally adds only what a title needs about it (§5.1).
TallyFor(killer).Killed(info, player, true);
}
Dictionary<string, object> frame = Frame("player.death", "event");
frame["steamId"] = SteamIdOf(player);
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"] = SteamIdOf(player);
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);
}
// ---- the rest of what a player gathers (F1, D159) ----
//
// `OnDispenserGather` alone is the swings. The first walk had 825 wood in the inventory
// and 587 on the tally, and 20 cloth from hemp and none on it: the bonus on a tree's final
// hit is `OnDispenserBonus`, and anything picked up off the ground — hemp, mushrooms,
// stones, a wild pumpkin — is `OnCollectiblePickup`. A farmed plant is
// `OnGrowableGathered`. D159: "gathered" is everything a player harvests, so the
// leaderboard agrees with the inventory. All three are counters (rule 2) and void (rule 1).
/// <summary>The bonus on the final hit of a tree or a node. Void: an Item return replaces the bonus.</summary>
private void OnDispenserBonus(ResourceDispenser dispenser, BasePlayer player, Item item)
{
MarkHook("OnDispenserBonus");
if (player == null || player.IsNpc || item == null || item.info == null)
return;
TallyFor(player).Gather(item.info.shortname, item.amount);
}
/// <summary>
/// A pick-up off the ground. Void: a non-null return cancels it. It fires BEFORE the
/// items move, so what is counted is the collectible's list — and a pick-up another
/// plugin vetoes is counted anyway, which is a rarity worth less than a second hook.
/// </summary>
private void OnCollectiblePickup(CollectibleEntity collectible, BasePlayer player)
{
MarkHook("OnCollectiblePickup");
if (player == null || player.IsNpc || collectible == null || collectible.itemList == null)
return;
Tally tally = TallyFor(player);
foreach (ItemAmount amount in collectible.itemList)
{
if (amount == null || amount.itemDef == null)
continue;
tally.Gather(amount.itemDef.shortname, (int)amount.amount);
}
}
/// <summary>A farmed plant, harvested. No return behaviour on either framework.</summary>
private void OnGrowableGathered(GrowableEntity plant, Item item, BasePlayer player)
{
MarkHook("OnGrowableGathered");
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;
// Before the early return below: a patrol helicopter usually dies by crashing, and a
// crash's HitInfo names no player (§5.4).
if (IsVehicleKill(entity))
{
CreditVehicle(entity, attacker);
return;
}
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)
{
Tally tally = TallyFor(attacker);
tally.NpcKills++;
tally.Killed(info, entity, false);
string profile = RunicNpcProfileOf(victimPlayer);
if (profile != null)
tally.KilledProfile(profile);
CreditKill(attacker, false);
}
return;
}
// An animal is an NPC to a run's tally exactly as it is to the leaderboard (§29.5):
// two meanings of one word would be two answers to "how many NPCs did I kill".
// `animalKills` counts it AGAIN, separately (§5.2): the public NPC column keeps its
// meaning mid-wipe, and the site takes the human NPCs as the difference.
//
// Rust's own animals are now `Rust.Ai.Gen2.BaseNPC2` — stag, boar, wolf, bears, big cats,
// crocodile, farm animals, critters, sea life — and not `BaseNpc`, which is all this
// tested until the RunicNPC 9d walk found no animal kill had counted at all. `BaseNpc`
// stays for whatever still derives from it. `ScientistNPC2` is the one person among
// them, so it is a human NPC kill, not an animal.
if (entity is Rust.Ai.Gen2.ScientistNPC2)
{
Tally tally = TallyFor(attacker);
tally.NpcKills++;
tally.Killed(info, entity, false);
CreditKill(attacker, false);
return;
}
if (entity is BaseNpc || entity is Rust.Ai.Gen2.BaseNPC2)
{
Tally tally = TallyFor(attacker);
tally.NpcKills++;
tally.AnimalKills++;
tally.Killed(info, entity, false);
CreditKill(attacker, false);
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.
//
// **But not your own** (F3). The first walk's org lead broke their own wall and the
// next tally carried `structures: 1`. Taking down a piece you placed, or one on a
// cupboard you are authorised on, is building, not raiding. The frame still goes: the
// site already skips the raid ALERT for an authorised attacker (D59), and a staff feed
// that loses a line because the player happened to own it is a feed with a hole.
if (structure == "block" && !OwnsOrAuthed(attacker, entity))
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);
}
// ---- what a chat title counts (PLAN_REDESIGNS §5, D172–D174) ----
//
// Every one of these is a counter (rule 2) and void (rule 1). Three of them can veto —
// `OnStructureRepair`, `OnHealingItemUse`, `OnPlayerRevive` — and `OnEntityTakeDamage`
// can cancel the damage outright, which is exactly why none of them returns anything.
/// <summary>How long a player's hit on a vehicle still earns its kill when the blow that ends it names nobody (§5.4).</summary>
private const float VehicleCreditSeconds = 60f;
/// <summary>The last player to hurt each APC or helicopter, by net id. A handful at most.</summary>
private readonly Dictionary<ulong, KeyValuePair<ulong, float>> _vehicleHits =
new Dictionary<ulong, KeyValuePair<ulong, float>>();
/// <summary>The APC and the two helicopters: the three things a title credits a kill of (D174).</summary>
private static bool IsVehicleKill(BaseEntity entity)
{
return entity is BradleyAPC || entity is PatrolHelicopter || entity is CH47Helicopter;
}
/// <summary>
/// Void: a non-null return cancels the damage. It fires for every hit on everything,
/// so the type test is the first thing it does; only a hit on one of the three
/// vehicles is remembered, and only who dealt it and when.
/// </summary>
private void OnEntityTakeDamage(BaseCombatEntity entity, HitInfo info)
{
MarkHook("OnEntityTakeDamage");
if (entity == null || info == null || !IsVehicleKill(entity) || entity.net == null)
return;
BasePlayer player = info.InitiatorPlayer;
if (player == null || player.IsNpc)
return;
float now = UnityEngine.Time.realtimeSinceStartup;
// A helicopter that flies off the map is never killed, so its row is never removed
// at death; a stale one is dropped here instead.
if (_vehicleHits.Count > 16)
{
var stale = new List<ulong>();
foreach (KeyValuePair<ulong, KeyValuePair<ulong, float>> hit in _vehicleHits)
if (now - hit.Value.Value > VehicleCreditSeconds)
stale.Add(hit.Key);
foreach (ulong id in stale)
_vehicleHits.Remove(id);
}
_vehicleHits[entity.net.ID.Value] = new KeyValuePair<ulong, float>(player.userID, now);
}
/// <summary>
/// The killing blow's player (D174), or, when the blow names nobody — a helicopter
/// crashing — the last player who hurt it within <see cref="VehicleCreditSeconds"/>.
/// </summary>
private void CreditVehicle(BaseCombatEntity entity, BasePlayer attacker)
{
KeyValuePair<ulong, float> last = default(KeyValuePair<ulong, float>);
bool hit = entity.net != null && _vehicleHits.TryGetValue(entity.net.ID.Value, out last);
if (entity.net != null)
_vehicleHits.Remove(entity.net.ID.Value);
BasePlayer credited = attacker != null && !attacker.IsNpc ? attacker : null;
if (credited == null && hit && UnityEngine.Time.realtimeSinceStartup - last.Value <= VehicleCreditSeconds)
credited = BasePlayer.FindByID(last.Key) ?? BasePlayer.FindSleeping(last.Key);
if (credited == null)
return;
Tally tally = TallyFor(credited);
if (entity is BradleyAPC)
tally.ApcKills++;
else
tally.HeliKills++;
}
/// <summary>
/// No return behaviour. A crafted shirt, rifle or anything else, by its item category.
/// Who crafted it is the crafter's owner: the task no longer carries one (read off the
/// rig, 2026-09-29).
/// </summary>
private void OnItemCraftFinished(ItemCraftTask task, Item item, ItemCrafter crafter)
{
MarkHook("OnItemCraftFinished");
BasePlayer player = crafter != null ? crafter.owner : null;
if (player == null || player.IsNpc || item == null || item.info == null)
return;
TallyFor(player).Craft(item.info.category.ToString().ToLowerInvariant(), item.amount);
}
/// <summary>No return behaviour. Only a building block is a structure built; a deployable is not.</summary>
private void OnEntityBuilt(Planner planner, UnityEngine.GameObject go)
{
MarkHook("OnEntityBuilt");
BasePlayer player = planner != null ? planner.GetOwnerPlayer() : null;
if (player == null || player.IsNpc || go == null || go.GetComponent<BuildingBlock>() == null)
return;
TallyFor(player).Built++;
}
/// <summary>
/// Void: a non-null return refuses the repair. It fires on every hammer hit, BEFORE the
/// game decides whether there is anything to repair, the resources to pay for it, or a
/// recent attack that forbids it. So the health is read now and again next tick, and
/// only a hit that actually raised it is a repair.
/// </summary>
private void OnStructureRepair(BaseCombatEntity entity, BasePlayer player)
{
MarkHook("OnStructureRepair");
if (entity == null || player == null || player.IsNpc)
return;
float before = entity.health;
ulong id = player.userID;
NextTick(() =>
{
if (entity == null || entity.IsDestroyed || entity.health <= before)
return;
BasePlayer still = BasePlayer.FindByID(id);
if (still != null)
TallyFor(still).Repaired++;
});
}
// A syringe on a downed player raises `OnHealingItemUse` and then, from inside the same
// `GiveEffectsTo`, `OnPlayerRevive`; one act of help is one heal. The last one counted is
// remembered for the frame it happened in, and the other hook skips the same pair.
private ulong _helpHealer;
private ulong _helpTarget;
private int _helpFrame = -1;
/// <summary>Counts one act of help unless it is yourself, an NPC, or the other hook already did this frame.</summary>
private void CountHelp(BasePlayer healer, BasePlayer player)
{
if (healer == null || healer.IsNpc || player == null || player.IsNpc
|| (ulong)healer.userID == (ulong)player.userID)
return;
int frame = UnityEngine.Time.frameCount;
if (_helpFrame == frame && _helpHealer == (ulong)healer.userID && _helpTarget == (ulong)player.userID)
return;
_helpHealer = healer.userID;
_helpTarget = player.userID;
_helpFrame = frame;
TallyFor(healer).Healed++;
}
/// <summary>
/// Void: a non-null return refuses the heal. Healing yourself is not a heal (D174).
///
/// <para>
/// Rust's `MedicalTool.GiveEffectsTo(fromPlayer, toTarget)` raises it as
/// <c>(tool, fromPlayer, toTarget)</c>. Until the RunicNPC 9d walk this took two
/// parameters, so the second — the healer — arrived as the target, every heal looked like
/// healing yourself, and none counted. The target is <c>object</c>: `IMedicalToolTarget`
/// is not always a player, and only a player healed counts.
/// </para>
/// </summary>
private void OnHealingItemUse(MedicalTool tool, BasePlayer fromPlayer, object target)
{
MarkHook("OnHealingItemUse");
CountHelp(fromPlayer ?? (tool != null ? tool.GetOwnerPlayer() : null), target as BasePlayer);
}
/// <summary>Void: a non-null return refuses the revive. Reviving yourself is not possible, and is not counted.</summary>
private void OnPlayerRevive(BasePlayer reviver, BasePlayer player)
{
MarkHook("OnPlayerRevive");
CountHelp(reviver, player);
}
/// <summary>No return behaviour. One rocket leaving a launcher.</summary>
private void OnRocketLaunched(BasePlayer player, BaseEntity entity)
{
MarkHook("OnRocketLaunched");
if (player == null || player.IsNpc)
return;
TallyFor(player).Rockets++;
}
/// <summary>No return behaviour. A grenade, beancan, satchel or C4 thrown.</summary>
private void OnExplosiveThrown(BasePlayer player, BaseEntity entity, ThrownWeapon item)
{
MarkHook("OnExplosiveThrown");
CountExplosive(player, entity);
}
/// <summary>No return behaviour. The same, dropped at your feet rather than thrown.</summary>
private void OnExplosiveDropped(BasePlayer player, BaseEntity entity, ThrownWeapon item)
{
MarkHook("OnExplosiveDropped");
CountExplosive(player, entity);
}
/// <summary>
/// A supply signal and a smoke grenade are thrown through the same hooks, and neither
/// is an explosive. What is one is a timed explosive that does damage.
/// </summary>
private void CountExplosive(BasePlayer player, BaseEntity entity)
{
if (player == null || player.IsNpc)
return;
TimedExplosive explosive = entity as TimedExplosive;
if (explosive == null || explosive is SupplySignal || explosive.damageTypes == null)
return;
foreach (Rust.DamageTypeEntry damage in explosive.damageTypes)
{
if (damage != null && damage.amount > 0f)
{
TallyFor(player).Explosives++;
return;
}
}
}
/// <summary>
/// No return behaviour. A mission one of the game's NPCs handed out, finished (D173). A
/// failed or abandoned one raises a different hook, and is never counted.
/// </summary>
private void OnMissionSucceeded(BaseMission mission, BaseMission.MissionInstance instance, BasePlayer assignee)
{
MarkHook("OnMissionSucceeded");
// The game hands the assignee over: `MissionSuccess(instance, assignee)`. The instance
// itself has no accessor for it (read off the rig, 2026-09-29).
if (assignee == null || assignee.IsNpc)
return;
TallyFor(assignee).Missions++;
}
/// <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"] = SteamIdOf(reporter);
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;
// Protocol 13, the chat-title conditions (PLAN_REDESIGNS §5.1). Sums unless named a best.
public int AnimalKills;
public readonly Dictionary<string, int> WeaponKills = new Dictionary<string, int>();
public int Headshots;
/// <summary>The longest player kill this interval, in metres: a best, not a sum.</summary>
public float BestPvpM;
/// <summary>The longest NPC or animal kill this interval: a best, not a sum.</summary>
public float BestPveM;
public int ApcKills;
public int HeliKills;
public readonly Dictionary<string, int> Crafted = new Dictionary<string, int>();
/// <summary>Stage 4 (D247): kills of RunicNPC's NPCs, by profile name. Also in <see cref="NpcKills"/>.</summary>
public readonly Dictionary<string, int> NpcProfileKills = new Dictionary<string, int>();
public int Built;
public int Repaired;
public int Healed;
public int Rockets;
public int Explosives;
public int Missions;
public void Gather(string shortname, int amount)
{
Add(Gathered, shortname, amount);
}
public void Craft(string category, int amount)
{
Add(Crafted, category, amount);
}
public void KilledProfile(string profile)
{
Add(NpcProfileKills, profile, 1);
}
/// <summary>
/// What a credited kill adds for the titles: its weapon (any victim, D174), a
/// headshot, and its distance as this interval's best of its kind.
/// </summary>
public void Killed(HitInfo info, BaseEntity victim, bool victimIsPlayer)
{
Add(WeaponKills, WeaponOf(info), 1);
if (info != null && info.isHeadshot)
Headshots++;
float distance = DistanceOf(info, victim);
if (victimIsPlayer)
BestPvpM = Math.Max(BestPvpM, distance);
else
BestPveM = Math.Max(BestPveM, distance);
}
private static void Add(Dictionary<string, int> into, string key, int amount)
{
if (string.IsNullOrEmpty(key) || amount <= 0)
return;
int have;
into.TryGetValue(key, out have);
into[key] = have + amount;
}
public bool IsEmpty()
{
return Gathered.Count == 0 && NpcKills == 0 && Structures == 0
&& AnimalKills == 0 && WeaponKills.Count == 0 && Headshots == 0
&& BestPvpM <= 0f && BestPveM <= 0f && ApcKills == 0 && HeliKills == 0
&& Crafted.Count == 0 && Built == 0 && Repaired == 0 && Healed == 0
&& Rockets == 0 && Explosives == 0 && Missions == 0 && NpcProfileKills.Count == 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;
// The title conditions (§5.1), each absent when nothing moved, as the three above.
if (tally.AnimalKills > 0)
frame["animalKills"] = tally.AnimalKills;
if (tally.WeaponKills.Count > 0)
frame["weaponKills"] = tally.WeaponKills;
if (tally.Headshots > 0)
frame["headshots"] = tally.Headshots;
if (tally.BestPvpM > 0f)
frame["bestPvpM"] = (float)Math.Round(tally.BestPvpM, 1);
if (tally.BestPveM > 0f)
frame["bestPveM"] = (float)Math.Round(tally.BestPveM, 1);
if (tally.ApcKills > 0)
frame["apcKills"] = tally.ApcKills;
if (tally.HeliKills > 0)
frame["heliKills"] = tally.HeliKills;
if (tally.Crafted.Count > 0)
frame["crafted"] = tally.Crafted;
if (tally.Built > 0)
frame["built"] = tally.Built;
if (tally.Repaired > 0)
frame["repaired"] = tally.Repaired;
if (tally.Healed > 0)
frame["healed"] = tally.Healed;
if (tally.Rockets > 0)
frame["rockets"] = tally.Rockets;
if (tally.Explosives > 0)
frame["explosives"] = tally.Explosives;
if (tally.Missions > 0)
frame["missions"] = tally.Missions;
if (tally.NpcProfileKills.Count > 0)
frame["npcProfileKills"] = tally.NpcProfileKills;
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 = SteamIdOf(player);
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 = SteamIdOf(player);
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 never touched by a sync. Until protocol 13 the
// sync scanned the names the site claimed and reported such holders `foreign` (D31).
// Now the site owns the whole store (D160) and reads it with `perm.inventory`, so it
// finds a hand edit itself and answers it with the server's policy (D161): the sync
// only ever carries what the site decided.
// • 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,
/// <summary>
/// Protocol 13: a group's parent (PLAN_REDESIGNS §1.4). Compiled after every group,
/// so the parent it names already exists when it is applied.
/// </summary>
EnsureGroupParent,
/// <summary>Protocol 12: a group's BetterChat style (D138).</summary>
EnsureChatStyle,
/// <summary>Protocol 12: a styled group's BetterChat group, gone from the site (D139).</summary>
RetireChatGroup
}
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>
/// Whether the site sent a rank. A group whose frame has none keeps the rank it has,
/// rather than being set to 0 by an absent field.
/// </summary>
public bool HasRank;
}
/// <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;
/// <summary>Protocol 13: an existing group whose title, rank or parent the sync changed.</summary>
public int GroupsUpdated;
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<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). Not applied; 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>
/// The site's kit credits for this server (protocol 10, D103), or null when the sync
/// carried none — an older website, whose silence must not withdraw anybody's credit.
/// </summary>
public List<KitCredit> Credits;
public int CreditsApplied;
public int CreditsWithdrawn;
public bool CreditsWaiting;
public readonly List<string> CreditsUnresolved = new List<string>();
/// <summary>
/// Whether BetterChat was loaded when this sync was compiled (protocol 12). When it
/// was not, no style op is compiled and the report says <c>loaded: false</c> and
/// nothing else: the permissions sync exactly as they did before.
/// </summary>
public bool ChatLoaded;
public string ChatVersion;
/// <summary>The fields the site wants on each styled group.</summary>
public readonly Dictionary<string, List<ChatFieldWant>> ChatStyles =
new Dictionary<string, List<ChatFieldWant>>();
public int ChatApplied;
public int ChatSaved;
public int ChatSame;
public readonly List<object> ChatDrift = new List<object>();
public readonly List<object> ChatRemoved = new List<object>();
public readonly List<object> ChatFailed = new List<object>();
}
/// <summary>
/// One BetterChat field the site wants on a group (protocol 12): the value, and the
/// value this site last pushed there.
/// </summary>
private sealed class ChatFieldWant
{
public string Field;
public string Value;
/// <summary>
/// What the site last pushed, or null for a field it has never pushed. A game value
/// that is neither this nor the one wanted is somebody's hand edit (D138).
/// </summary>
public string Expect;
}
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);
}
// ---- protocol 13: the whole store (PLAN_REDESIGNS §1.2, D160) ----
//
// The site owns every permission and group on this server, so it has to be able to read
// all of them — not just the names it once authored, which is all the foreign scan ever
// looked at. One verb, `perm.inventory`, answers with:
//
// • every registered permission and the plugin that REGISTERED it. The owner comes from
// `PermissionExists(name, plugin)` asked of each loaded plugin — public API on both
// frameworks, proven on both rigs — never from the name's prefix, which is how
// `zonemanager.ignoreflag.nokits` ends up filed under Kits in other tools.
// • every group with its title, rank, parent and the permissions it carries itself.
// • every Steam id that holds anything: its own permissions and its groups, except
// `default`, which every connected player is in by the framework's own rule.
// • the group permissions an event lease is holding, which are the lease's, not a hand
// edit's.
//
// Neither framework can list its users, so the holders come from asking each permission
// and each group who has it, and each of those walks every user. On a real store that is
// permissions × users, so it is built a slice per tick, like the sync drain, and then
// sent in pages that each fit the game link's 1 MiB line cap. The snapshot is taken once
// and paged from memory, so a grant made between two pages cannot tear it.
/// <summary>How many permissions or groups one build step asks about.</summary>
private const int InventorySourcesPerStep = 25;
/// <summary>How many bytes of rows one page may carry, well inside the 1 MiB line cap.</summary>
private const int InventoryPageBudgetBytes = 700 * 1024;
/// <summary>
/// How long a build may run before it is abandoned. The sidecar waits ten seconds for a
/// reply; a build past this has already lost that wait, and a store that large needs a
/// person to hear about it rather than a hitch every sync.
/// </summary>
private const long InventoryMaxBuildMs = 8000;
/// <summary>How long a finished snapshot is kept for the pages after the first.</summary>
private const long InventoryKeepMs = 2 * 60 * 1000;
private sealed class InventoryUser
{
public string SteamId;
public string Name;
public readonly List<string> Permissions = new List<string>();
public readonly List<string> Groups = new List<string>();
}
/// <summary>A build in flight. There is at most one; a second request is answered `busy`.</summary>
private sealed class InventoryJob
{
public string ReqId;
public long StartedMs;
/// <summary>Started from the server console (<c>rg.inventory</c>): logged, never sent.</summary>
public bool FromConsole;
/// <summary>Permissions first, then groups prefixed with a NUL, one store question each.</summary>
public readonly List<string> Sources = new List<string>();
public int Next;
public readonly Dictionary<string, InventoryUser> Users = new Dictionary<string, InventoryUser>();
public List<object> Permissions;
public List<object> Groups;
public List<object> Leased;
}
/// <summary>A finished snapshot, paged from memory.</summary>
private sealed class Inventory
{
public string Id;
public long BuiltMs;
public long BuildMs;
public List<object> Permissions;
public List<object> Groups;
public List<object> Leased;
public int UserCount;
public readonly List<List<object>> Pages = new List<List<object>>();
}
private InventoryJob _inventoryJob;
private Timer _inventoryTimer;
private Inventory _inventory;
/// <summary>The last inventory built, for <c>rg.perms</c>. Diagnostics only.</summary>
private string _lastInventorySummary = "none yet";
/// <summary>
/// The same build from the server console, logged rather than sent: how a rig proves the
/// read — and times it on a large store — without the website or the sidecar's token.
/// </summary>
[ConsoleCommand("rg.inventory")]
private void CmdInventory(ConsoleSystem.Arg arg)
{
if (arg.Connection != null)
return;
if (_inventoryJob != null)
{
arg.ReplyWith("an inventory is already being built");
return;
}
StartInventory(null, true);
arg.ReplyWith("inventory started; the summary is logged");
}
/// <summary>Main thread. Page 0 starts a build; any other page is read from the snapshot it names.</summary>
private void HandlePermInventory(Dictionary<string, object> cmd, string reqId)
{
string snapshotId = StringField(cmd, "snapshotId");
int page = IntField(cmd, "page");
if (!string.IsNullOrEmpty(snapshotId))
{
Inventory held = _inventory;
if (held == null || held.Id != snapshotId || NowMs() - held.BuiltMs > InventoryKeepMs ||
page < 0 || page >= held.Pages.Count)
{
// The site starts again from page 0; a stale page is never a partial answer.
EmitPermError(reqId, "stale");
return;
}
EmitInventoryPage(held, page, reqId);
return;
}
if (_inventoryJob != null)
{
EmitPermError(reqId, "busy");
return;
}
StartInventory(reqId, false);
}
/// <summary>Main thread. Reads the small parts at once and starts the sliced holder walk.</summary>
private void StartInventory(string reqId, bool fromConsole)
{
var job = new InventoryJob { ReqId = reqId, StartedMs = NowMs(), FromConsole = fromConsole };
// Owners, groups and leases are small and read at once; the holder walk is sliced.
var owner = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
string[] registered = permission.GetPermissions() ?? new string[0];
foreach (Plugin plugin in plugins.GetAll())
{
if (plugin == null)
continue;
foreach (string perm in registered)
{
if (!string.IsNullOrEmpty(perm) && !owner.ContainsKey(perm) && permission.PermissionExists(perm, plugin))
owner[perm] = plugin.Name;
}
}
job.Permissions = new List<object>();
foreach (string perm in registered)
{
if (string.IsNullOrEmpty(perm))
continue;
string lowered = Lower(perm);
var row = new Dictionary<string, object> { { "name", lowered } };
string by;
// A name no plugin owns — Carbon's built-in modules register theirs that way — is
// sent without one, and the site groups it by prefix.
if (owner.TryGetValue(perm, out by))
row["owner"] = by;
job.Permissions.Add(row);
job.Sources.Add(lowered);
}
job.Groups = new List<object>();
foreach (string name in permission.GetGroups() ?? new string[0])
{
if (string.IsNullOrEmpty(name))
continue;
string lowered = Lower(name);
var carried = new List<object>();
foreach (string perm in permission.GetGroupPermissions(name, false) ?? new string[0])
{
if (!string.IsNullOrEmpty(perm))
carried.Add(Lower(perm));
}
job.Groups.Add(new Dictionary<string, object>
{
{ "name", lowered },
{ "title", permission.GetGroupTitle(name) ?? "" },
{ "rank", permission.GetGroupRank(name) },
{ "parent", Lower(permission.GetGroupParent(name)) ?? "" },
{ "permissions", carried }
});
// `default` holds every player who ever connected, by the framework's rule rather
// than anybody's decision, so its members are not the site's to own.
if (!string.Equals(lowered, "default", StringComparison.Ordinal))
job.Sources.Add("\0" + lowered);
}
job.Leased = new List<object>();
foreach (LeaseHold hold in _leases.Values)
{
if (hold.Key != GroupPermissionKey || string.IsNullOrEmpty(hold.Target))
continue;
int slash = hold.Target.IndexOf('/');
if (slash <= 0)
continue;
job.Leased.Add(new Dictionary<string, object>
{
{ "kind", "group-permission" },
{ "subject", Lower(hold.Target.Substring(0, slash)) },
{ "object", Lower(hold.Target.Substring(slash + 1)) }
});
}
_inventoryJob = job;
_inventoryTimer = timer.Every(PermStepSeconds, StepInventory);
// A small store — every rig, and most real servers — finishes inside this call.
StepInventory();
}
/// <summary>Main thread. Asks up to <see cref="InventorySourcesPerStep"/> permissions or groups who holds them.</summary>
private void StepInventory()
{
InventoryJob job = _inventoryJob;
if (job == null)
{
StopInventoryTimer();
return;
}
if (NowMs() - job.StartedMs > InventoryMaxBuildMs)
{
_inventoryJob = null;
StopInventoryTimer();
Puts($"permission inventory abandoned after {InventoryMaxBuildMs} ms at {job.Next} of {job.Sources.Count} sources, {job.Users.Count} users");
if (!job.FromConsole)
EmitPermError(job.ReqId, "too-large");
return;
}
int budget = InventorySourcesPerStep;
while (budget-- > 0 && job.Next < job.Sources.Count)
{
string source = job.Sources[job.Next++];
try
{
bool isGroup = source.Length > 0 && source[0] == '\0';
string name = isGroup ? source.Substring(1) : source;
string[] entries = isGroup ? permission.GetUsersInGroup(name) : permission.GetPermissionUsers(name);
if (entries == null)
continue;
foreach (string entry in entries)
{
string id = UserId(entry);
if (string.IsNullOrEmpty(id))
continue;
InventoryUser user;
if (!job.Users.TryGetValue(id, out user))
{
user = new InventoryUser { SteamId = id, Name = StoredName(entry) };
job.Users[id] = user;
}
if (isGroup)
user.Groups.Add(name);
else
user.Permissions.Add(name);
}
}
catch (Exception ex)
{
// One odd name must not lose the whole read; the next sync reads it again.
Puts($"permission inventory: '{source.TrimStart('\0')}' threw: {Flatten(ex.Message)}");
}
}
if (job.Next < job.Sources.Count)
return;
_inventoryJob = null;
StopInventoryTimer();
Inventory done = Paginate(job);
_inventory = done;
_lastInventorySummary = $"{done.Id}: {done.Permissions.Count} permissions, {done.Groups.Count} groups, " +
$"{done.UserCount} users, {done.Pages.Count} pages, {done.BuildMs} ms at={DateTime.UtcNow:HH:mm:ss}Z";
if (job.FromConsole)
{
// To the log, where a rig walk can read it: the console's own reply never reaches
// Oxide's log file.
Puts($"inventory {_lastInventorySummary}");
var owners = new Dictionary<string, int>();
foreach (Dictionary<string, object> row in done.Permissions)
{
object by;
string key = row.TryGetValue("owner", out by) ? (string)by : "(none)";
int n;
owners.TryGetValue(key, out n);
owners[key] = n + 1;
}
var ownerText = new List<string>();
foreach (KeyValuePair<string, int> kv in owners)
ownerText.Add(kv.Key + "=" + kv.Value);
List<object> sample = done.Pages[0].GetRange(0, Math.Min(10, done.Pages[0].Count));
Puts("inventory owners: " + string.Join(", ", ownerText.ToArray()));
Puts("inventory groups: " + JsonConvert.SerializeObject(done.Groups, Formatting.None));
Puts("inventory leased: " + JsonConvert.SerializeObject(done.Leased, Formatting.None));
Puts("inventory users: " + JsonConvert.SerializeObject(sample, Formatting.None));
return;
}
EmitInventoryPage(done, 0, job.ReqId);
}
private void StopInventoryTimer()
{
if (_inventoryTimer == null)
return;
_inventoryTimer.Destroy();
_inventoryTimer = null;
}
/// <summary>
/// Splits the users into pages by their serialised size. Page 0 also carries the
/// permissions, groups and leases, so its budget is what they leave.
/// </summary>
private Inventory Paginate(InventoryJob job)
{
var inv = new Inventory
{
Id = Guid.NewGuid().ToString("N").Substring(0, 12),
BuiltMs = NowMs(),
BuildMs = NowMs() - job.StartedMs,
Permissions = job.Permissions,
Groups = job.Groups,
Leased = job.Leased,
UserCount = job.Users.Count
};
int header = Encoding.UTF8.GetByteCount(JsonConvert.SerializeObject(new Dictionary<string, object>
{
{ "permissions", job.Permissions },
{ "groups", job.Groups },
{ "leased", job.Leased }
}, Formatting.None));
var ids = new List<string>(job.Users.Keys);
ids.Sort(StringComparer.Ordinal);
var current = new List<object>();
int bytes = header;
foreach (string id in ids)
{
InventoryUser u = job.Users[id];
var row = new Dictionary<string, object>
{
{ "steamId", u.SteamId },
{ "permissions", u.Permissions },
{ "groups", u.Groups }
};
if (!string.IsNullOrEmpty(u.Name))
row["name"] = u.Name;
int size = Encoding.UTF8.GetByteCount(JsonConvert.SerializeObject(row, Formatting.None)) + 1;
if (current.Count > 0 && bytes + size > InventoryPageBudgetBytes)
{
inv.Pages.Add(current);
current = new List<object>();
bytes = 0;
}
current.Add(row);
bytes += size;
}
// Always at least one page, so an empty store still answers page 0.
inv.Pages.Add(current);
return inv;
}
private void EmitInventoryPage(Inventory inv, int page, string reqId)
{
Dictionary<string, object> reply = Frame("perm.inventory", "reply");
reply["snapshotId"] = inv.Id;
reply["page"] = page;
reply["pages"] = inv.Pages.Count;
reply["more"] = page + 1 < inv.Pages.Count;
reply["users"] = inv.Pages[page];
if (page == 0)
{
reply["permissions"] = inv.Permissions;
reply["groups"] = inv.Groups;
reply["leased"] = inv.Leased;
reply["stats"] = new Dictionary<string, object>
{
{ "permissions", inv.Permissions.Count },
{ "groups", inv.Groups.Count },
{ "users", inv.UserCount },
{ "buildMs", inv.BuildMs }
};
}
if (reqId != null)
reply["reqId"] = reqId;
EmitFrame(reply);
}
/// <summary>
/// The name out of a store listing entry — <c>76561198…(Wanderer)</c> or
/// <c>76561198… (Wanderer)</c> — or null. Between the FIRST bracket and the LAST, so a
/// name with brackets of its own survives.
/// </summary>
private static string StoredName(string entry)
{
if (string.IsNullOrEmpty(entry))
return null;
int open = entry.IndexOf('(');
int close = entry.LastIndexOf(')');
if (open < 0 || close <= open + 1)
return null;
string name = entry.Substring(open + 1, close - open - 1).Trim();
return name.Length > 0 && name != "Unnamed" ? name : null;
}
/// <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")
};
Plugin betterChat = BetterChatPlugin();
job.ChatLoaded = betterChat != null;
job.ChatVersion = betterChat != null ? betterChat.Version.ToString() : null;
// ── 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.
// Protocol 13: parents are applied after every group exists, because a group may name
// a parent that comes later in the frame.
var parentOps = new List<PermOp>();
foreach (Dictionary<string, object> group in Rows(cmd, "groups"))
{
string name = Lower(StringField(group, "name"));
if (string.IsNullOrEmpty(name))
continue;
// Protocol 13: an absent title or rank means "leave it" — the site holds an
// in-game change to them while a person decides (D161's `adopt`) — and an
// empty title is a title, sent verbatim, not a reason to use the name.
job.Ops.Add(new PermOp
{
Kind = PermOpKind.EnsureGroup,
Subject = name,
Title = group.ContainsKey("title") ? (StringField(group, "title") ?? "") : null,
Rank = IntField(group, "rank"),
HasRank = group.ContainsKey("rank")
});
// Only a frame that says something about the parent changes it: "" clears it, and
// an absent key leaves whatever the group has.
if (group.ContainsKey("parent"))
{
parentOps.Add(new PermOp
{
Kind = PermOpKind.EnsureGroupParent,
Subject = name,
Object = Lower(StringField(group, "parent")) ?? ""
});
}
foreach (string perm in Strings(group, "permissions"))
{
string lowered = Lower(perm);
if (string.IsNullOrEmpty(lowered))
continue;
job.Ops.Add(new PermOp
{
Kind = PermOpKind.EnsureGroupPermission,
Subject = name,
Object = lowered
});
}
foreach (string member in Strings(group, "members"))
{
if (string.IsNullOrEmpty(member))
continue;
job.Ops.Add(new PermOp
{
Kind = PermOpKind.EnsureMember,
Subject = member,
Object = name
});
}
// Protocol 12: the group's BetterChat style. After the group's own ops, so the
// Oxide group BetterChat's members are read from exists first. With BetterChat
// absent nothing is compiled, and the report says why (§33.2).
if (job.ChatLoaded)
{
List<ChatFieldWant> style = ChatStyle(job, name, group);
if (style.Count > 0)
{
job.ChatStyles[name] = style;
job.Ops.Add(new PermOp { Kind = PermOpKind.EnsureChatStyle, Subject = 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;
job.Ops.Add(new PermOp
{
Kind = PermOpKind.EnsureGrant,
Subject = steamId,
Object = lowered
});
}
}
// Parents after every group exists and before any retirement, so a group the site is
// about to remove is no longer anybody's parent when it goes.
job.Ops.AddRange(parentOps);
// ── 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;
case "chat-group":
// Only with BetterChat here to ask. Otherwise the report says `loaded:
// false`, and the site keeps the retirement for a sync that can act on it.
if (job.ChatLoaded)
job.Ops.Add(new PermOp { Kind = PermOpKind.RetireChatGroup, Subject = Lower(subject) });
break;
}
}
// Protocol 13 dropped the `managed` list and the foreign scan it drove: the site reads
// the whole store with `perm.inventory` and decides for itself (D160, D161).
// ── The kit credits (protocol 10) ──────────────────────────────────
//
// Not store operations, so not drained with them: they are settled once, after the
// grants have landed, and counted in the same report.
if (cmd.ContainsKey("credits"))
{
job.Credits = new List<KitCredit>();
foreach (Dictionary<string, object> row in Rows(cmd, "credits"))
{
string steamId = StringField(row, "steamId");
string kit = StringField(row, "kit");
int count = IntField(row, "count");
if (string.IsNullOrEmpty(steamId) || string.IsNullOrEmpty(kit) || count <= 0)
continue;
job.Credits.Add(new KitCredit { SteamId = steamId, Kit = kit, Earned = count });
}
}
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();
if (job.Credits != null)
{
try
{
ReconcileCredits(job);
}
catch (Exception ex)
{
// The grants have landed and must still be reported; a credit that could not be
// settled is settled by the next sync, or by the player's next redemption.
Puts($"could not settle kit credits: {Flatten(ex.Message)}");
}
}
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))
{
permission.CreateGroup(op.Subject, op.Title ?? op.Subject, op.Rank);
job.GroupsCreated++;
break;
}
// Protocol 13: the site owns an existing group's title and rank too (D160), so
// a difference is written rather than left. Until now a group's title was only
// ever set when the sync created it.
bool updated = false;
if (op.Title != null && !string.Equals(permission.GetGroupTitle(op.Subject) ?? "", op.Title, StringComparison.Ordinal))
{
permission.SetGroupTitle(op.Subject, op.Title);
updated = true;
}
if (op.HasRank && permission.GetGroupRank(op.Subject) != op.Rank)
{
permission.SetGroupRank(op.Subject, op.Rank);
updated = true;
}
if (updated)
job.GroupsUpdated++;
else
job.AlreadyCorrect++;
break;
case PermOpKind.EnsureGroupParent:
string currentParent = Lower(permission.GetGroupParent(op.Subject)) ?? "";
if (string.Equals(currentParent, op.Object, StringComparison.Ordinal))
{
job.AlreadyCorrect++;
break;
}
// Both frameworks refuse a parent that does not exist or that would make a
// loop, and say so only by returning false. The read-back is what counts.
permission.SetGroupParent(op.Subject, string.IsNullOrEmpty(op.Object) ? null : op.Object);
if (string.Equals(Lower(permission.GetGroupParent(op.Subject)) ?? "", op.Object, StringComparison.Ordinal))
job.GroupsUpdated++;
else
job.NotLanded.Add(op.Subject + ":parent:" + op.Object);
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.EnsureChatStyle:
ApplyChatStyle(job, op.Subject);
break;
case PermOpKind.RetireChatGroup:
RemoveChatGroup(job, op.Subject);
break;
}
}
/// <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 },
{ "groupsUpdated", job.GroupsUpdated },
{ "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["leased"] = job.Leased;
report["pending"] = job.Pending;
report["notLanded"] = job.NotLanded;
report["operations"] = job.Ops.Count;
if (job.Credits != null)
{
// Counted the way `notLanded` is counted for grants: what actually changed in Kits,
// read back, never what was asked for.
report["creditsApplied"] = job.CreditsApplied;
report["creditsWithdrawn"] = job.CreditsWithdrawn;
report["creditsUnresolved"] = job.CreditsUnresolved;
report["creditsWaiting"] = job.CreditsWaiting;
}
// Protocol 12. Always present, so a site can tell "BetterChat is absent" from "this
// plugin predates styles" — the second has no `chat` at all.
var chat = new Dictionary<string, object> { { "loaded", job.ChatLoaded } };
if (job.ChatLoaded)
{
chat["version"] = job.ChatVersion;
chat["applied"] = job.ChatApplied;
chat["saved"] = job.ChatSaved;
chat["same"] = job.ChatSame;
chat["drift"] = job.ChatDrift;
chat["removed"] = job.ChatRemoved;
chat["failed"] = job.ChatFailed;
}
report["chat"] = chat;
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}+{job.GroupsUpdated} members={job.Members} same={job.AlreadyCorrect} " +
$"unresolved={job.Unresolved.Count} " +
$"pending={job.Pending.Count} notLanded={job.NotLanded.Count} " +
$"chat={(job.ChatLoaded ? $"applied {job.ChatApplied} saved {job.ChatSaved} drift {job.ChatDrift.Count} removed {job.ChatRemoved.Count} failed {job.ChatFailed.Count}" : "absent")} " +
$"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 inventory is (protocol 13), because only the site's own record can say
// whether a grant is a hand edit. 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.
//
// Protocol 13 made them a feed as well, and a narrow one (F8, D184). A grant the site made
// for a plugin that was not loaded is `unresolved` until the plugin comes back — and the
// first walk watched it land thirteen minutes after the plugin did, on the fifteen-minute
// audit, under a page promising "it will land by itself". So a load or an unload is now
// announced with the permissions that plugin added or removed, and the site re-syncs when
// that list is not empty. Only once the world is ready: a boot loads every plugin, and a
// boot is the restart sync's job.
/// <summary>The registered permission list as the last load or unload left it. Main thread.</summary>
private HashSet<string> _registeredPerms;
/// <summary>
/// Main thread. The list as it is now; the first call is the baseline. From
/// <c>OnServerInitialized</c>, which is also the moment <c>_worldReady</c> turns true.
/// </summary>
private void SeedRegisteredPerms()
{
string[] now = permission.GetPermissions();
_registeredPerms = new HashSet<string>(now ?? new string[0], StringComparer.OrdinalIgnoreCase);
}
/// <summary>
/// Main thread, one tick after the hook. The diff is taken then rather than inside the
/// hook because the order of "the framework drops an unloaded plugin's permissions" and
/// "the framework calls <c>OnPluginUnloaded</c>" is the framework's, and a tick later
/// both have happened on either. Two plugins loaded in the same tick share one diff and
/// the first name gets it — the site's answer (re-sync) is the same either way, and
/// §4.1's owner is taken from the inventory, not from this.
/// </summary>
private void EmitPluginChange(string name, string version, bool loaded)
{
if (_registeredPerms == null)
return;
string[] now = permission.GetPermissions() ?? new string[0];
HashSet<string> current = new HashSet<string>(now, StringComparer.OrdinalIgnoreCase);
List<string> changed = new List<string>();
if (loaded)
{
foreach (string perm in current)
{
if (!_registeredPerms.Contains(perm))
changed.Add(perm);
}
}
else
{
foreach (string perm in _registeredPerms)
{
if (!current.Contains(perm))
changed.Add(perm);
}
}
_registeredPerms = current;
changed.Sort(StringComparer.Ordinal);
Dictionary<string, object> frame = Frame(loaded ? "plugin.loaded" : "plugin.unloaded", "event");
frame["name"] = name;
if (!string.IsNullOrEmpty(version))
frame["version"] = version;
frame["permissions"] = changed;
EmitFrame(frame);
}
private bool IsZonePlugin(Plugin plugin)
{
if (plugin == null || plugin == this)
return false;
string name = plugin.Name;
return name == "ZoneManager" || name == "ZoneDomes" || name == "RunicGatewayZones" || name == "RunicGatewayDomes";
}
/// <summary>RunicNPC's presence and API are in hello, so its load and unload say it again (stage 4).</summary>
private bool IsRunicNpc(Plugin plugin)
{
return plugin != null && plugin != this && plugin.Name == "RunicNPC";
}
/// <summary>Main thread. Hello again, once the world is up and the link is.</summary>
private void ResendHello()
{
if (_worldReady && _connected)
SendHello();
}
/// <summary>Main thread. Queues <see cref="EmitPluginChange"/> for somebody else's plugin, once the world is up.</summary>
private void NotePluginChange(Plugin plugin, bool loaded)
{
if (plugin == null || plugin == this || !_worldReady)
return;
string name = plugin.Name;
string version = plugin.Version.ToString();
NextTick(() => EmitPluginChange(name, version, loaded));
}
private void OnPluginLoaded(Plugin plugin)
{
MarkHook("OnPluginLoaded");
NotePluginChange(plugin, true);
// 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"));
// Hello carries ZoneManager's flag list and both helpers' states, so a change to any
// of the four is said again rather than waiting for the next connect (§3.1, §3.2).
if (IsZonePlugin(plugin) || IsRunicNpc(plugin))
NextTick(ResendHello);
// BetterChat keeps third-party titles on its INSTANCE, so a reloaded BetterChat has
// forgotten our getter. Registering again is the only way back (§33.1).
if (plugin != null && plugin != this && plugin.Name == "BetterChat")
NextTick(() => RegisterTitles("BetterChat loaded"));
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");
NotePluginChange(plugin, false);
if (plugin != null && plugin.Name == "BetterChat")
_titlesRegistered = false;
if (IsZonePlugin(plugin) || IsRunicNpc(plugin))
NextTick(ResendHello);
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>
/// The longest a reload may take before this plugin stops waiting for it (D178).
///
/// <para>
/// <b>A ceiling, not the test.</b> Protocol 12 rolled back any reload that had not
/// announced itself within four seconds, because the caller held a socket for the whole
/// wait. That window lost to work whose length nothing here controls: the org lead's
/// first walk saw Oxide's compiler idle out, restart, and load Kits 2.85 s later — just
/// past the window — and the plugin put a valid edit back (F9). Protocol 13 answers the
/// save at once and sends the outcome as a frame, so the wait no longer has to fit
/// inside an RPC, and a reload fails on <b>evidence</b>: the framework's log saying the
/// plugin did not compile or initialise (<see cref="ConfigWatchTick"/>). This number
/// only bounds a reload that says nothing at all.
/// </para>
///
/// <para>
/// Each phase gets it once — the edit's reload, and the restore's if there is one — and
/// the pending reply carries it as <c>ceilingMs</c>, so the website knows when a write it
/// never heard back about is lost rather than slow.
/// </para>
/// </summary>
private const float ConfigReloadCeilingSeconds = 30f;
/// <summary>How often a pending reload reads the log for its failure.</summary>
private const float ConfigLogPollSeconds = 1f;
/// <summary>The most one poll reads from the log. A server spamming it cannot make a tick expensive.</summary>
private const int ConfigLogPollMaxBytes = 64 * 1024;
/// <summary>How much of the newest log file is read back when a reload fails.</summary>
private const int ConfigLogTailBytes = 8 * 1024;
/// <summary>
/// What a failed load says in the log, beside the plugin's name.
///
/// <para>
/// Lower case, matched case-insensitively. Oxide's are its compiler's and its loader's
/// own words ("Error while compiling", "Failed to initialize plugin", and "Could not
/// initialize plugin 'Kits v4.4.9' (JsonReaderException: …)" when a plugin's own
/// <c>LoadConfig</c> throws, seen on the rig in configuration step 4). The last one
/// carries the exception, so it is the line an operator needs; Oxide follows it with
/// "Previous version of plugin failed to load", which says nothing about why. Carbon's
/// differ, and the rest of the list is there so that the same file works on both (R19);
/// the Carbon pass of the re-walk is what pins them. The first pinned is Carbon
/// 2.0.259's "[Kits] Failed ILoadConfig (Could not convert string to double: abc. …)" —
/// after which Carbon loads the plugin anyway, broken, and raises <c>Loaded</c>.
/// </para>
/// </summary>
private static readonly string[] ConfigFailureWords =
{
"error while compiling",
"could not initialize",
"failed to initialize",
"failed initializing",
"failed to compile",
"failed compiling",
"failed to load",
"failed loading",
"failed iloadconfig",
"unable to load",
};
/// <summary>
/// What the framework says when it gave up on a compile, beside the plugin's name.
///
/// <para>
/// Not a failure of the file: Oxide shuts its compiler down after a minute idle, and a
/// reload asked for in that moment is dropped with "Timed out waiting for plugin to be
/// compiled: ZoneManager" while the plugin carries on, loaded, on its old file (seen on
/// the rig, configuration step 2b). The compiler starts again on demand, so the reload
/// is asked for once more; only a second give-up settles the job.
/// </para>
/// </summary>
private static readonly string[] ConfigStallWords =
{
"timed out waiting for plugin to be compiled",
};
/// <summary>Which reload a job is waiting on: the edit's, or the restore's after a rollback.</summary>
private enum ConfigPhase
{
Edit,
Restore
}
/// <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
{
/// <summary>
/// Ours, not the sidecar's <c>reqId</c>. The reply and the later
/// <c>config.outcome</c> both carry it, and the website files the outcome by it —
/// the sidecar's request ids restart with the sidecar, so they cannot name a write
/// across one.
/// </summary>
public string WriteId;
public string Reload;
public List<StagedConfigWrite> Staged;
/// <summary>The one-second poll that reads the log and enforces the ceiling.</summary>
public Timer Watch;
public ConfigPhase Phase;
/// <summary>When the current phase's reload was asked for. Each phase gets the whole ceiling.</summary>
public DateTime PhaseStarted;
/// <summary>When the write landed, for the outcome's <c>elapsedMs</c>.</summary>
public DateTime Started;
/// <summary>Whether the target was seen leaving in this phase. It separates "never started" from "did not come back".</summary>
public bool Unloaded;
/// <summary>Whether this phase's reload was already asked for again after the framework gave up compiling.</summary>
public bool Retried;
/// <summary>When <c>OnPluginLoaded</c> named the target in this phase, or null. The next log read settles it.</summary>
public DateTime? LoadedAt;
/// <summary>The log file being read, and how far into it this job has read.</summary>
public string LogPath;
public long LogOffset;
/// <summary>A line cut off at the end of the last read, kept until the rest arrives.</summary>
public string LogPartial = "";
/// <summary>Why the edit was rolled back, carried into the outcome once the restore settles.</summary>
public string RollbackReason;
/// <summary>The log lines that showed the failure, or the tail read when the ceiling passed.</summary>
public string RollbackLog;
}
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;
}
var job = new ConfigWriteJob
{
WriteId = Guid.NewGuid().ToString("N"),
Reload = reload,
Staged = staged,
Phase = ConfigPhase.Edit,
Started = DateTime.UtcNow,
PhaseStarted = DateTime.UtcNow
};
// Where the log is NOW, before the reload writes anything to it. Only lines after this
// point can be about this reload.
MarkLogPosition(job);
_configJob = job;
if (!ReloadPluginByName(reload))
{
_configJob = null;
RestoreConfig(staged);
EmitConfigError(reqId, "reload-failed", $"this framework refused to reload '{reload}'");
return;
}
// The reload was asked for and has not finished; it may be seconds away behind a cold
// compile. Answer now — the caller is holding a socket with a ten-second budget — and
// send the outcome as a frame when there is one (F9, D177).
job.Watch = timer.Every(ConfigLogPollSeconds, ConfigWatchTick);
SendConfigReport(reqId, staged, false, false, null, null, job);
}
/// <summary>
/// Main thread. <c>OnPluginLoaded</c> named the target. It is noted, not settled: the
/// next log read settles it (W5).
///
/// <para>
/// "Loaded" is not "loaded well" on Carbon. A config its <c>LoadConfig</c> cannot read
/// is logged as a failure and the plugin is loaded anyway, broken, in the same instant
/// (seen on the rig, configuration step 4). Settling on the hook reported that edit as
/// applied; reading the log first rolls it back, for about a second's wait.
/// </para>
/// </summary>
private void ConfigReloadArrived()
{
ConfigWriteJob job = _configJob;
if (job == null || job.LoadedAt.HasValue)
return;
job.LoadedAt = DateTime.UtcNow;
}
/// <summary>
/// Main thread. A load that a full log read after it found no failure: in the edit
/// phase the write stands; in the restore phase the plugin is back on its old file.
/// </summary>
private void ConfigLoadConfirmed(ConfigWriteJob job)
{
if (job.Phase == ConfigPhase.Edit)
SettleConfigJob(job, true, false, null, null, null);
else
SettleConfigJob(job, false, true, true, job.RollbackReason, job.RollbackLog);
}
/// <summary>
/// Main thread, once a second while a reload is pending. Reads what the framework has
/// logged since the last tick, and enforces the ceiling.
///
/// <para>
/// <b>A failure is a line, not a silence (D178).</b> Both frameworks log the moment a
/// plugin fails to compile or to initialise, so a broken edit is rolled back about a
/// second after it breaks, and a slow compile — which logs nothing until it succeeds —
/// is simply waited for. The ceiling is for a reload that says nothing either way.
/// </para>
///
/// <para>
/// <b>What the ceiling does depends on what is loaded, not on the clock (F10).</b>
/// Protocol 12 restored the files whenever the window closed, even over a plugin that
/// was about to finish loading the edit, and then reported "did not come back" about a
/// plugin that did. Now: a target that is loaded again was reloaded and its announcement
/// missed, so the edit stands; one that is loaded and was never seen unloading was never
/// reloaded at all, so the file stands and applies at its next load; only a target that
/// is not loaded has its files put back.
/// </para>
/// </summary>
private void ConfigWatchTick()
{
ConfigWriteJob job = _configJob;
if (job == null)
{
return;
}
string failure = ReadConfigFailure(job);
if (failure != null && IsCompileStallOf(failure, job.Reload))
{
ConfigCompileStalled(job, failure);
return;
}
if (failure != null)
{
if (job.Phase == ConfigPhase.Edit)
{
RollBackConfig(job, $"'{job.Reload}' failed to load: {ShortLine(failure)}", failure);
}
else
{
SettleConfigJob(job, false, true, false,
$"{job.RollbackReason}; the restored file did not load either: {ShortLine(failure)}",
job.RollbackLog + "\n" + failure);
}
return;
}
// A load is trusted once a read that began at least a poll after it found nothing:
// the failure it would have to beat is logged before the load, never after.
if (job.LoadedAt.HasValue &&
(DateTime.UtcNow - job.LoadedAt.Value).TotalSeconds >= ConfigLogPollSeconds)
{
ConfigLoadConfirmed(job);
return;
}
if ((DateTime.UtcNow - job.PhaseStarted).TotalSeconds < ConfigReloadCeilingSeconds)
return;
SettleByWhatIsLoaded(job, $"within {ConfigReloadCeilingSeconds:0}s");
}
/// <summary>
/// Main thread. The framework gave up compiling the target (W1). The first time, the
/// reload is asked for again with a fresh ceiling; the second time, the job is settled
/// at once by what is loaded — the same judgement the ceiling makes, without waiting
/// for it, because the framework has already said it is not going to finish.
/// </summary>
private void ConfigCompileStalled(ConfigWriteJob job, string line)
{
if (!job.Retried)
{
job.Retried = true;
job.PhaseStarted = DateTime.UtcNow;
job.Unloaded = false;
job.LoadedAt = null;
MarkLogPosition(job);
Puts($"the framework gave up compiling '{job.Reload}'; asking for the reload once more");
if (ReloadPluginByName(job.Reload))
return;
}
SettleByWhatIsLoaded(job, $"before the framework gave up compiling it ({ShortLine(line)})");
}
/// <summary>
/// Main thread. Settles a reload that said nothing conclusive, by what is loaded — not
/// by the clock (F10). <paramref name="when"/> finishes the sentence: "within 30s", or
/// why it stopped sooner.
/// </summary>
private void SettleByWhatIsLoaded(ConfigWriteJob job, string when)
{
bool loaded = IsPluginLoaded(job.Reload);
if (job.Phase == ConfigPhase.Restore)
{
SettleConfigJob(job, false, true, loaded,
loaded
? job.RollbackReason
: $"{job.RollbackReason}; '{job.Reload}' did not come back on the restored file " +
$"{when} either",
job.RollbackLog);
return;
}
if (loaded && job.Unloaded)
{
SettleConfigJob(job, true, false, null,
$"'{job.Reload}' is loaded again, but its load was not announced", null);
}
else if (loaded)
{
SettleConfigJob(job, false, false, null,
$"the framework did not reload '{job.Reload}' {when}; " +
"the file stands and applies at its next load",
null);
}
else
{
RollBackConfig(job,
job.Unloaded
? $"'{job.Reload}' unloaded and did not come back {when}"
: $"'{job.Reload}' did not load {when}",
LogTail());
}
}
/// <summary>
/// Main thread. The edit took the plugin down: put every file back and reload again.
///
/// <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. The outcome waits for the
/// restore's own reload, so it can say whether the plugin is back, rather than only that
/// the files are.
/// </para>
/// </summary>
private void RollBackConfig(ConfigWriteJob job, string reason, string log)
{
RestoreConfig(job.Staged);
job.Phase = ConfigPhase.Restore;
job.PhaseStarted = DateTime.UtcNow;
job.Unloaded = false;
job.Retried = false;
job.LoadedAt = null;
job.RollbackReason = reason;
job.RollbackLog = log;
// The restore's failure, if it has one, is logged after this point.
MarkLogPosition(job);
if (!ReloadPluginByName(job.Reload))
{
SettleConfigJob(job, false, true, false,
$"{reason}; the files are back, but this framework refused to reload '{job.Reload}'", log);
}
}
/// <summary>Main thread. The job is over: stop watching and say how it ended.</summary>
private void SettleConfigJob(
ConfigWriteJob job,
bool reloaded,
bool rolledBack,
bool? restored,
string reason,
string log)
{
if (_configJob != job)
return;
StopConfigWatch();
_configJob = null;
EmitConfigOutcome(job, reloaded, rolledBack, restored, reason, log);
}
private void StopConfigWatch()
{
ConfigWriteJob job = _configJob;
if (job == null || job.Watch == null)
return;
job.Watch.Destroy();
job.Watch = null;
}
private bool IsPluginLoaded(string name)
{
Plugin found = plugins.Find(name);
return found != null;
}
/// <summary>Main thread. Points the job at the end of the newest log file.</summary>
private void MarkLogPosition(ConfigWriteJob job)
{
job.LogPath = NewestLogFile();
job.LogPartial = "";
try
{
job.LogOffset = job.LogPath != null ? new FileInfo(job.LogPath).Length : 0;
}
catch (Exception)
{
job.LogOffset = 0;
}
}
/// <summary>
/// Main thread. The first line logged since the last read that says the target failed to
/// load, or null.
///
/// <para>
/// Bounded per call. A log that rotates mid-job (midnight) is followed to the new file
/// from its start; one that shrank was truncated and is read again from the top. Lines
/// this plugin wrote itself are skipped, so a message of ours that names the target
/// beside the word "failed" cannot roll back a reload that is going fine.
/// </para>
/// </summary>
private string ReadConfigFailure(ConfigWriteJob job)
{
try
{
string newest = NewestLogFile();
if (newest == null)
return null;
if (!string.Equals(newest, job.LogPath, StringComparison.OrdinalIgnoreCase))
{
job.LogPath = newest;
job.LogOffset = 0;
job.LogPartial = "";
}
string text;
using (var stream = new FileStream(job.LogPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
{
if (stream.Length < job.LogOffset)
{
job.LogOffset = 0;
job.LogPartial = "";
}
long available = stream.Length - job.LogOffset;
if (available <= 0)
return null;
int take = (int)Math.Min(available, ConfigLogPollMaxBytes);
stream.Seek(job.LogOffset, SeekOrigin.Begin);
var buffer = new byte[take];
int read = stream.Read(buffer, 0, take);
job.LogOffset += read;
text = job.LogPartial + Encoding.UTF8.GetString(buffer, 0, read);
}
int lastBreak = text.LastIndexOf('\n');
if (lastBreak < 0)
{
job.LogPartial = text.Length > ConfigLogPollMaxBytes ? "" : text;
return null;
}
job.LogPartial = text.Substring(lastBreak + 1);
if (job.LogPartial.Length > ConfigLogPollMaxBytes)
job.LogPartial = "";
string own = "[" + Title + "]";
foreach (string raw in text.Substring(0, lastBreak).Split('\n'))
{
string line = raw.TrimEnd('\r');
if (line.IndexOf(own, StringComparison.OrdinalIgnoreCase) >= 0)
continue;
if (IsLoadFailureOf(line, job.Reload) || IsCompileStallOf(line, job.Reload))
return line;
}
}
catch (Exception)
{
// A log that cannot be read costs the fast path and nothing else: the ceiling
// still settles the job.
}
return null;
}
/// <summary>Whether a log line says <paramref name="plugin"/> failed to compile or to initialise.</summary>
private static bool IsLoadFailureOf(string line, string plugin)
{
return SaysAbout(line, plugin, ConfigFailureWords);
}
/// <summary>Whether a log line says the framework gave up compiling <paramref name="plugin"/> (W1).</summary>
private static bool IsCompileStallOf(string line, string plugin)
{
return SaysAbout(line, plugin, ConfigStallWords);
}
private static bool SaysAbout(string line, string plugin, string[] words)
{
if (string.IsNullOrEmpty(line) || string.IsNullOrEmpty(plugin))
return false;
string lower = line.ToLowerInvariant();
bool said = false;
foreach (string word in words)
{
if (lower.IndexOf(word, StringComparison.Ordinal) >= 0)
{
said = true;
break;
}
}
return said && MentionsName(lower, plugin.ToLowerInvariant());
}
/// <summary>
/// Whether <paramref name="name"/> appears as a whole name — so a failure of
/// <c>KitsExtended</c> is not read as one of <c>Kits</c>. A trailing <c>.cs</c> or a
/// version still counts: "Kits.cs", "'Kits v4.4.9'".
/// </summary>
private static bool MentionsName(string lower, string name)
{
int from = 0;
while (true)
{
int at = lower.IndexOf(name, from, StringComparison.Ordinal);
if (at < 0)
return false;
int end = at + name.Length;
bool startOk = at == 0 || !char.IsLetterOrDigit(lower[at - 1]);
bool endOk = end == lower.Length || !char.IsLetterOrDigit(lower[end]);
if (startOk && endOk)
return true;
from = at + 1;
}
}
/// <summary>A log line short enough for a reason sentence.</summary>
private static string ShortLine(string line)
{
string trimmed = (line ?? "").Trim();
return trimmed.Length <= 240 ? trimmed : trimmed.Substring(0, 240) + "…";
}
/// <summary>
/// Main thread. Each file as it 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 List<object> ConfigFilesNow(List<StagedConfigWrite> staged, bool rolledBack)
{
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);
}
return files;
}
/// <summary>
/// Main thread. The reply to <c>config.write</c>.
///
/// <para>
/// With a <paramref name="pending"/> job it says "written, reloading": <c>pending</c>,
/// the <c>writeId</c> the outcome will carry, and <c>ceilingMs</c>. Without one it is the
/// whole answer — nothing was asked to reload, or the target was not loaded.
/// </para>
/// </summary>
private void SendConfigReport(
string reqId,
List<StagedConfigWrite> staged,
bool reloaded,
bool rolledBack,
string reason,
string log,
ConfigWriteJob pending = null)
{
Dictionary<string, object> report = Frame("config.report", "reply");
report["ok"] = !rolledBack;
report["reloaded"] = reloaded;
report["rolledBack"] = rolledBack;
report["pending"] = pending != null;
report["files"] = ConfigFilesNow(staged, rolledBack);
if (pending != null)
{
report["writeId"] = pending.WriteId;
report["reload"] = pending.Reload;
report["ceilingMs"] = (long)(ConfigReloadCeilingSeconds * 1000);
}
if (!string.IsNullOrEmpty(reason))
report["reason"] = reason;
if (!string.IsNullOrEmpty(log))
report["log"] = log;
if (reqId != null)
report["reqId"] = reqId;
_lastConfigSummary = pending != null
? $"files={staged.Count} reloading={pending.Reload} at={DateTime.UtcNow:HH:mm:ss}Z"
: $"files={staged.Count} reloaded={reloaded} rolledBack={rolledBack} " +
$"at={DateTime.UtcNow:HH:mm:ss}Z" +
(string.IsNullOrEmpty(reason) ? "" : $" reason={reason}");
EmitFrame(report);
}
/// <summary>
/// Main thread. How a pending reload ended, as an event (protocol 13, F9).
///
/// <para>
/// An event, not a reply, because nothing is waiting for it: the sidecar files it in its
/// feed and the website's ingest settles the write it names. Staff class on the site —
/// the log tail is an operator's console.
/// </para>
/// </summary>
private void EmitConfigOutcome(
ConfigWriteJob job,
bool reloaded,
bool rolledBack,
bool? restored,
string reason,
string log)
{
Dictionary<string, object> frame = Frame("config.outcome", "event");
frame["writeId"] = job.WriteId;
frame["reload"] = job.Reload;
frame["ok"] = !rolledBack;
frame["reloaded"] = reloaded;
frame["rolledBack"] = rolledBack;
frame["elapsedMs"] = (long)(DateTime.UtcNow - job.Started).TotalMilliseconds;
frame["files"] = ConfigFilesNow(job.Staged, rolledBack);
if (restored.HasValue)
frame["restored"] = restored.Value;
if (!string.IsNullOrEmpty(reason))
frame["reason"] = reason;
if (!string.IsNullOrEmpty(log))
frame["log"] = log;
_lastConfigSummary =
$"files={job.Staged.Count} reloaded={reloaded} rolledBack={rolledBack}" +
(restored.HasValue ? $" restored={restored.Value}" : "") +
$" at={DateTime.UtcNow:HH:mm:ss}Z" +
(string.IsNullOrEmpty(reason) ? "" : $" reason={reason}");
EmitFrame(frame);
}
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 framework's newest log file, or null. Oxide's own log is the newest <c>*.txt</c>
/// directly in <c>LogDirectory</c>; plugins that log to files write into subdirectories
/// of it, which this does not look in.
///
/// <para>
/// Carbon's is <c>Carbon.Core.log</c> in <c>carbon/logs</c>, beside four other
/// <c>*.log</c> files and no <c>*.txt</c> at all (read on the rig, Carbon 2.0.259). Its
/// Harmony log is the one written most often, so "newest" would pick the wrong file;
/// the name is the only reliable way to choose.
/// </para>
/// </summary>
private static string NewestLogFile()
{
try
{
string dir = Interface.Oxide.LogDirectory;
if (string.IsNullOrEmpty(dir) || !Directory.Exists(dir))
return null;
if (IsCarbon())
{
string core = Path.Combine(dir, "Carbon.Core.log");
return File.Exists(core) ? core : null;
}
string newest = null;
DateTime newestAt = DateTime.MinValue;
foreach (string candidate in Directory.GetFiles(dir, "*.txt"))
{
DateTime at = File.GetLastWriteTimeUtc(candidate);
if (at <= newestAt)
continue;
newest = candidate;
newestAt = at;
}
return newest;
}
catch (Exception)
{
return null;
}
}
/// <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 newest = NewestLogFile();
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)
{
// Carbon's Oxide-compatible ReloadPlugin is not a reload (W3, Carbon 2.0.259 on the
// rig): one call unloaded Kits and never compiled it again, the next did nothing at
// all. Its own console command, typed at the panel, reloads the same plugin in about a
// second — so on Carbon the reload IS that command, run on the server console.
if (IsCarbon())
return RunReloadCommand("c.reload", 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}");
}
return RunReloadCommand("oxide.reload", name);
}
/// <summary>Runs a reload command on the server console, as an operator at the panel would.</summary>
private bool RunReloadCommand(string command, string name)
{
try
{
ConsoleSystem.Run(ConsoleSystem.Option.Server, command, name);
return true;
}
catch (Exception ex)
{
Puts($"console {command} 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 does not apply to it, and the
/// inventory lists it under <c>leased</c> so the site never takes it for a hand edit.
/// </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>
/// A zone's ZoneManager flags and settings (PLAN_REDESIGNS §3.1), kept so a re-created
/// zone gets them back. Null on a zone from before protocol 13's options.
/// </summary>
public List<string> Flags;
public Dictionary<string, string> Settings;
/// <summary>What a player is told on the way in and out, and how (D193). Said by this plugin, not ZoneManager.</summary>
public string EnterMessage;
public string LeaveMessage;
public string Delivery;
public string Format;
/// <summary>A ZoneDomes dome over the zone (D194): its type (0–4) and how many spheres are stacked.</summary>
public bool Dome;
public int DomeType;
public int DomeStack;
/// <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");
}
// ---- the ZoneManager helper (F12, D181, D182) ----
//
// ZoneManager counts a player in a zone only when its trigger fires on entry, so a zone
// created, updated or re-created around somebody standing there holds nobody. The helper,
// `RunicGatewayZones.cs`, ships beside this file and patches that. It is optional: without it,
// or when it loaded and could not find what it patches, the tally and the kill credit ask
// ZoneManager's public `IsPositionInZone` instead of its membership — so a missing helper
// costs ZoneManager's own flags for somebody already inside, never their score.
private Plugin ZoneHelperPlugin()
{
return plugins.Find("RunicGatewayZones");
}
/// <summary>Main thread. <c>patched</c>, <c>unsupported</c>, <c>no-zonemanager</c> or <c>missing</c>.</summary>
private string ZoneHelperState()
{
Plugin helper = ZoneHelperPlugin();
return helper == null ? "missing" : helper.Call("State") as string ?? "unsupported";
}
/// <summary>
/// Main thread. Whether ZoneManager's own membership can be trusted for a zone that may
/// have been made around somebody. Asked once per tally tick or kill, not per player.
/// </summary>
private bool ZoneMembershipTrusted()
{
return ZoneHelperState() == "patched";
}
/// <summary>
/// Main thread. Is this player in the zone: ZoneManager's membership when the helper has
/// made it right, and the zone's shape otherwise (D182).
/// </summary>
private static bool InZone(Plugin zm, string zoneId, BasePlayer player, bool trusted)
{
if (zm == null || player == null)
return false;
object inside = trusted
? zm.Call("IsPlayerInZone", zoneId, player)
: zm.Call("IsPositionInZone", zoneId, player.transform.position);
return inside is bool && (bool)inside;
}
// ---- a zone's options (PLAN_REDESIGNS §3.1, D166, D211) ----
//
// ZoneManager's flags are whatever the installed version declares, so the list is read from
// it, never kept here. `ZoneFieldListRaw` is in ZoneManager's own API region and answers
// the zone fields followed by every flag name; the fields this plugin controls or does not
// offer are dropped from the front. Plan §0.6 believed only a plugin that references
// ZoneManager's types could read the list, so it put the read in the zone helper. That was
// wrong for 3.1.14 (D211), and a server without the helper still gets every flag.
/// <summary>The fields `ZoneFieldListRaw` answers that are not flags, lower-cased.</summary>
private static readonly HashSet<string> ZoneNonFlagFields = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"name", "id", "radius", "rotation", "size", "location", "enter_message", "leave_message",
"radiation", "comfort", "temperature", "safezone", "permission", "ejectspawns", "parentid", "enabled", "enable"
};
/// <summary>
/// The settings a step may give a zone, and their bounds. Every other ZoneManager field
/// (rotation, parent, eject spawns, size) changes what this plugin believes the zone is,
/// so it stays out (§3.1). The messages are this plugin's own (D193).
/// </summary>
private static readonly string[] ZoneSettingKeys = { "radiation", "comfort", "temperature", "safezone", "permission" };
private const float ZoneRadiationMax = 500f;
private const float ZoneComfortMax = 1f;
private const float ZoneTemperatureLimit = 100f;
private const int ZoneMessageMax = 256;
private const int ZoneFlagsMax = 80;
/// <summary>ZoneDomes' own sphere types: Standard, Red, Blue, Green, Purple.</summary>
private const int DomeTypeMax = 4;
/// <summary>The most spheres one dome stacks. Each is an entity; ZoneDomes itself has no limit.</summary>
private const int DomeStackMax = 10;
/// <summary>A zone this plugin just put back says nothing to whoever is inside it for this long.</summary>
private const long ZoneQuietMs = 5000L;
/// <summary>Zone id → the moment its enter and leave messages start again. Main thread.</summary>
private readonly Dictionary<string, long> _zoneQuietUntil = new Dictionary<string, long>();
/// <summary>
/// Main thread. ZoneManager's flag names, as it spells them, or null when it is not loaded
/// or does not answer. Asked when needed: an operator can update ZoneManager at any time.
/// </summary>
private static List<string> ZoneFlagNames(Plugin zm)
{
var fields = zm == null ? null : zm.Call("ZoneFieldListRaw") as List<string>;
if (fields == null)
return null;
var flags = new List<string>();
foreach (string field in fields)
{
if (!string.IsNullOrEmpty(field) && !ZoneNonFlagFields.Contains(field))
flags.Add(field);
}
return flags;
}
/// <summary>
/// Main thread. Reads a step's flags and settings against the live ZoneManager. Each flag
/// is returned as ZoneManager spells it; each setting is checked and written as
/// ZoneManager parses it. False with a refusal on anything unknown or out of range.
/// </summary>
private static bool ReadZoneOptions(Plugin zm, Dictionary<string, object> cmd,
out List<string> flags, out Dictionary<string, string> settings, out string refusal)
{
flags = new List<string>();
settings = new Dictionary<string, string>(StringComparer.Ordinal);
refusal = null;
object raw;
if (cmd.TryGetValue("flags", out raw) && raw != null)
{
var list = raw as Newtonsoft.Json.Linq.JArray;
if (list == null)
{
refusal = "a zone's flags are a list of names";
return false;
}
if (list.Count > ZoneFlagsMax)
{
refusal = $"a zone takes at most {ZoneFlagsMax} flags, not {list.Count}";
return false;
}
List<string> known = ZoneFlagNames(zm);
if (known == null)
{
refusal = "this server's ZoneManager did not say which flags it has";
return false;
}
var byName = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (string k in known)
byName[k] = k;
foreach (Newtonsoft.Json.Linq.JToken token in list)
{
string name = token.Type == Newtonsoft.Json.Linq.JTokenType.String ? (string)token : null;
string spelled;
if (string.IsNullOrEmpty(name) || !byName.TryGetValue(name.Trim(), out spelled))
{
refusal = $"'{name}' is not a flag this server's ZoneManager has";
return false;
}
if (!flags.Contains(spelled))
flags.Add(spelled);
}
}
if (cmd.TryGetValue("settings", out raw) && raw != null)
{
var obj = raw as Newtonsoft.Json.Linq.JObject;
if (obj == null)
{
refusal = "a zone's settings are an object of name and value";
return false;
}
foreach (Newtonsoft.Json.Linq.JProperty p in obj.Properties())
{
string key = p.Name.ToLowerInvariant();
if (Array.IndexOf(ZoneSettingKeys, key) < 0)
{
refusal = $"'{p.Name}' is not a zone setting this server offers (radiation, comfort, temperature, safezone, permission)";
return false;
}
// Invariant, because a JSON 0.5 read under the host's culture can come back "0,5".
var jv = p.Value as Newtonsoft.Json.Linq.JValue;
string value = jv == null ? null : Convert.ToString(jv.Value, System.Globalization.CultureInfo.InvariantCulture);
string written;
if (!ZoneSettingValue(key, value, out written, out refusal))
return false;
settings[key] = written;
}
}
return true;
}
private static bool ZoneSettingValue(string key, string value, out string written, out string refusal)
{
written = null;
refusal = null;
var inv = System.Globalization.CultureInfo.InvariantCulture;
if (key == "safezone")
{
bool on;
if (!bool.TryParse(value, out on))
{
refusal = $"safezone is true or false, not '{value}'";
return false;
}
written = on ? "true" : "false";
return true;
}
if (key == "permission")
{
string perm = (value ?? string.Empty).Trim().ToLowerInvariant();
if (perm.StartsWith("zonemanager.", StringComparison.Ordinal))
perm = perm.Substring("zonemanager.".Length);
if (perm.Length == 0 || perm.Length > 32)
{
refusal = "a zone's permission is 1 to 32 characters";
return false;
}
foreach (char c in perm)
{
if (!(c >= 'a' && c <= 'z') && !(c >= '0' && c <= '9') && c != '_')
{
refusal = $"a zone's permission is letters, digits and _, not '{value}'";
return false;
}
}
written = perm;
return true;
}
float number;
if (!float.TryParse(value, System.Globalization.NumberStyles.Float, inv, out number) || float.IsNaN(number) || float.IsInfinity(number))
{
refusal = $"{key} is a number, not '{value}'";
return false;
}
float low = key == "temperature" ? -ZoneTemperatureLimit : 0f;
float high = key == "radiation" ? ZoneRadiationMax : key == "comfort" ? ZoneComfortMax : ZoneTemperatureLimit;
if (number < low || number > high)
{
refusal = $"{key} is {low} to {high}, not {number.ToString(inv)}";
return false;
}
written = number.ToString(inv);
return true;
}
// ---- the dome (PLAN_REDESIGNS §3.2, D167, D194) ----
//
// ZoneDomes draws the dome. Two things it lacks are made up here and in the domes helper,
// `RunicGatewayDomes.cs`: its API ends in a chat message to the player who asked, which
// throws for a call with none (the helper patches that), and at its own start it drops
// every dome whose zone does not exist yet, which is every zone this plugin re-creates.
// So this plugin puts its domes back after it puts its zones back, and only once ZoneDomes
// has finished its own start: a dome added before that is drawn twice, and the first set
// of spheres is left in the world where nothing can remove it. The helper says when.
private Plugin ZoneDomesPlugin()
{
return plugins.Find("ZoneDomes");
}
private Plugin DomesHelperPlugin()
{
return plugins.Find("RunicGatewayDomes");
}
/// <summary>Main thread. <c>patched</c>, <c>unsupported</c>, <c>no-zonedomes</c> or <c>missing</c>.</summary>
private string DomesHelperState()
{
Plugin helper = DomesHelperPlugin();
return helper == null ? "missing" : helper.Call("State") as string ?? "unsupported";
}
/// <summary>Main thread. Whether a dome may be added now: ZoneDomes loaded, patched, and past its own start.</summary>
private bool DomesReady()
{
Plugin helper = DomesHelperPlugin();
return ZoneDomesPlugin() != null && helper != null
&& helper.Call("State") as string == "patched" && helper.Call("Ready") is bool ready && ready;
}
/// <summary>Main thread. Why a dome cannot be added now, or null when it can.</summary>
private string DomeRefusal()
{
if (ZoneDomesPlugin() == null)
return "ZoneDomes is not loaded on this server, so a zone cannot have a dome";
string state = DomesHelperState();
if (state == "missing")
return "RunicGatewayDomes.cs is not installed beside the bridge, and ZoneDomes cannot be called without it";
if (state != "patched")
return $"RunicGatewayDomes.cs could not patch this ZoneDomes ({state})";
return DomesReady() ? null : "ZoneDomes has not finished starting";
}
/// <summary>Main thread. Adds a zone's dome. True when ZoneDomes made it, or already had it.</summary>
private bool AddDome(WorldEntry e)
{
Plugin zd = ZoneDomesPlugin();
if (zd == null || !e.Dome)
return false;
object made = zd.Call("AddNewDome", null, e.Id, e.DomeType, e.DomeStack);
return made is bool && (bool)made;
}
/// <summary>
/// Main thread. Takes a zone's dome out of the world and out of ZoneDomes' data file.
/// Called BEFORE the zone is erased, so a dome never outlives its zone.
/// </summary>
private void RemoveDome(WorldEntry e)
{
Plugin zd = ZoneDomesPlugin();
if (zd == null || !e.Dome)
return;
// Without the helper the call still removes the dome and then throws on its message;
// the framework logs that, and the dome is gone either way.
zd.Call("RemoveExistingDome", null, e.Id);
}
/// <summary>
/// Main thread. Puts back the dome of every standing zone that has one. ZoneDomes answers
/// false for a dome it already has, so this is safe to repeat.
/// </summary>
private void RestoreDomes(string why)
{
if (!_worldReady || !DomesReady())
return;
Plugin zm = ZoneManagerPlugin();
if (zm == null)
return;
int added = 0;
foreach (WorldEntry e in _world.Values)
{
if (e.Kind != WorldKindZone || !e.Dome || ZoneExpired(e) || zm.Call("CheckZoneID", e.Id) == null)
continue;
if (AddDome(e))
added++;
}
if (added > 0)
{
_lastWorldSummary = $"domes after {why}: {added} put back";
Puts($"world: {_lastWorldSummary}");
}
}
/// <summary>Raised by the domes helper when ZoneDomes has finished its own start.</summary>
private void OnRgDomesReady()
{
MarkHook("OnRgDomesReady");
NextTick(() => RestoreDomes("ZoneDomes started"));
}
// ---- the zone's messages (D193) ----
//
// ZoneManager can say a zone's enter and leave messages itself, but only to chat unless its
// own config turns popups on for every zone on the server. So this plugin never gives it
// the messages for its own zones, and says them here, as each zone's step chose.
private void OnEnterZone(string zoneId, BasePlayer player)
{
MarkHook("OnEnterZone");
SayZoneMessage(zoneId, player, true);
}
private void OnExitZone(string zoneId, BasePlayer player)
{
MarkHook("OnExitZone");
SayZoneMessage(zoneId, player, false);
}
private void SayZoneMessage(string zoneId, BasePlayer player, bool entering)
{
WorldEntry e;
if (zoneId == null || player == null || !player.IsConnected || !_world.TryGetValue(zoneId, out e) || e.Kind != WorldKindZone)
return;
string message = entering ? e.EnterMessage : e.LeaveMessage;
if (string.IsNullOrEmpty(message))
return;
// A zone this plugin just put back (a restart, a ZoneManager reload) re-enters whoever
// is standing in it. They were told when they walked in.
long quiet;
if (_zoneQuietUntil.TryGetValue(zoneId, out quiet))
{
if (NowMs() < quiet)
return;
_zoneQuietUntil.Remove(zoneId);
}
// A popup where PopupNotifications is loaded, and chat where it is not (§2.4): the
// step was accepted long before this moment, and a player is told either way.
Plugin popup = e.Delivery == DeliveryPopup ? PopupPlugin() : null;
if (popup != null)
popup.Call("CreatePopupNotification", message, player, 0f);
else
player.ChatMessage(e.Format == null ? message : Voice(e.Format, message));
}
/// <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);
// PLAN_REDESIGNS §3.1: the flags and settings, each checked against this server's own
// ZoneManager. An unknown one is refused, never dropped: a zone that quietly lacks the
// "no building" its author chose is worse than a step that fails and says why.
List<string> flags;
Dictionary<string, string> settings;
string optionRefusal;
if (!ReadZoneOptions(zm, cmd, out flags, out settings, out optionRefusal))
{
EmitWorldError(reqId, "bad-option", optionRefusal);
return;
}
// D193: what a player is told on the way in and out, and whether as chat or a popup.
string enterMessage = StringField(cmd, "enterMessage");
string leaveMessage = StringField(cmd, "leaveMessage");
if ((enterMessage != null && enterMessage.Length > ZoneMessageMax) || (leaveMessage != null && leaveMessage.Length > ZoneMessageMax))
{
EmitWorldError(reqId, "out-of-range", $"a zone's message is at most {ZoneMessageMax} characters");
return;
}
string delivery = StringField(cmd, "delivery");
if (string.IsNullOrEmpty(delivery))
delivery = DeliveryChat;
if (delivery != DeliveryChat && delivery != DeliveryPopup)
{
EmitWorldError(reqId, "malformed", $"a zone's messages go to chat or to a popup, not to '{delivery}'");
return;
}
string format = StringField(cmd, "format");
if (format != null && (format.Length > ChatFormatMax || Occurrences(format, "{message}") != 1))
{
EmitWorldError(reqId, "malformed", $"a chat format holds exactly one {{message}} and at most {ChatFormatMax} characters");
return;
}
// D194: a dome, where ZoneDomes and the domes helper are both here.
bool dome = false;
int domeType = 0, domeStack = 1;
object domeRaw;
if (cmd.TryGetValue("dome", out domeRaw) && domeRaw != null)
{
var d = domeRaw as Newtonsoft.Json.Linq.JObject;
if (d == null)
{
EmitWorldError(reqId, "malformed", "a zone's dome is an object of type and stack");
return;
}
Newtonsoft.Json.Linq.JToken t = d["type"], s = d["stack"];
// No type is Standard, the one full dome; the colours show only where they meet
// the ground (D212).
domeType = t == null ? 0 : t.Type == Newtonsoft.Json.Linq.JTokenType.Integer ? (int)t : -1;
domeStack = s == null ? 1 : s.Type == Newtonsoft.Json.Linq.JTokenType.Integer ? (int)s : -1;
if (domeType < 0 || domeType > DomeTypeMax || domeStack < 1 || domeStack > DomeStackMax)
{
EmitWorldError(reqId, "out-of-range", $"a dome's type is 0 to {DomeTypeMax} and its stack 1 to {DomeStackMax}");
return;
}
string noDome = DomeRefusal();
if (noDome != null)
{
EmitWorldError(reqId, "dome-unavailable", noDome);
return;
}
dome = true;
}
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,
Flags = flags.Count > 0 ? flags : null,
Settings = settings.Count > 0 ? settings : null,
EnterMessage = string.IsNullOrEmpty(enterMessage) ? null : enterMessage,
LeaveMessage = string.IsNullOrEmpty(leaveMessage) ? null : leaveMessage,
Delivery = delivery,
Format = delivery == DeliveryChat ? format : null,
Dome = dome,
DomeType = domeType,
DomeStack = domeStack,
DeadlineMs = NowMs() + holdMs,
BootId = _bootId,
WipeId = _wipeId,
TakenMs = NowMs()
};
if (!CreateZone(zm, entry))
{
EmitWorldError(reqId, "refused", "ZoneManager did not create the zone");
return;
}
// The dome after the zone, because ZoneDomes reads the zone's place and size from
// ZoneManager. A dome that is not made takes its zone with it: the step asked for both.
if (dome && !AddDome(entry))
{
zm.Call("EraseTemporaryZone", this, entry.Id);
EmitWorldError(reqId, "dome-unavailable", "ZoneDomes did not make the dome, so the zone was not opened");
return;
}
_world[entry.Id] = entry;
SaveWorld();
EnsureWorldTimer();
_lastWorldSummary = $"zone {entry.Id} r={radius:0} for {holdMs / 60000L} min, {flags.Count} flag(s){(dome ? ", domed" : "")}, 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)
{
var args = new List<string>
{
"name", entry.Name ?? "Event zone",
"radius", entry.Radius.ToString(System.Globalization.CultureInfo.InvariantCulture)
};
// ZoneManager's own key/value arguments (§3.1): each setting by its key, and each flag
// as "<flag>", "true". Kept on the record, so a zone put back after a restart or a
// ZoneManager reload gets them back. A flag a newer ZoneManager no longer has is
// ignored by ZoneManager rather than failing the zone.
if (entry.Settings != null)
{
foreach (KeyValuePair<string, string> kv in entry.Settings)
{
args.Add(kv.Key);
args.Add(kv.Value);
}
}
if (entry.Flags != null)
{
foreach (string flag in entry.Flags)
{
args.Add(flag);
args.Add("true");
}
}
object created = zm.Call("CreateOrUpdateTemporaryZone", this, entry.Id, args.ToArray(), 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++;
_zoneQuietUntil[e.Id] = NowMs() + ZoneQuietMs;
}
else
{
failed++;
}
}
if (restored > 0 || failed > 0)
{
_lastWorldSummary = $"zones after {why}: {restored} re-created, {failed} failed";
Puts($"world: {_lastWorldSummary}");
}
// The domes after the zones (D194). At a real start ZoneDomes has not finished its own
// start yet, and the helper's ready hook brings this back when it has.
RestoreDomes(why);
}
/// <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;
}
// Stage 4 (D243): a RunicNPC profile instead of one of Rust's own prefabs.
string profile = StringField(cmd, "profile");
if (!string.IsNullOrEmpty(profile))
{
if (!string.IsNullOrEmpty(StringField(cmd, "prefab")))
{
EmitWorldError(reqId, "malformed", "a placement names a prefab or a profile, not both");
return;
}
HandleWorldPlaceProfile(cmd, reqId, key, runId, profile);
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;
}
// RunicNPC stage 9 (D310): Rust's own scientists need RunicNPC too, so a module that
// missed the hello cannot place NPCs on a server the site would call incomplete.
if (prefab.Kind == WorldKindNpc && !RunicNpcForWorld(reqId))
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;
// The dome first: ZoneDomes keeps it in its own data file, and a dome
// left there outlives the zone it was drawn over (D194).
RemoveDome(e);
if (wasThere)
zm.Call("EraseTemporaryZone", this, e.Id);
_zoneQuietUntil.Remove(e.Id);
}
else
{
wasThere = e.Kind == WorldKindNpc ? KillWorldNpc(e) : 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)
{
RemoveDome(e);
if (zm != null && zm.Call("CheckZoneID", e.Id) != null)
zm.Call("EraseTemporaryZone", this, e.Id);
_world.Remove(e.Id);
_zoneQuietUntil.Remove(e.Id);
Dictionary<string, object> frame = Frame("world.expired", "event");
frame["id"] = e.Id;
// `what`, never `kind` (F13, protocol 13). Until 13 this line wrote the entity's
// kind over the FRAME's, so every expiry left the plugin filed as `kind: "zone"`
// and nothing listening for `world.expired` ever heard one. `what` is the name the
// site's resource rows already use for the same thing.
frame["what"] = 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}");
}
// ---- the rewards (protocol 10, PLAN.md §29) ----
//
// Three things, and the website counts none of them:
//
// * **The tally** — who took part in a run. It is counted here because only the game
// sees a player stand in a zone or land the last hit (D81), and it is persisted,
// because a restart in the middle of an event must not forget who was there.
// * **The credits** — each reward is one more use of a kit (D103). The site authors how
// many a player has earned and pushes them with `perm.sync`; this plugin spends them
// LAST, from `OnKitRedeemed`, because Kits cannot be told in advance: its
// `SetPlayerKitUses` does nothing for a player with no usage record of the kit.
// * **The chat line** — `chat.say`, for a run's `rust.announce` and for the news leg.
//
// The zone a tally counts in is asked of ZoneManager on every tick (`GetPlayersInZone`,
// `IsPlayerInZone`) rather than mirrored from `OnEnterZone`/`OnExitZone`. ZoneManager
// already holds each zone's members, and a mirror would have to be reseeded every time a
// zone is re-created (D92) — the tick asks at most `EventsMaxTallies` questions every five
// seconds, and no chatty hook is subscribed at all (R17).
/// <summary>The most tallies that may count at once. <c>EventsMaxTallies</c> may lower it.</summary>
private const int TallyHardMax = 8;
/// <summary>
/// The most people one reward step may name. The plugin publishes it and the website
/// refuses a step over it (§29.5): a reward is priced before its tally is read, so this
/// is the price of every mode that is not a count.
/// </summary>
private const int RecipientsHardMax = 100;
/// <summary>The longest chat line, in characters. <c>EventsMaxChat</c> may lower it.</summary>
private const int ChatHardMax = 256;
private const float TallyTickSeconds = 5f;
/// <summary>
/// The longest a tally counts, and the longest it is kept at all: seven days from
/// opening, the lease and zone ceiling (D108). A tally that stopped counting earlier is
/// kept for the steps that read it after — collect and the reward — until then, and a
/// teardown closes it sooner. One ceiling for everything, so a website that went away
/// never leaves a tally on disk for ever.
/// </summary>
private const long TallyMaxHoldMs = 7L * 24L * 60L * 60L * 1000L;
/// <summary>Core files at most 5 000 participants per step; a tally holds no more.</summary>
private const int TallyMaxPeople = 5000;
/// <summary>
/// The most one tick may credit. A tick that arrives late accrues the time that really
/// passed, so a longer interval never rewrites history — but a clock that jumps is not
/// time anybody spent standing in a zone.
/// </summary>
private const long TallyMaxStepMs = 60000L;
/// <summary>Every twelfth tick (a minute) writes the file, when anything changed.</summary>
private const int TallySaveEveryTicks = 12;
private const long ChatKeyMemoryMs = 10L * 60L * 1000L;
private const string ScoreSeconds = "seconds";
private const string ScoreKills = "kills";
private const string ScoreBoth = "both";
/// <summary>One person in one tally. Persisted as it is.</summary>
private sealed class Participant
{
public string SteamId;
public string Name;
public double Seconds;
public int Kills;
public long JoinedMs;
}
/// <summary>One run's tally on this server. There is at most one per run (§29.5).</summary>
private sealed class RunTally
{
public string RunId;
/// <summary>The idempotency key of the step that opened it; a repeat is answered, not refused.</summary>
public string Key;
/// <summary>The zone step's <c>name</c>, as the step gave it, or null for the whole server.</summary>
public string Zone;
/// <summary>What that name resolved to when the tally opened (D100).</summary>
public string ZoneId;
public string Score;
public string KillsOf;
public double KillWeight;
public long OpenedMs;
/// <summary>When counting stops. What was counted is kept until seven days after opening (D108).</summary>
public long DeadlineMs;
/// <summary>People who took part after the tally was full, counted so a result can say so.</summary>
public int Overflow;
public Dictionary<string, Participant> People = new Dictionary<string, Participant>();
/// <summary>Not persisted: a load starts accruing from the load, never from before a restart.</summary>
[JsonIgnore]
public long LastAccrueMs;
}
/// <summary>
/// One player's credits for one kit (D103). <c>Earned</c> is the site's; the other two
/// are this plugin's own record of what it did with them.
/// </summary>
private sealed class KitCredit
{
public string SteamId;
public string Kit;
/// <summary>How many the site says this player has earned. Authored there, pushed here.</summary>
public int Earned;
/// <summary>How many have been moved into Kits' counter — each one a use count lowered by one.</summary>
public int Applied;
/// <summary>How many of those moved uses have since been redeemed.</summary>
public int Consumed;
}
private Dictionary<string, RunTally> _runTallies = new Dictionary<string, RunTally>();
private Dictionary<string, KitCredit> _credits = new Dictionary<string, KitCredit>();
/// <summary>Recently said chat keys, and when. Main thread only.</summary>
private readonly Dictionary<string, long> _chatKeys = new Dictionary<string, long>();
private Timer _tallyTimer;
private int _tallyTicks;
private bool _talliesDirty;
private string _lastRewardSummary = "none yet";
private int MaxTallies()
{
return _config != null ? _config.EventsMaxTallies : TallyHardMax;
}
private int MaxRecipients()
{
return _config != null ? _config.EventsMaxRecipients : RecipientsHardMax;
}
private int MaxChat()
{
return _config != null ? _config.EventsMaxChat : ChatHardMax;
}
private static bool Counting(RunTally t, long now)
{
return now < t.DeadlineMs;
}
/// <summary>Whether a player is somebody a tally counts: a real, connected, living, awake person.</summary>
private static bool CountsAsPresent(BasePlayer p)
{
return p != null && !p.IsNpc && p.IsConnected && !p.IsDead() && !p.IsSleeping();
}
private static Participant PersonIn(RunTally t, BasePlayer player, long now)
{
string id = ((ulong)player.userID).ToString(System.Globalization.CultureInfo.InvariantCulture);
Participant person;
if (t.People.TryGetValue(id, out person))
{
person.Name = player.displayName;
return person;
}
if (t.People.Count >= TallyMaxPeople)
{
t.Overflow++;
return null;
}
person = new Participant { SteamId = id, Name = player.displayName, JoinedMs = now };
t.People[id] = person;
return person;
}
/// <summary>The D99 score: seconds, kills, or minutes present plus the weight times kills.</summary>
private static double ScoreOf(RunTally t, Participant p)
{
switch (t.Score)
{
case ScoreSeconds:
return Math.Floor(p.Seconds);
case ScoreKills:
return p.Kills;
default:
return Math.Round(p.Seconds / 60.0 + t.KillWeight * p.Kills, 2);
}
}
/// <summary>Main thread. Start counting who takes part in a run.</summary>
private void HandleTallyOpen(Dictionary<string, object> cmd, string reqId)
{
string runId = StringField(cmd, "runId");
string key = StringField(cmd, "key");
if (string.IsNullOrEmpty(runId))
{
EmitRewardError("tally.error", reqId, "malformed", "a tally is opened for a run");
return;
}
RunTally existing;
if (_runTallies.TryGetValue(runId, out existing))
{
// A retry whose first answer was lost repeats the key; anything else is a second
// tally for one run, which would split its people across two answers (§29.5).
if (!string.IsNullOrEmpty(key) && existing.Key == key)
EmitTallyOk(reqId, existing, true);
else
EmitRewardError("tally.error", reqId, "already-open",
"this run already has a tally on this server, and a run holds one");
return;
}
// The same consent as the world verbs (D94): a tally is an event doing something on
// this server unattended. Reading and closing one are never behind it.
if (_config == null || !_config.EventsEnabled)
{
EmitRewardError("tally.error", reqId, "events-disabled",
"events are switched off on this server — set EventsEnabled in the RunicGateway plugin config");
return;
}
string score = Lower(StringField(cmd, "score"));
if (score != ScoreSeconds && score != ScoreKills && score != ScoreBoth)
{
EmitRewardError("tally.error", reqId, "malformed", "a tally scores seconds, kills or both");
return;
}
string killsOf = Lower(StringField(cmd, "killsOf"));
if (score == ScoreSeconds)
{
killsOf = null;
}
else if (killsOf != "players" && killsOf != "npcs" && killsOf != "both")
{
EmitRewardError("tally.error", reqId, "malformed", "a tally that counts kills says whose: players, npcs or both");
return;
}
double weight = score == ScoreBoth ? (DoubleField(cmd, "killWeight") ?? 5.0) : 0.0;
if (weight < 0 || weight > 1000)
{
EmitRewardError("tally.error", reqId, "out-of-range", $"a kill is worth 0 to 1000 minutes, not {weight}");
return;
}
long holdMs = LongField(cmd, "holdMs");
if (holdMs == 0)
holdMs = TallyMaxHoldMs;
if (holdMs < 60000L || holdMs > TallyMaxHoldMs)
{
EmitRewardError("tally.error", reqId, "out-of-range",
$"a tally counts for 1 minute to {TallyMaxHoldMs / 60000L} minutes, not {holdMs / 60000L}");
return;
}
long now = NowMs();
int live = 0;
foreach (RunTally t in _runTallies.Values)
{
if (Counting(t, now))
live++;
}
if (live >= MaxTallies())
{
EmitRewardError("tally.error", reqId, "too-many",
$"this server counts at most {MaxTallies()} tallies at once, and {live} are counting");
return;
}
string zone = StringField(cmd, "zone");
string zoneId = null;
if (!string.IsNullOrEmpty(zone))
{
// Only a zone THIS run opened (D100) — never an operator's arena, never another
// run's. Matched on the name its step gave it, the way the step's author wrote it.
var matches = new List<WorldEntry>();
foreach (WorldEntry e in _world.Values)
{
if (e.Kind == WorldKindZone && e.RunId == runId &&
string.Equals((e.Name ?? "").Trim(), zone.Trim(), StringComparison.OrdinalIgnoreCase))
matches.Add(e);
}
if (matches.Count == 0)
{
EmitRewardError("tally.error", reqId, "no-zone",
$"this run has opened no zone named '{zone}' on this server — open it in an earlier step");
return;
}
if (matches.Count > 1)
{
EmitRewardError("tally.error", reqId, "ambiguous-zone",
$"this run has opened {matches.Count} zones named '{zone}' — give each its own name");
return;
}
zoneId = matches[0].Id;
}
var tally = new RunTally
{
RunId = runId,
Key = key,
Zone = zoneId != null ? zone.Trim() : null,
ZoneId = zoneId,
Score = score,
KillsOf = killsOf,
KillWeight = weight,
OpenedMs = now,
DeadlineMs = now + holdMs,
LastAccrueMs = now
};
_runTallies[runId] = tally;
SaveTallies();
EnsureTallyTimer();
_lastRewardSummary = $"tally opened for run {runId} ({score}, {(zoneId ?? "whole server")}) for {holdMs / 60000L} min";
Puts($"rewards: {_lastRewardSummary}");
EmitTallyOk(reqId, tally, false);
}
/// <summary>Main thread. Who has taken part in a run so far. Never behind <c>EventsEnabled</c>.</summary>
private void HandleTallySnapshot(Dictionary<string, object> cmd, string reqId)
{
string runId = StringField(cmd, "runId");
RunTally t;
if (string.IsNullOrEmpty(runId) || !_runTallies.TryGetValue(runId, out t))
{
EmitRewardError("tally.error", reqId, "no-tally", "this run has no tally on this server");
return;
}
// Brought up to this moment first, so a reward read at the end of an event includes
// its last few seconds.
Accrue(t, NowMs(), null);
var people = new List<object>();
foreach (Participant p in t.People.Values)
{
people.Add(new Dictionary<string, object>
{
{ "steamId", p.SteamId },
{ "name", p.Name },
{ "seconds", (long)Math.Floor(p.Seconds) },
{ "kills", p.Kills },
{ "score", ScoreOf(t, p) },
{ "joinedAt", p.JoinedMs }
});
}
Dictionary<string, object> reply = Frame("tally.snapshot", "reply");
reply["runId"] = t.RunId;
reply["score"] = t.Score;
reply["killWeight"] = t.KillWeight;
reply["counting"] = Counting(t, NowMs());
reply["remainingMs"] = Math.Max(0L, t.DeadlineMs - NowMs());
reply["overflow"] = t.Overflow;
reply["people"] = people;
reply["maxRecipients"] = MaxRecipients();
if (t.KillsOf != null)
reply["killsOf"] = t.KillsOf;
if (t.Zone != null)
{
reply["zone"] = t.Zone;
Plugin zm = ZoneManagerPlugin();
reply["zoneStanding"] = zm != null && zm.Call("CheckZoneID", t.ZoneId) != null;
}
if (reqId != null)
reply["reqId"] = reqId;
EmitFrame(reply);
}
/// <summary>Main thread. Stop counting and forget. A tally already gone is a success.</summary>
private void HandleTallyClose(Dictionary<string, object> cmd, string reqId)
{
string runId = StringField(cmd, "runId");
if (string.IsNullOrEmpty(runId))
{
EmitRewardError("tally.error", reqId, "malformed", "a close names the run whose tally to forget");
return;
}
bool closed = _runTallies.Remove(runId);
if (closed)
SaveTallies();
_lastRewardSummary = closed ? $"tally for run {runId} closed" : $"tally for run {runId} was already gone";
Dictionary<string, object> reply = Frame("tally.ok", "reply");
reply["runId"] = runId;
reply["closed"] = closed;
if (reqId != null)
reply["reqId"] = reqId;
EmitFrame(reply);
}
private void EmitTallyOk(string reqId, RunTally t, bool repeat)
{
Dictionary<string, object> reply = Frame("tally.ok", "reply");
reply["runId"] = t.RunId;
reply["score"] = t.Score;
reply["remainingMs"] = Math.Max(0L, t.DeadlineMs - NowMs());
reply["repeat"] = repeat;
if (t.Zone != null)
{
reply["zone"] = t.Zone;
reply["zoneId"] = t.ZoneId;
}
if (reqId != null)
reply["reqId"] = reqId;
EmitFrame(reply);
}
private void EmitRewardError(string kind, string reqId, string reason, string message)
{
Dictionary<string, object> frame = Frame(kind, "reply");
frame["reason"] = reason;
if (!string.IsNullOrEmpty(message))
frame["message"] = message;
if (reqId != null)
frame["reqId"] = reqId;
_lastRewardSummary = $"refused ({reason}): {message}";
EmitFrame(frame);
}
/// <summary>
/// Main thread. Credits presence up to <paramref name="now"/> — and no further than the
/// tally's deadline. <paramref name="zm"/> is looked up once per tick by the caller.
/// </summary>
private void Accrue(RunTally t, long now, Plugin zm)
{
long until = Math.Min(now, t.DeadlineMs);
long from = t.LastAccrueMs > 0 ? t.LastAccrueMs : until;
if (until <= from)
{
t.LastAccrueMs = Math.Max(t.LastAccrueMs, until);
return;
}
t.LastAccrueMs = until;
double seconds = Math.Min(until - from, TallyMaxStepMs) / 1000.0;
IEnumerable<BasePlayer> present;
if (t.ZoneId != null)
{
zm = zm ?? ZoneManagerPlugin();
// A zone that is not standing — ZoneManager unloaded, or a wipe took it — gains
// nobody, and keeps what it has.
if (zm == null || zm.Call("CheckZoneID", t.ZoneId) == null)
return;
if (ZoneMembershipTrusted())
{
present = zm.Call("GetPlayersInZone", t.ZoneId) as List<BasePlayer>;
}
else
{
// No helper (D182): ZoneManager's membership misses whoever was standing here
// when the zone was made, so the zone's own shape decides.
var inside = new List<BasePlayer>();
foreach (BasePlayer player in BasePlayer.activePlayerList)
{
if (InZone(zm, t.ZoneId, player, false))
inside.Add(player);
}
present = inside;
}
}
else
{
present = BasePlayer.activePlayerList;
}
if (present == null)
return;
foreach (BasePlayer player in present)
{
if (!CountsAsPresent(player))
continue;
Participant person = PersonIn(t, player, now);
if (person == null)
continue;
person.Seconds += seconds;
_talliesDirty = true;
}
}
/// <summary>Main thread, every five seconds while any tally is held.</summary>
private void TallyTick()
{
long now = NowMs();
List<string> forget = null;
Plugin zm = ZoneManagerPlugin();
foreach (RunTally t in _runTallies.Values)
{
// Forgotten seven days after it opened, whatever its minutes (D108): a website
// that went away must not leave a tally on disk for ever.
if (now >= t.OpenedMs + TallyMaxHoldMs)
{
if (forget == null)
forget = new List<string>();
forget.Add(t.RunId);
continue;
}
if (Counting(t, now) || t.LastAccrueMs < t.DeadlineMs)
Accrue(t, now, zm);
}
if (forget != null)
{
foreach (string id in forget)
_runTallies.Remove(id);
_talliesDirty = true;
_lastRewardSummary = $"{forget.Count} tally(s) seven days old were forgotten";
Puts($"rewards: {_lastRewardSummary}");
}
if (_talliesDirty && (++_tallyTicks % TallySaveEveryTicks == 0 || forget != null))
SaveTallies();
if (_runTallies.Count == 0)
StopTallyTimer();
}
/// <summary>
/// Main thread. The last hit's kill, for every tally counting kills of that kind whose area
/// the killer is standing in when the victim dies (D98).
/// </summary>
private void CreditKill(BasePlayer killer, bool victimIsPlayer)
{
if (_runTallies.Count == 0 || killer == null || killer.IsNpc)
return;
long now = NowMs();
Plugin zm = null;
bool zmLooked = false;
bool trusted = false;
foreach (RunTally t in _runTallies.Values)
{
if (!Counting(t, now) || t.KillsOf == null)
continue;
if (victimIsPlayer ? t.KillsOf == "npcs" : t.KillsOf == "players")
continue;
if (t.ZoneId != null)
{
if (!zmLooked)
{
zm = ZoneManagerPlugin();
trusted = ZoneMembershipTrusted();
zmLooked = true;
}
// Membership with the helper, the zone's shape without it (D182).
if (!InZone(zm, t.ZoneId, killer, trusted))
continue;
}
Participant person = PersonIn(t, killer, now);
if (person == null)
continue;
person.Kills++;
_talliesDirty = true;
}
}
private void EnsureTallyTimer()
{
if (_tallyTimer == null && _runTallies.Count > 0)
_tallyTimer = timer.Every(TallyTickSeconds, TallyTick);
}
private void StopTallyTimer()
{
if (_tallyTimer != null)
{
_tallyTimer.Destroy();
_tallyTimer = null;
}
}
private string TalliesPath()
{
return Path.Combine(Interface.Oxide.DataDirectory, Name, "tallies.json");
}
private string CreditsPath()
{
return Path.Combine(Interface.Oxide.DataDirectory, Name, "credits.json");
}
/// <summary>Main thread. Written at most once a minute while counting, and on every open and close.</summary>
private void SaveTallies()
{
WriteDataFile(TalliesPath(), new List<RunTally>(_runTallies.Values), "tallies");
_talliesDirty = false;
}
private void SaveCredits()
{
WriteDataFile(CreditsPath(), new List<KitCredit>(_credits.Values), "kit credits");
}
private void WriteDataFile(string path, object rows, string what)
{
try
{
Directory.CreateDirectory(Path.GetDirectoryName(path));
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 {what}: {Flatten(ex.Message)}");
}
}
private List<T> ReadDataFile<T>(string path, string what)
{
try
{
if (File.Exists(path))
return JsonConvert.DeserializeObject<List<T>>(File.ReadAllText(path));
}
catch (Exception ex)
{
Puts($"could not read the {what}: {Flatten(ex.Message)}");
}
return null;
}
/// <summary>
/// Main thread, once per load. A tally survives a restart, a reload and a wipe: it records
/// people, not the map. Presence accrues from now — the time the server was down is not
/// time anybody spent in a zone.
/// </summary>
private void LoadRewards()
{
_runTallies = new Dictionary<string, RunTally>();
_credits = new Dictionary<string, KitCredit>();
long now = NowMs();
List<RunTally> tallies = ReadDataFile<RunTally>(TalliesPath(), "tallies");
if (tallies != null)
{
foreach (RunTally t in tallies)
{
if (t == null || string.IsNullOrEmpty(t.RunId))
continue;
if (t.People == null)
t.People = new Dictionary<string, Participant>();
t.LastAccrueMs = Math.Min(now, t.DeadlineMs);
_runTallies[t.RunId] = t;
}
}
List<KitCredit> credits = ReadDataFile<KitCredit>(CreditsPath(), "kit credits");
if (credits != null)
{
foreach (KitCredit c in credits)
{
if (c != null && !string.IsNullOrEmpty(c.SteamId) && !string.IsNullOrEmpty(c.Kit))
_credits[CreditKey(c.SteamId, c.Kit)] = c;
}
}
Puts($"rewards: {_runTallies.Count} tallies and {_credits.Count} kit credits on record");
EnsureTallyTimer();
}
// ── RunicNPC (docs/runicnpc/PLAN.md stage 4, PROTOCOL.md §19.12) ─────────────────────
//
// RunicNPC is Runic Gateway's own NPC plugin. The bridge is its only link to the site
// (runicnpc PLAN.md §4): it calls RunicNPC's API for the site's commands and turns its hooks
// into frames. RunicNPC never talks to the sidecar, so "the sidecar is a dumb forwarder"
// stays true. It is required since RunicNPC's stage 9 (D310): without it every `npc.*` command
// answers `runicnpc-missing`, and so does every NPC an event places, Rust's own included.
/// <summary>
/// The RunicNPC API these commands call: 3 added placements from a website (D249), 4 the
/// faction table and an event's escort, ally and tether (runicnpc stage 5), 5 bosses and
/// passive NPCs (runicnpc stage 6): a profile's `boss` and `use`, and the boss hooks, 6 loot (runicnpc
/// stage 7): a profile's `loot`, and the `dropTable` override a Place NPCs step's switch sends.
/// </summary>
private const int RunicNpcApiNeeded = 6;
private Plugin RunicNpcPlugin()
{
return plugins.Find("RunicNPC");
}
/// <summary>RunicNPC's API version, or 0 when it is not loaded.</summary>
private int RunicNpcApi()
{
Plugin npc = RunicNpcPlugin();
if (npc == null)
return 0;
object api = npc.Call("RunicNpc_ApiVersion");
return api is int ? (int)api : 0;
}
/// <summary>For the integrations body: loaded, its version, and the API it answers.</summary>
private Dictionary<string, object> RunicNpcBody()
{
Dictionary<string, object> body = IntegrationBody(RunicNpcPlugin());
if (RunicNpcPlugin() != null)
body["api"] = RunicNpcApi();
return body;
}
/// <summary>
/// Main thread. The profile name of one of RunicNPC's NPCs, or null for anything else —
/// a Rust scientist, an animal, or any NPC while RunicNPC is not loaded.
/// </summary>
private string RunicNpcProfileOf(BaseEntity entity)
{
if (entity == null)
return null;
Plugin npc = RunicNpcPlugin();
return npc != null ? npc.Call("RunicNpc_ProfileOf", entity) as string : null;
}
/// <summary>
/// Main thread. The plugin to call, or null after answering <c>npc.error</c> — missing, or
/// older than the API these commands need.
/// </summary>
private Plugin RunicNpcFor(string reqId)
{
Plugin npc = RunicNpcPlugin();
if (npc == null)
{
EmitNpcError(reqId, "runicnpc-missing", "RunicNPC is not loaded on this server");
return null;
}
int api = RunicNpcApi();
if (api < RunicNpcApiNeeded)
{
EmitNpcError(reqId, "runicnpc-old",
$"RunicNPC {npc.Version} answers API {api}, and this bridge needs {RunicNpcApiNeeded}");
return null;
}
return npc;
}
/// <summary>
/// Main thread. Whether an event may place NPCs here (stage 9, D310): RunicNPC loaded and
/// answering the API this bridge needs, for a profile and for Rust's own scientists alike.
/// Answers <c>world.error</c> with the reason when not.
/// </summary>
private bool RunicNpcForWorld(string reqId)
{
Plugin npc = RunicNpcPlugin();
if (npc == null)
{
EmitWorldError(reqId, "runicnpc-missing", "this server needs RunicNPC to place NPCs, and it is not loaded");
return false;
}
int api = RunicNpcApi();
if (api < RunicNpcApiNeeded)
{
EmitWorldError(reqId, "runicnpc-old",
$"this server needs RunicNPC to place NPCs: RunicNPC {npc.Version} answers API {api}, and this bridge needs {RunicNpcApiNeeded}");
return false;
}
return true;
}
/// <summary>
/// Main thread. <c>npc.profiles</c>: the server's profile set as RunicNPC holds it — whether
/// a site manages it, every profile, and those refused and why. The site reads this before
/// its first push to a server, to adopt the server's own (D244).
/// </summary>
private void HandleNpcProfiles(string reqId)
{
Plugin npc = RunicNpcFor(reqId);
if (npc == null)
return;
var all = npc.Call("RunicNpc_Profiles") as Newtonsoft.Json.Linq.JObject;
if (all == null)
{
EmitNpcError(reqId, "refused", "RunicNPC did not answer with its profiles");
return;
}
Dictionary<string, object> reply = Frame("npc.profiles", "reply");
reply["managed"] = all["managed"] != null && (bool)all["managed"];
reply["profiles"] = all["profiles"] ?? new Newtonsoft.Json.Linq.JObject();
reply["refused"] = all["refused"] ?? new Newtonsoft.Json.Linq.JObject();
// Runicnpc stage 5 (D254): the faction table, read with the profiles so the site can adopt it.
reply["factions"] = all["factions"] ?? new Newtonsoft.Json.Linq.JArray();
if (reqId != null)
reply["reqId"] = reqId;
EmitFrame(reply);
}
/// <summary>
/// Main thread. <c>npc.profiles.set</c>: replaces the whole profile set, as the site does on
/// every push, and so marks the server managed (D221). RunicNPC checks each profile — a kit
/// this server lacks, for one — and the answer lists those it refused.
/// </summary>
private void HandleNpcProfilesSet(Dictionary<string, object> cmd, string reqId)
{
Plugin npc = RunicNpcFor(reqId);
if (npc == null)
return;
object value;
var profiles = cmd.TryGetValue("profiles", out value) ? value as Newtonsoft.Json.Linq.JObject : null;
if (profiles == null)
{
EmitNpcError(reqId, "malformed", "a push carries `profiles`, an object of profiles by name");
return;
}
// Runicnpc stage 5 (D254): the faction table travels with the profiles. It is set first, so the
// profiles are checked against the table they will be used with; a refused table is reported
// and the old one kept, and the profiles are pushed either way.
var factions = cmd.TryGetValue("factions", out value) ? value as Newtonsoft.Json.Linq.JArray : null;
string factionsRefused = factions != null ? npc.Call("RunicNpc_SetFactions", factions) as string : null;
var refused = npc.Call("RunicNpc_SetProfiles", profiles) as Dictionary<string, string>;
_lastNpcSummary = $"profiles pushed: {profiles.Count}, refused {(refused != null ? refused.Count : 0)}" +
(factions != null ? $", factions {factions.Count}{(factionsRefused != null ? " REFUSED" : "")}" : "");
Puts($"runicnpc: {_lastNpcSummary}");
Dictionary<string, object> reply = Frame("npc.ok", "reply");
reply["refused"] = refused ?? new Dictionary<string, string>();
if (factionsRefused != null)
reply["factionsRefused"] = factionsRefused;
if (reqId != null)
reply["reqId"] = reqId;
EmitFrame(reply);
}
/// <summary>
/// Main thread. <c>npc.placements</c>: every placement with its values, how many of its NPCs
/// are alive, what it waits for and its note (D237, D239); the routes a placement may walk;
/// and the cost warning for what the server plans now (D227).
/// </summary>
private void HandleNpcPlacements(string reqId)
{
Plugin npc = RunicNpcFor(reqId);
if (npc == null)
return;
var routes = npc.Call("RunicNpc_Routes") as Newtonsoft.Json.Linq.JObject;
var names = new List<string>();
if (routes != null)
{
foreach (KeyValuePair<string, Newtonsoft.Json.Linq.JToken> route in routes)
names.Add(route.Key);
}
names.Sort(StringComparer.Ordinal);
Dictionary<string, object> reply = Frame("npc.placements", "reply");
reply["placements"] = npc.Call("RunicNpc_Placements") ?? new List<Dictionary<string, object>>();
reply["routes"] = names;
reply["cost"] = npc.Call("RunicNpc_CostWarning", 0) as string;
if (reqId != null)
reply["reqId"] = reqId;
EmitFrame(reply);
}
/// <summary>
/// Main thread. <c>npc.placement</c>: one change to one placement, by <c>op</c> — <c>add</c>
/// (a new one, named by RunicNPC as in game, from a map point it puts on the ground and
/// checks against the navmesh: D245, D246), <c>set</c> (new values for an existing one),
/// <c>remove</c>, <c>rename</c> and <c>respawn</c>. Every refusal is RunicNPC's own sentence.
/// </summary>
private void HandleNpcPlacement(Dictionary<string, object> cmd, string reqId)
{
Plugin npc = RunicNpcFor(reqId);
if (npc == null)
return;
string op = StringField(cmd, "op");
string id = StringField(cmd, "id");
object value;
var placement = cmd.TryGetValue("placement", out value) ? value as Newtonsoft.Json.Linq.JObject : null;
Dictionary<string, object> reply = Frame("npc.ok", "reply");
switch (op)
{
case "add":
{
if (placement == null)
{
EmitNpcError(reqId, "malformed", "add carries `placement`");
return;
}
var answer = npc.Call("RunicNpc_AddPlacement", placement) as Dictionary<string, object>;
object error = null;
if (answer == null || answer.TryGetValue("error", out error))
{
EmitNpcError(reqId, "refused", answer != null ? error as string : "RunicNPC did not answer");
return;
}
Merge(reply, answer);
id = answer["id"] as string;
break;
}
case "set":
{
if (string.IsNullOrEmpty(id) || placement == null)
{
EmitNpcError(reqId, "malformed", "set carries `id` and `placement`");
return;
}
if (!PlacementExists(npc, id))
{
EmitNpcError(reqId, "not-found", $"there is no placement '{id}'");
return;
}
string error = npc.Call("RunicNpc_SetPlacement", id, placement) as string;
if (error != null)
{
EmitNpcError(reqId, "refused", error);
return;
}
reply["id"] = id;
reply["cost"] = npc.Call("RunicNpc_CostWarning", 0) as string;
break;
}
case "remove":
{
object removed = npc.Call("RunicNpc_RemovePlacement", id);
if (!(removed is bool) || !(bool)removed)
{
EmitNpcError(reqId, "not-found", $"there is no placement '{id}'");
return;
}
reply["id"] = id;
break;
}
case "rename":
{
string to = StringField(cmd, "to");
if (string.IsNullOrEmpty(id) || string.IsNullOrEmpty(to))
{
EmitNpcError(reqId, "malformed", "rename carries `id` and `to`");
return;
}
if (!PlacementExists(npc, id))
{
EmitNpcError(reqId, "not-found", $"there is no placement '{id}'");
return;
}
string error = npc.Call("RunicNpc_RenamePlacement", id, to) as string;
if (error != null)
{
EmitNpcError(reqId, "refused", error);
return;
}
reply["id"] = to;
reply["previous"] = id;
break;
}
case "respawn":
{
object count = npc.Call("RunicNpc_RespawnPlacement", id);
int n = count is int ? (int)count : -1;
if (n < 0)
{
EmitNpcError(reqId, "not-found", $"there is no placement '{id}'");
return;
}
reply["id"] = id;
reply["respawned"] = n;
break;
}
default:
EmitNpcError(reqId, "malformed", "op is add, set, remove, rename or respawn");
return;
}
_lastNpcSummary = $"placement {op} {id}";
Puts($"runicnpc: {_lastNpcSummary}");
if (reqId != null)
reply["reqId"] = reqId;
EmitFrame(reply);
}
private static bool PlacementExists(Plugin npc, string id)
{
var all = npc.Call("RunicNpc_Placements") as List<Dictionary<string, object>>;
if (all == null)
return false;
foreach (Dictionary<string, object> row in all)
{
object rowId;
if (row.TryGetValue("id", out rowId) && rowId as string == id)
return true;
}
return false;
}
private void EmitNpcError(string reqId, string reason, string message)
{
Dictionary<string, object> frame = Frame("npc.error", "reply");
frame["reason"] = reason;
if (!string.IsNullOrEmpty(message))
frame["message"] = message;
if (reqId != null)
frame["reqId"] = reqId;
_lastNpcSummary = $"refused ({reason}): {message}";
EmitFrame(frame);
}
/// <summary>What the last RunicNPC command did, for <c>rg.status</c>.</summary>
private string _lastNpcSummary;
// ---- RunicNPC's hooks, as frames ----
/// <summary>
/// One of RunicNPC's NPCs died (stage 4). <c>npc.died</c> carries its profile and name, who
/// landed the killing blow, and every player who took health from it, most first — what a
/// phase waiting on "8 guards died" counts, and what a boss's reward goes to (stage 6).
/// The per-player kill count is the tally's (<c>npcProfileKills</c>), not this frame's.
/// </summary>
private void OnRunicNpcDied(BasePlayer npc, string profile, string owner, HitInfo info, Dictionary<ulong, float> contributors)
{
MarkHook("OnRunicNpcDied");
if (npc == null)
return;
Dictionary<string, object> frame = Frame("npc.died", "event");
PutNpc(frame, npc, profile, owner);
BasePlayer killer = info != null ? info.InitiatorPlayer : null;
if (killer != null && !killer.IsNpc)
{
frame["killerId"] = ((ulong)killer.userID).ToString();
frame["killerName"] = killer.displayName;
}
string weapon = WeaponOf(info);
if (weapon != null)
frame["weapon"] = weapon;
var rows = new List<object>();
if (contributors != null)
{
var sorted = new List<KeyValuePair<ulong, float>>(contributors);
sorted.Sort((a, b) => b.Value.CompareTo(a.Value));
foreach (KeyValuePair<ulong, float> kv in sorted)
{
BasePlayer who = BasePlayer.FindByID(kv.Key) ?? BasePlayer.FindSleeping(kv.Key);
var row = new Dictionary<string, object>
{
{ "steamId", kv.Key.ToString() },
{ "damage", (float)Math.Round(kv.Value, 1) }
};
if (who != null)
row["name"] = who.displayName;
rows.Add(row);
}
}
frame["contributors"] = rows;
EmitFrame(frame);
}
/// <summary>An NPC fell to one of its profile's <c>healthThresholds</c> (a boss below 50%).</summary>
private void OnRunicNpcHealth(BasePlayer npc, string profile, float threshold)
{
MarkHook("OnRunicNpcHealth");
if (npc == null)
return;
Dictionary<string, object> frame = Frame("npc.health", "event");
PutNpc(frame, npc, profile, null);
frame["threshold"] = threshold;
frame["health"] = (float)Math.Round(npc.health, 1);
frame["maxHealth"] = (float)Math.Round(npc.MaxHealth(), 1);
EmitFrame(frame);
}
/// <summary>
/// A placement was set, removed or renamed, from the site or in game (D249). The frame says
/// which; the site reads the list again rather than trusting a diff.
/// </summary>
private void OnRunicNpcPlacementChanged(string id, string change, string previous)
{
MarkHook("OnRunicNpcPlacementChanged");
Dictionary<string, object> frame = Frame("npc.placement.changed", "event");
frame["id"] = id;
frame["change"] = change;
if (!string.IsNullOrEmpty(previous))
frame["previous"] = previous;
EmitFrame(frame);
}
/// <summary>The fields every NPC frame shares. <c>owner</c> null reads it from the NPC's placement, if any.</summary>
// ---- bosses (runicnpc stage 6: D277) ----
//
// Three frames for the site's public boss triggers: spawned, a phase, killed. A boss's death
// also raises npc.died as every NPC's does; npc.boss.died adds the boss's own facts, and names
// no Steam id but the killer's, because the triggers it feeds are public.
/// <summary>A boss spawned: an admin's placement, an event's step or another plugin.</summary>
private void OnRunicNpcBossSpawned(BasePlayer npc, string profile, string owner)
{
MarkHook("OnRunicNpcBossSpawned");
if (npc == null)
return;
Dictionary<string, object> frame = Frame("npc.boss.spawned", "event");
PutNpc(frame, npc, profile, owner);
frame["maxHealth"] = (float)Math.Round(npc.MaxHealth(), 1);
EmitFrame(frame);
}
/// <summary>A boss entered a phase: <c>phase</c> counts from 1, <c>at</c> is its health fraction.</summary>
private void OnRunicNpcBossPhase(BasePlayer npc, string profile, string owner, int phase, float at)
{
MarkHook("OnRunicNpcBossPhase");
if (npc == null)
return;
Dictionary<string, object> frame = Frame("npc.boss.phase", "event");
PutNpc(frame, npc, profile, owner);
frame["phase"] = phase;
frame["at"] = at;
frame["health"] = (float)Math.Round(npc.health, 1);
frame["maxHealth"] = (float)Math.Round(npc.MaxHealth(), 1);
EmitFrame(frame);
}
/// <summary>A boss died: the killing blow's player, and how many players hurt it.</summary>
private void OnRunicNpcBossDied(BasePlayer npc, string profile, string owner, HitInfo info, Dictionary<ulong, float> contributors)
{
MarkHook("OnRunicNpcBossDied");
if (npc == null)
return;
Dictionary<string, object> frame = Frame("npc.boss.died", "event");
PutNpc(frame, npc, profile, owner);
BasePlayer killer = info != null ? info.InitiatorPlayer : null;
if (killer != null && !killer.IsNpc)
{
frame["killerId"] = ((ulong)killer.userID).ToString();
frame["killerName"] = killer.displayName;
}
frame["damagers"] = contributors != null ? contributors.Count : 0;
EmitFrame(frame);
}
private void PutNpc(Dictionary<string, object> frame, BasePlayer npc, string profile, string owner)
{
frame["netId"] = npc.net != null ? npc.net.ID.Value.ToString(System.Globalization.CultureInfo.InvariantCulture) : null;
frame["profile"] = profile;
frame["name"] = npc.displayName;
if (!string.IsNullOrEmpty(owner))
{
frame["owner"] = owner;
// An event run's NPC names its run, for the site's phase gates; a placement's names
// the placement. `plugin:` owners are another plugin's business.
if (owner.StartsWith("run:", StringComparison.Ordinal))
frame["runId"] = owner.Substring(4);
else if (owner.StartsWith("placement:", StringComparison.Ordinal))
frame["placement"] = owner.Substring(10);
}
UnityEngine.Vector3 at = npc.transform.position;
frame["x"] = (float)Math.Round(at.x, 1);
frame["z"] = (float)Math.Round(at.z, 1);
}
// ---- events: an NPC of a profile (D243) ----
/// <summary>
/// Main thread. <c>world.place</c> with a <c>profile</c>: the NPCs are RunicNPC's, owned by
/// the run (<c>run:&lt;runId&gt;</c>), so its restart and teardown rules are the bridge's as
/// for any placed NPC (§15.3). All or nothing, like a prefab: if RunicNPC refuses one, the
/// ones already spawned are removed.
/// </summary>
private void HandleWorldPlaceProfile(Dictionary<string, object> cmd, string reqId, string key, string runId, string profile)
{
if (!RunicNpcForWorld(reqId))
return;
Plugin npc = RunicNpcPlugin();
var profiles = npc.Call("RunicNpc_Profiles") as Newtonsoft.Json.Linq.JObject;
Newtonsoft.Json.Linq.JToken known = profiles != null ? profiles["profiles"]?[profile] : null;
if (known == null)
{
EmitWorldError(reqId, "unknown-profile", $"this server has no RunicNPC profile '{profile}'");
return;
}
Newtonsoft.Json.Linq.JToken refusal = profiles["refused"]?[profile];
if (refusal != null)
{
EmitWorldError(reqId, "unknown-profile", $"RunicNPC refuses the profile '{profile}': {refusal}");
return;
}
int count = IntField(cmd, "count");
int max = _config != null ? _config.EventsMaxNpcs : WorldHardMaxNpcs;
if (count < 1 || count > max)
{
EmitWorldError(reqId, "out-of-range", $"this server places 1 to {max} NPCs 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, message;
if (!ResolveWorldPoint(cmd, out center, out reason, out message))
{
EmitWorldError(reqId, reason, message);
return;
}
NpcOrders orders;
if (!ReadNpcOrders(cmd, runId, center, out orders, out reason, out message))
{
EmitWorldError(reqId, reason, message);
return;
}
string owner = "run:" + runId;
var made = new List<WorldEntry>();
// Runicnpc stage 7 (D296): the step's "drop loot" switch. Off, the NPCs keep their corpse's start
// but roll no loot table; on (or absent, a step older than the switch) they drop it as anywhere.
object dropLootValue;
bool dropLoot = !(cmd.TryGetValue("dropLoot", out dropLootValue) && dropLootValue is bool && !(bool)dropLootValue);
Newtonsoft.Json.Linq.JObject spawnOverrides = dropLoot ? null : Newtonsoft.Json.Linq.JObject.Parse("{\"loot\":{\"dropTable\":false}}");
for (int i = 0; i < count; i++)
{
BasePlayer spawned = null;
// A scattered point may land off the navmesh (a rock, a slope); a few tries, then the
// centre itself, before the step is refused.
for (int attempt = 0; attempt < 4 && spawned == null; attempt++)
{
UnityEngine.Vector3 at = center;
if (count > 1 && attempt < 3)
{
UnityEngine.Vector2 jitter = UnityEngine.Random.insideUnitCircle * (float)spread;
at += new UnityEngine.Vector3(jitter.x, 0f, jitter.y);
at.y = TerrainMeta.HeightMap.GetHeight(at);
}
spawned = npc.Call("RunicNpc_Spawn", at, profile, owner, spawnOverrides) as BasePlayer;
}
if (spawned == null)
{
foreach (WorldEntry e in made)
npc.Call("RunicNpc_Despawn", ulong.Parse(e.Id, System.Globalization.CultureInfo.InvariantCulture));
EmitWorldError(reqId, "refused",
$"RunicNPC would not spawn '{profile}' there (off the navmesh, a cap, or the navmesh still building; the server log says which)");
return;
}
string orderRefusal = ApplyNpcOrders(npc, spawned, orders);
if (orderRefusal != null)
{
npc.Call("RunicNpc_Despawn", spawned.net.ID.Value);
foreach (WorldEntry e in made)
npc.Call("RunicNpc_Despawn", ulong.Parse(e.Id, System.Globalization.CultureInfo.InvariantCulture));
EmitWorldError(reqId, "refused", $"RunicNPC would not give '{profile}' its orders: {orderRefusal}");
return;
}
UnityEngine.Vector3 put = spawned.transform.position;
made.Add(new WorldEntry
{
Id = spawned.net.ID.Value.ToString(System.Globalization.CultureInfo.InvariantCulture),
Kind = WorldKindNpc,
Prefab = RunicNpcPrefix + profile,
RunId = runId,
Key = key,
X = put.x,
Y = put.y,
Z = put.z,
BootId = _bootId,
WipeId = _wipeId,
TakenMs = NowMs()
});
}
foreach (WorldEntry e in made)
_world[e.Id] = e;
SaveWorld();
_lastWorldSummary = $"placed {count} of RunicNPC profile {profile} for run {runId}";
Puts($"world: {_lastWorldSummary}");
EmitWorldPlaced(reqId, made, false);
}
/// <summary>A registry entry's prefab for a RunicNPC profile: <c>runicnpc:&lt;profile&gt;</c>.</summary>
private const string RunicNpcPrefix = "runicnpc:";
// ---- an event's NPCs: escort, ally and tether (runicnpc stage 5, D269, D270, D272) ----
/// <summary>What a placing step tells its NPCs to do besides their profile. Every part is optional.</summary>
private sealed class NpcOrders
{
/// <summary>D269: the player they escort.</summary>
public BasePlayer Escort;
/// <summary>D270: <c>clan</c> (the game's clan id) or <c>player</c> (a Steam id: that player and their team).</summary>
public string AllyKind;
public string AllyId;
/// <summary>D272: the zone of this run that holds the step's point.</summary>
public string Tether;
public bool Any => Escort != null || AllyKind != null || Tether != null;
}
/// <summary>
/// Main thread. Reads <c>escort</c> (a Steam id), <c>ally</c> (<c>{kind: clan|player, id}</c>) and
/// <c>tether</c> (true: the run's own zone around the point) from a <c>world.place</c>. False with
/// the reason when one cannot be honoured, so the step is refused before anything spawns.
/// </summary>
private bool ReadNpcOrders(Dictionary<string, object> cmd, string runId, UnityEngine.Vector3 center,
out NpcOrders orders, out string reason, out string message)
{
orders = new NpcOrders();
reason = null;
message = null;
string escort = StringField(cmd, "escort");
if (!string.IsNullOrEmpty(escort))
{
ulong steamId;
if (!ulong.TryParse(escort, out steamId))
{
reason = "malformed";
message = $"an escort is a player's Steam id, not '{escort}'";
return false;
}
BasePlayer player = BasePlayer.FindByID(steamId);
if (player == null || !player.IsConnected || player.IsDead())
{
reason = "escort-offline";
message = $"the player {steamId} is not on the server, so there is nobody to escort";
return false;
}
orders.Escort = player;
}
object allyValue;
var ally = cmd.TryGetValue("ally", out allyValue) ? allyValue as Newtonsoft.Json.Linq.JObject : null;
if (ally != null)
{
orders.AllyKind = (string)ally["kind"];
orders.AllyId = (string)ally["id"];
if ((orders.AllyKind != "clan" && orders.AllyKind != "player") || string.IsNullOrEmpty(orders.AllyId))
{
reason = "malformed";
message = "an ally is {kind: clan|player, id}";
return false;
}
}
object tetherValue;
if (cmd.TryGetValue("tether", out tetherValue) && tetherValue is bool && (bool)tetherValue)
{
orders.Tether = RunZoneAt(runId, center);
if (orders.Tether == null)
{
reason = "no-zone";
message = "no zone this event made holds that point, so there is nothing to keep the NPCs inside";
return false;
}
}
if (orders.Any && RunicNpcApi() < 4)
{
reason = "runicnpc-old";
message = $"escort, ally and tether need RunicNPC's API 4, and it answers {RunicNpcApi()}";
return false;
}
return true;
}
/// <summary>Main thread. The zone a run made that holds a point, or null.</summary>
private string RunZoneAt(string runId, UnityEngine.Vector3 at)
{
Plugin zm = plugins.Find("ZoneManager");
if (zm == null)
return null;
foreach (WorldEntry e in _world.Values)
{
if (e.Kind != WorldKindZone || e.RunId != runId || ZoneExpired(e))
continue;
object inside = zm.Call("IsPositionInZone", e.Id, at);
if (inside is bool && (bool)inside)
return e.Id;
}
return null;
}
/// <summary>Main thread. Gives one spawned NPC its orders; null, or RunicNPC's reason for refusing one.</summary>
private static string ApplyNpcOrders(Plugin npc, BasePlayer spawned, NpcOrders orders)
{
if (!orders.Any)
return null;
ulong netId = spawned.net.ID.Value;
string refusal = null;
if (orders.Escort != null)
refusal = npc.Call("RunicNpc_Escort", netId, (BaseEntity)orders.Escort) as string;
if (refusal == null && orders.AllyKind != null)
refusal = npc.Call("RunicNpc_Ally", netId, orders.AllyKind, orders.AllyId) as string;
if (refusal == null && orders.Tether != null)
refusal = npc.Call("RunicNpc_Tether", netId, orders.Tether) as string;
return refusal;
}
/// <summary>
/// Main thread. Removes a run's RunicNPC NPC through RunicNPC, so it hears of it (and raises
/// its own despawn hook); anything else, or RunicNPC gone, is killed as any entity is.
/// </summary>
private bool KillWorldNpc(WorldEntry e)
{
Plugin npc = RunicNpcPlugin();
ulong netId;
if (npc != null && e.Prefab != null && e.Prefab.StartsWith(RunicNpcPrefix, StringComparison.Ordinal)
&& ulong.TryParse(e.Id, out netId))
{
object removed = npc.Call("RunicNpc_Despawn", netId);
if (removed is int && (int)removed > 0)
return true;
}
return KillEntity(e.Id);
}
/// <summary>RunicNPC as the bridge sees it: loaded, its API, and the last command.</summary>
[ConsoleCommand("rg.npc")]
private void CmdNpc(ConsoleSystem.Arg arg)
{
if (arg.Connection != null && !arg.IsAdmin)
return;
Plugin npc = RunicNpcPlugin();
arg.ReplyWith(npc == null
? "RunicNPC: not loaded. npc.* commands and every NPC an event places answer runicnpc-missing (it is required)."
: $"RunicNPC {npc.Version}, API {RunicNpcApi()} (this bridge needs {RunicNpcApiNeeded}). last: {_lastNpcSummary ?? "none yet"}");
}
// ── Kits ──────────────────────────────────────────────────────────────────
private Plugin KitsPlugin()
{
return plugins.Find("Kits");
}
/// <summary>Main thread. The kits a reward can name, and what gates each (the kit source).</summary>
private void HandleKitsList(string reqId)
{
Plugin kits = KitsPlugin();
if (kits == null)
{
EmitRewardError("kits.error", reqId, "kits-missing", "Kits is not loaded on this server");
return;
}
var names = new List<string>();
kits.Call("GetKitNames", names);
var rows = new List<object>();
foreach (string name in names)
{
if (string.IsNullOrEmpty(name))
continue;
var info = kits.Call("GetKitInfo", name) as Newtonsoft.Json.Linq.JObject;
if (info == null)
continue;
rows.Add(new Dictionary<string, object>
{
{ "name", name },
{ "permission", (string)info["permission"] ?? "" },
{ "max", info["max"] != null ? (int)info["max"] : 0 },
{ "cooldown", info["cooldown"] != null ? (int)info["cooldown"] : 0 },
{ "hidden", info["hide"] != null && (bool)info["hide"] },
{ "description", Flatten((string)info["description"] ?? "") }
});
}
Dictionary<string, object> reply = Frame("kits.list", "reply");
reply["kits"] = rows;
reply["maxRecipients"] = MaxRecipients();
reply["maxTallies"] = MaxTallies();
reply["maxChat"] = MaxChat();
reply["eventsEnabled"] = _config != null && _config.EventsEnabled;
if (reqId != null)
reply["reqId"] = reqId;
EmitFrame(reply);
}
private static string CreditKey(string steamId, string kit)
{
return steamId + "|" + Lower(kit);
}
private static int CallInt(Plugin plugin, string method, params object[] args)
{
object value = plugin.Call(method, args);
return value is int ? (int)value : 0;
}
/// <summary>
/// Main thread. Brings one player's credits for one kit into line with what the site says
/// they earned and what Kits says they have used. Three steps, in this order:
///
/// <list type="number">
/// <item><description>
/// <b>Notice a redemption.</b> A moved credit lowered the use count to one below the
/// kit's limit; if the count is back at the limit, the player has redeemed it. This is
/// how <c>OnKitRedeemed</c> is read, and it also heals a redemption made while this
/// plugin was unloaded.
/// </description></item>
/// <item><description>
/// <b>Withdraw what the site took back</b> (a revert). Only a moved credit not yet
/// redeemed is put back; a redemption already made stands (R16). A use count is
/// never raised past the kit's limit.
/// </description></item>
/// <item><description>
/// <b>Spend the next one, last.</b> Only when the player has used the kit up, and one
/// at a time, so the credit is always the use after the kit's own.
/// </description></item>
/// </list>
/// </summary>
private void SettleCredit(Plugin kits, KitCredit c, ref int applied, ref int withdrawn)
{
if (kits == null)
return;
int max = CallInt(kits, "GetKitMaxUses", c.Kit);
// A kit with no use limit has no "one more use" to give; its permission is the reward.
if (max <= 0)
return;
ulong id;
if (!ulong.TryParse(c.SteamId, out id))
return;
int uses = CallInt(kits, "GetPlayerKitUses", id, c.Kit);
if (c.Applied > c.Consumed && uses >= max)
c.Consumed = c.Applied;
if (c.Applied > c.Earned)
{
int withdraw = Math.Min(c.Applied - c.Earned, c.Applied - c.Consumed);
if (withdraw > 0)
{
kits.Call("SetPlayerKitUses", id, c.Kit, Math.Min(uses + withdraw, max));
uses = CallInt(kits, "GetPlayerKitUses", id, c.Kit);
c.Applied -= withdraw;
withdrawn += withdraw;
}
// What is left over was redeemed and stands. It is forgiven rather than held
// against the next credit this player earns: the reward it came from is gone.
c.Applied = Math.Min(c.Applied, c.Earned);
c.Consumed = Math.Min(c.Consumed, c.Applied);
}
if (c.Applied < c.Earned && c.Applied == c.Consumed && uses >= max)
{
// `SetPlayerKitUses` is a no-op for a player with no usage record of this kit, so
// the read-back is what makes the count true — the same rule as the grants (D85).
kits.Call("SetPlayerKitUses", id, c.Kit, max - 1);
if (CallInt(kits, "GetPlayerKitUses", id, c.Kit) == max - 1)
{
c.Applied++;
applied++;
}
}
}
/// <summary>
/// Main thread, at the end of a <c>perm.sync</c> that carried <c>credits</c>. The field is
/// the site's whole set for this server: a player and kit it no longer names has earned
/// nothing, and their unredeemed credit is withdrawn.
/// </summary>
private void ReconcileCredits(PermSyncJob job)
{
var desired = new Dictionary<string, KitCredit>();
foreach (KitCredit row in job.Credits)
{
string key = CreditKey(row.SteamId, row.Kit);
KitCredit have;
if (desired.TryGetValue(key, out have))
have.Earned += row.Earned;
else
desired[key] = row;
}
foreach (KitCredit c in _credits.Values)
{
if (!desired.ContainsKey(CreditKey(c.SteamId, c.Kit)))
c.Earned = 0;
}
foreach (KeyValuePair<string, KitCredit> kv in desired)
{
KitCredit c;
if (!_credits.TryGetValue(kv.Key, out c))
{
c = new KitCredit { SteamId = kv.Value.SteamId, Kit = kv.Value.Kit };
_credits[kv.Key] = c;
}
c.Earned = kv.Value.Earned;
}
Plugin kits = KitsPlugin();
var unknown = new HashSet<string>();
foreach (KitCredit c in _credits.Values)
{
if (kits == null)
continue;
object isKit = kits.Call("IsKit", c.Kit);
if (!(isKit is bool) || !(bool)isKit)
{
if (c.Earned > 0 && unknown.Add(c.Kit))
job.CreditsUnresolved.Add(c.Kit);
continue;
}
SettleCredit(kits, c, ref job.CreditsApplied, ref job.CreditsWithdrawn);
}
var empty = new List<string>();
foreach (KeyValuePair<string, KitCredit> kv in _credits)
{
if (kv.Value.Earned == 0 && kv.Value.Applied == 0)
empty.Add(kv.Key);
}
foreach (string key in empty)
_credits.Remove(key);
job.CreditsWaiting = kits == null;
SaveCredits();
}
/// <summary>
/// Void, like every hook here: Kits raises this after a redemption has been counted, which
/// is the only moment a player's usage record of the kit is certain to exist.
/// </summary>
private void OnKitRedeemed(BasePlayer player, string kitName)
{
MarkHook("OnKitRedeemed");
if (player == null || string.IsNullOrEmpty(kitName) || _credits.Count == 0)
return;
KitCredit c;
if (!_credits.TryGetValue(CreditKey(SteamIdOf(player), kitName), out c))
return;
int applied = 0, withdrawn = 0;
SettleCredit(KitsPlugin(), c, ref applied, ref withdrawn);
SaveCredits();
if (applied > 0)
_lastRewardSummary = $"{player.displayName} redeemed {kitName}; one more use applied from a reward";
}
/// <summary>
/// Whether Kits clears its usage data at a wipe (its own <c>Wipe player data when the
/// server is wiped</c>, off by default). Read from its config file, because the switch
/// is not in its API — and on Carbon that file is under <c>carbon/configs</c>, which
/// is why the directory comes from the framework and never from a literal (R19).
/// </summary>
private bool KitsWipesPlayerData()
{
try
{
string path = Path.Combine(Interface.Oxide.ConfigDirectory, "Kits.json");
if (!File.Exists(path))
return false;
var config = Newtonsoft.Json.Linq.JObject.Parse(File.ReadAllText(path));
Newtonsoft.Json.Linq.JToken wipe = config["Wipe player data when the server is wiped"];
return wipe != null && wipe.Type == Newtonsoft.Json.Linq.JTokenType.Boolean && (bool)wipe;
}
catch (Exception ex)
{
Puts($"could not read Kits' wipe setting: {Flatten(ex.Message)}");
return false;
}
}
/// <summary>
/// Main thread, from <c>OnNewSave</c>. When Kits has wiped every use count, a credit this
/// plugin moved into one is gone with it — so the record of moving it goes too, or the
/// next redemption would be read as spending a credit that no longer exists. What the
/// site says was earned stays: a wipe does not take a reward away.
/// </summary>
private void ResetCreditsForWipe()
{
if (_credits.Count == 0 || !KitsWipesPlayerData())
return;
foreach (KitCredit c in _credits.Values)
{
c.Applied = 0;
c.Consumed = 0;
}
SaveCredits();
Puts($"rewards: Kits wipes use counts at a new save, so {_credits.Count} kit credits start over");
}
// ── Chat ──────────────────────────────────────────────────────────────────
/// <summary>
/// Main thread. One line to everybody on the server. A key said in the last ten minutes
/// is answered <c>ok</c> and not said again, so a retried step never repeats itself.
/// A run's line is behind <c>EventsEnabled</c>; a news post is behind the site's own
/// switch instead (D104), because news is not an event.
/// </summary>
private void HandleChatSay(Dictionary<string, object> cmd, string reqId)
{
string key = StringField(cmd, "key");
bool fromEvent = cmd.ContainsKey("event") && string.Equals(StringField(cmd, "event"), "true", StringComparison.OrdinalIgnoreCase);
string message = Flatten(StringField(cmd, "message"));
long now = NowMs();
if (fromEvent && (_config == null || !_config.EventsEnabled))
{
EmitRewardError("chat.error", reqId, "events-disabled",
"events are switched off on this server — set EventsEnabled in the RunicGateway plugin config");
return;
}
if (string.IsNullOrEmpty(key) || string.IsNullOrEmpty(message))
{
EmitRewardError("chat.error", reqId, "malformed", "a chat line has a key and a message");
return;
}
if (message.Length > MaxChat())
{
EmitRewardError("chat.error", reqId, "too-long",
$"this server says at most {MaxChat()} characters in one line, not {message.Length}");
return;
}
// Protocol 12: where the line goes, and the voice it is said in (D140, D141). A caller
// from protocol 11 sends neither and is answered exactly as before.
string delivery = StringField(cmd, "delivery");
if (string.IsNullOrEmpty(delivery))
delivery = DeliveryChat;
if (delivery != DeliveryChat && delivery != DeliveryPopup)
{
EmitRewardError("chat.error", reqId, "malformed",
$"a line is delivered to chat or to a popup, not to '{delivery}'");
return;
}
string format = StringField(cmd, "format");
if (format != null && (format.Length > ChatFormatMax || Occurrences(format, "{message}") != 1))
{
EmitRewardError("chat.error", reqId, "malformed",
$"a chat format holds exactly one {{message}} and at most {ChatFormatMax} characters");
return;
}
Plugin popup = null;
if (delivery == DeliveryPopup)
{
popup = PopupPlugin();
// R3: a refusal with a reason, never a line silently said somewhere else.
if (popup == null)
{
EmitRewardError("chat.error", reqId, "popup-unavailable",
"PopupNotifications is not loaded on this server, so a popup cannot be shown — install it, or deliver this line to chat");
return;
}
}
var stale = new List<string>();
foreach (KeyValuePair<string, long> kv in _chatKeys)
{
if (now - kv.Value > ChatKeyMemoryMs)
stale.Add(kv.Key);
}
foreach (string s in stale)
_chatKeys.Remove(s);
bool repeat = _chatKeys.ContainsKey(key);
if (!repeat)
{
// A popup ignores the format: its panel is not a chat line. A null player is
// everybody connected, and a duration of 0 is the plugin's own default. Nothing
// queues for a player who is offline (§33.1).
string line = popup != null || format == null ? message : Voice(format, message);
if (popup != null)
popup.Call("CreatePopupNotification", line, null, 0f);
else
Server.Broadcast(line);
// Logged, because the game logs neither a broadcast nor a popup, and a line the
// website put in front of every player deserves a record on the host (§33.3).
Puts($"said ({delivery}): {line}");
_chatKeys[key] = now;
_lastRewardSummary = $"said a line ({message.Length} chars, {delivery}{(format != null ? ", voiced" : "")}) to {BasePlayer.activePlayerList.Count} players";
}
Dictionary<string, object> reply = Frame("chat.ok", "reply");
reply["said"] = !repeat;
reply["repeat"] = repeat;
reply["players"] = BasePlayer.activePlayerList.Count;
reply["delivery"] = delivery;
if (reqId != null)
reply["reqId"] = reqId;
EmitFrame(reply);
}
// ── The optional mods (protocol 12) ───────────────────────────────────────
//
// BetterChat and PopupNotifications are optional (PLAN.md §33, D141). Each is looked up at
// the moment it is needed, never held from our load, because an operator can load or
// unload either at any time; and every call into one that is missing answers with a
// reason instead of doing nothing quietly (R3).
private const string DeliveryChat = "chat";
private const string DeliveryPopup = "popup";
/// <summary>The most titles one set carries. The site has its own, lower, limit.</summary>
private const int TitlesMax = 500;
/// <summary>
/// The longest one player's title string may be. The site caps a title's text at 24
/// characters (§33.4); this bounds what a player with every title, in markup, can hold.
/// </summary>
private const int TitleTextMax = 1024;
/// <summary>The longest voice format a chat line may carry (§33.2).</summary>
private const int ChatFormatMax = 512;
/// <summary>
/// The chat titles players have earned on this server, by Steam id, already in
/// BetterChat's markup. The website composes them from the wipe's standings and pushes
/// the whole set (<c>titles.set</c>).
///
/// <para>
/// <b>Kept on disk as well (D144).</b> The site re-sends on a new boot id or wipe id, and
/// a hot reload of this plugin — an update, an <c>oxide.reload</c> — is neither: the boot
/// id names the server process. Without the file a reload empties every title until the
/// standings next move. A file from another wipe is not loaded, because titles rank the
/// wipe they were earned on.
/// </para>
///
/// <para>
/// <b>Swapped, never edited.</b> BetterChat reads this through <see cref="TitleFor"/> on
/// every chat line, and another plugin can make it do so from its own thread through
/// BetterChat's API. A dictionary written while it is read can throw or answer wrong; a
/// reference replaced whole is read either before the swap or after it.
/// </para>
/// </summary>
private volatile Dictionary<ulong, string> _titles = new Dictionary<ulong, string>();
/// <summary>The getter BetterChat holds, kept so every registration hands it the same delegate.</summary>
private Func<Oxide.Core.Libraries.Covalence.IPlayer, string> _titleGetter;
/// <summary>
/// Whether the BetterChat loaded right now holds our getter. Cleared when BetterChat
/// unloads: its registrations live on the instance, so a reloaded BetterChat has
/// forgotten us until <see cref="OnPluginLoaded"/> registers again (§33.1).
/// </summary>
private bool _titlesRegistered;
/// <summary>The last title set, for <c>rg.titles</c>. Diagnostics only.</summary>
private string _lastTitlesSummary = "none yet";
/// <summary>
/// The twelve fields of a BetterChat group (D138), by the name its API takes, with
/// BetterChat 5.2.15's default for each. <c>Title</c> is absent: its default is the
/// group's own name in brackets (<see cref="ChatDefault"/>).
/// </summary>
private static readonly Dictionary<string, string> ChatFieldDefaults =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
{ "Priority", "0" },
{ "Title", null },
{ "TitleColor", "#55aaff" },
{ "TitleSize", "15" },
{ "TitleHidden", "false" },
{ "TitleHiddenIfNotPrimary", "false" },
{ "UsernameColor", "#55aaff" },
{ "UsernameSize", "15" },
{ "MessageColor", "white" },
{ "MessageSize", "15" },
{ "ChatFormat", "{Title} {Username}: {Message}" },
{ "ConsoleFormat", "{Title} {Username}: {Message}" }
};
private Plugin BetterChatPlugin()
{
return plugins.Find("BetterChat");
}
private Plugin PopupPlugin()
{
return plugins.Find("PopupNotifications");
}
/// <summary>
/// BetterChat's third-party title getter. It runs <b>on the chat path</b>, once per line
/// and again whenever another plugin asks BetterChat to format one, so it does one
/// lookup and nothing else: no socket, no file, no allocation (R15). A player with no
/// title gets null, which BetterChat skips.
/// </summary>
private string TitleFor(Oxide.Core.Libraries.Covalence.IPlayer player)
{
if (player == null)
return null;
ulong id;
string title;
return ulong.TryParse(player.Id, out id) && _titles.TryGetValue(id, out title) ? title : null;
}
/// <summary>Main thread. Hands BetterChat our title getter, if BetterChat is here.</summary>
private void RegisterTitles(string why)
{
Plugin betterChat = BetterChatPlugin();
if (betterChat == null)
{
_titlesRegistered = false;
return;
}
if (_titleGetter == null)
_titleGetter = TitleFor;
// Void, so the answer is null either way. `rg.titles <steamId>` is how a rig proves
// BetterChat renders what this handed it.
betterChat.Call("API_RegisterThirdPartyTitle", this, _titleGetter);
_titlesRegistered = true;
if (why != "load")
Puts($"titles: registered with BetterChat again ({why}), holding {_titles.Count}");
}
/// <summary>
/// Main thread. Replaces the titles this server holds with the set the site sent. A
/// malformed entry refuses the whole set rather than being skipped: the set is whole by
/// contract, and half of one is a set nobody sent.
/// </summary>
private void HandleTitlesSet(Dictionary<string, object> cmd, string reqId)
{
object raw;
var list = cmd.TryGetValue("titles", out raw) ? raw as Newtonsoft.Json.Linq.JArray : null;
if (list == null)
{
EmitTitlesError(reqId, "malformed", "a title set carries a titles array");
return;
}
if (list.Count > TitlesMax)
{
EmitTitlesError(reqId, "too-large", $"this server holds at most {TitlesMax} titles, not {list.Count}");
return;
}
var next = new Dictionary<ulong, string>();
foreach (Newtonsoft.Json.Linq.JToken item in list)
{
var row = item as Newtonsoft.Json.Linq.JObject;
string steamId = row != null ? JsonText(row["steamId"]) : null;
string text = row != null ? JsonText(row["text"]) : null;
ulong id;
if (!ulong.TryParse(steamId, out id) || string.IsNullOrEmpty(text) || text.Length > TitleTextMax)
{
EmitTitlesError(reqId, "malformed",
$"every title names a Steam id and carries text of at most {TitleTextMax} characters");
return;
}
next[id] = text;
}
_titles = next;
SaveTitles();
// A BetterChat that arrived without us hearing it load is picked up here, which also
// makes a restart of either plugin heal on the site's next push.
if (!_titlesRegistered && BetterChatPlugin() != null)
RegisterTitles("title set");
bool shown = _titlesRegistered && BetterChatPlugin() != null;
_lastTitlesSummary = $"{next.Count} titles, betterChat={shown}, at={DateTime.UtcNow:HH:mm:ss}Z";
Dictionary<string, object> reply = Frame("titles.ok", "reply");
reply["count"] = next.Count;
reply["betterChat"] = shown;
string setId = StringField(cmd, "setId");
if (!string.IsNullOrEmpty(setId))
reply["setId"] = setId;
if (reqId != null)
reply["reqId"] = reqId;
EmitFrame(reply);
}
/// <summary>What <c>titles.json</c> holds: the set, and the wipe it was pushed on.</summary>
private sealed class TitleFile
{
public string WipeId;
public Dictionary<string, string> Titles = new Dictionary<string, string>();
}
private string TitlesPath()
{
return Path.Combine(Interface.Oxide.DataDirectory, Name, "titles.json");
}
/// <summary>Main thread. Written on every set the site pushes — a set arrives only when it changed.</summary>
private void SaveTitles()
{
var file = new TitleFile { WipeId = _wipeId };
foreach (KeyValuePair<ulong, string> kv in _titles)
file.Titles[kv.Key.ToString()] = kv.Value;
WriteDataFile(TitlesPath(), file, "titles");
}
/// <summary>
/// Main thread, from <c>OnServerInitialized</c> — the first moment the wipe id is real.
/// A file from another wipe, or with no wipe, is left unread: the site sends this wipe's.
/// </summary>
private void LoadTitles()
{
try
{
string path = TitlesPath();
if (!File.Exists(path))
return;
var file = JsonConvert.DeserializeObject<TitleFile>(File.ReadAllText(path));
if (file == null || file.Titles == null || string.IsNullOrEmpty(_wipeId) || file.WipeId != _wipeId)
return;
var loaded = new Dictionary<ulong, string>();
foreach (KeyValuePair<string, string> kv in file.Titles)
{
ulong id;
if (ulong.TryParse(kv.Key, out id) && !string.IsNullOrEmpty(kv.Value))
loaded[id] = kv.Value;
}
_titles = loaded;
_lastTitlesSummary = $"{loaded.Count} titles from disk, at={DateTime.UtcNow:HH:mm:ss}Z";
}
catch (Exception ex)
{
Puts($"could not read the titles: {Flatten(ex.Message)}");
}
}
private void EmitTitlesError(string reqId, string reason, string message)
{
Dictionary<string, object> frame = Frame("titles.error", "reply");
frame["reason"] = reason;
frame["message"] = message;
if (reqId != null)
frame["reqId"] = reqId;
_lastTitlesSummary = $"refused ({reason}): {message}";
EmitFrame(frame);
}
/// <summary>A JSON string or number as text, or null for anything else.</summary>
private static string JsonText(Newtonsoft.Json.Linq.JToken token)
{
if (token == null)
return null;
switch (token.Type)
{
case Newtonsoft.Json.Linq.JTokenType.String:
return (string)token;
case Newtonsoft.Json.Linq.JTokenType.Integer:
return token.ToString();
case Newtonsoft.Json.Linq.JTokenType.Boolean:
return (bool)token ? "true" : "false";
default:
return null;
}
}
/// <summary>
/// A chat line in a voice (D140): the site's format with the words put into it, turned
/// into the game's rich text. Said by us, with no player as its sender, so it works
/// whether or not BetterChat is loaded.
/// </summary>
private string Voice(string format, string message)
{
// BetterChat's own rule for a player's words (its GetOutput), for the same reason: the
// line is ours to style and the words inside it are not.
if (message.Contains("[#") || message.Contains("[+"))
message = message.Replace("[", string.Empty).Replace("]", string.Empty);
return covalence.FormatText(format.Replace("{message}", message));
}
private static int Occurrences(string text, string needle)
{
int count = 0;
for (int at = text.IndexOf(needle, StringComparison.Ordinal); at >= 0; at = text.IndexOf(needle, at + needle.Length, StringComparison.Ordinal))
count++;
return count;
}
private Dictionary<string, object> IntegrationsBody()
{
return new Dictionary<string, object>
{
{ "betterChat", IntegrationBody(BetterChatPlugin()) },
{ "popupNotifications", IntegrationBody(PopupPlugin()) },
{ "zoneDomes", ZoneDomesBody() },
{ "runicNpc", RunicNpcBody() }
};
}
/// <summary>
/// ZoneDomes and the domes helper (D194), live on every status frame like the other
/// optional mods: the site offers a dome only where both are here and patched.
/// </summary>
private Dictionary<string, object> ZoneDomesBody()
{
Dictionary<string, object> body = IntegrationBody(ZoneDomesPlugin());
var helper = new Dictionary<string, object> { { "state", ZoneDomesPlugin() == null && DomesHelperPlugin() != null ? "no-zonedomes" : DomesHelperState() } };
Plugin domes = DomesHelperPlugin();
if (domes != null)
{
helper["version"] = domes.Version.ToString();
if (domes.Call("StateReason") is string reason)
helper["reason"] = reason;
}
body["helper"] = helper;
body["ready"] = DomesReady();
return body;
}
/// <summary>A version only for a plugin that is loaded. Absent, not empty.</summary>
private static Dictionary<string, object> IntegrationBody(Plugin plugin)
{
var body = new Dictionary<string, object> { { "loaded", plugin != null } };
if (plugin != null)
body["version"] = plugin.Version.ToString();
return body;
}
// ---- group styles (D138, D139, D143) ----
/// <summary>
/// The BetterChat fields a sync asks for on one group. A field this build does not
/// know, or a value that is not a string, number or boolean, is reported failed and
/// left out rather than guessed at.
/// </summary>
private List<ChatFieldWant> ChatStyle(PermSyncJob job, string group, Dictionary<string, object> row)
{
var style = new List<ChatFieldWant>();
object raw;
if (!row.TryGetValue("chat", out raw))
return style;
var fields = raw as Newtonsoft.Json.Linq.JObject;
if (fields == null)
return style;
foreach (Newtonsoft.Json.Linq.JProperty field in fields.Properties())
{
var want = field.Value as Newtonsoft.Json.Linq.JObject;
string value = want != null ? JsonText(want["value"]) : null;
if (!ChatFieldDefaults.ContainsKey(field.Name) || value == null)
{
job.ChatFailed.Add(new Dictionary<string, object>
{
{ "group", group },
{ "field", field.Name },
{ "reason", "malformed" }
});
continue;
}
style.Add(new ChatFieldWant
{
Field = field.Name,
Value = value,
Expect = JsonText(want["expect"])
});
}
return style;
}
/// <summary>
/// Main thread. Puts the site's style on one BetterChat group, a field at a time, and
/// leaves alone any field somebody changed by hand.
///
/// <para>
/// A field is written when the game still holds what the site last pushed there, or
/// holds BetterChat's default and the site has never pushed it. Anything else is a hand
/// edit: it is not written, and it is reported with the game's value (D138, R2). A group
/// this sync had to create is written whole, because nobody can have edited it yet.
/// </para>
///
/// <para>
/// <b>The save is the part the API does not do.</b> <c>API_SetGroupField</c> changes a
/// field in memory and never writes BetterChat's file, so on its next reload the style
/// would revert and read as drift. BetterChat's own <c>chat group set</c> does write
/// it, and writes every group, so it is run once per group touched with a value that
/// has not changed (D143).
/// </para>
/// </summary>
private void ApplyChatStyle(PermSyncJob job, string group)
{
Plugin betterChat = BetterChatPlugin();
if (betterChat == null)
{
ChatFailure(job, group, null, "betterchat-unloaded");
return;
}
bool created = false;
if (!ChatGroupExists(betterChat, group))
{
// Saves on its own.
betterChat.Call("API_AddGroup", group);
if (!ChatGroupExists(betterChat, group))
{
ChatFailure(job, group, null, "not-created");
return;
}
created = true;
}
Dictionary<string, object> game = ChatGroupFields(betterChat, group);
bool touched = false;
foreach (ChatFieldWant want in job.ChatStyles[group])
{
string now = ChatFieldValue(game, want.Field);
if (now == want.Value)
{
job.ChatSame++;
continue;
}
bool ours = created || now == want.Expect || (want.Expect == null && now == ChatDefault(group, want.Field));
if (!ours)
{
job.ChatDrift.Add(new Dictionary<string, object>
{
{ "group", group },
{ "field", want.Field },
{ "game", now }
});
continue;
}
// The typed answer is why each field goes through the API (D143): `Success`,
// `InvalidField` or `InvalidValue`, as a BetterChat enum this plugin cannot name —
// so read by its name. Null is a group that is not there, or a value BetterChat's
// parser threw on without catching (a number too big for an int).
object result = betterChat.Call("API_SetGroupField", group, want.Field, want.Value);
string answer = result != null ? result.ToString() : null;
if (answer != "Success")
{
ChatFailure(job, group, want.Field, answer ?? "no-answer");
continue;
}
job.ChatApplied++;
touched = true;
}
if (!touched)
return;
game = ChatGroupFields(betterChat, group);
string priority = ChatFieldValue(game, "Priority");
ConsoleSystem.Run(ConsoleSystem.Option.Server, "chat", "group", "set", group, "Priority", priority);
// Read back rather than trusted: the command has no answer a plugin can read, and a
// console path that did something else would otherwise be counted as a save.
if (ChatFieldValue(ChatGroupFields(betterChat, group), "Priority") == priority)
job.ChatSaved++;
else
ChatFailure(job, group, "Priority", "not-saved");
}
/// <summary>
/// Main thread. Removes a BetterChat group the site no longer styles (D139). There is
/// no API for it, only BetterChat's own <c>chat group remove</c>, run as the server
/// console. A group already gone is removed: the retirement is idempotent.
/// </summary>
private void RemoveChatGroup(PermSyncJob job, string group)
{
Plugin betterChat = BetterChatPlugin();
if (betterChat == null)
{
ChatFailure(job, group, null, "betterchat-unloaded");
return;
}
if (ChatGroupExists(betterChat, group))
ConsoleSystem.Run(ConsoleSystem.Option.Server, "chat", "group", "remove", group);
if (ChatGroupExists(betterChat, group))
ChatFailure(job, group, null, "still-present");
else
job.ChatRemoved.Add(group);
}
private static void ChatFailure(PermSyncJob job, string group, string field, string reason)
{
var row = new Dictionary<string, object> { { "group", group }, { "reason", reason } };
if (field != null)
row["field"] = field;
job.ChatFailed.Add(row);
}
private static bool ChatGroupExists(Plugin betterChat, string group)
{
object exists = betterChat.Call("API_GroupExists", group);
return exists is bool && (bool)exists;
}
private static Dictionary<string, object> ChatGroupFields(Plugin betterChat, string group)
{
return betterChat.Call("API_GetGroupFields", group) as Dictionary<string, object>
?? new Dictionary<string, object>();
}
/// <summary>
/// A field's value as the text the site compares: <c>true</c>/<c>false</c> for a
/// boolean, never C#'s <c>True</c>, and an invariant number.
/// </summary>
private static string ChatFieldValue(Dictionary<string, object> fields, string field)
{
object value = null;
foreach (KeyValuePair<string, object> kv in fields)
{
if (string.Equals(kv.Key, field, StringComparison.OrdinalIgnoreCase))
{
value = kv.Value;
break;
}
}
if (value == null)
return null;
if (value is bool)
return (bool)value ? "true" : "false";
if (value is IFormattable)
return ((IFormattable)value).ToString(null, System.Globalization.CultureInfo.InvariantCulture);
return value.ToString();
}
/// <summary>
/// BetterChat's default for a field on a new group. A group's title defaults to its own
/// name in brackets, except <c>default</c>'s, which is <c>[Player]</c>.
/// </summary>
private static string ChatDefault(string group, string field)
{
if (string.Equals(field, "Title", StringComparison.OrdinalIgnoreCase))
return group == "default" ? "[Player]" : "[" + group + "]";
string value;
return ChatFieldDefaults.TryGetValue(field, out value) ? value : null;
}
/// <summary>
/// The titles this server holds, from the server console. Given a Steam id it also
/// prints what BetterChat would render for that player — which is how a rig proves a
/// title without a person typing in chat (§33.3).
/// </summary>
[ConsoleCommand("rg.titles")]
private void CmdTitles(ConsoleSystem.Arg arg)
{
Plugin betterChat = BetterChatPlugin();
var lines = new List<string>
{
$"protocol={ProtocolVersion} titles={_titles.Count} " +
$"betterChat={(betterChat != null ? betterChat.Version.ToString() : "absent")} " +
$"registered={(_titlesRegistered && betterChat != null)}",
$"last set: {_lastTitlesSummary}"
};
// `GetString`, not `Args[0]`: current Rust hands a console command StringViews.
string steamId = arg.GetString(0, null);
if (!string.IsNullOrEmpty(steamId))
{
ulong id;
string held;
lines.Add(ulong.TryParse(steamId, out id) && _titles.TryGetValue(id, out held)
? $"held: {held}"
: "held: (none)");
Oxide.Core.Libraries.Covalence.IPlayer known = covalence.Players.FindPlayerById(steamId);
if (betterChat == null)
lines.Add("rendered: (BetterChat is not loaded)");
else if (known == null)
lines.Add("rendered: (this server has never seen that player)");
else
lines.Add("rendered: " + (betterChat.Call("API_GetFormattedMessage", known, "(rg.titles)", false) as string ?? "(no answer)"));
}
arg.ReplyWith(string.Join(Environment.NewLine, lines.ToArray()));
}
/// <summary>What events are counting and what rewards are held, from the server console.</summary>
[ConsoleCommand("rg.rewards")]
private void CmdRewards(ConsoleSystem.Arg arg)
{
var lines = new List<string>
{
$"protocol={ProtocolVersion} tallies={_runTallies.Count} credits={_credits.Count} " +
$"kits={(KitsPlugin() != null)} maxTallies={MaxTallies()} maxRecipients={MaxRecipients()}"
};
long now = NowMs();
foreach (RunTally t in _runTallies.Values)
{
lines.Add(
$" run {t.RunId}: {t.Score}{(t.KillsOf != null ? "/" + t.KillsOf : "")} " +
$"{(t.Zone != null ? "zone '" + t.Zone + "'" : "whole server")} people={t.People.Count} " +
$"{(Counting(t, now) ? "counting, " + (t.DeadlineMs - now) / 1000L + "s left" : "closed")}");
}
foreach (KitCredit c in _credits.Values)
lines.Add($" credit {c.SteamId} {c.Kit}: earned={c.Earned} applied={c.Applied} redeemed={c.Consumed}");
lines.Add($"last: {_lastRewardSummary}");
arg.ReplyWith(string.Join(Environment.NewLine, lines.ToArray()));
}
// ---- the map (protocol 11, PLAN.md §30) ----
//
// The picture of this wipe's map and everything that moves on it, in four verbs. Five rules
// shape the section, and each came from the rig (§30.0):
//
// * **Nothing renders unasked.** The game's renderer holds the main thread for 8.5 s on a
// 3000 map and 21 s at full scale. The picture comes from Rust+'s cache, which the game
// fills at boot when `app.port` is set; only an admin's `map.render` ever draws one, and
// then on a later frame than the one that answered (D109).
// * **The picture is read, never copied, on the main thread.** Hashing it and cutting it
// into base64 slices happen on the thread pool. The bytes are never mutated once made, so
// handing the array to another thread is safe.
// * **No sweep per ask.** What moves is kept in sets by `OnEntitySpawned` / `OnEntityKill`,
// seeded by one sweep when the world is ready. An ask copies positions out of the sets.
// * **Filtering is the website's job** (§8.5). `map.live` answers every layer, and which of
// them a viewer may see is decided by the module, never here.
// * **Every map verb is outside `EventsEnabled`.** Nothing here changes the world.
/// <summary>
/// One slice of the picture on the wire. Base64 grows it by a third, and the result must
/// fit the game link's 1 MiB line cap with its envelope (the asset bridge's phase 7 shape).
/// </summary>
private const int MapChunkBytes = 512 * 1024;
/// <summary>
/// The render's geometry, which is the Rust+ cache's geometry exactly: half scale, and an
/// ocean margin of 500 PIXELS on each side, unscaled (§30.0).
/// </summary>
private const float MapRenderScale = 0.5f;
private const int MapOceanMargin = 500;
/// <summary>The default most sleepers, and the most bases, one live answer carries.</summary>
private const int MapDefaultMax = 2000;
private const string MapSourceCompanion = "companion";
private const string MapSourceRendered = "rendered";
private const string MapSourceNone = "none";
/// <summary>What this plugin knows about the picture it can serve. Replaced whole, never edited.</summary>
private sealed class MapPicture
{
public string MapKey;
public string Source;
public byte[] Bytes;
public int Width;
public int Height;
public string Background;
/// <summary>Null until the thread pool has hashed <see cref="Bytes"/>.</summary>
public volatile string Sha256;
}
/// <summary>A rendered picture's companion file: which map it is a picture of.</summary>
private sealed class RenderedMapMeta
{
public string MapKey;
public string Background;
public long RenderedMs;
public long TookMs;
}
/// <summary>The current picture, or null. Main thread writes; the pool only reads what it is handed.</summary>
private MapPicture _mapPicture;
/// <summary>A picture this plugin rendered for the current map, read from disk at most once per map.</summary>
private MapPicture _mapRendered;
private string _mapRenderedCheckedFor;
/// <summary>Set between a `map.render` being accepted and the render finishing.</summary>
private bool _mapRendering;
private readonly HashSet<BaseEntity> _mapWorld = new HashSet<BaseEntity>();
private readonly HashSet<BaseEntity> _mapBases = new HashSet<BaseEntity>();
private string _lastMapSummary = "none yet";
private long _mapLiveAsks, _mapLiveLastMs, _mapLiveMaxMs;
private int MapMaxSleepers()
{
return _config != null ? _config.MapMaxSleepers : MapDefaultMax;
}
private int MapMaxBases()
{
return _config != null ? _config.MapMaxBases : MapDefaultMax;
}
/// <summary>
/// Which map this is. A procedural map is its size, seed and salt; a custom map is the
/// hash of the URL it was downloaded from. The key says WHICH map, and the picture's own
/// SHA-256 says which picture of it (§30.2).
/// </summary>
private static string MapKey()
{
if (World.Procedural)
{
return string.Format(System.Globalization.CultureInfo.InvariantCulture,
"{0}.{1}.{2}", World.Size, World.Seed, World.Salt);
}
string url = !string.IsNullOrEmpty(World.Url) ? World.Url : ConVar.Server.levelurl;
return "custom." + Sha256Hex(Encoding.UTF8.GetBytes(url ?? string.Empty));
}
private static string Sha256Hex(byte[] bytes)
{
using (var sha = System.Security.Cryptography.SHA256.Create())
{
byte[] hash = sha.ComputeHash(bytes);
var sb = new StringBuilder(hash.Length * 2);
foreach (byte b in hash)
sb.Append(b.ToString("x2", System.Globalization.CultureInfo.InvariantCulture));
return sb.ToString();
}
}
/// <summary>
/// A JPEG's own width and height, read from its frame header. The picture says what size
/// it is; a private field that says so as well is a second opinion that can drift.
/// </summary>
private static bool JpegSize(byte[] bytes, out int width, out int height)
{
width = 0;
height = 0;
if (bytes == null || bytes.Length < 4 || bytes[0] != 0xFF || bytes[1] != 0xD8)
return false;
int i = 2;
while (i + 9 < bytes.Length)
{
if (bytes[i] != 0xFF)
return false;
byte marker = bytes[i + 1];
int length = (bytes[i + 2] << 8) | bytes[i + 3];
// SOF0..SOF15 carry the frame's size — except DHT (C4), JPG (C8) and DAC (CC),
// which share the range and do not.
if (marker >= 0xC0 && marker <= 0xCF && marker != 0xC4 && marker != 0xC8 && marker != 0xCC)
{
height = (bytes[i + 5] << 8) | bytes[i + 6];
width = (bytes[i + 7] << 8) | bytes[i + 8];
return width > 0 && height > 0;
}
i += 2 + length;
}
return false;
}
private static System.Reflection.PropertyInfo _companionImage;
private static System.Reflection.FieldInfo _companionBackground;
private static bool _companionResolved;
/// <summary>
/// Main thread. The Rust+ cache's picture, or null. Read by reflection, by NAME, and only
/// the two members this needs: <c>CompanionServer</c> types hold the server's Rust+
/// secret, and a probe that enumerated one printed it into the log (§30.0). A Facepunch
/// rename makes this answer null, which is <c>none</c> — never an exception.
/// </summary>
private static byte[] CompanionImage(out string background)
{
background = null;
try
{
if (!_companionResolved)
{
_companionResolved = true;
Type map = typeof(BasePlayer).Assembly.GetType("CompanionServer.Handlers.Map", false);
if (map != null)
{
const System.Reflection.BindingFlags Static =
System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic |
System.Reflection.BindingFlags.Static;
_companionImage = map.GetProperty("ImageData", Static);
_companionBackground = map.GetField("_background", Static);
}
}
if (_companionImage == null)
return null;
byte[] bytes = _companionImage.GetValue(null, null) as byte[];
if (bytes == null || bytes.Length == 0)
return null;
if (_companionBackground != null)
background = _companionBackground.GetValue(null) as string;
return bytes;
}
catch (Exception)
{
return null;
}
}
private string MapDir()
{
return Path.Combine(Interface.Oxide.DataDirectory, Name);
}
/// <summary>
/// Main thread. A picture this plugin rendered earlier for THIS map, from disk. Read once
/// per map key, so a restart within the map does not ask for another render (§30.2).
/// </summary>
private MapPicture RenderedFromDisk(string key)
{
if (_mapRenderedCheckedFor == key)
return _mapRendered;
_mapRenderedCheckedFor = key;
_mapRendered = null;
try
{
string meta = Path.Combine(MapDir(), "map.json");
string jpeg = Path.Combine(MapDir(), "map.jpg");
if (!File.Exists(meta) || !File.Exists(jpeg))
return null;
RenderedMapMeta m = JsonConvert.DeserializeObject<RenderedMapMeta>(File.ReadAllText(meta));
if (m == null || m.MapKey != key)
return null;
byte[] bytes = File.ReadAllBytes(jpeg);
int w, h;
if (!JpegSize(bytes, out w, out h))
return null;
_mapRendered = new MapPicture
{
MapKey = key,
Source = MapSourceRendered,
Bytes = bytes,
Width = w,
Height = h,
Background = m.Background
};
}
catch (Exception ex)
{
Puts($"map: could not read the rendered picture: {Flatten(ex.Message)}");
}
return _mapRendered;
}
/// <summary>
/// Main thread. The picture to serve now: the Rust+ cache when it has one, else a render
/// of this map kept on disk, else null. Rebuilt only when the key or the cache's array
/// changes, and hashed on the pool the first time a picture is seen.
/// </summary>
private MapPicture CurrentPicture()
{
string key = MapKey();
string background;
byte[] companion = CompanionImage(out background);
MapPicture current = _mapPicture;
if (companion != null)
{
if (current != null && current.MapKey == key && ReferenceEquals(current.Bytes, companion))
return current;
int w, h;
if (!JpegSize(companion, out w, out h))
return null;
current = new MapPicture
{
MapKey = key,
Source = MapSourceCompanion,
Bytes = companion,
Width = w,
Height = h,
Background = background
};
}
else
{
current = RenderedFromDisk(key);
}
_mapPicture = current;
HashLater(current);
return current;
}
private static void HashLater(MapPicture picture)
{
if (picture == null || picture.Sha256 != null)
return;
ThreadPool.QueueUserWorkItem(_ =>
{
try
{
picture.Sha256 = Sha256Hex(picture.Bytes);
}
catch (Exception)
{
// Left null: `map.info` keeps answering `hashing`, and a later ask tries again.
}
});
}
/// <summary>
/// Main thread. The game's own grid, from its own function: how many cells a side has and
/// how wide one is. <c>MapHelper</c> is what the in-game map labels with, so the website
/// draws the game's grid by construction rather than from a constant somebody measured
/// once (§30.3).
/// </summary>
private static void GameGrid(out int cells, out float cellSize)
{
float size = ConVar.Server.worldsize;
float half = size / 2f;
Vector2i last = MapHelper.PositionToGrid(new UnityEngine.Vector3(half - 0.5f, 0f, -half + 0.5f));
cells = Math.Max(1, last.x + 1);
cellSize = size / cells;
}
/// <summary>
/// Main thread. The monuments the in-game map shows: those with a display phrase, in the
/// same <c>kind#n</c> vocabulary as <c>world.monuments</c>, so a monument a step names and
/// a monument on the map are the same token.
/// </summary>
private static List<object> MapMonuments()
{
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];
string phrase = m.displayPhrase != null ? m.displayPhrase.english : null;
if (string.IsNullOrEmpty(phrase))
continue;
UnityEngine.Vector3 p = m.transform.position;
rows.Add(new Dictionary<string, object>
{
{ "value", kv.Value.Count > 1 ? kv.Key + "#" + (i + 1) : kv.Key },
{ "kind", kv.Key },
{ "label", phrase },
{ "x", Math.Round(p.x, 1) },
{ "z", Math.Round(p.z, 1) },
{ "grid", MapHelper.PositionToString(p) }
});
}
}
return rows;
}
/// <summary>
/// Stage one: what exists. Reads the cache, and hashes it on the pool once per map; no
/// other work. <c>sha256</c> is absent while the hash is still being computed, and the
/// answer then says <c>hashing</c> so the caller asks again.
/// </summary>
private void HandleMapInfo(string reqId)
{
if (!MapReady(reqId))
return;
MapPicture picture = CurrentPicture();
int cells;
float cellSize;
GameGrid(out cells, out cellSize);
Dictionary<string, object> reply = Frame("map.info", "reply");
reply["mapKey"] = MapKey();
reply["worldSize"] = ConVar.Server.worldsize;
reply["oceanMargin"] = MapOceanMargin;
reply["gridCells"] = cells;
reply["gridCellSize"] = Math.Round(cellSize, 3);
reply["monuments"] = MapMonuments();
reply["rendering"] = _mapRendering;
if (picture == null)
{
reply["source"] = MapSourceNone;
}
else
{
reply["source"] = picture.Source;
reply["bytes"] = picture.Bytes.Length;
reply["width"] = picture.Width;
reply["height"] = picture.Height;
reply["chunks"] = (picture.Bytes.Length + MapChunkBytes - 1) / MapChunkBytes;
reply["chunkBytes"] = MapChunkBytes;
if (!string.IsNullOrEmpty(picture.Background))
reply["background"] = picture.Background;
string hash = picture.Sha256;
if (hash != null)
reply["sha256"] = hash;
else
reply["hashing"] = true;
}
if (reqId != null)
reply["reqId"] = reqId;
EmitFrame(reply);
}
/// <summary>
/// Stage two: one slice of the picture. Refused <c>stale</c> when the map or the picture
/// moved since the caller asked stage one, so a fetch that straddles a wipe cannot splice
/// two maps. The slice is base64'd and serialized on the pool: at 512 KiB that is work the
/// main thread has no business doing.
/// </summary>
private void HandleMapFetch(Dictionary<string, object> cmd, string reqId)
{
if (!MapReady(reqId))
return;
if (_mapRendering)
{
EmitMapError(reqId, "busy", "the server is drawing its map; ask again when map.info says it is done");
return;
}
string key = StringField(cmd, "mapKey");
string sha = StringField(cmd, "sha256");
int n = IntField(cmd, "chunk");
MapPicture picture = CurrentPicture();
if (picture == null)
{
EmitMapError(reqId, "no-picture", "this server has no picture of its map");
return;
}
string hash = picture.Sha256;
if (key != picture.MapKey || hash == null || !string.Equals(sha, hash, StringComparison.OrdinalIgnoreCase))
{
EmitMapError(reqId, "stale", "the map or its picture changed since it was described; ask map.info again");
return;
}
int chunks = (picture.Bytes.Length + MapChunkBytes - 1) / MapChunkBytes;
if (n < 0 || n >= chunks)
{
EmitMapError(reqId, "malformed", $"this picture has {chunks} slices, numbered from 0");
return;
}
Dictionary<string, object> reply = Frame("map.chunk", "reply");
reply["mapKey"] = picture.MapKey;
reply["sha256"] = hash;
reply["chunk"] = n;
reply["chunks"] = chunks;
if (reqId != null)
reply["reqId"] = reqId;
byte[] bytes = picture.Bytes;
int offset = n * MapChunkBytes;
int length = Math.Min(MapChunkBytes, bytes.Length - offset);
ThreadPool.QueueUserWorkItem(_ =>
{
try
{
reply["data"] = Convert.ToBase64String(bytes, offset, length);
Emit(JsonConvert.SerializeObject(reply, Formatting.None));
}
catch (Exception ex)
{
// Nothing is sent, and the caller's ask times out: a slice is re-asked, never trusted half-made.
Interface.Oxide.NextTick(() => Puts($"map: could not encode slice {n}: {Flatten(ex.Message)}"));
}
});
}
/// <summary>
/// The admin fallback (D109): draw the map with the game's own renderer, once. Refused
/// unless there is no picture at all, and while the world is loading. Answers
/// <c>accepted</c> AT ONCE and renders on a later frame, because the render outlasts the
/// sidecar's reply budget on any map larger than the rig's; the caller polls
/// <c>map.info</c> until its source is <c>rendered</c>.
/// </summary>
private void HandleMapRender(Dictionary<string, object> cmd, string reqId)
{
if (!MapReady(reqId))
return;
if (_mapRendering)
{
EmitMapError(reqId, "busy", "the server is already drawing its map");
return;
}
MapPicture picture = CurrentPicture();
if (picture != null)
{
EmitMapError(reqId, "has-picture",
$"this server already has a picture of its map ({picture.Source}); nothing needs drawing");
return;
}
_mapRendering = true;
string actor = StringField(cmd, "actor");
string key = MapKey();
Dictionary<string, object> reply = Frame("map.render", "reply");
reply["accepted"] = true;
reply["mapKey"] = key;
reply["worldSize"] = ConVar.Server.worldsize;
if (reqId != null)
reply["reqId"] = reqId;
EmitFrame(reply);
Puts($"map: drawing the map for {key} at an admin's request{(string.IsNullOrEmpty(actor) ? "" : " (" + actor + ")")}; " +
"the server will stall while it does");
// A timer rather than NextTick, so the reply above is on the wire before the stall.
timer.Once(0.5f, () => RenderMap(key));
}
/// <summary>Main thread, deliberately: the renderer reads the terrain, which is main-thread-only.</summary>
private void RenderMap(string key)
{
var clock = System.Diagnostics.Stopwatch.StartNew();
try
{
if (MapKey() != key)
{
_lastMapSummary = "a render was abandoned: the map changed before it started";
return;
}
System.Reflection.MethodInfo render = null;
foreach (System.Reflection.MethodInfo m in typeof(MapImageRenderer).GetMethods(
System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static))
{
if (m.Name == "Render" && m.ReturnType == typeof(byte[]))
{
render = m;
break;
}
}
if (render == null)
{
_lastMapSummary = "a render failed: the game has no MapImageRenderer.Render";
Puts("map: " + _lastMapSummary);
return;
}
// Called by parameter NAME, as the probe did: a reordered signature must not swap
// the scale for the margin.
System.Reflection.ParameterInfo[] ps = render.GetParameters();
object[] args = new object[ps.Length];
for (int i = 0; i < ps.Length; i++)
{
if (ps[i].IsOut)
continue;
if (ps[i].Name == "scale")
args[i] = MapRenderScale;
else if (ps[i].Name == "oceanMargin")
args[i] = MapOceanMargin;
else if (ps[i].Name == "lossy")
args[i] = true;
else if (ps[i].Name == "transparent")
args[i] = false;
else if (ps[i].HasDefaultValue)
args[i] = ps[i].DefaultValue;
}
byte[] bytes = render.Invoke(null, args) as byte[];
clock.Stop();
int w, h;
if (bytes == null || !JpegSize(bytes, out w, out h))
{
_lastMapSummary = $"a render produced no picture after {clock.ElapsedMilliseconds} ms";
Puts("map: " + _lastMapSummary);
return;
}
string background = null;
for (int i = 0; i < ps.Length; i++)
{
if (ps[i].IsOut && args[i] is UnityEngine.Color)
background = "#" + UnityEngine.ColorUtility.ToHtmlStringRGB((UnityEngine.Color)args[i]);
}
Directory.CreateDirectory(MapDir());
File.WriteAllBytes(Path.Combine(MapDir(), "map.jpg"), bytes);
File.WriteAllText(Path.Combine(MapDir(), "map.json"), JsonConvert.SerializeObject(new RenderedMapMeta
{
MapKey = key,
Background = background,
RenderedMs = NowMs(),
TookMs = clock.ElapsedMilliseconds
}, Formatting.Indented));
// Read back through the path a restart would take, so what is served now is exactly
// what a restart would serve.
_mapRenderedCheckedFor = null;
_mapPicture = null;
CurrentPicture();
_lastMapSummary = $"drew the map for {key}: {w}x{h}, {bytes.Length} bytes, {clock.ElapsedMilliseconds} ms of stall";
Puts("map: " + _lastMapSummary);
}
catch (Exception ex)
{
_lastMapSummary = $"a render threw after {clock.ElapsedMilliseconds} ms: {Flatten((ex.InnerException ?? ex).Message)}";
Puts("map: " + _lastMapSummary);
}
finally
{
_mapRendering = false;
}
}
/// <summary>Refuses a map command until the world is loaded, as a <c>map.error</c>.</summary>
private bool MapReady(string reqId)
{
if (_worldReady)
return true;
EmitMapError(reqId, "not-ready", "the server is still loading its world; ask again once it has started");
return false;
}
// ── What moves ────────────────────────────────────────────────────────────
/// <summary>The world-event types the map follows (D112). A cheap type test: this runs on every spawn.</summary>
private static bool IsMapWorldThing(BaseNetworkable entity)
{
return entity is CargoShip || entity is PatrolHelicopter || entity is CH47Helicopter ||
entity is BradleyAPC || entity is SupplyDrop || entity is HackableLockedCrate;
}
/// <summary>Tool cupboards, and a player's vending machine — never a monument's (NPC) one.</summary>
private static bool IsMapBase(BaseNetworkable entity)
{
return entity is BuildingPrivlidge || (entity is VendingMachine && !(entity is NPCVendingMachine));
}
/// <summary>
/// Runs on EVERY entity spawn on the server, tens of thousands at boot: two type tests and,
/// rarely, a set insert. Void, so it can never veto a spawn.
/// </summary>
private void OnEntitySpawned(BaseNetworkable entity)
{
MarkHook("OnEntitySpawned");
if (entity == null)
return;
if (IsMapWorldThing(entity))
_mapWorld.Add((BaseEntity)entity);
else if (IsMapBase(entity))
_mapBases.Add((BaseEntity)entity);
}
private void OnEntityKill(BaseNetworkable entity)
{
MarkHook("OnEntityKill");
var e = entity as BaseEntity;
if (e == null)
return;
if (!_mapWorld.Remove(e))
_mapBases.Remove(e);
}
/// <summary>
/// Main thread, once per load when the world is there: the one sweep that seeds the sets.
/// The spawn hook keeps them after that; a hotload has no spawns to hear, which is why this
/// runs at all.
/// </summary>
private void SeedMapSets()
{
var clock = System.Diagnostics.Stopwatch.StartNew();
_mapWorld.Clear();
_mapBases.Clear();
foreach (BaseNetworkable n in BaseNetworkable.serverEntities)
{
if (IsMapWorldThing(n))
_mapWorld.Add((BaseEntity)n);
else if (IsMapBase(n))
_mapBases.Add((BaseEntity)n);
}
clock.Stop();
Puts($"map: following {_mapWorld.Count} world things and {_mapBases.Count} bases ({clock.ElapsedMilliseconds} ms sweep)");
// Hashed now rather than at the first ask, so the first `map.info` usually has it.
CurrentPicture();
}
/// <summary>
/// A player's Steam id as text, from <c>userID</c> itself. <c>UserIDString</c> is a
/// cached string the game fills for a player it has seen connect, and the phase 14 walk
/// read it back empty from a sleeper made on the server: an id the map could not name
/// is an own dot and a clan mate the site can never find.
/// </summary>
/// <summary>
/// Whether the player placed this piece or is authorised on the cupboard that covers
/// it — the test for "their own base" (F3). Main thread: it reads the building's
/// privilege. A piece with no cupboard is judged on its owner alone.
/// </summary>
private static bool OwnsOrAuthed(BasePlayer player, BaseEntity entity)
{
if (player == null || entity == null)
return false;
if (entity.OwnerID == (ulong)player.userID)
return true;
// `authorizedPlayers` rather than `IsAuthed`, for AddAuthorization's reason: it is the
// set this plugin already reads for the raid frame, proven on both frameworks.
BuildingPrivlidge privilege = entity.GetBuildingPrivilege();
return privilege != null && privilege.authorizedPlayers != null
&& privilege.authorizedPlayers.Contains(player.userID);
}
private static string SteamIdOf(BasePlayer p)
{
ulong id = p.userID;
return id.ToString(System.Globalization.CultureInfo.InvariantCulture);
}
private static bool Gone(BaseEntity e)
{
return e == null || e.IsDestroyed || e.transform == null;
}
/// <summary>
/// Main thread. RunicNPC's NPCs keyed by net id as text (a ledger entry's id), from
/// `RunicNpc_List`; empty when RunicNPC is not loaded or answers anything else.
/// </summary>
private Dictionary<string, Dictionary<string, object>> RunicNpcsForMap()
{
var byId = new Dictionary<string, Dictionary<string, object>>();
Plugin npc = RunicNpcPlugin();
if (npc == null)
return byId;
var listed = npc.Call("RunicNpc_List", (string)null) as List<Dictionary<string, object>>;
if (listed == null)
return byId;
foreach (Dictionary<string, object> d in listed)
{
object id;
if (d != null && d.TryGetValue("netId", out id) && id != null)
byId[id.ToString()] = d;
}
return byId;
}
/// <summary>
/// A RunicNPC NPC's name, and for a boss `boss` and its health as a fraction. The health is
/// for the event page's progress line; the site's map does not show it (D303).
/// </summary>
private static void MapNpcFields(Dictionary<string, object> row, Dictionary<string, object> npc)
{
object value;
if (npc.TryGetValue("name", out value) && value is string)
row["name"] = value;
if (!npc.TryGetValue("boss", out value) || value == null)
return;
row["boss"] = true;
object health, max;
if (npc.TryGetValue("health", out health) && npc.TryGetValue("maxHealth", out max))
{
double h = Convert.ToDouble(health), m = Convert.ToDouble(max);
if (m > 0)
row["health"] = Math.Round(Math.Max(0, Math.Min(1, h / m)), 3);
}
}
/// <summary>Where one of RunicNPC's NPCs stands, from its `RunicNpc_List` row.</summary>
private static Dictionary<string, object> MapNpcAt(Dictionary<string, object> npc)
{
object value;
var p = npc.TryGetValue("position", out value) ? value as Dictionary<string, object> : null;
if (p == null)
return At(UnityEngine.Vector3.zero);
return At(new UnityEngine.Vector3(Convert.ToSingle(p["x"]), 0f, Convert.ToSingle(p["z"])));
}
private static Dictionary<string, object> At(UnityEngine.Vector3 p)
{
return new Dictionary<string, object>
{
{ "x", Math.Round(p.x, 1) },
{ "z", Math.Round(p.z, 1) }
};
}
private static string WorldKind(BaseEntity e)
{
if (e is CargoShip) return "cargo";
if (e is PatrolHelicopter) return "heli";
if (e is CH47Helicopter) return "chinook";
if (e is BradleyAPC) return "bradley";
if (e is SupplyDrop) return "supply";
return "crate";
}
/// <summary>
/// What moves, every layer at once. Positions only, and nothing a player typed about a
/// base: no owner, no authorised list, no shop name (§30.5). The two unbounded layers —
/// sleepers and bases — stop at their bound and say so with <c>truncated</c>; a read has
/// nobody to refuse.
/// </summary>
private void HandleMapLive(string reqId)
{
if (!MapReady(reqId))
return;
var clock = System.Diagnostics.Stopwatch.StartNew();
var world = new List<object>();
var dead = new List<BaseEntity>();
foreach (BaseEntity e in _mapWorld)
{
if (Gone(e))
{
dead.Add(e);
continue;
}
Dictionary<string, object> row = At(e.transform.position);
row["kind"] = WorldKind(e);
var crate = e as HackableLockedCrate;
if (crate != null)
{
if (crate.IsFullyHacked())
row["hacked"] = true;
else if (crate.IsBeingHacked())
row["hackLeftSec"] = (int)Math.Max(0f, HackableLockedCrate.requiredHackSeconds - crate.hackSeconds);
}
world.Add(row);
}
foreach (BaseEntity e in dead)
_mapWorld.Remove(e);
var events = new List<object>();
// RunicNPC's own NPCs by net id, for their names, bosses and a boss's adds (runicnpc
// stage 8, D302, D303). Empty without RunicNPC.
Dictionary<string, Dictionary<string, object>> runic = RunicNpcsForMap();
foreach (WorldEntry w in _world.Values)
{
Dictionary<string, object> row;
if (w.Kind == WorldKindZone)
{
row = At(new UnityEngine.Vector3(w.X, 0f, w.Z));
row["radius"] = Math.Round(w.Radius, 1);
if (!string.IsNullOrEmpty(w.Name))
row["name"] = w.Name;
}
else
{
// Where it is NOW: an NPC walks, and a crate can be knocked about.
var found = FindEntity(w.Id) as BaseEntity;
if (Gone(found))
continue;
row = At(found.transform.position);
row["prefab"] = w.Prefab;
// The step that placed it, so a step's NPCs still standing can be counted.
row["key"] = w.Key;
Dictionary<string, object> npc;
if (runic.TryGetValue(w.Id, out npc))
{
MapNpcFields(row, npc);
runic.Remove(w.Id);
}
}
row["kind"] = w.Kind;
row["runId"] = w.RunId;
events.Add(row);
}
// What is left of RunicNPC's list was placed by no step. A boss's adds are owned by its
// run (`run:<id>`) and join the events layer under it; any other boss is in the open
// world, as cargo or a helicopter is. A placement's ordinary NPCs are not on the map.
foreach (Dictionary<string, object> npc in runic.Values)
{
object value;
string owner = npc.TryGetValue("owner", out value) ? value as string : null;
bool boss = npc.TryGetValue("boss", out value) && value != null;
Dictionary<string, object> row;
if (owner != null && owner.StartsWith("run:", StringComparison.Ordinal))
{
row = MapNpcAt(npc);
MapNpcFields(row, npc);
row["kind"] = WorldKindNpc;
row["runId"] = owner.Substring(4);
row["add"] = true;
events.Add(row);
}
else if (boss)
{
row = MapNpcAt(npc);
row["kind"] = "boss";
if (npc.TryGetValue("name", out value) && value is string)
row["name"] = value;
world.Add(row);
}
}
var players = new List<object>();
foreach (BasePlayer p in BasePlayer.activePlayerList)
{
if (Gone(p) || p.IsDead())
continue;
Dictionary<string, object> row = At(p.transform.position);
row["steamId"] = SteamIdOf(p);
row["name"] = p.displayName;
row["sleeping"] = p.IsSleeping();
row["online"] = true;
players.Add(row);
}
int maxSleepers = MapMaxSleepers();
int sleepers = 0;
bool sleepersTruncated = false;
foreach (BasePlayer p in BasePlayer.sleepingPlayerList)
{
if (Gone(p) || p.IsDead() || p.IsConnected)
continue;
if (sleepers >= maxSleepers)
{
sleepersTruncated = true;
break;
}
Dictionary<string, object> row = At(p.transform.position);
row["steamId"] = SteamIdOf(p);
row["name"] = p.displayName;
row["sleeping"] = true;
row["online"] = false;
players.Add(row);
sleepers++;
}
int maxBases = MapMaxBases();
var bases = new List<object>();
bool basesTruncated = false;
dead.Clear();
foreach (BaseEntity e in _mapBases)
{
if (Gone(e))
{
dead.Add(e);
continue;
}
if (bases.Count >= maxBases)
{
basesTruncated = true;
continue;
}
Dictionary<string, object> row = At(e.transform.position);
row["kind"] = e is BuildingPrivlidge ? "tc" : "vending";
bases.Add(row);
}
foreach (BaseEntity e in dead)
_mapBases.Remove(e);
clock.Stop();
_mapLiveAsks++;
_mapLiveLastMs = clock.ElapsedMilliseconds;
_mapLiveMaxMs = Math.Max(_mapLiveMaxMs, _mapLiveLastMs);
Dictionary<string, object> reply = Frame("map.live", "reply");
reply["mapKey"] = MapKey();
reply["world"] = world;
reply["events"] = events;
reply["players"] = players;
reply["bases"] = bases;
reply["tookMs"] = clock.ElapsedMilliseconds;
if (sleepersTruncated)
reply["playersTruncated"] = true;
if (basesTruncated)
reply["basesTruncated"] = true;
if (reqId != null)
reply["reqId"] = reqId;
EmitFrame(reply);
}
/// <summary>The map's one refusal frame. Every reason is a word the module can switch on.</summary>
private void EmitMapError(string reqId, string reason, string message)
{
Dictionary<string, object> frame = Frame("map.error", "reply");
frame["reason"] = reason;
if (!string.IsNullOrEmpty(message))
frame["message"] = message;
if (reqId != null)
frame["reqId"] = reqId;
_lastMapSummary = $"refused ({reason}): {message}";
EmitFrame(frame);
}
/// <summary>What the map can serve and what asking it costs, from the server console.</summary>
[ConsoleCommand("rg.map")]
private void CmdMap(ConsoleSystem.Arg arg)
{
// Re-read rather than print the last answer: the cache can empty, or a render land,
// between asks, and a console that shows yesterday's source misleads the one person
// looking at it.
MapPicture picture = _worldReady ? CurrentPicture() : _mapPicture;
int cells;
float cellSize;
GameGrid(out cells, out cellSize);
string hash = picture != null ? picture.Sha256 : null;
arg.ReplyWith(
$"protocol={ProtocolVersion} mapKey={MapKey()} source={(picture != null ? picture.Source : MapSourceNone)} " +
$"bytes={(picture != null ? picture.Bytes.Length : 0)} size={(picture != null ? picture.Width + "x" + picture.Height : "-")} " +
$"sha256={(hash != null ? hash.Substring(0, 12) : "-")} rendering={_mapRendering} " +
$"grid={cells}x{cellSize:0.##}m" + Environment.NewLine +
$"following world={_mapWorld.Count} bases={_mapBases.Count} " +
$"live asks={_mapLiveAsks} last={_mapLiveLastMs}ms max={_mapLiveMaxMs}ms" + Environment.NewLine +
$"last: {_lastMapSummary}");
}
// ---- 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();
}
// ---- 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)} inventoryBuilding={(_inventoryJob != null)}" + Environment.NewLine +
$"last sync: {_lastPermSummary}" + Environment.NewLine +
$"last inventory: {_lastInventorySummary}");
}
/// <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();
ConfigWriteJob job = _configJob;
string waiting = "no";
if (job != null)
{
double seconds = (DateTime.UtcNow - job.PhaseStarted).TotalSeconds;
waiting = $"{job.Reload} ({job.Phase.ToString().ToLowerInvariant()}, " +
$"{seconds:0}s of {ConfigReloadCeilingSeconds:0}s)";
}
arg.ReplyWith(
$"protocol={ProtocolVersion} framework={(IsCarbon() ? "carbon" : "oxide")} " +
$"root={(string.IsNullOrEmpty(root) ? "(none)" : root)}" + Environment.NewLine +
$"log={NewestLogFile() ?? "(none: a failed reload waits out the ceiling)"}" + Environment.NewLine +
$"waiting={waiting}" + 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}");
}
}
}