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 { /// /// The Runic Gateway bridge. Dials out to a rust-link sidecar on loopback and speaks /// newline-delimited JSON over it: events out, commands in. /// /// /// Threading contract, which everything later in this plugin depends on: /// /// /// /// 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. /// /// /// One link thread owns the socket. It connects, drains the queue, and reconnects with /// backoff. A single writer keeps event ordering intact. /// /// /// A reader thread parses inbound lines and hands each to the main thread via /// Interface.Oxide.NextTick. 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. /// /// /// /// /// The outbound queue is bounded. On overflow the oldest record is dropped and counted, /// because telemetry is worth less than the server's memory. /// /// /// /// Loopback is the trust boundary. There is no token on this link: the plugin and /// the sidecar share a host, and the sidecar binds 127.0.0.1. Pointing Host at /// anything routable puts an unauthenticated command channel on the network. /// /// [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 { /// /// Wire-protocol version. Declared in three places that must agree: here, /// PROTOCOL_VERSION in the sidecar, and module.json in Module-Rust. The /// overlay's overlay.toml carries this same number so the installer can refuse /// to pair a sidecar and an overlay that disagree. /// /// /// 14 (PROTOCOL.md §20, the module's PLAN_REDESIGNS.md §2): kits.uses, and the /// one-time reward messages perm.sync carries as notices (D333); and /// Economics (§20.3, PLAN_REDESIGNS.md §11.2): economics.pay, /// economics.balances, economics.adjust, the tally's balance and the /// economics.wiped event; and Backpacks (§20.4, PLAN_REDESIGNS.md §11.3): /// backpacks.read and hello's integrations.backpacks.sizes; and Rust's own /// world events (§20.5, PLAN_REDESIGNS.md §14.3.1): the world.event kind and its /// 24 types, world.call, hello's console.plugins, and the tally's /// cratesHacked and dropsLooted, where apcKills and /// heliKills are no longer sent; and TruePVE (§20.6, PLAN_REDESIGNS.md §14.3.2): /// world.zone's truePve and the integrations.truePve it needs; and /// Dynamic PVP (§20.7, PLAN_REDESIGNS.md §14.3.3): pvp.zone.created and /// pvp.zone.deleted, map.live's pvp layer and /// integrations.dynamicPvp; and Dangerous Treasures (§20.8, PLAN_REDESIGNS.md /// §14.3.4): dt.started, dt.won and dt.ended, dt.start, /// map.live's treasure rows, the tally's treasuresWon and /// integrations.dangerousTreasures; and Guarded Crate (§20.9, PLAN_REDESIGNS.md /// §14.3.5): gc.started, gc.ended and gc.failed, gc.place, /// map.live's guarded rows, the tally's guardedWon and /// integrations.guardedCrate with NpcSpawn beside it. /// /// private const int ProtocolVersion = 14; /// /// Loopback reconnects are cheap, so the ceiling is low. A sidecar restart should cost /// a few seconds of buffering, not half a minute. /// private const int MaxBackoffMs = 5000; /// /// 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. /// private const int StableLinkMs = 10000; /// The longest line accepted from the sidecar. Over-long lines are discarded. private const int MaxInboundLineBytes = 1024 * 1024; /// /// How long to wait for the TCP connect itself before giving up and backing off. /// /// /// It exists because TcpClient.Connect 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 drops 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 /// Unload waits out its whole join on the game's MAIN THREAD. /// /// /// /// Generous against any real network and short against a hang. Loopback, which is the /// supported deployment, connects in well under a millisecond. /// /// private const int ConnectTimeoutMs = 3000; /// /// 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. /// private const float BoardCadenceSeconds = 60f; private static readonly ConcurrentQueue Outbound = new ConcurrentQueue(); private static readonly AutoResetEvent Wake = new AutoResetEvent(false); /// /// Set once, by Unload, and never reset. It is what lets a thread blocked on a /// CONNECT give up immediately instead of at the end of its timeout. /// /// /// It cannot be : that handle also means "the queue has something in /// it", which Emit signals constantly, so waiting on it during a connect would /// abandon the attempt every time a hook fired. Two meanings, two handles. /// /// private readonly ManualResetEvent _stopping = new ManualResetEvent(false); private PluginConfig _config; private Thread _link; private volatile bool _running; private volatile bool _connected; /// Set when the peer goes away, so the writer stops trying. private volatile bool _dead; /// /// 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. /// private volatile bool _loggedConnectFailure; /// /// Incremented per connection attempt. A reader from a previous connection must not be /// able to mark a newer one dead — reader.Join can time out, and the stale /// thread's finally block would otherwise tear down the connection that replaced it. /// private int _epoch; private int _depth; private long _sent, _dropped, _received, _connects, _writeErrors; /// /// Identifies this run of the server process. 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. /// /// /// It must therefore be stable across a plugin reload, which a fresh /// Guid at Init is not: oxide.reload 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. /// /// private string _bootId; /// /// The current wipe, as w-yyyyMMddTHHmmssZ, 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. /// /// /// Cached rather than read per frame, and re-read on OnNewSave, 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. /// /// private string _wipeId; /// /// Per-player counters waiting to be flushed as a player.tally 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. /// private readonly Dictionary _tallies = new Dictionary(); /// /// When each connected player connected, in epoch milliseconds, so a disconnect can /// report a session length. Main thread only. /// /// /// A player already on the server when this plugin loaded has no entry, and /// sessionSec is then absent 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. /// /// private readonly Dictionary _connectedAt = new Dictionary(); /// /// Every hook name this plugin declares, and whether it has fired at least once. /// /// /// Both frameworks bind hooks by name and arity, through reflection, with no /// compile-time check: 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. rg.hooks 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. /// /// private readonly Dictionary _hookCounts = new Dictionary(); /// /// The repeating boards/tally timer. /// /// /// Typed as the plugin-facing Oxide.Plugins.Timer, which is what /// timer.Every hands back — not Oxide.Core.Libraries.Timer.TimerInstance, /// 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 /// Unload stop it before the link thread is joined rather than after. /// /// private Timer _cadence; // ---- lifecycle ---- /// /// Every hook this plugin declares. Seeded into _hookCounts at zero so that /// rg.hooks can report a hook that has never fired — which is the whole /// question, and which a dictionary that only learns names as they arrive can never /// answer. /// 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", "OnPlayerSleepEnded", "OnEconomicsBalanceUpdated", "OnEconomicsDataWiped", // Protocol 14, Rust's own world events (§20.5). "OnPatrolHelicopterKill", "OnHelicopterRetire", "OnBradleyApcInitialize", "OnCargoShipHarborApproach", "OnCargoShipHarborArrived", "OnCargoShipHarborLeave", "OnCargoShipEgress", "OnHelicopterDropCrate", "OnCrateLanded", "CanHackCrate", "OnCrateHack", "OnCrateHackEnd", "OnLootEntity", "OnCargoPlaneSignaled", "OnAirdrop", "OnSupplyDropDropped", "OnSupplyDropLanded", "OnDeepSeaOpened", "OnDeepSeaClosed", // Protocol 14, Dynamic PVP's zones (§20.7). "OnCreatedDynamicPVP", "OnDeletedDynamicPVP", // Protocol 14, Dangerous Treasures (§20.8). "OnDangerousEventStarted", "OnDangerousEventWon", "OnDangerousEventEnded", // Protocol 14, Guarded Crate (§20.9). "OnGuardedCrateEventStart", "OnGuardedCrateEventEnded", "OnGuardedCrateEventFailed" }; 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 { /// Where the sidecar listens. Loopback is the trust boundary; see the class docs. public string Host = "127.0.0.1"; public int Port = 7799; /// /// 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. /// public int QueueCap = 5000; /// /// This server's stable identity across wipes and restarts, as the website knows /// it. It is not derived from the hostname: an operator renames a server for /// a season and the site must not lose its history for it. /// public string ServerId = "main"; /// /// Whether the website may borrow values on this server for events (protocol 8, /// D76). Off until an operator turns it on. 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. /// public bool EventsEnabled = false; /// /// The most crates one event step may place (protocol 9, D95). An operator may /// lower it; is the ceiling it cannot be raised /// past. A step over the bound is refused, never trimmed to fit. /// public int EventsMaxCrates = WorldHardMaxCrates; /// The most NPCs one event step may place (D95). Lowerable, like the above. public int EventsMaxNpcs = WorldHardMaxNpcs; /// /// 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. /// public int EventsMaxRecipients = RecipientsHardMax; /// The most tallies that may count at once on this server (§29.5). public int EventsMaxTallies = TallyHardMax; /// The longest line an event or a news post may say in chat, in characters. public int EventsMaxChat = ChatHardMax; /// /// The most world events one run may call on this server (protocol 14, D354): patrol /// helicopters, cargo ships, airdrops and the rest, each counted. Lowerable, like the /// bounds above; is the ceiling. /// public int EventsMaxWorldCalls = WorldCallsHardMax; /// /// 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. /// public int MapMaxSleepers = MapDefaultMax; /// The most tool cupboards and vending machines one map answer carries. public int MapMaxBases = MapDefaultMax; } /// /// 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. /// 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); } /// The website's rule for a server id, `^[a-z0-9][a-z0-9-]{0,63}$`, without a Regex. 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() ?? 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.EventsMaxWorldCalls = Math.Max(0, Math.Min(_config.EventsMaxWorldCalls, WorldCallsHardMax)); _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["EventsMaxWorldCalls"] == 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(); LoadTruePvePending(); LoadWorld(); LoadRewards(); LoadWorldCalls(); LoadDtStarts(); LoadGcPlacements(); } // ---- the outbound queue ---- /// /// Main thread. Non-blocking. must already be a complete JSON /// object with no embedded newline; the newline is appended by the writer as the frame /// delimiter. /// 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 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; } /// /// Connects with a bound, and with a way out. /// /// /// The wait ends on whichever comes first: the connect completing, the plugin being /// unloaded, or . Without the middle one a reload against /// an unreachable sidecar costs the game's main thread the whole join timeout — /// observed as hook 'Unload' took longer than 100ms [2002ms] beside /// link thread did not stop cleanly, which is phase 1's stall arriving by a /// different road. /// /// 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; } } } /// /// Reader thread. Splits the stream into lines as bytes and decodes each finished /// line as UTF-8. /// /// /// 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 0x0A /// 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 char, 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). /// /// 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 } } } /// /// Reader thread. Marshals to the main thread; nothing that touches the game happens /// here. /// 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 ---- /// Main thread. Every world read below is safe only because of that. private void HandleCommand(string line) { Dictionary cmd; try { cmd = JsonConvert.DeserializeObject>(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 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 "world.call": // Protocol 14 (PROTOCOL.md §20.5, D354). One of the game's own world events, // now: kept, never reverted. HandleWorldCall(cmd, reqId); break; case "dt.start": // Protocol 14 (PROTOCOL.md §20.8, D365). One Dangerous Treasures event, opened // through DT's own console command; DT picks the spot and despawns its own. HandleDtStart(cmd, reqId); break; case "gc.place": // Protocol 14 (PROTOCOL.md §20.9, D364, D373). One guarded crate, placed through // Guarded Crate's own console command; kept when the run ends. HandleGcPlace(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 "kits.uses": // Protocol 14 (the module's PLAN_REDESIGNS.md §2.6). How many of the given // players have used a kit up, for the warning beside a kit weekend. HandleKitsUses(cmd, reqId); break; case "economics.pay": // Protocol 14 (PROTOCOL.md §20.3, the module's PLAN_REDESIGNS.md §11.2). Coins // from an event step, kept once paid (D335). HandleEconomicsPay(cmd, reqId); break; case "economics.balances": HandleEconomicsBalances(cmd, reqId); break; case "economics.adjust": // Staff, from a player's admin page: deposit, withdraw or set. HandleEconomicsAdjust(cmd, reqId); break; case "backpacks.read": // Protocol 14 (PROTOCOL.md §20.4, the module's PLAN_REDESIGNS.md §11.3). One // player's backpack, read and never written (R2). HandleBackpacksRead(cmd, 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 ---- /// /// Builds the envelope every outbound frame shares (PROTOCOL.md §8.1). Nothing may /// emit a frame it did not get from here. /// /// /// is what the sidecar files on — event is appended to /// history, snapshot replaces a board, reply is routed by /// reqId, control 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. /// /// /// /// wipeId is omitted 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. /// /// private Dictionary Frame(string kind, string type) { var frame = new Dictionary { { "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; } /// /// Main thread. Both boards, in the order a reader wants them: what the server is, /// then who is on it. /// /// /// 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. /// /// 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)}"); } } /// Main thread. private void SendHello() { Dictionary 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 { { "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 { { "version", ZoneManagerPlugin().Version.ToString() } }; List flags = ZoneFlagNames(ZoneManagerPlugin()); if (flags != null) zoneManager["flags"] = flags; hello["zoneManager"] = zoneManager; } EmitFrame(hello); } /// /// 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. /// private void SendPlayersBoard() { var players = new List(); if (BasePlayer.activePlayerList != null) { foreach (BasePlayer player in BasePlayer.activePlayerList) { if (player == null) continue; var row = new Dictionary { { "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 frame = Frame("players.online", "snapshot"); frame["count"] = players.Count; frame["players"] = players; EmitFrame(frame); } /// /// Main thread. The shared body of server.hello and server.status — 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. /// private Dictionary ServerBody() { var status = new Dictionary { { "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(); // Protocol 14 (D363): whether another plugin's console command can be run from here. status["console"] = ConsoleBody(); return status; } /// /// Puts an IP address onto a frame, or leaves it off. /// /// /// The game does not always have one. A console banid against an offline id /// produces the literal string "0" 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. /// /// /// /// Absent, not zero. The same rule `saveCreatedAt` and `sessionSec` follow. /// /// private static void PutAddress(Dictionary frame, string ip) { if (string.IsNullOrEmpty(ip) || ip == "0" || ip == "0.0.0.0") return; frame["ip"] = ip; } private static void Merge(Dictionary into, Dictionary from) { foreach (KeyValuePair pair in from) into[pair.Key] = pair.Value; } /// /// Describes who or what killed somebody, onto . /// /// /// Four outcomes, and collapsing any two of them loses something a killfeed needs: /// player (a real kill, and the only one that belongs on a leaderboard), /// self, npc and environment — 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. /// /// private void DescribeAttacker( Dictionary 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); } /// /// The killing blow's weapon as the killfeed names it: the held entity's short prefab /// name (bow_hunting.entity), 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. /// 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; } /// /// 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. /// 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 ---- /// /// An entity's position as Rust's own map reference (H7), 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. /// private static string GridOf(BaseEntity entity) { if (entity == null || entity.transform == null) return null; return Grid(entity.transform.position); } /// /// The game's own label for a position, from MapHelper — the function the in-game /// map draws its grid with (D119). /// /// /// 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. /// /// 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 ---- /// /// The current wipe, as w-yyyyMMddTHHmmssZ, or null if this server has never /// saved. /// /// /// 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. /// /// 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; } } /// /// Records that a hook fired. See _hookCounts for why this exists at all. /// 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"); // Mappings an erase left while TruePVE was away (§14.3.2). When TruePVE loads after // this, its own OnPluginLoaded does it. FlushTruePvePending("load"); // The map's sets are seeded here for the same reason: a hotload hears no spawns. SeedMapSets(); SeedPvpZones("load"); SeedTreasures("load"); NoteDtSeen(DangerousTreasuresPlugin() != null); SeedGuardedCrates("load"); // Protocol 14: the console probe (D363) and the world events' sweep. Before hello, which // carries what the probe found. StartWorldEvents(); // 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 frame = Frame("server.initialized", "event"); frame["initial"] = initial; EmitFrame(frame); } private void OnServerShutdown() { MarkHook("OnServerShutdown"); FlushTallies(); if (_talliesDirty) SaveTallies(); EmitFrame(Frame("server.shutdown", "event")); } /// /// 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. /// 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 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(); ClearWorldEvents(); // 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 frame = Frame("player.connected", "event"); frame["steamId"] = SteamIdOf(player); frame["name"] = player.displayName; EmitFrame(frame); // A balance carried over from last wipe is known at once, without waiting for it to // move (D336). Nothing when Economics is not loaded. NoteBalance(player); } 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 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 frame = Frame("player.respawned", "event"); frame["steamId"] = SteamIdOf(player); frame["name"] = player.displayName; EmitFrame(frame); } /// /// Void, and deliberately: uMod documents a non-null return here as overriding the /// death itself. See rule 1 at the top of this region. /// 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 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); } /// /// Void, and deliberately: a non-null return here overrides chat delivery — the bridge /// would be silently eating messages. /// private void OnPlayerChat(BasePlayer player, string message, ConVar.Chat.ChatChannel channel) { MarkHook("OnPlayerChat"); if (player == null || string.IsNullOrEmpty(message)) return; Dictionary 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); } /// /// Void, and deliberately: a non-null return here replaces what the player receives. /// This hook is a counter — see rule 2. /// 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). /// The bonus on the final hit of a tree or a node. Void: an Item return replaces the bonus. 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); } /// /// 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. /// 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); } } /// A farmed plant, harvested. No return behaviour on either framework. 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); } /// /// 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. /// /// /// The type checks are ordered cheapest-first and return early, because this hook /// fires for every tree, barrel and animal on the map. /// /// 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). Protocol 14: the death is a world event that // carries every candidate, and the site credits Rotorbane and Tankbreaker (D372). if (IsVehicleKill(entity)) { WorldVehicleDied(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 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. /// The APC and the two helicopters: the three things a title credits a kill of (D174). private static bool IsVehicleKill(BaseEntity entity) { return entity is BradleyAPC || entity is PatrolHelicopter || entity is CH47Helicopter; } /// /// 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 — who dealt it and how much — for the credit block its death /// carries (protocol 14, D372). A helicopter that flies off the map is never killed; /// the world events' sweep forgets its row. /// 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; NoteVehicleHit(entity, player, info); } /// /// 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). /// 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); } /// No return behaviour. Only a building block is a structure built; a deployable is not. 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() == null) return; TallyFor(player).Built++; } /// /// 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. /// 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; /// Counts one act of help unless it is yourself, an NPC, or the other hook already did this frame. 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++; } /// /// Void: a non-null return refuses the heal. Healing yourself is not a heal (D174). /// /// /// Rust's `MedicalTool.GiveEffectsTo(fromPlayer, toTarget)` raises it as /// (tool, fromPlayer, toTarget). 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 object: `IMedicalToolTarget` /// is not always a player, and only a player healed counts. /// /// private void OnHealingItemUse(MedicalTool tool, BasePlayer fromPlayer, object target) { MarkHook("OnHealingItemUse"); CountHelp(fromPlayer ?? (tool != null ? tool.GetOwnerPlayer() : null), target as BasePlayer); } /// Void: a non-null return refuses the revive. Reviving yourself is not possible, and is not counted. private void OnPlayerRevive(BasePlayer reviver, BasePlayer player) { MarkHook("OnPlayerRevive"); CountHelp(reviver, player); } /// No return behaviour. One rocket leaving a launcher. private void OnRocketLaunched(BasePlayer player, BaseEntity entity) { MarkHook("OnRocketLaunched"); if (player == null || player.IsNpc) return; TallyFor(player).Rockets++; } /// No return behaviour. A grenade, beancan, satchel or C4 thrown. private void OnExplosiveThrown(BasePlayer player, BaseEntity entity, ThrownWeapon item) { MarkHook("OnExplosiveThrown"); CountExplosive(player, entity); } /// No return behaviour. The same, dropped at your feet rather than thrown. private void OnExplosiveDropped(BasePlayer player, BaseEntity entity, ThrownWeapon item) { MarkHook("OnExplosiveDropped"); CountExplosive(player, entity); } /// /// 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. /// 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; } } } /// /// 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. /// 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++; } /// /// The most people one entity.destroyed 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. /// private const int MaxAuthorizedOnFrame = 64; /// /// 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. /// 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; } /// /// 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). /// /// /// authorizedPlayers is the set, and nothing else is. The cupboard also holds /// recentGroupMembers, which LOOKS like residents and is not: it counts code-lock /// users towards group upkeep and prunes itself on a timer. /// /// /// /// 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. /// /// private static void AddAuthorization(Dictionary 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 online = new HashSet(); if (BasePlayer.activePlayerList != null) { foreach (BasePlayer player in BasePlayer.activePlayerList) { if (player != null) online.Add(player.userID); } } List authorized = new List(); foreach (ulong steamId in cupboard.authorizedPlayers) { if (authorized.Count >= MaxAuthorizedOnFrame) { frame["authorizedTruncated"] = true; break; } authorized.Add(new Dictionary { { "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 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 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 frame = Frame("player.unbanned", "event"); frame["steamId"] = id; frame["name"] = name; PutAddress(frame, ipAddress); EmitFrame(frame); } /// /// 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. /// /// /// It fires on every connection attempt, approved or not — so it is an attempt, not a /// denial. uMod publishes no OnUserRejected, 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 player.approved, decided by whoever reads the two /// frames later; PROTOCOL.md §8.8 records that as a correction to the trigger /// catalogue. /// /// private void CanUserLogin(string name, string id, string ipAddress) { MarkHook("CanUserLogin"); Dictionary 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 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. /// /// 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. /// private const int MaxClansOnBoard = 100; /// /// 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. /// private const int ClanBoardBudgetBytes = 768 * 1024; /// /// 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. /// private const float ClanBoardSettleSeconds = 3f; /// A pending early re-send of the clan board, so a burst of changes sends one. private Timer _clansBoardSoon; /// /// Main thread. Every clan on this server, with its roster and roles, as the board the /// website reconciles Teams against. /// /// /// The board is ALWAYS sent, including when there is nothing to read — with /// supported: false 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. /// /// private void SendClansBoard() { Dictionary frame = Frame("clans", "snapshot"); var clans = new List(); 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>> 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 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 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); } /// /// 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. /// private static IClan ReadClan(IClanBackend backend, long clanId) { IClan clan; if (backend.TryGet(clanId, out clan) && clan != null) return clan; System.Threading.Tasks.ValueTask> read = backend.Get(clanId); if (!read.IsCompleted || !read.Result.IsSuccess) return null; return read.Result.Value; } /// /// Main thread. A clan as the board carries it. /// /// /// A member's LastSeen 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. /// /// private Dictionary DescribeClan(IClan clan) { var roles = new Dictionary(); if (clan.Roles != null) { foreach (ClanRole role in clan.Roles) roles[role.RoleId] = role; } var members = new List(); if (clan.Members != null) { foreach (ClanMember member in clan.Members) { var row = new Dictionary { { "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 { { "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 } }; } /// /// 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. /// 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; } /// /// 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. /// private Dictionary ClanFrame(string kind, IClan clan) { Dictionary frame = Frame(kind, "event"); frame["clanId"] = clan.ClanId; frame["createdMs"] = clan.Created; frame["clanName"] = clan.Name; return frame; } /// /// Main thread. Puts a player onto a clan event as steamId and name — 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. /// private void PutPlayer(Dictionary 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; } /// /// 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. /// 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)}"); } }); } /// /// The founder's own membership raises no OnClanMemberAdded — the game adds them /// inside the creation — so this event carries them, and the website counts them in. /// private void OnClanCreated(LocalClan clan, ulong steamId) { MarkHook("OnClanCreated"); if (clan == null) return; Dictionary frame = ClanFrame("clan.created", clan); PutPlayer(frame, "steamId", "name", steamId); EmitFrame(frame); ScheduleClansBoard(); } /// /// Typed exactly, and that matters here more than anywhere: the uMod Clans plugin raises /// a hook with this SAME NAME and arity — OnClanDisbanded(string, List<ulong>) /// — 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. /// private void OnClanDisbanded(LocalClan clan, ulong steamId) { MarkHook("OnClanDisbanded"); if (clan == null) return; Dictionary 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 members = new List(); foreach (ClanMember member in clan.Members) members.Add(member.SteamId.ToString()); frame["members"] = members; } EmitFrame(frame); ScheduleClansBoard(); } /// /// 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 createdMs and the website matches it on the id alone. /// 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 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 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 frame = ClanFrame("clan.member.kicked", clan); PutPlayer(frame, "steamId", "name", steamId); PutPlayer(frame, "bySteamId", "byName", bySteamId); EmitFrame(frame); ScheduleClansBoard(); } /// /// 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. /// private void OnClanColorChanged(LocalClan clan, UnityEngine.Color32 color, ulong steamId) { MarkHook("OnClanColorChanged"); ScheduleClansBoard(); } // ---- tallies ---- /// /// One player's counters between two flushes. Main thread only; no lock, ever. /// private class Tally { public readonly Dictionary Gathered = new Dictionary(); 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 WeaponKills = new Dictionary(); public int Headshots; /// The longest player kill this interval, in metres: a best, not a sum. public float BestPvpM; /// The longest NPC or animal kill this interval: a best, not a sum. public float BestPveM; // APC and helicopter kills left the tally at protocol 14: they are counted by the site // from `world.event`'s credit block, by the server's own rule (D372). /// Protocol 14 (D356): locked crates this player started hacking, once per crate (Safecracker). public int CratesHacked; /// Protocol 14 (D356): airdrops this player looted first (Scavenger). public int DropsLooted; /// Protocol 14 (D375): Dangerous Treasures chests this player emptied (Treasure Hunter). public int TreasuresWon; /// Protocol 14 (D375): guarded crates this player cleared, by killing the last guard (Warden). public int GuardedWon; public readonly Dictionary Crafted = new Dictionary(); /// Stage 4 (D247): kills of RunicNPC's NPCs, by profile name. Also in . public readonly Dictionary NpcProfileKills = new Dictionary(); public int Built; public int Repaired; public int Healed; public int Rockets; public int Explosives; public int Missions; /// /// Protocol 14 (§11.2.2): the player's Economics balance, a value and not a change, /// like the bests. Sent only when : it moved, or was read at /// connect, since the last flush. /// public double Balance; public bool HasBalance; 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); } /// /// 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. /// 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 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 && CratesHacked == 0 && DropsLooted == 0 && TreasuresWon == 0 && GuardedWon == 0 && Crafted.Count == 0 && Built == 0 && Repaired == 0 && Healed == 0 && Rockets == 0 && Explosives == 0 && Missions == 0 && NpcProfileKills.Count == 0 && !HasBalance; } } 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; } /// /// The tally of a player who need not be online — an Economics balance reaches an offline /// player too. It carries no name, and the frame it flushes has none. /// private Tally TallyForId(ulong userId) { Tally tally; if (!_tallies.TryGetValue(userId, out tally)) { tally = new Tally(); _tallies[userId] = tally; } return tally; } /// /// Emits one player.tally per player with anything to report, and clears what it /// emitted. /// /// /// A tally frame is a delta, not a running total: 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). /// /// 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(_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 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.CratesHacked > 0) frame["cratesHacked"] = tally.CratesHacked; if (tally.DropsLooted > 0) frame["dropsLooted"] = tally.DropsLooted; if (tally.TreasuresWon > 0) frame["treasuresWon"] = tally.TreasuresWon; if (tally.GuardedWon > 0) frame["guardedWon"] = tally.GuardedWon; 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; // Protocol 14: the current balance, a value the site stores rather than sums. if (tally.HasBalance) frame["balance"] = Math.Round(tally.Balance, 2); EmitFrame(frame); } /// /// The one repeating job: re-send the boards and flush the tallies. /// /// /// 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. /// /// private void Cadence() { if (!_connected) return; FlushTallies(); SendBoards(); } /// /// Collapses every run of whitespace and control characters to one space, so an /// exception message stays one log entry. /// /// /// The control half is not defensive padding. Mono's SocketException.Message on /// Windows comes back from a fixed-size OS buffer and is NUL-padded — a couple /// of hundred \0 bytes sitting in the middle of the sentence. \0 is not /// whitespace, so Trim 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. /// /// 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(); } /// /// The server process's start instant, as boot-yyyyMMddTHHmmssZ. /// /// /// Process.StartTime 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. /// /// 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 ---- /// /// The alphabet a link code is drawn from. /// /// /// No O, 0, I or 1. 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. /// /// private const string CodeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; private const int CodeLength = 6; /// /// 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. /// private static readonly TimeSpan CodeTtl = TimeSpan.FromMinutes(5); /// How long a player must wait between /link requests. private static readonly TimeSpan RequestCooldown = TimeSpan.FromSeconds(30); /// /// An unconfirmed code, and the Steam identity it will bind. /// /// /// These live in memory and nowhere else, 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 /link again, which is /// cheaper than a credential in a second process. /// /// private sealed class PendingLink { public string SteamId; public string Name; public DateTime Expires; } /// code -> pending link. Main thread only, like every world read here. private readonly Dictionary _codes = new Dictionary(StringComparer.OrdinalIgnoreCase); /// steam id -> when they last asked, so /link cannot be spammed. private readonly Dictionary _lastLinkRequest = new Dictionary(StringComparer.Ordinal); private readonly System.Random _codeRandom = new System.Random(); /// /// /link — mint a one-time code for the caller and tell the sidecar about it. /// /// /// Chat, not console, and the reply is private. Both frameworks consume a /// / command rather than broadcasting it, and SendReply 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. /// /// /// /// The plugin does not 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. /// /// [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 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 " + code + "."); SendReply( player, "Enter it on the website within " + (int)CodeTtl.TotalMinutes + " minutes to link this Steam account. It works once."); } /// /// /unlink — ask the website to sever this Steam id's link. /// /// /// It emits rather than deletes, because the plugin holds no link to delete. The /// site owns the record and applies this like any other event off the feed. /// /// /// /// It exists because the site refuses 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. /// /// [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 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."); } /// /// Inbound link.confirm — the website has been given a code and wants to know /// whose it is. /// /// /// This is the first command the website originates 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. /// /// /// /// A code is consumed on the first 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. /// /// private void HandleLinkConfirm(Dictionary 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 ok = Frame("link.ok", "reply"); ok["steamId"] = pending.SteamId; ok["name"] = pending.Name; if (reqId != null) ok["reqId"] = reqId; EmitFrame(ok); } /// /// A refusal, with a reason the website can turn into a sentence. /// /// /// unknown and expired are deliberately separate here and /// deliberately the same 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. /// /// private void EmitLinkError(string reqId, string reason) { Dictionary frame = Frame("link.error", "reply"); frame["reason"] = reason; if (reqId != null) frame["reqId"] = reqId; EmitFrame(frame); } /// Drops every outstanding code belonging to one Steam id. private void DropCodesFor(string steamId) { List stale = null; foreach (KeyValuePair entry in _codes) { if (entry.Value != null && entry.Value.SteamId == steamId) { if (stale == null) stale = new List(); stale.Add(entry.Key); } } if (stale == null) return; foreach (string key in stale) _codes.Remove(key); } /// /// Drops expired codes and stale cooldowns. /// /// /// Run on a timer rather than only on lookup, because an unconfirmed code is never /// looked up — a player who runs /link and wanders off leaves an entry that /// nothing else would ever remove. /// /// private void PurgeExpiredCodes() { DateTime now = DateTime.UtcNow; List dead = null; foreach (KeyValuePair entry in _codes) { if (entry.Value == null || now > entry.Value.Expires) { if (dead == null) dead = new List(); dead.Add(entry.Key); } } if (dead != null) { foreach (string key in dead) _codes.Remove(key); } List cold = null; foreach (KeyValuePair entry in _lastLinkRequest) { if (now - entry.Value > RequestCooldown) { if (cold == null) cold = new List(); cold.Add(entry.Key); } } if (cold == null) return; foreach (string key in cold) _lastLinkRequest.Remove(key); } /// /// A code from , retried until it does not collide. /// /// /// System.Random 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. /// /// 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. /// /// How many store operations one timer step performs. /// /// /// 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. /// /// private const int PermOpsPerStep = 200; /// How often the sync drain runs while a job is in flight. private const float PermStepSeconds = 0.05f; /// /// The ceiling on one sync, in operations. /// /// /// 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. /// /// private const int PermMaxOps = 20000; /// The operations a compiled sync is made of. One store question each. private enum PermOpKind { EnsureGroup, EnsureGroupPermission, EnsureMember, EnsureGrant, RetireGroupPermission, RetireMember, RetireGrant, RetireGroup, /// /// 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. /// EnsureGroupParent, /// Protocol 12: a group's BetterChat style (D138). EnsureChatStyle, /// Protocol 12: a styled group's BetterChat group, gone from the site (D139). RetireChatGroup } private struct PermOp { public PermOpKind Kind; /// A Steam id, or a group name — whichever the op is about. public string Subject; /// A permission name, a group name, or null. public string Object; /// Only uses these. public string Title; public int Rank; /// /// 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. /// public bool HasRank; } /// /// 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. /// private sealed class PermSyncJob { public string ReqId; public string SetId; public List Ops = new List(); public int Next; public int Grants; public int Revokes; public int GroupsCreated; /// Protocol 13: an existing group whose title, rank or parent the sync changed. 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 Unresolved = new List(); public readonly List Pending = new List(); /// /// Grants the store did not hold after being told to (subject:permission). /// Reported rather than counted, so the site can never again be told a grant /// landed that did not (D85). /// public readonly List NotLanded = new List(); /// /// Group permissions an event lease holds (protocol 8). Not applied; what the site /// asked for is kept on the hold instead. /// public readonly List Leased = new List(); public readonly HashSet LeasedSeen = new HashSet(); /// Permission names already reported unresolved, so each is reported once. public readonly HashSet UnresolvedSeen = new HashSet(); /// /// 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. /// public List Credits; public int CreditsApplied; public int CreditsWithdrawn; public bool CreditsWaiting; public readonly List CreditsUnresolved = new List(); /// /// The site's untold reward messages for this server (protocol 14, D333), or null when /// the sync carried none — an older website, whose silence must not cancel anything. /// public List Notices; /// The keys of already told, for the report. public readonly List NoticesTold = new List(); /// /// Whether BetterChat was loaded when this sync was compiled (protocol 12). When it /// was not, no style op is compiled and the report says loaded: false and /// nothing else: the permissions sync exactly as they did before. /// public bool ChatLoaded; public string ChatVersion; /// The fields the site wants on each styled group. public readonly Dictionary> ChatStyles = new Dictionary>(); public int ChatApplied; public int ChatSaved; public int ChatSame; public readonly List ChatDrift = new List(); public readonly List ChatRemoved = new List(); public readonly List ChatFailed = new List(); } /// /// One BetterChat field the site wants on a group (protocol 12): the value, and the /// value this site last pushed there. /// private sealed class ChatFieldWant { public string Field; public string Value; /// /// 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). /// public string Expect; } private PermSyncJob _permJob; private Timer _permTimer; /// /// True while this plugin is writing to the permission store. /// /// /// The framework raises OnUserPermissionGranted 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. /// /// private bool _applyingPermissions; /// The last report, for rg.perms. Diagnostics only. private string _lastPermSummary = "none yet"; /// /// Main thread. Everything this server knows about permissions and groups — the option /// source the website's authoring form is built from (D33). /// /// /// 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. /// /// private void HandlePermCatalogue(string reqId) { Dictionary reply = Frame("perm.catalogue", "reply"); var permissions = new List(); string[] registered = permission.GetPermissions(); if (registered != null) { foreach (string name in registered) { if (!string.IsNullOrEmpty(name)) permissions.Add(name); } } var groups = new List(); string[] groupNames = permission.GetGroups(); if (groupNames != null) { foreach (string name in groupNames) { if (string.IsNullOrEmpty(name)) continue; var row = new Dictionary { { "name", name }, { "title", permission.GetGroupTitle(name) }, { "rank", permission.GetGroupRank(name) } }; var granted = new List(); 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. /// How many permissions or groups one build step asks about. private const int InventorySourcesPerStep = 25; /// How many bytes of rows one page may carry, well inside the 1 MiB line cap. private const int InventoryPageBudgetBytes = 700 * 1024; /// /// 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. /// private const long InventoryMaxBuildMs = 8000; /// How long a finished snapshot is kept for the pages after the first. private const long InventoryKeepMs = 2 * 60 * 1000; private sealed class InventoryUser { public string SteamId; public string Name; public readonly List Permissions = new List(); public readonly List Groups = new List(); } /// A build in flight. There is at most one; a second request is answered `busy`. private sealed class InventoryJob { public string ReqId; public long StartedMs; /// Started from the server console (rg.inventory): logged, never sent. public bool FromConsole; /// Permissions first, then groups prefixed with a NUL, one store question each. public readonly List Sources = new List(); public int Next; public readonly Dictionary Users = new Dictionary(); public List Permissions; public List Groups; public List Leased; } /// A finished snapshot, paged from memory. private sealed class Inventory { public string Id; public long BuiltMs; public long BuildMs; public List Permissions; public List Groups; public List Leased; public int UserCount; public readonly List> Pages = new List>(); } private InventoryJob _inventoryJob; private Timer _inventoryTimer; private Inventory _inventory; /// The last inventory built, for rg.perms. Diagnostics only. private string _lastInventorySummary = "none yet"; /// /// 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. /// [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"); } /// Main thread. Page 0 starts a build; any other page is read from the snapshot it names. private void HandlePermInventory(Dictionary 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); } /// Main thread. Reads the small parts at once and starts the sliced holder walk. 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(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(); foreach (string perm in registered) { if (string.IsNullOrEmpty(perm)) continue; string lowered = Lower(perm); var row = new Dictionary { { "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(); foreach (string name in permission.GetGroups() ?? new string[0]) { if (string.IsNullOrEmpty(name)) continue; string lowered = Lower(name); var carried = new List(); foreach (string perm in permission.GetGroupPermissions(name, false) ?? new string[0]) { if (!string.IsNullOrEmpty(perm)) carried.Add(Lower(perm)); } job.Groups.Add(new Dictionary { { "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(); 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 { { "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(); } /// Main thread. Asks up to permissions or groups who holds them. 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(); foreach (Dictionary 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(); foreach (KeyValuePair kv in owners) ownerText.Add(kv.Key + "=" + kv.Value); List 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; } /// /// 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. /// 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 { { "permissions", job.Permissions }, { "groups", job.Groups }, { "leased", job.Leased } }, Formatting.None)); var ids = new List(job.Users.Keys); ids.Sort(StringComparer.Ordinal); var current = new List(); int bytes = header; foreach (string id in ids) { InventoryUser u = job.Users[id]; var row = new Dictionary { { "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(); 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 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 { { "permissions", inv.Permissions.Count }, { "groups", inv.Groups.Count }, { "users", inv.UserCount }, { "buildMs", inv.BuildMs } }; } if (reqId != null) reply["reqId"] = reqId; EmitFrame(reply); } /// /// The name out of a store listing entry — 76561198…(Wanderer) or /// 76561198… (Wanderer) — or null. Between the FIRST bracket and the LAST, so a /// name with brackets of its own survives. /// 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; } /// /// Main thread. Compiles a desired set into operations and starts draining it. /// /// /// 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. /// /// private void HandlePermSync(Dictionary 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(); foreach (Dictionary 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 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 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 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(); foreach (Dictionary 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 }); } } // ── The reward messages (protocol 14, D333) ──────────────────────── // // "You earned kit vip", said once to each player, now or when they next wake up. The // site's WHOLE untold set, like the credits: a notice it stops sending is one it has // been told about, or one whose reward was withdrawn before anybody heard of it. if (cmd.ContainsKey("notices")) { job.Notices = new List(); foreach (Dictionary row in Rows(cmd, "notices")) { string key = StringField(row, "key"); string steamId = StringField(row, "steamId"); string message = Flatten(StringField(row, "message") ?? ""); ulong id; if (string.IsNullOrEmpty(key) || key.Length > NoticeKeyMax || !ulong.TryParse(steamId, out id) || message.Length == 0) continue; if (job.Notices.Count >= NoticesMax) break; job.Notices.Add(new RewardNotice { Key = key, SteamId = steamId, Message = message.Length > MaxChat() ? message.Substring(0, MaxChat()) : message, Delivery = StringField(row, "delivery") == DeliveryPopup ? DeliveryPopup : DeliveryChat }); } } 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(); } /// /// Main thread. Drains up to operations and, on the last /// one, sends the report. /// 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)}"); } } if (job.Notices != null) { try { ApplyNotices(job); } catch (Exception ex) { // The grants have landed and must still be reported; an untold notice is told // by the next sync, or when its player wakes. Puts($"could not tell reward notices: {Flatten(ex.Message)}"); } } SendPermReport(job); } private void StopPermTimer() { if (_permTimer == null) return; _permTimer.Destroy(); _permTimer = null; } /// Main thread. One store question, and at most one store write. 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; } } /// /// 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. /// 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 { { "kind", "group-permission" }, { "subject", Lower(group) }, { "object", Lower(perm) } }); } /// /// Whether this server has a plugin that registered the name, recording the answer once /// per sync when it has not. /// /// /// 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. /// /// 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; } /// /// Whether a player holds a permission through a grant of their own, as opposed to /// through a group. /// /// /// UserHasPermission 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. /// GetPermissionUsers reads the user's own set, which is the question. /// /// 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; } /// /// The id out of a store listing entry. /// /// /// Both GetPermissionUsers and GetUsersInGroup answer with the id and the /// player's last known name stuck together — 76561198…(Wanderer) — 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. /// /// 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 { { "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 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 14 (D333). Every notice of this sync's set that its player has been told — // at this sync or before it — so the site marks it told and stops sending it. Present // only when the sync carried `notices`, like the credit counts. if (job.Notices != null) report["noticesTold"] = job.NoticesTold; // 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 { { "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); } /// A refusal of the whole sync, with a reason the website turns into a sentence. private void EmitPermError(string reqId, string reason) { Dictionary 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. /// The registered permission list as the last load or unload left it. Main thread. private HashSet _registeredPerms; /// /// Main thread. The list as it is now; the first call is the baseline. From /// OnServerInitialized, which is also the moment _worldReady turns true. /// private void SeedRegisteredPerms() { string[] now = permission.GetPermissions(); _registeredPerms = new HashSet(now ?? new string[0], StringComparer.OrdinalIgnoreCase); } /// /// 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 OnPluginUnloaded" 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. /// private void EmitPluginChange(string name, string version, bool loaded) { if (_registeredPerms == null) return; string[] now = permission.GetPermissions() ?? new string[0]; HashSet current = new HashSet(now, StringComparer.OrdinalIgnoreCase); List changed = new List(); 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 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"; } /// RunicNPC's presence and API are in hello, so its load and unload say it again (stage 4). private bool IsRunicNpc(Plugin plugin) { return plugin != null && plugin != this && plugin.Name == "RunicNPC"; } /// Main thread. Hello again, once the world is up and the link is. private void ResendHello() { if (_worldReady && _connected) SendHello(); } /// Main thread. Queues for somebody else's plugin, once the world is up. 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")); // TruePVE has read its table by now (it does in Init), so what an erase left while it // was away can go (§14.3.2). if (IsTruePve(plugin)) NextTick(() => FlushTruePvePending("TruePVE loaded")); // 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). // Economics too: hello carries its starting balance, which the richest column needs; // and Backpacks, whose sizes are what a Bigger backpack step offers; and TruePVE, which a // zone step's PvP rules are refused without (§14.3.2); and Dynamic PVP (§14.3.3). // Guarded Crate and NpcSpawn too: a placement is refused without either (D364). if (IsZonePlugin(plugin) || IsRunicNpc(plugin) || IsEconomics(plugin) || IsBackpacks(plugin) || IsTruePve(plugin) || IsDynamicPvp(plugin) || IsDangerousTreasures(plugin) || IsGuardedCrate(plugin)) NextTick(ResendHello); // Dynamic PVP raises its creates as it opens its zones, which at a load are its // monuments'; seeding once it has finished starting catches any it opened before us. if (IsDynamicPvp(plugin)) NextTick(() => SeedPvpZones("Dynamic PVP loaded")); // Its spawn grid is built from now, so a start that finds no spot is "not ready" for a // while (D365); and any chest a reload kept is found again. if (IsDangerousTreasures(plugin)) { NoteDtSeen(true); NextTick(() => SeedTreasures("Dangerous Treasures loaded")); } // Economics loaded after players connected: what they hold now is not otherwise known // until it moves (D336). if (IsEconomics(plugin)) NextTick(NoteOnlineBalances); // 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) || IsEconomics(plugin) || IsBackpacks(plugin) || IsTruePve(plugin) || IsDynamicPvp(plugin) || IsDangerousTreasures(plugin) || IsGuardedCrate(plugin)) NextTick(ResendHello); // Guarded Crate's Unload ended every event WITHOUT a hook. if (plugin != null && plugin != this && plugin.Name == "GuardedCrate") _guardedCrates.Clear(); // Its Unload deleted every zone, each with its hook; whatever is left was missed. if (IsDynamicPvp(plugin)) _pvpZones.Clear(); // Its Unload ended every chest, each with its hook. if (IsDangerousTreasures(plugin)) { _treasures.Clear(); NoteDtSeen(false); } 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; } /// /// Main thread. Says that the store changed under us, and by how much it is worth /// caring. /// /// /// 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. /// /// 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 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. /// How deep the walk goes before it stops trying to describe a tree. private const int ConfigMaxDepth = 6; /// How many files the walk will report. private const int ConfigMaxFiles = 500; /// /// The largest file this plugin will read or write. /// /// /// A file past it is reported and marked un-editable with the reason, 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. /// /// private const int ConfigMaxFileBytes = 256 * 1024; /// Files one write may touch. A plugin can own several; ten of them is not a tree. private const int ConfigMaxWriteFiles = 10; /// /// The longest a reload may take before this plugin stops waiting for it (D178). /// /// /// A ceiling, not the test. 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 evidence: the framework's log saying the /// plugin did not compile or initialise (). This number /// only bounds a reload that says nothing at all. /// /// /// /// Each phase gets it once — the edit's reload, and the restore's if there is one — and /// the pending reply carries it as ceilingMs, so the website knows when a write it /// never heard back about is lost rather than slow. /// /// private const float ConfigReloadCeilingSeconds = 30f; /// How often a pending reload reads the log for its failure. private const float ConfigLogPollSeconds = 1f; /// The most one poll reads from the log. A server spamming it cannot make a tick expensive. private const int ConfigLogPollMaxBytes = 64 * 1024; /// How much of the newest log file is read back when a reload fails. private const int ConfigLogTailBytes = 8 * 1024; /// /// What a failed load says in the log, beside the plugin's name. /// /// /// 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 /// LoadConfig 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 Loaded. /// /// 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", }; /// /// What the framework says when it gave up on a compile, beside the plugin's name. /// /// /// 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. /// /// private static readonly string[] ConfigStallWords = { "timed out waiting for plugin to be compiled", }; /// Which reload a job is waiting on: the edit's, or the restore's after a rollback. private enum ConfigPhase { Edit, Restore } /// One file, resolved and checked, on its way to disk. private sealed class StagedConfigWrite { public string RelPath; public string FullPath; public string Text; /// What was there before, held for the rollback that may never be needed. public string Original; public bool Written; } /// /// A write that has landed and is waiting to hear whether the plugin came back. /// /// /// 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. /// /// private sealed class ConfigWriteJob { /// /// Ours, not the sidecar's reqId. The reply and the later /// config.outcome 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. /// public string WriteId; public string Reload; public List Staged; /// The one-second poll that reads the log and enforces the ceiling. public Timer Watch; public ConfigPhase Phase; /// When the current phase's reload was asked for. Each phase gets the whole ceiling. public DateTime PhaseStarted; /// When the write landed, for the outcome's elapsedMs. public DateTime Started; /// Whether the target was seen leaving in this phase. It separates "never started" from "did not come back". public bool Unloaded; /// Whether this phase's reload was already asked for again after the framework gave up compiling. public bool Retried; /// When OnPluginLoaded named the target in this phase, or null. The next log read settles it. public DateTime? LoadedAt; /// The log file being read, and how far into it this job has read. public string LogPath; public long LogOffset; /// A line cut off at the end of the last read, kept until the rest arrives. public string LogPartial = ""; /// Why the edit was rolled back, carried into the outcome once the restore settles. public string RollbackReason; /// The log lines that showed the failure, or the tail read when the ceiling passed. public string RollbackLog; } private ConfigWriteJob _configJob; private string _lastConfigSummary = "none yet"; /// Whether this is a Carbon host. Resolved once, by looking, never by compiling twice (R19). 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(); bool truncated = false; try { WalkConfig(root, root, 0, files, ref truncated); } catch (Exception ex) { EmitConfigError(reqId, "unreadable", ex.Message); return; } Dictionary 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 { { "depth", ConfigMaxDepth }, { "files", ConfigMaxFiles }, { "fileBytes", ConfigMaxFileBytes }, { "writeFiles", ConfigMaxWriteFiles } }; if (reqId != null) reply["reqId"] = reqId; EmitFrame(reply); } /// /// 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 config.read, on the one /// file somebody actually opened. /// private void WalkConfig(string root, string dir, int depth, List 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 { { "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 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 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); } /// /// Main thread. Replaces a set of files and reloads what owns them. /// /// /// Everything is resolved, version-checked and parsed before the first byte is /// written — the same posture as perm.sync, 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. /// /// private void HandleConfigWrite(Dictionary cmd, string reqId) { if (_configJob != null) { EmitConfigError(reqId, "busy", "another configuration write is still waiting for its reload"); return; } var rows = new List>(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(); var seen = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (Dictionary 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 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); } /// /// Main thread. OnPluginLoaded named the target. It is noted, not settled: the /// next log read settles it (W5). /// /// /// "Loaded" is not "loaded well" on Carbon. A config its LoadConfig 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. /// /// private void ConfigReloadArrived() { ConfigWriteJob job = _configJob; if (job == null || job.LoadedAt.HasValue) return; job.LoadedAt = DateTime.UtcNow; } /// /// 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. /// 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); } /// /// Main thread, once a second while a reload is pending. Reads what the framework has /// logged since the last tick, and enforces the ceiling. /// /// /// A failure is a line, not a silence (D178). 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. /// /// /// /// What the ceiling does depends on what is loaded, not on the clock (F10). /// 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. /// /// 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"); } /// /// 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. /// 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)})"); } /// /// Main thread. Settles a reload that said nothing conclusive, by what is loaded — not /// by the clock (F10). finishes the sentence: "within 30s", or /// why it stopped sooner. /// 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()); } } /// /// Main thread. The edit took the plugin down: put every file back and reload again. /// /// /// 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. /// /// 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); } } /// Main thread. The job is over: stop watching and say how it ended. 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; } /// Main thread. Points the job at the end of the newest log file. 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; } } /// /// Main thread. The first line logged since the last read that says the target failed to /// load, or null. /// /// /// 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. /// /// 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; } /// Whether a log line says failed to compile or to initialise. private static bool IsLoadFailureOf(string line, string plugin) { return SaysAbout(line, plugin, ConfigFailureWords); } /// Whether a log line says the framework gave up compiling (W1). 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()); } /// /// Whether appears as a whole name — so a failure of /// KitsExtended is not read as one of Kits. A trailing .cs or a /// version still counts: "Kits.cs", "'Kits v4.4.9'". /// 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; } } /// A log line short enough for a reason sentence. private static string ShortLine(string line) { string trimmed = (line ?? "").Trim(); return trimmed.Length <= 240 ? trimmed : trimmed.Substring(0, 240) + "…"; } /// /// Main thread. Each file as it is on disk now. /// /// /// 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. /// /// private List ConfigFilesNow(List staged, bool rolledBack) { var files = new List(); foreach (StagedConfigWrite file in staged) { var row = new Dictionary { { "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; } /// /// Main thread. The reply to config.write. /// /// /// With a job it says "written, reloading": pending, /// the writeId the outcome will carry, and ceilingMs. Without one it is the /// whole answer — nothing was asked to reload, or the target was not loaded. /// /// private void SendConfigReport( string reqId, List staged, bool reloaded, bool rolledBack, string reason, string log, ConfigWriteJob pending = null) { Dictionary 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); } /// /// Main thread. How a pending reload ended, as an event (protocol 13, F9). /// /// /// 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. /// /// private void EmitConfigOutcome( ConfigWriteJob job, bool reloaded, bool rolledBack, bool? restored, string reason, string log) { Dictionary 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 frame = Frame("config.error", "reply"); frame["reason"] = reason; if (!string.IsNullOrEmpty(detail)) frame["detail"] = detail; if (reqId != null) frame["reqId"] = reqId; EmitFrame(frame); } /// Main thread. Puts every file that landed back the way it was. private void RestoreConfig(List 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}"); } } } /// /// Main thread. If a write touched this plugin's own config, take the one setting that /// is safe to change live — EventsEnabled — from what was written. Returns /// whether it did. /// private bool ApplyOwnSettings(List 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; } /// /// Keeps a copy of a file about to be overwritten, and the nine before it. /// /// /// Under DataDirectory, 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. /// /// 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) { } } } /// /// The framework's newest log file, or null. Oxide's own log is the newest *.txt /// directly in LogDirectory; plugins that log to files write into subdirectories /// of it, which this does not look in. /// /// /// Carbon's is Carbon.Core.log in carbon/logs, beside four other /// *.log files and no *.txt 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. /// /// 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; } } /// /// The tail of the newest log file, read only when a reload failed. /// /// /// 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. /// /// 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; } } /// /// Reloads a plugin through the framework rather than by composing a console command. /// /// /// The console is the one place the two frameworks genuinely differ — Carbon's commands /// are 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 #if CARBON, because one /// byte-identical source file on both frameworks is the property R19 proved and is /// worth keeping. /// /// 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); } /// Runs a reload command on the server console, as an operator at the panel would. 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; } } /// /// Turns a path from a web form into a path on this host, or refuses it with a reason. /// /// /// Phase 7 addressed things by name; this phase addresses them by path, 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. /// /// 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('\\', '/'); } /// /// Which plugin a file probably belongs to: the folder for a nested file, the file's own /// name otherwise. /// /// /// A guess, and it is labelled one all the way to the form. A folder name is /// convention, not contract. Infer it silently and the failure is the nastiest available /// here: we reload the wrong plugin, watch OnPluginLoaded fire for it, and /// report success while the plugin that was actually edited never re-read a thing. /// /// private static string PluginGuess(string rel) { int slash = rel.IndexOf('/'); return slash > 0 ? rel.Substring(0, slash) : Path.GetFileNameWithoutExtension(rel); } private List LoadedPlugins() { var rows = new List(); 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 { { "name", loaded.Name }, { "title", loaded.Title }, { "version", loaded.Version.ToString() }, { "author", loaded.Author } }); } return rows; } /// /// A file's identity for optimistic concurrency: its length and an FNV-1a hash of its /// text. /// /// /// 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 /// System.Security.Cryptography is a namespace whose availability under a plugin /// compiler is one more thing that would have to be true on two frameworks. /// /// 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}"; } /// UTF-8 with no byte-order mark, which is what both frameworks write. 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. /// /// 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. /// private const long LeaseMaxHoldMs = 7L * 24L * 60L * 60L * 1000L; /// How often held deadlines are checked. A lease ends within a second of its time. private const float LeaseCheckSeconds = 1f; /// The wire key of the one lease that is not a convar. private const string GroupPermissionKey = "group.permission"; /// /// 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. /// 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; } } /// /// Every convar a lease may name (D74: the game's own convars, nothing Carbon-only). /// /// /// 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). /// /// 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) }; /// /// One value this plugin is holding. Persisted as it is, so every field is public and /// plain. /// private sealed class LeaseHold { public string Key; /// Null for a convar; group/permission for a group permission. public string Target; public string Baseline; public string Applied; /// When the hold ends, on this host's clock: taken time plus holdMs. public long DeadlineMs; /// The website's absolute deadline, for display. Never compared against. public long UntilMs; public string RunId; public string BootId; public long TakenMs; /// /// 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). /// public bool? SiteWants; } /// Holds by . Main thread only. private Dictionary _leases = new Dictionary(); /// Convars by lowered full name, resolved once. Null until first needed. private Dictionary _convars; private Timer _leaseTimer; /// The last thing a lease did, for rg.leases. Diagnostics only. 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; } /// /// Main thread. The game's convar, or null when this build has no such convar or it /// is one the plugin refuses to hold. /// /// /// A Saved convar is refused (§27.1). The game writes those to /// serverauto.cfg, 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 Saved today; this is /// what keeps a Facepunch change to that from becoming our bug silently. /// /// private ConsoleSystem.Command LeaseConvar(string key, out string refusal) { refusal = null; if (_convars == null) { _convars = new Dictionary(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; } /// /// Whether two values are the same value, parsed. A convar reads back through /// float.ToString(), so the tolerance is a float's, not a double's. /// 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))); } /// /// 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. /// 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); } /// /// Main thread. The value a key holds right now, as text — or null with a reason. /// is set when the target itself no longer exists, which a /// release treats as nothing left to give back rather than as a failure. /// 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; } /// /// 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. /// 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; } /// /// Main thread. Protocol 8's catalogue: every key this server lends and what it holds /// now, and every hold in force. /// /// /// Narrowed by key (and target) 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. /// /// private void HandleLeaseList(Dictionary cmd, string reqId) { string onlyKey = Lower(StringField(cmd, "key")); string onlyTarget = StringField(cmd, "target"); var rows = new List(); 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(); foreach (LeaseHold h in _leases.Values) holds.Add(HoldBody(h)); Dictionary 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 LeaseRow(string key, string family, double min, double max, string target) { var row = new Dictionary { { "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 HoldBody(LeaseHold h) { var body = new Dictionary { { "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; } /// /// Main thread. Take a value for a while. /// /// /// A key this plugin already holds keeps its ORIGINAL baseline. 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. /// /// private void HandleLeaseApply(Dictionary 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 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); } /// /// Main thread. Give a value back — compare-and-set. /// /// /// The comparison is against what the LEASE applied, from this plugin's own record /// when it has one, else from the website's expected. 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. /// /// private void HandleLeaseRelease(Dictionary 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 outcome = Restore(key, target, expected, baseline, hold, "release"); if (reqId != null) outcome["reqId"] = reqId; EmitFrame(outcome); } /// /// 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. /// private Dictionary 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 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 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 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 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 RestoredFrame(string key, string target, string value) { Dictionary ok = Frame("lease.ok", "reply"); ok["key"] = key; if (!string.IsNullOrEmpty(target)) ok["target"] = target; ok["restored"] = value; return ok; } /// /// 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. /// private void CheckLeaseDeadlines() { if (_leases.Count == 0) { StopLeaseTimer(); return; } long now = NowMs(); List due = null; foreach (LeaseHold h in _leases.Values) { if (h.DeadlineMs <= now) { if (due == null) due = new List(); due.Add(h); } } if (due == null) return; foreach (LeaseHold h in due) Expire(h); } private void Expire(LeaseHold hold) { Dictionary 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 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 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"); } /// /// 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. /// private void SaveLeases() { try { string path = LeasesPath(); Directory.CreateDirectory(Path.GetDirectoryName(path)); var rows = new List(_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)}"); } } /// /// Main thread, once per load, with the world available. Re-arms what is still owed. /// /// /// 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. /// /// private void LoadLeases() { _leases = new Dictionary(); List rows = null; try { string path = LeasesPath(); if (File.Exists(path)) rows = JsonConvert.DeserializeObject>(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(); } /// /// 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 leased so the site never takes it for a hand edit. /// private LeaseHold LeasedGroupPermission(string group, string perm) { LeaseHold hold; return _leases.TryGetValue(LeaseSlot(GroupPermissionKey, Lower(group) + "/" + Lower(perm)), out hold) ? hold : null; } /// What every hold is doing, from the server console. [ConsoleCommand("rg.leases")] private void CmdLeases(ConsoleSystem.Arg arg) { var lines = new List { $"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). /// The ceiling on crates per step. EventsMaxCrates may lower it, never raise it. private const int WorldHardMaxCrates = 25; /// The ceiling on NPCs per step (D95). private const int WorldHardMaxNpcs = 20; /// How far a group may be scattered around its point, in metres. private const float WorldMaxSpread = 50f; /// How far from a monument's centre a step may put its point, in metres. private const float WorldMaxOffset = 150f; private const float ZoneMinRadius = 5f; private const float ZoneMaxRadius = 150f; /// /// 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. /// private const long ZoneMaxHoldMs = 7L * 24L * 60L * 60L * 1000L; private const string WorldKindZone = "zone"; private const string WorldKindCrate = "crate"; private const string WorldKindNpc = "npc"; /// One thing this plugin will place, by the key the website names it with. 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; } } /// /// 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). /// 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") }; /// One thing an event made. Persisted as it is, so every field is public and plain. private sealed class WorldEntry { /// The net id for an entity, as text; the zone id for a zone. public string Id; public string Kind; /// The allowlist key, for a crate or an NPC. public string Prefab; public string RunId; /// The website's idempotency key for the step that made it. public string Key; public float X, Y, Z; /// A zone's radius and name, for re-creating it. public float Radius; public string Name; /// /// 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. /// public List Flags; public Dictionary Settings; /// What a player is told on the way in and out, and how (D193). Said by this plugin, not ZoneManager. public string EnterMessage; public string LeaveMessage; public string Delivery; public string Format; /// A ZoneDomes dome over the zone (D194): its type (0–4) and how many spheres are stacked. public bool Dome; public int DomeType; public int DomeStack; /// /// The TruePVE mapping this zone was given (PLAN_REDESIGNS §14.3.2): exclude or a /// ruleset's name, or null for none. Kept so the path that erases the zone removes the /// mapping too (D362), and touches nothing else in TruePVE's table. /// public string TruePve; /// When a zone is erased without being asked, on this host's clock (D96). Zero for an entity. public long DeadlineMs; public string BootId; public string WipeId; public long TakenMs; } /// Everything events have made on this map, by id. Main thread only. private Dictionary _world = new Dictionary(); /// A counter for zone ids, so two zones one run opens in one tick never collide. private int _zoneSerial; /// /// Whether the world is loaded for THIS load of the plugin — set at /// OnServerInitialized, which fires at the end of a real start and at once on a /// hotload. Until then every world command is refused not-ready. /// /// /// Found by the phase 13a walk, and it is the worst kind of wrong: the link starts in /// Init, BEFORE the save loads, so a website that saw the new boot id asked /// world.owned into an empty world — and the plugin, finding no entity behind /// any net id, pruned three live crates from its own registry. They stayed in the /// world owned by nothing, and a revert would have refused them. An empty world at /// boot is not an answer; it is the question arriving too early. /// /// 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"); } /// Main thread. patched, unsupported, no-zonemanager or missing. private string ZoneHelperState() { Plugin helper = ZoneHelperPlugin(); return helper == null ? "missing" : helper.Call("State") as string ?? "unsupported"; } /// /// 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. /// private bool ZoneMembershipTrusted() { return ZoneHelperState() == "patched"; } /// /// 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). /// 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. /// The fields `ZoneFieldListRaw` answers that are not flags, lower-cased. private static readonly HashSet ZoneNonFlagFields = new HashSet(StringComparer.OrdinalIgnoreCase) { "name", "id", "radius", "rotation", "size", "location", "enter_message", "leave_message", "radiation", "comfort", "temperature", "safezone", "permission", "ejectspawns", "parentid", "enabled", "enable" }; /// /// 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). /// 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; /// ZoneDomes' own sphere types: Standard, Red, Blue, Green, Purple. private const int DomeTypeMax = 4; /// The most spheres one dome stacks. Each is an entity; ZoneDomes itself has no limit. private const int DomeStackMax = 10; /// A zone this plugin just put back says nothing to whoever is inside it for this long. private const long ZoneQuietMs = 5000L; /// Zone id → the moment its enter and leave messages start again. Main thread. private readonly Dictionary _zoneQuietUntil = new Dictionary(); /// /// 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. /// private static List ZoneFlagNames(Plugin zm) { var fields = zm == null ? null : zm.Call("ZoneFieldListRaw") as List; if (fields == null) return null; var flags = new List(); foreach (string field in fields) { if (!string.IsNullOrEmpty(field) && !ZoneNonFlagFields.Contains(field)) flags.Add(field); } return flags; } /// /// 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. /// private static bool ReadZoneOptions(Plugin zm, Dictionary cmd, out List flags, out Dictionary settings, out string refusal) { flags = new List(); settings = new Dictionary(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 known = ZoneFlagNames(zm); if (known == null) { refusal = "this server's ZoneManager did not say which flags it has"; return false; } var byName = new Dictionary(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"); } /// Main thread. patched, unsupported, no-zonedomes or missing. private string DomesHelperState() { Plugin helper = DomesHelperPlugin(); return helper == null ? "missing" : helper.Call("State") as string ?? "unsupported"; } /// Main thread. Whether a dome may be added now: ZoneDomes loaded, patched, and past its own start. private bool DomesReady() { Plugin helper = DomesHelperPlugin(); return ZoneDomesPlugin() != null && helper != null && helper.Call("State") as string == "patched" && helper.Call("Ready") is bool ready && ready; } /// Main thread. Why a dome cannot be added now, or null when it can. 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"; } /// Main thread. Adds a zone's dome. True when ZoneDomes made it, or already had it. 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; } /// /// 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. /// 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); } /// /// 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. /// 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}"); } } /// Raised by the domes helper when ZoneDomes has finished its own start. private void OnRgDomesReady() { MarkHook("OnRgDomesReady"); NextTick(() => RestoreDomes("ZoneDomes started")); } // ---- TruePVE in the zone step (PLAN_REDESIGNS §14.3.2, D362, D368) ---- // // A zone may ask TruePVE to leave it alone (`exclude`) or to use one of its rulesets. The // §14.2 test found the three facts this is built on: // // * `AddOrUpdateMapping(zoneId, name)` answers false for a ruleset TruePVE does not have, // and nothing lists the names, so the call is the check (D368). // * TruePVE keeps its mappings in its own data file, across its reloads and a restart, and // prunes a mapping whose zone is gone only when the zone id is a number. Ours are `rg-…`, // so the path that erases one of our zones removes its mapping (D362). // * It reads that file in `Init`, so by `OnPluginLoaded` the table is there to change. // // An erase while TruePVE is not loaded keeps the id in `truepve.json` and removes it when // TruePVE comes back. Only ids this plugin mapped are ever removed. private const string TruePveExclude = "exclude"; /// The longest ruleset name a step may send. private const int TruePveRulesetMax = 64; /// Zone ids whose mapping is still to remove, because TruePVE was not loaded. Main thread only. private HashSet _truePvePending = new HashSet(StringComparer.Ordinal); private Plugin TruePvePlugin() { return plugins.Find("TruePVE"); } private bool IsTruePve(Plugin plugin) { return plugin != null && plugin != this && plugin.Name == "TruePVE"; } /// /// A zone's truePve: {"exclude": true} or {"ruleset": "name"}, exactly one. /// Null with true when the command has none; false with a reason when it is malformed. /// private static bool ReadTruePve(Dictionary cmd, out string mapping, out string refusal) { mapping = null; refusal = null; object raw; if (!cmd.TryGetValue("truePve", out raw) || raw == null) return true; var o = raw as Newtonsoft.Json.Linq.JObject; Newtonsoft.Json.Linq.JToken exclude = o != null ? o["exclude"] : null; Newtonsoft.Json.Linq.JToken ruleset = o != null ? o["ruleset"] : null; bool hasExclude = exclude != null && exclude.Type != Newtonsoft.Json.Linq.JTokenType.Null; bool hasRuleset = ruleset != null && ruleset.Type != Newtonsoft.Json.Linq.JTokenType.Null; if (o == null || hasExclude == hasRuleset) { refusal = "a zone's truePve is {\"exclude\": true} or {\"ruleset\": \"name\"}, exactly one"; return false; } if (hasExclude) { if (exclude.Type != Newtonsoft.Json.Linq.JTokenType.Boolean || !(bool)exclude) { refusal = "a zone's truePve exclude is true, or left out"; return false; } mapping = TruePveExclude; return true; } string name = ruleset.Type == Newtonsoft.Json.Linq.JTokenType.String ? ((string)ruleset).Trim() : null; if (string.IsNullOrEmpty(name) || name.Length > TruePveRulesetMax) { refusal = $"a TruePVE ruleset is named by 1 to {TruePveRulesetMax} characters"; return false; } mapping = name; return true; } /// /// Main thread. Removes the TruePVE mapping of a zone this plugin is erasing, if it gave the /// zone one. Called before the zone goes, on every path that erases one (D362). /// private void UnmapTruePve(WorldEntry e) { if (e == null || e.Kind != WorldKindZone || string.IsNullOrEmpty(e.TruePve)) return; Plugin tp = TruePvePlugin(); if (tp != null) { // False is "not in the table", which is what this was for. tp.Call("RemoveMapping", e.Id); return; } if (_truePvePending.Add(e.Id)) { SaveTruePvePending(); Puts($"world: TruePVE is not loaded; {e.Id}'s mapping is removed when it is"); } } /// Main thread. Removes every mapping an erase left while TruePVE was away. private void FlushTruePvePending(string why) { Plugin tp = TruePvePlugin(); if (tp == null || _truePvePending.Count == 0) return; int removed = 0; foreach (string id in _truePvePending) { object gone = tp.Call("RemoveMapping", id); if (gone is bool && (bool)gone) removed++; } int count = _truePvePending.Count; _truePvePending.Clear(); SaveTruePvePending(); _lastWorldSummary = $"TruePVE after {why}: {removed} of {count} left-over mapping(s) removed"; Puts($"world: {_lastWorldSummary}"); } private string TruePvePendingPath() { return Path.Combine(Interface.Oxide.DataDirectory, Name, "truepve.json"); } private void SaveTruePvePending() { try { string path = TruePvePendingPath(); if (_truePvePending.Count == 0) { if (File.Exists(path)) File.Delete(path); return; } Directory.CreateDirectory(Path.GetDirectoryName(path)); File.WriteAllText(path, JsonConvert.SerializeObject(new List(_truePvePending), Formatting.Indented), Utf8NoBom); } catch (Exception ex) { Puts($"could not save the TruePVE leftovers: {Flatten(ex.Message)}"); } } private void LoadTruePvePending() { _truePvePending = new HashSet(StringComparer.Ordinal); try { string path = TruePvePendingPath(); if (!File.Exists(path)) return; List ids = JsonConvert.DeserializeObject>(File.ReadAllText(path)); foreach (string id in ids ?? new List()) { if (!string.IsNullOrEmpty(id)) _truePvePending.Add(id); } } catch (Exception ex) { Puts($"could not read the TruePVE leftovers: {Flatten(ex.Message)}"); } } // ---- Dynamic PVP's zones (PLAN_REDESIGNS §14.3.3) ---- // // HunterZ's Dynamic PVP opens its own PvP zones around a downed helicopter, a Bradley, a // locked crate, an airdrop, cargo and the rest. They are its own: the bridge never makes, // maps or erases one, and only says that they are there, for the feed and the map. The // §14.2 test and the plugin's own source (5.2.0) shape it: // // * `OnCreatedDynamicPVP(zoneId, eventName, position, duration)` and // `OnDeletedDynamicPVP(zoneId, eventName)` are raised for every zone, a reload's and an // unload's included. Its ids are always numeric, so never one of our `rg-…`. // * Neither hook carries the zone's size, and a zone is a sphere (`Radius`) or a cube // (`Size`, cargo's), so both are read from ZoneManager. Cargo's zone is parented to the // ship, so where a zone is comes from its transform, never from where it was made. // * `AllDynamicPVPZones()` and `GetEventName(zoneId)` say which zones are live, so the // set is seeded from Dynamic PVP itself at load: a hotload of the bridge hears no creates. // * `OnPlayerEnterPVP` / `ExitPVP` are not subscribed: they would say who stands where. private const string PvpZoneCreatedKind = "pvp.zone.created"; private const string PvpZoneDeletedKind = "pvp.zone.deleted"; /// One of Dynamic PVP's live zones: its event's name, and how long it was given. private sealed class PvpZone { public string Event; /// Seconds, or -1 for "as long as its event" (cargo, a monument's). public int DurationSec; } /// Dynamic PVP's live zones, by its zone id. Main thread only. private readonly Dictionary _pvpZones = new Dictionary(StringComparer.Ordinal); private Plugin DynamicPvpPlugin() { return plugins.Find("DynamicPVP"); } private bool IsDynamicPvp(Plugin plugin) { return plugin != null && plugin != this && plugin.Name == "DynamicPVP"; } private void OnCreatedDynamicPVP(string zoneId, string eventName, UnityEngine.Vector3 position, float duration) { MarkHook("OnCreatedDynamicPVP"); if (string.IsNullOrEmpty(zoneId)) return; var zone = new PvpZone { Event = eventName ?? "", DurationSec = duration > 0f ? (int)Math.Round(duration) : -1 }; _pvpZones[zoneId] = zone; // A boot or a reload of Dynamic PVP opens every monument's zone at once, and that is // not news; the map has them all the same. if (!WorldEventsLive()) return; Dictionary frame = Frame(PvpZoneCreatedKind, "event"); frame["zoneId"] = zoneId; frame["event"] = zone.Event; UnityEngine.Vector3 at; float radius; string shape; if (!PvpZoneShape(zoneId, out at, out radius, out shape)) at = position; PutPosition(frame, at); frame["radius"] = Math.Round(radius, 1); frame["shape"] = shape; frame["durationSec"] = zone.DurationSec; EmitFrame(frame); } private void OnDeletedDynamicPVP(string zoneId, string eventName) { MarkHook("OnDeletedDynamicPVP"); if (string.IsNullOrEmpty(zoneId) || !_pvpZones.Remove(zoneId)) return; // A shutdown deletes every zone, and that is not news. Dynamic PVP's own unload does too, // and is said: the zones really have closed, and the line is staff's. if (!WorldEventsLive()) return; Dictionary frame = Frame(PvpZoneDeletedKind, "event"); frame["zoneId"] = zoneId; frame["event"] = eventName ?? ""; EmitFrame(frame); } /// /// Main thread. Where one of Dynamic PVP's zones is now, and how big: a sphere's radius, /// or for a cube half its longer side across the ground, so the map can draw a ring. /// False when ZoneManager does not hold it. /// private bool PvpZoneShape(string zoneId, out UnityEngine.Vector3 at, out float radius, out string shape) { at = UnityEngine.Vector3.zero; radius = 0f; shape = "sphere"; Plugin zm = ZoneManagerPlugin(); var zone = zm != null ? zm.Call("GetZoneByID", zoneId) as UnityEngine.Component : null; if (zone == null || zone.transform == null) return false; at = zone.transform.position; object r = zm.Call("GetZoneRadius", zoneId); if (r is float && (float)r > 0f) { radius = (float)r; return true; } object s = zm.Call("GetZoneSize", zoneId); if (s is UnityEngine.Vector3) { var size = (UnityEngine.Vector3)s; radius = Math.Max(size.x, size.z) / 2f; shape = "cube"; } return true; } /// Main thread. The live zones from Dynamic PVP itself, replacing what the hooks kept. private void SeedPvpZones(string why) { _pvpZones.Clear(); Plugin dpvp = DynamicPvpPlugin(); if (dpvp == null) return; var ids = dpvp.Call("AllDynamicPVPZones") as string[]; if (ids == null) return; foreach (string id in ids) { if (string.IsNullOrEmpty(id)) continue; // How long it was given is not asked for; the map does not need it. _pvpZones[id] = new PvpZone { Event = dpvp.Call("GetEventName", id) as string ?? "", DurationSec = -1 }; } if (_pvpZones.Count > 0) Puts($"map: {_pvpZones.Count} Dynamic PVP zone(s) after {why}"); } /// Main thread. The map's pvp layer: every live zone ZoneManager still holds, where it is now. private List PvpZoneRows() { var rows = new List(); List gone = null; foreach (KeyValuePair z in _pvpZones) { UnityEngine.Vector3 at; float radius; string shape; if (!PvpZoneShape(z.Key, out at, out radius, out shape)) { // Erased without its hook (ZoneManager reloaded); Dynamic PVP's own state is // its business, and the map shows only what is there. if (gone == null) gone = new List(); gone.Add(z.Key); continue; } Dictionary row = At(at); row["zoneId"] = z.Key; row["event"] = z.Value.Event; row["radius"] = Math.Round(radius, 1); row["shape"] = shape; rows.Add(row); } if (gone != null) { foreach (string id in gone) _pvpZones.Remove(id); } return rows; } // ---- Dangerous Treasures (PLAN_REDESIGNS §14.3.4, D365) ---- // // nivex's Dangerous Treasures opens a guarded, burning treasure chest somewhere on the map, // with its own NPCs, fire and marker. The bridge says when one opens, is won and ends, for // the feed, the map and the tally (Treasure Hunter), and opens one for a run's step through // DT's own console command. DT's source (3.0.0) shapes it: // // * `OnDangerousEventStarted(Vector3)` is raised INSIDE the call that opens the chest (the // chest's radius setter runs its `Awaken`), so a start our `dtevent` caused is heard // before `ConsoleSystem.Run` returns. That is the key's join, and it is exact: nothing // else can open a chest between the two on the main thread. // * `OnDangerousEventWon(looter, invaders)` when the chest is emptied, then // `OnDangerousEventEnded(Vector3)` as it goes. The win does not say which chest, so it is // placed at the running chest nearest the looter. DT's unload ends every chest, each // with its hook. // * A start that opened nothing is told only by DT's reply, which DT's lang file words. So // "it started" is the hook, never the text; the reply is compared with DT's own "Manual // Event Failed" message, read from its lang, to know a start that found no spot. DT // builds its spawn grid for about ten minutes after it loads and fails every request // meanwhile (§14.2.1), so inside fifteen minutes of DT's load that is `dt-not-ready` // (D365), and after it `dt-no-spot`. // * DT's console command is a config value; this plugin runs its default, `dtevent`. An // operator who renamed it gets the refusal with whatever the console answered. private const string DtStartedKind = "dt.started"; private const string DtWonKind = "dt.won"; private const string DtEndedKind = "dt.ended"; /// DT's console command, as its config ships it. private const string DtCommand = "dtevent"; /// How long after DT loads a start that found no spot is "not ready" rather than "no spot" (D365). private const float DtWarmUpSeconds = 15f * 60f; /// How far from a running chest a winner may stand and still be placed at it. private const float DtWinReach = 100f; /// The most invaders a win names; the rest are counted in invaderCount. private const int DtInvadersMax = 32; /// Every DT chest is a large wooden box (`box.wooden.large`), for the seed at load. private const uint DtChestPrefabId = 2206646561u; /// How long a start's key is remembered: seven days, as a payment's is. private const long DtStartMemoryMs = 7L * 24L * 60L * 60L * 1000L; /// One running chest: where it is, and the step's key when a step opened it. private sealed class Treasure { public UnityEngine.Vector3 At; public string Key; } /// The running chests, oldest first. Main thread only. private readonly List _treasures = new List(); /// One start a step asked for, kept for its key as a world call is. Persisted as it is. private sealed class DtStart { public string Key; public int Level; public float X; public float Z; public long AtMs; } private Dictionary _dtStarts = new Dictionary(StringComparer.Ordinal); /// The key of the `dtevent` this plugin is running now, for the start hook. Null otherwise. private string _dtStarting; /// The chest the start hook heard while our `dtevent` ran. private Treasure _dtOpened; /// Time.realtimeSinceStartup when this plugin first saw DT loaded; negative while it is not. private float _dtSeenAt = -1f; private Plugin DangerousTreasuresPlugin() { return plugins.Find("DangerousTreasures"); } private bool IsDangerousTreasures(Plugin plugin) { return plugin != null && plugin != this && plugin.Name == "DangerousTreasures"; } /// /// Main thread. DT's load as this plugin saw it: DT's own, or this plugin's when DT was /// already there, which can only make the warm-up window start late, never early. /// private void NoteDtSeen(bool loaded) { _dtSeenAt = loaded ? UnityEngine.Time.realtimeSinceStartup : -1f; } private bool DtWarmingUp() { return _dtSeenAt >= 0f && UnityEngine.Time.realtimeSinceStartup - _dtSeenAt < DtWarmUpSeconds; } private void OnDangerousEventStarted(UnityEngine.Vector3 at) { MarkHook("OnDangerousEventStarted"); var treasure = new Treasure { At = at, Key = _dtStarting }; _treasures.Add(treasure); // One `dtevent ` opens one chest, so the key is spent on the first. if (_dtStarting != null) { _dtOpened = treasure; _dtStarting = null; } if (!WorldEventsLive()) return; Dictionary frame = Frame(DtStartedKind, "event"); PutPosition(frame, at); if (treasure.Key != null) frame["key"] = treasure.Key; EmitFrame(frame); } private void OnDangerousEventWon(BasePlayer looter, List invaders) { MarkHook("OnDangerousEventWon"); if (looter == null || looter.IsNpc) return; TallyFor(looter).TreasuresWon++; if (!WorldEventsLive()) return; Treasure chest = NearestTreasure(looter.transform.position, DtWinReach); Dictionary frame = Frame(DtWonKind, "event"); frame["steamId"] = SteamIdOf(looter); frame["name"] = looter.displayName; var named = new List(); int count = 0; if (invaders != null) { foreach (ulong id in invaders) { count++; if (named.Count < DtInvadersMax) named.Add(id.ToString(System.Globalization.CultureInfo.InvariantCulture)); } } frame["invaders"] = named; frame["invaderCount"] = count; PutPosition(frame, chest != null ? chest.At : looter.transform.position); EmitFrame(frame); } private void OnDangerousEventEnded(UnityEngine.Vector3 at) { MarkHook("OnDangerousEventEnded"); // The chest's own position, the one its start was raised with. Treasure chest = NearestTreasure(at, 1f); if (chest != null) _treasures.Remove(chest); if (!WorldEventsLive()) return; Dictionary frame = Frame(DtEndedKind, "event"); PutPosition(frame, at); EmitFrame(frame); } private Treasure NearestTreasure(UnityEngine.Vector3 at, float reach) { Treasure best = null; float bestDistance = reach; foreach (Treasure t in _treasures) { float d = Distance2D(t.At, at); if (d <= bestDistance) { bestDistance = d; best = t; } } return best; } /// /// Main thread. The running chests, found in the world, replacing what the hooks kept: a /// hotload hears no starts. DT has no API that lists them, so a chest is a large wooden /// box carrying DT's own TreasureChest component. /// private void SeedTreasures(string why) { _treasures.Clear(); if (DangerousTreasuresPlugin() == null) return; foreach (BaseNetworkable n in BaseNetworkable.serverEntities) { var box = n as StorageContainer; if (box == null || box.IsDestroyed || box.prefabID != DtChestPrefabId || box.GetComponent("TreasureChest") == null) continue; _treasures.Add(new Treasure { At = box.transform.position }); } if (_treasures.Count > 0) Puts($"map: {_treasures.Count} Dangerous Treasures chest(s) after {why}"); } /// The map's treasure rows, for its world layer. private void AddTreasureRows(List world) { foreach (Treasure t in _treasures) { Dictionary row = At(t.At); row["kind"] = "treasure"; world.Add(row); } } private string DtStartsPath() { return Path.Combine(Interface.Oxide.DataDirectory, Name, "dt-starts.json"); } private void SaveDtStarts() { WriteDataFile(DtStartsPath(), new List(_dtStarts.Values), "Dangerous Treasures starts"); } private void LoadDtStarts() { _dtStarts = new Dictionary(StringComparer.Ordinal); List starts = ReadDataFile(DtStartsPath(), "Dangerous Treasures starts"); if (starts != null) foreach (DtStart s in starts) if (s != null && !string.IsNullOrEmpty(s.Key)) _dtStarts[s.Key] = s; ForgetOldDtStarts(); } private void ForgetOldDtStarts() { long now = NowMs(); var stale = new List(); foreach (KeyValuePair kv in _dtStarts) if (now >= kv.Value.AtMs + DtStartMemoryMs) stale.Add(kv.Key); foreach (string key in stale) _dtStarts.Remove(key); if (stale.Count > 0) SaveDtStarts(); } /// /// Main thread. Open one Dangerous Treasures event for a run's step: { key, level }, /// 0 Easy, 1 Medium, 2 Hard; DT picks the spot. A key already answered is answered again /// with its first start and opens nothing, so a retry never opens a second chest. /// private void HandleDtStart(Dictionary cmd, string reqId) { if (!WorldWritesAllowed(reqId)) return; string key = StringField(cmd, "key"); if (string.IsNullOrEmpty(key) || key.Length > PaymentKeyMax) { EmitDtError(reqId, "malformed", "a start is made under a key of at most 128 characters"); return; } ForgetOldDtStarts(); DtStart seen; if (_dtStarts.TryGetValue(key, out seen)) { EmitDtOpened(reqId, seen, true); return; } // Not IntField, which reads anything unparseable as 0, and 0 is Easy. string rawLevel = StringField(cmd, "level"); int level; if (rawLevel == null || !int.TryParse(rawLevel, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out level) || level < 0 || level > 2) { EmitDtError(reqId, "bad-level", $"a Dangerous Treasures event is level 0 (Easy), 1 (Medium) or 2 (Hard), not '{rawLevel}'"); return; } Plugin dt = DangerousTreasuresPlugin(); if (dt == null) { EmitDtError(reqId, "dt-missing", "Dangerous Treasures is not loaded on this server"); return; } if (!_consolePlugins) { EmitDtError(reqId, "console-unavailable", "this framework does not run another plugin's console command from code, so Dangerous Treasures cannot be started here (D363, Rust-Plugins#34)"); return; } string reply = null; _dtOpened = null; _dtStarting = key; try { reply = ConsoleSystem.Run(ConsoleSystem.Option.Server.Quiet(), DtCommand, level); } catch (Exception ex) { reply = ex.Message; } finally { _dtStarting = null; } Treasure opened = _dtOpened; _dtOpened = null; if (opened == null) { RefuseDtStart(reqId, dt, reply); return; } var start = new DtStart { Key = key, Level = level, X = opened.At.x, Z = opened.At.z, AtMs = NowMs() }; _dtStarts[key] = start; SaveDtStarts(); _lastWorldSummary = $"opened a level {level} Dangerous Treasures event at {Grid(opened.At) ?? "?"} for key {key}"; Puts($"world: {_lastWorldSummary}"); EmitDtOpened(reqId, start, false); } /// Why our dtevent opened nothing: no spot (yet), or DT's own refusal in its words. private void RefuseDtStart(string reqId, Plugin dt, string reply) { string said = string.IsNullOrEmpty(reply) ? null : Flatten(reply).Trim(); string failed = null; try { failed = lang.GetMessage("Manual Event Failed", dt); } catch (Exception) { // A DT without the message: only an empty reply then reads as "no spot". } bool noSpot = said == null || (!string.IsNullOrEmpty(failed) && string.Equals(said, Flatten(failed).Trim(), StringComparison.Ordinal)); if (!noSpot) EmitDtError(reqId, "refused", $"Dangerous Treasures did not start: {said}"); else if (DtWarmingUp()) EmitDtError(reqId, "dt-not-ready", "Dangerous Treasures is not ready yet: it spends about ten minutes after it loads finding where events may go"); else EmitDtError(reqId, "dt-no-spot", "Dangerous Treasures found no valid spot for an event"); } private void EmitDtOpened(string reqId, DtStart start, bool repeat) { Dictionary frame = Frame("dt.opened", "reply"); frame["key"] = start.Key; frame["level"] = start.Level; frame["repeat"] = repeat; PutPosition(frame, new UnityEngine.Vector3(start.X, 0f, start.Z)); if (reqId != null) frame["reqId"] = reqId; EmitFrame(frame); } private void EmitDtError(string reqId, string reason, string message) { Dictionary frame = Frame("dt.error", "reply"); frame["reason"] = reason; frame["message"] = message; if (reqId != null) frame["reqId"] = reqId; _lastWorldSummary = $"Dangerous Treasures refused ({reason}): {message}"; EmitFrame(frame); } // ---- Guarded Crate, with NpcSpawn (PLAN_REDESIGNS §14.3.5, D361, D364, D373) ---- // // Bazz3l's Guarded Crate drops a locked crate from a cargo plane and rings it with // NpcSpawn's scientists; whoever kills the last guard has cleared it. The bridge says when // one starts, is cleared and times out, for the feed, the map and the tally (Warden), and // places one for a run's step through Guarded Crate's own console command. Its source // (2.0.17) shapes it: // // * `OnGuardedCrateEventStart(Vector3)` is raised INSIDE the call that creates the // event, so a start our `gcrate position` caused is heard before `ConsoleSystem.Run` // returns: the key's join is exact, as Dangerous Treasures' is (§14.3.18). // * `OnGuardedCrateEventEnded(player, crate)` when the last guard dies, crediting that // guard's killer; the player is null when the guards went some other way (NpcSpawn // reloaded), which is "no winner". It does not say which event, so the event is // found by reading Guarded Crate's own list (below), else by the crate's position. // `OnGuardedCrateEventFailed(Vector3)` is the timeout. // * **Its unload and `gcrate stop all` end every event and raise no hook.** So the // running set is cleared when it unloads, and checked against its own list before // the map is drawn; a hotload seeds from that list too. // * **`gcrate position` does not check NpcSpawn** (only `start` does), so without it a // placed crate would land with no guards and be free to hack. The bridge refuses that // itself (D364). // * The events are rows of its DATA file, not its config, and its console command is a // config value. Both are read from their files, as Economics' config is. private const string GcStartedKind = "gc.started"; private const string GcEndedKind = "gc.ended"; private const string GcFailedKind = "gc.failed"; /// Guarded Crate's console command when its config does not say. private const string GcDefaultCommand = "gcrate"; /// How far from a running event its crate or winner may be and still be placed at it. private const float GcEndReach = 100f; /// How long a placement's key is remembered: seven days, as a payment's is. private const long GcPlaceMemoryMs = 7L * 24L * 60L * 60L * 1000L; /// One running guarded crate: where, and the step's key when a step placed it. private sealed class GuardedCrateEvent { public UnityEngine.Vector3 At; public string Key; } /// The running guarded crates, oldest first. Main thread only. private readonly List _guardedCrates = new List(); /// One placement a step asked for, kept for its key. Persisted as a DT start is. private sealed class GcPlacement { public string Key; public string Event; public float X; public float Z; public long AtMs; } private Dictionary _gcPlacements = new Dictionary(StringComparer.Ordinal); /// The key of the `gcrate position` this plugin is running now, for the start hook. Null otherwise. private string _gcStarting; /// The event the start hook heard while our `gcrate position` ran. private GuardedCrateEvent _gcOpened; /// Guarded Crate's events (its data file) and console command (its config), each read again only when its file changes. private List _gcEventNames; private DateTime _gcEventNamesAt; private string _gcCommand; private DateTime _gcCommandAt; /// Whether Guarded Crate's own list of events could be read the last time it was asked. private bool _gcListReadable; private bool _gcListSaid; private Plugin GuardedCratePlugin() { return plugins.Find("GuardedCrate"); } private Plugin NpcSpawnPlugin() { return plugins.Find("NpcSpawn"); } private bool IsGuardedCrate(Plugin plugin) { return plugin != null && plugin != this && (plugin.Name == "GuardedCrate" || plugin.Name == "NpcSpawn"); } private void OnGuardedCrateEventStart(UnityEngine.Vector3 at) { MarkHook("OnGuardedCrateEventStart"); var crate = new GuardedCrateEvent { At = at, Key = _gcStarting }; _guardedCrates.Add(crate); // One `gcrate position` creates one event, so the key is spent on the first. if (_gcStarting != null) { _gcOpened = crate; _gcStarting = null; } if (!WorldEventsLive()) return; Dictionary frame = Frame(GcStartedKind, "event"); PutPosition(frame, at); if (crate.Key != null) frame["key"] = crate.Key; EmitFrame(frame); } private void OnGuardedCrateEventEnded(BasePlayer player, HackableLockedCrate crate) { MarkHook("OnGuardedCrateEventEnded"); // A guard killed by another NPC, or by nobody, clears the crate for nobody. bool winner = player != null && !player.IsNpc; if (winner) TallyFor(player).GuardedWon++; GuardedCrateEvent ended = EndedGuardedCrate(player, crate); if (ended != null) _guardedCrates.Remove(ended); if (!WorldEventsLive()) return; Dictionary frame = Frame(GcEndedKind, "event"); if (winner) { frame["steamId"] = SteamIdOf(player); frame["name"] = player.displayName; } if (ended != null) PutPosition(frame, ended.At); else if (crate != null && !crate.IsDestroyed) PutPosition(frame, crate.transform.position); EmitFrame(frame); } private void OnGuardedCrateEventFailed(UnityEngine.Vector3 at) { MarkHook("OnGuardedCrateEventFailed"); GuardedCrateEvent failed = NearestGuardedCrate(at, 1f); if (failed != null) _guardedCrates.Remove(failed); if (!WorldEventsLive()) return; Dictionary frame = Frame(GcFailedKind, "event"); PutPosition(frame, at); EmitFrame(frame); } /// /// Main thread, inside the ended hook. Which running event ended: the one Guarded /// Crate's own list has just marked complete; else the one nearest its crate, else its /// winner; else the only one running. Null when none can be told. /// private GuardedCrateEvent EndedGuardedCrate(BasePlayer player, HackableLockedCrate crate) { List> listed = GuardedCrateList(); if (listed != null) foreach (KeyValuePair e in listed) if (e.Value == "Complete") { GuardedCrateEvent exact = NearestGuardedCrate(e.Key, 1f); if (exact != null) return exact; } GuardedCrateEvent near = null; if (crate != null && !crate.IsDestroyed) near = NearestGuardedCrate(crate.transform.position, GcEndReach); if (near == null && player != null) near = NearestGuardedCrate(player.transform.position, GcEndReach); if (near == null && _guardedCrates.Count == 1) near = _guardedCrates[0]; return near; } private GuardedCrateEvent NearestGuardedCrate(UnityEngine.Vector3 at, float reach) { GuardedCrateEvent best = null; float bestDistance = reach; foreach (GuardedCrateEvent g in _guardedCrates) { float d = Distance2D(g.At, at); if (d <= bestDistance) { bestDistance = d; best = g; } } return best; } /// /// Main thread. Guarded Crate's own running events, as (position, state) pairs: its /// private EventManager.Instances, read by reflection because it has no API /// that lists them. Null when it is not loaded or its shape has changed, which is said /// once; the hooks alone then keep the set. /// private List> GuardedCrateList() { Plugin gc = GuardedCratePlugin(); if (gc == null) return null; try { const System.Reflection.BindingFlags Static = System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static; const System.Reflection.BindingFlags Instance = System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance; Type manager = gc.GetType().GetNestedType("EventManager", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic); System.Reflection.FieldInfo field = manager != null ? manager.GetField("Instances", Static) : null; var list = field != null ? field.GetValue(null) as System.Collections.IList : null; if (list == null) throw new InvalidOperationException("no EventManager.Instances"); var rows = new List>(); foreach (object item in list) { var component = item as UnityEngine.Component; // Unity's own null: a destroyed instance still in the list. if (component == null) continue; System.Reflection.FieldInfo state = item.GetType().GetField("currentState", Instance); object value = state != null ? state.GetValue(item) : null; rows.Add(new KeyValuePair(component.transform.position, value != null ? value.ToString() : null)); } _gcListReadable = true; return rows; } catch (Exception ex) { _gcListReadable = false; if (!_gcListSaid) { _gcListSaid = true; Puts($"could not read Guarded Crate's running events ({Flatten(ex.Message)}); its hooks alone keep the map's guarded crates"); } return null; } } /// /// Main thread. The running set, replaced by what Guarded Crate's own list says: a /// hotload hears no starts, and its unload and `gcrate stop all` end events without a /// hook. A step's key is kept for an event still running. Nothing changes when the /// list cannot be read. /// private void SeedGuardedCrates(string why) { if (GuardedCratePlugin() == null) { _guardedCrates.Clear(); return; } List> listed = GuardedCrateList(); if (listed == null) return; var running = new List(); foreach (KeyValuePair e in listed) { if (e.Value != null && e.Value != "Running") continue; GuardedCrateEvent known = NearestGuardedCrate(e.Key, 1f); running.Add(new GuardedCrateEvent { At = e.Key, Key = known != null ? known.Key : null }); } int before = _guardedCrates.Count; _guardedCrates.Clear(); _guardedCrates.AddRange(running); if (why != null && (running.Count > 0 || before > 0)) Puts($"map: {running.Count} guarded crate(s) after {why}"); } /// The map's guarded rows, for its world layer. private void AddGuardedRows(List world) { // `gcrate stop all` ends events without a hook; what its own list no longer has goes. SeedGuardedCrates(null); foreach (GuardedCrateEvent g in _guardedCrates) { Dictionary row = At(g.At); row["kind"] = "guarded"; world.Add(row); } } /// /// Hello's integrations.guardedCrate: loaded and version, NpcSpawn's beside it /// (D364), and events, the event names in Guarded Crate's data file, absent when /// the file cannot be read. /// private Dictionary GuardedCrateBody() { Plugin gc = GuardedCratePlugin(); Dictionary body = IntegrationBody(gc); body["npcSpawn"] = IntegrationBody(NpcSpawnPlugin()); if (gc == null) return body; List events = GuardedCrateEventNames(); if (events != null) body["events"] = events; return body; } /// /// The event names in Guarded Crate's data file (CrateEventEntries, each row's /// event display name), the key's stray bracket and all), in its order. Null when /// the file is missing or will not parse. Read again only when the file changes: every /// status frame asks. The directory comes from the framework (R19). /// private List GuardedCrateEventNames() { string path = Path.Combine(Interface.Oxide.DataDirectory, "GuardedCrate.json"); DateTime at; try { if (!File.Exists(path)) return null; at = File.GetLastWriteTimeUtc(path); } catch (Exception) { return null; } if (at == _gcEventNamesAt) return _gcEventNames; List names = null; try { var data = Newtonsoft.Json.Linq.JObject.Parse(File.ReadAllText(path)); var rows = data["CrateEventEntries"] as Newtonsoft.Json.Linq.JArray; if (rows != null) { names = new List(); foreach (Newtonsoft.Json.Linq.JToken row in rows) { var entry = row as Newtonsoft.Json.Linq.JObject; Newtonsoft.Json.Linq.JToken name = entry != null ? entry["event display name)"] : null; if (name != null && name.Type == Newtonsoft.Json.Linq.JTokenType.String && !string.IsNullOrWhiteSpace((string)name)) names.Add((string)name); } } } catch (Exception ex) { Puts($"could not read Guarded Crate's data file: {Flatten(ex.Message)}"); } _gcEventNames = names; _gcEventNamesAt = at; return names; } /// /// Guarded Crate's console command from its config (command name - (default: /// gcrate)), or gcrate when the file does not say. Empty when the operator /// blanked it, which registers no command at all. /// private string GuardedCrateCommand() { string path = Path.Combine(Interface.Oxide.ConfigDirectory, "GuardedCrate.json"); DateTime at; try { if (!File.Exists(path)) return GcDefaultCommand; at = File.GetLastWriteTimeUtc(path); } catch (Exception) { return GcDefaultCommand; } if (_gcCommand != null && at == _gcCommandAt) return _gcCommand; string command = GcDefaultCommand; try { var config = Newtonsoft.Json.Linq.JObject.Parse(File.ReadAllText(path)); Newtonsoft.Json.Linq.JToken name = config["command name - (default: gcrate)"]; if (name != null && name.Type == Newtonsoft.Json.Linq.JTokenType.String) command = ((string)name).Trim(); } catch (Exception ex) { Puts($"could not read Guarded Crate's config: {Flatten(ex.Message)}"); } _gcCommand = command; _gcCommandAt = at; return command; } private string GcPlacementsPath() { return Path.Combine(Interface.Oxide.DataDirectory, Name, "gc-places.json"); } private void SaveGcPlacements() { WriteDataFile(GcPlacementsPath(), new List(_gcPlacements.Values), "Guarded Crate placements"); } private void LoadGcPlacements() { _gcPlacements = new Dictionary(StringComparer.Ordinal); List placed = ReadDataFile(GcPlacementsPath(), "Guarded Crate placements"); if (placed != null) foreach (GcPlacement p in placed) if (p != null && !string.IsNullOrEmpty(p.Key)) _gcPlacements[p.Key] = p; ForgetOldGcPlacements(); } private void ForgetOldGcPlacements() { long now = NowMs(); var stale = new List(); foreach (KeyValuePair kv in _gcPlacements) if (now >= kv.Value.AtMs + GcPlaceMemoryMs) stale.Add(kv.Key); foreach (string key in stale) _gcPlacements.Remove(key); if (stale.Count > 0) SaveGcPlacements(); } /// /// Main thread. Place one guarded crate for a run's step: { key, event } and a /// location as a world call takes one (a monument and offsets, or x and z). A key /// already answered is answered again with its first placement and places nothing. /// private void HandleGcPlace(Dictionary cmd, string reqId) { if (!WorldWritesAllowed(reqId)) return; string key = StringField(cmd, "key"); if (string.IsNullOrEmpty(key) || key.Length > PaymentKeyMax) { EmitGcError(reqId, "malformed", "a placement is made under a key of at most 128 characters"); return; } ForgetOldGcPlacements(); GcPlacement seen; if (_gcPlacements.TryGetValue(key, out seen)) { EmitGcPlaced(reqId, seen, true); return; } string wanted = (StringField(cmd, "event") ?? string.Empty).Trim(); if (wanted.Length == 0) { EmitGcError(reqId, "bad-event", "a guarded crate is placed as one of Guarded Crate's events, and none was named"); return; } Plugin gc = GuardedCratePlugin(); if (gc == null) { EmitGcError(reqId, "gc-missing", "Guarded Crate is not loaded on this server"); return; } // Guarded Crate's `position` does not check this itself: the crate would land unguarded. if (NpcSpawnPlugin() == null) { EmitGcError(reqId, "npcspawn-missing", "Guarded Crate is loaded but NpcSpawn is missing: its events cannot start"); return; } List events = GuardedCrateEventNames(); string name = null; if (events != null) { // As Guarded Crate finds an event: by name, ignoring case. foreach (string e in events) if (string.Equals(e, wanted, StringComparison.OrdinalIgnoreCase)) name = e; if (name == null) { EmitGcError(reqId, "bad-event", $"'{wanted}' is not one of Guarded Crate's events here ({string.Join(", ", events.ToArray())})"); return; } } else name = wanted; if (!_consolePlugins) { EmitGcError(reqId, "console-unavailable", "this framework does not run another plugin's console command from code, so a guarded crate cannot be placed here (D363, Rust-Plugins#34)"); return; } UnityEngine.Vector3 at; string reason, refusal; if (!ResolveWorldPoint(cmd, out at, out reason, out refusal)) { EmitGcError(reqId, reason, refusal); return; } string command = GuardedCrateCommand(); if (string.IsNullOrEmpty(command)) { EmitGcError(reqId, "refused", "Guarded Crate's console command is switched off in its config"); return; } // Invariant, so a server whose locale writes 1,5 still sends 1.5. string point = string.Format(System.Globalization.CultureInfo.InvariantCulture, "{0:0.##},{1:0.##},{2:0.##}", at.x, at.y, at.z); string reply = null; _gcOpened = null; _gcStarting = key; try { reply = ConsoleSystem.Run(ConsoleSystem.Option.Server.Quiet(), command, "position", name, point); } catch (Exception ex) { reply = ex.Message; } finally { _gcStarting = null; } GuardedCrateEvent opened = _gcOpened; _gcOpened = null; if (opened == null) { RefuseGcPlace(reqId, gc, reply); return; } var placement = new GcPlacement { Key = key, Event = name, X = opened.At.x, Z = opened.At.z, AtMs = NowMs() }; _gcPlacements[key] = placement; SaveGcPlacements(); _lastWorldSummary = $"placed a {name} guarded crate at {Grid(opened.At) ?? "?"} for key {key}"; Puts($"world: {_lastWorldSummary}"); EmitGcPlaced(reqId, placement, false); } /// Why our gcrate position placed nothing, told by Guarded Crate's own messages, read from its lang. private void RefuseGcPlace(string reqId, Plugin gc, string reply) { string said = string.IsNullOrEmpty(reply) ? null : Flatten(reply).Trim(); if (said != null && said == GcMessage(gc, "EVENT_INTERSECTING")) EmitGcError(reqId, "gc-too-close", "another guarded crate is running within 80 m of that spot"); else if (said != null && said == GcMessage(gc, "EVENT_NOT_FOUND")) EmitGcError(reqId, "bad-event", "Guarded Crate has no event of that name"); else if (said != null && said == GcMessage(gc, "EVENT_POSITION_INVALID")) EmitGcError(reqId, "bad-position", "Guarded Crate refused that position"); else EmitGcError(reqId, "refused", $"Guarded Crate placed nothing{(said != null ? $": {said}" : string.Empty)}"); } /// One of Guarded Crate's messages, flattened as a reply is, or null. private string GcMessage(Plugin gc, string key) { try { string text = lang.GetMessage(key, gc); return string.IsNullOrEmpty(text) ? null : Flatten(text).Trim(); } catch (Exception) { return null; } } private void EmitGcPlaced(string reqId, GcPlacement placement, bool repeat) { Dictionary frame = Frame("gc.placed", "reply"); frame["key"] = placement.Key; frame["event"] = placement.Event; frame["repeat"] = repeat; PutPosition(frame, new UnityEngine.Vector3(placement.X, 0f, placement.Z)); if (reqId != null) frame["reqId"] = reqId; EmitFrame(frame); } private void EmitGcError(string reqId, string reason, string message) { Dictionary frame = Frame("gc.error", "reply"); frame["reason"] = reason; frame["message"] = message; if (reqId != null) frame["reqId"] = reqId; _lastWorldSummary = $"Guarded Crate refused ({reason}): {message}"; EmitFrame(frame); } // ---- 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)); } /// /// The map's monuments in the one order every caller agrees on: grouped by prefab short /// name, then by position. #n in a value counts within its group, so it names the /// same monument every time it is asked on this map (D93). /// private static List>> MonumentGroups() { var byKind = new SortedDictionary>(StringComparer.Ordinal); List 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 list; if (!byKind.TryGetValue(kind, out list)) { list = new List(); byKind[kind] = list; } list.Add(m); } } var groups = new List>>(); foreach (KeyValuePair> 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; } /// /// Main thread. A monument value — kind or kind#n — 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. /// 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> 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 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; } /// /// 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. /// private bool ResolveWorldPoint(Dictionary 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; } /// Main thread. Refuses a world command until the world is loaded (see _worldReady). 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; } /// /// 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). /// private List EntriesForKey(string key) { if (string.IsNullOrEmpty(key)) return null; List found = null; foreach (WorldEntry e in _world.Values) { if (e.Key != key) continue; if (found == null) found = new List(); found.Add(e); } return found; } private void HandleWorldMonuments(string reqId) { if (!WorldReady(reqId)) return; var rows = new List(); foreach (KeyValuePair> 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 { { "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 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["maxWorldCalls"] = WorldCallsLimit(); reply["zoneManager"] = ZoneManagerPlugin() != null; if (reqId != null) reply["reqId"] = reqId; EmitFrame(reply); } private static List WorldPrefabRows() { var rows = new List(); foreach (WorldPrefab p in WorldPrefabs) { rows.Add(new Dictionary { { "key", p.Key }, { "kind", p.Kind }, { "label", p.Label } }); } return rows; } /// Main thread. Open a zone an event owns. private void HandleWorldZone(Dictionary cmd, string reqId) { if (!WorldWritesAllowed(reqId)) return; string key = StringField(cmd, "key"); string runId = StringField(cmd, "runId"); List 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 flags; Dictionary 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; } // §14.3.2 (D368): TruePVE's rules in the zone, as `exclude` or one ruleset by name. string truePve; string truePveRefusal; if (!ReadTruePve(cmd, out truePve, out truePveRefusal)) { EmitWorldError(reqId, "malformed", truePveRefusal); return; } if (truePve != null && TruePvePlugin() == null) { EmitWorldError(reqId, "truepve-missing", "TruePVE is not loaded on this server, so a zone cannot change its PvP rules"); return; } 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; } // The mapping last, all or nothing like the dome: only TruePVE's own answer can say // whether it has a ruleset of that name (D368), and a zone without the rules its // step asked for is not opened at all. if (truePve != null) { object mapped = TruePvePlugin()?.Call("AddOrUpdateMapping", entry.Id, truePve); if (!(mapped is bool && (bool)mapped)) { RemoveDome(entry); zm.Call("EraseTemporaryZone", this, entry.Id); if (truePve == TruePveExclude) EmitWorldError(reqId, "refused", "TruePVE did not map the zone, so it was not opened"); else EmitWorldError(reqId, "truepve-ruleset", $"TruePVE on this server has no ruleset named '{truePve}' (names are case-sensitive), so the zone was not opened"); return; } entry.TruePve = truePve; } _world[entry.Id] = entry; SaveWorld(); EnsureWorldTimer(); _lastWorldSummary = $"zone {entry.Id} r={radius:0} for {holdMs / 60000L} min, {flags.Count} flag(s){(dome ? ", domed" : "")}{(truePve != null ? $", TruePVE {truePve}" : "")}, run {runId}"; Puts($"world: {_lastWorldSummary}"); EmitWorldPlaced(reqId, new List { 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"; } /// /// 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. /// private bool CreateZone(Plugin zm, WorldEntry entry) { var args = new List { "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 "", "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 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; } /// /// 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 world.owned. /// 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); } /// Main thread. Place crates or NPCs an event owns, one registry entry per net id. private void HandleWorldPlace(Dictionary cmd, string reqId) { if (!WorldWritesAllowed(reqId)) return; string key = StringField(cmd, "key"); string runId = StringField(cmd, "runId"); List 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(); 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; } /// Main thread. Whether the entity was there to kill; either way it is not now. private static bool KillEntity(string id) { BaseNetworkable found = FindEntity(id); if (found == null) return false; found.Kill(); return true; } /// /// Main thread. Give back what a run owns. Never behind EventsEnabled: switching /// events off must not strand anything already placed (D94). /// /// /// 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. An id the registry does not hold for this run is refused — this /// bridge erases only what it made, whatever ZoneManager would allow (§12.4). /// /// private void HandleWorldRevert(Dictionary 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(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(); var refused = new List(); var gone = new List(); 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(); 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); // And its TruePVE mapping, which TruePVE never prunes for an id like ours (D362). UnmapTruePve(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 reply = Frame("world.ok", "reply"); reply["removed"] = removed; reply["gone"] = gone; reply["refused"] = refused; if (reqId != null) reply["reqId"] = reqId; EmitFrame(reply); } /// /// 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. /// private void HandleWorldOwned(Dictionary cmd, string reqId) { if (!WorldReady(reqId)) return; string runId = StringField(cmd, "runId"); RecreateZones("owned"); Plugin zm = ZoneManagerPlugin(); var rows = new List(); List 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(); pruned.Add(e.Id); continue; } rows.Add(WorldRow(e)); } if (pruned != null) { foreach (string id in pruned) { // A zone ZoneManager could not put back is erased all the same, mapping and all. UnmapTruePve(_world[id]); _world.Remove(id); } SaveWorld(); } Dictionary 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 WorldRow(WorldEntry e) { var row = new Dictionary { { "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 entries, bool repeat) { var placed = new List(); foreach (WorldEntry e in entries) placed.Add(WorldRow(e)); Dictionary 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 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"); } /// Main thread. Written on every change, like the lease holds and for the same reason. private void SaveWorld() { try { string path = WorldPath(); Directory.CreateDirectory(Path.GetDirectoryName(path)); var rows = new List(_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)}"); } } /// /// 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. /// private void LoadWorld() { _world = new Dictionary(); List rows = null; try { string path = WorldPath(); if (File.Exists(path)) rows = JsonConvert.DeserializeObject>(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; } } /// /// 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. /// private void CheckZoneDeadlines() { List 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(); due.Add(e); } if (!anyZone) { StopWorldTimer(); return; } if (due == null) return; Plugin zm = ZoneManagerPlugin(); foreach (WorldEntry e in due) { RemoveDome(e); UnmapTruePve(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 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}"); } /// /// 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 OnServerInitialized. /// private void DropWorldFromOtherWipes() { if (string.IsNullOrEmpty(_wipeId) || _world.Count == 0) return; var stale = new List(); foreach (WorldEntry e in _world.Values) { if (!string.IsNullOrEmpty(e.WipeId) && e.WipeId != _wipeId) stale.Add(e.Id); } if (stale.Count == 0) return; // TruePVE's table outlives a wipe, so the old map's mappings are removed with it. foreach (string id in stale) { UnmapTruePve(_world[id]); _world.Remove(id); } SaveWorld(); _lastWorldSummary = $"a new wipe dropped {stale.Count} things from the old map"; Puts($"world: {_lastWorldSummary}"); } /// What events have made on this map, from the server console. [ConsoleCommand("rg.world")] private void CmdWorld(ConsoleSystem.Arg arg) { int zones = 0, crates = 0, npcs = 0; // Guarded Crate's `stop all` ends events without a hook; count what is really running. SeedGuardedCrates(null); 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)} " + $"truePve={(TruePvePlugin() != null)} truePveLeftover={_truePvePending.Count} " + $"dynamicPvp={(DynamicPvpPlugin() != null)} pvpZones={_pvpZones.Count} " + $"dangerousTreasures={(DangerousTreasuresPlugin() != null)} treasures={_treasures.Count} dtStarts={_dtStarts.Count} dtWarmingUp={DtWarmingUp()} " + $"guardedCrate={(GuardedCratePlugin() != null)} npcSpawn={(NpcSpawnPlugin() != null)} guarded={_guardedCrates.Count} gcPlaces={_gcPlacements.Count} " + $"gcEvents={(GuardedCrateEventNames() != null ? string.Join("|", GuardedCrateEventNames().ToArray()) : "none")} gcCommand={GuardedCrateCommand()} gcListReadable={_gcListReadable} " + $"wipeId={(_wipeId ?? "none")} calls={_worldCalls.Count} consolePlugins={_consolePlugins} " + $"credits={_vehicleCredit.Count} chinookCrates={_chinookCrates.Count} said={_worldEventsSaid} " + $"worldEvents={(WorldEventsLive() ? "live" : $"quiet (ready={_worldReady} quitting={Rust.Application.isQuitting} loadingSave={Rust.Application.isLoadingSave})")}" + 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). /// The most tallies that may count at once. EventsMaxTallies may lower it. private const int TallyHardMax = 8; /// /// 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. /// private const int RecipientsHardMax = 100; /// The longest chat line, in characters. EventsMaxChat may lower it. private const int ChatHardMax = 256; private const float TallyTickSeconds = 5f; /// /// 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. /// private const long TallyMaxHoldMs = 7L * 24L * 60L * 60L * 1000L; /// Core files at most 5 000 participants per step; a tally holds no more. private const int TallyMaxPeople = 5000; /// /// 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. /// private const long TallyMaxStepMs = 60000L; /// Every twelfth tick (a minute) writes the file, when anything changed. 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"; /// One person in one tally. Persisted as it is. private sealed class Participant { public string SteamId; public string Name; public double Seconds; public int Kills; public long JoinedMs; } /// One run's tally on this server. There is at most one per run (§29.5). private sealed class RunTally { public string RunId; /// The idempotency key of the step that opened it; a repeat is answered, not refused. public string Key; /// The zone step's name, as the step gave it, or null for the whole server. public string Zone; /// What that name resolved to when the tally opened (D100). public string ZoneId; public string Score; public string KillsOf; public double KillWeight; public long OpenedMs; /// When counting stops. What was counted is kept until seven days after opening (D108). public long DeadlineMs; /// People who took part after the tally was full, counted so a result can say so. public int Overflow; public Dictionary People = new Dictionary(); /// Not persisted: a load starts accruing from the load, never from before a restart. [JsonIgnore] public long LastAccrueMs; } /// /// One player's credits for one kit (D103). Earned is the site's; the other two /// are this plugin's own record of what it did with them. /// private sealed class KitCredit { public string SteamId; public string Kit; /// How many the site says this player has earned. Authored there, pushed here. public int Earned; /// How many have been moved into Kits' counter — each one a use count lowered by one. public int Applied; /// How many of those moved uses have since been redeemed. public int Consumed; } /// /// One "you earned a reward" message for one player (protocol 14, D333). The key names /// the reward — a run and its step — so a message is told once however many syncs carry /// it. /// private sealed class RewardNotice { public string Key; public string SteamId; public string Message; public string Delivery; } /// The most notices one sync carries; the rest wait for the next. private const int NoticesMax = 2000; /// The longest notice key. The site's are short (run:N:step:N). private const int NoticeKeyMax = 128; private Dictionary _runTallies = new Dictionary(); private Dictionary _credits = new Dictionary(); /// /// The site's untold notices, by key, as the last sync carried them. In memory only: the /// site sends its whole untold set on every sync, so a reload loses nothing it will not /// hear again. /// private Dictionary _notices = new Dictionary(); /// /// The keys already told, kept on disk until the site stops sending them. A reload /// between telling a player and the report that says so would otherwise tell them twice. /// private HashSet _noticesTold = new HashSet(StringComparer.Ordinal); /// Recently said chat keys, and when. Main thread only. private readonly Dictionary _chatKeys = new Dictionary(); 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; } /// Whether a player is somebody a tally counts: a real, connected, living, awake person. 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; } /// The D99 score: seconds, kills, or minutes present plus the weight times kills. 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); } } /// Main thread. Start counting who takes part in a run. private void HandleTallyOpen(Dictionary 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(); 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); } /// Main thread. Who has taken part in a run so far. Never behind EventsEnabled. private void HandleTallySnapshot(Dictionary 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(); foreach (Participant p in t.People.Values) { people.Add(new Dictionary { { "steamId", p.SteamId }, { "name", p.Name }, { "seconds", (long)Math.Floor(p.Seconds) }, { "kills", p.Kills }, { "score", ScoreOf(t, p) }, { "joinedAt", p.JoinedMs } }); } Dictionary 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); } /// Main thread. Stop counting and forget. A tally already gone is a success. private void HandleTallyClose(Dictionary 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 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 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 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); } /// /// Main thread. Credits presence up to — and no further than the /// tally's deadline. is looked up once per tick by the caller. /// 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 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; } 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(); 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; } } /// Main thread, every five seconds while any tally is held. private void TallyTick() { long now = NowMs(); List 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(); 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(); } /// /// 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). /// 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"); } /// Main thread. Written at most once a minute while counting, and on every open and close. private void SaveTallies() { WriteDataFile(TalliesPath(), new List(_runTallies.Values), "tallies"); _talliesDirty = false; } private void SaveCredits() { WriteDataFile(CreditsPath(), new List(_credits.Values), "kit credits"); } // ── Reward notices (protocol 14, PROTOCOL.md §20.2, D333) ────────────────────────────── // // "You earned kit vip — type /kit vip", said once to the player a reward step picked, in // chat or as a popup. The site decides what to say and to whom; this plugin decides only // WHEN: at once if the player is up and about, otherwise the next time they wake on this // server. A sleeping or loading player sees no popup and reads no chat, so "online" is not // enough — awake is. private string NoticesPath() { return Path.Combine(Interface.Oxide.DataDirectory, Name, "notices.json"); } private void SaveNoticesTold() { WriteDataFile(NoticesPath(), new List(_noticesTold), "told reward notices"); } private void LoadNoticesTold() { List told = ReadDataFile(NoticesPath(), "told reward notices"); if (told == null) return; foreach (string key in told) { if (!string.IsNullOrEmpty(key)) _noticesTold.Add(key); } } /// /// Main thread, at the end of a sync that carried notices. Takes the site's set as /// the whole truth, forgets told keys the site no longer sends (it has heard, or the /// reward was withdrawn), tells everybody awake now, and lists every told key of the set /// for the report. /// private void ApplyNotices(PermSyncJob job) { var next = new Dictionary(StringComparer.Ordinal); foreach (RewardNotice n in job.Notices) next[n.Key] = n; _notices = next; int before = _noticesTold.Count; _noticesTold.RemoveWhere(k => !next.ContainsKey(k)); bool changed = _noticesTold.Count != before; foreach (RewardNotice n in next.Values) { if (_noticesTold.Contains(n.Key)) continue; BasePlayer player = AwakePlayer(n.SteamId); if (player != null && TellNotice(player, n)) changed = true; } if (changed) SaveNoticesTold(); foreach (string key in _noticesTold) job.NoticesTold.Add(key); } /// The player with this Steam id if they are connected and awake, else null. private static BasePlayer AwakePlayer(string steamId) { ulong id; if (!ulong.TryParse(steamId, out id)) return null; BasePlayer player = BasePlayer.FindByID(id); return player != null && player.IsConnected && !player.IsSleeping() ? player : null; } /// /// Main thread. Says one notice to one player and records it told. A popup where /// PopupNotifications is loaded and the step chose one, chat otherwise (§18) — the reward /// was granted long before this moment, and the player is told either way. /// private bool TellNotice(BasePlayer player, RewardNotice n) { SayTo(player, n.Message, n.Delivery); return _noticesTold.Add(n.Key); } /// Main thread. One line to one player, as a popup where one was asked for and can be shown. private void SayTo(BasePlayer player, string message, string delivery) { Plugin popup = delivery == DeliveryPopup ? PopupPlugin() : null; if (popup != null) popup.Call("CreatePopupNotification", message, player, 0f); else player.ChatMessage(message); } /// /// A player woke — after connecting, or after sleeping in a bag. Their untold notices are /// said now. The site hears at the next sync's report. /// private void OnPlayerSleepEnded(BasePlayer player) { MarkHook("OnPlayerSleepEnded"); if (player == null) return; // Coins paid while they were away (protocol 14, §20.3). TellPayments(player); if (_notices.Count == 0) return; string steamId = SteamIdOf(player); bool changed = false; foreach (RewardNotice n in _notices.Values) { if (n.SteamId == steamId && !_noticesTold.Contains(n.Key) && TellNotice(player, n)) changed = true; } if (changed) SaveNoticesTold(); } 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 ReadDataFile(string path, string what) { try { if (File.Exists(path)) return JsonConvert.DeserializeObject>(File.ReadAllText(path)); } catch (Exception ex) { Puts($"could not read the {what}: {Flatten(ex.Message)}"); } return null; } /// /// 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. /// private void LoadRewards() { _runTallies = new Dictionary(); _credits = new Dictionary(); long now = NowMs(); List tallies = ReadDataFile(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(); t.LastAccrueMs = Math.Min(now, t.DeadlineMs); _runTallies[t.RunId] = t; } } List credits = ReadDataFile(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; } } LoadNoticesTold(); LoadPayments(); Puts($"rewards: {_runTallies.Count} tallies, {_credits.Count} kit credits, {_noticesTold.Count} told notices and {_payments.Count} Economics payments 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. /// /// 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. /// private const int RunicNpcApiNeeded = 6; private Plugin RunicNpcPlugin() { return plugins.Find("RunicNPC"); } /// RunicNPC's API version, or 0 when it is not loaded. private int RunicNpcApi() { Plugin npc = RunicNpcPlugin(); if (npc == null) return 0; object api = npc.Call("RunicNpc_ApiVersion"); return api is int ? (int)api : 0; } /// For the integrations body: loaded, its version, and the API it answers. private Dictionary RunicNpcBody() { Dictionary body = IntegrationBody(RunicNpcPlugin()); if (RunicNpcPlugin() != null) body["api"] = RunicNpcApi(); return body; } /// /// 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. /// private string RunicNpcProfileOf(BaseEntity entity) { if (entity == null) return null; Plugin npc = RunicNpcPlugin(); return npc != null ? npc.Call("RunicNpc_ProfileOf", entity) as string : null; } /// /// Main thread. The plugin to call, or null after answering npc.error — missing, or /// older than the API these commands need. /// 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; } /// /// 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 world.error with the reason when not. /// 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; } /// /// Main thread. npc.profiles: 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). /// 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 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); } /// /// Main thread. npc.profiles.set: 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. /// private void HandleNpcProfilesSet(Dictionary 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; _lastNpcSummary = $"profiles pushed: {profiles.Count}, refused {(refused != null ? refused.Count : 0)}" + (factions != null ? $", factions {factions.Count}{(factionsRefused != null ? " REFUSED" : "")}" : ""); Puts($"runicnpc: {_lastNpcSummary}"); Dictionary reply = Frame("npc.ok", "reply"); reply["refused"] = refused ?? new Dictionary(); if (factionsRefused != null) reply["factionsRefused"] = factionsRefused; if (reqId != null) reply["reqId"] = reqId; EmitFrame(reply); } /// /// Main thread. npc.placements: 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). /// 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(); if (routes != null) { foreach (KeyValuePair route in routes) names.Add(route.Key); } names.Sort(StringComparer.Ordinal); Dictionary reply = Frame("npc.placements", "reply"); reply["placements"] = npc.Call("RunicNpc_Placements") ?? new List>(); reply["routes"] = names; reply["cost"] = npc.Call("RunicNpc_CostWarning", 0) as string; if (reqId != null) reply["reqId"] = reqId; EmitFrame(reply); } /// /// Main thread. npc.placement: one change to one placement, by op — add /// (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), set (new values for an existing one), /// remove, rename and respawn. Every refusal is RunicNPC's own sentence. /// private void HandleNpcPlacement(Dictionary 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 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; 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>; if (all == null) return false; foreach (Dictionary 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 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); } /// What the last RunicNPC command did, for rg.status. private string _lastNpcSummary; // ---- RunicNPC's hooks, as frames ---- /// /// One of RunicNPC's NPCs died (stage 4). npc.died 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 (npcProfileKills), not this frame's. /// private void OnRunicNpcDied(BasePlayer npc, string profile, string owner, HitInfo info, Dictionary contributors) { MarkHook("OnRunicNpcDied"); if (npc == null) return; Dictionary 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(); if (contributors != null) { var sorted = new List>(contributors); sorted.Sort((a, b) => b.Value.CompareTo(a.Value)); foreach (KeyValuePair kv in sorted) { BasePlayer who = BasePlayer.FindByID(kv.Key) ?? BasePlayer.FindSleeping(kv.Key); var row = new Dictionary { { "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); } /// An NPC fell to one of its profile's healthThresholds (a boss below 50%). private void OnRunicNpcHealth(BasePlayer npc, string profile, float threshold) { MarkHook("OnRunicNpcHealth"); if (npc == null) return; Dictionary 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); } /// /// 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. /// private void OnRunicNpcPlacementChanged(string id, string change, string previous) { MarkHook("OnRunicNpcPlacementChanged"); Dictionary frame = Frame("npc.placement.changed", "event"); frame["id"] = id; frame["change"] = change; if (!string.IsNullOrEmpty(previous)) frame["previous"] = previous; EmitFrame(frame); } /// The fields every NPC frame shares. owner null reads it from the NPC's placement, if any. // ---- 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. /// A boss spawned: an admin's placement, an event's step or another plugin. private void OnRunicNpcBossSpawned(BasePlayer npc, string profile, string owner) { MarkHook("OnRunicNpcBossSpawned"); if (npc == null) return; Dictionary frame = Frame("npc.boss.spawned", "event"); PutNpc(frame, npc, profile, owner); frame["maxHealth"] = (float)Math.Round(npc.MaxHealth(), 1); EmitFrame(frame); } /// A boss entered a phase: phase counts from 1, at is its health fraction. private void OnRunicNpcBossPhase(BasePlayer npc, string profile, string owner, int phase, float at) { MarkHook("OnRunicNpcBossPhase"); if (npc == null) return; Dictionary 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); } /// A boss died: the killing blow's player, and how many players hurt it. private void OnRunicNpcBossDied(BasePlayer npc, string profile, string owner, HitInfo info, Dictionary contributors) { MarkHook("OnRunicNpcBossDied"); if (npc == null) return; Dictionary 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 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) ---- /// /// Main thread. world.place with a profile: the NPCs are RunicNPC's, owned by /// the run (run:<runId>), 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. /// private void HandleWorldPlaceProfile(Dictionary 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(); // 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); } /// A registry entry's prefab for a RunicNPC profile: runicnpc:<profile>. private const string RunicNpcPrefix = "runicnpc:"; // ---- an event's NPCs: escort, ally and tether (runicnpc stage 5, D269, D270, D272) ---- /// What a placing step tells its NPCs to do besides their profile. Every part is optional. private sealed class NpcOrders { /// D269: the player they escort. public BasePlayer Escort; /// D270: clan (the game's clan id) or player (a Steam id: that player and their team). public string AllyKind; public string AllyId; /// D272: the zone of this run that holds the step's point. public string Tether; public bool Any => Escort != null || AllyKind != null || Tether != null; } /// /// Main thread. Reads escort (a Steam id), ally ({kind: clan|player, id}) and /// tether (true: the run's own zone around the point) from a world.place. False with /// the reason when one cannot be honoured, so the step is refused before anything spawns. /// private bool ReadNpcOrders(Dictionary 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; } /// Main thread. The zone a run made that holds a point, or null. 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; } /// Main thread. Gives one spawned NPC its orders; null, or RunicNPC's reason for refusing one. 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; } /// /// 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. /// 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); } /// RunicNPC as the bridge sees it: loaded, its API, and the last command. [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"); } /// Main thread. The kits a reward can name, and what gates each (the kit source). 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(); kits.Call("GetKitNames", names); var rows = new List(); 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 { { "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 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); } /// /// Main thread. Protocol 14 (PROTOCOL.md §20.1; the module's PLAN_REDESIGNS.md §2.6). Of /// the given players, how many have used kit up and how many are waiting out its /// cooldown — the warning beside a kit weekend ("9 of 41 linked players have used it /// up"). Counts, never a list: the website asked about players it already knows, /// and the answer it needs is how many, not who. /// /// /// Two Kits reads per player, so the list is bounded by the sync's own bound: the site /// sends its linked players for this server, and a sync carries no more than that. /// /// private void HandleKitsUses(Dictionary cmd, string reqId) { Plugin kits = KitsPlugin(); if (kits == null) { EmitRewardError("kits.error", reqId, "kits-missing", "Kits is not loaded on this server"); return; } string kit = StringField(cmd, "kit"); if (string.IsNullOrEmpty(kit) || !(kits.Call("GetKitInfo", kit) is Newtonsoft.Json.Linq.JObject)) { EmitRewardError("kits.error", reqId, "unknown-kit", $"this server has no kit named '{Flatten(kit ?? "")}'"); return; } var ids = new List(); foreach (string raw in Strings(cmd, "steamIds")) { ulong id; if (ulong.TryParse(raw, out id)) ids.Add(id); } if (ids.Count > PermMaxOps) { EmitRewardError("kits.error", reqId, "too-large", $"at most {PermMaxOps} players in one question, not {ids.Count}"); return; } int max = CallInt(kits, "GetKitMaxUses", kit); int exhausted = 0; int onCooldown = 0; foreach (ulong id in ids) { // A kit with no use limit is never used up. if (max > 0 && CallInt(kits, "GetPlayerKitUses", id, kit) >= max) exhausted++; object cooldown = kits.Call("GetPlayerKitCooldown", id, kit); if (cooldown is double && (double)cooldown > 0) onCooldown++; } Dictionary reply = Frame("kits.uses", "reply"); reply["kit"] = kit; reply["max"] = max; reply["cooldown"] = CallInt(kits, "GetKitCooldown", kit); reply["asked"] = ids.Count; reply["exhausted"] = exhausted; reply["onCooldown"] = onCooldown; 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; } /// /// 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: /// /// /// /// Notice a redemption. 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 OnKitRedeemed is read, and it also heals a redemption made while this /// plugin was unloaded. /// /// /// Withdraw what the site took back (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. /// /// /// Spend the next one, last. 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. /// /// /// 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++; } } } /// /// Main thread, at the end of a perm.sync that carried credits. 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. /// private void ReconcileCredits(PermSyncJob job) { var desired = new Dictionary(); 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 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(); 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(); foreach (KeyValuePair 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(); } /// /// 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. /// 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"; } /// /// Whether Kits clears its usage data at a wipe (its own Wipe player data when the /// server is wiped, off by default). Read from its config file, because the switch /// is not in its API — and on Carbon that file is under carbon/configs, which /// is why the directory comes from the framework and never from a literal (R19). /// 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; } } /// /// Main thread, from OnNewSave. 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. /// 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"); } // ── Economics (protocol 14, PROTOCOL.md §20.3; the module's PLAN_REDESIGNS.md §11.2) ──── // // Wulf's Economics, optional like the other mods (D199): every verb below answers // `economics-missing` without it, and nothing else in this plugin needs it. Its API is five // private methods, each with a `string` and an `object` overload, called by name. What the // spike found (§11.2.1) shapes every call here: // // * an amount is boxed as a `double` — an `int` matches no overload, and `Call` answers // null having done nothing; // * only ids parsed as Steam ids are sent, because Economics makes an account of any // string it is given; // * what a deposit paid is the balance after less the balance before, because Deposit // answers `true` while rounding to two places and clamping to its balance limit. // // Coins paid are kept (D335): there is no revert. A payment or adjustment is remembered by // its key for seven days, so a retried step or a resent form is answered from the first // reply and never pays twice. private const double EconomicsAmountMin = 0.01; private const double EconomicsAmountMax = 1000000d; /// A deposit that moved the balance by less than this moved nothing: it rounds to two places. private const double EconomicsMovedMin = 0.005; /// How long a payment's key and reply are kept: seven days, the tally ceiling (D108). private const long PaymentMemoryMs = TallyMaxHoldMs; /// The longest payment key. The site's are short (runId:stepIndex). private const int PaymentKeyMax = 128; /// The lowest individual Steam id. Anything below it is not a player. private const ulong SteamIdBase = 76561197960265728UL; private const string EconomicsPay = "pay"; private const string EconomicsDeposit = "deposit"; private const string EconomicsWithdraw = "withdraw"; private const string EconomicsSet = "set"; /// /// One payment (economics.pay) or one staff adjustment (economics.adjust), /// kept with what it did so a repeat of its key is answered the same. Persisted as it is. /// private sealed class EconomicsPayment { public string Key; /// pay, or the adjustment's deposit, withdraw or set. public string Op; public long AtMs; public double Amount; /// A payment's message, {amount} not yet filled in. Blank tells nobody. public string Message; public string Delivery; public List Paid = new List(); public List Failed = new List(); } private sealed class EconomicsPaid { public string SteamId; public double Before; public double After; /// Whether the payment's message has been said to this player (always, when there is none). public bool Told; } private sealed class EconomicsFailed { public string SteamId; public string Reason; } /// What Economics' own config says, read from its file because its API does not. private sealed class EconomicsSettings { public double? StartingBalance; public bool? WipesWithMap; } /// Payments and adjustments of the last seven days, by . private Dictionary _payments = new Dictionary(StringComparer.Ordinal); private EconomicsSettings _economicsSettings; private DateTime _economicsSettingsAt; private Plugin EconomicsPlugin() { return plugins.Find("Economics"); } private bool IsEconomics(Plugin plugin) { return plugin != null && plugin != this && plugin.Name == "Economics"; } /// /// Hello's integrations.economics: loaded and version like every optional mod, and /// the two settings the site needs from Economics' config. Each is absent when the file /// does not say, and the site then assumes Economics' defaults. /// private Dictionary EconomicsBody() { Plugin eco = EconomicsPlugin(); Dictionary body = IntegrationBody(eco); if (eco == null) return body; EconomicsSettings settings = EconomicsConfig(); if (settings != null && settings.StartingBalance.HasValue) body["startingBalance"] = settings.StartingBalance.Value; if (settings != null && settings.WipesWithMap.HasValue) body["wipesWithMap"] = settings.WipesWithMap.Value; return body; } /// /// Economics' config, read again only when the file changes: every status frame asks. /// A file that will not parse is said once per version of it, and answers nothing. On /// Carbon the file is under carbon/configs, so the directory comes from the /// framework (R19), as Kits' does. /// private EconomicsSettings EconomicsConfig() { string path = Path.Combine(Interface.Oxide.ConfigDirectory, "Economics.json"); DateTime at; try { if (!File.Exists(path)) return null; at = File.GetLastWriteTimeUtc(path); } catch (Exception) { return null; } if (_economicsSettings != null && at == _economicsSettingsAt) return _economicsSettings; var settings = new EconomicsSettings(); try { var config = Newtonsoft.Json.Linq.JObject.Parse(File.ReadAllText(path)); Newtonsoft.Json.Linq.JToken start = config["Starting account balance (0 or higher)"]; Newtonsoft.Json.Linq.JToken wipe = config["Wipe balances on new save file"]; if (start != null && (start.Type == Newtonsoft.Json.Linq.JTokenType.Integer || start.Type == Newtonsoft.Json.Linq.JTokenType.Float)) settings.StartingBalance = (double)start; if (wipe != null && wipe.Type == Newtonsoft.Json.Linq.JTokenType.Boolean) settings.WipesWithMap = (bool)wipe; } catch (Exception ex) { Puts($"could not read Economics' config: {Flatten(ex.Message)}"); } _economicsSettings = settings; _economicsSettingsAt = at; return settings; } /// Main thread. A balance to two places, or null when Economics did not answer with one. private static double? ReadBalance(Plugin eco, string steamId) { object value = eco.Call("Balance", steamId); return value is double ? (double?)Math.Round((double)value, 2) : null; } /// A Steam id as Economics should see it, or false. Economics checks none itself. private static bool TryParseSteamId(string raw, out ulong id) { return ulong.TryParse(raw, System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out id) && id >= SteamIdBase && raw.Length == 17; } private static string Coins(double amount) { return amount.ToString("#,0.##", System.Globalization.CultureInfo.InvariantCulture); } private static bool AmountIn(double amount, double min) { return !double.IsNaN(amount) && !double.IsInfinity(amount) && amount >= min && amount <= EconomicsAmountMax; } /// /// Where a payment or an adjustment is remembered. The two keep apart, so a site's step /// key can never be answered with a staff adjustment's reply; the three adjustments share /// one, so a key is one adjustment whatever its op. /// private static string PaymentSlot(string op, string key) { return (op == EconomicsPay ? "pay|" : "adjust|") + key; } /// Main thread. One player's balance into their tally, from their connect or Economics loading. private void NoteBalance(BasePlayer player) { Plugin eco = EconomicsPlugin(); if (eco == null || player == null) return; double? balance = ReadBalance(eco, SteamIdOf(player)); if (!balance.HasValue) return; Tally tally = TallyFor(player); tally.Balance = balance.Value; tally.HasBalance = true; } private void NoteOnlineBalances() { foreach (BasePlayer player in BasePlayer.activePlayerList) NoteBalance(player); } /// /// Every balance change, deposits and withdrawals included, raised before their own hooks. /// Folded into the tally as a value (rule 2: aggregates only). The player need not be /// online: a payment or a staff adjustment reaches an offline player, and their frame /// then has no name. /// private void OnEconomicsBalanceUpdated(string playerId, double balance) { MarkHook("OnEconomicsBalanceUpdated"); ulong id; if (!TryParseSteamId(playerId, out id)) return; // Named only while connected: a sleeper is found too, and a name on the frame is what // tells the site a player was here (it moves their last-seen). A balance is not play. BasePlayer player = BasePlayer.FindByID(id); Tally tally = player != null && player.IsConnected ? TallyFor(player) : TallyForId(id); tally.Balance = Math.Round(balance, 2); tally.HasBalance = true; } /// /// Raised with no arguments when a new save wipes the balances (Economics' own setting), /// and with the caller after ecowipe. One method taking an IPlayer binds /// both on both frameworks, with null for the first (§11.2.1). A balance not yet flushed /// is from before the wipe, so it is dropped. /// private void OnEconomicsDataWiped(Oxide.Core.Libraries.Covalence.IPlayer player) { MarkHook("OnEconomicsDataWiped"); foreach (Tally tally in _tallies.Values) tally.HasBalance = false; Dictionary frame = Frame("economics.wiped", "event"); // A new save's wipe opens a new map, whose balances the site has not seen yet; the // last wipe's are a record of how it ended. `ecowipe` clears the wipe in play. frame["newSave"] = player == null; EmitFrame(frame); Puts($"Economics wiped every balance ({(player == null ? "new save" : "ecowipe")})"); } /// /// Main thread. Coins from an event step to a list of players, kept once paid (D335). One /// call for the whole list. A key already paid is answered from its first reply with /// repeat: true, and pays nobody. /// private void HandleEconomicsPay(Dictionary cmd, string reqId) { Plugin eco = EconomicsPlugin(); if (eco == null) { EmitEconomicsError(reqId, "economics-missing", "Economics is not installed on this server", null); return; } if (_config == null || !_config.EventsEnabled) { EmitEconomicsError(reqId, "events-disabled", "events are switched off on this server — set EventsEnabled in the RunicGateway plugin config", null); return; } string key = StringField(cmd, "key"); if (string.IsNullOrEmpty(key) || key.Length > PaymentKeyMax) { EmitEconomicsError(reqId, "malformed", "a payment has a key", null); return; } ForgetOldPayments(); EconomicsPayment done; if (_payments.TryGetValue(PaymentSlot(EconomicsPay, key), out done)) { EmitPaid(done, reqId, true); return; } double amount = DoubleField(cmd, "amount") ?? double.NaN; if (!AmountIn(amount, EconomicsAmountMin)) { EmitEconomicsError(reqId, "bad-amount", $"a payment is {Coins(EconomicsAmountMin)} to {Coins(EconomicsAmountMax)} coins", null); return; } var payment = new EconomicsPayment { Key = key, Op = EconomicsPay, AtMs = NowMs(), Amount = amount, Message = Flatten(StringField(cmd, "message") ?? "").Trim(), Delivery = StringField(cmd, "delivery") == DeliveryPopup ? DeliveryPopup : DeliveryChat }; var ids = new List(); var seen = new HashSet(StringComparer.Ordinal); foreach (string raw in Strings(cmd, "steamIds")) { ulong id; if (!seen.Add(raw)) continue; if (TryParseSteamId(raw, out id)) ids.Add(raw); else payment.Failed.Add(new EconomicsFailed { SteamId = raw, Reason = "bad-id" }); } if (seen.Count > MaxRecipients()) { EmitEconomicsError(reqId, "too-large", $"this server pays at most {MaxRecipients()} players in one step, not {seen.Count}", null); return; } foreach (string id in ids) { double? before = ReadBalance(eco, id); object ok = eco.Call("Deposit", id, amount); double? after = ReadBalance(eco, id); if (!(ok is bool) || !(bool)ok) { payment.Failed.Add(new EconomicsFailed { SteamId = id, Reason = "refused" }); continue; } if (!before.HasValue || !after.HasValue) { // Deposited, but not measured. Listed as failed so nobody is told an amount, // and remembered with the key so a retry still pays nothing more. payment.Failed.Add(new EconomicsFailed { SteamId = id, Reason = "unmeasured" }); continue; } if (after.Value - before.Value < EconomicsMovedMin) { payment.Failed.Add(new EconomicsFailed { SteamId = id, Reason = "at-limit" }); continue; } var paid = new EconomicsPaid { SteamId = id, Before = before.Value, After = after.Value, Told = payment.Message.Length == 0 }; payment.Paid.Add(paid); BasePlayer awake = AwakePlayer(id); if (awake != null && !paid.Told) TellPaid(awake, payment, paid); } _payments[PaymentSlot(EconomicsPay, key)] = payment; SavePayments(); _lastRewardSummary = $"paid {Coins(amount)} coins to {payment.Paid.Count} player(s), {payment.Failed.Count} failed"; EmitPaid(payment, reqId, false); } /// Main thread. Of the given players, each one's balance, for the account and admin pages. private void HandleEconomicsBalances(Dictionary cmd, string reqId) { Plugin eco = EconomicsPlugin(); if (eco == null) { EmitEconomicsError(reqId, "economics-missing", "Economics is not installed on this server", null); return; } var ids = new List(); var seen = new HashSet(StringComparer.Ordinal); foreach (string raw in Strings(cmd, "steamIds")) { ulong id; if (TryParseSteamId(raw, out id) && seen.Add(raw)) ids.Add(raw); } if (ids.Count > PermMaxOps) { EmitEconomicsError(reqId, "too-large", $"at most {PermMaxOps} players in one question, not {ids.Count}", null); return; } var rows = new List(); foreach (string id in ids) { double? balance = ReadBalance(eco, id); if (balance.HasValue) rows.Add(new Dictionary { { "steamId", id }, { "balance", balance.Value } }); } Dictionary reply = Frame("economics.balances", "reply"); reply["balances"] = rows; // A balance at the starting value may be an account Economics has never seen (§11.2.1). EconomicsSettings settings = EconomicsConfig(); if (settings != null && settings.StartingBalance.HasValue) reply["startingBalance"] = settings.StartingBalance.Value; if (reqId != null) reply["reqId"] = reqId; EmitFrame(reply); } /// /// Main thread. Staff: deposit, withdraw or set one player's balance. A withdrawal above the /// balance is refused with the balance, because Economics takes all or nothing. /// private void HandleEconomicsAdjust(Dictionary cmd, string reqId) { Plugin eco = EconomicsPlugin(); if (eco == null) { EmitEconomicsError(reqId, "economics-missing", "Economics is not installed on this server", null); return; } string key = StringField(cmd, "key"); if (string.IsNullOrEmpty(key) || key.Length > PaymentKeyMax) { EmitEconomicsError(reqId, "malformed", "an adjustment has a key", null); return; } string op = StringField(cmd, "op"); string method = op == EconomicsDeposit ? "Deposit" : op == EconomicsWithdraw ? "Withdraw" : op == EconomicsSet ? "SetBalance" : null; if (method == null) { EmitEconomicsError(reqId, "malformed", "an adjustment deposits, withdraws or sets", null); return; } ForgetOldPayments(); EconomicsPayment done; if (_payments.TryGetValue(PaymentSlot(op, key), out done)) { EmitAdjusted(done, reqId, true); return; } string steamId = StringField(cmd, "steamId"); ulong parsed; if (!TryParseSteamId(steamId, out parsed)) { EmitEconomicsError(reqId, "malformed", "an adjustment names one player by Steam id", null); return; } double amount = DoubleField(cmd, "amount") ?? double.NaN; if (!AmountIn(amount, op == EconomicsSet ? 0d : EconomicsAmountMin)) { EmitEconomicsError(reqId, "bad-amount", $"an adjustment is {(op == EconomicsSet ? "0" : Coins(EconomicsAmountMin))} to {Coins(EconomicsAmountMax)} coins", null); return; } double? before = ReadBalance(eco, steamId); object ok = eco.Call(method, steamId, amount); double? after = ReadBalance(eco, steamId); if (!(ok is bool) || !(bool)ok) { if (op == EconomicsWithdraw && before.HasValue && amount > before.Value) EmitEconomicsError(reqId, "insufficient", $"this player has only {Coins(before.Value)} coins", before); else EmitEconomicsError(reqId, "refused", $"Economics refused to {op} {Coins(amount)} coins", before); return; } if (!before.HasValue || !after.HasValue) { EmitEconomicsError(reqId, "refused", "Economics did not report this player's balance", null); return; } if (op == EconomicsDeposit && after.Value - before.Value < EconomicsMovedMin) { EmitEconomicsError(reqId, "at-limit", "this player's balance is at Economics' limit", before); return; } var adjustment = new EconomicsPayment { Key = key, Op = op, AtMs = NowMs(), Amount = amount }; adjustment.Paid.Add(new EconomicsPaid { SteamId = steamId, Before = before.Value, After = after.Value, Told = true }); _payments[PaymentSlot(op, key)] = adjustment; SavePayments(); _lastRewardSummary = $"{op} {Coins(amount)} coins for {steamId}: {Coins(before.Value)} → {Coins(after.Value)}"; EmitAdjusted(adjustment, reqId, false); } private void EmitPaid(EconomicsPayment payment, string reqId, bool repeat) { var paid = new List(); var failed = new List(); foreach (EconomicsPaid row in payment.Paid) paid.Add(new Dictionary { { "steamId", row.SteamId }, { "before", row.Before }, { "after", row.After } }); foreach (EconomicsFailed row in payment.Failed) failed.Add(new Dictionary { { "steamId", row.SteamId }, { "reason", row.Reason } }); Dictionary reply = Frame("economics.paid", "reply"); reply["key"] = payment.Key; reply["repeat"] = repeat; reply["amount"] = payment.Amount; reply["paid"] = paid; reply["failed"] = failed; if (reqId != null) reply["reqId"] = reqId; EmitFrame(reply); } private void EmitAdjusted(EconomicsPayment adjustment, string reqId, bool repeat) { EconomicsPaid row = adjustment.Paid.Count > 0 ? adjustment.Paid[0] : null; Dictionary reply = Frame("economics.adjusted", "reply"); reply["key"] = adjustment.Key; reply["repeat"] = repeat; reply["op"] = adjustment.Op; reply["amount"] = adjustment.Amount; if (row != null) { reply["steamId"] = row.SteamId; reply["before"] = row.Before; reply["after"] = row.After; } if (reqId != null) reply["reqId"] = reqId; EmitFrame(reply); } private void EmitEconomicsError(string reqId, string reason, string message, double? balance) { Dictionary frame = Frame("economics.error", "reply"); frame["reason"] = reason; frame["message"] = message; if (balance.HasValue) frame["balance"] = balance.Value; if (reqId != null) frame["reqId"] = reqId; _lastRewardSummary = $"refused ({reason}): {message}"; EmitFrame(frame); } /// Main thread. Says a payment's message to one player, with what they received, and marks it told. private void TellPaid(BasePlayer player, EconomicsPayment payment, EconomicsPaid row) { string line = payment.Message.Replace("{amount}", Coins(row.After - row.Before)); if (line.Length > MaxChat()) line = line.Substring(0, MaxChat()); SayTo(player, line, payment.Delivery); row.Told = true; } /// Main thread, as a player wakes. Every payment they have not been told of yet. private void TellPayments(BasePlayer player) { if (_payments.Count == 0) return; string steamId = SteamIdOf(player); bool changed = false; foreach (EconomicsPayment payment in _payments.Values) { if (payment.Op != EconomicsPay || string.IsNullOrEmpty(payment.Message)) continue; foreach (EconomicsPaid row in payment.Paid) { if (row.SteamId == steamId && !row.Told) { TellPaid(player, payment, row); changed = true; } } } if (changed) SavePayments(); } /// Forgets payments seven days old, with any message nobody woke up to hear. True when any went. private bool ForgetOldPayments() { long now = NowMs(); var stale = new List(); foreach (KeyValuePair kv in _payments) { if (now >= kv.Value.AtMs + PaymentMemoryMs) stale.Add(kv.Key); } foreach (string slot in stale) _payments.Remove(slot); if (stale.Count > 0) SavePayments(); return stale.Count > 0; } private string PaymentsPath() { return Path.Combine(Interface.Oxide.DataDirectory, Name, "payments.json"); } private void SavePayments() { WriteDataFile(PaymentsPath(), new List(_payments.Values), "Economics payments"); } private void LoadPayments() { _payments = new Dictionary(StringComparer.Ordinal); List payments = ReadDataFile(PaymentsPath(), "Economics payments"); if (payments == null) return; foreach (EconomicsPayment p in payments) { if (p == null || string.IsNullOrEmpty(p.Key) || string.IsNullOrEmpty(p.Op)) continue; if (p.Paid == null) p.Paid = new List(); if (p.Failed == null) p.Failed = new List(); if (p.Message == null) p.Message = ""; _payments[PaymentSlot(p.Op, p.Key)] = p; } ForgetOldPayments(); } // ── Backpacks (protocol 14, PROTOCOL.md §20.4; the module's PLAN_REDESIGNS.md §11.3) ──── // // WhiteThunder's Backpacks, optional like the other mods (D199). **Read, never write (R2):** // `backpacks.read` is the only verb, and nothing here calls a Backpacks method that changes // a backpack or a capacity. The extra space an event gives is a `backpacks.size.N` // permission the site leases through `perm.sync` like a kit reward (D337), so it needs no // code here at all. What the spike found (§11.3.1) shapes the read: // // * `API_ReadBackpackContentsAsJson` answers Backpacks' own item data — item ids, slots // that run across pages, nested contents — and null for a player who never had one; // * a capacity read while the backpack is not loaded is cached by Backpacks and pins it // until the player reconnects, so the capacity is read only after the contents read // has loaded the backpack. private const string BackpacksSizePrefix = "backpacks.size."; /// Backpacks' own bound on a page, and its default "Max size per page". private const int BackpacksPageMax = 48; private int _backpacksPageSize = BackpacksPageMax; private DateTime _backpacksConfigAt; private Plugin BackpacksPlugin() { return plugins.Find("Backpacks"); } private bool IsBackpacks(Plugin plugin) { return plugin != null && plugin != this && plugin.Name == "Backpacks"; } /// /// Hello's integrations.backpacks: loaded and version like every optional mod, and /// sizes, the N of every registered backpacks.size.N, ascending. They are /// the operator's own Permission sizes, and what the site may lease (D337). /// private Dictionary BackpacksBody() { Plugin backpacks = BackpacksPlugin(); Dictionary body = IntegrationBody(backpacks); if (backpacks != null) body["sizes"] = BackpackSizes(); return body; } private List BackpackSizes() { var sizes = new SortedSet(); foreach (string name in permission.GetPermissions() ?? new string[0]) { if (name == null || !name.StartsWith(BackpacksSizePrefix, StringComparison.OrdinalIgnoreCase)) continue; int n; // `backpacks.size.profile.6-48` is a dynamic-size profile, not a size. if (int.TryParse(name.Substring(BackpacksSizePrefix.Length), System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out n) && n > 0) sizes.Add(n); } return new List(sizes); } /// /// Backpacks' "Max size per page" from its config, read again only when the file /// changes; 48, its default, when the file does not say. The directory comes from the /// framework, as Economics' does (R19). /// private int BackpacksPageSize() { string path = Path.Combine(Interface.Oxide.ConfigDirectory, "Backpacks.json"); DateTime at; try { if (!File.Exists(path)) return BackpacksPageMax; at = File.GetLastWriteTimeUtc(path); } catch (Exception) { return BackpacksPageMax; } if (at == _backpacksConfigAt) return _backpacksPageSize; int size = BackpacksPageMax; try { var config = Newtonsoft.Json.Linq.JObject.Parse(File.ReadAllText(path)); var section = config["Backpack size"] as Newtonsoft.Json.Linq.JObject; Newtonsoft.Json.Linq.JToken page = section != null ? section["Max size per page"] : null; // Clamped as Backpacks clamps it. if (page != null && page.Type == Newtonsoft.Json.Linq.JTokenType.Integer) size = Math.Max(1, Math.Min(BackpacksPageMax, (int)page)); } catch (Exception ex) { Puts($"could not read Backpacks' config: {Flatten(ex.Message)}"); } _backpacksPageSize = size; _backpacksConfigAt = at; return size; } /// /// Main thread. One player's backpack, for a staff view or the player's own page. Live: /// nothing is kept here, and the site stores nothing either. /// private void HandleBackpacksRead(Dictionary cmd, string reqId) { Plugin backpacks = BackpacksPlugin(); if (backpacks == null) { EmitBackpacksError(reqId, "backpacks-missing", "Backpacks is not installed on this server"); return; } string raw = StringField(cmd, "steamId"); ulong id; if (raw == null || !TryParseSteamId(raw, out id)) { EmitBackpacksError(reqId, "bad-id", $"'{Flatten(raw ?? "")}' is not a Steam id"); return; } Dictionary reply = Frame("backpacks.contents", "reply"); reply["steamId"] = raw; // Loads an offline player's backpack from disk; null when they have never had one. string json = backpacks.Call("API_ReadBackpackContentsAsJson", id) as string; if (json == null) { reply["exists"] = false; } else { List> items; try { items = BackpackStacks(Newtonsoft.Json.Linq.JArray.Parse(json), true); } catch (Exception ex) { EmitBackpacksError(reqId, "unreadable", $"Backpacks answered contents this bridge could not read: {Flatten(ex.Message)}"); return; } // Only now, through the backpack the read just loaded, so the capacity follows a // size granted later (§11.3.1's trap). int capacity = CallInt(backpacks, "API_GetBackpackCapacityById", id, raw); // Past the capacity is what waits for the owner's next open to be moved (D338). foreach (Dictionary stack in items) { if ((int)stack["slot"] >= capacity) stack["overflow"] = true; } reply["exists"] = true; reply["capacity"] = capacity; reply["pageSize"] = BackpacksPageSize(); reply["items"] = items; } if (reqId != null) reply["reqId"] = reqId; EmitFrame(reply); } /// /// Backpacks' item data reduced to what a grid shows, by slot. /// keeps one level of contents — a weapon's attachments, a container's items — /// and no deeper. /// private static List> BackpackStacks(Newtonsoft.Json.Linq.JArray data, bool nest) { var stacks = new List>(); foreach (Newtonsoft.Json.Linq.JToken token in data) { var item = token as Newtonsoft.Json.Linq.JObject; if (item != null) stacks.Add(BackpackStack(item, nest)); } stacks.Sort((a, b) => ((int)a["slot"]).CompareTo((int)b["slot"])); return stacks; } /// /// One stack: slot, shortname, the game's English name (D340, so the site needs no item /// catalogue), amount, and skin and name when set. An id no item definition knows is sent /// with a null shortname and its itemId. A blueprint names what it is for (D341): /// blueprint is the target's shortname and display the target's name. /// private static Dictionary BackpackStack(Newtonsoft.Json.Linq.JObject item, bool nest) { int itemId = JsonInt(item["ID"]); ItemDefinition def = ItemManager.FindItemDefinition(itemId); var stack = new Dictionary { { "slot", JsonInt(item["Position"]) }, { "shortname", def != null ? def.shortname : null }, { "amount", JsonInt(item["Amount"]) } }; if (def == null) stack["itemId"] = itemId; else stack["display"] = def.displayName != null ? def.displayName.english : def.shortname; Newtonsoft.Json.Linq.JToken blueprint = item["IsBlueprint"]; if (blueprint != null && blueprint.Type == Newtonsoft.Json.Linq.JTokenType.Boolean && (bool)blueprint) { ItemDefinition target = ItemManager.FindItemDefinition(JsonInt(item["BlueprintTarget"])); if (target != null) { stack["blueprint"] = target.shortname; stack["display"] = target.displayName != null ? target.displayName.english : target.shortname; } } Newtonsoft.Json.Linq.JToken skin = item["Skin"]; if (skin != null && skin.Type == Newtonsoft.Json.Linq.JTokenType.Integer && (ulong)skin != 0UL) stack["skin"] = (ulong)skin; Newtonsoft.Json.Linq.JToken name = item["Name"]; if (name != null && name.Type == Newtonsoft.Json.Linq.JTokenType.String && ((string)name).Length > 0) stack["name"] = (string)name; var contents = item["Contents"] as Newtonsoft.Json.Linq.JArray; if (nest && contents != null && contents.Count > 0) stack["contents"] = BackpackStacks(contents, false); return stack; } private static int JsonInt(Newtonsoft.Json.Linq.JToken token) { return token != null && token.Type == Newtonsoft.Json.Linq.JTokenType.Integer ? (int)token : 0; } private void EmitBackpacksError(string reqId, string reason, string message) { Dictionary frame = Frame("backpacks.error", "reply"); frame["reason"] = reason; frame["message"] = message; if (reqId != null) frame["reqId"] = reqId; EmitFrame(frame); } // ── Chat ────────────────────────────────────────────────────────────────── /// /// Main thread. One line to everybody on the server. A key said in the last ten minutes /// is answered ok and not said again, so a retried step never repeats itself. /// A run's line is behind EventsEnabled; a news post is behind the site's own /// switch instead (D104), because news is not an event. /// private void HandleChatSay(Dictionary 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(); foreach (KeyValuePair 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 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"; /// The most titles one set carries. The site has its own, lower, limit. private const int TitlesMax = 500; /// /// 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. /// private const int TitleTextMax = 1024; /// The longest voice format a chat line may carry (§33.2). private const int ChatFormatMax = 512; /// /// 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 (titles.set). /// /// /// Kept on disk as well (D144). The site re-sends on a new boot id or wipe id, and /// a hot reload of this plugin — an update, an oxide.reload — 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. /// /// /// /// Swapped, never edited. BetterChat reads this through 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. /// /// private volatile Dictionary _titles = new Dictionary(); /// The getter BetterChat holds, kept so every registration hands it the same delegate. private Func _titleGetter; /// /// 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 registers again (§33.1). /// private bool _titlesRegistered; /// The last title set, for rg.titles. Diagnostics only. private string _lastTitlesSummary = "none yet"; /// /// The twelve fields of a BetterChat group (D138), by the name its API takes, with /// BetterChat 5.2.15's default for each. Title is absent: its default is the /// group's own name in brackets (). /// private static readonly Dictionary ChatFieldDefaults = new Dictionary(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"); } /// /// BetterChat's third-party title getter. It runs on the chat path, 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. /// 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; } /// Main thread. Hands BetterChat our title getter, if BetterChat is here. 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 ` 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}"); } /// /// 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. /// private void HandleTitlesSet(Dictionary 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(); 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 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); } /// What titles.json holds: the set, and the wipe it was pushed on. private sealed class TitleFile { public string WipeId; public Dictionary Titles = new Dictionary(); } private string TitlesPath() { return Path.Combine(Interface.Oxide.DataDirectory, Name, "titles.json"); } /// Main thread. Written on every set the site pushes — a set arrives only when it changed. private void SaveTitles() { var file = new TitleFile { WipeId = _wipeId }; foreach (KeyValuePair kv in _titles) file.Titles[kv.Key.ToString()] = kv.Value; WriteDataFile(TitlesPath(), file, "titles"); } /// /// Main thread, from OnServerInitialized — 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. /// private void LoadTitles() { try { string path = TitlesPath(); if (!File.Exists(path)) return; var file = JsonConvert.DeserializeObject(File.ReadAllText(path)); if (file == null || file.Titles == null || string.IsNullOrEmpty(_wipeId) || file.WipeId != _wipeId) return; var loaded = new Dictionary(); foreach (KeyValuePair 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 frame = Frame("titles.error", "reply"); frame["reason"] = reason; frame["message"] = message; if (reqId != null) frame["reqId"] = reqId; _lastTitlesSummary = $"refused ({reason}): {message}"; EmitFrame(frame); } /// A JSON string or number as text, or null for anything else. 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; } } /// /// 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. /// 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 IntegrationsBody() { return new Dictionary { { "betterChat", IntegrationBody(BetterChatPlugin()) }, { "popupNotifications", IntegrationBody(PopupPlugin()) }, { "zoneDomes", ZoneDomesBody() }, { "runicNpc", RunicNpcBody() }, { "economics", EconomicsBody() }, { "backpacks", BackpacksBody() }, // Protocol 14 (§14.3.2): whether a zone step may change PvP rules here. { "truePve", IntegrationBody(TruePvePlugin()) }, // Protocol 14 (§14.3.3): whether the map's pvp layer and the feed's zone lines can come. { "dynamicPvp", IntegrationBody(DynamicPvpPlugin()) }, // Protocol 14 (§14.3.4): whether a step may open a Dangerous Treasures event here. { "dangerousTreasures", IntegrationBody(DangerousTreasuresPlugin()) }, // Protocol 14 (§14.3.5): whether a step may place a guarded crate here, and which. { "guardedCrate", GuardedCrateBody() } }; } /// /// 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. /// private Dictionary ZoneDomesBody() { Dictionary body = IntegrationBody(ZoneDomesPlugin()); var helper = new Dictionary { { "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; } /// A version only for a plugin that is loaded. Absent, not empty. private static Dictionary IntegrationBody(Plugin plugin) { var body = new Dictionary { { "loaded", plugin != null } }; if (plugin != null) body["version"] = plugin.Version.ToString(); return body; } // ---- group styles (D138, D139, D143) ---- /// /// 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. /// private List ChatStyle(PermSyncJob job, string group, Dictionary row) { var style = new List(); 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 { { "group", group }, { "field", field.Name }, { "reason", "malformed" } }); continue; } style.Add(new ChatFieldWant { Field = field.Name, Value = value, Expect = JsonText(want["expect"]) }); } return style; } /// /// 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. /// /// /// 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. /// /// /// /// The save is the part the API does not do. API_SetGroupField 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 chat group set does write /// it, and writes every group, so it is run once per group touched with a value that /// has not changed (D143). /// /// 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 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 { { "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"); } /// /// Main thread. Removes a BetterChat group the site no longer styles (D139). There is /// no API for it, only BetterChat's own chat group remove, run as the server /// console. A group already gone is removed: the retirement is idempotent. /// 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 { { "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 ChatGroupFields(Plugin betterChat, string group) { return betterChat.Call("API_GetGroupFields", group) as Dictionary ?? new Dictionary(); } /// /// A field's value as the text the site compares: true/false for a /// boolean, never C#'s True, and an invariant number. /// private static string ChatFieldValue(Dictionary fields, string field) { object value = null; foreach (KeyValuePair 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(); } /// /// BetterChat's default for a field on a new group. A group's title defaults to its own /// name in brackets, except default's, which is [Player]. /// 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; } /// /// 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). /// [ConsoleCommand("rg.titles")] private void CmdTitles(ConsoleSystem.Arg arg) { Plugin betterChat = BetterChatPlugin(); var lines = new List { $"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())); } /// What events are counting and what rewards are held, from the server console. [ConsoleCommand("rg.rewards")] private void CmdRewards(ConsoleSystem.Arg arg) { var lines = new List { $"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. /// /// 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). /// private const int MapChunkBytes = 512 * 1024; /// /// 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). /// private const float MapRenderScale = 0.5f; private const int MapOceanMargin = 500; /// The default most sleepers, and the most bases, one live answer carries. private const int MapDefaultMax = 2000; private const string MapSourceCompanion = "companion"; private const string MapSourceRendered = "rendered"; private const string MapSourceNone = "none"; /// What this plugin knows about the picture it can serve. Replaced whole, never edited. private sealed class MapPicture { public string MapKey; public string Source; public byte[] Bytes; public int Width; public int Height; public string Background; /// Null until the thread pool has hashed . public volatile string Sha256; } /// A rendered picture's companion file: which map it is a picture of. private sealed class RenderedMapMeta { public string MapKey; public string Background; public long RenderedMs; public long TookMs; } /// The current picture, or null. Main thread writes; the pool only reads what it is handed. private MapPicture _mapPicture; /// A picture this plugin rendered for the current map, read from disk at most once per map. private MapPicture _mapRendered; private string _mapRenderedCheckedFor; /// Set between a `map.render` being accepted and the render finishing. private bool _mapRendering; private readonly HashSet _mapWorld = new HashSet(); private readonly HashSet _mapBases = new HashSet(); 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; } /// /// 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). /// 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(); } } /// /// 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. /// 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; /// /// Main thread. The Rust+ cache's picture, or null. Read by reflection, by NAME, and only /// the two members this needs: CompanionServer 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 none — never an exception. /// 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); } /// /// 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). /// 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(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; } /// /// 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. /// 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. } }); } /// /// Main thread. The game's own grid, from its own function: how many cells a side has and /// how wide one is. MapHelper 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). /// 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; } /// /// Main thread. The monuments the in-game map shows: those with a display phrase, in the /// same kind#n vocabulary as world.monuments, so a monument a step names and /// a monument on the map are the same token. /// private static List MapMonuments() { var rows = new List(); foreach (KeyValuePair> 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 { { "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; } /// /// Stage one: what exists. Reads the cache, and hashes it on the pool once per map; no /// other work. sha256 is absent while the hash is still being computed, and the /// answer then says hashing so the caller asks again. /// private void HandleMapInfo(string reqId) { if (!MapReady(reqId)) return; MapPicture picture = CurrentPicture(); int cells; float cellSize; GameGrid(out cells, out cellSize); Dictionary 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); } /// /// Stage two: one slice of the picture. Refused stale 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. /// private void HandleMapFetch(Dictionary 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 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)}")); } }); } /// /// 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 /// accepted 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 /// map.info until its source is rendered. /// private void HandleMapRender(Dictionary 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 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)); } /// Main thread, deliberately: the renderer reads the terrain, which is main-thread-only. 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; } } /// Refuses a map command until the world is loaded, as a map.error. 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 ──────────────────────────────────────────────────────────── /// The world-event types the map follows (D112). A cheap type test: this runs on every spawn. 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; } /// Tool cupboards, and a player's vending machine — never a monument's (NPC) one. private static bool IsMapBase(BaseNetworkable entity) { return entity is BuildingPrivlidge || (entity is VendingMachine && !(entity is NPCVendingMachine)); } /// /// 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. /// 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); // Protocol 14: the world events a spawn says (§20.5). Its own type tests, all cheap. WorldEventSpawned(entity); } private void OnEntityKill(BaseNetworkable entity) { MarkHook("OnEntityKill"); var e = entity as BaseEntity; if (e == null) return; if (!_mapWorld.Remove(e)) _mapBases.Remove(e); WorldEventKilled(entity); } /// /// 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. /// 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(); } /// /// A player's Steam id as text, from userID itself. UserIDString 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. /// /// /// 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. /// 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; } /// /// 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. /// private Dictionary> RunicNpcsForMap() { var byId = new Dictionary>(); Plugin npc = RunicNpcPlugin(); if (npc == null) return byId; var listed = npc.Call("RunicNpc_List", (string)null) as List>; if (listed == null) return byId; foreach (Dictionary d in listed) { object id; if (d != null && d.TryGetValue("netId", out id) && id != null) byId[id.ToString()] = d; } return byId; } /// /// 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). /// private static void MapNpcFields(Dictionary row, Dictionary 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); } } /// Where one of RunicNPC's NPCs stands, from its `RunicNpc_List` row. private static Dictionary MapNpcAt(Dictionary npc) { object value; var p = npc.TryGetValue("position", out value) ? value as Dictionary : 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 At(UnityEngine.Vector3 p) { return new Dictionary { { "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"; } /// /// 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 truncated; a read has /// nobody to refuse. /// private void HandleMapLive(string reqId) { if (!MapReady(reqId)) return; var clock = System.Diagnostics.Stopwatch.StartNew(); var world = new List(); var dead = new List(); foreach (BaseEntity e in _mapWorld) { if (Gone(e)) { dead.Add(e); continue; } Dictionary 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(); // 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> runic = RunicNpcsForMap(); foreach (WorldEntry w in _world.Values) { Dictionary 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 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:`) 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 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 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(); foreach (BasePlayer p in BasePlayer.activePlayerList) { if (Gone(p) || p.IsDead()) continue; Dictionary 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 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(); 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 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); // Dangerous Treasures' running chests (§14.3.4): the world's, like a locked crate. AddTreasureRows(world); // Guarded Crate's running events (§14.3.5), likewise. AddGuardedRows(world); Dictionary reply = Frame("map.live", "reply"); reply["mapKey"] = MapKey(); reply["world"] = world; reply["events"] = events; reply["players"] = players; reply["bases"] = bases; reply["pvp"] = PvpZoneRows(); reply["tookMs"] = clock.ElapsedMilliseconds; if (sleepersTruncated) reply["playersTruncated"] = true; if (basesTruncated) reply["basesTruncated"] = true; if (reqId != null) reply["reqId"] = reqId; EmitFrame(reply); } /// The map's one refusal frame. Every reason is a word the module can switch on. private void EmitMapError(string reqId, string reason, string message) { Dictionary 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); } /// What the map can serve and what asking it costs, from the server console. [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 row, string key) { object value; return row != null && row.TryGetValue(key, out value) && value != null ? value.ToString() : null; } private static int IntField(Dictionary 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 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; } /// The objects in an array field, skipping anything that is not one. private static IEnumerable> Rows(Dictionary 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>(); } } /// The strings in an array field. private static IEnumerable Strings(Dictionary 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(); } } /// /// 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. /// private static string Lower(string value) { return value == null ? null : value.Trim().ToLowerInvariant(); } // ---- Rust's own world events (protocol 14, PROTOCOL.md §20.5; PLAN_REDESIGNS §14.3.1) ---- // // The patrol helicopter, Bradley, the cargo ship, the Chinook, airdrops, locked crates, the // Travelling Vendor, the satellite and the Deep Sea, as one `world.event` frame whose // `event` says which (D349, D353). Every hook here is void and never vetoes (rule 1), the // `Can` one included. What the test phase (§14.2.1) and stage 1's spike found shapes all of it: // // * **Nothing is said until the world is loaded.** A booting server spawns the cargo ship // and the vendor it saved, and that is not either of them arriving. // * **A crate is Rust's own only when where it is can be told positively:** an oil rig's or // a ghost ship's prefab, a cargo ship for a parent, or a Chinook seen dropping it. Anything // else is `other` and says nothing — Guarded Crate's crates are ordinary locked crates — // and a crate one of our own steps placed is skipped by the world ledger. // * **A Bradley is Launch Site's when the game's spawner holds it, a road one when the road // event's list does.** `RoadSpawned` cannot say: it defaults to true, so any plugin's // Bradley would read as a road one. A Bradley neither holds is `other`; its spawn is not // news, but its death still counts toward Tankbreaker, as it did under D174. // * **Who downed a helicopter, a Chinook or a Bradley is the site's to decide** (D372, // D379). The plugin keeps a damage table for those three classes and sends every // candidate; it credits nobody itself. // * **The joins are memory only** — plane to thrower, crate to hacker, looted once — keyed // by the entity, dropped when it goes, and swept every ten minutes. Nothing is written // to disk: a reload forgets a crate's hacker, which costs one feed line its name. private const string WorldEventKind = "world.event"; /// How often the joins below are swept of entities the world no longer holds. private const float WorldEventSweepSeconds = 600f; /// The most damagers one credit block names, by damage. A helicopter fight is rarely a crowd. private const int CreditMaxDamagers = 32; /// How long a CanHackCrate may wait for the OnCrateHack it belongs to. private const float HackJoinSeconds = 2f; /// /// How long after a Chinook opens its door the crate under it may land and still be its /// drop. The crate falls from the Chinook's height, which takes well under a minute. /// private const float ChinookDropJoinSeconds = 120f; /// How far, across the ground, a landed crate may be from the Chinook that dropped it. private const float ChinookDropJoinMetres = 40f; /// A Chinook that spawns this soon after an oil rig's hack is that rig's reinforcement. private const float RigReinforcementSeconds = 10f; /// Road Bradleys spawned this close together are one line: the game sends twelve at once. private const float RoadBradleyBatchSeconds = 1f; /// One player, as a world event names them. private sealed class WorldWho { public ulong Id; public string Name; /// Time.realtimeSinceStartup when it was seen, for joins that must be close in time. public float At; } /// /// What hurt one patrol helicopter, Chinook or Bradley. Kept until it dies or the sweep /// finds it gone, which is how a helicopter that flies off the map is forgotten. /// private sealed class VehicleCredit { public readonly Dictionary Damage = new Dictionary(); public readonly Dictionary Names = new Dictionary(); /// The last player to hurt it. public ulong Last; /// /// The killing blow, when the game named one before the death: a patrol helicopter's /// crippling hit (OnPatrolHelicopterKill), which comes seconds before a crash /// that names nobody. /// public ulong Blow; } private readonly Dictionary _vehicleCredit = new Dictionary(); // Airdrops: who threw the signal, by plane (a plane may not have a net id when the game first // names it), then by drop. A schedule's plane has a null caller. private readonly Dictionary _planeCaller = new Dictionary(); private readonly Dictionary _planeDropPoint = new Dictionary(); private readonly HashSet _planeAnnounced = new HashSet(); private readonly Dictionary _dropCaller = new Dictionary(); private readonly HashSet _dropLanded = new HashSet(); private readonly HashSet _dropLooted = new HashSet(); // Locked crates: the last player to try a hack, by crate; the crates whose hack has been // said; those looted; and the crates a Chinook was seen dropping. private readonly Dictionary _crateHacker = new Dictionary(); private readonly HashSet _crateHackSaid = new HashSet(); private readonly HashSet _crateLooted = new HashSet(); private readonly HashSet _chinookCrates = new HashSet(); private readonly List> _chinookDrops = new List>(); /// Where each Bradley came from, decided at its spawn, by net id. private readonly Dictionary _bradleyWhere = new Dictionary(); private int _roadBradleysPending; private Timer _roadBradleysFlush; /// Time.realtimeSinceStartup of the last hack started on an oil rig, or a long time ago. private float _rigHackAt = -1000f; private Timer _worldEventSweep; /// /// Whether a plugin's console command can be run through ConsoleSystem.Run on this /// server (D363). Measured at every load by running this plugin's own rg.probe; /// Carbon 2.0.262 answers "not found" to every plugin command that way (§14.2.1), and the /// steps that drive another plugin through its console command are refused while it does. /// private bool _consolePlugins; /// Set by rg.probe while the probe runs. private bool _consoleProbeHit; private static System.Reflection.FieldInfo _roadBradleysList; private static bool InWorld(BaseNetworkable entity) { return entity != null && !entity.IsDestroyed; } private static WorldWho WhoOf(BasePlayer player) { if (player == null || player.IsNpc) return null; return new WorldWho { Id = (ulong)player.userID, Name = player.displayName, At = UnityEngine.Time.realtimeSinceStartup }; } /// /// Whether world events may be said now: the world is loaded, and the server is not on its /// way down — a shutdown is not the vendor leaving. /// private bool WorldEventsLive() { return _worldReady && !Rust.Application.isQuitting && !Rust.Application.isLoadingSave; } /// World events said since this load, for rg.world. private int _worldEventsSaid; private Dictionary WorldEventFrame(string type, BaseEntity at) { _worldEventsSaid++; // `event`, never `type`: `type` is the envelope's, and the sidecar files on it. The // plan's own example wrote the world event's type there, and the sidecar dropped // every one as untyped (stage 1) — protocol 13's `world.expired` lesson again. Dictionary frame = Frame(WorldEventKind, "event"); frame["event"] = type; if (at != null && at.transform != null) PutPosition(frame, at.transform.position); return frame; } /// The site computes the grid from these, as the map does, so the grid has one source. private static void PutPosition(Dictionary frame, UnityEngine.Vector3 at) { frame["x"] = Math.Round((double)at.x, 1); frame["z"] = Math.Round((double)at.z, 1); } private static void PutWho(Dictionary frame, WorldWho who) { if (who == null) return; frame["steamId"] = who.Id.ToString(System.Globalization.CultureInfo.InvariantCulture); if (!string.IsNullOrEmpty(who.Name)) frame["name"] = who.Name; } /// /// The monument nearest a point whose kind starts with (any, when /// empty), onto the frame as field (its kind, the world-verb vocabulary) and /// fieldName (the game's English name for it). Nothing when the map has none. /// private static void PutMonument(Dictionary frame, string field, UnityEngine.Vector3 at, string prefix) { string kind; MonumentInfo near = NearestMonument(at, prefix, out kind); if (near == null) return; frame[field] = kind; string phrase = near.displayPhrase != null ? near.displayPhrase.english : null; if (!string.IsNullOrEmpty(phrase)) frame[field + "Name"] = phrase; } /// How far apart two points are across the ground, ignoring height. private static float Distance2D(UnityEngine.Vector3 a, UnityEngine.Vector3 b) { float dx = a.x - b.x; float dz = a.z - b.z; return (float)Math.Sqrt(dx * dx + dz * dz); } private static MonumentInfo NearestMonument(UnityEngine.Vector3 at, string prefix, out string kind) { kind = null; MonumentInfo best = null; float bestDistance = float.MaxValue; List all = TerrainMeta.Path != null ? TerrainMeta.Path.Monuments : null; if (all == null) return null; foreach (MonumentInfo m in all) { if (m == null || string.IsNullOrEmpty(m.name)) continue; string k = Path.GetFileNameWithoutExtension(m.name).ToLowerInvariant(); if (!string.IsNullOrEmpty(prefix) && !k.StartsWith(prefix, StringComparison.Ordinal)) continue; float d = Distance2D(m.transform.position, at); if (d < bestDistance) { bestDistance = d; best = m; kind = k; } } return best; } /// Main thread, at every load once the world is there: the console probe, and the sweep. private void StartWorldEvents() { // From a timer, not from here: this hook runs INSIDE a console command when an operator // types `c.reload RunicGateway`, and Carbon 2.0.262 answers "not found" to every plugin // command run from inside one — and from a NextTick queued by one. Run from a timer, or // from a NextTick the link's reader queues (every website command's path), it finds // them (stage 1, 2026-10-15). The probe measures the context the steps run in. timer.Once(1f, RunConsoleProbe); if (_worldEventSweep == null) _worldEventSweep = timer.Every(WorldEventSweepSeconds, SweepWorldEvents); } /// /// D363: can a plugin's console command be run from code here? Our own is the test, /// because it is the one command this plugin knows is registered. Quiet, so a server where /// it is not found does not print the refusal at every load. Hello is said again when the /// answer changed, so the site does not wait a minute for it. /// private void RunConsoleProbe() { _consoleProbeHit = false; try { ConsoleSystem.Run(ConsoleSystem.Option.Server.Quiet(), "rg.probe"); } catch (Exception ex) { Puts($"could not run the console probe: {Flatten(ex.Message)}"); } bool before = _consolePlugins; _consolePlugins = _consoleProbeHit; if (!_consolePlugins) Puts("console probe: this framework does not run a plugin's console command from code; steps that need one are refused (D363)"); if (before != _consolePlugins && _connected) SendHello(); } /// /// The probe's target. Only the server itself may run it; a player typing it in F1 changes /// nothing, because the flag is read only while the probe is running. /// [ConsoleCommand("rg.probe")] private void CmdProbe(ConsoleSystem.Arg arg) { if (arg.Connection != null) return; _consoleProbeHit = true; } /// Hello's and status's console: what the probe found at this load. private Dictionary ConsoleBody() { return new Dictionary { { "plugins", _consolePlugins } }; } /// Every ten minutes: what the world no longer holds is forgotten (bounded state). private void SweepWorldEvents() { SweepIds(_vehicleCredit); SweepIds(_dropCaller); SweepIds(_dropLanded); SweepIds(_dropLooted); SweepIds(_crateHacker); SweepIds(_crateHackSaid); SweepIds(_crateLooted); SweepIds(_chinookCrates); SweepIds(_bradleyWhere); var planes = new List(); foreach (BaseEntity plane in _planeCaller.Keys) if (plane == null || plane.IsDestroyed) planes.Add(plane); foreach (BaseEntity plane in _planeAnnounced) if (plane == null || plane.IsDestroyed) planes.Add(plane); foreach (BaseEntity plane in planes) ForgetPlane(plane); float now = UnityEngine.Time.realtimeSinceStartup; _chinookDrops.RemoveAll(d => now - d.Value > ChinookDropJoinSeconds); } private static void SweepIds(HashSet ids) { ids.RemoveWhere(id => BaseNetworkable.serverEntities.Find(new NetworkableId(id)) == null); } private static void SweepIds(Dictionary byId) { var gone = new List(); foreach (ulong id in byId.Keys) if (BaseNetworkable.serverEntities.Find(new NetworkableId(id)) == null) gone.Add(id); foreach (ulong id in gone) byId.Remove(id); } private void ForgetPlane(BaseEntity plane) { _planeCaller.Remove(plane); _planeDropPoint.Remove(plane); _planeAnnounced.Remove(plane); } /// A wipe: no entity of the old map is coming back, so nothing joined to one is kept. private void ClearWorldEvents() { _vehicleCredit.Clear(); _planeCaller.Clear(); _planeDropPoint.Clear(); _planeAnnounced.Clear(); _dropCaller.Clear(); _dropLanded.Clear(); _dropLooted.Clear(); _crateHacker.Clear(); _crateHackSaid.Clear(); _crateLooted.Clear(); _chinookCrates.Clear(); _chinookDrops.Clear(); _bradleyWhere.Clear(); } /// /// From OnEntitySpawned, which runs for every spawn on the server: the type tests /// come first and are all this costs for anything that is not one of these. /// private void WorldEventSpawned(BaseNetworkable entity) { if (entity is PatrolHelicopter) { if (WorldEventsLive()) EmitFrame(WorldEventFrame("heli.arrived", (BaseEntity)entity)); } else if (entity is CargoShip) { if (WorldEventsLive()) EmitFrame(WorldEventFrame("cargo.arrived", (BaseEntity)entity)); } else if (entity is CH47HelicopterAIController) { if (WorldEventsLive()) ChinookSpawned((CH47HelicopterAIController)entity); } else if (entity is TravellingVendor) { if (WorldEventsLive()) EmitFrame(WorldEventFrame("vendor.arrived", (BaseEntity)entity)); } else if (entity is CargoPlane) { if (WorldEventsLive()) AnnounceAirdropSoon((BaseEntity)entity); } else if (entity is SatelliteCrashRemains) { // The satellite has no hook of its own; its remains appear at the impact (§14.2.1). if (WorldEventsLive()) EmitFrame(WorldEventFrame("satellite.crashed", (BaseEntity)entity)); } } /// From OnEntityKill. The vendor leaving is the only kill that is news. private void WorldEventKilled(BaseNetworkable entity) { if (entity is TravellingVendor) { if (WorldEventsLive()) EmitFrame(WorldEventFrame("vendor.left", (BaseEntity)entity)); } else if (entity is CargoPlane) { ForgetPlane((BaseEntity)entity); } } /// /// A Chinook, a second later: by then an oil rig's reinforcement has its landing target, /// and it is told by the crate hack it answers rather than said as a Chinook arriving. /// private void ChinookSpawned(CH47HelicopterAIController chinook) { timer.Once(1f, () => { if (!InWorld(chinook) || !WorldEventsLive()) return; if (chinook.ShouldLand() || UnityEngine.Time.realtimeSinceStartup - _rigHackAt < RigReinforcementSeconds) return; EmitFrame(WorldEventFrame("chinook.arrived", chinook)); }); } // ── The helicopter, the Chinook and the Bradley: credit (D372, D379) ────────────────── /// Main thread. One hit on one of the three, from OnEntityTakeDamage. private void NoteVehicleHit(BaseCombatEntity entity, BasePlayer player, HitInfo info) { VehicleCredit credit = CreditFor(entity); ulong id = (ulong)player.userID; float damage = info.damageTypes != null ? info.damageTypes.Total() : 0f; float had; credit.Damage.TryGetValue(id, out had); credit.Damage[id] = had + Math.Max(0f, damage); credit.Names[id] = player.displayName; credit.Last = id; } private VehicleCredit CreditFor(BaseEntity entity) { VehicleCredit credit; ulong id = entity.net.ID.Value; if (!_vehicleCredit.TryGetValue(id, out credit)) { credit = new VehicleCredit(); _vehicleCredit[id] = credit; } return credit; } /// /// Fires twice for one helicopter (§14.2.1): at the crippling hit, naming the player, and /// at the crash, naming nobody. The first player it names is the killing blow. /// private void OnPatrolHelicopterKill(PatrolHelicopter heli, HitInfo info) { MarkHook("OnPatrolHelicopterKill"); BasePlayer player = info != null ? info.InitiatorPlayer : null; if (heli == null || heli.net == null || player == null || player.IsNpc) return; VehicleCredit credit = CreditFor(heli); if (credit.Blow == 0UL) { credit.Blow = (ulong)player.userID; credit.Names[credit.Blow] = player.displayName; } } /// /// From OnEntityDeath: a patrol helicopter, a Chinook or a Bradley died. The frame /// carries every candidate for the credit and the site applies the server's rule to the /// line and to Rotorbane / Tankbreaker alike, so the two never disagree (D372). /// private void WorldVehicleDied(BaseCombatEntity entity, BasePlayer attacker) { VehicleCredit credit = null; if (entity.net != null && _vehicleCredit.TryGetValue(entity.net.ID.Value, out credit)) _vehicleCredit.Remove(entity.net.ID.Value); if (!_worldReady) return; Dictionary frame; if (entity is BradleyAPC) { frame = WorldEventFrame("bradley.destroyed", entity); frame["where"] = BradleyWhere((BradleyAPC)entity) ?? "other"; if (entity.net != null) _bradleyWhere.Remove(entity.net.ID.Value); } else if (entity is PatrolHelicopter) { frame = WorldEventFrame("heli.downed", entity); } else { frame = WorldEventFrame("chinook.downed", entity); } PutCredit(frame, credit, attacker != null && !attacker.IsNpc ? attacker : null); EmitFrame(frame); } /// /// credit {blow, last, most, damagers} and names. Each of the three is absent /// when nobody qualifies; a death nobody touched carries an empty damagers. /// private static void PutCredit(Dictionary frame, VehicleCredit credit, BasePlayer killer) { var body = new Dictionary(); var names = new Dictionary(); var rows = new List>(); if (credit != null) rows.AddRange(credit.Damage); rows.Sort((a, b) => b.Value.CompareTo(a.Value)); ulong blow = credit != null ? credit.Blow : 0UL; // A Bradley's and a Chinook's death names its killer outright; a helicopter's crash // does not, and its blow was kept at the crippling hit. if (blow == 0UL && killer != null) blow = (ulong)killer.userID; if (blow != 0UL) body["blow"] = Id(blow); if (credit != null && credit.Last != 0UL) body["last"] = Id(credit.Last); if (rows.Count > 0) body["most"] = Id(rows[0].Key); var damagers = new List(); for (int i = 0; i < rows.Count && i < CreditMaxDamagers; i++) { damagers.Add(new Dictionary { { "steamId", Id(rows[i].Key) }, { "damage", Math.Round((double)rows[i].Value, 1) } }); } body["damagers"] = damagers; if (credit != null) foreach (KeyValuePair n in credit.Names) names[Id(n.Key)] = n.Value; if (killer != null && blow == (ulong)killer.userID) names[Id(blow)] = killer.displayName; frame["credit"] = body; if (names.Count > 0) frame["names"] = names; } private static string Id(ulong id) { return id.ToString(System.Globalization.CultureInfo.InvariantCulture); } // ── The patrol helicopter leaving ─────────────────────────────────────────────────────── private void OnHelicopterRetire(PatrolHelicopterAI ai) { MarkHook("OnHelicopterRetire"); if (ai != null && ai.helicopterBase != null && WorldEventsLive()) EmitFrame(WorldEventFrame("heli.left", ai.helicopterBase)); } // ── Bradley ───────────────────────────────────────────────────────────────────────────── /// /// Next tick, because both spawners record the Bradley only after this hook has run inside /// its spawn. Launch Site's is one line; road Bradleys are gathered for a second and said /// as one line with a count. /// private void OnBradleyApcInitialize(BradleyAPC apc) { MarkHook("OnBradleyApcInitialize"); if (apc == null || !WorldEventsLive()) return; NextTick(() => { if (!InWorld(apc)) return; string where = BradleyWhere(apc); if (where == "launch_site") { Dictionary frame = WorldEventFrame("bradley.spawned", null); frame["where"] = where; frame["count"] = 1; EmitFrame(frame); } else if (where == "road") { _roadBradleysPending++; if (_roadBradleysFlush == null) _roadBradleysFlush = timer.Once(RoadBradleyBatchSeconds, FlushRoadBradleys); } }); } private void FlushRoadBradleys() { _roadBradleysFlush = null; int count = _roadBradleysPending; _roadBradleysPending = 0; if (count <= 0 || !WorldEventsLive()) return; Dictionary frame = WorldEventFrame("bradley.spawned", null); frame["where"] = "road"; frame["count"] = count; EmitFrame(frame); } /// launch_site, road, or null for a Bradley neither of the game's spawners holds. private string BradleyWhere(BradleyAPC apc) { string where; if (apc.net != null && _bradleyWhere.TryGetValue(apc.net.ID.Value, out where)) return where; where = null; if (BradleySpawner.singleton != null && BradleySpawner.singleton.spawned == apc) where = "launch_site"; else if (InRoadBradleys(apc)) where = "road"; if (where != null && apc.net != null) _bradleyWhere[apc.net.ID.Value] = where; return where; } /// Whether the road event holds this Bradley in its own list, which is private. private static bool InRoadBradleys(BradleyAPC apc) { if (_roadBradleysList == null) _roadBradleysList = typeof(RoadBradleys).GetField("spawnedAPCs", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic); if (_roadBradleysList == null) return false; foreach (RoadBradleys road in UnityEngine.Object.FindObjectsOfType()) { var list = _roadBradleysList.GetValue(road) as List; if (list != null && list.Contains(apc)) return true; } return false; } // ── The cargo ship ────────────────────────────────────────────────────────────────────── private void OnCargoShipHarborApproach(CargoShip ship, object harbor = null) { MarkHook("OnCargoShipHarborApproach"); EmitCargo("cargo.docking", ship); } private void OnCargoShipHarborArrived(CargoShip ship) { MarkHook("OnCargoShipHarborArrived"); EmitCargo("cargo.docked", ship); } private void OnCargoShipHarborLeave(CargoShip ship) { MarkHook("OnCargoShipHarborLeave"); EmitCargo("cargo.undocked", ship); } /// The ship turning for the edge of the map, radiation on: it has left. The kill follows when it is gone (stage 1's spike). private void OnCargoShipEgress(CargoShip ship) { MarkHook("OnCargoShipEgress"); if (ship != null && WorldEventsLive()) EmitFrame(WorldEventFrame("cargo.left", ship)); } /// The three harbour types name the nearest harbour. private void EmitCargo(string type, CargoShip ship) { if (ship == null || !WorldEventsLive()) return; Dictionary frame = WorldEventFrame(type, ship); PutMonument(frame, "harbour", ship.transform.position, "harbor"); EmitFrame(frame); } // ── The Chinook's drop ────────────────────────────────────────────────────────────────── /// /// The door opening over a drop zone. The crate is made a moment later with no net id /// yet, so it is joined to this drop when it lands, by place and time. /// private void OnHelicopterDropCrate(CH47HelicopterAIController chinook) { MarkHook("OnHelicopterDropCrate"); if (chinook == null || chinook.transform == null) return; float now = UnityEngine.Time.realtimeSinceStartup; _chinookDrops.RemoveAll(d => now - d.Value > ChinookDropJoinSeconds); _chinookDrops.Add(new KeyValuePair(chinook.transform.position, now)); } private void OnCrateLanded(HackableLockedCrate crate) { MarkHook("OnCrateLanded"); if (crate == null || crate.net == null || _chinookDrops.Count == 0) return; ulong id = crate.net.ID.Value; if (_chinookCrates.Contains(id)) return; float now = UnityEngine.Time.realtimeSinceStartup; UnityEngine.Vector3 at = crate.transform.position; int found = _chinookDrops.FindIndex(d => now - d.Value <= ChinookDropJoinSeconds && Distance2D(d.Key, at) <= ChinookDropJoinMetres); if (found < 0) return; _chinookDrops.RemoveAt(found); _chinookCrates.Add(id); if (!WorldEventsLive() || InWorldLedger(id)) return; Dictionary frame = WorldEventFrame("chinook.dropped", crate); PutMonument(frame, "monument", at, null); EmitFrame(frame); } // ── Locked crates ─────────────────────────────────────────────────────────────────────── /// A crate one of our own steps placed. It is the event's, not Rust's. private bool InWorldLedger(ulong id) { return _world.ContainsKey(id.ToString(System.Globalization.CultureInfo.InvariantCulture)); } /// /// Where a locked crate is, told positively: oilrig_large, oilrig_small, /// cargo, ghostship, chinook, or other for anything else. /// private string CrateWhere(HackableLockedCrate crate) { if (crate.net != null && InWorldLedger(crate.net.ID.Value)) return "other"; string prefab = crate.ShortPrefabName ?? string.Empty; if (prefab.EndsWith("_oilrig", StringComparison.Ordinal)) { string kind; NearestMonument(crate.transform.position, "oilrig", out kind); if (kind == "oilrig_1") return "oilrig_large"; if (kind == "oilrig_2") return "oilrig_small"; return "other"; } if (prefab.EndsWith("_ghostship", StringComparison.Ordinal)) return "ghostship"; if (crate.GetParentEntity() is CargoShip) return "cargo"; if (crate.net != null && _chinookCrates.Contains(crate.net.ID.Value)) return "chinook"; return "other"; } /// Who is trying. The hack itself is OnCrateHack, which names nobody, a moment later. private void CanHackCrate(BasePlayer player, HackableLockedCrate crate) { MarkHook("CanHackCrate"); WorldWho who = WhoOf(player); if (who != null && crate != null && crate.net != null) _crateHacker[crate.net.ID.Value] = who; } /// /// A hack started: said once per crate, and counted once for its starter (Safecracker, /// D356). On an oil rig this is also the scientists being called, at once. /// private void OnCrateHack(HackableLockedCrate crate) { MarkHook("OnCrateHack"); if (crate == null || crate.net == null) return; string where = CrateWhere(crate); if (where.StartsWith("oilrig", StringComparison.Ordinal)) _rigHackAt = UnityEngine.Time.realtimeSinceStartup; ulong id = crate.net.ID.Value; if (where == "other" || !_crateHackSaid.Add(id)) return; WorldWho who; _crateHacker.TryGetValue(id, out who); if (who != null && UnityEngine.Time.realtimeSinceStartup - who.At > HackJoinSeconds) who = null; if (who != null) { BasePlayer player = BasePlayer.FindByID(who.Id) ?? BasePlayer.FindSleeping(who.Id); if (player != null) TallyFor(player).CratesHacked++; } else { _crateHacker.Remove(id); } if (!WorldEventsLive()) return; Dictionary frame = WorldEventFrame("crate.hack.started", crate); frame["where"] = where; PutWho(frame, who); EmitFrame(frame); } private void OnCrateHackEnd(HackableLockedCrate crate) { MarkHook("OnCrateHackEnd"); if (crate == null || crate.net == null || !WorldEventsLive()) return; string where = CrateWhere(crate); if (where == "other") return; WorldWho who; _crateHacker.TryGetValue(crate.net.ID.Value, out who); Dictionary frame = WorldEventFrame("crate.unlocked", crate); frame["where"] = where; PutWho(frame, who); EmitFrame(frame); } /// /// The first loot of an airdrop (Scavenger, D356) and of an unlocked crate. Fires for every /// container anybody opens, so the type test is first. /// private void OnLootEntity(BasePlayer player, BaseEntity entity) { MarkHook("OnLootEntity"); if (!(entity is SupplyDrop || entity is HackableLockedCrate) || entity.net == null || player == null || player.IsNpc) return; ulong id = entity.net.ID.Value; if (InWorldLedger(id)) return; if (entity is SupplyDrop) { if (!_dropLooted.Add(id)) return; TallyFor(player).DropsLooted++; if (!WorldEventsLive()) return; Dictionary drop = WorldEventFrame("airdrop.looted", entity); PutWho(drop, WhoOf(player)); EmitFrame(drop); return; } var crate = (HackableLockedCrate)entity; if (!crate.IsFullyHacked() || _crateLooted.Contains(id)) return; string where = CrateWhere(crate); if (where == "other") return; _crateLooted.Add(id); if (!WorldEventsLive()) return; Dictionary frame = WorldEventFrame("crate.looted", crate); frame["where"] = where; PutWho(frame, WhoOf(player)); EmitFrame(frame); } // ── Airdrops ──────────────────────────────────────────────────────────────────────────── /// A supply signal: its thrower is the caller, and the drop point is where it lies. private void OnCargoPlaneSignaled(BaseEntity plane, SupplySignal signal) { MarkHook("OnCargoPlaneSignaled"); if (plane == null || signal == null) return; _planeCaller[plane] = WhoOf(signal.creatorEntity as BasePlayer); _planeDropPoint[plane] = signal.transform.position; } /// The schedule's airdrop: nobody called it. Only its drop point is kept; the plane's spawn says the line. private void OnAirdrop(CargoPlane plane, UnityEngine.Vector3 at = default(UnityEngine.Vector3)) { MarkHook("OnAirdrop"); if (plane != null && !_planeDropPoint.ContainsKey(plane) && at != UnityEngine.Vector3.zero) _planeDropPoint[plane] = at; } private static System.Reflection.FieldInfo _planeDropPositionField; /// /// A cargo plane's spawn, next tick, says airdrop.called: once per plane, by every /// route there is. The console's supply.call raised neither hook above (stage 1), /// so the spawn is the one thing every airdrop has; the hooks only say who and where, /// and next tick is after a signal's hook has named its thrower. /// private void AnnounceAirdropSoon(BaseEntity plane) { NextTick(() => { if (plane == null || plane.IsDestroyed || !_planeAnnounced.Add(plane) || !WorldEventsLive()) return; // Guarded Crate delivers its crate by cargo plane: that is its event, said as // `gc.started`, not an airdrop (§14.3.5). It has registered the plane by now. Plugin gc = GuardedCratePlugin(); object ours = gc != null ? gc.Call("API_IsGuardedCrateCargoPlane", plane as CargoPlane) : null; if (ours is bool && (bool)ours) return; WorldWho who; _planeCaller.TryGetValue(plane, out who); Dictionary frame = WorldEventFrame("airdrop.called", null); UnityEngine.Vector3 at; if (_planeDropPoint.TryGetValue(plane, out at) || TryPlaneDropPoint(plane, out at)) PutPosition(frame, at); PutWho(frame, who); EmitFrame(frame); }); } /// The plane's own drop point, which is private to it: read by reflection, as Carbon leaves it private. private static bool TryPlaneDropPoint(BaseEntity plane, out UnityEngine.Vector3 at) { at = UnityEngine.Vector3.zero; if (_planeDropPositionField == null) _planeDropPositionField = typeof(CargoPlane).GetField("dropPosition", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic); if (_planeDropPositionField == null || !(plane is CargoPlane)) return false; object value = _planeDropPositionField.GetValue(plane); if (!(value is UnityEngine.Vector3)) return false; at = (UnityEngine.Vector3)value; return at != UnityEngine.Vector3.zero; } private void OnSupplyDropDropped(BaseEntity drop, CargoPlane plane) { MarkHook("OnSupplyDropDropped"); if (drop == null || drop.net == null || plane == null) return; WorldWho who; if (_planeCaller.TryGetValue(plane, out who) && who != null) _dropCaller[drop.net.ID.Value] = who; } /// Said once per drop: the hook fired twice for one drop in the test phase. private void OnSupplyDropLanded(SupplyDrop drop) { MarkHook("OnSupplyDropLanded"); if (drop == null || drop.net == null || InWorldLedger(drop.net.ID.Value) || !_dropLanded.Add(drop.net.ID.Value)) return; if (!WorldEventsLive()) return; WorldWho who; _dropCaller.TryGetValue(drop.net.ID.Value, out who); Dictionary frame = WorldEventFrame("airdrop.landed", drop); PutWho(frame, who); EmitFrame(frame); } // ── The Deep Sea (off the map: no position) ───────────────────────────────────────────── private void OnDeepSeaOpened(DeepSeaManager manager) { MarkHook("OnDeepSeaOpened"); if (WorldEventsLive()) EmitFrame(WorldEventFrame("deepsea.opened", null)); } private void OnDeepSeaClosed(DeepSeaManager manager) { MarkHook("OnDeepSeaClosed"); if (WorldEventsLive()) EmitFrame(WorldEventFrame("deepsea.closed", null)); } // ---- calling a world event (protocol 14, PROTOCOL.md §20.5; D354, D367, D378) ---- // // `world.call` runs one of the game's own scheduled events now, the way the schedule does: // one of its `TriggeredEvent`s' `RunEvent`, without `Trigger`, which would also reset the // schedule's own clock. No console command is involved, so it works the same on Carbon. // // **Kept.** Nothing a call starts is removed at teardown — a cargo ship or a drop may // already hold players or their loot (D354) — so there is no registry of what a call made, // only of the call: its key, for a retry, and its run, for the per-run ceiling. // // **A position only where the game has a route to one** (stage 1's spike): the airdrop's // drop point (`InitDropPosition` before it spawns) and the patrol helicopter's first // destination (`SetInitialDestination` before it spawns, after which it patrols as usual). // The Chinook drops only at the map's drop zones, and the one route to a chosen zone is // the Airfield terminal's shared state, so it takes no position (D378). /// The events a call may run: the schedules' keys, and the satellite. private static readonly string[] WorldCallEvents = { "patrol_heli", "cargo", "chinook", "airdrop", "travelling_vendor", "bradley_road", "f15", "satellite" }; /// The most of one event one call runs. The game runs the cargo ship, the vendor and road Bradleys one at a time. private const int WorldCallHardMaxCount = 5; /// The ceiling on calls per run. EventsMaxWorldCalls may lower it, never raise it. private const int WorldCallsHardMax = 10; /// How long a call's key is remembered: seven days, as a payment's is. private const long WorldCallMemoryMs = 7L * 24L * 60L * 60L * 1000L; /// One call, kept for its key and its run. Persisted as it is. private sealed class WorldCall { public string Key; public string RunId; public string Event; public int Called; public long AtMs; } private Dictionary _worldCalls = new Dictionary(StringComparer.Ordinal); private static bool WorldCallPlaceable(string ev) { return ev == "airdrop" || ev == "patrol_heli"; } private static int WorldCallMaxCount(string ev) { return ev == "cargo" || ev == "travelling_vendor" || ev == "bradley_road" ? 1 : WorldCallHardMaxCount; } private string WorldCallsPath() { return Path.Combine(Interface.Oxide.DataDirectory, Name, "world-calls.json"); } private void SaveWorldCalls() { WriteDataFile(WorldCallsPath(), new List(_worldCalls.Values), "world calls"); } private void LoadWorldCalls() { _worldCalls = new Dictionary(StringComparer.Ordinal); List calls = ReadDataFile(WorldCallsPath(), "world calls"); if (calls != null) foreach (WorldCall c in calls) if (c != null && !string.IsNullOrEmpty(c.Key)) _worldCalls[c.Key] = c; ForgetOldWorldCalls(); } private void ForgetOldWorldCalls() { long now = NowMs(); var stale = new List(); foreach (KeyValuePair kv in _worldCalls) if (now >= kv.Value.AtMs + WorldCallMemoryMs) stale.Add(kv.Key); foreach (string key in stale) _worldCalls.Remove(key); if (stale.Count > 0) SaveWorldCalls(); } private int WorldCallsLimit() { return _config != null ? _config.EventsMaxWorldCalls : WorldCallsHardMax; } /// Main thread. Run one of the game's world events now, for a run. private void HandleWorldCall(Dictionary cmd, string reqId) { if (!WorldWritesAllowed(reqId)) return; string key = StringField(cmd, "key"); string runId = StringField(cmd, "runId"); if (string.IsNullOrEmpty(key) || key.Length > PaymentKeyMax || string.IsNullOrEmpty(runId)) { EmitWorldError(reqId, "malformed", "a call is made for a run, under a key of at most 128 characters"); return; } ForgetOldWorldCalls(); WorldCall seen; if (_worldCalls.TryGetValue(key, out seen)) { EmitWorldCalled(reqId, seen, true); return; } string ev = (StringField(cmd, "event") ?? string.Empty).Trim().ToLowerInvariant(); if (Array.IndexOf(WorldCallEvents, ev) < 0) { EmitWorldError(reqId, "bad-event", $"'{ev}' is not a world event this server calls ({string.Join(", ", WorldCallEvents)})"); return; } int count = cmd.ContainsKey("count") ? IntField(cmd, "count") : 1; if (count < 1 || count > WorldCallMaxCount(ev)) { EmitWorldError(reqId, "bad-count", $"'{ev}' is called 1 to {WorldCallMaxCount(ev)} at a time, not {count}"); return; } bool placed = !string.IsNullOrEmpty(StringField(cmd, "monument")) || cmd.ContainsKey("x") || cmd.ContainsKey("z"); UnityEngine.Vector3 at = UnityEngine.Vector3.zero; if (placed) { if (!WorldCallPlaceable(ev)) { EmitWorldError(reqId, "not-placeable", $"'{ev}' goes where the game puts it; only an airdrop and a patrol helicopter take a position"); return; } string reason, refusal; if (!ResolveWorldPoint(cmd, out at, out reason, out refusal)) { EmitWorldError(reqId, reason, refusal); return; } } int used = 0; foreach (WorldCall c in _worldCalls.Values) if (c.RunId == runId) used += c.Called; if (used + count > WorldCallsLimit()) { EmitWorldError(reqId, "too-many", $"a run calls at most {WorldCallsLimit()} world events on this server, and this one has called {used}"); return; } int called = 0; string failure = null; for (int i = 0; i < count; i++) { if (!RunWorldCall(ev, placed ? (UnityEngine.Vector3?)at : null, out failure)) break; called++; } if (called == 0) { EmitWorldError(reqId, "refused", failure ?? $"the game did not start '{ev}'"); return; } var call = new WorldCall { Key = key, RunId = runId, Event = ev, Called = called, AtMs = NowMs() }; _worldCalls[key] = call; SaveWorldCalls(); _lastWorldSummary = $"called {called} {ev} for run {runId}" + (placed ? $" at {Grid(at) ?? "?"}" : string.Empty); Puts($"world: {_lastWorldSummary}"); EmitWorldCalled(reqId, call, false); } private void EmitWorldCalled(string reqId, WorldCall call, bool repeat) { Dictionary frame = Frame("world.called", "reply"); frame["key"] = call.Key; frame["event"] = call.Event; frame["repeat"] = repeat; frame["called"] = call.Called; if (reqId != null) frame["reqId"] = reqId; EmitFrame(frame); } /// The game's own schedule for an event, enabled or not: an operator who switched one off may still call it. private static EventSchedule ScheduleFor(string key) { foreach (EventSchedule s in UnityEngine.Object.FindObjectsOfType()) if (s != null && string.Equals(s.Key, key, StringComparison.OrdinalIgnoreCase)) return s; return null; } /// Main thread. One run of one event; false, with why, when the game has nothing to run. private bool RunWorldCall(string ev, UnityEngine.Vector3? at, out string failure) { failure = null; if (ev == "satellite") { // The satellite is not on the schedule; its own console command crashes one at a // random spot. A native command, which Carbon runs from code (§14.2.1). ConsoleSystem.Run(ConsoleSystem.Option.Server.Quiet(), "satellite.random"); return true; } EventSchedule schedule = ScheduleFor(ev); TriggeredEvent[] events = schedule != null ? schedule.GetComponents() : null; if (events == null || events.Length == 0) { failure = $"this map has no '{ev}' event"; return false; } if (at.HasValue) return SpawnPlaced(ev, events, at.Value, out failure); // As the schedule picks: one of its events at random. TriggeredEvent picked = events[UnityEngine.Random.Range(0, events.Length)]; try { picked.RunEvent(); } catch (Exception ex) { failure = $"the game could not start '{ev}': {Flatten(ex.Message)}"; return false; } return true; } /// /// The airdrop or the patrol helicopter at a point: the schedule's own prefab, spawned the /// way TriggeredEventPrefab.RunEvent does it, with the position set before the spawn. /// private bool SpawnPlaced(string ev, TriggeredEvent[] events, UnityEngine.Vector3 at, out string failure) { failure = null; TriggeredEventPrefab source = null; foreach (TriggeredEvent e in events) { source = e as TriggeredEventPrefab; if (source != null && source.targetPrefab != null && source.targetPrefab.isValid) break; source = null; } if (source == null) { failure = $"'{ev}' has no prefab to place on this map"; return false; } BaseEntity entity = GameManager.server.CreateEntity(source.targetPrefab.resourcePath); if (entity == null) { failure = $"the game could not create '{ev}'"; return false; } entity.SendMessage("TriggeredEventSpawn", UnityEngine.SendMessageOptions.DontRequireReceiver); var plane = entity as CargoPlane; var heli = entity as PatrolHelicopter; if (plane != null) { plane.InitDropPosition(at); } else if (heli != null) { PatrolHelicopterAI ai = heli.GetComponent(); if (ai == null) { entity.Kill(); failure = "the patrol helicopter has no AI to send"; return false; } at.y = TerrainMeta.HeightMap.GetHeight(at) + 10f; ai.SetInitialDestination(at, 0.25f); } else { entity.Kill(); failure = $"'{ev}' cannot be placed"; return false; } entity.Spawn(); entity.SendMessage("TriggeredEventPostSpawn", UnityEngine.SendMessageOptions.DontRequireReceiver); if (source.shouldBroadcastSpawn) BasePlayer.Server_SendWorldNotificationToAllActivePlayers(source.notificationType, entity.transform.position); return true; } // ---- diagnostics ---- /// /// 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. /// [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")}"); } /// /// What the last permission sync did, from the server console. /// /// /// 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. /// /// [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}"); } /// /// Where this framework keeps configuration, and what the last write from the website /// did. /// /// /// The root is the interesting half. It is oxide/config on one rig and /// carbon/configs 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. /// /// [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}"); } /// /// Which of this plugin's hooks have actually fired, and how often. /// /// /// 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. /// /// /// /// A zero is not automatically a fault: OnNewSave fires on wipe day and /// OnUserBanned only when somebody is banned. It is a fault when the thing it /// names has demonstrably happened. /// /// [ConsoleCommand("rg.hooks")] private void CmdHooks(ConsoleSystem.Arg arg) { var fired = new List(); var silent = new List(); 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}"); } } }