All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 6s
One conflict, ExpectedHooks: both phases' hooks are kept (48, all void). Compiled and loaded on both rigs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
12577 lines
494 KiB
C#
12577 lines
494 KiB
C#
using System;
|
||
using System.Collections.Concurrent;
|
||
using System.Collections.Generic;
|
||
using System.IO;
|
||
using System.Net.Sockets;
|
||
using System.Text;
|
||
using System.Threading;
|
||
using Newtonsoft.Json;
|
||
using Oxide.Core;
|
||
using Oxide.Core.Plugins;
|
||
|
||
namespace Oxide.Plugins
|
||
{
|
||
/// <summary>
|
||
/// The Runic Gateway bridge. Dials out to a rust-link sidecar on loopback and speaks
|
||
/// newline-delimited JSON over it: events out, commands in.
|
||
///
|
||
/// <para>
|
||
/// Threading contract, which everything later in this plugin depends on:
|
||
/// </para>
|
||
/// <list type="bullet">
|
||
/// <item><description>
|
||
/// <see cref="Emit"/> is called from the server's main thread. It formats nothing,
|
||
/// blocks on nothing, and touches no socket. It enqueues and returns. A slow, wedged,
|
||
/// or absent sidecar cannot stall the game.
|
||
/// </description></item>
|
||
/// <item><description>
|
||
/// One link thread owns the socket. It connects, drains the queue, and reconnects with
|
||
/// backoff. A single writer keeps event ordering intact.
|
||
/// </description></item>
|
||
/// <item><description>
|
||
/// A reader thread parses inbound lines and hands each to the main thread via
|
||
/// <c>Interface.Oxide.NextTick</c>. The reader touches no Unity object, no BasePlayer
|
||
/// and no ConVar — every one of those is main-thread-only, and reading them from here
|
||
/// is the kind of bug that presents as a crash somewhere else entirely.
|
||
/// </description></item>
|
||
/// </list>
|
||
///
|
||
/// <para>
|
||
/// The outbound queue is bounded. On overflow the oldest record is dropped and counted,
|
||
/// because telemetry is worth less than the server's memory.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// <b>Loopback is the trust boundary.</b> There is no token on this link: the plugin and
|
||
/// the sidecar share a host, and the sidecar binds 127.0.0.1. Pointing <c>Host</c> at
|
||
/// anything routable puts an unauthenticated command channel on the network.
|
||
/// </para>
|
||
/// </summary>
|
||
[Info("Runic Gateway", "RunicGateway", "0.1.0")]
|
||
[Description("Bridges this Rust server to a Runic Gateway website through a rust-link sidecar.")]
|
||
internal class RunicGateway : RustPlugin
|
||
{
|
||
/// <summary>
|
||
/// Wire-protocol version. Declared in three places that must agree: here,
|
||
/// <c>PROTOCOL_VERSION</c> in the sidecar, and <c>module.json</c> in Module-Rust. The
|
||
/// overlay's <c>overlay.toml</c> carries this same number so the installer can refuse
|
||
/// to pair a sidecar and an overlay that disagree.
|
||
/// </summary>
|
||
private const int ProtocolVersion = 13;
|
||
|
||
/// <summary>
|
||
/// Loopback reconnects are cheap, so the ceiling is low. A sidecar restart should cost
|
||
/// a few seconds of buffering, not half a minute.
|
||
/// </summary>
|
||
private const int MaxBackoffMs = 5000;
|
||
|
||
/// <summary>
|
||
/// How long a link must stay up before its drop counts as an outage rather than a
|
||
/// refusal. The backoff resets only past it: a sidecar that closes every connection at
|
||
/// once — one refusing a plugin that names another server (D155), or one crash-looping —
|
||
/// would otherwise be redialled twice a second for ever, rebuilding the boards on the
|
||
/// main thread and printing two console lines each time.
|
||
/// </summary>
|
||
private const int StableLinkMs = 10000;
|
||
|
||
/// <summary>The longest line accepted from the sidecar. Over-long lines are discarded.</summary>
|
||
private const int MaxInboundLineBytes = 1024 * 1024;
|
||
|
||
/// <summary>
|
||
/// How long to wait for the TCP connect itself before giving up and backing off.
|
||
///
|
||
/// <para>
|
||
/// It exists because <c>TcpClient.Connect</c> is blocking and has no timeout of its
|
||
/// own, and the OS default is tens of seconds. A host that REFUSES answers instantly —
|
||
/// which is every loopback test, and why this was invisible for two phases — but a host
|
||
/// that <b>drops</b> does not answer at all: a firewall, a wrong address, a machine
|
||
/// that is off. The link thread then sits in the connect, deaf to everything, and
|
||
/// <c>Unload</c> waits out its whole join on the game's MAIN THREAD.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// Generous against any real network and short against a hang. Loopback, which is the
|
||
/// supported deployment, connects in well under a millisecond.
|
||
/// </para>
|
||
/// </summary>
|
||
private const int ConnectTimeoutMs = 3000;
|
||
|
||
/// <summary>
|
||
/// How often the boards are re-sent (PROTOCOL.md §8.3) and the per-player tallies are
|
||
/// flushed (§8.6). One cadence serves both because both answer the same question —
|
||
/// "what has this server been doing lately" — and two timers would only be two things
|
||
/// to get out of step.
|
||
/// </summary>
|
||
private const float BoardCadenceSeconds = 60f;
|
||
|
||
private static readonly ConcurrentQueue<string> Outbound = new ConcurrentQueue<string>();
|
||
private static readonly AutoResetEvent Wake = new AutoResetEvent(false);
|
||
|
||
/// <summary>
|
||
/// Set once, by <c>Unload</c>, and never reset. It is what lets a thread blocked on a
|
||
/// CONNECT give up immediately instead of at the end of its timeout.
|
||
///
|
||
/// <para>
|
||
/// It cannot be <see cref="Wake"/>: that handle also means "the queue has something in
|
||
/// it", which <c>Emit</c> signals constantly, so waiting on it during a connect would
|
||
/// abandon the attempt every time a hook fired. Two meanings, two handles.
|
||
/// </para>
|
||
/// </summary>
|
||
private readonly ManualResetEvent _stopping = new ManualResetEvent(false);
|
||
|
||
private PluginConfig _config;
|
||
|
||
private Thread _link;
|
||
private volatile bool _running;
|
||
private volatile bool _connected;
|
||
|
||
/// <summary>Set when the peer goes away, so the writer stops trying.</summary>
|
||
private volatile bool _dead;
|
||
|
||
/// <summary>
|
||
/// Whether the "cannot reach the sidecar" line has been printed for this load. It is
|
||
/// printed once, not per attempt: the retry runs every few seconds for ever.
|
||
/// </summary>
|
||
private volatile bool _loggedConnectFailure;
|
||
|
||
/// <summary>
|
||
/// Incremented per connection attempt. A reader from a previous connection must not be
|
||
/// able to mark a newer one dead — <c>reader.Join</c> can time out, and the stale
|
||
/// thread's finally block would otherwise tear down the connection that replaced it.
|
||
/// </summary>
|
||
private int _epoch;
|
||
|
||
private int _depth;
|
||
private long _sent, _dropped, _received, _connects, _writeErrors;
|
||
|
||
/// <summary>
|
||
/// Identifies this run of the <b>server process</b>. It is how the website tells a game
|
||
/// restart apart from a sidecar reconnect — the same link comes back either way, and
|
||
/// only one of the two means the world it is describing started over.
|
||
///
|
||
/// <para>
|
||
/// It must therefore be stable across a <b>plugin</b> reload, which a fresh
|
||
/// <c>Guid</c> at <c>Init</c> is not: <c>oxide.reload</c> would announce a brand new
|
||
/// boot to a website that is about to reconcile its whole ledger on the strength of it,
|
||
/// while the world it describes never moved. The process's own start time is the only
|
||
/// value here that changes exactly when the thing it names changes.
|
||
/// </para>
|
||
/// </summary>
|
||
private string _bootId;
|
||
|
||
/// <summary>
|
||
/// The current wipe, as <c>w-yyyyMMddTHHmmssZ</c>, derived from the save's creation
|
||
/// time. Null until the server has saved at least once — a server with no save has no
|
||
/// wipe, and PROTOCOL.md §8.2 says that is reported by the field being **absent**
|
||
/// rather than by an empty string that will sort beside every other empty string for
|
||
/// ever.
|
||
///
|
||
/// <para>
|
||
/// Cached rather than read per frame, and re-read on <c>OnNewSave</c>, which is the
|
||
/// only moment it can change. A wipe is a fact about the world; nothing about a
|
||
/// reconnect, a plugin reload or a restart moves it.
|
||
/// </para>
|
||
/// </summary>
|
||
private string _wipeId;
|
||
|
||
/// <summary>
|
||
/// Per-player counters waiting to be flushed as a <c>player.tally</c> frame. Main
|
||
/// thread only — every writer is a game hook and the flush runs on a timer, which is
|
||
/// also the main thread, so this needs no lock and must never acquire one.
|
||
/// </summary>
|
||
private readonly Dictionary<ulong, Tally> _tallies = new Dictionary<ulong, Tally>();
|
||
|
||
/// <summary>
|
||
/// When each connected player connected, in epoch milliseconds, so a disconnect can
|
||
/// report a session length. Main thread only.
|
||
///
|
||
/// <para>
|
||
/// A player already on the server when this plugin loaded has no entry, and
|
||
/// <c>sessionSec</c> is then <b>absent</b> from their disconnect rather than zero: an
|
||
/// unknown session is not a zero-length one, and summing the zeros would under-report
|
||
/// playtime quietly and for ever.
|
||
/// </para>
|
||
/// </summary>
|
||
private readonly Dictionary<ulong, long> _connectedAt = new Dictionary<ulong, long>();
|
||
|
||
/// <summary>
|
||
/// Every hook name this plugin declares, and whether it has fired at least once.
|
||
///
|
||
/// <para>
|
||
/// Both frameworks bind hooks by <b>name and arity, through reflection, with no
|
||
/// compile-time check</b>: a hook Facepunch renames on a Thursday simply stops being
|
||
/// called, and a hook one framework's catalogue does not list may or may not exist.
|
||
/// Neither failure logs anything anywhere. <c>rg.hooks</c> reads this dictionary, which
|
||
/// makes the plugin its own answer to both questions on whichever framework it is
|
||
/// actually running under — and a cheaper answer than either catalogue.
|
||
/// </para>
|
||
/// </summary>
|
||
private readonly Dictionary<string, long> _hookCounts = new Dictionary<string, long>();
|
||
|
||
/// <summary>
|
||
/// The repeating boards/tally timer.
|
||
///
|
||
/// <para>
|
||
/// Typed as the plugin-facing <c>Oxide.Plugins.Timer</c>, which is what
|
||
/// <c>timer.Every</c> hands back — <b>not</b> <c>Oxide.Core.Libraries.Timer.TimerInstance</c>,
|
||
/// which is the library's own type and does not convert. Both frameworks would destroy
|
||
/// this timer for us when the plugin unloads; holding the handle is what lets
|
||
/// <c>Unload</c> stop it before the link thread is joined rather than after.
|
||
/// </para>
|
||
/// </summary>
|
||
private Timer _cadence;
|
||
|
||
// ---- lifecycle ----
|
||
|
||
/// <summary>
|
||
/// Every hook this plugin declares. Seeded into <c>_hookCounts</c> at zero so that
|
||
/// <c>rg.hooks</c> can report a hook that has <b>never</b> fired — which is the whole
|
||
/// question, and which a dictionary that only learns names as they arrive can never
|
||
/// answer.
|
||
/// </summary>
|
||
private static readonly string[] ExpectedHooks =
|
||
{
|
||
"OnServerInitialized", "OnServerShutdown", "OnNewSave",
|
||
"OnPlayerConnected", "OnPlayerDisconnected", "OnPlayerRespawned",
|
||
"OnPlayerDeath", "OnPlayerChat", "OnDispenserGather", "OnEntityDeath",
|
||
"OnDispenserBonus", "OnCollectiblePickup", "OnGrowableGathered",
|
||
"OnPlayerReported", "OnUserBanned", "OnUserUnbanned",
|
||
"CanUserLogin", "OnUserApproved",
|
||
"OnUserPermissionGranted", "OnUserPermissionRevoked",
|
||
"OnUserGroupAdded", "OnUserGroupRemoved",
|
||
"OnGroupPermissionGranted", "OnGroupPermissionRevoked",
|
||
"OnPluginLoaded", "OnPluginUnloaded",
|
||
"OnClanCreated", "OnClanDisbanded", "OnClanMemberAdded",
|
||
"OnClanMemberLeft", "OnClanMemberKicked", "OnClanColorChanged",
|
||
"OnKitRedeemed",
|
||
"OnEntitySpawned", "OnEntityKill",
|
||
"OnEntityTakeDamage", "OnItemCraftFinished", "OnEntityBuilt", "OnStructureRepair",
|
||
"OnHealingItemUse", "OnPlayerRevive", "OnRocketLaunched",
|
||
"OnExplosiveThrown", "OnExplosiveDropped", "OnMissionSucceeded",
|
||
"OnEnterZone", "OnExitZone", "OnRgDomesReady"
|
||
};
|
||
|
||
private void Init()
|
||
{
|
||
foreach (string hook in ExpectedHooks)
|
||
_hookCounts[hook] = 0L;
|
||
|
||
_bootId = ResolveBootId();
|
||
_wipeId = ResolveWipeId();
|
||
|
||
// Before the link thread, not in `Loaded`: both frameworks call `Init` first, and a
|
||
// thread started here with `_config` still null dials the 7799 fallback. With one
|
||
// server per host that is its own sidecar by coincidence; with two it is the OTHER
|
||
// server's, whose sidecar then files this server's history under that one's id
|
||
// (found by the phase 18 walk, D154).
|
||
LoadConfigValues();
|
||
_running = true;
|
||
|
||
// Expired link codes are dropped on a timer, not on lookup: an unconfirmed code is
|
||
// never looked up, so nothing else would ever remove one (protocol 3).
|
||
timer.Every(60f, PurgeExpiredCodes);
|
||
|
||
_link = new Thread(LinkLoop)
|
||
{
|
||
Name = "RunicGateway Link",
|
||
IsBackground = true
|
||
};
|
||
|
||
_link.Start();
|
||
}
|
||
|
||
private void Unload()
|
||
{
|
||
// Attempted before `_running` goes false, because `Emit` refuses to enqueue once it
|
||
// does — but be honest about what this achieves: the writer stops on the same flag a
|
||
// few lines below and the queue is cleared after the join, so a frame enqueued here
|
||
// only survives if the writer happens to be mid-drain. It usually is not.
|
||
//
|
||
// That is left as it is rather than fixed with a drain-before-exit, because draining
|
||
// means waiting on a socket from `Unload`, which runs on the MAIN THREAD — the exact
|
||
// stall phase 1 removed when a 1.9-second reload showed up as
|
||
// `Calling 'Unload' took 1918ms`. A wedged peer would make it worse than the bug.
|
||
//
|
||
// What is actually lost is under a minute of one player's gathering, and only on a
|
||
// manual plugin reload: a real shutdown flushes at `OnServerShutdown`, and a player
|
||
// leaving flushes at their disconnect.
|
||
try
|
||
{
|
||
FlushTallies();
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Puts($"could not flush tallies on unload: {ex.Message}");
|
||
}
|
||
|
||
if (_cadence != null)
|
||
{
|
||
_cadence.Destroy();
|
||
_cadence = null;
|
||
}
|
||
|
||
if (_clansBoardSoon != null)
|
||
{
|
||
_clansBoardSoon.Destroy();
|
||
_clansBoardSoon = null;
|
||
}
|
||
|
||
// A half-drained permission sync is abandoned rather than finished, and the site is
|
||
// told nothing — which is right: the report would describe a set that was only partly
|
||
// applied, and the next sync re-derives the whole answer from the store anyway. What
|
||
// must not happen is the timer outliving the plugin and writing during a reload.
|
||
_permJob = null;
|
||
StopPermTimer();
|
||
|
||
// The same for an inventory being built: the site's request times out and it asks
|
||
// again from page 0, which is the only way it ever reads one.
|
||
_inventoryJob = null;
|
||
_inventory = null;
|
||
StopInventoryTimer();
|
||
|
||
// A config write waiting on a reload dies here too, and its files stay as written.
|
||
// That is the honest outcome: the rollback exists to undo an edit that took a plugin
|
||
// down, and this plugin being unloaded is not evidence of either — it is usually THIS
|
||
// plugin being reloaded by somebody. No `config.outcome` follows; the website calls a
|
||
// write it never heard about lost once twice `ceilingMs` has passed, and re-reads.
|
||
StopConfigWatch();
|
||
_configJob = null;
|
||
|
||
// Holds are NOT given back on unload. The file keeps them, and the next load re-arms
|
||
// them: a plugin reload is not the end of an event (§27.3).
|
||
StopLeaseTimer();
|
||
|
||
// Zones are not erased here by us — ZoneManager erases them itself as this plugin
|
||
// unloads — and they are NOT forgotten: the next load re-creates them (D92).
|
||
StopWorldTimer();
|
||
|
||
// A tally is kept across a reload — it records who was there, and the next load
|
||
// carries on counting — so what it has counted since the last minute's save is written
|
||
// now rather than lost.
|
||
StopTallyTimer();
|
||
|
||
if (_talliesDirty)
|
||
SaveTallies();
|
||
|
||
_running = false;
|
||
_stopping.Set();
|
||
Wake.Set();
|
||
|
||
Thread t = _link;
|
||
|
||
if (t != null && !t.Join(TimeSpan.FromSeconds(2.0)))
|
||
Puts("link thread did not stop cleanly");
|
||
|
||
_link = null;
|
||
_connected = false;
|
||
_tallies.Clear();
|
||
|
||
// A reload leaves the static queue holding lines addressed to a connection that no
|
||
// longer exists. Clearing it is the difference between a reload and a slow leak.
|
||
string discard;
|
||
while (Outbound.TryDequeue(out discard))
|
||
{
|
||
}
|
||
|
||
Interlocked.Exchange(ref _depth, 0);
|
||
}
|
||
|
||
// ---- configuration ----
|
||
|
||
private class PluginConfig
|
||
{
|
||
/// <summary>Where the sidecar listens. Loopback is the trust boundary; see the class docs.</summary>
|
||
public string Host = "127.0.0.1";
|
||
|
||
public int Port = 7799;
|
||
|
||
/// <summary>
|
||
/// How many outbound lines may wait for a wedged sidecar before the oldest is
|
||
/// dropped. Sized in lines rather than bytes because that is what is cheap to
|
||
/// count; the inbound cap is what bounds a single line's size.
|
||
/// </summary>
|
||
public int QueueCap = 5000;
|
||
|
||
/// <summary>
|
||
/// This server's stable identity across wipes and restarts, as the website knows
|
||
/// it. It is <b>not</b> derived from the hostname: an operator renames a server for
|
||
/// a season and the site must not lose its history for it.
|
||
/// </summary>
|
||
public string ServerId = "main";
|
||
|
||
/// <summary>
|
||
/// Whether the website may borrow values on this server for events (protocol 8,
|
||
/// D76). <b>Off until an operator turns it on.</b> Letting staff moderate from a
|
||
/// screen they are looking at, and letting a schedule change the world at four in
|
||
/// the morning, are different consents, and one switch cannot honestly express
|
||
/// both. It gates taking a lease only: listing and giving back always work, so
|
||
/// turning it off never strands a value somebody already borrowed.
|
||
/// </summary>
|
||
public bool EventsEnabled = false;
|
||
|
||
/// <summary>
|
||
/// The most crates one event step may place (protocol 9, D95). An operator may
|
||
/// lower it; <see cref="WorldHardMaxCrates"/> is the ceiling it cannot be raised
|
||
/// past. A step over the bound is refused, never trimmed to fit.
|
||
/// </summary>
|
||
public int EventsMaxCrates = WorldHardMaxCrates;
|
||
|
||
/// <summary>The most NPCs one event step may place (D95). Lowerable, like the above.</summary>
|
||
public int EventsMaxNpcs = WorldHardMaxNpcs;
|
||
|
||
/// <summary>
|
||
/// The most people one reward step may name (protocol 10, §29.5). The website reads
|
||
/// it and refuses a step over it; it is never trimmed to fit.
|
||
/// </summary>
|
||
public int EventsMaxRecipients = RecipientsHardMax;
|
||
|
||
/// <summary>The most tallies that may count at once on this server (§29.5).</summary>
|
||
public int EventsMaxTallies = TallyHardMax;
|
||
|
||
/// <summary>The longest line an event or a news post may say in chat, in characters.</summary>
|
||
public int EventsMaxChat = ChatHardMax;
|
||
|
||
/// <summary>
|
||
/// The most offline sleepers one map answer carries (protocol 11). Over it, the layer
|
||
/// answers the first this many and says it was cut short.
|
||
/// </summary>
|
||
public int MapMaxSleepers = MapDefaultMax;
|
||
|
||
/// <summary>The most tool cupboards and vending machines one map answer carries.</summary>
|
||
public int MapMaxBases = MapDefaultMax;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Runs only when this plugin has no config file yet — so it is the one moment a server id
|
||
/// can arrive from outside the file (D150). A Pterodactyl egg cannot write a framework's
|
||
/// config directory (Carbon lets an operator move it), but it can set an environment
|
||
/// variable, and `RUSTLINK_SERVER_ID` is the one the sidecar already reads for its
|
||
/// cross-check. After this write the file is canonical and the website's lock applies,
|
||
/// so changing the variable later changes nothing — which is what keeps a server's
|
||
/// history attached to one id.
|
||
/// </summary>
|
||
protected override void LoadDefaultConfig()
|
||
{
|
||
var config = new PluginConfig();
|
||
|
||
string fromEnv = Environment.GetEnvironmentVariable("RUSTLINK_SERVER_ID");
|
||
if (!string.IsNullOrEmpty(fromEnv))
|
||
{
|
||
if (IsValidServerId(fromEnv))
|
||
config.ServerId = fromEnv;
|
||
else
|
||
PrintWarning($"RUSTLINK_SERVER_ID '{fromEnv}' is not a valid server id " +
|
||
$"(lowercase letters, digits and '-', 1-64, not starting with '-'); " +
|
||
$"the new config keeps '{config.ServerId}'");
|
||
}
|
||
|
||
Config.WriteObject(config, true);
|
||
}
|
||
|
||
/// <summary>The website's rule for a server id, `^[a-z0-9][a-z0-9-]{0,63}$`, without a Regex.</summary>
|
||
private static bool IsValidServerId(string id)
|
||
{
|
||
if (string.IsNullOrEmpty(id) || id.Length > 64 || id[0] == '-')
|
||
return false;
|
||
|
||
foreach (char c in id)
|
||
{
|
||
bool ok = (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-';
|
||
if (!ok)
|
||
return false;
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
private void LoadConfigValues()
|
||
{
|
||
_config = Config.ReadObject<PluginConfig>() ?? new PluginConfig();
|
||
|
||
if (string.IsNullOrEmpty(_config.Host))
|
||
_config.Host = "127.0.0.1";
|
||
|
||
if (_config.Port <= 0 || _config.Port > 65535)
|
||
_config.Port = 7799;
|
||
|
||
// A cap of zero would drop every line on the way in and report a healthy link doing
|
||
// nothing, which is the worst failure available here.
|
||
if (_config.QueueCap < 16)
|
||
_config.QueueCap = 16;
|
||
|
||
if (string.IsNullOrEmpty(_config.ServerId))
|
||
_config.ServerId = "main";
|
||
|
||
// A config written before protocol 8 has no `EventsEnabled`, and a key that is not in
|
||
// the file is a key the website's config editor cannot show an operator. Written back
|
||
// once, with its default, so the switch is visible before anybody needs it.
|
||
// The two bounds are clamped to their ceilings rather than refused: a config asking
|
||
// for MORE than the plugin will ever place is an operator's typo, and the ceiling is
|
||
// what the website mirrors (D95).
|
||
_config.EventsMaxCrates = Math.Max(0, Math.Min(_config.EventsMaxCrates, WorldHardMaxCrates));
|
||
_config.EventsMaxNpcs = Math.Max(0, Math.Min(_config.EventsMaxNpcs, WorldHardMaxNpcs));
|
||
_config.EventsMaxRecipients = Math.Max(0, Math.Min(_config.EventsMaxRecipients, RecipientsHardMax));
|
||
_config.EventsMaxTallies = Math.Max(0, Math.Min(_config.EventsMaxTallies, TallyHardMax));
|
||
_config.EventsMaxChat = Math.Max(0, Math.Min(_config.EventsMaxChat, ChatHardMax));
|
||
_config.MapMaxSleepers = Math.Max(0, _config.MapMaxSleepers);
|
||
_config.MapMaxBases = Math.Max(0, _config.MapMaxBases);
|
||
|
||
if (Config["EventsEnabled"] == null || Config["EventsMaxCrates"] == null || Config["EventsMaxNpcs"] == null ||
|
||
Config["EventsMaxRecipients"] == null || Config["EventsMaxTallies"] == null || Config["EventsMaxChat"] == null ||
|
||
Config["MapMaxSleepers"] == null || Config["MapMaxBases"] == null)
|
||
Config.WriteObject(_config, true);
|
||
}
|
||
|
||
private void Loaded()
|
||
{
|
||
Puts($"protocol {ProtocolVersion}, serverId '{_config.ServerId}', sidecar {_config.Host}:{_config.Port}");
|
||
|
||
// Here rather than at `OnServerInitialized`: the link thread starts in `Init`, so a
|
||
// lease command can arrive before the world has finished loading, and it must find
|
||
// the holds already in place rather than have them loaded over it afterwards.
|
||
LoadLeases();
|
||
LoadWorld();
|
||
LoadRewards();
|
||
}
|
||
|
||
// ---- the outbound queue ----
|
||
|
||
/// <summary>
|
||
/// Main thread. Non-blocking. <paramref name="line"/> must already be a complete JSON
|
||
/// object with no embedded newline; the newline is appended by the writer as the frame
|
||
/// delimiter.
|
||
/// </summary>
|
||
private void Emit(string line)
|
||
{
|
||
if (line == null || !_running)
|
||
return;
|
||
|
||
int cap = _config != null ? _config.QueueCap : 5000;
|
||
|
||
// Drop-oldest. Bound first, then enqueue, so the queue can transiently sit one over the
|
||
// cap but never grows without limit.
|
||
while (Volatile.Read(ref _depth) >= cap)
|
||
{
|
||
string discard;
|
||
|
||
if (!Outbound.TryDequeue(out discard))
|
||
break;
|
||
|
||
Interlocked.Decrement(ref _depth);
|
||
Interlocked.Increment(ref _dropped);
|
||
}
|
||
|
||
Outbound.Enqueue(line);
|
||
Interlocked.Increment(ref _depth);
|
||
|
||
Wake.Set();
|
||
}
|
||
|
||
private void EmitFrame(Dictionary<string, object> frame)
|
||
{
|
||
try
|
||
{
|
||
Emit(JsonConvert.SerializeObject(frame, Formatting.None));
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
// A frame that cannot be serialized must not take the caller down with it — the
|
||
// caller is usually a game hook.
|
||
Puts($"could not serialize frame: {ex.Message}");
|
||
}
|
||
}
|
||
|
||
// ---- the link ----
|
||
|
||
private void LinkLoop()
|
||
{
|
||
int backoffMs = 500;
|
||
|
||
while (_running)
|
||
{
|
||
TcpClient client = null;
|
||
Thread reader = null;
|
||
System.Diagnostics.Stopwatch upFor = null;
|
||
|
||
int epoch = Interlocked.Increment(ref _epoch);
|
||
|
||
try
|
||
{
|
||
string host = _config != null ? _config.Host : "127.0.0.1";
|
||
int port = _config != null ? _config.Port : 7799;
|
||
|
||
client = new TcpClient();
|
||
client.NoDelay = true;
|
||
Connect(client, host, port);
|
||
|
||
NetworkStream stream = client.GetStream();
|
||
stream.WriteTimeout = 5000; // a wedged peer must surface as an error, not a hang
|
||
|
||
_dead = false;
|
||
_connected = true;
|
||
_loggedConnectFailure = false; // so a LATER outage is reported again
|
||
upFor = System.Diagnostics.Stopwatch.StartNew();
|
||
|
||
Interlocked.Increment(ref _connects);
|
||
Puts($"connected to {host}:{port}");
|
||
|
||
// Building the boards reads the world, so it must happen on the main thread.
|
||
// They are sent on every connect rather than once at startup: the sidecar
|
||
// restarts independently of the game, and anything it needs up front has to be
|
||
// re-sent per connection.
|
||
Interface.Oxide.NextTick(SendBoards);
|
||
|
||
NetworkStream localStream = stream;
|
||
reader = new Thread(() => ReadLoop(localStream, epoch))
|
||
{
|
||
Name = "RunicGateway Reader",
|
||
IsBackground = true
|
||
};
|
||
reader.Start();
|
||
|
||
WriteLoop(stream);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
// Socket exception messages carry EMBEDDED newlines on Windows — the refusal
|
||
// text and the endpoint arrive on separate lines — so trimming the ends is not
|
||
// enough to keep the Oxide log one entry per line.
|
||
string reason = Flatten(ex.Message);
|
||
|
||
if (_connected)
|
||
{
|
||
Puts($"link error: {reason}");
|
||
}
|
||
else if (!_loggedConnectFailure)
|
||
{
|
||
// The FIRST failure to connect, and only the first: a sidecar that is not
|
||
// running yet is normal at boot and would otherwise spam the console every
|
||
// few seconds, but a wrong Host or Port is otherwise silent for ever.
|
||
_loggedConnectFailure = true;
|
||
Puts($"cannot reach the sidecar: {reason} — retrying quietly until it answers");
|
||
}
|
||
}
|
||
finally
|
||
{
|
||
// Captured before it is cleared: a connection that ended because the READER saw
|
||
// EOF leaves the writer to exit cleanly, so nothing throws and the catch above
|
||
// never runs. Without this the link going down is invisible in the game console,
|
||
// which is the one place an operator looks first.
|
||
bool wasConnected = _connected;
|
||
|
||
_connected = false;
|
||
_dead = true;
|
||
|
||
try
|
||
{
|
||
if (client != null)
|
||
client.Close();
|
||
}
|
||
catch
|
||
{
|
||
// Closing a socket that is already gone is not news.
|
||
}
|
||
|
||
if (reader != null)
|
||
reader.Join(TimeSpan.FromSeconds(1.0));
|
||
|
||
if (wasConnected)
|
||
Puts("sidecar link lost; reconnecting");
|
||
}
|
||
|
||
if (!_running)
|
||
break;
|
||
|
||
// A link that held is an outage: start again from the shortest wait. One that was
|
||
// closed on arrival keeps doubling (see StableLinkMs).
|
||
if (upFor != null && upFor.ElapsedMilliseconds >= StableLinkMs)
|
||
backoffMs = 500;
|
||
|
||
// Nothing is listening yet, or the sidecar restarted. Both are normal.
|
||
//
|
||
// A handle, not `Thread.Sleep`: Unload runs on the MAIN thread and joins this one,
|
||
// so an uninterruptible sleep here is a stall of up to MaxBackoffMs on every reload
|
||
// — Oxide reports it as `Calling 'Unload' took 1918ms`, and the server is frozen for
|
||
// that whole time. Unload sets `_stopping`, so waiting on it turns that stall into
|
||
// an immediate return.
|
||
//
|
||
// `_stopping`, not `Wake`, for the reason `_stopping` gives: `Emit` signals `Wake`
|
||
// on every frame, so a backoff waited on it lasted until the next hook fired. That
|
||
// was invisible while a failed connect was silent; against a sidecar that refuses
|
||
// (D155) the phase 18 walk measured some 250 redials a second, each printing two
|
||
// console lines and rebuilding the boards.
|
||
_stopping.WaitOne(backoffMs);
|
||
backoffMs = Math.Min(backoffMs * 2, MaxBackoffMs);
|
||
}
|
||
|
||
_connected = false;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Connects with a bound, and with a way out.
|
||
///
|
||
/// <para>
|
||
/// The wait ends on whichever comes first: the connect completing, the plugin being
|
||
/// unloaded, or <see cref="ConnectTimeoutMs"/>. Without the middle one a reload against
|
||
/// an unreachable sidecar costs the game's main thread the whole join timeout —
|
||
/// observed as <c>hook 'Unload' took longer than 100ms [2002ms]</c> beside
|
||
/// <c>link thread did not stop cleanly</c>, which is phase 1's stall arriving by a
|
||
/// different road.
|
||
/// </para>
|
||
/// </summary>
|
||
private void Connect(TcpClient client, string host, int port)
|
||
{
|
||
IAsyncResult pending = client.BeginConnect(host, port, null, null);
|
||
|
||
int signalled = WaitHandle.WaitAny(
|
||
new WaitHandle[] { pending.AsyncWaitHandle, _stopping }, ConnectTimeoutMs);
|
||
|
||
if (signalled != 0)
|
||
{
|
||
// Closing the client is what abandons the in-flight connect; the socket would
|
||
// otherwise stay open behind an IAsyncResult nobody is going to end.
|
||
try
|
||
{
|
||
client.Close();
|
||
}
|
||
catch
|
||
{
|
||
}
|
||
|
||
throw new System.IO.IOException(
|
||
signalled == 1
|
||
? "unloading"
|
||
: $"connect to {host}:{port} timed out after {ConnectTimeoutMs}ms");
|
||
}
|
||
|
||
// Throws the real socket error — a refusal, an unknown host — which is what the caller
|
||
// logs. Only reached when the connect actually completed or failed on its own.
|
||
client.EndConnect(pending);
|
||
}
|
||
|
||
private void WriteLoop(NetworkStream stream)
|
||
{
|
||
while (_running && !_dead)
|
||
{
|
||
string line;
|
||
|
||
if (!Outbound.TryDequeue(out line))
|
||
{
|
||
Wake.WaitOne(250);
|
||
continue;
|
||
}
|
||
|
||
Interlocked.Decrement(ref _depth);
|
||
|
||
try
|
||
{
|
||
byte[] bytes = Encoding.UTF8.GetBytes(line + "\n");
|
||
stream.Write(bytes, 0, bytes.Length);
|
||
Interlocked.Increment(ref _sent);
|
||
}
|
||
catch (Exception)
|
||
{
|
||
// The record is already off the queue. Count it and let the outer loop
|
||
// reconnect; re-queueing risks an unbounded retry storm against a dead peer.
|
||
Interlocked.Increment(ref _writeErrors);
|
||
_dead = true;
|
||
throw;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Reader thread. Splits the stream into lines as <b>bytes</b> and decodes each finished
|
||
/// line as UTF-8.
|
||
///
|
||
/// <para>
|
||
/// Bytes, not characters, because a character can straddle two reads: the first half of
|
||
/// an em dash at the end of one buffer and the rest at the start of the next. Splitting
|
||
/// on the byte is safe where splitting the text is not — UTF-8 never uses <c>0x0A</c>
|
||
/// inside a multi-byte sequence, so a newline byte is always a newline — and decoding
|
||
/// the whole line at once is what keeps the halves together. The old loop cast each byte
|
||
/// to a <c>char</c>, which is Latin-1, and turned every non-ASCII character the website
|
||
/// sent into two or three wrong ones: announcements, titles, kit names, and the text of
|
||
/// every configuration file saved from the site (F15).
|
||
/// </para>
|
||
/// </summary>
|
||
private void ReadLoop(NetworkStream stream, int epoch)
|
||
{
|
||
byte[] buffer = new byte[8192];
|
||
var line = new MemoryStream(512);
|
||
bool discarding = false;
|
||
|
||
try
|
||
{
|
||
while (_running && !_dead)
|
||
{
|
||
int read = stream.Read(buffer, 0, buffer.Length);
|
||
|
||
if (read <= 0)
|
||
break; // clean EOF: the sidecar closed. Normal.
|
||
|
||
for (int i = 0; i < read; i++)
|
||
{
|
||
byte b = buffer[i];
|
||
|
||
if (b == (byte)'\n')
|
||
{
|
||
if (discarding)
|
||
{
|
||
// The tail of a line already given up on. Swallowing it here is
|
||
// what stops the remainder being parsed as a line of its own.
|
||
discarding = false;
|
||
}
|
||
else
|
||
{
|
||
Dispatch(Encoding.UTF8.GetString(line.GetBuffer(), 0, (int)line.Length));
|
||
}
|
||
|
||
line.SetLength(0);
|
||
}
|
||
else if (b != (byte)'\r')
|
||
{
|
||
if (discarding)
|
||
continue;
|
||
|
||
line.WriteByte(b);
|
||
|
||
if (line.Length > MaxInboundLineBytes)
|
||
{
|
||
Puts("inbound line over the cap; discarding to the next newline");
|
||
line.SetLength(0);
|
||
discarding = true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
catch (System.IO.IOException)
|
||
{
|
||
// Expected when the peer vanishes mid-read.
|
||
}
|
||
catch (ObjectDisposedException)
|
||
{
|
||
// Expected when Unload closes the socket under us.
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Puts($"reader error: {ex.Message}");
|
||
}
|
||
finally
|
||
{
|
||
// Only tear down the connection this reader actually owned.
|
||
if (Volatile.Read(ref _epoch) == epoch)
|
||
{
|
||
_dead = true;
|
||
Wake.Set(); // let the writer notice and fall through to reconnect
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Reader thread. Marshals to the main thread; nothing that touches the game happens
|
||
/// here.
|
||
/// </summary>
|
||
private void Dispatch(string line)
|
||
{
|
||
if (string.IsNullOrEmpty(line))
|
||
return;
|
||
|
||
Interlocked.Increment(ref _received);
|
||
|
||
Interface.Oxide.NextTick(() =>
|
||
{
|
||
try
|
||
{
|
||
HandleCommand(line);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
// A malformed command must never escape into a game code path.
|
||
Puts($"inbound handler threw: {ex}");
|
||
}
|
||
});
|
||
}
|
||
|
||
// ---- commands ----
|
||
|
||
/// <summary>Main thread. Every world read below is safe only because of that.</summary>
|
||
private void HandleCommand(string line)
|
||
{
|
||
Dictionary<string, object> cmd;
|
||
|
||
try
|
||
{
|
||
cmd = JsonConvert.DeserializeObject<Dictionary<string, object>>(line);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Puts($"unparseable command: {ex.Message}");
|
||
return;
|
||
}
|
||
|
||
if (cmd == null)
|
||
return;
|
||
|
||
object verbObj;
|
||
if (!cmd.TryGetValue("cmd", out verbObj) || verbObj == null)
|
||
return;
|
||
|
||
string verb = verbObj.ToString();
|
||
object reqIdObj;
|
||
string reqId = cmd.TryGetValue("reqId", out reqIdObj) && reqIdObj != null
|
||
? reqIdObj.ToString()
|
||
: null;
|
||
|
||
switch (verb)
|
||
{
|
||
case "ping":
|
||
// `control`: neither kept nor forwarded. It exists to move the sidecar's
|
||
// last-seen clock on a server nobody is playing on.
|
||
EmitFrame(Frame("pong", "control"));
|
||
break;
|
||
|
||
case "server.status":
|
||
Dictionary<string, object> status = Frame("server.status", "reply");
|
||
Merge(status, ServerBody());
|
||
|
||
// The correlation id is echoed verbatim, and only when one was supplied. A
|
||
// reply that invents one would be routed to nobody; a reply that omits one the
|
||
// caller sent would leave that caller waiting out its whole timeout.
|
||
if (reqId != null)
|
||
status["reqId"] = reqId;
|
||
|
||
EmitFrame(status);
|
||
break;
|
||
|
||
case "link.confirm":
|
||
// The first command the WEBSITE originates (protocol 3). Everything before it
|
||
// was the game talking, or the sidecar asking the game to say something it
|
||
// already knew.
|
||
HandleLinkConfirm(cmd, reqId);
|
||
break;
|
||
|
||
case "perm.catalogue":
|
||
// Protocol 4. What this server's loaded plugins have registered — the option
|
||
// source the website's authoring form offers, so a grant can only be written
|
||
// against a name that will actually resolve.
|
||
HandlePermCatalogue(reqId);
|
||
break;
|
||
|
||
case "perm.sync":
|
||
// The first command that WRITES to the game (protocol 4, R2). Everything
|
||
// before it read, or spent a code the game itself had minted.
|
||
HandlePermSync(cmd, reqId);
|
||
break;
|
||
|
||
case "perm.inventory":
|
||
// Protocol 13 (D160). The whole store, owners included, in pages — what the
|
||
// site reads before every sync now that it owns all of it.
|
||
HandlePermInventory(cmd, reqId);
|
||
break;
|
||
|
||
case "config.list":
|
||
// Protocol 5 (R18). What settings files exist on this host, and which plugins
|
||
// are loaded to reload them. A description of the tree, never its contents.
|
||
HandleConfigList(reqId);
|
||
break;
|
||
|
||
case "config.read":
|
||
HandleConfigRead(cmd, reqId);
|
||
break;
|
||
|
||
case "config.write":
|
||
// The first command that writes to the game HOST's filesystem, which is the
|
||
// most powerful thing the website can do to a server.
|
||
HandleConfigWrite(cmd, reqId);
|
||
break;
|
||
|
||
case "lease.list":
|
||
// Protocol 8 (PLAN.md §27). What this server lends, what it holds now, and
|
||
// every hold in force — one read serving the website's `read()` and
|
||
// `inForce()` alike.
|
||
HandleLeaseList(cmd, reqId);
|
||
break;
|
||
|
||
case "lease.apply":
|
||
HandleLeaseApply(cmd, reqId);
|
||
break;
|
||
|
||
case "lease.release":
|
||
HandleLeaseRelease(cmd, reqId);
|
||
break;
|
||
|
||
case "world.monuments":
|
||
// Protocol 9 (PLAN.md §28). The monuments this wipe's map has, for the
|
||
// authoring form — and the same vocabulary a step's location is written in.
|
||
HandleWorldMonuments(reqId);
|
||
break;
|
||
|
||
case "world.zone":
|
||
HandleWorldZone(cmd, reqId);
|
||
break;
|
||
|
||
case "world.place":
|
||
HandleWorldPlace(cmd, reqId);
|
||
break;
|
||
|
||
case "world.revert":
|
||
HandleWorldRevert(cmd, reqId);
|
||
break;
|
||
|
||
case "world.owned":
|
||
HandleWorldOwned(cmd, reqId);
|
||
break;
|
||
|
||
case "tally.open":
|
||
// Protocol 10 (PLAN.md §29). Who takes part in a run, counted by the game.
|
||
HandleTallyOpen(cmd, reqId);
|
||
break;
|
||
|
||
case "tally.snapshot":
|
||
HandleTallySnapshot(cmd, reqId);
|
||
break;
|
||
|
||
case "tally.close":
|
||
HandleTallyClose(cmd, reqId);
|
||
break;
|
||
|
||
case "kits.list":
|
||
HandleKitsList(reqId);
|
||
break;
|
||
|
||
case "chat.say":
|
||
HandleChatSay(cmd, reqId);
|
||
break;
|
||
|
||
case "titles.set":
|
||
// Protocol 12. The chat titles players have earned, as one whole set that
|
||
// replaces the last (PLAN.md §33).
|
||
HandleTitlesSet(cmd, reqId);
|
||
break;
|
||
|
||
case "map.info":
|
||
// Protocol 11 (PLAN.md §30). What this map is and where its picture comes from.
|
||
HandleMapInfo(reqId);
|
||
break;
|
||
|
||
case "map.fetch":
|
||
HandleMapFetch(cmd, reqId);
|
||
break;
|
||
|
||
case "map.render":
|
||
HandleMapRender(cmd, reqId);
|
||
break;
|
||
|
||
case "map.live":
|
||
HandleMapLive(reqId);
|
||
break;
|
||
|
||
default:
|
||
Puts($"unknown command '{verb}'");
|
||
break;
|
||
}
|
||
}
|
||
|
||
// ---- frames ----
|
||
|
||
/// <summary>
|
||
/// Builds the envelope every outbound frame shares (PROTOCOL.md §8.1). Nothing may
|
||
/// emit a frame it did not get from here.
|
||
///
|
||
/// <para>
|
||
/// <paramref name="type"/> is what the sidecar files on — <c>event</c> is appended to
|
||
/// history, <c>snapshot</c> replaces a board, <c>reply</c> is routed by
|
||
/// <c>reqId</c>, <c>control</c> is neither kept nor forwarded. It is the reason the
|
||
/// sidecar needs no arm per kind and the reason the catalogue can grow for free.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// <c>wipeId</c> is <b>omitted</b> when the server has never saved. A server with no
|
||
/// save has no wipe, and the difference between an absent field and an empty string is
|
||
/// the difference between a fact and a row that sorts beside every other empty string
|
||
/// for ever.
|
||
/// </para>
|
||
/// </summary>
|
||
private Dictionary<string, object> Frame(string kind, string type)
|
||
{
|
||
var frame = new Dictionary<string, object>
|
||
{
|
||
{ "kind", kind },
|
||
{ "type", type },
|
||
{ "t", NowMs() },
|
||
{ "serverId", _config != null ? _config.ServerId : "main" }
|
||
};
|
||
|
||
// Resolved lazily while it is still unknown, which covers the case above on a
|
||
// framework that never fires `OnServerInitialized` — and costs one static property
|
||
// read per frame only until the answer exists.
|
||
if (string.IsNullOrEmpty(_wipeId))
|
||
_wipeId = ResolveWipeId();
|
||
|
||
if (!string.IsNullOrEmpty(_wipeId))
|
||
frame["wipeId"] = _wipeId;
|
||
|
||
return frame;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Main thread. Both boards, in the order a reader wants them: what the server is,
|
||
/// then who is on it.
|
||
///
|
||
/// <para>
|
||
/// Sent on every connect and every cadence tick. A board is current state with exactly
|
||
/// one producer, re-sent rather than reconstructed — which is what makes "a restarted
|
||
/// sidecar is fully populated within one connection" true by construction instead of
|
||
/// by hoping nothing was in flight.
|
||
/// </para>
|
||
/// </summary>
|
||
private void SendBoards()
|
||
{
|
||
SendHello();
|
||
SendPlayersBoard();
|
||
|
||
// Last, and fenced: the clan board reads the game's clan database, which is the one
|
||
// board here built from something other than a list in memory. A failure there must
|
||
// cost the clans and nothing else — the two boards above are what every page renders.
|
||
try
|
||
{
|
||
SendClansBoard();
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Puts($"could not build the clans board: {Flatten(ex.Message)}");
|
||
}
|
||
}
|
||
|
||
/// <summary>Main thread.</summary>
|
||
private void SendHello()
|
||
{
|
||
Dictionary<string, object> hello = Frame("server.hello", "snapshot");
|
||
|
||
Merge(hello, ServerBody());
|
||
|
||
hello["protocol"] = ProtocolVersion;
|
||
hello["bootId"] = _bootId;
|
||
hello["plugin"] = Version.ToString();
|
||
|
||
// The website compares boot ids to decide when to reconcile the events ledger, and
|
||
// must not do it until the world it would be asking about exists (§28.6).
|
||
hello["worldReady"] = _worldReady;
|
||
|
||
// The ZoneManager helper (D182), only where there is a ZoneManager for it to help.
|
||
// `patched` is the whole fix; anything else means this plugin scores its zones by
|
||
// position and ZoneManager's flags miss somebody already inside a new zone.
|
||
if (ZoneManagerPlugin() != null)
|
||
{
|
||
var helper = new Dictionary<string, object> { { "state", ZoneHelperState() } };
|
||
Plugin zones = ZoneHelperPlugin();
|
||
|
||
if (zones != null)
|
||
{
|
||
helper["version"] = zones.Version.ToString();
|
||
|
||
if (zones.Call("StateReason") is string reason)
|
||
helper["reason"] = reason;
|
||
}
|
||
|
||
hello["zoneHelper"] = helper;
|
||
|
||
// D211: ZoneManager's own flag list, for the site's zone presets (§3.1). In hello
|
||
// rather than every status frame, because it changes only when ZoneManager does,
|
||
// and a ZoneManager load sends hello again.
|
||
var zoneManager = new Dictionary<string, object> { { "version", ZoneManagerPlugin().Version.ToString() } };
|
||
List<string> flags = ZoneFlagNames(ZoneManagerPlugin());
|
||
|
||
if (flags != null)
|
||
zoneManager["flags"] = flags;
|
||
|
||
hello["zoneManager"] = zoneManager;
|
||
}
|
||
|
||
EmitFrame(hello);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Main thread. Who is connected right now, as the board the far side reconciles
|
||
/// against — the connect and disconnect events carry the story in between.
|
||
/// </summary>
|
||
private void SendPlayersBoard()
|
||
{
|
||
var players = new List<object>();
|
||
|
||
if (BasePlayer.activePlayerList != null)
|
||
{
|
||
foreach (BasePlayer player in BasePlayer.activePlayerList)
|
||
{
|
||
if (player == null)
|
||
continue;
|
||
|
||
var row = new Dictionary<string, object>
|
||
{
|
||
{ "steamId", SteamIdOf(player) },
|
||
{ "name", player.displayName },
|
||
{ "sleeping", player.IsSleeping() }
|
||
};
|
||
|
||
long since;
|
||
if (_connectedAt.TryGetValue(player.userID, out since))
|
||
row["connectedAt"] = since;
|
||
|
||
players.Add(row);
|
||
}
|
||
}
|
||
|
||
Dictionary<string, object> frame = Frame("players.online", "snapshot");
|
||
frame["count"] = players.Count;
|
||
frame["players"] = players;
|
||
|
||
EmitFrame(frame);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Main thread. The shared body of <c>server.hello</c> and <c>server.status</c> — the
|
||
/// two frames differ in what wraps them, not in what they say about the server, and
|
||
/// letting them drift is how a site ends up showing two different player counts.
|
||
/// </summary>
|
||
private Dictionary<string, object> ServerBody()
|
||
{
|
||
var status = new Dictionary<string, object>
|
||
{
|
||
{ "hostname", ConVar.Server.hostname },
|
||
{ "description", ConVar.Server.description },
|
||
{ "level", ConVar.Server.level },
|
||
{ "seed", ConVar.Server.seed },
|
||
{ "worldSize", ConVar.Server.worldsize },
|
||
{ "maxPlayers", ConVar.Server.maxplayers },
|
||
{ "players", BasePlayer.activePlayerList != null ? BasePlayer.activePlayerList.Count : 0 },
|
||
{ "joining", ServerMgr.Instance != null ? ServerMgr.Instance.connectionQueue.Joining : 0 },
|
||
{ "queued", ServerMgr.Instance != null ? ServerMgr.Instance.connectionQueue.Queued : 0 },
|
||
{ "uptimeSec", (int)UnityEngine.Time.realtimeSinceStartup }
|
||
};
|
||
|
||
// The instant `wipeId` is derived from, kept in its own right: the id is for grouping
|
||
// rows and this is for showing a date, and deriving one back from the other is the
|
||
// kind of parsing nobody should have to do twice.
|
||
try
|
||
{
|
||
status["saveCreatedAt"] = SaveRestore.SaveCreatedTime.ToUniversalTime()
|
||
.ToString("yyyy-MM-ddTHH:mm:ssZ", System.Globalization.CultureInfo.InvariantCulture);
|
||
}
|
||
catch (Exception)
|
||
{
|
||
// A server that has never saved has no created time. Absent, not zero.
|
||
}
|
||
|
||
// Protocol 12: which of the optional mods this server has, so Admin → Rust can say why
|
||
// a title or a popup is not showing before anybody goes looking (§33.2).
|
||
status["integrations"] = IntegrationsBody();
|
||
|
||
return status;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Puts an IP address onto a frame, or leaves it off.
|
||
///
|
||
/// <para>
|
||
/// The game does not always have one. A console <c>banid</c> against an offline id
|
||
/// produces the literal string <b><c>"0"</c></b> rather than an address or a null —
|
||
/// observed on a live server, not guessed — and a database column full of "0" is worse
|
||
/// than one full of nulls, because "0" survives every is-it-missing test a reader
|
||
/// writes and then fails whatever parses it.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// Absent, not zero. The same rule `saveCreatedAt` and `sessionSec` follow.
|
||
/// </para>
|
||
/// </summary>
|
||
private static void PutAddress(Dictionary<string, object> frame, string ip)
|
||
{
|
||
if (string.IsNullOrEmpty(ip) || ip == "0" || ip == "0.0.0.0")
|
||
return;
|
||
|
||
frame["ip"] = ip;
|
||
}
|
||
|
||
private static void Merge(Dictionary<string, object> into, Dictionary<string, object> from)
|
||
{
|
||
foreach (KeyValuePair<string, object> pair in from)
|
||
into[pair.Key] = pair.Value;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Describes who or what killed somebody, onto <paramref name="frame"/>.
|
||
///
|
||
/// <para>
|
||
/// Four outcomes, and collapsing any two of them loses something a killfeed needs:
|
||
/// <c>player</c> (a real kill, and the only one that belongs on a leaderboard),
|
||
/// <c>self</c>, <c>npc</c> and <c>environment</c> — falling, drowning, a helicopter's
|
||
/// fire, or the world simply deciding. `HitInfo` may legitimately be null, which is
|
||
/// the environment case and not an error.
|
||
/// </para>
|
||
/// </summary>
|
||
private static void DescribeAttacker(
|
||
Dictionary<string, object> frame, BasePlayer victim, HitInfo info)
|
||
{
|
||
BaseEntity initiator = info != null ? info.Initiator : null;
|
||
BasePlayer attacker = info != null ? info.InitiatorPlayer : null;
|
||
|
||
string weapon = 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;
|
||
}
|
||
|
||
float distance = DistanceOf(info, victim);
|
||
|
||
if (distance > 0f)
|
||
frame["distance"] = (float)Math.Round(distance, 1);
|
||
}
|
||
|
||
/// <summary>
|
||
/// The killing blow's weapon as the killfeed names it: the held entity's short prefab
|
||
/// name (<c>bow_hunting.entity</c>), or what was thrown or placed. Null when the hit
|
||
/// carries neither. The tally's weapon kills (§5.3) read this same name, so the lists
|
||
/// the site keeps are lists of what the killfeed already shows.
|
||
/// </summary>
|
||
private static string WeaponOf(HitInfo info)
|
||
{
|
||
if (info == null)
|
||
return null;
|
||
|
||
if (info.WeaponPrefab != null)
|
||
return info.WeaponPrefab.ShortPrefabName;
|
||
|
||
return info.Weapon != null ? info.Weapon.ShortPrefabName : null;
|
||
}
|
||
|
||
/// <summary>
|
||
/// How far the killing blow travelled, in metres: the projectile's own distance, or the
|
||
/// gap between the two when there was no projectile. 0 when neither can be read.
|
||
/// </summary>
|
||
private static float DistanceOf(HitInfo info, BaseEntity victim)
|
||
{
|
||
if (info == null)
|
||
return 0f;
|
||
|
||
float distance = info.ProjectileDistance;
|
||
BaseEntity initiator = info.Initiator;
|
||
|
||
if (distance <= 0f && initiator != null && initiator.transform != null
|
||
&& victim != null && victim.transform != null)
|
||
distance = UnityEngine.Vector3.Distance(
|
||
initiator.transform.position, victim.transform.position);
|
||
|
||
return distance;
|
||
}
|
||
|
||
// ---- the map grid ----
|
||
|
||
/// <summary>
|
||
/// An entity's position as Rust's own map reference (<c>H7</c>), or null if it has no
|
||
/// position to read. A reference, never a coordinate: a grid cell is about 150 m
|
||
/// across, which is precise enough to say where a fight happened and far too coarse to
|
||
/// walk to somebody's door with.
|
||
/// </summary>
|
||
private static string GridOf(BaseEntity entity)
|
||
{
|
||
if (entity == null || entity.transform == null)
|
||
return null;
|
||
|
||
return Grid(entity.transform.position);
|
||
}
|
||
|
||
/// <summary>
|
||
/// The game's own label for a position, from <c>MapHelper</c> — the function the in-game
|
||
/// map draws its grid with (D119).
|
||
///
|
||
/// <para>
|
||
/// Phases 3 to 13 computed this here from a 146.3 m cell, the number community tools
|
||
/// quote. It is wrong: the game fits a whole number of cells to the world, so a 3000 map
|
||
/// is 20 cells of exactly 150 m, and the constant put every row below the first a row
|
||
/// too far south (the ferry terminal read O17 where the game says O16). Nobody could
|
||
/// see it without a second opinion, and the map's grid was the second opinion.
|
||
/// </para>
|
||
/// </summary>
|
||
private static string Grid(UnityEngine.Vector3 position)
|
||
{
|
||
if (ConVar.Server.worldsize <= 0)
|
||
return null;
|
||
|
||
try
|
||
{
|
||
return MapHelper.PositionToString(position);
|
||
}
|
||
catch (Exception)
|
||
{
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// ---- identity of the world ----
|
||
|
||
/// <summary>
|
||
/// The current wipe, as <c>w-yyyyMMddTHHmmssZ</c>, or null if this server has never
|
||
/// saved.
|
||
///
|
||
/// <para>
|
||
/// Protocol 1 left this to the website. Protocol 2 derives it here because by now
|
||
/// three components store rows that need it, and the plugin is the only one of the
|
||
/// three that can read the value — the other two would be re-deriving something they
|
||
/// were already told, and two derivations of one fact eventually disagree about a
|
||
/// boundary.
|
||
/// </para>
|
||
/// </summary>
|
||
private static string ResolveWipeId()
|
||
{
|
||
try
|
||
{
|
||
DateTime created = SaveRestore.SaveCreatedTime.ToUniversalTime();
|
||
|
||
// A server that has never saved reports the bottom of the calendar. That is not a
|
||
// wipe in 0001, it is the absence of one.
|
||
if (created.Year < 2000)
|
||
return null;
|
||
|
||
return "w-" + created.ToString(
|
||
"yyyyMMdd'T'HHmmss'Z'", System.Globalization.CultureInfo.InvariantCulture);
|
||
}
|
||
catch (Exception)
|
||
{
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Records that a hook fired. See <c>_hookCounts</c> for why this exists at all.
|
||
/// </summary>
|
||
private void MarkHook(string name)
|
||
{
|
||
long count;
|
||
_hookCounts.TryGetValue(name, out count);
|
||
_hookCounts[name] = count + 1;
|
||
}
|
||
|
||
// ---- the read path ----
|
||
//
|
||
// Every hook below obeys three rules, and each of them has a failure behind it.
|
||
//
|
||
// 1. **A read-path hook never vetoes.** Four of these are documented by uMod as
|
||
// "returning a non-null value overrides default behavior" — a bridge that returned
|
||
// something by accident would cancel a death, swallow a player's wood or refuse a
|
||
// login, on somebody's production server at 3am. So they are declared `void`: both
|
||
// frameworks take the method's return value, and a void method has none. The rule is
|
||
// enforced by the signature rather than by remembering to write `return null`, which
|
||
// is the only version of it that survives a year of edits.
|
||
//
|
||
// 2. **A hook that can fire more than once a second per player is a counter, not an
|
||
// event.** `OnDispenserGather` fires on every swing at a tree. It accumulates into
|
||
// `_tallies` and leaves as one `player.tally` frame a minute (PROTOCOL.md §8.6).
|
||
//
|
||
// 3. **Every hook marks itself.** `MarkHook` is what makes `rg.hooks` able to say which
|
||
// of these actually fire on the framework this server is running — the standing
|
||
// answer both to Facepunch renaming a hook and to a framework catalogue that does not
|
||
// list one.
|
||
//
|
||
// All of it is the main thread, which is what makes reading `BasePlayer`, `ConVar` and a
|
||
// transform legal here and illegal in the reader thread.
|
||
|
||
private void OnServerInitialized(bool initial)
|
||
{
|
||
MarkHook("OnServerInitialized");
|
||
|
||
// Re-read the wipe, and this is not belt-and-braces: at a real server
|
||
// start `Init` runs BEFORE the save is loaded, so `SaveCreatedTime` is not yet
|
||
// meaningful and the id resolved there is null for the entire session. This hook is
|
||
// the first moment the world is actually there.
|
||
//
|
||
// It was invisible on a development rig for the reason such things usually are: a
|
||
// hot-reloaded plugin reads an already-loaded world and gets the right answer every
|
||
// time. It took a server that BOOTED with the plugin installed — which is every real
|
||
// one — to show `wipeId=none` on a server with a save sitting on disk.
|
||
_wipeId = ResolveWipeId();
|
||
|
||
// Only now is the wipe known, so only now can what an earlier map owned be dropped;
|
||
// and this hook also fires on a hotload, which is exactly when ZoneManager has just
|
||
// erased every zone the previous load of this plugin owned (D92).
|
||
_worldReady = true;
|
||
SeedRegisteredPerms();
|
||
DropWorldFromOtherWipes();
|
||
RecreateZones("load");
|
||
|
||
// The map's sets are seeded here for the same reason: a hotload hears no spawns.
|
||
SeedMapSets();
|
||
|
||
// The titles this plugin last held, if they are this wipe's (D144), and then the getter
|
||
// handed to BetterChat, if it is here. When it loads later, or reloads and forgets us,
|
||
// OnPluginLoaded does this again (§33.1).
|
||
LoadTitles();
|
||
RegisterTitles("load");
|
||
|
||
// Started here rather than in Init: this hook fires both at the end of server startup
|
||
// and immediately on a hotload, so it is the one place that runs exactly once per
|
||
// load with the world available either way.
|
||
if (_cadence == null)
|
||
_cadence = timer.Every(BoardCadenceSeconds, Cadence);
|
||
|
||
// Say the world is ready now rather than at the next tick: the website holds its
|
||
// reconcile until it hears this.
|
||
if (_connected)
|
||
SendHello();
|
||
|
||
Dictionary<string, object> frame = Frame("server.initialized", "event");
|
||
frame["initial"] = initial;
|
||
EmitFrame(frame);
|
||
}
|
||
|
||
private void OnServerShutdown()
|
||
{
|
||
MarkHook("OnServerShutdown");
|
||
FlushTallies();
|
||
|
||
if (_talliesDirty)
|
||
SaveTallies();
|
||
EmitFrame(Frame("server.shutdown", "event"));
|
||
}
|
||
|
||
/// <summary>
|
||
/// A new save is the only moment a wipe id can change, so it is the only place that
|
||
/// re-reads it. The frame carries both ids because "which wipe did this replace" is
|
||
/// the question a site asks when it draws the boundary, and it is unanswerable
|
||
/// afterwards.
|
||
/// </summary>
|
||
private void OnNewSave(string filename)
|
||
{
|
||
MarkHook("OnNewSave");
|
||
|
||
string previous = _wipeId;
|
||
_wipeId = ResolveWipeId();
|
||
|
||
// Built after the re-read, so `wipeId` on the envelope is the NEW wipe: this frame
|
||
// belongs to the world it opens, not to the one it closes.
|
||
Dictionary<string, object> frame = Frame("server.wipe", "event");
|
||
|
||
if (!string.IsNullOrEmpty(previous))
|
||
frame["previousWipeId"] = previous;
|
||
|
||
EmitFrame(frame);
|
||
|
||
// Nothing from the old world is still true. The tallies are one such piece of state
|
||
// this plugin holds.
|
||
_tallies.Clear();
|
||
_connectedAt.Clear();
|
||
|
||
// Nor is anything an event placed: the old map, and everything on it, is gone.
|
||
DropWorldFromOtherWipes();
|
||
|
||
// A run's tally is NOT dropped: it records people, not the map, and a zone it counted
|
||
// in simply stops gaining anybody. Kit credits are kept too — unless Kits is about to
|
||
// clear its use counts, when the ones moved into them go with it (§29.3).
|
||
ResetCreditsForWipe();
|
||
|
||
Puts($"new save; wipe is now {(_wipeId ?? "unknown")}");
|
||
}
|
||
|
||
private void OnPlayerConnected(BasePlayer player)
|
||
{
|
||
MarkHook("OnPlayerConnected");
|
||
|
||
if (player == null)
|
||
return;
|
||
|
||
_connectedAt[player.userID] = NowMs();
|
||
|
||
Dictionary<string, object> frame = Frame("player.connected", "event");
|
||
frame["steamId"] = SteamIdOf(player);
|
||
frame["name"] = player.displayName;
|
||
EmitFrame(frame);
|
||
}
|
||
|
||
private void OnPlayerDisconnected(BasePlayer player, string reason)
|
||
{
|
||
MarkHook("OnPlayerDisconnected");
|
||
|
||
if (player == null)
|
||
return;
|
||
|
||
// Before the frame: a session's last minute of gathering is worth more than the order
|
||
// of two lines, and after this point there is nothing to attribute it to.
|
||
FlushTally(player.userID);
|
||
|
||
Dictionary<string, object> frame = Frame("player.disconnected", "event");
|
||
frame["steamId"] = SteamIdOf(player);
|
||
frame["name"] = player.displayName;
|
||
|
||
if (!string.IsNullOrEmpty(reason))
|
||
frame["reason"] = Flatten(reason);
|
||
|
||
long since;
|
||
if (_connectedAt.TryGetValue(player.userID, out since))
|
||
{
|
||
frame["sessionSec"] = (int)((NowMs() - since) / 1000L);
|
||
_connectedAt.Remove(player.userID);
|
||
}
|
||
|
||
// A player who was already on when this plugin loaded has no recorded connect, so
|
||
// `sessionSec` is ABSENT rather than zero: a session of unknown length is not a
|
||
// session of no length, and summing zeros silently under-reports playtime for ever.
|
||
|
||
EmitFrame(frame);
|
||
}
|
||
|
||
private void OnPlayerRespawned(BasePlayer player)
|
||
{
|
||
MarkHook("OnPlayerRespawned");
|
||
|
||
if (player == null)
|
||
return;
|
||
|
||
Dictionary<string, object> frame = Frame("player.respawned", "event");
|
||
frame["steamId"] = SteamIdOf(player);
|
||
frame["name"] = player.displayName;
|
||
EmitFrame(frame);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Void, and deliberately: uMod documents a non-null return here as overriding the
|
||
/// death itself. See rule 1 at the top of this region.
|
||
/// </summary>
|
||
private void OnPlayerDeath(BasePlayer player, HitInfo info)
|
||
{
|
||
MarkHook("OnPlayerDeath");
|
||
|
||
if (player == null || player.IsNpc)
|
||
return;
|
||
|
||
// A tally's player kill (D98): the last hit, never your own.
|
||
BasePlayer killer = info != null ? info.InitiatorPlayer : null;
|
||
|
||
if (killer != null && !killer.IsNpc && (ulong)killer.userID != (ulong)player.userID)
|
||
{
|
||
CreditKill(killer, true);
|
||
|
||
// The kill itself is `player.death`'s, counted by the site from this frame; the
|
||
// tally adds only what a title needs about it (§5.1).
|
||
TallyFor(killer).Killed(info, player, true);
|
||
}
|
||
|
||
Dictionary<string, object> frame = Frame("player.death", "event");
|
||
frame["steamId"] = SteamIdOf(player);
|
||
frame["name"] = player.displayName;
|
||
frame["sleeping"] = player.IsSleeping();
|
||
|
||
string grid = GridOf(player);
|
||
if (grid != null)
|
||
frame["grid"] = grid;
|
||
|
||
DescribeAttacker(frame, player, info);
|
||
EmitFrame(frame);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Void, and deliberately: a non-null return here overrides chat delivery — the bridge
|
||
/// would be silently eating messages.
|
||
/// </summary>
|
||
private void OnPlayerChat(BasePlayer player, string message, ConVar.Chat.ChatChannel channel)
|
||
{
|
||
MarkHook("OnPlayerChat");
|
||
|
||
if (player == null || string.IsNullOrEmpty(message))
|
||
return;
|
||
|
||
Dictionary<string, object> frame = Frame("player.chat", "event");
|
||
frame["steamId"] = SteamIdOf(player);
|
||
frame["name"] = player.displayName;
|
||
frame["channel"] = channel.ToString();
|
||
|
||
// Flattened for the same reason every other string on this wire is: one frame is one
|
||
// line, and a chat message is the one field a player controls the bytes of.
|
||
frame["message"] = Flatten(message);
|
||
|
||
EmitFrame(frame);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Void, and deliberately: a non-null return here replaces what the player receives.
|
||
/// This hook is a counter — see rule 2.
|
||
/// </summary>
|
||
private void OnDispenserGather(ResourceDispenser dispenser, BasePlayer player, Item item)
|
||
{
|
||
MarkHook("OnDispenserGather");
|
||
|
||
if (player == null || player.IsNpc || item == null || item.info == null)
|
||
return;
|
||
|
||
TallyFor(player).Gather(item.info.shortname, item.amount);
|
||
}
|
||
|
||
// ---- the rest of what a player gathers (F1, D159) ----
|
||
//
|
||
// `OnDispenserGather` alone is the swings. The first walk had 825 wood in the inventory
|
||
// and 587 on the tally, and 20 cloth from hemp and none on it: the bonus on a tree's final
|
||
// hit is `OnDispenserBonus`, and anything picked up off the ground — hemp, mushrooms,
|
||
// stones, a wild pumpkin — is `OnCollectiblePickup`. A farmed plant is
|
||
// `OnGrowableGathered`. D159: "gathered" is everything a player harvests, so the
|
||
// leaderboard agrees with the inventory. All three are counters (rule 2) and void (rule 1).
|
||
|
||
/// <summary>The bonus on the final hit of a tree or a node. Void: an Item return replaces the bonus.</summary>
|
||
private void OnDispenserBonus(ResourceDispenser dispenser, BasePlayer player, Item item)
|
||
{
|
||
MarkHook("OnDispenserBonus");
|
||
|
||
if (player == null || player.IsNpc || item == null || item.info == null)
|
||
return;
|
||
|
||
TallyFor(player).Gather(item.info.shortname, item.amount);
|
||
}
|
||
|
||
/// <summary>
|
||
/// A pick-up off the ground. Void: a non-null return cancels it. It fires BEFORE the
|
||
/// items move, so what is counted is the collectible's list — and a pick-up another
|
||
/// plugin vetoes is counted anyway, which is a rarity worth less than a second hook.
|
||
/// </summary>
|
||
private void OnCollectiblePickup(CollectibleEntity collectible, BasePlayer player)
|
||
{
|
||
MarkHook("OnCollectiblePickup");
|
||
|
||
if (player == null || player.IsNpc || collectible == null || collectible.itemList == null)
|
||
return;
|
||
|
||
Tally tally = TallyFor(player);
|
||
|
||
foreach (ItemAmount amount in collectible.itemList)
|
||
{
|
||
if (amount == null || amount.itemDef == null)
|
||
continue;
|
||
|
||
tally.Gather(amount.itemDef.shortname, (int)amount.amount);
|
||
}
|
||
}
|
||
|
||
/// <summary>A farmed plant, harvested. No return behaviour on either framework.</summary>
|
||
private void OnGrowableGathered(GrowableEntity plant, Item item, BasePlayer player)
|
||
{
|
||
MarkHook("OnGrowableGathered");
|
||
|
||
if (player == null || player.IsNpc || item == null || item.info == null)
|
||
return;
|
||
|
||
TallyFor(player).Gather(item.info.shortname, item.amount);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Everything that dies which is not a player death: NPC kills, which are a counter,
|
||
/// and raided structures, which are an event with a `staff` class because a
|
||
/// structure's grid is where somebody lives.
|
||
///
|
||
/// <para>
|
||
/// The type checks are ordered cheapest-first and return early, because this hook
|
||
/// fires for every tree, barrel and animal on the map.
|
||
/// </para>
|
||
/// </summary>
|
||
private void OnEntityDeath(BaseCombatEntity entity, HitInfo info)
|
||
{
|
||
MarkHook("OnEntityDeath");
|
||
|
||
if (entity == null)
|
||
return;
|
||
|
||
BasePlayer attacker = info != null ? info.InitiatorPlayer : null;
|
||
|
||
// Before the early return below: a patrol helicopter usually dies by crashing, and a
|
||
// crash's HitInfo names no player (§5.4).
|
||
if (IsVehicleKill(entity))
|
||
{
|
||
CreditVehicle(entity, attacker);
|
||
return;
|
||
}
|
||
|
||
bool byPlayer = attacker != null && !attacker.IsNpc;
|
||
|
||
// Nothing below is interesting unless a real player did it. That single test discards
|
||
// decay, despawns and the world killing itself, which is the bulk of this hook.
|
||
if (!byPlayer)
|
||
return;
|
||
|
||
BasePlayer victimPlayer = entity as BasePlayer;
|
||
|
||
if (victimPlayer != null)
|
||
{
|
||
// A real player's death is OnPlayerDeath's, not ours — emitting here too would
|
||
// double every kill in the feed. Only the NPC kind is ours to count.
|
||
if (victimPlayer.IsNpc)
|
||
{
|
||
Tally tally = TallyFor(attacker);
|
||
tally.NpcKills++;
|
||
tally.Killed(info, entity, false);
|
||
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.
|
||
if (entity is BaseNpc)
|
||
{
|
||
Tally tally = TallyFor(attacker);
|
||
tally.NpcKills++;
|
||
tally.AnimalKills++;
|
||
tally.Killed(info, entity, false);
|
||
CreditKill(attacker, false);
|
||
return;
|
||
}
|
||
|
||
// Protocol 7 (PROTOCOL.md §13): a raid is the loss of anything a base is made of, not
|
||
// only a building block. A door is NOT a BuildingBlock — both derive from
|
||
// StabilityEntity as siblings — so until protocol 7 the most common raid there is, two
|
||
// charges on a door, reached nobody.
|
||
string structure = StructureKind(entity);
|
||
|
||
// `OwnerID == 0` is the world's own: a monument's doors and walls. Destroying those is
|
||
// not a raid on anybody, and emitting them would fill the staff feed with loot runs.
|
||
if (structure == null || entity.OwnerID == 0UL)
|
||
return;
|
||
|
||
// The tally's meaning is unchanged since protocol 2 — building blocks only — so a
|
||
// leaderboard column does not quietly change what it counts in the middle of a wipe.
|
||
//
|
||
// **But not your own** (F3). The first walk's org lead broke their own wall and the
|
||
// next tally carried `structures: 1`. Taking down a piece you placed, or one on a
|
||
// cupboard you are authorised on, is building, not raiding. The frame still goes: the
|
||
// site already skips the raid ALERT for an authorised attacker (D59), and a staff feed
|
||
// that loses a line because the player happened to own it is a feed with a hole.
|
||
if (structure == "block" && !OwnsOrAuthed(attacker, entity))
|
||
TallyFor(attacker).Structures++;
|
||
|
||
Dictionary<string, object> frame = Frame("entity.destroyed", "event");
|
||
frame["ownerId"] = entity.OwnerID.ToString();
|
||
frame["prefab"] = entity.ShortPrefabName;
|
||
frame["structure"] = structure;
|
||
|
||
// From `userID`, not `UserIDString`. The string is filled in when a player connects,
|
||
// loads as a sleeper, or is an engine bot — and at no other time, so a player another
|
||
// plugin spawned carries none. The website skips the raid alert when the attacker is
|
||
// authorised on the cupboard (PLAN.md D59), and a null here would quietly defeat that.
|
||
frame["attackerId"] = ((ulong)attacker.userID).ToString();
|
||
frame["attackerName"] = attacker.displayName;
|
||
|
||
string grid = GridOf(entity);
|
||
if (grid != null)
|
||
frame["grid"] = grid;
|
||
|
||
AddAuthorization(frame, entity);
|
||
|
||
EmitFrame(frame);
|
||
}
|
||
|
||
// ---- what a chat title counts (PLAN_REDESIGNS §5, D172–D174) ----
|
||
//
|
||
// Every one of these is a counter (rule 2) and void (rule 1). Three of them can veto —
|
||
// `OnStructureRepair`, `OnHealingItemUse`, `OnPlayerRevive` — and `OnEntityTakeDamage`
|
||
// can cancel the damage outright, which is exactly why none of them returns anything.
|
||
|
||
/// <summary>How long a player's hit on a vehicle still earns its kill when the blow that ends it names nobody (§5.4).</summary>
|
||
private const float VehicleCreditSeconds = 60f;
|
||
|
||
/// <summary>The last player to hurt each APC or helicopter, by net id. A handful at most.</summary>
|
||
private readonly Dictionary<ulong, KeyValuePair<ulong, float>> _vehicleHits =
|
||
new Dictionary<ulong, KeyValuePair<ulong, float>>();
|
||
|
||
/// <summary>The APC and the two helicopters: the three things a title credits a kill of (D174).</summary>
|
||
private static bool IsVehicleKill(BaseEntity entity)
|
||
{
|
||
return entity is BradleyAPC || entity is PatrolHelicopter || entity is CH47Helicopter;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Void: a non-null return cancels the damage. It fires for every hit on everything,
|
||
/// so the type test is the first thing it does; only a hit on one of the three
|
||
/// vehicles is remembered, and only who dealt it and when.
|
||
/// </summary>
|
||
private void OnEntityTakeDamage(BaseCombatEntity entity, HitInfo info)
|
||
{
|
||
MarkHook("OnEntityTakeDamage");
|
||
|
||
if (entity == null || info == null || !IsVehicleKill(entity) || entity.net == null)
|
||
return;
|
||
|
||
BasePlayer player = info.InitiatorPlayer;
|
||
if (player == null || player.IsNpc)
|
||
return;
|
||
|
||
float now = UnityEngine.Time.realtimeSinceStartup;
|
||
|
||
// A helicopter that flies off the map is never killed, so its row is never removed
|
||
// at death; a stale one is dropped here instead.
|
||
if (_vehicleHits.Count > 16)
|
||
{
|
||
var stale = new List<ulong>();
|
||
foreach (KeyValuePair<ulong, KeyValuePair<ulong, float>> hit in _vehicleHits)
|
||
if (now - hit.Value.Value > VehicleCreditSeconds)
|
||
stale.Add(hit.Key);
|
||
foreach (ulong id in stale)
|
||
_vehicleHits.Remove(id);
|
||
}
|
||
|
||
_vehicleHits[entity.net.ID.Value] = new KeyValuePair<ulong, float>(player.userID, now);
|
||
}
|
||
|
||
/// <summary>
|
||
/// The killing blow's player (D174), or, when the blow names nobody — a helicopter
|
||
/// crashing — the last player who hurt it within <see cref="VehicleCreditSeconds"/>.
|
||
/// </summary>
|
||
private void CreditVehicle(BaseCombatEntity entity, BasePlayer attacker)
|
||
{
|
||
KeyValuePair<ulong, float> last = default(KeyValuePair<ulong, float>);
|
||
bool hit = entity.net != null && _vehicleHits.TryGetValue(entity.net.ID.Value, out last);
|
||
|
||
if (entity.net != null)
|
||
_vehicleHits.Remove(entity.net.ID.Value);
|
||
|
||
BasePlayer credited = attacker != null && !attacker.IsNpc ? attacker : null;
|
||
|
||
if (credited == null && hit && UnityEngine.Time.realtimeSinceStartup - last.Value <= VehicleCreditSeconds)
|
||
credited = BasePlayer.FindByID(last.Key) ?? BasePlayer.FindSleeping(last.Key);
|
||
|
||
if (credited == null)
|
||
return;
|
||
|
||
Tally tally = TallyFor(credited);
|
||
|
||
if (entity is BradleyAPC)
|
||
tally.ApcKills++;
|
||
else
|
||
tally.HeliKills++;
|
||
}
|
||
|
||
/// <summary>
|
||
/// No return behaviour. A crafted shirt, rifle or anything else, by its item category.
|
||
/// Who crafted it is the crafter's owner: the task no longer carries one (read off the
|
||
/// rig, 2026-09-29).
|
||
/// </summary>
|
||
private void OnItemCraftFinished(ItemCraftTask task, Item item, ItemCrafter crafter)
|
||
{
|
||
MarkHook("OnItemCraftFinished");
|
||
|
||
BasePlayer player = crafter != null ? crafter.owner : null;
|
||
|
||
if (player == null || player.IsNpc || item == null || item.info == null)
|
||
return;
|
||
|
||
TallyFor(player).Craft(item.info.category.ToString().ToLowerInvariant(), item.amount);
|
||
}
|
||
|
||
/// <summary>No return behaviour. Only a building block is a structure built; a deployable is not.</summary>
|
||
private void OnEntityBuilt(Planner planner, UnityEngine.GameObject go)
|
||
{
|
||
MarkHook("OnEntityBuilt");
|
||
|
||
BasePlayer player = planner != null ? planner.GetOwnerPlayer() : null;
|
||
|
||
if (player == null || player.IsNpc || go == null || go.GetComponent<BuildingBlock>() == null)
|
||
return;
|
||
|
||
TallyFor(player).Built++;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Void: a non-null return refuses the repair. It fires on every hammer hit, BEFORE the
|
||
/// game decides whether there is anything to repair, the resources to pay for it, or a
|
||
/// recent attack that forbids it. So the health is read now and again next tick, and
|
||
/// only a hit that actually raised it is a repair.
|
||
/// </summary>
|
||
private void OnStructureRepair(BaseCombatEntity entity, BasePlayer player)
|
||
{
|
||
MarkHook("OnStructureRepair");
|
||
|
||
if (entity == null || player == null || player.IsNpc)
|
||
return;
|
||
|
||
float before = entity.health;
|
||
ulong id = player.userID;
|
||
|
||
NextTick(() =>
|
||
{
|
||
if (entity == null || entity.IsDestroyed || entity.health <= before)
|
||
return;
|
||
|
||
BasePlayer still = BasePlayer.FindByID(id);
|
||
if (still != null)
|
||
TallyFor(still).Repaired++;
|
||
});
|
||
}
|
||
|
||
// A syringe on a downed player raises `OnPlayerRevive` and then `OnHealingItemUse` for the
|
||
// same use; one act of help is one heal. The revive is remembered for the frame it happened in.
|
||
private ulong _reviveHealer;
|
||
private ulong _reviveTarget;
|
||
private int _reviveFrame = -1;
|
||
|
||
/// <summary>
|
||
/// Void: a non-null return refuses the heal. <paramref name="target"/> is what is healed;
|
||
/// the tool's owner is who heals. Healing yourself is not a heal (D174).
|
||
///
|
||
/// <para>
|
||
/// The target is taken as <c>object</c>: the game's `GiveEffectsTo` now takes an
|
||
/// `IMedicalToolTarget`, which is not always a player, and a parameter typed
|
||
/// `BasePlayer` would simply never be called for the hook's current shape. Only a
|
||
/// player healed counts.
|
||
/// </para>
|
||
/// </summary>
|
||
private void OnHealingItemUse(MedicalTool tool, object target)
|
||
{
|
||
MarkHook("OnHealingItemUse");
|
||
|
||
BasePlayer player = target as BasePlayer;
|
||
BasePlayer healer = tool != null ? tool.GetOwnerPlayer() : null;
|
||
|
||
if (healer == null || healer.IsNpc || player == null || player.IsNpc
|
||
|| (ulong)healer.userID == (ulong)player.userID)
|
||
return;
|
||
|
||
if (_reviveFrame == UnityEngine.Time.frameCount
|
||
&& _reviveHealer == (ulong)healer.userID && _reviveTarget == (ulong)player.userID)
|
||
return;
|
||
|
||
TallyFor(healer).Healed++;
|
||
}
|
||
|
||
/// <summary>Void: a non-null return refuses the revive. Reviving yourself is not possible, and is not counted.</summary>
|
||
private void OnPlayerRevive(BasePlayer reviver, BasePlayer player)
|
||
{
|
||
MarkHook("OnPlayerRevive");
|
||
|
||
if (reviver == null || reviver.IsNpc || player == null || player.IsNpc
|
||
|| (ulong)reviver.userID == (ulong)player.userID)
|
||
return;
|
||
|
||
_reviveHealer = reviver.userID;
|
||
_reviveTarget = player.userID;
|
||
_reviveFrame = UnityEngine.Time.frameCount;
|
||
|
||
TallyFor(reviver).Healed++;
|
||
}
|
||
|
||
/// <summary>No return behaviour. One rocket leaving a launcher.</summary>
|
||
private void OnRocketLaunched(BasePlayer player, BaseEntity entity)
|
||
{
|
||
MarkHook("OnRocketLaunched");
|
||
|
||
if (player == null || player.IsNpc)
|
||
return;
|
||
|
||
TallyFor(player).Rockets++;
|
||
}
|
||
|
||
/// <summary>No return behaviour. A grenade, beancan, satchel or C4 thrown.</summary>
|
||
private void OnExplosiveThrown(BasePlayer player, BaseEntity entity, ThrownWeapon item)
|
||
{
|
||
MarkHook("OnExplosiveThrown");
|
||
CountExplosive(player, entity);
|
||
}
|
||
|
||
/// <summary>No return behaviour. The same, dropped at your feet rather than thrown.</summary>
|
||
private void OnExplosiveDropped(BasePlayer player, BaseEntity entity, ThrownWeapon item)
|
||
{
|
||
MarkHook("OnExplosiveDropped");
|
||
CountExplosive(player, entity);
|
||
}
|
||
|
||
/// <summary>
|
||
/// A supply signal and a smoke grenade are thrown through the same hooks, and neither
|
||
/// is an explosive. What is one is a timed explosive that does damage.
|
||
/// </summary>
|
||
private void CountExplosive(BasePlayer player, BaseEntity entity)
|
||
{
|
||
if (player == null || player.IsNpc)
|
||
return;
|
||
|
||
TimedExplosive explosive = entity as TimedExplosive;
|
||
if (explosive == null || explosive is SupplySignal || explosive.damageTypes == null)
|
||
return;
|
||
|
||
foreach (Rust.DamageTypeEntry damage in explosive.damageTypes)
|
||
{
|
||
if (damage != null && damage.amount > 0f)
|
||
{
|
||
TallyFor(player).Explosives++;
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// No return behaviour. A mission one of the game's NPCs handed out, finished (D173). A
|
||
/// failed or abandoned one raises a different hook, and is never counted.
|
||
/// </summary>
|
||
private void OnMissionSucceeded(BaseMission mission, BaseMission.MissionInstance instance, BasePlayer assignee)
|
||
{
|
||
MarkHook("OnMissionSucceeded");
|
||
|
||
// The game hands the assignee over: `MissionSuccess(instance, assignee)`. The instance
|
||
// itself has no accessor for it (read off the rig, 2026-09-29).
|
||
if (assignee == null || assignee.IsNpc)
|
||
return;
|
||
|
||
TallyFor(assignee).Missions++;
|
||
}
|
||
|
||
/// <summary>
|
||
/// The most people one <c>entity.destroyed</c> frame names as authorised. A cupboard
|
||
/// has no hard cap on vanilla, and an unbounded list is an unbounded line on the game
|
||
/// link; 64 is several full teams, and a frame cut short says so.
|
||
/// </summary>
|
||
private const int MaxAuthorizedOnFrame = 64;
|
||
|
||
/// <summary>
|
||
/// Which of the four things a base is made of this entity is, or null for anything else.
|
||
/// Ordered by how often each dies, which is also cheapest-first.
|
||
/// </summary>
|
||
private static string StructureKind(BaseEntity entity)
|
||
{
|
||
if (entity is BuildingBlock)
|
||
return "block";
|
||
|
||
// External gates are Doors too, which is right: a gate is a door in a wall.
|
||
if (entity is Door)
|
||
return "door";
|
||
|
||
if (entity is SimpleBuildingBlock)
|
||
return "wall";
|
||
|
||
if (entity is BuildingPrivlidge)
|
||
return "cupboard";
|
||
|
||
return null;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Who lives here, by the game's own definition: everyone authorised on the building's
|
||
/// tool cupboard. Both fields are ABSENT when there is no cupboard, which is a different
|
||
/// answer from an empty list — "nobody is authorised" and "there is nothing to be
|
||
/// authorised on" — and the module alerts nobody in either case (PLAN.md D67).
|
||
///
|
||
/// <para>
|
||
/// <c>authorizedPlayers</c> is the set, and nothing else is. The cupboard also holds
|
||
/// <c>recentGroupMembers</c>, which LOOKS like residents and is not: it counts code-lock
|
||
/// users towards group upkeep and prunes itself on a timer.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// The lookup resolves through the entity's building, which is cheap. An external wall
|
||
/// has no building and falls back to a sphere query on this thread — acceptable because
|
||
/// it runs only after every cheap filter above has passed, at the rate walls fall rather
|
||
/// than the rate this hook fires.
|
||
/// </para>
|
||
/// </summary>
|
||
private static void AddAuthorization(Dictionary<string, object> frame, BaseEntity entity)
|
||
{
|
||
// A cupboard being destroyed still holds its list: OnEntityDeath runs before the kill.
|
||
BuildingPrivlidge cupboard = entity as BuildingPrivlidge ?? entity.GetBuildingPrivilege();
|
||
|
||
if (cupboard == null || cupboard.net == null || cupboard.authorizedPlayers == null)
|
||
return;
|
||
|
||
HashSet<ulong> online = new HashSet<ulong>();
|
||
|
||
if (BasePlayer.activePlayerList != null)
|
||
{
|
||
foreach (BasePlayer player in BasePlayer.activePlayerList)
|
||
{
|
||
if (player != null)
|
||
online.Add(player.userID);
|
||
}
|
||
}
|
||
|
||
List<object> authorized = new List<object>();
|
||
|
||
foreach (ulong steamId in cupboard.authorizedPlayers)
|
||
{
|
||
if (authorized.Count >= MaxAuthorizedOnFrame)
|
||
{
|
||
frame["authorizedTruncated"] = true;
|
||
break;
|
||
}
|
||
|
||
authorized.Add(new Dictionary<string, object>
|
||
{
|
||
{ "steamId", steamId.ToString() },
|
||
{ "online", online.Contains(steamId) },
|
||
});
|
||
}
|
||
|
||
frame["buildingId"] = cupboard.net.ID.Value.ToString();
|
||
frame["authorized"] = authorized;
|
||
}
|
||
|
||
private void OnPlayerReported(
|
||
BasePlayer reporter, string targetName, string targetId,
|
||
string subject, string message, string type)
|
||
{
|
||
MarkHook("OnPlayerReported");
|
||
|
||
Dictionary<string, object> frame = Frame("player.reported", "event");
|
||
|
||
if (reporter != null)
|
||
{
|
||
frame["reporterId"] = SteamIdOf(reporter);
|
||
frame["reporterName"] = reporter.displayName;
|
||
}
|
||
|
||
frame["targetId"] = targetId;
|
||
frame["targetName"] = targetName;
|
||
frame["subject"] = Flatten(subject);
|
||
frame["message"] = Flatten(message);
|
||
frame["reportType"] = type;
|
||
|
||
EmitFrame(frame);
|
||
}
|
||
|
||
private void OnUserBanned(string name, string id, string ipAddress, string reason)
|
||
{
|
||
MarkHook("OnUserBanned");
|
||
|
||
Dictionary<string, object> frame = Frame("player.banned", "event");
|
||
frame["steamId"] = id;
|
||
frame["name"] = name;
|
||
frame["reason"] = Flatten(reason);
|
||
PutAddress(frame, ipAddress);
|
||
EmitFrame(frame);
|
||
}
|
||
|
||
private void OnUserUnbanned(string name, string id, string ipAddress)
|
||
{
|
||
MarkHook("OnUserUnbanned");
|
||
|
||
Dictionary<string, object> frame = Frame("player.unbanned", "event");
|
||
frame["steamId"] = id;
|
||
frame["name"] = name;
|
||
PutAddress(frame, ipAddress);
|
||
EmitFrame(frame);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Void, and this is the most important void in the file: a non-null return here
|
||
/// **kicks the player**, with the returned string as the reason. The bridge is here to
|
||
/// watch logins, never to decide one.
|
||
///
|
||
/// <para>
|
||
/// It fires on every connection attempt, approved or not — so it is an attempt, not a
|
||
/// denial. uMod publishes no <c>OnUserRejected</c>, and the only way to observe a
|
||
/// refusal from this hook is to be the thing refusing. A denial is therefore the
|
||
/// ABSENCE of a matching <c>player.approved</c>, decided by whoever reads the two
|
||
/// frames later; PROTOCOL.md §8.8 records that as a correction to the trigger
|
||
/// catalogue.
|
||
/// </para>
|
||
/// </summary>
|
||
private void CanUserLogin(string name, string id, string ipAddress)
|
||
{
|
||
MarkHook("CanUserLogin");
|
||
|
||
Dictionary<string, object> frame = Frame("player.login.attempt", "event");
|
||
frame["steamId"] = id;
|
||
frame["name"] = name;
|
||
PutAddress(frame, ipAddress);
|
||
EmitFrame(frame);
|
||
}
|
||
|
||
private void OnUserApproved(string name, string id, string ipAddress)
|
||
{
|
||
MarkHook("OnUserApproved");
|
||
|
||
Dictionary<string, object> frame = Frame("player.approved", "event");
|
||
frame["steamId"] = id;
|
||
frame["name"] = name;
|
||
PutAddress(frame, ipAddress);
|
||
EmitFrame(frame);
|
||
}
|
||
|
||
// ---- clans (protocol 6) ----
|
||
//
|
||
// Rust's FIRST-PARTY clan system, which the website turns into core's Teams (R5,
|
||
// PROTOCOL.md §12). Not the uMod Clans plugin: that is a separate system that never
|
||
// touches `ClanManager` (D47), and this region reads nothing of it except whether it is
|
||
// loaded, so the website can warn that its clans are not Teams.
|
||
//
|
||
// One board and five events, the same split as presence. The events carry the story —
|
||
// founded, joined, left, removed, disbanded — and the board is the reconciliation point
|
||
// that corrects anything missed within a minute. Leadership travels ONLY on the board,
|
||
// because the game raises no hook when somebody is promoted or demoted (§12.3).
|
||
//
|
||
// Three facts about the game shape everything below, and all three were read from its
|
||
// assemblies rather than from a catalogue:
|
||
//
|
||
// 1. **There is no "list every clan" call.** The backend offers get-by-id and
|
||
// get-by-member; the only listing is the clan leaderboard, and the game clamps that
|
||
// to 100 clans by score. That clamp is the board's ceiling (D55), and a board at it
|
||
// says `truncated` rather than pretending to be the whole answer.
|
||
//
|
||
// 2. **A clan id is not an identity.** The database file's version number is
|
||
// hard-coded, so a game update that bumps it starts a fresh file whose ids restart at
|
||
// 1. Every frame therefore carries the clan's creation time beside its id, and the
|
||
// pair is what the website keys a Team on (D52).
|
||
//
|
||
// 3. **The local backend is synchronous behind an async signature.** Every `ValueTask`
|
||
// it returns has already completed, which is what makes reading it from a hook
|
||
// legal. A task that has NOT completed means some other backend, and it is refused
|
||
// rather than waited on — waiting would be the main thread blocking on a database.
|
||
|
||
/// <summary>
|
||
/// The most clans one board can carry, and it is the GAME's number: the clan leaderboard
|
||
/// clamps its limit to 10..100. Asking for more returns 100.
|
||
/// </summary>
|
||
private const int MaxClansOnBoard = 100;
|
||
|
||
/// <summary>
|
||
/// How many bytes of clan rows one board may carry. Well inside the sidecar's 1 MiB line
|
||
/// cap, which discards an over-long line outright: a board that silently never arrived
|
||
/// would look like a server with no clans. 100 clans of 100 members each is about a
|
||
/// megabyte, so the cap is reachable, and a board that meets it says so.
|
||
/// </summary>
|
||
private const int ClanBoardBudgetBytes = 768 * 1024;
|
||
|
||
/// <summary>
|
||
/// How long after a membership change the clan board is re-sent. The event reaches the
|
||
/// sidecar at once; the roster that follows it waits for the game to refresh its own
|
||
/// cached clan, which `OnClanMemberAdded` fires before.
|
||
/// </summary>
|
||
private const float ClanBoardSettleSeconds = 3f;
|
||
|
||
/// <summary>A pending early re-send of the clan board, so a burst of changes sends one.</summary>
|
||
private Timer _clansBoardSoon;
|
||
|
||
/// <summary>
|
||
/// Main thread. Every clan on this server, with its roster and roles, as the board the
|
||
/// website reconciles Teams against.
|
||
///
|
||
/// <para>
|
||
/// The board is ALWAYS sent, including when there is nothing to read — with
|
||
/// <c>supported: false</c> and a reason. Saying nothing would leave the website holding
|
||
/// the last board it saw and no way to know it had stopped being true.
|
||
/// </para>
|
||
/// </summary>
|
||
private void SendClansBoard()
|
||
{
|
||
Dictionary<string, object> frame = Frame("clans", "snapshot");
|
||
var clans = new List<object>();
|
||
|
||
frame["enabled"] = ConVar.Clan.enabled;
|
||
frame["umodClans"] = plugins.Find("Clans") != null;
|
||
frame["clans"] = clans;
|
||
frame["count"] = 0;
|
||
frame["truncated"] = false;
|
||
|
||
// Switched off by the operator. That is an authoritative answer — this server has no
|
||
// clans — and different from "could not read them", so it is supported and empty.
|
||
if (!ConVar.Clan.enabled)
|
||
{
|
||
frame["supported"] = true;
|
||
EmitFrame(frame);
|
||
return;
|
||
}
|
||
|
||
IClanBackend backend = ClanManager.ServerInstance != null ? ClanManager.ServerInstance.Backend : null;
|
||
|
||
if (backend == null)
|
||
{
|
||
frame["supported"] = false;
|
||
frame["reason"] = "the game's clan backend has not started yet";
|
||
EmitFrame(frame);
|
||
return;
|
||
}
|
||
|
||
frame["backend"] = backend.GetType().Name;
|
||
|
||
// A Nexus server keeps its clans on Facepunch's side of the Nexus, behind a backend
|
||
// whose calls do not complete on the spot. Refused with a reason rather than guessed
|
||
// at (D55): half-reading it would be a board that is wrong in a way nobody can see.
|
||
if (!(backend is LocalClanBackend))
|
||
{
|
||
frame["supported"] = false;
|
||
frame["reason"] = $"this server's clans are held by a {backend.GetType().Name}, which the bridge does not read";
|
||
EmitFrame(frame);
|
||
return;
|
||
}
|
||
|
||
System.Threading.Tasks.ValueTask<ClanValueResult<List<ClanLeaderboardEntry>>> listing = backend.GetLeaderboard(MaxClansOnBoard);
|
||
|
||
if (!listing.IsCompleted || !listing.Result.IsSuccess)
|
||
{
|
||
frame["supported"] = false;
|
||
frame["reason"] = listing.IsCompleted
|
||
? $"the game's clan list could not be read ({listing.Result.Result})"
|
||
: "the game's clan list did not answer at once";
|
||
EmitFrame(frame);
|
||
return;
|
||
}
|
||
|
||
List<ClanLeaderboardEntry> entries = listing.Result.Value;
|
||
|
||
// At the clamp there may be more clans than the game will list. It cannot be told
|
||
// apart from exactly 100, so it is reported as possibly more — the honest direction,
|
||
// since the website then only ADDS Teams and never removes one on this answer.
|
||
bool truncated = entries.Count >= MaxClansOnBoard;
|
||
int bytes = 0;
|
||
|
||
foreach (ClanLeaderboardEntry entry in entries)
|
||
{
|
||
IClan clan = ReadClan(backend, entry.ClanId);
|
||
|
||
if (clan == null)
|
||
continue;
|
||
|
||
Dictionary<string, object> row = DescribeClan(clan);
|
||
int size = Encoding.UTF8.GetByteCount(JsonConvert.SerializeObject(row, Formatting.None));
|
||
|
||
if (bytes + size > ClanBoardBudgetBytes)
|
||
{
|
||
truncated = true;
|
||
break;
|
||
}
|
||
|
||
bytes += size;
|
||
clans.Add(row);
|
||
}
|
||
|
||
frame["supported"] = true;
|
||
frame["truncated"] = truncated;
|
||
frame["count"] = clans.Count;
|
||
|
||
EmitFrame(frame);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Main thread. One clan by id, from the game's cache if it is there and from its
|
||
/// database if not, or null. Never waits: see fact 3 at the top of this region.
|
||
/// </summary>
|
||
private static IClan ReadClan(IClanBackend backend, long clanId)
|
||
{
|
||
IClan clan;
|
||
|
||
if (backend.TryGet(clanId, out clan) && clan != null)
|
||
return clan;
|
||
|
||
System.Threading.Tasks.ValueTask<ClanValueResult<IClan>> read = backend.Get(clanId);
|
||
|
||
if (!read.IsCompleted || !read.Result.IsSuccess)
|
||
return null;
|
||
|
||
return read.Result.Value;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Main thread. A clan as the board carries it.
|
||
///
|
||
/// <para>
|
||
/// A member's <c>LastSeen</c> is left off on purpose. It says when somebody was last on
|
||
/// the server, which is presence, and the website's rule is that nothing names who is on
|
||
/// by default (PLAN.md §23). Not sending it is simpler than trusting every reader to
|
||
/// withhold it.
|
||
/// </para>
|
||
/// </summary>
|
||
private Dictionary<string, object> DescribeClan(IClan clan)
|
||
{
|
||
var roles = new Dictionary<int, ClanRole>();
|
||
|
||
if (clan.Roles != null)
|
||
{
|
||
foreach (ClanRole role in clan.Roles)
|
||
roles[role.RoleId] = role;
|
||
}
|
||
|
||
var members = new List<object>();
|
||
|
||
if (clan.Members != null)
|
||
{
|
||
foreach (ClanMember member in clan.Members)
|
||
{
|
||
var row = new Dictionary<string, object>
|
||
{
|
||
{ "steamId", member.SteamId.ToString() },
|
||
{ "joinedMs", member.Joined }
|
||
};
|
||
|
||
ClanRole role;
|
||
|
||
// A member whose role id matches no role is left without a rank rather than
|
||
// given one: "not known" must never read as "leads this clan", and rank 1 is
|
||
// exactly what a default of zero-plus-one would produce.
|
||
if (roles.TryGetValue(member.RoleId, out role))
|
||
{
|
||
row["rank"] = role.Rank;
|
||
row["role"] = role.Name;
|
||
}
|
||
|
||
string name = PlayerName(member.SteamId);
|
||
if (!string.IsNullOrEmpty(name))
|
||
row["name"] = name;
|
||
|
||
members.Add(row);
|
||
}
|
||
}
|
||
|
||
UnityEngine.Color32 color = clan.Color;
|
||
|
||
return new Dictionary<string, object>
|
||
{
|
||
{ "clanId", clan.ClanId },
|
||
{ "createdMs", clan.Created },
|
||
{ "name", clan.Name },
|
||
{ "color", $"#{color.r:x2}{color.g:x2}{color.b:x2}" },
|
||
{ "score", clan.Score },
|
||
{ "maxMembers", clan.MaxMemberCount },
|
||
{ "members", members }
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// Main thread. A player's name, whether or not they are on, or null. The framework
|
||
/// remembers everybody who has ever joined, which is what lets an offline clan member
|
||
/// have a name on a roster.
|
||
/// </summary>
|
||
private string PlayerName(ulong steamId)
|
||
{
|
||
BasePlayer player = BasePlayer.FindByID(steamId) ?? BasePlayer.FindSleeping(steamId);
|
||
|
||
if (player != null)
|
||
return player.displayName;
|
||
|
||
Oxide.Core.Libraries.Covalence.IPlayer known = covalence.Players.FindPlayerById(steamId.ToString());
|
||
return known != null ? known.Name : null;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Main thread. The envelope every clan event shares: which clan, identified the way
|
||
/// the website keys it (D52), and its name so a feed line can say something without a
|
||
/// lookup.
|
||
/// </summary>
|
||
private Dictionary<string, object> ClanFrame(string kind, IClan clan)
|
||
{
|
||
Dictionary<string, object> frame = Frame(kind, "event");
|
||
frame["clanId"] = clan.ClanId;
|
||
frame["createdMs"] = clan.Created;
|
||
frame["clanName"] = clan.Name;
|
||
return frame;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Main thread. Puts a player onto a clan event as <c>steamId</c> and <c>name</c> — the
|
||
/// same two fields every player event carries, so the website's player table learns the
|
||
/// name the same way it does from a connect.
|
||
/// </summary>
|
||
private void PutPlayer(Dictionary<string, object> frame, string idKey, string nameKey, ulong steamId)
|
||
{
|
||
if (steamId == 0UL)
|
||
return;
|
||
|
||
frame[idKey] = steamId.ToString();
|
||
|
||
string name = PlayerName(steamId);
|
||
if (!string.IsNullOrEmpty(name))
|
||
frame[nameKey] = name;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Main thread. Re-sends the clan board shortly, once, however many changes arrive in
|
||
/// the meantime. The cadence would get there within a minute anyway; this is what makes
|
||
/// a roster follow the event that changed it.
|
||
/// </summary>
|
||
private void ScheduleClansBoard()
|
||
{
|
||
if (_clansBoardSoon != null)
|
||
return;
|
||
|
||
_clansBoardSoon = timer.Once(ClanBoardSettleSeconds, () =>
|
||
{
|
||
_clansBoardSoon = null;
|
||
|
||
if (!_connected)
|
||
return;
|
||
|
||
try
|
||
{
|
||
SendClansBoard();
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Puts($"could not build the clans board: {Flatten(ex.Message)}");
|
||
}
|
||
});
|
||
}
|
||
|
||
/// <summary>
|
||
/// The founder's own membership raises no <c>OnClanMemberAdded</c> — the game adds them
|
||
/// inside the creation — so this event carries them, and the website counts them in.
|
||
/// </summary>
|
||
private void OnClanCreated(LocalClan clan, ulong steamId)
|
||
{
|
||
MarkHook("OnClanCreated");
|
||
|
||
if (clan == null)
|
||
return;
|
||
|
||
Dictionary<string, object> frame = ClanFrame("clan.created", clan);
|
||
PutPlayer(frame, "steamId", "name", steamId);
|
||
EmitFrame(frame);
|
||
|
||
ScheduleClansBoard();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Typed exactly, and that matters here more than anywhere: the uMod Clans plugin raises
|
||
/// a hook with this SAME NAME and arity — <c>OnClanDisbanded(string, List<ulong>)</c>
|
||
/// — about its own, unrelated clans. Both frameworks match a call to a method by its
|
||
/// argument types, so a loosely typed signature here would file the plugin's clans as
|
||
/// the game's.
|
||
/// </summary>
|
||
private void OnClanDisbanded(LocalClan clan, ulong steamId)
|
||
{
|
||
MarkHook("OnClanDisbanded");
|
||
|
||
if (clan == null)
|
||
return;
|
||
|
||
Dictionary<string, object> frame = ClanFrame("clan.disbanded", clan);
|
||
PutPlayer(frame, "steamId", "name", steamId);
|
||
|
||
// Protocol 7: who was in it. The website tells a disbanded clan's members, and by the
|
||
// time it reads this frame the next board may already have removed the roster it would
|
||
// otherwise ask — the board is re-sent seconds after this, and after an outage it is
|
||
// applied before the backlog. The game has deleted the clan from its database by now,
|
||
// and walked `Members` to drop each one's membership, but it never empties the list
|
||
// itself — so it is still whole here, bounded by the clan's own member limit.
|
||
if (clan.Members != null)
|
||
{
|
||
List<object> members = new List<object>();
|
||
|
||
foreach (ClanMember member in clan.Members)
|
||
members.Add(member.SteamId.ToString());
|
||
|
||
frame["members"] = members;
|
||
}
|
||
|
||
EmitFrame(frame);
|
||
|
||
ScheduleClansBoard();
|
||
}
|
||
|
||
/// <summary>
|
||
/// The one clan hook that hands over a bare id rather than a clan: the game raises it
|
||
/// from inside its database layer, before the cached clan has been refreshed. The id is
|
||
/// enough to find the clan and its creation time; if even that fails, the frame goes
|
||
/// without <c>createdMs</c> and the website matches it on the id alone.
|
||
/// </summary>
|
||
private void OnClanMemberAdded(long clanId, ulong steamId)
|
||
{
|
||
MarkHook("OnClanMemberAdded");
|
||
|
||
IClanBackend backend = ClanManager.ServerInstance != null ? ClanManager.ServerInstance.Backend : null;
|
||
IClan clan = backend != null ? ReadClan(backend, clanId) : null;
|
||
|
||
Dictionary<string, object> frame;
|
||
|
||
if (clan != null)
|
||
{
|
||
frame = ClanFrame("clan.member.added", clan);
|
||
}
|
||
else
|
||
{
|
||
frame = Frame("clan.member.added", "event");
|
||
frame["clanId"] = clanId;
|
||
}
|
||
|
||
PutPlayer(frame, "steamId", "name", steamId);
|
||
EmitFrame(frame);
|
||
|
||
ScheduleClansBoard();
|
||
}
|
||
|
||
private void OnClanMemberLeft(LocalClan clan, ulong steamId)
|
||
{
|
||
MarkHook("OnClanMemberLeft");
|
||
|
||
if (clan == null)
|
||
return;
|
||
|
||
Dictionary<string, object> frame = ClanFrame("clan.member.left", clan);
|
||
PutPlayer(frame, "steamId", "name", steamId);
|
||
EmitFrame(frame);
|
||
|
||
ScheduleClansBoard();
|
||
}
|
||
|
||
private void OnClanMemberKicked(LocalClan clan, ulong steamId, ulong bySteamId)
|
||
{
|
||
MarkHook("OnClanMemberKicked");
|
||
|
||
if (clan == null)
|
||
return;
|
||
|
||
Dictionary<string, object> frame = ClanFrame("clan.member.kicked", clan);
|
||
PutPlayer(frame, "steamId", "name", steamId);
|
||
PutPlayer(frame, "bySteamId", "byName", bySteamId);
|
||
EmitFrame(frame);
|
||
|
||
ScheduleClansBoard();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Not an event: a colour is a property of the clan, so it travels on the board. The
|
||
/// hook only brings the next board forward.
|
||
/// </summary>
|
||
private void OnClanColorChanged(LocalClan clan, UnityEngine.Color32 color, ulong steamId)
|
||
{
|
||
MarkHook("OnClanColorChanged");
|
||
ScheduleClansBoard();
|
||
}
|
||
|
||
// ---- tallies ----
|
||
|
||
/// <summary>
|
||
/// One player's counters between two flushes. Main thread only; no lock, ever.
|
||
/// </summary>
|
||
private class Tally
|
||
{
|
||
public readonly Dictionary<string, int> Gathered = new Dictionary<string, int>();
|
||
public int NpcKills;
|
||
public int Structures;
|
||
public string Name;
|
||
|
||
// Protocol 13, the chat-title conditions (PLAN_REDESIGNS §5.1). Sums unless named a best.
|
||
public int AnimalKills;
|
||
public readonly Dictionary<string, int> WeaponKills = new Dictionary<string, int>();
|
||
public int Headshots;
|
||
/// <summary>The longest player kill this interval, in metres: a best, not a sum.</summary>
|
||
public float BestPvpM;
|
||
/// <summary>The longest NPC or animal kill this interval: a best, not a sum.</summary>
|
||
public float BestPveM;
|
||
public int ApcKills;
|
||
public int HeliKills;
|
||
public readonly Dictionary<string, int> Crafted = new Dictionary<string, int>();
|
||
public int Built;
|
||
public int Repaired;
|
||
public int Healed;
|
||
public int Rockets;
|
||
public int Explosives;
|
||
public int Missions;
|
||
|
||
public void Gather(string shortname, int amount)
|
||
{
|
||
Add(Gathered, shortname, amount);
|
||
}
|
||
|
||
public void Craft(string category, int amount)
|
||
{
|
||
Add(Crafted, category, amount);
|
||
}
|
||
|
||
/// <summary>
|
||
/// What a credited kill adds for the titles: its weapon (any victim, D174), a
|
||
/// headshot, and its distance as this interval's best of its kind.
|
||
/// </summary>
|
||
public void Killed(HitInfo info, BaseEntity victim, bool victimIsPlayer)
|
||
{
|
||
Add(WeaponKills, WeaponOf(info), 1);
|
||
|
||
if (info != null && info.isHeadshot)
|
||
Headshots++;
|
||
|
||
float distance = DistanceOf(info, victim);
|
||
|
||
if (victimIsPlayer)
|
||
BestPvpM = Math.Max(BestPvpM, distance);
|
||
else
|
||
BestPveM = Math.Max(BestPveM, distance);
|
||
}
|
||
|
||
private static void Add(Dictionary<string, int> into, string key, int amount)
|
||
{
|
||
if (string.IsNullOrEmpty(key) || amount <= 0)
|
||
return;
|
||
|
||
int have;
|
||
into.TryGetValue(key, out have);
|
||
into[key] = have + amount;
|
||
}
|
||
|
||
public bool IsEmpty()
|
||
{
|
||
return Gathered.Count == 0 && NpcKills == 0 && Structures == 0
|
||
&& AnimalKills == 0 && WeaponKills.Count == 0 && Headshots == 0
|
||
&& BestPvpM <= 0f && BestPveM <= 0f && ApcKills == 0 && HeliKills == 0
|
||
&& Crafted.Count == 0 && Built == 0 && Repaired == 0 && Healed == 0
|
||
&& Rockets == 0 && Explosives == 0 && Missions == 0;
|
||
}
|
||
}
|
||
|
||
private Tally TallyFor(BasePlayer player)
|
||
{
|
||
Tally tally;
|
||
|
||
if (!_tallies.TryGetValue(player.userID, out tally))
|
||
{
|
||
tally = new Tally();
|
||
_tallies[player.userID] = tally;
|
||
}
|
||
|
||
tally.Name = player.displayName;
|
||
return tally;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Emits one <c>player.tally</c> per player with anything to report, and clears what it
|
||
/// emitted.
|
||
///
|
||
/// <para>
|
||
/// A tally frame is a <b>delta, not a running total</b>: it says what happened since
|
||
/// the last flush, so the far side sums rather than diffs. A missed frame then costs
|
||
/// one interval instead of corrupting the whole series, which is the failure mode a
|
||
/// running total has over a link that is allowed to drop frames (and this one is —
|
||
/// the outbound queue is drop-oldest by design).
|
||
/// </para>
|
||
/// </summary>
|
||
private void FlushTallies()
|
||
{
|
||
if (_tallies.Count == 0)
|
||
return;
|
||
|
||
// Materialised, because emitting mutates nothing here but clearing does, and iterating
|
||
// a dictionary while clearing it is a different bug on every runtime.
|
||
var ids = new List<ulong>(_tallies.Keys);
|
||
|
||
foreach (ulong id in ids)
|
||
FlushTally(id);
|
||
}
|
||
|
||
private void FlushTally(ulong userId)
|
||
{
|
||
Tally tally;
|
||
|
||
if (!_tallies.TryGetValue(userId, out tally))
|
||
return;
|
||
|
||
_tallies.Remove(userId);
|
||
|
||
if (tally.IsEmpty())
|
||
return;
|
||
|
||
Dictionary<string, object> frame = Frame("player.tally", "event");
|
||
frame["steamId"] = userId.ToString();
|
||
|
||
if (!string.IsNullOrEmpty(tally.Name))
|
||
frame["name"] = tally.Name;
|
||
|
||
if (tally.Gathered.Count > 0)
|
||
frame["gathered"] = tally.Gathered;
|
||
|
||
if (tally.NpcKills > 0)
|
||
frame["npcKills"] = tally.NpcKills;
|
||
|
||
if (tally.Structures > 0)
|
||
frame["structures"] = tally.Structures;
|
||
|
||
// The title conditions (§5.1), each absent when nothing moved, as the three above.
|
||
if (tally.AnimalKills > 0)
|
||
frame["animalKills"] = tally.AnimalKills;
|
||
if (tally.WeaponKills.Count > 0)
|
||
frame["weaponKills"] = tally.WeaponKills;
|
||
if (tally.Headshots > 0)
|
||
frame["headshots"] = tally.Headshots;
|
||
if (tally.BestPvpM > 0f)
|
||
frame["bestPvpM"] = (float)Math.Round(tally.BestPvpM, 1);
|
||
if (tally.BestPveM > 0f)
|
||
frame["bestPveM"] = (float)Math.Round(tally.BestPveM, 1);
|
||
if (tally.ApcKills > 0)
|
||
frame["apcKills"] = tally.ApcKills;
|
||
if (tally.HeliKills > 0)
|
||
frame["heliKills"] = tally.HeliKills;
|
||
if (tally.Crafted.Count > 0)
|
||
frame["crafted"] = tally.Crafted;
|
||
if (tally.Built > 0)
|
||
frame["built"] = tally.Built;
|
||
if (tally.Repaired > 0)
|
||
frame["repaired"] = tally.Repaired;
|
||
if (tally.Healed > 0)
|
||
frame["healed"] = tally.Healed;
|
||
if (tally.Rockets > 0)
|
||
frame["rockets"] = tally.Rockets;
|
||
if (tally.Explosives > 0)
|
||
frame["explosives"] = tally.Explosives;
|
||
if (tally.Missions > 0)
|
||
frame["missions"] = tally.Missions;
|
||
|
||
EmitFrame(frame);
|
||
}
|
||
|
||
/// <summary>
|
||
/// The one repeating job: re-send the boards and flush the tallies.
|
||
///
|
||
/// <para>
|
||
/// It does nothing at all while the link is down. That is not an optimisation — the
|
||
/// outbound queue is drop-oldest, so a disconnected server would spend its whole
|
||
/// outage pushing minute-old boards over the top of the events that actually matter.
|
||
/// A reconnect re-sends the boards anyway, which is the whole point of a board.
|
||
/// </para>
|
||
/// </summary>
|
||
private void Cadence()
|
||
{
|
||
if (!_connected)
|
||
return;
|
||
|
||
FlushTallies();
|
||
SendBoards();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Collapses every run of whitespace <b>and control characters</b> to one space, so an
|
||
/// exception message stays one log entry.
|
||
///
|
||
/// <para>
|
||
/// The control half is not defensive padding. Mono's <c>SocketException.Message</c> on
|
||
/// Windows comes back from a fixed-size OS buffer and is <b>NUL-padded</b> — a couple
|
||
/// of hundred <c>\0</c> bytes sitting in the middle of the sentence. <c>\0</c> is not
|
||
/// whitespace, so <c>Trim</c> does not touch it and neither does a whitespace-only
|
||
/// collapse; the line looks like it contains a huge run of spaces and no amount of
|
||
/// trimming removes it.
|
||
/// </para>
|
||
/// </summary>
|
||
private static string Flatten(string text)
|
||
{
|
||
if (string.IsNullOrEmpty(text))
|
||
return string.Empty;
|
||
|
||
var sb = new StringBuilder(text.Length);
|
||
bool lastWasSpace = false;
|
||
|
||
foreach (char c in text)
|
||
{
|
||
if (char.IsWhiteSpace(c) || char.IsControl(c))
|
||
{
|
||
if (!lastWasSpace && sb.Length > 0)
|
||
sb.Append(' ');
|
||
|
||
lastWasSpace = true;
|
||
}
|
||
else
|
||
{
|
||
sb.Append(c);
|
||
lastWasSpace = false;
|
||
}
|
||
}
|
||
|
||
return sb.ToString().TrimEnd();
|
||
}
|
||
|
||
/// <summary>
|
||
/// The server process's start instant, as <c>boot-yyyyMMddTHHmmssZ</c>.
|
||
///
|
||
/// <para>
|
||
/// <c>Process.StartTime</c> is exact and identical on every read, which is what makes
|
||
/// the id survive a plugin reload. The fallback derives the same instant from Unity's
|
||
/// monotonic clock, and is only reached if the platform refuses the process query; it
|
||
/// is rounded to the second deliberately, because the subtraction jitters in the last
|
||
/// decimal and an id that changes on rounding is worse than one a second off.
|
||
/// </para>
|
||
/// </summary>
|
||
private static string ResolveBootId()
|
||
{
|
||
DateTime startUtc;
|
||
|
||
try
|
||
{
|
||
startUtc = System.Diagnostics.Process.GetCurrentProcess().StartTime.ToUniversalTime();
|
||
}
|
||
catch (Exception)
|
||
{
|
||
double up = UnityEngine.Time.realtimeSinceStartup;
|
||
startUtc = DateTime.UtcNow.AddSeconds(-Math.Round(up));
|
||
}
|
||
|
||
startUtc = new DateTime(
|
||
startUtc.Year, startUtc.Month, startUtc.Day,
|
||
startUtc.Hour, startUtc.Minute, startUtc.Second,
|
||
DateTimeKind.Utc);
|
||
|
||
return "boot-" + startUtc.ToString(
|
||
"yyyyMMdd'T'HHmmss'Z'", System.Globalization.CultureInfo.InvariantCulture);
|
||
}
|
||
|
||
private static long NowMs()
|
||
{
|
||
return ToUnixMs(DateTime.UtcNow);
|
||
}
|
||
|
||
// ---- account linking ----
|
||
|
||
/// <summary>
|
||
/// The alphabet a link code is drawn from.
|
||
///
|
||
/// <para>
|
||
/// <b>No O, 0, I or 1.</b> A player reads this code off their screen and types it into
|
||
/// a browser, often on a phone, sometimes reading it aloud to somebody else. Those four
|
||
/// glyphs are the ones that get transcribed wrong, and a code that fails for a reason
|
||
/// the player cannot see is worse than a longer one.
|
||
/// </para>
|
||
/// </summary>
|
||
private const string CodeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||
|
||
private const int CodeLength = 6;
|
||
|
||
/// <summary>
|
||
/// How long a code is good for. Short on purpose: R1 made this an authorization path,
|
||
/// not a convenience — the site becomes the author of who may do what in game, so a
|
||
/// code that lingers is a privilege waiting to be picked up.
|
||
/// </summary>
|
||
private static readonly TimeSpan CodeTtl = TimeSpan.FromMinutes(5);
|
||
|
||
/// <summary>How long a player must wait between <c>/link</c> requests.</summary>
|
||
private static readonly TimeSpan RequestCooldown = TimeSpan.FromSeconds(30);
|
||
|
||
/// <summary>
|
||
/// An unconfirmed code, and the Steam identity it will bind.
|
||
///
|
||
/// <para>
|
||
/// <b>These live in memory and nowhere else</b>, which is the same call the UO bridge
|
||
/// made and it is the right one for the same reason: the secret exists only on the
|
||
/// server that minted it, for five minutes, and nothing writes it to disk. A plugin
|
||
/// reload drops every pending code — and phase 7b's config editor reloads plugins
|
||
/// routinely — but the cost of that is a player typing <c>/link</c> again, which is
|
||
/// cheaper than a credential in a second process.
|
||
/// </para>
|
||
/// </summary>
|
||
private sealed class PendingLink
|
||
{
|
||
public string SteamId;
|
||
public string Name;
|
||
public DateTime Expires;
|
||
}
|
||
|
||
/// <summary>code -> pending link. Main thread only, like every world read here.</summary>
|
||
private readonly Dictionary<string, PendingLink> _codes =
|
||
new Dictionary<string, PendingLink>(StringComparer.OrdinalIgnoreCase);
|
||
|
||
/// <summary>steam id -> when they last asked, so <c>/link</c> cannot be spammed.</summary>
|
||
private readonly Dictionary<string, DateTime> _lastLinkRequest =
|
||
new Dictionary<string, DateTime>(StringComparer.Ordinal);
|
||
|
||
private readonly System.Random _codeRandom = new System.Random();
|
||
|
||
/// <summary>
|
||
/// <c>/link</c> — mint a one-time code for the caller and tell the sidecar about it.
|
||
///
|
||
/// <para>
|
||
/// <b>Chat, not console, and the reply is private.</b> Both frameworks consume a
|
||
/// <c>/</c> command rather than broadcasting it, and <c>SendReply</c> addresses one
|
||
/// player — so neither the request nor the code reaches anybody else's chat. That is
|
||
/// load-bearing rather than polish: a code read off a stream is a code somebody else
|
||
/// can spend.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// The plugin does <b>not</b> record who is linked. The website is the author of
|
||
/// record for that (R2), and a second copy here would be a second thing to keep in
|
||
/// step across a wipe. So there is no "you are already linked" check on this path —
|
||
/// the site refuses a Steam id it already holds, where it can say which account holds
|
||
/// it.
|
||
/// </para>
|
||
/// </summary>
|
||
[ChatCommand("link")]
|
||
private void CmdLinkChat(BasePlayer player, string command, string[] args)
|
||
{
|
||
if (player == null)
|
||
return;
|
||
|
||
string steamId = SteamIdOf(player);
|
||
if (string.IsNullOrEmpty(steamId))
|
||
return;
|
||
|
||
DateTime last;
|
||
if (_lastLinkRequest.TryGetValue(steamId, out last) && DateTime.UtcNow - last < RequestCooldown)
|
||
{
|
||
SendReply(player, "Please wait a moment before asking for another link code.");
|
||
return;
|
||
}
|
||
|
||
// One outstanding code per player: drop any earlier one so only the newest works.
|
||
// Without this a player who ran /link three times leaves three live codes behind.
|
||
DropCodesFor(steamId);
|
||
|
||
string code = MintCode();
|
||
_codes[code] = new PendingLink
|
||
{
|
||
SteamId = steamId,
|
||
Name = player.displayName,
|
||
Expires = DateTime.UtcNow + CodeTtl
|
||
};
|
||
_lastLinkRequest[steamId] = DateTime.UtcNow;
|
||
|
||
Dictionary<string, object> frame = Frame("account.link.requested", "event");
|
||
frame["steamId"] = steamId;
|
||
frame["name"] = player.displayName;
|
||
frame["ttlSec"] = (long)CodeTtl.TotalSeconds;
|
||
|
||
// **The code is NOT in this frame, and that is the point.** The event exists so an
|
||
// operator can see that linking is being used and so the site can rate-limit a player
|
||
// who is fishing; it is not how the code travels. The code travels through the player,
|
||
// which is what makes typing it proof that they are the one who asked.
|
||
EmitFrame(frame);
|
||
|
||
SendReply(player, "Your link code is <color=#e0b34d>" + code + "</color>.");
|
||
SendReply(
|
||
player,
|
||
"Enter it on the website within " + (int)CodeTtl.TotalMinutes +
|
||
" minutes to link this Steam account. It works once.");
|
||
}
|
||
|
||
/// <summary>
|
||
/// <c>/unlink</c> — ask the website to sever this Steam id's link.
|
||
///
|
||
/// <para>
|
||
/// <b>It emits rather than deletes</b>, because the plugin holds no link to delete. The
|
||
/// site owns the record and applies this like any other event off the feed.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// It exists because the site <b>refuses</b> to move a Steam id that another account
|
||
/// already holds. Without a way out, a player who linked the wrong account while logged
|
||
/// in as it would need staff; with this they walk to a computer they are already sitting
|
||
/// at. The authority is the Steam id itself — whoever is connected as it is who it is.
|
||
/// </para>
|
||
/// </summary>
|
||
[ChatCommand("unlink")]
|
||
private void CmdUnlinkChat(BasePlayer player, string command, string[] args)
|
||
{
|
||
if (player == null)
|
||
return;
|
||
|
||
string steamId = SteamIdOf(player);
|
||
if (string.IsNullOrEmpty(steamId))
|
||
return;
|
||
|
||
DropCodesFor(steamId);
|
||
|
||
Dictionary<string, object> frame = Frame("account.unlinked", "event");
|
||
frame["steamId"] = steamId;
|
||
frame["name"] = player.displayName;
|
||
frame["origin"] = "in-game";
|
||
EmitFrame(frame);
|
||
|
||
SendReply(
|
||
player,
|
||
"Asked the website to unlink this Steam account. If it was linked, it is not now.");
|
||
}
|
||
|
||
/// <summary>
|
||
/// Inbound <c>link.confirm</c> — the website has been given a code and wants to know
|
||
/// whose it is.
|
||
///
|
||
/// <para>
|
||
/// <b>This is the first command the website originates</b> in this bridge; everything
|
||
/// before protocol 3 was the game talking. The reply carries the Steam id and nothing
|
||
/// the caller did not already prove — there is no session here, no account creation,
|
||
/// and no write to the game.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// A code is consumed on the <b>first</b> lookup whether or not it turns out to be
|
||
/// expired, so a code cannot be probed twice. That is why the removal happens before
|
||
/// the expiry check rather than after it.
|
||
/// </para>
|
||
/// </summary>
|
||
private void HandleLinkConfirm(Dictionary<string, object> cmd, string reqId)
|
||
{
|
||
object codeObj;
|
||
string code = cmd.TryGetValue("code", out codeObj) && codeObj != null
|
||
? codeObj.ToString().Trim()
|
||
: null;
|
||
|
||
if (string.IsNullOrEmpty(code))
|
||
{
|
||
EmitLinkError(reqId, "malformed");
|
||
return;
|
||
}
|
||
|
||
PendingLink pending;
|
||
if (!_codes.TryGetValue(code, out pending))
|
||
{
|
||
EmitLinkError(reqId, "unknown");
|
||
return;
|
||
}
|
||
|
||
_codes.Remove(code);
|
||
|
||
if (DateTime.UtcNow > pending.Expires)
|
||
{
|
||
EmitLinkError(reqId, "expired");
|
||
return;
|
||
}
|
||
|
||
Dictionary<string, object> ok = Frame("link.ok", "reply");
|
||
ok["steamId"] = pending.SteamId;
|
||
ok["name"] = pending.Name;
|
||
if (reqId != null)
|
||
ok["reqId"] = reqId;
|
||
|
||
EmitFrame(ok);
|
||
}
|
||
|
||
/// <summary>
|
||
/// A refusal, with a reason the website can turn into a sentence.
|
||
///
|
||
/// <para>
|
||
/// <c>unknown</c> and <c>expired</c> are deliberately <b>separate</b> here and
|
||
/// deliberately <b>the same</b> to the player: an operator reading a log wants to know
|
||
/// whether codes are being guessed or merely going stale, and a stranger typing codes
|
||
/// must not learn which of the two they hit.
|
||
/// </para>
|
||
/// </summary>
|
||
private void EmitLinkError(string reqId, string reason)
|
||
{
|
||
Dictionary<string, object> frame = Frame("link.error", "reply");
|
||
frame["reason"] = reason;
|
||
if (reqId != null)
|
||
frame["reqId"] = reqId;
|
||
|
||
EmitFrame(frame);
|
||
}
|
||
|
||
/// <summary>Drops every outstanding code belonging to one Steam id.</summary>
|
||
private void DropCodesFor(string steamId)
|
||
{
|
||
List<string> stale = null;
|
||
|
||
foreach (KeyValuePair<string, PendingLink> entry in _codes)
|
||
{
|
||
if (entry.Value != null && entry.Value.SteamId == steamId)
|
||
{
|
||
if (stale == null)
|
||
stale = new List<string>();
|
||
|
||
stale.Add(entry.Key);
|
||
}
|
||
}
|
||
|
||
if (stale == null)
|
||
return;
|
||
|
||
foreach (string key in stale)
|
||
_codes.Remove(key);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Drops expired codes and stale cooldowns.
|
||
///
|
||
/// <para>
|
||
/// Run on a timer rather than only on lookup, because an unconfirmed code is never
|
||
/// looked up — a player who runs <c>/link</c> and wanders off leaves an entry that
|
||
/// nothing else would ever remove.
|
||
/// </para>
|
||
/// </summary>
|
||
private void PurgeExpiredCodes()
|
||
{
|
||
DateTime now = DateTime.UtcNow;
|
||
List<string> dead = null;
|
||
|
||
foreach (KeyValuePair<string, PendingLink> entry in _codes)
|
||
{
|
||
if (entry.Value == null || now > entry.Value.Expires)
|
||
{
|
||
if (dead == null)
|
||
dead = new List<string>();
|
||
|
||
dead.Add(entry.Key);
|
||
}
|
||
}
|
||
|
||
if (dead != null)
|
||
{
|
||
foreach (string key in dead)
|
||
_codes.Remove(key);
|
||
}
|
||
|
||
List<string> cold = null;
|
||
|
||
foreach (KeyValuePair<string, DateTime> entry in _lastLinkRequest)
|
||
{
|
||
if (now - entry.Value > RequestCooldown)
|
||
{
|
||
if (cold == null)
|
||
cold = new List<string>();
|
||
|
||
cold.Add(entry.Key);
|
||
}
|
||
}
|
||
|
||
if (cold == null)
|
||
return;
|
||
|
||
foreach (string key in cold)
|
||
_lastLinkRequest.Remove(key);
|
||
}
|
||
|
||
/// <summary>
|
||
/// A code from <see cref="CodeAlphabet"/>, retried until it does not collide.
|
||
///
|
||
/// <para>
|
||
/// <c>System.Random</c> rather than a cryptographic source, and the reason it is
|
||
/// defensible is the shape of the guess rather than the shape of the generator: a code
|
||
/// lives five minutes, is consumed on first lookup, and the website rate-limits the
|
||
/// confirm endpoint. An attacker who could predict the sequence would still have to
|
||
/// reach a code before the player does.
|
||
/// </para>
|
||
/// </summary>
|
||
private string MintCode()
|
||
{
|
||
for (int attempt = 0; attempt < 16; attempt++)
|
||
{
|
||
var chars = new char[CodeLength];
|
||
|
||
for (int i = 0; i < CodeLength; i++)
|
||
chars[i] = CodeAlphabet[_codeRandom.Next(CodeAlphabet.Length)];
|
||
|
||
string candidate = new string(chars);
|
||
|
||
if (!_codes.ContainsKey(candidate))
|
||
return candidate;
|
||
}
|
||
|
||
// Sixteen collisions in a row against a table this small is not bit-space exhaustion,
|
||
// it is something being wrong. A code that cannot collide is better than a loop that
|
||
// might not end.
|
||
return Guid.NewGuid().ToString("N").Substring(0, CodeLength).ToUpperInvariant();
|
||
}
|
||
|
||
// ---- site-owned permissions (protocol 4) ----
|
||
//
|
||
// R2: the website is the author of record for groups and grants, and this framework's own
|
||
// permission store is an ENFORCEMENT CACHE. Every third-party plugin honours a site grant
|
||
// with no adapter, because they all already call `UserHasPermission`.
|
||
//
|
||
// ── One verb, and the plugin does the diffing (D32) ───────────────────────
|
||
//
|
||
// The site sends the whole set it authors for this server; this plugin compares it against
|
||
// the live store and writes only what differs. It is the same shape as a board, pointed the
|
||
// other way: one producer, re-sent on connect, reconciled by the receiver.
|
||
//
|
||
// The alternative — shipping this server's permission store to the website and diffing
|
||
// there — was rejected for two reasons. The store is the bigger of the two sets and would
|
||
// cross the wire constantly, and a website holding a copy of it has a second source of
|
||
// truth that is stale the moment it lands.
|
||
//
|
||
// ── The site must not be able to stall the game, either ───────────────────
|
||
//
|
||
// This is the first command whose work is UNBOUNDED: a community with two thousand linked
|
||
// players has thousands of grants in one frame, and applying them in the tick the frame
|
||
// arrives is a freeze the operator will blame on the game. So a sync is compiled into a
|
||
// list of single-store operations and drained a few hundred at a time on a timer, and the
|
||
// report goes back when the last one lands.
|
||
//
|
||
// That is the same rule the outbound queue lives under (§5), applied to the inbound half:
|
||
// **nothing the far side sends may cost the main thread an unbounded amount of work.**
|
||
//
|
||
// ── What it reports, and what it will not do ──────────────────────────────
|
||
//
|
||
// • A permission this server has not REGISTERED is reported `unresolved` and skipped.
|
||
// `GrantUserPermission` silently no-ops for an unregistered name — void, no throw, no
|
||
// log (PLAN.md §12.2 rule 1) — so without the pre-check a re-push into a server missing
|
||
// one plugin is a silent partial. Registering the name here instead would fabricate a
|
||
// permission the operator never installed (D33).
|
||
// • A holder the site does not author is never touched by a sync. Until protocol 13 the
|
||
// sync scanned the names the site claimed and reported such holders `foreign` (D31).
|
||
// Now the site owns the whole store (D160) and reads it with `perm.inventory`, so it
|
||
// finds a hand edit itself and answers it with the server's policy (D161): the sync
|
||
// only ever carries what the site decided.
|
||
// • A group member who has never connected is reported `pending`. The store has no user
|
||
// record to put in a group yet (§12.2 rule 4), and the membership lands on their first
|
||
// connection — a direct grant would have reached them immediately, and pretending the
|
||
// two are the same thing is how a site promises access it did not give.
|
||
|
||
/// <summary>
|
||
/// How many store operations one timer step performs.
|
||
///
|
||
/// <para>
|
||
/// Each is a dictionary lookup and, at most, one write to an in-memory structure the
|
||
/// framework flushes on its own schedule — microseconds. Two hundred of them is well
|
||
/// under a frame at 60fps, and it drains ten thousand operations in about two and a
|
||
/// half seconds, which is inside the sidecar's reply timeout.
|
||
/// </para>
|
||
/// </summary>
|
||
private const int PermOpsPerStep = 200;
|
||
|
||
/// <summary>How often the sync drain runs while a job is in flight.</summary>
|
||
private const float PermStepSeconds = 0.05f;
|
||
|
||
/// <summary>
|
||
/// The ceiling on one sync, in operations.
|
||
///
|
||
/// <para>
|
||
/// A frame larger than this is refused whole rather than applied for a while and then
|
||
/// abandoned. The website has its own limit below this one, so reaching it means
|
||
/// something is wrong rather than something is big — and a half-applied permission set
|
||
/// is the state nobody can reason about.
|
||
/// </para>
|
||
/// </summary>
|
||
private const int PermMaxOps = 20000;
|
||
|
||
/// <summary>The operations a compiled sync is made of. One store question each.</summary>
|
||
private enum PermOpKind
|
||
{
|
||
EnsureGroup,
|
||
EnsureGroupPermission,
|
||
EnsureMember,
|
||
EnsureGrant,
|
||
RetireGroupPermission,
|
||
RetireMember,
|
||
RetireGrant,
|
||
RetireGroup,
|
||
|
||
/// <summary>
|
||
/// Protocol 13: a group's parent (PLAN_REDESIGNS §1.4). Compiled after every group,
|
||
/// so the parent it names already exists when it is applied.
|
||
/// </summary>
|
||
EnsureGroupParent,
|
||
|
||
/// <summary>Protocol 12: a group's BetterChat style (D138).</summary>
|
||
EnsureChatStyle,
|
||
|
||
/// <summary>Protocol 12: a styled group's BetterChat group, gone from the site (D139).</summary>
|
||
RetireChatGroup
|
||
}
|
||
|
||
private struct PermOp
|
||
{
|
||
public PermOpKind Kind;
|
||
|
||
/// <summary>A Steam id, or a group name — whichever the op is about.</summary>
|
||
public string Subject;
|
||
|
||
/// <summary>A permission name, a group name, or null.</summary>
|
||
public string Object;
|
||
|
||
/// <summary>Only <see cref="PermOpKind.EnsureGroup"/> uses these.</summary>
|
||
public string Title;
|
||
|
||
public int Rank;
|
||
|
||
/// <summary>
|
||
/// Whether the site sent a rank. A group whose frame has none keeps the rank it has,
|
||
/// rather than being set to 0 by an absent field.
|
||
/// </summary>
|
||
public bool HasRank;
|
||
}
|
||
|
||
/// <summary>
|
||
/// One sync in flight. There is at most one: a second arriving while this one drains is
|
||
/// answered `busy` rather than interleaved, because two writers reconciling the same
|
||
/// store against two different desired sets is a race with no correct outcome.
|
||
/// </summary>
|
||
private sealed class PermSyncJob
|
||
{
|
||
public string ReqId;
|
||
public string SetId;
|
||
public List<PermOp> Ops = new List<PermOp>();
|
||
public int Next;
|
||
|
||
public int Grants;
|
||
public int Revokes;
|
||
public int GroupsCreated;
|
||
|
||
/// <summary>Protocol 13: an existing group whose title, rank or parent the sync changed.</summary>
|
||
public int GroupsUpdated;
|
||
|
||
public int GroupsRemoved;
|
||
public int GroupPermissions;
|
||
public int GroupPermissionsRemoved;
|
||
public int Members;
|
||
public int MembersRemoved;
|
||
public int AlreadyCorrect;
|
||
public int Absent;
|
||
|
||
public readonly List<string> Unresolved = new List<string>();
|
||
public readonly List<string> Pending = new List<string>();
|
||
|
||
/// <summary>
|
||
/// Grants the store did not hold after being told to (<c>subject:permission</c>).
|
||
/// Reported rather than counted, so the site can never again be told a grant
|
||
/// landed that did not (D85).
|
||
/// </summary>
|
||
public readonly List<string> NotLanded = new List<string>();
|
||
|
||
/// <summary>
|
||
/// Group permissions an event lease holds (protocol 8). Not applied; what the site
|
||
/// asked for is kept on the hold instead.
|
||
/// </summary>
|
||
public readonly List<object> Leased = new List<object>();
|
||
|
||
public readonly HashSet<string> LeasedSeen = new HashSet<string>();
|
||
|
||
/// <summary>Permission names already reported unresolved, so each is reported once.</summary>
|
||
public readonly HashSet<string> UnresolvedSeen = new HashSet<string>();
|
||
|
||
/// <summary>
|
||
/// The site's kit credits for this server (protocol 10, D103), or null when the sync
|
||
/// carried none — an older website, whose silence must not withdraw anybody's credit.
|
||
/// </summary>
|
||
public List<KitCredit> Credits;
|
||
|
||
public int CreditsApplied;
|
||
public int CreditsWithdrawn;
|
||
public bool CreditsWaiting;
|
||
public readonly List<string> CreditsUnresolved = new List<string>();
|
||
|
||
/// <summary>
|
||
/// Whether BetterChat was loaded when this sync was compiled (protocol 12). When it
|
||
/// was not, no style op is compiled and the report says <c>loaded: false</c> and
|
||
/// nothing else: the permissions sync exactly as they did before.
|
||
/// </summary>
|
||
public bool ChatLoaded;
|
||
|
||
public string ChatVersion;
|
||
|
||
/// <summary>The fields the site wants on each styled group.</summary>
|
||
public readonly Dictionary<string, List<ChatFieldWant>> ChatStyles =
|
||
new Dictionary<string, List<ChatFieldWant>>();
|
||
|
||
public int ChatApplied;
|
||
public int ChatSaved;
|
||
public int ChatSame;
|
||
public readonly List<object> ChatDrift = new List<object>();
|
||
public readonly List<object> ChatRemoved = new List<object>();
|
||
public readonly List<object> ChatFailed = new List<object>();
|
||
}
|
||
|
||
/// <summary>
|
||
/// One BetterChat field the site wants on a group (protocol 12): the value, and the
|
||
/// value this site last pushed there.
|
||
/// </summary>
|
||
private sealed class ChatFieldWant
|
||
{
|
||
public string Field;
|
||
public string Value;
|
||
|
||
/// <summary>
|
||
/// What the site last pushed, or null for a field it has never pushed. A game value
|
||
/// that is neither this nor the one wanted is somebody's hand edit (D138).
|
||
/// </summary>
|
||
public string Expect;
|
||
}
|
||
|
||
private PermSyncJob _permJob;
|
||
private Timer _permTimer;
|
||
|
||
/// <summary>
|
||
/// True while this plugin is writing to the permission store.
|
||
///
|
||
/// <para>
|
||
/// The framework raises <c>OnUserPermissionGranted</c> and friends for every write,
|
||
/// including ours — so without this flag every sync would announce itself as drift and
|
||
/// the website would chase its own tail. It is set around each drain step rather than
|
||
/// for the whole job, so a write by somebody else BETWEEN steps is still seen.
|
||
/// </para>
|
||
/// </summary>
|
||
private bool _applyingPermissions;
|
||
|
||
/// <summary>The last report, for <c>rg.perms</c>. Diagnostics only.</summary>
|
||
private string _lastPermSummary = "none yet";
|
||
|
||
/// <summary>
|
||
/// Main thread. Everything this server knows about permissions and groups — the option
|
||
/// source the website's authoring form is built from (D33).
|
||
///
|
||
/// <para>
|
||
/// It is a live read rather than anything cached, because the answer changes when an
|
||
/// operator loads a plugin and the whole point of asking is to offer names that will
|
||
/// actually resolve.
|
||
/// </para>
|
||
/// </summary>
|
||
private void HandlePermCatalogue(string reqId)
|
||
{
|
||
Dictionary<string, object> reply = Frame("perm.catalogue", "reply");
|
||
|
||
var permissions = new List<object>();
|
||
string[] registered = permission.GetPermissions();
|
||
|
||
if (registered != null)
|
||
{
|
||
foreach (string name in registered)
|
||
{
|
||
if (!string.IsNullOrEmpty(name))
|
||
permissions.Add(name);
|
||
}
|
||
}
|
||
|
||
var groups = new List<object>();
|
||
string[] groupNames = permission.GetGroups();
|
||
|
||
if (groupNames != null)
|
||
{
|
||
foreach (string name in groupNames)
|
||
{
|
||
if (string.IsNullOrEmpty(name))
|
||
continue;
|
||
|
||
var row = new Dictionary<string, object>
|
||
{
|
||
{ "name", name },
|
||
{ "title", permission.GetGroupTitle(name) },
|
||
{ "rank", permission.GetGroupRank(name) }
|
||
};
|
||
|
||
var granted = new List<object>();
|
||
string[] groupPerms = permission.GetGroupPermissions(name, false);
|
||
|
||
if (groupPerms != null)
|
||
{
|
||
foreach (string perm in groupPerms)
|
||
{
|
||
if (!string.IsNullOrEmpty(perm))
|
||
granted.Add(perm);
|
||
}
|
||
}
|
||
|
||
row["permissions"] = granted;
|
||
groups.Add(row);
|
||
}
|
||
}
|
||
|
||
reply["permissions"] = permissions;
|
||
reply["groups"] = groups;
|
||
|
||
if (reqId != null)
|
||
reply["reqId"] = reqId;
|
||
|
||
EmitFrame(reply);
|
||
}
|
||
|
||
// ---- protocol 13: the whole store (PLAN_REDESIGNS §1.2, D160) ----
|
||
//
|
||
// The site owns every permission and group on this server, so it has to be able to read
|
||
// all of them — not just the names it once authored, which is all the foreign scan ever
|
||
// looked at. One verb, `perm.inventory`, answers with:
|
||
//
|
||
// • every registered permission and the plugin that REGISTERED it. The owner comes from
|
||
// `PermissionExists(name, plugin)` asked of each loaded plugin — public API on both
|
||
// frameworks, proven on both rigs — never from the name's prefix, which is how
|
||
// `zonemanager.ignoreflag.nokits` ends up filed under Kits in other tools.
|
||
// • every group with its title, rank, parent and the permissions it carries itself.
|
||
// • every Steam id that holds anything: its own permissions and its groups, except
|
||
// `default`, which every connected player is in by the framework's own rule.
|
||
// • the group permissions an event lease is holding, which are the lease's, not a hand
|
||
// edit's.
|
||
//
|
||
// Neither framework can list its users, so the holders come from asking each permission
|
||
// and each group who has it, and each of those walks every user. On a real store that is
|
||
// permissions × users, so it is built a slice per tick, like the sync drain, and then
|
||
// sent in pages that each fit the game link's 1 MiB line cap. The snapshot is taken once
|
||
// and paged from memory, so a grant made between two pages cannot tear it.
|
||
|
||
/// <summary>How many permissions or groups one build step asks about.</summary>
|
||
private const int InventorySourcesPerStep = 25;
|
||
|
||
/// <summary>How many bytes of rows one page may carry, well inside the 1 MiB line cap.</summary>
|
||
private const int InventoryPageBudgetBytes = 700 * 1024;
|
||
|
||
/// <summary>
|
||
/// How long a build may run before it is abandoned. The sidecar waits ten seconds for a
|
||
/// reply; a build past this has already lost that wait, and a store that large needs a
|
||
/// person to hear about it rather than a hitch every sync.
|
||
/// </summary>
|
||
private const long InventoryMaxBuildMs = 8000;
|
||
|
||
/// <summary>How long a finished snapshot is kept for the pages after the first.</summary>
|
||
private const long InventoryKeepMs = 2 * 60 * 1000;
|
||
|
||
private sealed class InventoryUser
|
||
{
|
||
public string SteamId;
|
||
public string Name;
|
||
public readonly List<string> Permissions = new List<string>();
|
||
public readonly List<string> Groups = new List<string>();
|
||
}
|
||
|
||
/// <summary>A build in flight. There is at most one; a second request is answered `busy`.</summary>
|
||
private sealed class InventoryJob
|
||
{
|
||
public string ReqId;
|
||
public long StartedMs;
|
||
|
||
/// <summary>Started from the server console (<c>rg.inventory</c>): logged, never sent.</summary>
|
||
public bool FromConsole;
|
||
|
||
/// <summary>Permissions first, then groups prefixed with a NUL, one store question each.</summary>
|
||
public readonly List<string> Sources = new List<string>();
|
||
|
||
public int Next;
|
||
public readonly Dictionary<string, InventoryUser> Users = new Dictionary<string, InventoryUser>();
|
||
public List<object> Permissions;
|
||
public List<object> Groups;
|
||
public List<object> Leased;
|
||
}
|
||
|
||
/// <summary>A finished snapshot, paged from memory.</summary>
|
||
private sealed class Inventory
|
||
{
|
||
public string Id;
|
||
public long BuiltMs;
|
||
public long BuildMs;
|
||
public List<object> Permissions;
|
||
public List<object> Groups;
|
||
public List<object> Leased;
|
||
public int UserCount;
|
||
public readonly List<List<object>> Pages = new List<List<object>>();
|
||
}
|
||
|
||
private InventoryJob _inventoryJob;
|
||
private Timer _inventoryTimer;
|
||
private Inventory _inventory;
|
||
|
||
/// <summary>The last inventory built, for <c>rg.perms</c>. Diagnostics only.</summary>
|
||
private string _lastInventorySummary = "none yet";
|
||
|
||
/// <summary>
|
||
/// The same build from the server console, logged rather than sent: how a rig proves the
|
||
/// read — and times it on a large store — without the website or the sidecar's token.
|
||
/// </summary>
|
||
[ConsoleCommand("rg.inventory")]
|
||
private void CmdInventory(ConsoleSystem.Arg arg)
|
||
{
|
||
if (arg.Connection != null)
|
||
return;
|
||
|
||
if (_inventoryJob != null)
|
||
{
|
||
arg.ReplyWith("an inventory is already being built");
|
||
return;
|
||
}
|
||
|
||
StartInventory(null, true);
|
||
arg.ReplyWith("inventory started; the summary is logged");
|
||
}
|
||
|
||
/// <summary>Main thread. Page 0 starts a build; any other page is read from the snapshot it names.</summary>
|
||
private void HandlePermInventory(Dictionary<string, object> cmd, string reqId)
|
||
{
|
||
string snapshotId = StringField(cmd, "snapshotId");
|
||
int page = IntField(cmd, "page");
|
||
|
||
if (!string.IsNullOrEmpty(snapshotId))
|
||
{
|
||
Inventory held = _inventory;
|
||
|
||
if (held == null || held.Id != snapshotId || NowMs() - held.BuiltMs > InventoryKeepMs ||
|
||
page < 0 || page >= held.Pages.Count)
|
||
{
|
||
// The site starts again from page 0; a stale page is never a partial answer.
|
||
EmitPermError(reqId, "stale");
|
||
return;
|
||
}
|
||
|
||
EmitInventoryPage(held, page, reqId);
|
||
return;
|
||
}
|
||
|
||
if (_inventoryJob != null)
|
||
{
|
||
EmitPermError(reqId, "busy");
|
||
return;
|
||
}
|
||
|
||
StartInventory(reqId, false);
|
||
}
|
||
|
||
/// <summary>Main thread. Reads the small parts at once and starts the sliced holder walk.</summary>
|
||
private void StartInventory(string reqId, bool fromConsole)
|
||
{
|
||
var job = new InventoryJob { ReqId = reqId, StartedMs = NowMs(), FromConsole = fromConsole };
|
||
|
||
// Owners, groups and leases are small and read at once; the holder walk is sliced.
|
||
var owner = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||
string[] registered = permission.GetPermissions() ?? new string[0];
|
||
|
||
foreach (Plugin plugin in plugins.GetAll())
|
||
{
|
||
if (plugin == null)
|
||
continue;
|
||
|
||
foreach (string perm in registered)
|
||
{
|
||
if (!string.IsNullOrEmpty(perm) && !owner.ContainsKey(perm) && permission.PermissionExists(perm, plugin))
|
||
owner[perm] = plugin.Name;
|
||
}
|
||
}
|
||
|
||
job.Permissions = new List<object>();
|
||
|
||
foreach (string perm in registered)
|
||
{
|
||
if (string.IsNullOrEmpty(perm))
|
||
continue;
|
||
|
||
string lowered = Lower(perm);
|
||
var row = new Dictionary<string, object> { { "name", lowered } };
|
||
string by;
|
||
|
||
// A name no plugin owns — Carbon's built-in modules register theirs that way — is
|
||
// sent without one, and the site groups it by prefix.
|
||
if (owner.TryGetValue(perm, out by))
|
||
row["owner"] = by;
|
||
|
||
job.Permissions.Add(row);
|
||
job.Sources.Add(lowered);
|
||
}
|
||
|
||
job.Groups = new List<object>();
|
||
|
||
foreach (string name in permission.GetGroups() ?? new string[0])
|
||
{
|
||
if (string.IsNullOrEmpty(name))
|
||
continue;
|
||
|
||
string lowered = Lower(name);
|
||
var carried = new List<object>();
|
||
|
||
foreach (string perm in permission.GetGroupPermissions(name, false) ?? new string[0])
|
||
{
|
||
if (!string.IsNullOrEmpty(perm))
|
||
carried.Add(Lower(perm));
|
||
}
|
||
|
||
job.Groups.Add(new Dictionary<string, object>
|
||
{
|
||
{ "name", lowered },
|
||
{ "title", permission.GetGroupTitle(name) ?? "" },
|
||
{ "rank", permission.GetGroupRank(name) },
|
||
{ "parent", Lower(permission.GetGroupParent(name)) ?? "" },
|
||
{ "permissions", carried }
|
||
});
|
||
|
||
// `default` holds every player who ever connected, by the framework's rule rather
|
||
// than anybody's decision, so its members are not the site's to own.
|
||
if (!string.Equals(lowered, "default", StringComparison.Ordinal))
|
||
job.Sources.Add("\0" + lowered);
|
||
}
|
||
|
||
job.Leased = new List<object>();
|
||
|
||
foreach (LeaseHold hold in _leases.Values)
|
||
{
|
||
if (hold.Key != GroupPermissionKey || string.IsNullOrEmpty(hold.Target))
|
||
continue;
|
||
|
||
int slash = hold.Target.IndexOf('/');
|
||
|
||
if (slash <= 0)
|
||
continue;
|
||
|
||
job.Leased.Add(new Dictionary<string, object>
|
||
{
|
||
{ "kind", "group-permission" },
|
||
{ "subject", Lower(hold.Target.Substring(0, slash)) },
|
||
{ "object", Lower(hold.Target.Substring(slash + 1)) }
|
||
});
|
||
}
|
||
|
||
_inventoryJob = job;
|
||
_inventoryTimer = timer.Every(PermStepSeconds, StepInventory);
|
||
|
||
// A small store — every rig, and most real servers — finishes inside this call.
|
||
StepInventory();
|
||
}
|
||
|
||
/// <summary>Main thread. Asks up to <see cref="InventorySourcesPerStep"/> permissions or groups who holds them.</summary>
|
||
private void StepInventory()
|
||
{
|
||
InventoryJob job = _inventoryJob;
|
||
|
||
if (job == null)
|
||
{
|
||
StopInventoryTimer();
|
||
return;
|
||
}
|
||
|
||
if (NowMs() - job.StartedMs > InventoryMaxBuildMs)
|
||
{
|
||
_inventoryJob = null;
|
||
StopInventoryTimer();
|
||
Puts($"permission inventory abandoned after {InventoryMaxBuildMs} ms at {job.Next} of {job.Sources.Count} sources, {job.Users.Count} users");
|
||
|
||
if (!job.FromConsole)
|
||
EmitPermError(job.ReqId, "too-large");
|
||
|
||
return;
|
||
}
|
||
|
||
int budget = InventorySourcesPerStep;
|
||
|
||
while (budget-- > 0 && job.Next < job.Sources.Count)
|
||
{
|
||
string source = job.Sources[job.Next++];
|
||
|
||
try
|
||
{
|
||
bool isGroup = source.Length > 0 && source[0] == '\0';
|
||
string name = isGroup ? source.Substring(1) : source;
|
||
string[] entries = isGroup ? permission.GetUsersInGroup(name) : permission.GetPermissionUsers(name);
|
||
|
||
if (entries == null)
|
||
continue;
|
||
|
||
foreach (string entry in entries)
|
||
{
|
||
string id = UserId(entry);
|
||
|
||
if (string.IsNullOrEmpty(id))
|
||
continue;
|
||
|
||
InventoryUser user;
|
||
|
||
if (!job.Users.TryGetValue(id, out user))
|
||
{
|
||
user = new InventoryUser { SteamId = id, Name = StoredName(entry) };
|
||
job.Users[id] = user;
|
||
}
|
||
|
||
if (isGroup)
|
||
user.Groups.Add(name);
|
||
else
|
||
user.Permissions.Add(name);
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
// One odd name must not lose the whole read; the next sync reads it again.
|
||
Puts($"permission inventory: '{source.TrimStart('\0')}' threw: {Flatten(ex.Message)}");
|
||
}
|
||
}
|
||
|
||
if (job.Next < job.Sources.Count)
|
||
return;
|
||
|
||
_inventoryJob = null;
|
||
StopInventoryTimer();
|
||
|
||
Inventory done = Paginate(job);
|
||
_inventory = done;
|
||
_lastInventorySummary = $"{done.Id}: {done.Permissions.Count} permissions, {done.Groups.Count} groups, " +
|
||
$"{done.UserCount} users, {done.Pages.Count} pages, {done.BuildMs} ms at={DateTime.UtcNow:HH:mm:ss}Z";
|
||
|
||
if (job.FromConsole)
|
||
{
|
||
// To the log, where a rig walk can read it: the console's own reply never reaches
|
||
// Oxide's log file.
|
||
Puts($"inventory {_lastInventorySummary}");
|
||
|
||
var owners = new Dictionary<string, int>();
|
||
|
||
foreach (Dictionary<string, object> row in done.Permissions)
|
||
{
|
||
object by;
|
||
string key = row.TryGetValue("owner", out by) ? (string)by : "(none)";
|
||
int n;
|
||
owners.TryGetValue(key, out n);
|
||
owners[key] = n + 1;
|
||
}
|
||
|
||
var ownerText = new List<string>();
|
||
|
||
foreach (KeyValuePair<string, int> kv in owners)
|
||
ownerText.Add(kv.Key + "=" + kv.Value);
|
||
|
||
List<object> sample = done.Pages[0].GetRange(0, Math.Min(10, done.Pages[0].Count));
|
||
|
||
Puts("inventory owners: " + string.Join(", ", ownerText.ToArray()));
|
||
Puts("inventory groups: " + JsonConvert.SerializeObject(done.Groups, Formatting.None));
|
||
Puts("inventory leased: " + JsonConvert.SerializeObject(done.Leased, Formatting.None));
|
||
Puts("inventory users: " + JsonConvert.SerializeObject(sample, Formatting.None));
|
||
return;
|
||
}
|
||
|
||
EmitInventoryPage(done, 0, job.ReqId);
|
||
}
|
||
|
||
private void StopInventoryTimer()
|
||
{
|
||
if (_inventoryTimer == null)
|
||
return;
|
||
|
||
_inventoryTimer.Destroy();
|
||
_inventoryTimer = null;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Splits the users into pages by their serialised size. Page 0 also carries the
|
||
/// permissions, groups and leases, so its budget is what they leave.
|
||
/// </summary>
|
||
private Inventory Paginate(InventoryJob job)
|
||
{
|
||
var inv = new Inventory
|
||
{
|
||
Id = Guid.NewGuid().ToString("N").Substring(0, 12),
|
||
BuiltMs = NowMs(),
|
||
BuildMs = NowMs() - job.StartedMs,
|
||
Permissions = job.Permissions,
|
||
Groups = job.Groups,
|
||
Leased = job.Leased,
|
||
UserCount = job.Users.Count
|
||
};
|
||
|
||
int header = Encoding.UTF8.GetByteCount(JsonConvert.SerializeObject(new Dictionary<string, object>
|
||
{
|
||
{ "permissions", job.Permissions },
|
||
{ "groups", job.Groups },
|
||
{ "leased", job.Leased }
|
||
}, Formatting.None));
|
||
|
||
var ids = new List<string>(job.Users.Keys);
|
||
ids.Sort(StringComparer.Ordinal);
|
||
|
||
var current = new List<object>();
|
||
int bytes = header;
|
||
|
||
foreach (string id in ids)
|
||
{
|
||
InventoryUser u = job.Users[id];
|
||
var row = new Dictionary<string, object>
|
||
{
|
||
{ "steamId", u.SteamId },
|
||
{ "permissions", u.Permissions },
|
||
{ "groups", u.Groups }
|
||
};
|
||
|
||
if (!string.IsNullOrEmpty(u.Name))
|
||
row["name"] = u.Name;
|
||
|
||
int size = Encoding.UTF8.GetByteCount(JsonConvert.SerializeObject(row, Formatting.None)) + 1;
|
||
|
||
if (current.Count > 0 && bytes + size > InventoryPageBudgetBytes)
|
||
{
|
||
inv.Pages.Add(current);
|
||
current = new List<object>();
|
||
bytes = 0;
|
||
}
|
||
|
||
current.Add(row);
|
||
bytes += size;
|
||
}
|
||
|
||
// Always at least one page, so an empty store still answers page 0.
|
||
inv.Pages.Add(current);
|
||
return inv;
|
||
}
|
||
|
||
private void EmitInventoryPage(Inventory inv, int page, string reqId)
|
||
{
|
||
Dictionary<string, object> reply = Frame("perm.inventory", "reply");
|
||
reply["snapshotId"] = inv.Id;
|
||
reply["page"] = page;
|
||
reply["pages"] = inv.Pages.Count;
|
||
reply["more"] = page + 1 < inv.Pages.Count;
|
||
reply["users"] = inv.Pages[page];
|
||
|
||
if (page == 0)
|
||
{
|
||
reply["permissions"] = inv.Permissions;
|
||
reply["groups"] = inv.Groups;
|
||
reply["leased"] = inv.Leased;
|
||
reply["stats"] = new Dictionary<string, object>
|
||
{
|
||
{ "permissions", inv.Permissions.Count },
|
||
{ "groups", inv.Groups.Count },
|
||
{ "users", inv.UserCount },
|
||
{ "buildMs", inv.BuildMs }
|
||
};
|
||
}
|
||
|
||
if (reqId != null)
|
||
reply["reqId"] = reqId;
|
||
|
||
EmitFrame(reply);
|
||
}
|
||
|
||
/// <summary>
|
||
/// The name out of a store listing entry — <c>76561198…(Wanderer)</c> or
|
||
/// <c>76561198… (Wanderer)</c> — or null. Between the FIRST bracket and the LAST, so a
|
||
/// name with brackets of its own survives.
|
||
/// </summary>
|
||
private static string StoredName(string entry)
|
||
{
|
||
if (string.IsNullOrEmpty(entry))
|
||
return null;
|
||
|
||
int open = entry.IndexOf('(');
|
||
int close = entry.LastIndexOf(')');
|
||
|
||
if (open < 0 || close <= open + 1)
|
||
return null;
|
||
|
||
string name = entry.Substring(open + 1, close - open - 1).Trim();
|
||
return name.Length > 0 && name != "Unnamed" ? name : null;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Main thread. Compiles a desired set into operations and starts draining it.
|
||
///
|
||
/// <para>
|
||
/// Nothing is written here. Compiling is pure bookkeeping over the frame, so a
|
||
/// malformed or enormous sync is refused before it has touched the store — there is no
|
||
/// state to unwind because none was made.
|
||
/// </para>
|
||
/// </summary>
|
||
private void HandlePermSync(Dictionary<string, object> cmd, string reqId)
|
||
{
|
||
if (_permJob != null)
|
||
{
|
||
EmitPermError(reqId, "busy");
|
||
return;
|
||
}
|
||
|
||
var job = new PermSyncJob
|
||
{
|
||
ReqId = reqId,
|
||
SetId = StringField(cmd, "setId")
|
||
};
|
||
|
||
Plugin betterChat = BetterChatPlugin();
|
||
job.ChatLoaded = betterChat != null;
|
||
job.ChatVersion = betterChat != null ? betterChat.Version.ToString() : null;
|
||
|
||
// ── The desired set ────────────────────────────────────────────────
|
||
//
|
||
// Order matters and it is not cosmetic: a group has to exist before anything can be
|
||
// put in it, and a permission has to be on the group before a member inherits it.
|
||
|
||
// Protocol 13: parents are applied after every group exists, because a group may name
|
||
// a parent that comes later in the frame.
|
||
var parentOps = new List<PermOp>();
|
||
|
||
foreach (Dictionary<string, object> group in Rows(cmd, "groups"))
|
||
{
|
||
string name = Lower(StringField(group, "name"));
|
||
if (string.IsNullOrEmpty(name))
|
||
continue;
|
||
|
||
// Protocol 13: an absent title or rank means "leave it" — the site holds an
|
||
// in-game change to them while a person decides (D161's `adopt`) — and an
|
||
// empty title is a title, sent verbatim, not a reason to use the name.
|
||
job.Ops.Add(new PermOp
|
||
{
|
||
Kind = PermOpKind.EnsureGroup,
|
||
Subject = name,
|
||
Title = group.ContainsKey("title") ? (StringField(group, "title") ?? "") : null,
|
||
Rank = IntField(group, "rank"),
|
||
HasRank = group.ContainsKey("rank")
|
||
});
|
||
|
||
// Only a frame that says something about the parent changes it: "" clears it, and
|
||
// an absent key leaves whatever the group has.
|
||
if (group.ContainsKey("parent"))
|
||
{
|
||
parentOps.Add(new PermOp
|
||
{
|
||
Kind = PermOpKind.EnsureGroupParent,
|
||
Subject = name,
|
||
Object = Lower(StringField(group, "parent")) ?? ""
|
||
});
|
||
}
|
||
|
||
foreach (string perm in Strings(group, "permissions"))
|
||
{
|
||
string lowered = Lower(perm);
|
||
if (string.IsNullOrEmpty(lowered))
|
||
continue;
|
||
|
||
job.Ops.Add(new PermOp
|
||
{
|
||
Kind = PermOpKind.EnsureGroupPermission,
|
||
Subject = name,
|
||
Object = lowered
|
||
});
|
||
}
|
||
|
||
foreach (string member in Strings(group, "members"))
|
||
{
|
||
if (string.IsNullOrEmpty(member))
|
||
continue;
|
||
|
||
job.Ops.Add(new PermOp
|
||
{
|
||
Kind = PermOpKind.EnsureMember,
|
||
Subject = member,
|
||
Object = name
|
||
});
|
||
}
|
||
|
||
// Protocol 12: the group's BetterChat style. After the group's own ops, so the
|
||
// Oxide group BetterChat's members are read from exists first. With BetterChat
|
||
// absent nothing is compiled, and the report says why (§33.2).
|
||
if (job.ChatLoaded)
|
||
{
|
||
List<ChatFieldWant> style = ChatStyle(job, name, group);
|
||
|
||
if (style.Count > 0)
|
||
{
|
||
job.ChatStyles[name] = style;
|
||
job.Ops.Add(new PermOp { Kind = PermOpKind.EnsureChatStyle, Subject = name });
|
||
}
|
||
}
|
||
}
|
||
|
||
foreach (Dictionary<string, object> grant in Rows(cmd, "grants"))
|
||
{
|
||
string steamId = StringField(grant, "steamId");
|
||
if (string.IsNullOrEmpty(steamId))
|
||
continue;
|
||
|
||
foreach (string perm in Strings(grant, "permissions"))
|
||
{
|
||
string lowered = Lower(perm);
|
||
if (string.IsNullOrEmpty(lowered))
|
||
continue;
|
||
|
||
job.Ops.Add(new PermOp
|
||
{
|
||
Kind = PermOpKind.EnsureGrant,
|
||
Subject = steamId,
|
||
Object = lowered
|
||
});
|
||
}
|
||
}
|
||
|
||
// Parents after every group exists and before any retirement, so a group the site is
|
||
// about to remove is no longer anybody's parent when it goes.
|
||
job.Ops.AddRange(parentOps);
|
||
|
||
// ── What the site has withdrawn ────────────────────────────────────
|
||
//
|
||
// The site remembers what it put here, so a grant it has since removed arrives as an
|
||
// explicit retirement. It cannot be inferred from the desired set: a name that is not
|
||
// in it is either something the site withdrew or something a human granted, and those
|
||
// two have opposite correct answers (D31).
|
||
|
||
foreach (Dictionary<string, object> row in Rows(cmd, "retire"))
|
||
{
|
||
string kind = StringField(row, "kind");
|
||
string subject = StringField(row, "subject");
|
||
string target = Lower(StringField(row, "object"));
|
||
|
||
if (string.IsNullOrEmpty(kind) || string.IsNullOrEmpty(subject))
|
||
continue;
|
||
|
||
switch (kind)
|
||
{
|
||
case "grant":
|
||
job.Ops.Add(new PermOp { Kind = PermOpKind.RetireGrant, Subject = subject, Object = target });
|
||
break;
|
||
case "member":
|
||
job.Ops.Add(new PermOp { Kind = PermOpKind.RetireMember, Subject = subject, Object = target });
|
||
break;
|
||
case "group-permission":
|
||
job.Ops.Add(new PermOp { Kind = PermOpKind.RetireGroupPermission, Subject = Lower(subject), Object = target });
|
||
break;
|
||
case "group":
|
||
job.Ops.Add(new PermOp { Kind = PermOpKind.RetireGroup, Subject = Lower(subject) });
|
||
break;
|
||
case "chat-group":
|
||
// Only with BetterChat here to ask. Otherwise the report says `loaded:
|
||
// false`, and the site keeps the retirement for a sync that can act on it.
|
||
if (job.ChatLoaded)
|
||
job.Ops.Add(new PermOp { Kind = PermOpKind.RetireChatGroup, Subject = Lower(subject) });
|
||
break;
|
||
}
|
||
}
|
||
|
||
// Protocol 13 dropped the `managed` list and the foreign scan it drove: the site reads
|
||
// the whole store with `perm.inventory` and decides for itself (D160, D161).
|
||
|
||
// ── The kit credits (protocol 10) ──────────────────────────────────
|
||
//
|
||
// Not store operations, so not drained with them: they are settled once, after the
|
||
// grants have landed, and counted in the same report.
|
||
if (cmd.ContainsKey("credits"))
|
||
{
|
||
job.Credits = new List<KitCredit>();
|
||
|
||
foreach (Dictionary<string, object> row in Rows(cmd, "credits"))
|
||
{
|
||
string steamId = StringField(row, "steamId");
|
||
string kit = StringField(row, "kit");
|
||
int count = IntField(row, "count");
|
||
|
||
if (string.IsNullOrEmpty(steamId) || string.IsNullOrEmpty(kit) || count <= 0)
|
||
continue;
|
||
|
||
job.Credits.Add(new KitCredit { SteamId = steamId, Kit = kit, Earned = count });
|
||
}
|
||
}
|
||
|
||
if (job.Ops.Count > PermMaxOps)
|
||
{
|
||
EmitPermError(reqId, "too-large");
|
||
Puts($"permission sync refused: {job.Ops.Count} operations exceeds {PermMaxOps}");
|
||
return;
|
||
}
|
||
|
||
_permJob = job;
|
||
_permTimer = timer.Every(PermStepSeconds, StepPermSync);
|
||
|
||
// Run the first slice now rather than waiting out a tick: a small sync — which is every
|
||
// ordinary one — then finishes inside the call the website is waiting on.
|
||
StepPermSync();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Main thread. Drains up to <see cref="PermOpsPerStep"/> operations and, on the last
|
||
/// one, sends the report.
|
||
/// </summary>
|
||
private void StepPermSync()
|
||
{
|
||
PermSyncJob job = _permJob;
|
||
|
||
if (job == null)
|
||
{
|
||
StopPermTimer();
|
||
return;
|
||
}
|
||
|
||
_applyingPermissions = true;
|
||
|
||
try
|
||
{
|
||
int budget = PermOpsPerStep;
|
||
|
||
while (budget-- > 0 && job.Next < job.Ops.Count)
|
||
{
|
||
PermOp op = job.Ops[job.Next++];
|
||
|
||
try
|
||
{
|
||
Apply(job, op);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
// One bad operation must not abandon the set. The report still goes back,
|
||
// and the next sync retries what this one could not do.
|
||
Puts($"permission op {op.Kind} on '{op.Subject}' threw: {ex.Message}");
|
||
}
|
||
}
|
||
}
|
||
finally
|
||
{
|
||
_applyingPermissions = false;
|
||
}
|
||
|
||
if (job.Next < job.Ops.Count)
|
||
return;
|
||
|
||
_permJob = null;
|
||
StopPermTimer();
|
||
|
||
if (job.Credits != null)
|
||
{
|
||
try
|
||
{
|
||
ReconcileCredits(job);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
// The grants have landed and must still be reported; a credit that could not be
|
||
// settled is settled by the next sync, or by the player's next redemption.
|
||
Puts($"could not settle kit credits: {Flatten(ex.Message)}");
|
||
}
|
||
}
|
||
|
||
SendPermReport(job);
|
||
}
|
||
|
||
private void StopPermTimer()
|
||
{
|
||
if (_permTimer == null)
|
||
return;
|
||
|
||
_permTimer.Destroy();
|
||
_permTimer = null;
|
||
}
|
||
|
||
/// <summary>Main thread. One store question, and at most one store write.</summary>
|
||
private void Apply(PermSyncJob job, PermOp op)
|
||
{
|
||
switch (op.Kind)
|
||
{
|
||
case PermOpKind.EnsureGroup:
|
||
if (!permission.GroupExists(op.Subject))
|
||
{
|
||
permission.CreateGroup(op.Subject, op.Title ?? op.Subject, op.Rank);
|
||
job.GroupsCreated++;
|
||
break;
|
||
}
|
||
|
||
// Protocol 13: the site owns an existing group's title and rank too (D160), so
|
||
// a difference is written rather than left. Until now a group's title was only
|
||
// ever set when the sync created it.
|
||
bool updated = false;
|
||
|
||
if (op.Title != null && !string.Equals(permission.GetGroupTitle(op.Subject) ?? "", op.Title, StringComparison.Ordinal))
|
||
{
|
||
permission.SetGroupTitle(op.Subject, op.Title);
|
||
updated = true;
|
||
}
|
||
|
||
if (op.HasRank && permission.GetGroupRank(op.Subject) != op.Rank)
|
||
{
|
||
permission.SetGroupRank(op.Subject, op.Rank);
|
||
updated = true;
|
||
}
|
||
|
||
if (updated)
|
||
job.GroupsUpdated++;
|
||
else
|
||
job.AlreadyCorrect++;
|
||
|
||
break;
|
||
|
||
case PermOpKind.EnsureGroupParent:
|
||
string currentParent = Lower(permission.GetGroupParent(op.Subject)) ?? "";
|
||
|
||
if (string.Equals(currentParent, op.Object, StringComparison.Ordinal))
|
||
{
|
||
job.AlreadyCorrect++;
|
||
break;
|
||
}
|
||
|
||
// Both frameworks refuse a parent that does not exist or that would make a
|
||
// loop, and say so only by returning false. The read-back is what counts.
|
||
permission.SetGroupParent(op.Subject, string.IsNullOrEmpty(op.Object) ? null : op.Object);
|
||
|
||
if (string.Equals(Lower(permission.GetGroupParent(op.Subject)) ?? "", op.Object, StringComparison.Ordinal))
|
||
job.GroupsUpdated++;
|
||
else
|
||
job.NotLanded.Add(op.Subject + ":parent:" + op.Object);
|
||
|
||
break;
|
||
|
||
case PermOpKind.EnsureGroupPermission:
|
||
if (!Registered(job, op.Object))
|
||
break;
|
||
|
||
if (DeferToLease(job, op, true))
|
||
break;
|
||
|
||
if (permission.GroupHasPermission(op.Subject, op.Object))
|
||
{
|
||
job.AlreadyCorrect++;
|
||
break;
|
||
}
|
||
|
||
// **`null`, not `this`, as the owner (D85).** With an owner, Oxide's grant
|
||
// first asks `PermissionExists(perm, owner)` — whether THAT plugin registered
|
||
// the name — and returns without a word when it did not. Every permission the
|
||
// site grants belongs to some other plugin, so from phase 7 until phase 13 this
|
||
// call did nothing on Oxide and was counted as applied (PLAN.md §27.6). The
|
||
// read-back is what makes the count true: only a grant the store now holds
|
||
// is reported as one.
|
||
permission.GrantGroupPermission(op.Subject, op.Object, null);
|
||
|
||
if (permission.GroupHasPermission(op.Subject, op.Object))
|
||
job.GroupPermissions++;
|
||
else
|
||
job.NotLanded.Add(op.Subject + ":" + op.Object);
|
||
|
||
break;
|
||
|
||
case PermOpKind.EnsureMember:
|
||
if (permission.UserHasGroup(op.Subject, op.Object))
|
||
{
|
||
job.AlreadyCorrect++;
|
||
break;
|
||
}
|
||
|
||
permission.AddUserGroup(op.Subject, op.Object);
|
||
|
||
// Read back, because this is the call that can do nothing at all. A player the
|
||
// store has never seen has no user record to put in a group (§12.2 rule 4), and
|
||
// the API says so by returning void and changing nothing.
|
||
if (permission.UserHasGroup(op.Subject, op.Object))
|
||
job.Members++;
|
||
else
|
||
job.Pending.Add(op.Subject + ":" + op.Object);
|
||
|
||
break;
|
||
|
||
case PermOpKind.EnsureGrant:
|
||
if (!Registered(job, op.Object))
|
||
break;
|
||
|
||
if (HoldsDirectly(op.Subject, op.Object))
|
||
{
|
||
job.AlreadyCorrect++;
|
||
break;
|
||
}
|
||
|
||
// The same owner trap as the group grant above (D85), and the same read-back.
|
||
permission.GrantUserPermission(op.Subject, op.Object, null);
|
||
|
||
if (HoldsDirectly(op.Subject, op.Object))
|
||
job.Grants++;
|
||
else
|
||
job.NotLanded.Add(op.Subject + ":" + op.Object);
|
||
|
||
break;
|
||
|
||
case PermOpKind.RetireGrant:
|
||
if (HoldsDirectly(op.Subject, op.Object))
|
||
{
|
||
permission.RevokeUserPermission(op.Subject, op.Object);
|
||
job.Revokes++;
|
||
}
|
||
else
|
||
{
|
||
job.Absent++;
|
||
}
|
||
|
||
break;
|
||
|
||
case PermOpKind.RetireMember:
|
||
if (permission.UserHasGroup(op.Subject, op.Object))
|
||
{
|
||
permission.RemoveUserGroup(op.Subject, op.Object);
|
||
job.MembersRemoved++;
|
||
}
|
||
else
|
||
{
|
||
job.Absent++;
|
||
}
|
||
|
||
break;
|
||
|
||
case PermOpKind.RetireGroupPermission:
|
||
if (DeferToLease(job, op, false))
|
||
break;
|
||
|
||
if (permission.GroupHasPermission(op.Subject, op.Object))
|
||
{
|
||
permission.RevokeGroupPermission(op.Subject, op.Object);
|
||
job.GroupPermissionsRemoved++;
|
||
}
|
||
else
|
||
{
|
||
job.Absent++;
|
||
}
|
||
|
||
break;
|
||
|
||
case PermOpKind.RetireGroup:
|
||
if (permission.GroupExists(op.Subject))
|
||
{
|
||
permission.RemoveGroup(op.Subject);
|
||
job.GroupsRemoved++;
|
||
}
|
||
else
|
||
{
|
||
job.Absent++;
|
||
}
|
||
|
||
break;
|
||
|
||
case PermOpKind.EnsureChatStyle:
|
||
ApplyChatStyle(job, op.Subject);
|
||
break;
|
||
|
||
case PermOpKind.RetireChatGroup:
|
||
RemoveChatGroup(job, op.Subject);
|
||
break;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// A sync op about a pair an event lease holds. The store is left to the lease; what
|
||
/// the site wants is recorded on the hold and becomes the value the lease gives back
|
||
/// (§27.3). Returns whether it deferred.
|
||
/// </summary>
|
||
private bool DeferToLease(PermSyncJob job, PermOp op, bool wants)
|
||
{
|
||
LeaseHold hold = LeasedGroupPermission(op.Subject, op.Object);
|
||
|
||
if (hold == null)
|
||
return false;
|
||
|
||
if (hold.SiteWants != wants)
|
||
{
|
||
hold.SiteWants = wants;
|
||
SaveLeases();
|
||
}
|
||
|
||
NoteLeased(job, op.Subject, op.Object);
|
||
return true;
|
||
}
|
||
|
||
private static void NoteLeased(PermSyncJob job, string group, string perm)
|
||
{
|
||
string id = Lower(group) + "/" + Lower(perm);
|
||
|
||
if (!job.LeasedSeen.Add(id))
|
||
return;
|
||
|
||
job.Leased.Add(new Dictionary<string, object>
|
||
{
|
||
{ "kind", "group-permission" },
|
||
{ "subject", Lower(group) },
|
||
{ "object", Lower(perm) }
|
||
});
|
||
}
|
||
|
||
/// <summary>
|
||
/// Whether this server has a plugin that registered the name, recording the answer once
|
||
/// per sync when it has not.
|
||
///
|
||
/// <para>
|
||
/// This is the pre-check PLAN.md §12.2 rule 1 exists for. Without it the grant below
|
||
/// would return void, throw nothing, log nothing and do nothing, and the website would
|
||
/// record a privilege it never gave.
|
||
/// </para>
|
||
/// </summary>
|
||
private bool Registered(PermSyncJob job, string perm)
|
||
{
|
||
if (string.IsNullOrEmpty(perm))
|
||
return false;
|
||
|
||
if (permission.PermissionExists(perm))
|
||
return true;
|
||
|
||
if (job.UnresolvedSeen.Add(perm))
|
||
job.Unresolved.Add(perm);
|
||
|
||
return false;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Whether a player holds a permission through a grant of their own, as opposed to
|
||
/// through a group.
|
||
///
|
||
/// <para>
|
||
/// <c>UserHasPermission</c> cannot answer this — it is true for a member of a group
|
||
/// that carries the permission, so using it would make every direct grant look already
|
||
/// applied the moment a group covered it, and a revoke look already done.
|
||
/// <c>GetPermissionUsers</c> reads the user's own set, which is the question.
|
||
/// </para>
|
||
/// </summary>
|
||
private bool HoldsDirectly(string steamId, string perm)
|
||
{
|
||
string[] holders = permission.GetPermissionUsers(perm);
|
||
|
||
if (holders == null)
|
||
return false;
|
||
|
||
foreach (string entry in holders)
|
||
{
|
||
if (UserId(entry) == steamId)
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/// <summary>
|
||
/// The id out of a store listing entry.
|
||
///
|
||
/// <para>
|
||
/// Both <c>GetPermissionUsers</c> and <c>GetUsersInGroup</c> answer with the id and the
|
||
/// player's last known name stuck together — <c>76561198…(Wanderer)</c> — and the
|
||
/// spacing differs between the two calls and between the frameworks. So the id is
|
||
/// everything before the first bracket, and a name containing one cannot confuse it
|
||
/// because only the first is looked for.
|
||
/// </para>
|
||
/// </summary>
|
||
private static string UserId(string entry)
|
||
{
|
||
if (string.IsNullOrEmpty(entry))
|
||
return null;
|
||
|
||
int bracket = entry.IndexOf('(');
|
||
string id = bracket >= 0 ? entry.Substring(0, bracket) : entry;
|
||
|
||
return id.Trim();
|
||
}
|
||
|
||
private void SendPermReport(PermSyncJob job)
|
||
{
|
||
var applied = new Dictionary<string, object>
|
||
{
|
||
{ "grants", job.Grants },
|
||
{ "revokes", job.Revokes },
|
||
{ "groupsCreated", job.GroupsCreated },
|
||
{ "groupsUpdated", job.GroupsUpdated },
|
||
{ "groupsRemoved", job.GroupsRemoved },
|
||
{ "groupPermissions", job.GroupPermissions },
|
||
{ "groupPermissionsRemoved", job.GroupPermissionsRemoved },
|
||
{ "members", job.Members },
|
||
{ "membersRemoved", job.MembersRemoved }
|
||
};
|
||
|
||
Dictionary<string, object> report = Frame("perm.report", "reply");
|
||
report["applied"] = applied;
|
||
report["alreadyCorrect"] = job.AlreadyCorrect;
|
||
report["absent"] = job.Absent;
|
||
report["unresolved"] = job.Unresolved;
|
||
report["leased"] = job.Leased;
|
||
report["pending"] = job.Pending;
|
||
report["notLanded"] = job.NotLanded;
|
||
report["operations"] = job.Ops.Count;
|
||
|
||
if (job.Credits != null)
|
||
{
|
||
// Counted the way `notLanded` is counted for grants: what actually changed in Kits,
|
||
// read back, never what was asked for.
|
||
report["creditsApplied"] = job.CreditsApplied;
|
||
report["creditsWithdrawn"] = job.CreditsWithdrawn;
|
||
report["creditsUnresolved"] = job.CreditsUnresolved;
|
||
report["creditsWaiting"] = job.CreditsWaiting;
|
||
}
|
||
|
||
// Protocol 12. Always present, so a site can tell "BetterChat is absent" from "this
|
||
// plugin predates styles" — the second has no `chat` at all.
|
||
var chat = new Dictionary<string, object> { { "loaded", job.ChatLoaded } };
|
||
|
||
if (job.ChatLoaded)
|
||
{
|
||
chat["version"] = job.ChatVersion;
|
||
chat["applied"] = job.ChatApplied;
|
||
chat["saved"] = job.ChatSaved;
|
||
chat["same"] = job.ChatSame;
|
||
chat["drift"] = job.ChatDrift;
|
||
chat["removed"] = job.ChatRemoved;
|
||
chat["failed"] = job.ChatFailed;
|
||
}
|
||
|
||
report["chat"] = chat;
|
||
|
||
if (!string.IsNullOrEmpty(job.SetId))
|
||
report["setId"] = job.SetId;
|
||
|
||
if (job.ReqId != null)
|
||
report["reqId"] = job.ReqId;
|
||
|
||
_lastPermSummary =
|
||
$"ops={job.Ops.Count} grants={job.Grants} revokes={job.Revokes} " +
|
||
$"groups={job.GroupsCreated}+{job.GroupsUpdated} members={job.Members} same={job.AlreadyCorrect} " +
|
||
$"unresolved={job.Unresolved.Count} " +
|
||
$"pending={job.Pending.Count} notLanded={job.NotLanded.Count} " +
|
||
$"chat={(job.ChatLoaded ? $"applied {job.ChatApplied} saved {job.ChatSaved} drift {job.ChatDrift.Count} removed {job.ChatRemoved.Count} failed {job.ChatFailed.Count}" : "absent")} " +
|
||
$"at={DateTime.UtcNow:HH:mm:ss}Z";
|
||
|
||
EmitFrame(report);
|
||
}
|
||
|
||
/// <summary>A refusal of the whole sync, with a reason the website turns into a sentence.</summary>
|
||
private void EmitPermError(string reqId, string reason)
|
||
{
|
||
Dictionary<string, object> frame = Frame("perm.error", "reply");
|
||
frame["reason"] = reason;
|
||
|
||
if (reqId != null)
|
||
frame["reqId"] = reqId;
|
||
|
||
EmitFrame(frame);
|
||
}
|
||
|
||
// ---- drift, as it happens ----
|
||
//
|
||
// Both frameworks raise a hook for every permission write, ours included. They are NOT the
|
||
// drift record — the inventory is (protocol 13), because only the site's own record can say
|
||
// whether a grant is a hand edit. What they are is a reason to reconcile SOON: an operator typing
|
||
// `oxide.grant` at the console shows up on the website in seconds instead of at the next
|
||
// scheduled audit, and the authoritative answer still comes from a sync.
|
||
//
|
||
// That division is what makes them safe to trust at exactly the weight they deserve. A hook
|
||
// that stops firing on a framework upgrade costs latency, not correctness.
|
||
|
||
private void OnUserPermissionGranted(string id, string permName)
|
||
{
|
||
MarkHook("OnUserPermissionGranted");
|
||
EmitPermDrift("granted", id, permName, null);
|
||
}
|
||
|
||
private void OnUserPermissionRevoked(string id, string permName)
|
||
{
|
||
MarkHook("OnUserPermissionRevoked");
|
||
EmitPermDrift("revoked", id, permName, null);
|
||
}
|
||
|
||
private void OnUserGroupAdded(string id, string groupName)
|
||
{
|
||
MarkHook("OnUserGroupAdded");
|
||
EmitPermDrift("group-added", id, null, groupName);
|
||
}
|
||
|
||
private void OnUserGroupRemoved(string id, string groupName)
|
||
{
|
||
MarkHook("OnUserGroupRemoved");
|
||
EmitPermDrift("group-removed", id, null, groupName);
|
||
}
|
||
|
||
private void OnGroupPermissionGranted(string name, string permName)
|
||
{
|
||
MarkHook("OnGroupPermissionGranted");
|
||
EmitPermDrift("group-permission-granted", null, permName, name);
|
||
}
|
||
|
||
private void OnGroupPermissionRevoked(string name, string permName)
|
||
{
|
||
MarkHook("OnGroupPermissionRevoked");
|
||
EmitPermDrift("group-permission-revoked", null, permName, name);
|
||
}
|
||
|
||
// ---- the reload, watched ----
|
||
//
|
||
// These two are why an automatic rollback is possible at all (R18): a reload either
|
||
// produces `OnPluginLoaded` for the target or it does not, and the second case is a plugin
|
||
// that is DOWN because of an edit the website just made.
|
||
//
|
||
// Protocol 13 made them a feed as well, and a narrow one (F8, D184). A grant the site made
|
||
// for a plugin that was not loaded is `unresolved` until the plugin comes back — and the
|
||
// first walk watched it land thirteen minutes after the plugin did, on the fifteen-minute
|
||
// audit, under a page promising "it will land by itself". So a load or an unload is now
|
||
// announced with the permissions that plugin added or removed, and the site re-syncs when
|
||
// that list is not empty. Only once the world is ready: a boot loads every plugin, and a
|
||
// boot is the restart sync's job.
|
||
|
||
/// <summary>The registered permission list as the last load or unload left it. Main thread.</summary>
|
||
private HashSet<string> _registeredPerms;
|
||
|
||
/// <summary>
|
||
/// Main thread. The list as it is now; the first call is the baseline. From
|
||
/// <c>OnServerInitialized</c>, which is also the moment <c>_worldReady</c> turns true.
|
||
/// </summary>
|
||
private void SeedRegisteredPerms()
|
||
{
|
||
string[] now = permission.GetPermissions();
|
||
_registeredPerms = new HashSet<string>(now ?? new string[0], StringComparer.OrdinalIgnoreCase);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Main thread, one tick after the hook. The diff is taken then rather than inside the
|
||
/// hook because the order of "the framework drops an unloaded plugin's permissions" and
|
||
/// "the framework calls <c>OnPluginUnloaded</c>" is the framework's, and a tick later
|
||
/// both have happened on either. Two plugins loaded in the same tick share one diff and
|
||
/// the first name gets it — the site's answer (re-sync) is the same either way, and
|
||
/// §4.1's owner is taken from the inventory, not from this.
|
||
/// </summary>
|
||
private void EmitPluginChange(string name, string version, bool loaded)
|
||
{
|
||
if (_registeredPerms == null)
|
||
return;
|
||
|
||
string[] now = permission.GetPermissions() ?? new string[0];
|
||
HashSet<string> current = new HashSet<string>(now, StringComparer.OrdinalIgnoreCase);
|
||
|
||
List<string> changed = new List<string>();
|
||
|
||
if (loaded)
|
||
{
|
||
foreach (string perm in current)
|
||
{
|
||
if (!_registeredPerms.Contains(perm))
|
||
changed.Add(perm);
|
||
}
|
||
}
|
||
else
|
||
{
|
||
foreach (string perm in _registeredPerms)
|
||
{
|
||
if (!current.Contains(perm))
|
||
changed.Add(perm);
|
||
}
|
||
}
|
||
|
||
_registeredPerms = current;
|
||
changed.Sort(StringComparer.Ordinal);
|
||
|
||
Dictionary<string, object> frame = Frame(loaded ? "plugin.loaded" : "plugin.unloaded", "event");
|
||
frame["name"] = name;
|
||
|
||
if (!string.IsNullOrEmpty(version))
|
||
frame["version"] = version;
|
||
|
||
frame["permissions"] = changed;
|
||
EmitFrame(frame);
|
||
}
|
||
|
||
private bool IsZonePlugin(Plugin plugin)
|
||
{
|
||
if (plugin == null || plugin == this)
|
||
return false;
|
||
|
||
string name = plugin.Name;
|
||
return name == "ZoneManager" || name == "ZoneDomes" || name == "RunicGatewayZones" || name == "RunicGatewayDomes";
|
||
}
|
||
|
||
/// <summary>Main thread. Hello again, once the world is up and the link is.</summary>
|
||
private void ResendHello()
|
||
{
|
||
if (_worldReady && _connected)
|
||
SendHello();
|
||
}
|
||
|
||
/// <summary>Main thread. Queues <see cref="EmitPluginChange"/> for somebody else's plugin, once the world is up.</summary>
|
||
private void NotePluginChange(Plugin plugin, bool loaded)
|
||
{
|
||
if (plugin == null || plugin == this || !_worldReady)
|
||
return;
|
||
|
||
string name = plugin.Name;
|
||
string version = plugin.Version.ToString();
|
||
NextTick(() => EmitPluginChange(name, version, loaded));
|
||
}
|
||
|
||
private void OnPluginLoaded(Plugin plugin)
|
||
{
|
||
MarkHook("OnPluginLoaded");
|
||
NotePluginChange(plugin, true);
|
||
|
||
// ZoneManager keeps temporary zones in memory, so a reload of it loses every zone an
|
||
// event opened. They are put back from this plugin's own record (D92).
|
||
if (plugin != null && plugin != this && plugin.Name == "ZoneManager")
|
||
NextTick(() => RecreateZones("ZoneManager reloaded"));
|
||
|
||
// Hello carries ZoneManager's flag list and both helpers' states, so a change to any
|
||
// of the four is said again rather than waiting for the next connect (§3.1, §3.2).
|
||
if (IsZonePlugin(plugin))
|
||
NextTick(ResendHello);
|
||
|
||
// BetterChat keeps third-party titles on its INSTANCE, so a reloaded BetterChat has
|
||
// forgotten our getter. Registering again is the only way back (§33.1).
|
||
if (plugin != null && plugin != this && plugin.Name == "BetterChat")
|
||
NextTick(() => RegisterTitles("BetterChat loaded"));
|
||
|
||
ConfigWriteJob job = _configJob;
|
||
|
||
if (job == null || plugin == null)
|
||
return;
|
||
|
||
if (string.Equals(plugin.Name, job.Reload, StringComparison.OrdinalIgnoreCase))
|
||
ConfigReloadArrived();
|
||
}
|
||
|
||
private void OnPluginUnloaded(Plugin plugin)
|
||
{
|
||
MarkHook("OnPluginUnloaded");
|
||
NotePluginChange(plugin, false);
|
||
|
||
if (plugin != null && plugin.Name == "BetterChat")
|
||
_titlesRegistered = false;
|
||
|
||
if (IsZonePlugin(plugin))
|
||
NextTick(ResendHello);
|
||
|
||
ConfigWriteJob job = _configJob;
|
||
|
||
if (job == null || plugin == null)
|
||
return;
|
||
|
||
// Only ever used to word the failure. "It unloaded and did not come back" is a
|
||
// compile error in the edited file; "it never reloaded" is a framework that did not
|
||
// act on the request at all, and those send an operator to different places.
|
||
if (string.Equals(plugin.Name, job.Reload, StringComparison.OrdinalIgnoreCase))
|
||
job.Unloaded = true;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Main thread. Says that the store changed under us, and by how much it is worth
|
||
/// caring.
|
||
///
|
||
/// <para>
|
||
/// Staff class (§8.5): it names a Steam id beside a privilege, which is a fact about a
|
||
/// person's standing rather than about what happened on the server.
|
||
/// </para>
|
||
/// </summary>
|
||
private void EmitPermDrift(string action, string steamId, string perm, string group)
|
||
{
|
||
// Our own writes raise these hooks too. Suppressing them here rather than filtering on
|
||
// the website is the only version that works: the site cannot tell its own grant from
|
||
// a human's by looking at one, which is the entire reason drift needs a desired set.
|
||
if (_applyingPermissions)
|
||
return;
|
||
|
||
Dictionary<string, object> frame = Frame("perm.drift", "event");
|
||
frame["action"] = action;
|
||
|
||
if (!string.IsNullOrEmpty(steamId))
|
||
frame["steamId"] = steamId;
|
||
|
||
if (!string.IsNullOrEmpty(perm))
|
||
frame["permission"] = perm;
|
||
|
||
if (!string.IsNullOrEmpty(group))
|
||
frame["group"] = group;
|
||
|
||
EmitFrame(frame);
|
||
}
|
||
|
||
// ---- protocol 5: plugin configuration ----
|
||
//
|
||
// R18. An admin edits a loaded plugin's settings on the website and the plugin reloads.
|
||
// Three request/reply verbs: `config.list` describes the tree, `config.read` returns one
|
||
// file, `config.write` replaces a set and reloads whatever owns it.
|
||
//
|
||
// Four properties live on this side because only the game host can hold them:
|
||
//
|
||
// * **The roots come from the framework, never from a literal.** `ConfigDirectory` is
|
||
// `oxide/config` on Oxide, `carbon/configs` on Carbon, and neither on a server whose
|
||
// operator moved it (`CARBON.md` §3). That amendment was proven the best way it could
|
||
// have been: this plugin's own config landed in both places, from this same file.
|
||
// * **`DataDirectory` is never walked.** It holds live state — kit cooldowns, zone
|
||
// definitions — and both frameworks' permission stores, which is R2's mirror one
|
||
// directory over. A settings editor that strayed there would be editing phase 7
|
||
// underneath itself.
|
||
// * **A reload is observable.** `OnPluginLoaded` is a real hook, so "did that work" has
|
||
// an answer rather than an assumption — and an answer is the whole reason an automatic
|
||
// rollback is possible at all.
|
||
// * **The site composes the bytes; this plugin writes them.** It receives whole file
|
||
// text, not a key and a value, because JavaScript cannot tell `1` from `1.0` and Oxide
|
||
// deserializes into typed C# classes (D35/D36). Everything this end does to a document
|
||
// is check that it parses.
|
||
|
||
/// <summary>How deep the walk goes before it stops trying to describe a tree.</summary>
|
||
private const int ConfigMaxDepth = 6;
|
||
|
||
/// <summary>How many files the walk will report.</summary>
|
||
private const int ConfigMaxFiles = 500;
|
||
|
||
/// <summary>
|
||
/// The largest file this plugin will read or write.
|
||
///
|
||
/// <para>
|
||
/// A file past it is reported and marked un-editable <b>with the reason</b>, never
|
||
/// omitted: an operator who cannot find a file they know exists goes looking for a bug
|
||
/// in the bridge, and one who can see why it was refused does not.
|
||
/// </para>
|
||
/// </summary>
|
||
private const int ConfigMaxFileBytes = 256 * 1024;
|
||
|
||
/// <summary>Files one write may touch. A plugin can own several; ten of them is not a tree.</summary>
|
||
private const int ConfigMaxWriteFiles = 10;
|
||
|
||
/// <summary>
|
||
/// The longest a reload may take before this plugin stops waiting for it (D178).
|
||
///
|
||
/// <para>
|
||
/// <b>A ceiling, not the test.</b> Protocol 12 rolled back any reload that had not
|
||
/// announced itself within four seconds, because the caller held a socket for the whole
|
||
/// wait. That window lost to work whose length nothing here controls: the org lead's
|
||
/// first walk saw Oxide's compiler idle out, restart, and load Kits 2.85 s later — just
|
||
/// past the window — and the plugin put a valid edit back (F9). Protocol 13 answers the
|
||
/// save at once and sends the outcome as a frame, so the wait no longer has to fit
|
||
/// inside an RPC, and a reload fails on <b>evidence</b>: the framework's log saying the
|
||
/// plugin did not compile or initialise (<see cref="ConfigWatchTick"/>). This number
|
||
/// only bounds a reload that says nothing at all.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// Each phase gets it once — the edit's reload, and the restore's if there is one — and
|
||
/// the pending reply carries it as <c>ceilingMs</c>, so the website knows when a write it
|
||
/// never heard back about is lost rather than slow.
|
||
/// </para>
|
||
/// </summary>
|
||
private const float ConfigReloadCeilingSeconds = 30f;
|
||
|
||
/// <summary>How often a pending reload reads the log for its failure.</summary>
|
||
private const float ConfigLogPollSeconds = 1f;
|
||
|
||
/// <summary>The most one poll reads from the log. A server spamming it cannot make a tick expensive.</summary>
|
||
private const int ConfigLogPollMaxBytes = 64 * 1024;
|
||
|
||
/// <summary>How much of the newest log file is read back when a reload fails.</summary>
|
||
private const int ConfigLogTailBytes = 8 * 1024;
|
||
|
||
/// <summary>
|
||
/// What a failed load says in the log, beside the plugin's name.
|
||
///
|
||
/// <para>
|
||
/// Lower case, matched case-insensitively. Oxide's are its compiler's and its loader's
|
||
/// own words ("Error while compiling", "Failed to initialize plugin", and "Could not
|
||
/// initialize plugin 'Kits v4.4.9' (JsonReaderException: …)" when a plugin's own
|
||
/// <c>LoadConfig</c> throws, seen on the rig in configuration step 4). The last one
|
||
/// carries the exception, so it is the line an operator needs; Oxide follows it with
|
||
/// "Previous version of plugin failed to load", which says nothing about why. Carbon's
|
||
/// differ, and the rest of the list is there so that the same file works on both (R19);
|
||
/// the Carbon pass of the re-walk is what pins them. The first pinned is Carbon
|
||
/// 2.0.259's "[Kits] Failed ILoadConfig (Could not convert string to double: abc. …)" —
|
||
/// after which Carbon loads the plugin anyway, broken, and raises <c>Loaded</c>.
|
||
/// </para>
|
||
/// </summary>
|
||
private static readonly string[] ConfigFailureWords =
|
||
{
|
||
"error while compiling",
|
||
"could not initialize",
|
||
"failed to initialize",
|
||
"failed initializing",
|
||
"failed to compile",
|
||
"failed compiling",
|
||
"failed to load",
|
||
"failed loading",
|
||
"failed iloadconfig",
|
||
"unable to load",
|
||
};
|
||
|
||
/// <summary>
|
||
/// What the framework says when it gave up on a compile, beside the plugin's name.
|
||
///
|
||
/// <para>
|
||
/// Not a failure of the file: Oxide shuts its compiler down after a minute idle, and a
|
||
/// reload asked for in that moment is dropped with "Timed out waiting for plugin to be
|
||
/// compiled: ZoneManager" while the plugin carries on, loaded, on its old file (seen on
|
||
/// the rig, configuration step 2b). The compiler starts again on demand, so the reload
|
||
/// is asked for once more; only a second give-up settles the job.
|
||
/// </para>
|
||
/// </summary>
|
||
private static readonly string[] ConfigStallWords =
|
||
{
|
||
"timed out waiting for plugin to be compiled",
|
||
};
|
||
|
||
/// <summary>Which reload a job is waiting on: the edit's, or the restore's after a rollback.</summary>
|
||
private enum ConfigPhase
|
||
{
|
||
Edit,
|
||
Restore
|
||
}
|
||
|
||
/// <summary>One file, resolved and checked, on its way to disk.</summary>
|
||
private sealed class StagedConfigWrite
|
||
{
|
||
public string RelPath;
|
||
public string FullPath;
|
||
public string Text;
|
||
|
||
/// <summary>What was there before, held for the rollback that may never be needed.</summary>
|
||
public string Original;
|
||
|
||
public bool Written;
|
||
}
|
||
|
||
/// <summary>
|
||
/// A write that has landed and is waiting to hear whether the plugin came back.
|
||
///
|
||
/// <para>
|
||
/// At most one exists at a time. A second write arriving mid-window is refused rather
|
||
/// than queued: two overlapping rollbacks over intersecting file sets can restore a
|
||
/// file to a state that was never on disk.
|
||
/// </para>
|
||
/// </summary>
|
||
private sealed class ConfigWriteJob
|
||
{
|
||
/// <summary>
|
||
/// Ours, not the sidecar's <c>reqId</c>. The reply and the later
|
||
/// <c>config.outcome</c> both carry it, and the website files the outcome by it —
|
||
/// the sidecar's request ids restart with the sidecar, so they cannot name a write
|
||
/// across one.
|
||
/// </summary>
|
||
public string WriteId;
|
||
|
||
public string Reload;
|
||
public List<StagedConfigWrite> Staged;
|
||
|
||
/// <summary>The one-second poll that reads the log and enforces the ceiling.</summary>
|
||
public Timer Watch;
|
||
|
||
public ConfigPhase Phase;
|
||
|
||
/// <summary>When the current phase's reload was asked for. Each phase gets the whole ceiling.</summary>
|
||
public DateTime PhaseStarted;
|
||
|
||
/// <summary>When the write landed, for the outcome's <c>elapsedMs</c>.</summary>
|
||
public DateTime Started;
|
||
|
||
/// <summary>Whether the target was seen leaving in this phase. It separates "never started" from "did not come back".</summary>
|
||
public bool Unloaded;
|
||
|
||
/// <summary>Whether this phase's reload was already asked for again after the framework gave up compiling.</summary>
|
||
public bool Retried;
|
||
|
||
/// <summary>When <c>OnPluginLoaded</c> named the target in this phase, or null. The next log read settles it.</summary>
|
||
public DateTime? LoadedAt;
|
||
|
||
/// <summary>The log file being read, and how far into it this job has read.</summary>
|
||
public string LogPath;
|
||
|
||
public long LogOffset;
|
||
|
||
/// <summary>A line cut off at the end of the last read, kept until the rest arrives.</summary>
|
||
public string LogPartial = "";
|
||
|
||
/// <summary>Why the edit was rolled back, carried into the outcome once the restore settles.</summary>
|
||
public string RollbackReason;
|
||
|
||
/// <summary>The log lines that showed the failure, or the tail read when the ceiling passed.</summary>
|
||
public string RollbackLog;
|
||
}
|
||
|
||
private ConfigWriteJob _configJob;
|
||
|
||
private string _lastConfigSummary = "none yet";
|
||
|
||
/// <summary>Whether this is a Carbon host. Resolved once, by looking, never by compiling twice (R19).</summary>
|
||
private static bool? _carbon;
|
||
|
||
private void HandleConfigList(string reqId)
|
||
{
|
||
string root = ConfigRoot();
|
||
|
||
if (string.IsNullOrEmpty(root) || !Directory.Exists(root))
|
||
{
|
||
EmitConfigError(reqId, "no-root", "this framework reports no configuration directory");
|
||
return;
|
||
}
|
||
|
||
var files = new List<object>();
|
||
bool truncated = false;
|
||
|
||
try
|
||
{
|
||
WalkConfig(root, root, 0, files, ref truncated);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
EmitConfigError(reqId, "unreadable", ex.Message);
|
||
return;
|
||
}
|
||
|
||
Dictionary<string, object> reply = Frame("config.catalogue", "reply");
|
||
reply["root"] = root;
|
||
reply["files"] = files;
|
||
reply["plugins"] = LoadedPlugins();
|
||
reply["truncated"] = truncated;
|
||
|
||
// Named rather than inferred, so the website's locked-key guard (D38) is written
|
||
// against what this plugin calls itself instead of against a filename somebody may
|
||
// rename. The file carrying the link is not a file the site may break from a form.
|
||
reply["self"] = Name;
|
||
|
||
reply["limits"] = new Dictionary<string, object>
|
||
{
|
||
{ "depth", ConfigMaxDepth },
|
||
{ "files", ConfigMaxFiles },
|
||
{ "fileBytes", ConfigMaxFileBytes },
|
||
{ "writeFiles", ConfigMaxWriteFiles }
|
||
};
|
||
|
||
if (reqId != null)
|
||
reply["reqId"] = reqId;
|
||
|
||
EmitFrame(reply);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Main thread, and deliberately cheap: it reads directory entries, never file
|
||
/// contents. A tree of 500 files costs 500 stat calls here; hashing all of them would
|
||
/// cost up to 128 MB of reads in one frame, which is exactly the unbounded work §10.5
|
||
/// forbids. A file's version is therefore produced by <c>config.read</c>, on the one
|
||
/// file somebody actually opened.
|
||
/// </summary>
|
||
private void WalkConfig(string root, string dir, int depth, List<object> into, ref bool truncated)
|
||
{
|
||
if (depth > ConfigMaxDepth)
|
||
{
|
||
truncated = true;
|
||
return;
|
||
}
|
||
|
||
string[] entries;
|
||
|
||
try
|
||
{
|
||
entries = Directory.GetFiles(dir, "*.json");
|
||
}
|
||
catch (Exception)
|
||
{
|
||
truncated = true;
|
||
return;
|
||
}
|
||
|
||
Array.Sort(entries, StringComparer.OrdinalIgnoreCase);
|
||
|
||
foreach (string full in entries)
|
||
{
|
||
if (into.Count >= ConfigMaxFiles)
|
||
{
|
||
truncated = true;
|
||
return;
|
||
}
|
||
|
||
FileInfo info;
|
||
|
||
try
|
||
{
|
||
info = new FileInfo(full);
|
||
}
|
||
catch (Exception)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
// A link is not followed even when it points somewhere legitimate. Resolving one
|
||
// is how a tree that looks bounded turns out not to be, and how a path that
|
||
// validates as "under the root" writes somewhere else.
|
||
if ((info.Attributes & FileAttributes.ReparsePoint) != 0)
|
||
continue;
|
||
|
||
string rel = RelativeConfigPath(root, full);
|
||
|
||
var row = new Dictionary<string, object>
|
||
{
|
||
{ "path", rel },
|
||
{ "bytes", info.Length },
|
||
{ "modified", ToUnixMs(info.LastWriteTimeUtc) },
|
||
{ "plugin", PluginGuess(rel) }
|
||
};
|
||
|
||
if (info.Length > ConfigMaxFileBytes)
|
||
{
|
||
row["editable"] = false;
|
||
row["reason"] = "larger than this bridge will carry";
|
||
}
|
||
else
|
||
{
|
||
row["editable"] = true;
|
||
}
|
||
|
||
into.Add(row);
|
||
}
|
||
|
||
string[] dirs;
|
||
|
||
try
|
||
{
|
||
dirs = Directory.GetDirectories(dir);
|
||
}
|
||
catch (Exception)
|
||
{
|
||
truncated = true;
|
||
return;
|
||
}
|
||
|
||
Array.Sort(dirs, StringComparer.OrdinalIgnoreCase);
|
||
|
||
foreach (string sub in dirs)
|
||
{
|
||
if (into.Count >= ConfigMaxFiles)
|
||
{
|
||
truncated = true;
|
||
return;
|
||
}
|
||
|
||
try
|
||
{
|
||
if ((new DirectoryInfo(sub).Attributes & FileAttributes.ReparsePoint) != 0)
|
||
continue;
|
||
}
|
||
catch (Exception)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
WalkConfig(root, sub, depth + 1, into, ref truncated);
|
||
}
|
||
}
|
||
|
||
private void HandleConfigRead(Dictionary<string, object> cmd, string reqId)
|
||
{
|
||
string rel = StringField(cmd, "path");
|
||
string full;
|
||
string refusal;
|
||
|
||
if (!ResolveConfigPath(rel, out full, out refusal))
|
||
{
|
||
EmitConfigError(reqId, "path", refusal);
|
||
return;
|
||
}
|
||
|
||
if (!File.Exists(full))
|
||
{
|
||
EmitConfigError(reqId, "missing", "no such configuration file");
|
||
return;
|
||
}
|
||
|
||
string text;
|
||
long bytes;
|
||
DateTime modified;
|
||
|
||
try
|
||
{
|
||
var info = new FileInfo(full);
|
||
bytes = info.Length;
|
||
modified = info.LastWriteTimeUtc;
|
||
|
||
if (bytes > ConfigMaxFileBytes)
|
||
{
|
||
EmitConfigError(reqId, "too-large", "larger than this bridge will carry");
|
||
return;
|
||
}
|
||
|
||
text = File.ReadAllText(full, Encoding.UTF8);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
EmitConfigError(reqId, "unreadable", ex.Message);
|
||
return;
|
||
}
|
||
|
||
Dictionary<string, object> reply = Frame("config.file", "reply");
|
||
reply["path"] = rel;
|
||
reply["text"] = text;
|
||
reply["version"] = VersionOf(text);
|
||
reply["bytes"] = bytes;
|
||
reply["modified"] = ToUnixMs(modified);
|
||
reply["plugin"] = PluginGuess(rel);
|
||
|
||
if (reqId != null)
|
||
reply["reqId"] = reqId;
|
||
|
||
EmitFrame(reply);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Main thread. Replaces a set of files and reloads what owns them.
|
||
///
|
||
/// <para>
|
||
/// Everything is resolved, version-checked and parsed <b>before the first byte is
|
||
/// written</b> — the same posture as <c>perm.sync</c>, and for the same reason: a
|
||
/// refusal that has touched nothing has nothing to unwind. What cannot be checked in
|
||
/// advance is whether the plugin will come back, which is what the window and the
|
||
/// rollback are for.
|
||
/// </para>
|
||
/// </summary>
|
||
private void HandleConfigWrite(Dictionary<string, object> cmd, string reqId)
|
||
{
|
||
if (_configJob != null)
|
||
{
|
||
EmitConfigError(reqId, "busy", "another configuration write is still waiting for its reload");
|
||
return;
|
||
}
|
||
|
||
var rows = new List<Dictionary<string, object>>(Rows(cmd, "files"));
|
||
|
||
if (rows.Count == 0)
|
||
{
|
||
EmitConfigError(reqId, "empty", "a write must name at least one file");
|
||
return;
|
||
}
|
||
|
||
if (rows.Count > ConfigMaxWriteFiles)
|
||
{
|
||
EmitConfigError(reqId, "too-many", $"a write may touch at most {ConfigMaxWriteFiles} files");
|
||
return;
|
||
}
|
||
|
||
string reload = StringField(cmd, "reload");
|
||
|
||
// The site may not reload the bridge. The reload would unload this plugin, close the
|
||
// link the answer travels on, and leave a rollback with nothing watching it — so the
|
||
// one failure this whole mechanism exists to prevent would be the one it could not
|
||
// report. Our own settings are still editable (D38); they take effect the next time
|
||
// somebody reloads this plugin deliberately.
|
||
if (!string.IsNullOrEmpty(reload) && reload.Equals(Name, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
EmitConfigError(reqId, "reload-self",
|
||
"this bridge will not reload itself: the reload would close the link carrying the answer");
|
||
return;
|
||
}
|
||
|
||
var staged = new List<StagedConfigWrite>();
|
||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||
|
||
foreach (Dictionary<string, object> row in rows)
|
||
{
|
||
string rel = StringField(row, "path");
|
||
string version = StringField(row, "version");
|
||
string text = StringField(row, "text");
|
||
string full;
|
||
string refusal;
|
||
|
||
if (!ResolveConfigPath(rel, out full, out refusal))
|
||
{
|
||
EmitConfigError(reqId, "path", refusal);
|
||
return;
|
||
}
|
||
|
||
if (!seen.Add(full))
|
||
{
|
||
EmitConfigError(reqId, "duplicate", $"'{rel}' is named twice in one write");
|
||
return;
|
||
}
|
||
|
||
if (text == null)
|
||
{
|
||
EmitConfigError(reqId, "empty", $"'{rel}' carries no text");
|
||
return;
|
||
}
|
||
|
||
if (Encoding.UTF8.GetByteCount(text) > ConfigMaxFileBytes)
|
||
{
|
||
EmitConfigError(reqId, "too-large", $"'{rel}' is larger than this bridge will carry");
|
||
return;
|
||
}
|
||
|
||
// A config file is edited, never created. A plugin writes its own defaults, and a
|
||
// file this walk has never seen is not a settings file somebody forgot — it is a
|
||
// path we have no reason to believe in.
|
||
if (!File.Exists(full))
|
||
{
|
||
EmitConfigError(reqId, "missing", $"'{rel}' does not exist");
|
||
return;
|
||
}
|
||
|
||
string current;
|
||
|
||
try
|
||
{
|
||
current = File.ReadAllText(full, Encoding.UTF8);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
EmitConfigError(reqId, "unreadable", $"'{rel}': {ex.Message}");
|
||
return;
|
||
}
|
||
|
||
string actual = VersionOf(current);
|
||
|
||
if (version != actual)
|
||
{
|
||
Dictionary<string, object> conflict = Frame("config.error", "reply");
|
||
conflict["reason"] = "conflict";
|
||
conflict["detail"] = $"'{rel}' changed on disk since it was read";
|
||
conflict["path"] = rel;
|
||
conflict["version"] = actual;
|
||
|
||
if (reqId != null)
|
||
conflict["reqId"] = reqId;
|
||
|
||
EmitFrame(conflict);
|
||
return;
|
||
}
|
||
|
||
try
|
||
{
|
||
Newtonsoft.Json.Linq.JToken.Parse(text);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
EmitConfigError(reqId, "invalid", $"'{rel}' is not valid JSON: {ex.Message}");
|
||
return;
|
||
}
|
||
|
||
staged.Add(new StagedConfigWrite
|
||
{
|
||
RelPath = rel,
|
||
FullPath = full,
|
||
Text = text,
|
||
Original = current
|
||
});
|
||
}
|
||
|
||
foreach (StagedConfigWrite file in staged)
|
||
{
|
||
try
|
||
{
|
||
BackupConfig(file);
|
||
File.WriteAllText(file.FullPath, file.Text, Utf8NoBom);
|
||
file.Written = true;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
// Half a set on disk is the state nothing can reason about. Put back whatever
|
||
// did land before answering.
|
||
RestoreConfig(staged);
|
||
EmitConfigError(reqId, "unwritable", $"'{file.RelPath}': {ex.Message}");
|
||
return;
|
||
}
|
||
}
|
||
|
||
// This plugin never reloads itself (§21.3), so a change to its own settings would
|
||
// otherwise wait for somebody's next manual reload — and `EventsEnabled` is the first
|
||
// of them an operator is MEANT to change from the website (protocol 8, D76). It is
|
||
// re-read here instead. Host, Port and ServerId are locked on the website's side and
|
||
// still apply on the next reload, because changing them live would mean tearing down
|
||
// the link this answer is about to travel on.
|
||
bool ownApplied = ApplyOwnSettings(staged);
|
||
|
||
if (string.IsNullOrEmpty(reload))
|
||
{
|
||
SendConfigReport(reqId, staged, false, false,
|
||
ownApplied
|
||
? "EventsEnabled took effect at once; the bridge's other settings apply on its next reload"
|
||
: "no reload was asked for",
|
||
null);
|
||
return;
|
||
}
|
||
|
||
if (plugins.Find(reload) == null)
|
||
{
|
||
// The file is written and the plugin is not loaded, so there is nothing to reload
|
||
// and nothing to roll back from: the change applies when it is next loaded. Said
|
||
// plainly, because "saved" and "in effect" are different claims.
|
||
SendConfigReport(reqId, staged, false, false, $"'{reload}' is not loaded", null);
|
||
return;
|
||
}
|
||
|
||
var job = new ConfigWriteJob
|
||
{
|
||
WriteId = Guid.NewGuid().ToString("N"),
|
||
Reload = reload,
|
||
Staged = staged,
|
||
Phase = ConfigPhase.Edit,
|
||
Started = DateTime.UtcNow,
|
||
PhaseStarted = DateTime.UtcNow
|
||
};
|
||
|
||
// Where the log is NOW, before the reload writes anything to it. Only lines after this
|
||
// point can be about this reload.
|
||
MarkLogPosition(job);
|
||
|
||
_configJob = job;
|
||
|
||
if (!ReloadPluginByName(reload))
|
||
{
|
||
_configJob = null;
|
||
RestoreConfig(staged);
|
||
EmitConfigError(reqId, "reload-failed", $"this framework refused to reload '{reload}'");
|
||
return;
|
||
}
|
||
|
||
// The reload was asked for and has not finished; it may be seconds away behind a cold
|
||
// compile. Answer now — the caller is holding a socket with a ten-second budget — and
|
||
// send the outcome as a frame when there is one (F9, D177).
|
||
job.Watch = timer.Every(ConfigLogPollSeconds, ConfigWatchTick);
|
||
SendConfigReport(reqId, staged, false, false, null, null, job);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Main thread. <c>OnPluginLoaded</c> named the target. It is noted, not settled: the
|
||
/// next log read settles it (W5).
|
||
///
|
||
/// <para>
|
||
/// "Loaded" is not "loaded well" on Carbon. A config its <c>LoadConfig</c> cannot read
|
||
/// is logged as a failure and the plugin is loaded anyway, broken, in the same instant
|
||
/// (seen on the rig, configuration step 4). Settling on the hook reported that edit as
|
||
/// applied; reading the log first rolls it back, for about a second's wait.
|
||
/// </para>
|
||
/// </summary>
|
||
private void ConfigReloadArrived()
|
||
{
|
||
ConfigWriteJob job = _configJob;
|
||
|
||
if (job == null || job.LoadedAt.HasValue)
|
||
return;
|
||
|
||
job.LoadedAt = DateTime.UtcNow;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Main thread. A load that a full log read after it found no failure: in the edit
|
||
/// phase the write stands; in the restore phase the plugin is back on its old file.
|
||
/// </summary>
|
||
private void ConfigLoadConfirmed(ConfigWriteJob job)
|
||
{
|
||
if (job.Phase == ConfigPhase.Edit)
|
||
SettleConfigJob(job, true, false, null, null, null);
|
||
else
|
||
SettleConfigJob(job, false, true, true, job.RollbackReason, job.RollbackLog);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Main thread, once a second while a reload is pending. Reads what the framework has
|
||
/// logged since the last tick, and enforces the ceiling.
|
||
///
|
||
/// <para>
|
||
/// <b>A failure is a line, not a silence (D178).</b> Both frameworks log the moment a
|
||
/// plugin fails to compile or to initialise, so a broken edit is rolled back about a
|
||
/// second after it breaks, and a slow compile — which logs nothing until it succeeds —
|
||
/// is simply waited for. The ceiling is for a reload that says nothing either way.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// <b>What the ceiling does depends on what is loaded, not on the clock (F10).</b>
|
||
/// Protocol 12 restored the files whenever the window closed, even over a plugin that
|
||
/// was about to finish loading the edit, and then reported "did not come back" about a
|
||
/// plugin that did. Now: a target that is loaded again was reloaded and its announcement
|
||
/// missed, so the edit stands; one that is loaded and was never seen unloading was never
|
||
/// reloaded at all, so the file stands and applies at its next load; only a target that
|
||
/// is not loaded has its files put back.
|
||
/// </para>
|
||
/// </summary>
|
||
private void ConfigWatchTick()
|
||
{
|
||
ConfigWriteJob job = _configJob;
|
||
|
||
if (job == null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
string failure = ReadConfigFailure(job);
|
||
|
||
if (failure != null && IsCompileStallOf(failure, job.Reload))
|
||
{
|
||
ConfigCompileStalled(job, failure);
|
||
return;
|
||
}
|
||
|
||
if (failure != null)
|
||
{
|
||
if (job.Phase == ConfigPhase.Edit)
|
||
{
|
||
RollBackConfig(job, $"'{job.Reload}' failed to load: {ShortLine(failure)}", failure);
|
||
}
|
||
else
|
||
{
|
||
SettleConfigJob(job, false, true, false,
|
||
$"{job.RollbackReason}; the restored file did not load either: {ShortLine(failure)}",
|
||
job.RollbackLog + "\n" + failure);
|
||
}
|
||
|
||
return;
|
||
}
|
||
|
||
// A load is trusted once a read that began at least a poll after it found nothing:
|
||
// the failure it would have to beat is logged before the load, never after.
|
||
if (job.LoadedAt.HasValue &&
|
||
(DateTime.UtcNow - job.LoadedAt.Value).TotalSeconds >= ConfigLogPollSeconds)
|
||
{
|
||
ConfigLoadConfirmed(job);
|
||
return;
|
||
}
|
||
|
||
if ((DateTime.UtcNow - job.PhaseStarted).TotalSeconds < ConfigReloadCeilingSeconds)
|
||
return;
|
||
|
||
SettleByWhatIsLoaded(job, $"within {ConfigReloadCeilingSeconds:0}s");
|
||
}
|
||
|
||
/// <summary>
|
||
/// Main thread. The framework gave up compiling the target (W1). The first time, the
|
||
/// reload is asked for again with a fresh ceiling; the second time, the job is settled
|
||
/// at once by what is loaded — the same judgement the ceiling makes, without waiting
|
||
/// for it, because the framework has already said it is not going to finish.
|
||
/// </summary>
|
||
private void ConfigCompileStalled(ConfigWriteJob job, string line)
|
||
{
|
||
if (!job.Retried)
|
||
{
|
||
job.Retried = true;
|
||
job.PhaseStarted = DateTime.UtcNow;
|
||
job.Unloaded = false;
|
||
job.LoadedAt = null;
|
||
MarkLogPosition(job);
|
||
|
||
Puts($"the framework gave up compiling '{job.Reload}'; asking for the reload once more");
|
||
|
||
if (ReloadPluginByName(job.Reload))
|
||
return;
|
||
}
|
||
|
||
SettleByWhatIsLoaded(job, $"before the framework gave up compiling it ({ShortLine(line)})");
|
||
}
|
||
|
||
/// <summary>
|
||
/// Main thread. Settles a reload that said nothing conclusive, by what is loaded — not
|
||
/// by the clock (F10). <paramref name="when"/> finishes the sentence: "within 30s", or
|
||
/// why it stopped sooner.
|
||
/// </summary>
|
||
private void SettleByWhatIsLoaded(ConfigWriteJob job, string when)
|
||
{
|
||
bool loaded = IsPluginLoaded(job.Reload);
|
||
|
||
if (job.Phase == ConfigPhase.Restore)
|
||
{
|
||
SettleConfigJob(job, false, true, loaded,
|
||
loaded
|
||
? job.RollbackReason
|
||
: $"{job.RollbackReason}; '{job.Reload}' did not come back on the restored file " +
|
||
$"{when} either",
|
||
job.RollbackLog);
|
||
return;
|
||
}
|
||
|
||
if (loaded && job.Unloaded)
|
||
{
|
||
SettleConfigJob(job, true, false, null,
|
||
$"'{job.Reload}' is loaded again, but its load was not announced", null);
|
||
}
|
||
else if (loaded)
|
||
{
|
||
SettleConfigJob(job, false, false, null,
|
||
$"the framework did not reload '{job.Reload}' {when}; " +
|
||
"the file stands and applies at its next load",
|
||
null);
|
||
}
|
||
else
|
||
{
|
||
RollBackConfig(job,
|
||
job.Unloaded
|
||
? $"'{job.Reload}' unloaded and did not come back {when}"
|
||
: $"'{job.Reload}' did not load {when}",
|
||
LogTail());
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Main thread. The edit took the plugin down: put every file back and reload again.
|
||
///
|
||
/// <para>
|
||
/// This is the feature's real content. Without it, R18 is a web form that takes a
|
||
/// required plugin off a production server one typo at a time. The outcome waits for the
|
||
/// restore's own reload, so it can say whether the plugin is back, rather than only that
|
||
/// the files are.
|
||
/// </para>
|
||
/// </summary>
|
||
private void RollBackConfig(ConfigWriteJob job, string reason, string log)
|
||
{
|
||
RestoreConfig(job.Staged);
|
||
|
||
job.Phase = ConfigPhase.Restore;
|
||
job.PhaseStarted = DateTime.UtcNow;
|
||
job.Unloaded = false;
|
||
job.Retried = false;
|
||
job.LoadedAt = null;
|
||
job.RollbackReason = reason;
|
||
job.RollbackLog = log;
|
||
|
||
// The restore's failure, if it has one, is logged after this point.
|
||
MarkLogPosition(job);
|
||
|
||
if (!ReloadPluginByName(job.Reload))
|
||
{
|
||
SettleConfigJob(job, false, true, false,
|
||
$"{reason}; the files are back, but this framework refused to reload '{job.Reload}'", log);
|
||
}
|
||
}
|
||
|
||
/// <summary>Main thread. The job is over: stop watching and say how it ended.</summary>
|
||
private void SettleConfigJob(
|
||
ConfigWriteJob job,
|
||
bool reloaded,
|
||
bool rolledBack,
|
||
bool? restored,
|
||
string reason,
|
||
string log)
|
||
{
|
||
if (_configJob != job)
|
||
return;
|
||
|
||
StopConfigWatch();
|
||
_configJob = null;
|
||
|
||
EmitConfigOutcome(job, reloaded, rolledBack, restored, reason, log);
|
||
}
|
||
|
||
private void StopConfigWatch()
|
||
{
|
||
ConfigWriteJob job = _configJob;
|
||
|
||
if (job == null || job.Watch == null)
|
||
return;
|
||
|
||
job.Watch.Destroy();
|
||
job.Watch = null;
|
||
}
|
||
|
||
private bool IsPluginLoaded(string name)
|
||
{
|
||
Plugin found = plugins.Find(name);
|
||
return found != null;
|
||
}
|
||
|
||
/// <summary>Main thread. Points the job at the end of the newest log file.</summary>
|
||
private void MarkLogPosition(ConfigWriteJob job)
|
||
{
|
||
job.LogPath = NewestLogFile();
|
||
job.LogPartial = "";
|
||
|
||
try
|
||
{
|
||
job.LogOffset = job.LogPath != null ? new FileInfo(job.LogPath).Length : 0;
|
||
}
|
||
catch (Exception)
|
||
{
|
||
job.LogOffset = 0;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Main thread. The first line logged since the last read that says the target failed to
|
||
/// load, or null.
|
||
///
|
||
/// <para>
|
||
/// Bounded per call. A log that rotates mid-job (midnight) is followed to the new file
|
||
/// from its start; one that shrank was truncated and is read again from the top. Lines
|
||
/// this plugin wrote itself are skipped, so a message of ours that names the target
|
||
/// beside the word "failed" cannot roll back a reload that is going fine.
|
||
/// </para>
|
||
/// </summary>
|
||
private string ReadConfigFailure(ConfigWriteJob job)
|
||
{
|
||
try
|
||
{
|
||
string newest = NewestLogFile();
|
||
|
||
if (newest == null)
|
||
return null;
|
||
|
||
if (!string.Equals(newest, job.LogPath, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
job.LogPath = newest;
|
||
job.LogOffset = 0;
|
||
job.LogPartial = "";
|
||
}
|
||
|
||
string text;
|
||
|
||
using (var stream = new FileStream(job.LogPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
|
||
{
|
||
if (stream.Length < job.LogOffset)
|
||
{
|
||
job.LogOffset = 0;
|
||
job.LogPartial = "";
|
||
}
|
||
|
||
long available = stream.Length - job.LogOffset;
|
||
|
||
if (available <= 0)
|
||
return null;
|
||
|
||
int take = (int)Math.Min(available, ConfigLogPollMaxBytes);
|
||
stream.Seek(job.LogOffset, SeekOrigin.Begin);
|
||
|
||
var buffer = new byte[take];
|
||
int read = stream.Read(buffer, 0, take);
|
||
job.LogOffset += read;
|
||
|
||
text = job.LogPartial + Encoding.UTF8.GetString(buffer, 0, read);
|
||
}
|
||
|
||
int lastBreak = text.LastIndexOf('\n');
|
||
|
||
if (lastBreak < 0)
|
||
{
|
||
job.LogPartial = text.Length > ConfigLogPollMaxBytes ? "" : text;
|
||
return null;
|
||
}
|
||
|
||
job.LogPartial = text.Substring(lastBreak + 1);
|
||
|
||
if (job.LogPartial.Length > ConfigLogPollMaxBytes)
|
||
job.LogPartial = "";
|
||
|
||
string own = "[" + Title + "]";
|
||
|
||
foreach (string raw in text.Substring(0, lastBreak).Split('\n'))
|
||
{
|
||
string line = raw.TrimEnd('\r');
|
||
|
||
if (line.IndexOf(own, StringComparison.OrdinalIgnoreCase) >= 0)
|
||
continue;
|
||
|
||
if (IsLoadFailureOf(line, job.Reload) || IsCompileStallOf(line, job.Reload))
|
||
return line;
|
||
}
|
||
}
|
||
catch (Exception)
|
||
{
|
||
// A log that cannot be read costs the fast path and nothing else: the ceiling
|
||
// still settles the job.
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/// <summary>Whether a log line says <paramref name="plugin"/> failed to compile or to initialise.</summary>
|
||
private static bool IsLoadFailureOf(string line, string plugin)
|
||
{
|
||
return SaysAbout(line, plugin, ConfigFailureWords);
|
||
}
|
||
|
||
/// <summary>Whether a log line says the framework gave up compiling <paramref name="plugin"/> (W1).</summary>
|
||
private static bool IsCompileStallOf(string line, string plugin)
|
||
{
|
||
return SaysAbout(line, plugin, ConfigStallWords);
|
||
}
|
||
|
||
private static bool SaysAbout(string line, string plugin, string[] words)
|
||
{
|
||
if (string.IsNullOrEmpty(line) || string.IsNullOrEmpty(plugin))
|
||
return false;
|
||
|
||
string lower = line.ToLowerInvariant();
|
||
bool said = false;
|
||
|
||
foreach (string word in words)
|
||
{
|
||
if (lower.IndexOf(word, StringComparison.Ordinal) >= 0)
|
||
{
|
||
said = true;
|
||
break;
|
||
}
|
||
}
|
||
|
||
return said && MentionsName(lower, plugin.ToLowerInvariant());
|
||
}
|
||
|
||
/// <summary>
|
||
/// Whether <paramref name="name"/> appears as a whole name — so a failure of
|
||
/// <c>KitsExtended</c> is not read as one of <c>Kits</c>. A trailing <c>.cs</c> or a
|
||
/// version still counts: "Kits.cs", "'Kits v4.4.9'".
|
||
/// </summary>
|
||
private static bool MentionsName(string lower, string name)
|
||
{
|
||
int from = 0;
|
||
|
||
while (true)
|
||
{
|
||
int at = lower.IndexOf(name, from, StringComparison.Ordinal);
|
||
|
||
if (at < 0)
|
||
return false;
|
||
|
||
int end = at + name.Length;
|
||
bool startOk = at == 0 || !char.IsLetterOrDigit(lower[at - 1]);
|
||
bool endOk = end == lower.Length || !char.IsLetterOrDigit(lower[end]);
|
||
|
||
if (startOk && endOk)
|
||
return true;
|
||
|
||
from = at + 1;
|
||
}
|
||
}
|
||
|
||
/// <summary>A log line short enough for a reason sentence.</summary>
|
||
private static string ShortLine(string line)
|
||
{
|
||
string trimmed = (line ?? "").Trim();
|
||
return trimmed.Length <= 240 ? trimmed : trimmed.Substring(0, 240) + "…";
|
||
}
|
||
|
||
/// <summary>
|
||
/// Main thread. Each file as it is on disk now.
|
||
///
|
||
/// <para>
|
||
/// The versions are re-read rather than assumed, because both frameworks merge missing
|
||
/// defaults into a config on load and save it back — so the file after a successful
|
||
/// reload is regularly not the file we wrote, and a site holding our version would
|
||
/// conflict with itself on the next save.
|
||
/// </para>
|
||
/// </summary>
|
||
private List<object> ConfigFilesNow(List<StagedConfigWrite> staged, bool rolledBack)
|
||
{
|
||
var files = new List<object>();
|
||
|
||
foreach (StagedConfigWrite file in staged)
|
||
{
|
||
var row = new Dictionary<string, object> { { "path", file.RelPath } };
|
||
|
||
try
|
||
{
|
||
string now = File.ReadAllText(file.FullPath, Encoding.UTF8);
|
||
row["version"] = VersionOf(now);
|
||
row["bytes"] = Encoding.UTF8.GetByteCount(now);
|
||
row["rewritten"] = !rolledBack && now != file.Text;
|
||
}
|
||
catch (Exception)
|
||
{
|
||
row["version"] = null;
|
||
}
|
||
|
||
files.Add(row);
|
||
}
|
||
|
||
return files;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Main thread. The reply to <c>config.write</c>.
|
||
///
|
||
/// <para>
|
||
/// With a <paramref name="pending"/> job it says "written, reloading": <c>pending</c>,
|
||
/// the <c>writeId</c> the outcome will carry, and <c>ceilingMs</c>. Without one it is the
|
||
/// whole answer — nothing was asked to reload, or the target was not loaded.
|
||
/// </para>
|
||
/// </summary>
|
||
private void SendConfigReport(
|
||
string reqId,
|
||
List<StagedConfigWrite> staged,
|
||
bool reloaded,
|
||
bool rolledBack,
|
||
string reason,
|
||
string log,
|
||
ConfigWriteJob pending = null)
|
||
{
|
||
Dictionary<string, object> report = Frame("config.report", "reply");
|
||
report["ok"] = !rolledBack;
|
||
report["reloaded"] = reloaded;
|
||
report["rolledBack"] = rolledBack;
|
||
report["pending"] = pending != null;
|
||
report["files"] = ConfigFilesNow(staged, rolledBack);
|
||
|
||
if (pending != null)
|
||
{
|
||
report["writeId"] = pending.WriteId;
|
||
report["reload"] = pending.Reload;
|
||
report["ceilingMs"] = (long)(ConfigReloadCeilingSeconds * 1000);
|
||
}
|
||
|
||
if (!string.IsNullOrEmpty(reason))
|
||
report["reason"] = reason;
|
||
|
||
if (!string.IsNullOrEmpty(log))
|
||
report["log"] = log;
|
||
|
||
if (reqId != null)
|
||
report["reqId"] = reqId;
|
||
|
||
_lastConfigSummary = pending != null
|
||
? $"files={staged.Count} reloading={pending.Reload} at={DateTime.UtcNow:HH:mm:ss}Z"
|
||
: $"files={staged.Count} reloaded={reloaded} rolledBack={rolledBack} " +
|
||
$"at={DateTime.UtcNow:HH:mm:ss}Z" +
|
||
(string.IsNullOrEmpty(reason) ? "" : $" reason={reason}");
|
||
|
||
EmitFrame(report);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Main thread. How a pending reload ended, as an event (protocol 13, F9).
|
||
///
|
||
/// <para>
|
||
/// An event, not a reply, because nothing is waiting for it: the sidecar files it in its
|
||
/// feed and the website's ingest settles the write it names. Staff class on the site —
|
||
/// the log tail is an operator's console.
|
||
/// </para>
|
||
/// </summary>
|
||
private void EmitConfigOutcome(
|
||
ConfigWriteJob job,
|
||
bool reloaded,
|
||
bool rolledBack,
|
||
bool? restored,
|
||
string reason,
|
||
string log)
|
||
{
|
||
Dictionary<string, object> frame = Frame("config.outcome", "event");
|
||
frame["writeId"] = job.WriteId;
|
||
frame["reload"] = job.Reload;
|
||
frame["ok"] = !rolledBack;
|
||
frame["reloaded"] = reloaded;
|
||
frame["rolledBack"] = rolledBack;
|
||
frame["elapsedMs"] = (long)(DateTime.UtcNow - job.Started).TotalMilliseconds;
|
||
frame["files"] = ConfigFilesNow(job.Staged, rolledBack);
|
||
|
||
if (restored.HasValue)
|
||
frame["restored"] = restored.Value;
|
||
|
||
if (!string.IsNullOrEmpty(reason))
|
||
frame["reason"] = reason;
|
||
|
||
if (!string.IsNullOrEmpty(log))
|
||
frame["log"] = log;
|
||
|
||
_lastConfigSummary =
|
||
$"files={job.Staged.Count} reloaded={reloaded} rolledBack={rolledBack}" +
|
||
(restored.HasValue ? $" restored={restored.Value}" : "") +
|
||
$" at={DateTime.UtcNow:HH:mm:ss}Z" +
|
||
(string.IsNullOrEmpty(reason) ? "" : $" reason={reason}");
|
||
|
||
EmitFrame(frame);
|
||
}
|
||
|
||
private void EmitConfigError(string reqId, string reason, string detail)
|
||
{
|
||
Dictionary<string, object> frame = Frame("config.error", "reply");
|
||
frame["reason"] = reason;
|
||
|
||
if (!string.IsNullOrEmpty(detail))
|
||
frame["detail"] = detail;
|
||
|
||
if (reqId != null)
|
||
frame["reqId"] = reqId;
|
||
|
||
EmitFrame(frame);
|
||
}
|
||
|
||
/// <summary>Main thread. Puts every file that landed back the way it was.</summary>
|
||
private void RestoreConfig(List<StagedConfigWrite> staged)
|
||
{
|
||
foreach (StagedConfigWrite file in staged)
|
||
{
|
||
if (!file.Written)
|
||
continue;
|
||
|
||
try
|
||
{
|
||
File.WriteAllText(file.FullPath, file.Original, Utf8NoBom);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
// Nothing else can be done from here, and it must be loud: the file on disk is
|
||
// now the edit that took a plugin down, and the operator is the only one who
|
||
// can fix it.
|
||
Puts($"could not restore '{file.RelPath}': {ex.Message}");
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Main thread. If a write touched this plugin's own config, take the one setting that
|
||
/// is safe to change live — <c>EventsEnabled</c> — from what was written. Returns
|
||
/// whether it did.
|
||
/// </summary>
|
||
private bool ApplyOwnSettings(List<StagedConfigWrite> staged)
|
||
{
|
||
string own;
|
||
|
||
try
|
||
{
|
||
own = Path.GetFullPath(Path.Combine(Interface.Oxide.ConfigDirectory, Name + ".json"));
|
||
}
|
||
catch (Exception)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
foreach (StagedConfigWrite file in staged)
|
||
{
|
||
string full;
|
||
|
||
try
|
||
{
|
||
full = Path.GetFullPath(file.FullPath);
|
||
}
|
||
catch (Exception)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
if (!string.Equals(full, own, StringComparison.OrdinalIgnoreCase))
|
||
continue;
|
||
|
||
try
|
||
{
|
||
var root = Newtonsoft.Json.Linq.JObject.Parse(file.Text);
|
||
Newtonsoft.Json.Linq.JToken flag = root["EventsEnabled"];
|
||
|
||
if (flag == null || flag.Type != Newtonsoft.Json.Linq.JTokenType.Boolean || _config == null)
|
||
return false;
|
||
|
||
bool now = (bool)flag;
|
||
|
||
if (_config.EventsEnabled != now)
|
||
Puts($"EventsEnabled is now {now} (set from the website)");
|
||
|
||
_config.EventsEnabled = now;
|
||
return true;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Puts($"could not re-read own settings: {Flatten(ex.Message)}");
|
||
return false;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Keeps a copy of a file about to be overwritten, and the nine before it.
|
||
///
|
||
/// <para>
|
||
/// Under <c>DataDirectory</c>, which is this plugin writing its own state — not the
|
||
/// editor reaching into the tree it must never walk. The rollback itself does not
|
||
/// depend on these files; it holds the original in memory. They are for the operator
|
||
/// who wants last Tuesday's config back.
|
||
/// </para>
|
||
/// </summary>
|
||
private void BackupConfig(StagedConfigWrite file)
|
||
{
|
||
string dir = Path.Combine(Interface.Oxide.DataDirectory, Name, "config-backups");
|
||
Directory.CreateDirectory(dir);
|
||
|
||
string flat = file.RelPath.Replace('/', '_');
|
||
string stamp = DateTime.UtcNow.ToString("yyyyMMdd-HHmmss");
|
||
|
||
File.WriteAllText(Path.Combine(dir, $"{flat}.{stamp}.bak"), file.Original, Utf8NoBom);
|
||
|
||
string[] kept;
|
||
|
||
try
|
||
{
|
||
kept = Directory.GetFiles(dir, $"{flat}.*.bak");
|
||
}
|
||
catch (Exception)
|
||
{
|
||
return;
|
||
}
|
||
|
||
if (kept.Length <= 10)
|
||
return;
|
||
|
||
Array.Sort(kept, StringComparer.Ordinal);
|
||
|
||
for (int i = 0; i < kept.Length - 10; i++)
|
||
{
|
||
try
|
||
{
|
||
File.Delete(kept[i]);
|
||
}
|
||
catch (Exception)
|
||
{
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// The framework's newest log file, or null. Oxide's own log is the newest <c>*.txt</c>
|
||
/// directly in <c>LogDirectory</c>; plugins that log to files write into subdirectories
|
||
/// of it, which this does not look in.
|
||
///
|
||
/// <para>
|
||
/// Carbon's is <c>Carbon.Core.log</c> in <c>carbon/logs</c>, beside four other
|
||
/// <c>*.log</c> files and no <c>*.txt</c> at all (read on the rig, Carbon 2.0.259). Its
|
||
/// Harmony log is the one written most often, so "newest" would pick the wrong file;
|
||
/// the name is the only reliable way to choose.
|
||
/// </para>
|
||
/// </summary>
|
||
private static string NewestLogFile()
|
||
{
|
||
try
|
||
{
|
||
string dir = Interface.Oxide.LogDirectory;
|
||
|
||
if (string.IsNullOrEmpty(dir) || !Directory.Exists(dir))
|
||
return null;
|
||
|
||
if (IsCarbon())
|
||
{
|
||
string core = Path.Combine(dir, "Carbon.Core.log");
|
||
return File.Exists(core) ? core : null;
|
||
}
|
||
|
||
string newest = null;
|
||
DateTime newestAt = DateTime.MinValue;
|
||
|
||
foreach (string candidate in Directory.GetFiles(dir, "*.txt"))
|
||
{
|
||
DateTime at = File.GetLastWriteTimeUtc(candidate);
|
||
|
||
if (at <= newestAt)
|
||
continue;
|
||
|
||
newest = candidate;
|
||
newestAt = at;
|
||
}
|
||
|
||
return newest;
|
||
}
|
||
catch (Exception)
|
||
{
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// The tail of the newest log file, read only when a reload failed.
|
||
///
|
||
/// <para>
|
||
/// A rollback that says "it did not come back" and nothing else sends the operator to a
|
||
/// console to find the line that says why. This carries the line instead. Bounded, and
|
||
/// paid for only on the failure path.
|
||
/// </para>
|
||
/// </summary>
|
||
private string LogTail()
|
||
{
|
||
try
|
||
{
|
||
string newest = NewestLogFile();
|
||
|
||
if (newest == null)
|
||
return null;
|
||
|
||
using (var stream = new FileStream(newest, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
|
||
{
|
||
int take = (int)Math.Min(stream.Length, ConfigLogTailBytes);
|
||
stream.Seek(-take, SeekOrigin.End);
|
||
|
||
var buffer = new byte[take];
|
||
int read = stream.Read(buffer, 0, take);
|
||
|
||
return Encoding.UTF8.GetString(buffer, 0, read);
|
||
}
|
||
}
|
||
catch (Exception)
|
||
{
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Reloads a plugin through the framework rather than by composing a console command.
|
||
///
|
||
/// <para>
|
||
/// The console is the one place the two frameworks genuinely differ — Carbon's commands
|
||
/// are <c>c.</c>-prefixed and an alias for the Oxide names is opt-in — and a wrong
|
||
/// prefix on Carbon prints nothing at all, so it looks exactly like a command that
|
||
/// worked (`CARBON.md` §5). Reflection rather than <c>#if CARBON</c>, because one
|
||
/// byte-identical source file on both frameworks is the property R19 proved and is
|
||
/// worth keeping.
|
||
/// </para>
|
||
/// </summary>
|
||
private bool ReloadPluginByName(string name)
|
||
{
|
||
// Carbon's Oxide-compatible ReloadPlugin is not a reload (W3, Carbon 2.0.259 on the
|
||
// rig): one call unloaded Kits and never compiled it again, the next did nothing at
|
||
// all. Its own console command, typed at the panel, reloads the same plugin in about a
|
||
// second — so on Carbon the reload IS that command, run on the server console.
|
||
if (IsCarbon())
|
||
return RunReloadCommand("c.reload", name);
|
||
|
||
try
|
||
{
|
||
System.Reflection.MethodInfo reload = Interface.Oxide.GetType()
|
||
.GetMethod("ReloadPlugin", new[] { typeof(string) });
|
||
|
||
if (reload != null)
|
||
{
|
||
reload.Invoke(Interface.Oxide, new object[] { name });
|
||
return true;
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Puts($"framework reload of '{name}' threw: {ex.Message}");
|
||
}
|
||
|
||
return RunReloadCommand("oxide.reload", name);
|
||
}
|
||
|
||
/// <summary>Runs a reload command on the server console, as an operator at the panel would.</summary>
|
||
private bool RunReloadCommand(string command, string name)
|
||
{
|
||
try
|
||
{
|
||
ConsoleSystem.Run(ConsoleSystem.Option.Server, command, name);
|
||
return true;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Puts($"console {command} of '{name}' threw: {ex.Message}");
|
||
return false;
|
||
}
|
||
}
|
||
|
||
private static bool IsCarbon()
|
||
{
|
||
if (_carbon.HasValue)
|
||
return _carbon.Value;
|
||
|
||
bool found = false;
|
||
|
||
try
|
||
{
|
||
foreach (System.Reflection.Assembly loaded in AppDomain.CurrentDomain.GetAssemblies())
|
||
{
|
||
string assembly = loaded.GetName().Name;
|
||
|
||
if (assembly == null)
|
||
continue;
|
||
|
||
if (assembly == "Carbon" || assembly.StartsWith("Carbon.", StringComparison.Ordinal))
|
||
{
|
||
found = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
catch (Exception)
|
||
{
|
||
}
|
||
|
||
_carbon = found;
|
||
return found;
|
||
}
|
||
|
||
private static string ConfigRoot()
|
||
{
|
||
try
|
||
{
|
||
return Interface.Oxide.ConfigDirectory;
|
||
}
|
||
catch (Exception)
|
||
{
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Turns a path from a web form into a path on this host, or refuses it with a reason.
|
||
///
|
||
/// <para>
|
||
/// Phase 7 addressed things by name; this phase addresses them by <b>path</b>, which is
|
||
/// exactly the change that introduces this bug class. Four refusals, and the last is the
|
||
/// one that matters: a resolved path must genuinely sit under the config root, checked
|
||
/// after canonicalisation rather than before.
|
||
/// </para>
|
||
/// </summary>
|
||
private static bool ResolveConfigPath(string rel, out string full, out string refusal)
|
||
{
|
||
full = null;
|
||
refusal = null;
|
||
|
||
if (string.IsNullOrEmpty(rel))
|
||
{
|
||
refusal = "no path";
|
||
return false;
|
||
}
|
||
|
||
if (rel.IndexOf('\0') >= 0 || rel.IndexOf("..", StringComparison.Ordinal) >= 0)
|
||
{
|
||
refusal = "a path may not step outside the configuration directory";
|
||
return false;
|
||
}
|
||
|
||
if (rel.StartsWith("/", StringComparison.Ordinal)
|
||
|| rel.StartsWith("\\", StringComparison.Ordinal)
|
||
|| Path.IsPathRooted(rel)
|
||
|| rel.IndexOf(':') >= 0)
|
||
{
|
||
refusal = "a path must be relative to the configuration directory";
|
||
return false;
|
||
}
|
||
|
||
if (!rel.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
refusal = "only .json files are configuration";
|
||
return false;
|
||
}
|
||
|
||
string root = ConfigRoot();
|
||
|
||
if (string.IsNullOrEmpty(root))
|
||
{
|
||
refusal = "this framework reports no configuration directory";
|
||
return false;
|
||
}
|
||
|
||
string rooted;
|
||
|
||
try
|
||
{
|
||
rooted = Path.GetFullPath(root);
|
||
full = Path.GetFullPath(Path.Combine(rooted, rel.Replace('/', Path.DirectorySeparatorChar)));
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
refusal = ex.Message;
|
||
full = null;
|
||
return false;
|
||
}
|
||
|
||
string prefix = rooted.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal)
|
||
? rooted
|
||
: rooted + Path.DirectorySeparatorChar;
|
||
|
||
if (!full.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
refusal = "that path is not inside the configuration directory";
|
||
full = null;
|
||
return false;
|
||
}
|
||
|
||
try
|
||
{
|
||
var info = new FileInfo(full);
|
||
|
||
if (info.Exists && (info.Attributes & FileAttributes.ReparsePoint) != 0)
|
||
{
|
||
refusal = "that file is a link, and a link is not followed";
|
||
full = null;
|
||
return false;
|
||
}
|
||
}
|
||
catch (Exception)
|
||
{
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
private static string RelativeConfigPath(string root, string full)
|
||
{
|
||
string prefix = root.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal)
|
||
? root
|
||
: root + Path.DirectorySeparatorChar;
|
||
|
||
string rel = full.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)
|
||
? full.Substring(prefix.Length)
|
||
: Path.GetFileName(full);
|
||
|
||
return rel.Replace(Path.DirectorySeparatorChar, '/').Replace('\\', '/');
|
||
}
|
||
|
||
/// <summary>
|
||
/// Which plugin a file probably belongs to: the folder for a nested file, the file's own
|
||
/// name otherwise.
|
||
///
|
||
/// <para>
|
||
/// <b>A guess, and it is labelled one all the way to the form.</b> A folder name is
|
||
/// convention, not contract. Infer it silently and the failure is the nastiest available
|
||
/// here: we reload the wrong plugin, watch <c>OnPluginLoaded</c> fire for <i>it</i>, and
|
||
/// report success while the plugin that was actually edited never re-read a thing.
|
||
/// </para>
|
||
/// </summary>
|
||
private static string PluginGuess(string rel)
|
||
{
|
||
int slash = rel.IndexOf('/');
|
||
|
||
return slash > 0
|
||
? rel.Substring(0, slash)
|
||
: Path.GetFileNameWithoutExtension(rel);
|
||
}
|
||
|
||
private List<object> LoadedPlugins()
|
||
{
|
||
var rows = new List<object>();
|
||
Plugin[] all;
|
||
|
||
try
|
||
{
|
||
all = plugins.GetAll();
|
||
}
|
||
catch (Exception)
|
||
{
|
||
return rows;
|
||
}
|
||
|
||
if (all == null)
|
||
return rows;
|
||
|
||
foreach (Plugin loaded in all)
|
||
{
|
||
if (loaded == null || string.IsNullOrEmpty(loaded.Name))
|
||
continue;
|
||
|
||
rows.Add(new Dictionary<string, object>
|
||
{
|
||
{ "name", loaded.Name },
|
||
{ "title", loaded.Title },
|
||
{ "version", loaded.Version.ToString() },
|
||
{ "author", loaded.Author }
|
||
});
|
||
}
|
||
|
||
return rows;
|
||
}
|
||
|
||
/// <summary>
|
||
/// A file's identity for optimistic concurrency: its length and an FNV-1a hash of its
|
||
/// text.
|
||
///
|
||
/// <para>
|
||
/// Deliberately not a cryptographic digest. Nothing here is a security claim — the site
|
||
/// never computes one of these, it only echoes back the one it was given — and
|
||
/// <c>System.Security.Cryptography</c> is a namespace whose availability under a plugin
|
||
/// compiler is one more thing that would have to be true on two frameworks.
|
||
/// </para>
|
||
/// </summary>
|
||
private static string VersionOf(string text)
|
||
{
|
||
byte[] bytes = Encoding.UTF8.GetBytes(text ?? string.Empty);
|
||
ulong hash = 14695981039346656037UL;
|
||
|
||
foreach (byte b in bytes)
|
||
{
|
||
hash ^= b;
|
||
hash *= 1099511628211UL;
|
||
}
|
||
|
||
return $"{bytes.Length:x}-{hash:x16}";
|
||
}
|
||
|
||
/// <summary>UTF-8 with no byte-order mark, which is what both frameworks write.</summary>
|
||
private static readonly UTF8Encoding Utf8NoBom = new UTF8Encoding(false);
|
||
|
||
private static long ToUnixMs(DateTime utc)
|
||
{
|
||
return (long)(utc - new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)).TotalMilliseconds;
|
||
}
|
||
|
||
// ---- leases (protocol 8, PLAN.md §27) ----
|
||
//
|
||
// An event does not edit the world. It borrows a value and gives it back. The website
|
||
// decides WHEN (core's `core.lease` reads the baseline, reserves the target and schedules
|
||
// the restore); this plugin decides WHETHER, holds the deadline, and does the writing.
|
||
//
|
||
// Four rules, each the answer to a way the obvious version goes wrong:
|
||
//
|
||
// • **The deadline lives here.** A hold arms a deadline and the plugin restores the
|
||
// baseline when it passes, whether or not the website is ever heard from again. Core's
|
||
// restore is the normal path; this is the fail-safe that makes an unattended change at
|
||
// 04:00 defensible. `holdMs` is authoritative and `untilMs` is display only — an
|
||
// absolute time computed on the website and honoured here is a deadline measured
|
||
// against two clocks.
|
||
// • **Release is compare-and-set, never a blind write.** A value that is no longer what
|
||
// the lease applied was moved by somebody on purpose: answer `lease.drifted` with what
|
||
// is there now and leave it alone.
|
||
// • **Values cross as text and compare parsed.** `1` and `1.0` are one number and two
|
||
// strings; a drift check on formatted numbers blames an innocent operator.
|
||
// • **A hold longer than the ceiling is refused, never clamped.** A clamp gives the
|
||
// website a shorter lease than it believes it has, and the website is the half that
|
||
// schedules the restore.
|
||
//
|
||
// And one that is Rust's own (§27.1): **a convar hold is memory-only and a restart is a
|
||
// free restore, but a group permission is persisted by the framework.** So holds are
|
||
// written to a file under this plugin's data directory with the boot they were taken
|
||
// under, and on load a convar hold from an earlier boot is dropped (the restart already
|
||
// gave it back) while a permission hold is re-armed — or restored at once, if its
|
||
// deadline passed while the server was down.
|
||
|
||
/// <summary>
|
||
/// The longest a lease may be held: seven days (D77). The website declares the same
|
||
/// ceiling; this one is independent of it and exists for the case where the website
|
||
/// is wrong.
|
||
/// </summary>
|
||
private const long LeaseMaxHoldMs = 7L * 24L * 60L * 60L * 1000L;
|
||
|
||
/// <summary>How often held deadlines are checked. A lease ends within a second of its time.</summary>
|
||
private const float LeaseCheckSeconds = 1f;
|
||
|
||
/// <summary>The wire key of the one lease that is not a convar.</summary>
|
||
private const string GroupPermissionKey = "group.permission";
|
||
|
||
/// <summary>
|
||
/// One convar the website may borrow, and the bounds this plugin holds it to. The
|
||
/// website's declaration carries the same bounds so a bad value is refused on a form;
|
||
/// these are what is true when the website is wrong.
|
||
/// </summary>
|
||
private sealed class LeaseKey
|
||
{
|
||
public string Key;
|
||
public string Family;
|
||
public double Min;
|
||
public double Max;
|
||
|
||
public LeaseKey(string key, string family, double min, double max)
|
||
{
|
||
Key = key;
|
||
Family = family;
|
||
Min = min;
|
||
Max = max;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Every convar a lease may name (D74: the game's own convars, nothing Carbon-only).
|
||
///
|
||
/// <para>
|
||
/// An allowlist rather than "any convar", because the catalogue is a promise: a key
|
||
/// here has been set, observed changing something in a running game, and restored. A
|
||
/// value the server reads once at boot applies cleanly, reads back cleanly and does
|
||
/// nothing, and nothing but a live walk can tell the two apart (§9).
|
||
/// </para>
|
||
/// </summary>
|
||
private static readonly LeaseKey[] LeaseKeys =
|
||
{
|
||
new LeaseKey("decay.scale", "decay", 0, 10),
|
||
|
||
// Every population is per square kilometre — vehicles too, whatever the game's own
|
||
// help text says ("active on the server"): on a 9 km² map a value of 3 is a target of
|
||
// 27. Each was walked: set, seen moving `spawn.report`'s target, and given back.
|
||
new LeaseKey("bear.population", "population", 0, 50),
|
||
new LeaseKey("polarbear.population", "population", 0, 50),
|
||
new LeaseKey("boar.population", "population", 0, 50),
|
||
new LeaseKey("chicken.population", "population", 0, 50),
|
||
new LeaseKey("stag.population", "population", 0, 50),
|
||
new LeaseKey("wolf2.population", "population", 0, 50),
|
||
new LeaseKey("ridablehorse.population", "population", 0, 50),
|
||
new LeaseKey("crocodile.population", "population", 0, 50),
|
||
new LeaseKey("panther.population", "population", 0, 50),
|
||
new LeaseKey("tiger.population", "population", 0, 50),
|
||
new LeaseKey("snakehazard.population", "population", 0, 50),
|
||
|
||
new LeaseKey("minicopter.population", "population", 0, 50),
|
||
new LeaseKey("scraptransporthelicopter.population", "population", 0, 50),
|
||
new LeaseKey("hotairballoon.population", "population", 0, 50),
|
||
new LeaseKey("modularcar.population", "population", 0, 50),
|
||
new LeaseKey("motorrowboat.population", "population", 0, 50),
|
||
new LeaseKey("rhib.rhibpopulation", "population", 0, 50),
|
||
new LeaseKey("traincar.population", "population", 0, 50),
|
||
|
||
// The population system's own scalars. Only the two MINIMUMS: the game scales between
|
||
// min and max by how full the server is, so on a rig with nobody on it the effective
|
||
// rate and density ARE the minimums, and `spawn.scalars` showed both moving. The two
|
||
// maximums only matter with players online, which no walk has had — so they are not
|
||
// lent until one does (§9: a key that cannot be seen doing something is not advertised).
|
||
new LeaseKey("spawn.min_rate", "spawn", 0, 10),
|
||
new LeaseKey("spawn.min_density", "spawn", 0, 10)
|
||
};
|
||
|
||
/// <summary>
|
||
/// One value this plugin is holding. Persisted as it is, so every field is public and
|
||
/// plain.
|
||
/// </summary>
|
||
private sealed class LeaseHold
|
||
{
|
||
public string Key;
|
||
|
||
/// <summary>Null for a convar; <c>group/permission</c> for a group permission.</summary>
|
||
public string Target;
|
||
|
||
public string Baseline;
|
||
public string Applied;
|
||
|
||
/// <summary>When the hold ends, on this host's clock: taken time plus <c>holdMs</c>.</summary>
|
||
public long DeadlineMs;
|
||
|
||
/// <summary>The website's absolute deadline, for display. Never compared against.</summary>
|
||
public long UntilMs;
|
||
|
||
public string RunId;
|
||
public string BootId;
|
||
public long TakenMs;
|
||
|
||
/// <summary>
|
||
/// What the website's permission set said about this pair while the lease held it:
|
||
/// null for nothing, else whether the site wants it granted. A group permission
|
||
/// held by a lease belongs to the lease until it ends; a sync that arrives in the
|
||
/// meantime is recorded here and honoured at release instead of being applied
|
||
/// under the lease's feet (§27.3).
|
||
/// </summary>
|
||
public bool? SiteWants;
|
||
}
|
||
|
||
/// <summary>Holds by <see cref="LeaseSlot"/>. Main thread only.</summary>
|
||
private Dictionary<string, LeaseHold> _leases = new Dictionary<string, LeaseHold>();
|
||
|
||
/// <summary>Convars by lowered full name, resolved once. Null until first needed.</summary>
|
||
private Dictionary<string, ConsoleSystem.Command> _convars;
|
||
|
||
private Timer _leaseTimer;
|
||
|
||
/// <summary>The last thing a lease did, for <c>rg.leases</c>. Diagnostics only.</summary>
|
||
private string _lastLeaseSummary = "none yet";
|
||
|
||
private static string LeaseSlot(string key, string target)
|
||
{
|
||
return string.IsNullOrEmpty(target) ? key : key + "#" + target;
|
||
}
|
||
|
||
private static LeaseKey FindLeaseKey(string key)
|
||
{
|
||
foreach (LeaseKey k in LeaseKeys)
|
||
{
|
||
if (string.Equals(k.Key, key, StringComparison.OrdinalIgnoreCase))
|
||
return k;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Main thread. The game's convar, or null when this build has no such convar or it
|
||
/// is one the plugin refuses to hold.
|
||
///
|
||
/// <para>
|
||
/// <b>A <c>Saved</c> convar is refused</b> (§27.1). The game writes those to
|
||
/// <c>serverauto.cfg</c>, so a hold on one would survive the restart that is
|
||
/// supposed to end it — and a lease that outlives its own fail-safe is the one thing
|
||
/// a lease exists to prevent. None of the allowlist is <c>Saved</c> today; this is
|
||
/// what keeps a Facepunch change to that from becoming our bug silently.
|
||
/// </para>
|
||
/// </summary>
|
||
private ConsoleSystem.Command LeaseConvar(string key, out string refusal)
|
||
{
|
||
refusal = null;
|
||
|
||
if (_convars == null)
|
||
{
|
||
_convars = new Dictionary<string, ConsoleSystem.Command>(StringComparer.OrdinalIgnoreCase);
|
||
ConsoleSystem.Command[] all = ConsoleSystem.Index.All;
|
||
|
||
if (all != null)
|
||
{
|
||
foreach (ConsoleSystem.Command c in all)
|
||
{
|
||
if (c != null && c.Variable && !string.IsNullOrEmpty(c.FullName))
|
||
_convars[c.FullName] = c;
|
||
}
|
||
}
|
||
}
|
||
|
||
ConsoleSystem.Command command;
|
||
|
||
if (!_convars.TryGetValue(key, out command) || command == null || command.SetOveride == null)
|
||
{
|
||
refusal = $"this server has no convar '{key}'";
|
||
return null;
|
||
}
|
||
|
||
if (command.Saved)
|
||
{
|
||
refusal = $"'{key}' is saved to serverauto.cfg, so a restart would not give it back";
|
||
return null;
|
||
}
|
||
|
||
return command;
|
||
}
|
||
|
||
private static bool TryParseNumber(string text, out double value)
|
||
{
|
||
return double.TryParse(
|
||
text, System.Globalization.NumberStyles.Float,
|
||
System.Globalization.CultureInfo.InvariantCulture, out value);
|
||
}
|
||
|
||
private static bool TryParseFlag(string text, out bool value)
|
||
{
|
||
value = false;
|
||
|
||
if (text == null)
|
||
return false;
|
||
|
||
string t = text.Trim().ToLowerInvariant();
|
||
|
||
if (t == "true" || t == "1")
|
||
{
|
||
value = true;
|
||
return true;
|
||
}
|
||
|
||
if (t == "false" || t == "0")
|
||
return true;
|
||
|
||
return false;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Whether two values are the same value, parsed. A convar reads back through
|
||
/// <c>float.ToString()</c>, so the tolerance is a float's, not a double's.
|
||
/// </summary>
|
||
private static bool SameLeaseValue(string key, string a, string b)
|
||
{
|
||
if (key == GroupPermissionKey)
|
||
{
|
||
bool x, y;
|
||
return TryParseFlag(a, out x) && TryParseFlag(b, out y) && x == y;
|
||
}
|
||
|
||
double p, q;
|
||
|
||
if (!TryParseNumber(a, out p) || !TryParseNumber(b, out q))
|
||
return string.Equals(a, b, StringComparison.Ordinal);
|
||
|
||
return Math.Abs(p - q) <= 1e-4 * Math.Max(1.0, Math.Max(Math.Abs(p), Math.Abs(q)));
|
||
}
|
||
|
||
/// <summary>
|
||
/// Splits a group-permission target at its LAST slash. A permission name never
|
||
/// contains one; a group name is a free-form string an operator typed, so it is the
|
||
/// half allowed to.
|
||
/// </summary>
|
||
private static bool SplitGroupTarget(string target, out string group, out string perm)
|
||
{
|
||
group = null;
|
||
perm = null;
|
||
|
||
if (string.IsNullOrEmpty(target))
|
||
return false;
|
||
|
||
int slash = target.LastIndexOf('/');
|
||
|
||
if (slash <= 0 || slash >= target.Length - 1)
|
||
return false;
|
||
|
||
group = Lower(target.Substring(0, slash));
|
||
perm = Lower(target.Substring(slash + 1));
|
||
return !string.IsNullOrEmpty(group) && !string.IsNullOrEmpty(perm);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Main thread. The value a key holds right now, as text — or null with a reason.
|
||
/// <paramref name="gone"/> is set when the target itself no longer exists, which a
|
||
/// release treats as nothing left to give back rather than as a failure.
|
||
/// </summary>
|
||
private string ReadLeaseValue(string key, string target, out string refusal, out bool gone)
|
||
{
|
||
gone = false;
|
||
|
||
if (key == GroupPermissionKey)
|
||
{
|
||
string group, perm;
|
||
|
||
if (!SplitGroupTarget(target, out group, out perm))
|
||
{
|
||
refusal = "a group permission is named as group/permission";
|
||
return null;
|
||
}
|
||
|
||
if (!permission.GroupExists(group))
|
||
{
|
||
gone = true;
|
||
refusal = $"there is no group '{group}' on this server";
|
||
return null;
|
||
}
|
||
|
||
refusal = null;
|
||
return permission.GroupHasPermission(group, perm) ? "true" : "false";
|
||
}
|
||
|
||
if (FindLeaseKey(key) == null)
|
||
{
|
||
refusal = $"'{key}' is not a value this server lends";
|
||
return null;
|
||
}
|
||
|
||
ConsoleSystem.Command command = LeaseConvar(key, out refusal);
|
||
return command == null ? null : command.String;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Main thread. Writes a value, then reads it back — the read-back is the answer, not
|
||
/// the call. Returns null on success, or why not.
|
||
/// </summary>
|
||
private string WriteLeaseValue(string key, string target, string value)
|
||
{
|
||
if (key == GroupPermissionKey)
|
||
{
|
||
string group, perm;
|
||
|
||
if (!SplitGroupTarget(target, out group, out perm))
|
||
return "a group permission is named as group/permission";
|
||
|
||
bool grant;
|
||
|
||
if (!TryParseFlag(value, out grant))
|
||
return $"'{value}' is not true or false";
|
||
|
||
// Our own write raises OnGroupPermissionGranted/Revoked. Without this the site
|
||
// would be told a human had just edited the store, and it would reconcile against
|
||
// the lease's own change (§27.3).
|
||
_applyingPermissions = true;
|
||
|
||
try
|
||
{
|
||
// **`null`, not `this`, as the owner.** With an owner, Oxide's grant first asks
|
||
// `PermissionExists(perm, owner)` — whether THAT plugin registered the name —
|
||
// and returns without a word when it did not. Every permission a lease borrows
|
||
// belongs to some other plugin, so `this` made every grant a silent no-op. The
|
||
// read-back below is what caught it.
|
||
if (grant)
|
||
permission.GrantGroupPermission(group, perm, null);
|
||
else
|
||
permission.RevokeGroupPermission(group, perm);
|
||
}
|
||
finally
|
||
{
|
||
_applyingPermissions = false;
|
||
}
|
||
|
||
if (permission.GroupHasPermission(group, perm) != grant)
|
||
return $"the store did not {(grant ? "grant" : "revoke")} '{perm}' on '{group}'";
|
||
|
||
return null;
|
||
}
|
||
|
||
string refusal;
|
||
ConsoleSystem.Command command = LeaseConvar(key, out refusal);
|
||
|
||
if (command == null)
|
||
return refusal;
|
||
|
||
command.Set(value);
|
||
|
||
if (!SameLeaseValue(key, command.String, value))
|
||
return $"'{key}' reads back as {command.String} after being set to {value}";
|
||
|
||
return null;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Main thread. Protocol 8's catalogue: every key this server lends and what it holds
|
||
/// now, and every hold in force.
|
||
///
|
||
/// <para>
|
||
/// Narrowed by <c>key</c> (and <c>target</c>) when asked, because a group permission
|
||
/// has no single current value — it is one per pair — and a caller asking about one
|
||
/// pair wants that pair's row, not the store.
|
||
/// </para>
|
||
/// </summary>
|
||
private void HandleLeaseList(Dictionary<string, object> cmd, string reqId)
|
||
{
|
||
string onlyKey = Lower(StringField(cmd, "key"));
|
||
string onlyTarget = StringField(cmd, "target");
|
||
var rows = new List<object>();
|
||
|
||
foreach (LeaseKey k in LeaseKeys)
|
||
{
|
||
if (!string.IsNullOrEmpty(onlyKey) && onlyKey != k.Key)
|
||
continue;
|
||
|
||
rows.Add(LeaseRow(k.Key, k.Family, k.Min, k.Max, null));
|
||
}
|
||
|
||
if (onlyKey == GroupPermissionKey && !string.IsNullOrEmpty(onlyTarget))
|
||
rows.Add(LeaseRow(GroupPermissionKey, "group", 0, 1, onlyTarget));
|
||
|
||
var holds = new List<object>();
|
||
|
||
foreach (LeaseHold h in _leases.Values)
|
||
holds.Add(HoldBody(h));
|
||
|
||
Dictionary<string, object> reply = Frame("lease.list", "reply");
|
||
reply["eventsEnabled"] = _config != null && _config.EventsEnabled;
|
||
reply["maxHoldMs"] = LeaseMaxHoldMs;
|
||
reply["leases"] = rows;
|
||
reply["holds"] = holds;
|
||
|
||
if (reqId != null)
|
||
reply["reqId"] = reqId;
|
||
|
||
EmitFrame(reply);
|
||
}
|
||
|
||
private Dictionary<string, object> LeaseRow(string key, string family, double min, double max, string target)
|
||
{
|
||
var row = new Dictionary<string, object>
|
||
{
|
||
{ "key", key },
|
||
{ "family", family }
|
||
};
|
||
|
||
if (key != GroupPermissionKey)
|
||
{
|
||
row["min"] = min;
|
||
row["max"] = max;
|
||
}
|
||
else
|
||
{
|
||
row["target"] = target;
|
||
}
|
||
|
||
string refusal;
|
||
bool gone;
|
||
string current = ReadLeaseValue(key, target, out refusal, out gone);
|
||
|
||
if (current != null)
|
||
row["current"] = current;
|
||
else
|
||
row["unreadable"] = refusal;
|
||
|
||
if (gone)
|
||
row["targetGone"] = true;
|
||
|
||
LeaseHold hold;
|
||
bool held = _leases.TryGetValue(LeaseSlot(key, target), out hold);
|
||
row["held"] = held;
|
||
|
||
if (held)
|
||
{
|
||
row["baseline"] = hold.Baseline;
|
||
row["applied"] = hold.Applied;
|
||
row["untilMs"] = hold.UntilMs;
|
||
row["runId"] = hold.RunId;
|
||
}
|
||
|
||
return row;
|
||
}
|
||
|
||
private static Dictionary<string, object> HoldBody(LeaseHold h)
|
||
{
|
||
var body = new Dictionary<string, object>
|
||
{
|
||
{ "key", h.Key },
|
||
{ "baseline", h.Baseline },
|
||
{ "applied", h.Applied },
|
||
{ "untilMs", h.UntilMs },
|
||
{ "remainingMs", Math.Max(0L, h.DeadlineMs - NowMs()) }
|
||
};
|
||
|
||
if (!string.IsNullOrEmpty(h.Target))
|
||
body["target"] = h.Target;
|
||
|
||
if (!string.IsNullOrEmpty(h.RunId))
|
||
body["runId"] = h.RunId;
|
||
|
||
return body;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Main thread. Take a value for a while.
|
||
///
|
||
/// <para>
|
||
/// <b>A key this plugin already holds keeps its ORIGINAL baseline.</b> Core reserves
|
||
/// a target before it applies, so a second holder is refused on the website's side;
|
||
/// a second apply arriving here means the first one's answer was lost, and core is
|
||
/// trying again. The value to give back at the end is still what was there before
|
||
/// anybody borrowed it, not the first attempt's value.
|
||
/// </para>
|
||
/// </summary>
|
||
private void HandleLeaseApply(Dictionary<string, object> cmd, string reqId)
|
||
{
|
||
string key = Lower(StringField(cmd, "key"));
|
||
string target = key == GroupPermissionKey ? StringField(cmd, "target") : null;
|
||
string family = Lower(StringField(cmd, "family"));
|
||
string value = StringField(cmd, "value");
|
||
long holdMs = LongField(cmd, "holdMs");
|
||
long untilMs = LongField(cmd, "untilMs");
|
||
|
||
if (_config == null || !_config.EventsEnabled)
|
||
{
|
||
EmitLeaseError(reqId, "events-disabled",
|
||
"events are switched off on this server — set EventsEnabled in the RunicGateway plugin config");
|
||
return;
|
||
}
|
||
|
||
if (string.IsNullOrEmpty(key) || value == null)
|
||
{
|
||
EmitLeaseError(reqId, "malformed", "a lease names a key and a value");
|
||
return;
|
||
}
|
||
|
||
if (holdMs <= 0)
|
||
{
|
||
EmitLeaseError(reqId, "malformed", "a lease is held for a positive number of milliseconds");
|
||
return;
|
||
}
|
||
|
||
if (holdMs > LeaseMaxHoldMs)
|
||
{
|
||
EmitLeaseError(reqId, "too-long",
|
||
$"this server holds a lease for at most {LeaseMaxHoldMs / 60000L} minutes, not {holdMs / 60000L}");
|
||
return;
|
||
}
|
||
|
||
if (key == GroupPermissionKey)
|
||
{
|
||
bool flag;
|
||
|
||
if (!TryParseFlag(value, out flag))
|
||
{
|
||
EmitLeaseError(reqId, "out-of-range", $"'{value}' is not true or false");
|
||
return;
|
||
}
|
||
|
||
string group, perm;
|
||
|
||
if (!SplitGroupTarget(target, out group, out perm))
|
||
{
|
||
EmitLeaseError(reqId, "malformed", "a group permission is named as group/permission");
|
||
return;
|
||
}
|
||
|
||
// The same pre-check the permission mirror makes (§12.2 rule 1): a grant of a
|
||
// name nothing registered returns void and does nothing, and the lease would then
|
||
// record a hold on a value that never changed.
|
||
if (!permission.PermissionExists(perm))
|
||
{
|
||
EmitLeaseError(reqId, "unresolved", $"no loaded plugin on this server registers '{perm}'");
|
||
return;
|
||
}
|
||
|
||
target = group + "/" + perm;
|
||
value = flag ? "true" : "false";
|
||
}
|
||
else
|
||
{
|
||
LeaseKey k = FindLeaseKey(key);
|
||
|
||
if (k == null)
|
||
{
|
||
EmitLeaseError(reqId, "unknown-key", $"'{key}' is not a value this server lends");
|
||
return;
|
||
}
|
||
|
||
if (!string.IsNullOrEmpty(family) && family != k.Family)
|
||
{
|
||
EmitLeaseError(reqId, "unknown-key", $"'{key}' is not a {family} value");
|
||
return;
|
||
}
|
||
|
||
double number;
|
||
|
||
if (!TryParseNumber(value, out number) || number < k.Min || number > k.Max)
|
||
{
|
||
EmitLeaseError(reqId, "out-of-range", $"{key} takes {k.Min} to {k.Max}, not {value}");
|
||
return;
|
||
}
|
||
}
|
||
|
||
string slot = LeaseSlot(key, target);
|
||
LeaseHold existing;
|
||
_leases.TryGetValue(slot, out existing);
|
||
|
||
string refusal;
|
||
bool gone;
|
||
string before = ReadLeaseValue(key, target, out refusal, out gone);
|
||
|
||
if (before == null)
|
||
{
|
||
EmitLeaseError(reqId, gone ? "target-gone" : "unreadable", refusal);
|
||
return;
|
||
}
|
||
|
||
string failed = WriteLeaseValue(key, target, value);
|
||
|
||
if (failed != null)
|
||
{
|
||
// Put back what was there; a half-applied lease is not a state to leave behind.
|
||
WriteLeaseValue(key, target, before);
|
||
EmitLeaseError(reqId, "refused", failed);
|
||
return;
|
||
}
|
||
|
||
long now = NowMs();
|
||
|
||
var hold = new LeaseHold
|
||
{
|
||
Key = key,
|
||
Target = target,
|
||
Baseline = existing != null ? existing.Baseline : before,
|
||
Applied = value,
|
||
DeadlineMs = now + holdMs,
|
||
UntilMs = untilMs > 0 ? untilMs : now + holdMs,
|
||
RunId = StringField(cmd, "runId"),
|
||
BootId = _bootId,
|
||
TakenMs = now,
|
||
SiteWants = existing != null ? existing.SiteWants : null
|
||
};
|
||
|
||
_leases[slot] = hold;
|
||
SaveLeases();
|
||
EnsureLeaseTimer();
|
||
|
||
_lastLeaseSummary = $"applied {slot}={value} (baseline {hold.Baseline}) for {holdMs / 1000L}s";
|
||
Puts($"lease {_lastLeaseSummary}");
|
||
|
||
Dictionary<string, object> reply = Frame("lease.ok", "reply");
|
||
reply["key"] = key;
|
||
|
||
if (!string.IsNullOrEmpty(target))
|
||
reply["target"] = target;
|
||
|
||
reply["baseline"] = hold.Baseline;
|
||
reply["applied"] = hold.Applied;
|
||
reply["untilMs"] = hold.UntilMs;
|
||
|
||
if (reqId != null)
|
||
reply["reqId"] = reqId;
|
||
|
||
EmitFrame(reply);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Main thread. Give a value back — compare-and-set.
|
||
///
|
||
/// <para>
|
||
/// The comparison is against what the LEASE applied, from this plugin's own record
|
||
/// when it has one, else from the website's <c>expected</c>. The record is missing
|
||
/// when the deadline already fired, or when a restart dropped a convar hold; both of
|
||
/// those have already given the value back, and the compare then finds baseline there
|
||
/// and answers success without writing anything.
|
||
/// </para>
|
||
/// </summary>
|
||
private void HandleLeaseRelease(Dictionary<string, object> cmd, string reqId)
|
||
{
|
||
string key = Lower(StringField(cmd, "key"));
|
||
string target = StringField(cmd, "target");
|
||
|
||
if (key == GroupPermissionKey)
|
||
{
|
||
string group, perm;
|
||
|
||
if (SplitGroupTarget(target, out group, out perm))
|
||
target = group + "/" + perm;
|
||
}
|
||
else
|
||
{
|
||
target = null;
|
||
}
|
||
|
||
if (string.IsNullOrEmpty(key))
|
||
{
|
||
EmitLeaseError(reqId, "malformed", "a release names a key");
|
||
return;
|
||
}
|
||
|
||
LeaseHold hold;
|
||
_leases.TryGetValue(LeaseSlot(key, target), out hold);
|
||
|
||
string expected = hold != null ? hold.Applied : StringField(cmd, "expected");
|
||
string baseline = hold != null ? hold.Baseline : StringField(cmd, "baseline");
|
||
|
||
Dictionary<string, object> outcome = Restore(key, target, expected, baseline, hold, "release");
|
||
|
||
if (reqId != null)
|
||
outcome["reqId"] = reqId;
|
||
|
||
EmitFrame(outcome);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Main thread. The one place a value is given back, by a release or a deadline. The
|
||
/// hold is ended either way — a drifted value is left alone, but the lease is over.
|
||
/// </summary>
|
||
private Dictionary<string, object> Restore(
|
||
string key, string target, string expected, string baseline, LeaseHold hold, string why)
|
||
{
|
||
string slot = LeaseSlot(key, target);
|
||
|
||
// A group permission the site asked for during the hold is given to the site, not
|
||
// back to the baseline: the lease borrowed the pair, the site owns what it becomes.
|
||
string restoreTo = baseline;
|
||
|
||
if (hold != null && hold.SiteWants.HasValue)
|
||
restoreTo = hold.SiteWants.Value ? "true" : "false";
|
||
|
||
string refusal;
|
||
bool gone;
|
||
string current = ReadLeaseValue(key, target, out refusal, out gone);
|
||
|
||
if (current == null)
|
||
{
|
||
if (gone)
|
||
{
|
||
// The group was deleted mid-hold: nothing is owed and nothing is out there.
|
||
_leases.Remove(slot);
|
||
SaveLeases();
|
||
_lastLeaseSummary = $"{why} {slot}: target gone";
|
||
|
||
Dictionary<string, object> goneFrame = Frame("lease.ok", "reply");
|
||
goneFrame["key"] = key;
|
||
goneFrame["targetGone"] = true;
|
||
|
||
if (!string.IsNullOrEmpty(target))
|
||
goneFrame["target"] = target;
|
||
|
||
return goneFrame;
|
||
}
|
||
|
||
// The value cannot be read, so it cannot be compared. The hold stays: the next
|
||
// release, or the next deadline check, tries again.
|
||
Dictionary<string, object> error = Frame("lease.error", "reply");
|
||
error["reason"] = "unreadable";
|
||
error["message"] = refusal;
|
||
return error;
|
||
}
|
||
|
||
if (restoreTo != null && SameLeaseValue(key, current, restoreTo))
|
||
{
|
||
// Already given back — by a deadline, a restart, or somebody who restored it by
|
||
// hand. Nothing to write, and it is a success.
|
||
_leases.Remove(slot);
|
||
SaveLeases();
|
||
_lastLeaseSummary = $"{why} {slot}: already {current}";
|
||
return RestoredFrame(key, target, current);
|
||
}
|
||
|
||
if (expected == null || !SameLeaseValue(key, current, expected))
|
||
{
|
||
_leases.Remove(slot);
|
||
SaveLeases();
|
||
_lastLeaseSummary = $"{why} {slot}: drifted to {current}, left alone";
|
||
Puts($"lease {_lastLeaseSummary}");
|
||
|
||
Dictionary<string, object> drifted = Frame("lease.drifted", "reply");
|
||
drifted["key"] = key;
|
||
|
||
if (!string.IsNullOrEmpty(target))
|
||
drifted["target"] = target;
|
||
|
||
drifted["current"] = current;
|
||
drifted["expected"] = expected;
|
||
return drifted;
|
||
}
|
||
|
||
string failed = restoreTo == null ? "no baseline to restore" : WriteLeaseValue(key, target, restoreTo);
|
||
|
||
if (failed != null)
|
||
{
|
||
Dictionary<string, object> error = Frame("lease.error", "reply");
|
||
error["reason"] = "refused";
|
||
error["message"] = failed;
|
||
return error;
|
||
}
|
||
|
||
_leases.Remove(slot);
|
||
SaveLeases();
|
||
_lastLeaseSummary = $"{why} {slot}: restored {restoreTo}";
|
||
Puts($"lease {_lastLeaseSummary}");
|
||
return RestoredFrame(key, target, restoreTo);
|
||
}
|
||
|
||
private Dictionary<string, object> RestoredFrame(string key, string target, string value)
|
||
{
|
||
Dictionary<string, object> ok = Frame("lease.ok", "reply");
|
||
ok["key"] = key;
|
||
|
||
if (!string.IsNullOrEmpty(target))
|
||
ok["target"] = target;
|
||
|
||
ok["restored"] = value;
|
||
return ok;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Main thread, every second while anything is held. The fail-safe: a deadline that
|
||
/// passes gives the value back without anybody asking, and says so.
|
||
/// </summary>
|
||
private void CheckLeaseDeadlines()
|
||
{
|
||
if (_leases.Count == 0)
|
||
{
|
||
StopLeaseTimer();
|
||
return;
|
||
}
|
||
|
||
long now = NowMs();
|
||
List<LeaseHold> due = null;
|
||
|
||
foreach (LeaseHold h in _leases.Values)
|
||
{
|
||
if (h.DeadlineMs <= now)
|
||
{
|
||
if (due == null)
|
||
due = new List<LeaseHold>();
|
||
|
||
due.Add(h);
|
||
}
|
||
}
|
||
|
||
if (due == null)
|
||
return;
|
||
|
||
foreach (LeaseHold h in due)
|
||
Expire(h);
|
||
}
|
||
|
||
private void Expire(LeaseHold hold)
|
||
{
|
||
Dictionary<string, object> outcome = Restore(
|
||
hold.Key, hold.Target, hold.Applied, hold.Baseline, hold, "expired");
|
||
|
||
string kind = StringField(outcome, "kind");
|
||
|
||
// An unreadable value keeps the hold; the next tick tries again rather than
|
||
// announcing an expiry that did not happen.
|
||
if (kind == "lease.error")
|
||
return;
|
||
|
||
Dictionary<string, object> frame = Frame("lease.expired", "event");
|
||
frame["key"] = hold.Key;
|
||
|
||
if (!string.IsNullOrEmpty(hold.Target))
|
||
frame["target"] = hold.Target;
|
||
|
||
if (!string.IsNullOrEmpty(hold.RunId))
|
||
frame["runId"] = hold.RunId;
|
||
|
||
frame["drifted"] = kind == "lease.drifted";
|
||
|
||
object current;
|
||
if (outcome.TryGetValue("current", out current))
|
||
frame["current"] = current;
|
||
|
||
EmitFrame(frame);
|
||
}
|
||
|
||
private void EnsureLeaseTimer()
|
||
{
|
||
if (_leaseTimer == null && _leases.Count > 0)
|
||
_leaseTimer = timer.Every(LeaseCheckSeconds, CheckLeaseDeadlines);
|
||
}
|
||
|
||
private void StopLeaseTimer()
|
||
{
|
||
if (_leaseTimer != null)
|
||
{
|
||
_leaseTimer.Destroy();
|
||
_leaseTimer = null;
|
||
}
|
||
}
|
||
|
||
private void EmitLeaseError(string reqId, string reason, string message)
|
||
{
|
||
Dictionary<string, object> frame = Frame("lease.error", "reply");
|
||
frame["reason"] = reason;
|
||
|
||
if (!string.IsNullOrEmpty(message))
|
||
frame["message"] = message;
|
||
|
||
if (reqId != null)
|
||
frame["reqId"] = reqId;
|
||
|
||
_lastLeaseSummary = $"refused ({reason}): {message}";
|
||
EmitFrame(frame);
|
||
}
|
||
|
||
private string LeasesPath()
|
||
{
|
||
return Path.Combine(Interface.Oxide.DataDirectory, Name, "leases.json");
|
||
}
|
||
|
||
/// <summary>
|
||
/// Main thread. Written on every change, because the whole point of the file is the
|
||
/// crash nobody saw coming. It is a handful of rows.
|
||
/// </summary>
|
||
private void SaveLeases()
|
||
{
|
||
try
|
||
{
|
||
string path = LeasesPath();
|
||
Directory.CreateDirectory(Path.GetDirectoryName(path));
|
||
|
||
var rows = new List<LeaseHold>(_leases.Values);
|
||
string tmp = path + ".tmp";
|
||
File.WriteAllText(tmp, JsonConvert.SerializeObject(rows, Formatting.Indented), Utf8NoBom);
|
||
|
||
if (File.Exists(path))
|
||
File.Delete(path);
|
||
|
||
File.Move(tmp, path);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Puts($"could not save leases: {Flatten(ex.Message)}");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Main thread, once per load, with the world available. Re-arms what is still owed.
|
||
///
|
||
/// <para>
|
||
/// A convar hold taken under an earlier BOOT is dropped: the restart put the game's
|
||
/// own value back, and re-applying it would resurrect a lease the restart ended. A
|
||
/// convar hold from THIS boot survives a plugin reload — the value is still in the
|
||
/// game's memory, and a config save from the website must not end an event. A group
|
||
/// permission hold is re-armed whatever the boot, because the framework kept it.
|
||
/// </para>
|
||
/// </summary>
|
||
private void LoadLeases()
|
||
{
|
||
_leases = new Dictionary<string, LeaseHold>();
|
||
|
||
List<LeaseHold> rows = null;
|
||
|
||
try
|
||
{
|
||
string path = LeasesPath();
|
||
|
||
if (File.Exists(path))
|
||
rows = JsonConvert.DeserializeObject<List<LeaseHold>>(File.ReadAllText(path));
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Puts($"could not read leases: {Flatten(ex.Message)}");
|
||
}
|
||
|
||
if (rows == null)
|
||
return;
|
||
|
||
int dropped = 0;
|
||
|
||
foreach (LeaseHold h in rows)
|
||
{
|
||
if (h == null || string.IsNullOrEmpty(h.Key))
|
||
continue;
|
||
|
||
if (h.Key != GroupPermissionKey && h.BootId != _bootId)
|
||
{
|
||
dropped++;
|
||
continue;
|
||
}
|
||
|
||
_leases[LeaseSlot(h.Key, h.Target)] = h;
|
||
}
|
||
|
||
if (dropped > 0)
|
||
SaveLeases();
|
||
|
||
Puts($"leases: {_leases.Count} held, {dropped} ended by a restart");
|
||
|
||
// Due now or later; the first tick restores anything whose deadline passed while the
|
||
// server was down.
|
||
EnsureLeaseTimer();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Whether a lease holds this group permission, for the permission mirror. A pair a
|
||
/// lease has borrowed is the lease's until it ends: a sync does not apply to it, and the
|
||
/// inventory lists it under <c>leased</c> so the site never takes it for a hand edit.
|
||
/// </summary>
|
||
private LeaseHold LeasedGroupPermission(string group, string perm)
|
||
{
|
||
LeaseHold hold;
|
||
return _leases.TryGetValue(LeaseSlot(GroupPermissionKey, Lower(group) + "/" + Lower(perm)), out hold)
|
||
? hold
|
||
: null;
|
||
}
|
||
|
||
/// <summary>What every hold is doing, from the server console.</summary>
|
||
[ConsoleCommand("rg.leases")]
|
||
private void CmdLeases(ConsoleSystem.Arg arg)
|
||
{
|
||
var lines = new List<string>
|
||
{
|
||
$"protocol={ProtocolVersion} eventsEnabled={(_config != null && _config.EventsEnabled)} " +
|
||
$"held={_leases.Count} bootId={_bootId}"
|
||
};
|
||
|
||
foreach (LeaseHold h in _leases.Values)
|
||
{
|
||
lines.Add(
|
||
$" {LeaseSlot(h.Key, h.Target)} applied={h.Applied} baseline={h.Baseline} " +
|
||
$"remaining={Math.Max(0L, h.DeadlineMs - NowMs()) / 1000L}s run={h.RunId ?? "-"}");
|
||
}
|
||
|
||
lines.Add($"last: {_lastLeaseSummary}");
|
||
arg.ReplyWith(string.Join(Environment.NewLine, lines.ToArray()));
|
||
}
|
||
|
||
// ---- the world verbs (protocol 9, PLAN.md §28) ----
|
||
//
|
||
// Two things an event makes that did not exist — a zone, and a crate or NPC placed in the
|
||
// world — and one registry that remembers which run made what. Four rules shape all of it,
|
||
// and each came from the rig rather than from a document (§28.1):
|
||
//
|
||
// * **A restart is not proof a placed thing is gone.** Crates are saved by the game and
|
||
// come back with the same net id; NPCs are not saved and do not. So nothing here is
|
||
// inferred from a boot id. `world.owned` LOOKS, by net id, every time.
|
||
// * **A wipe is proof everything is gone.** The registry is dropped whole on a new save
|
||
// and at load when it names another wipe.
|
||
// * **ZoneManager forgets.** It holds temporary zones in memory and erases every zone a
|
||
// plugin owns when that plugin unloads — so an `oxide.reload` of this bridge deletes
|
||
// every event zone mid-run. The registry is the record, and zones are put back from it
|
||
// on every load and every ZoneManager reload (D92).
|
||
// * **There is no at-most-once store on this bridge.** The registry is keyed by the
|
||
// website's idempotency key instead: a repeat answers with the ids the first call made
|
||
// and places nothing, which is the whole of what the key is for.
|
||
//
|
||
// What may be erased is decided by the registry and never by ZoneManager, whose
|
||
// `EraseTemporaryZone` deletes an operator's own hand-made zone and returns true (§12.4).
|
||
|
||
/// <summary>The ceiling on crates per step. <c>EventsMaxCrates</c> may lower it, never raise it.</summary>
|
||
private const int WorldHardMaxCrates = 25;
|
||
|
||
/// <summary>The ceiling on NPCs per step (D95).</summary>
|
||
private const int WorldHardMaxNpcs = 20;
|
||
|
||
/// <summary>How far a group may be scattered around its point, in metres.</summary>
|
||
private const float WorldMaxSpread = 50f;
|
||
|
||
/// <summary>How far from a monument's centre a step may put its point, in metres.</summary>
|
||
private const float WorldMaxOffset = 150f;
|
||
|
||
private const float ZoneMinRadius = 5f;
|
||
private const float ZoneMaxRadius = 150f;
|
||
|
||
/// <summary>
|
||
/// The longest a zone may stand: seven days, the lease ceiling (D96). A zone is priced
|
||
/// by its minutes, so it has a deadline, and the deadline is kept HERE — a zone never
|
||
/// outlives its event even when the website that opened it has gone.
|
||
/// </summary>
|
||
private const long ZoneMaxHoldMs = 7L * 24L * 60L * 60L * 1000L;
|
||
|
||
private const string WorldKindZone = "zone";
|
||
private const string WorldKindCrate = "crate";
|
||
private const string WorldKindNpc = "npc";
|
||
|
||
/// <summary>One thing this plugin will place, by the key the website names it with.</summary>
|
||
private sealed class WorldPrefab
|
||
{
|
||
public readonly string Key;
|
||
public readonly string Kind;
|
||
public readonly string Path;
|
||
public readonly string Label;
|
||
|
||
public WorldPrefab(string key, string kind, string path, string label)
|
||
{
|
||
Key = key;
|
||
Kind = kind;
|
||
Path = path;
|
||
Label = label;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// The allowlist (D88), and the only one: the sidecar forwards and the website offers
|
||
/// what this answers. Crates and NPCs, never vehicles — a player can take a vehicle, and
|
||
/// a revert would kill something a person may be sitting in. Every path here was
|
||
/// spawned on the rig and reported its type before it was listed (§28.1).
|
||
/// </summary>
|
||
private static readonly WorldPrefab[] WorldPrefabs =
|
||
{
|
||
new WorldPrefab("crate.basic", WorldKindCrate, "assets/bundled/prefabs/radtown/crate_basic.prefab", "Basic crate"),
|
||
new WorldPrefab("crate.normal", WorldKindCrate, "assets/bundled/prefabs/radtown/crate_normal.prefab", "Military crate"),
|
||
new WorldPrefab("crate.normal2", WorldKindCrate, "assets/bundled/prefabs/radtown/crate_normal_2.prefab", "Crate"),
|
||
new WorldPrefab("crate.elite", WorldKindCrate, "assets/bundled/prefabs/radtown/crate_elite.prefab", "Elite crate"),
|
||
new WorldPrefab("crate.tools", WorldKindCrate, "assets/bundled/prefabs/radtown/crate_tools.prefab", "Tool box"),
|
||
new WorldPrefab("crate.hackable", WorldKindCrate, "assets/prefabs/deployable/chinooklockedcrate/codelockedhackablecrate.prefab", "Locked crate (hackable)"),
|
||
new WorldPrefab("supply.drop", WorldKindCrate, "assets/prefabs/misc/supply drop/supply_drop.prefab", "Supply drop"),
|
||
new WorldPrefab("barrel.loot", WorldKindCrate, "assets/bundled/prefabs/radtown/loot_barrel_1.prefab", "Loot barrel"),
|
||
new WorldPrefab("npc.scientist", WorldKindNpc, "assets/rust.ai/agents/npcplayer/humannpc/scientist/scientistnpc_roam.prefab", "Scientist"),
|
||
new WorldPrefab("npc.scientist.heavy", WorldKindNpc, "assets/rust.ai/agents/npcplayer/humannpc/scientist/scientistnpc_heavy.prefab", "Heavy scientist"),
|
||
new WorldPrefab("npc.scientist.tethered", WorldKindNpc, "assets/rust.ai/agents/npcplayer/humannpc/scientist/scientistnpc_roamtethered.prefab", "Scientist (stays put)"),
|
||
new WorldPrefab("npc.bandit.guard", WorldKindNpc, "assets/rust.ai/agents/npcplayer/humannpc/banditguard/npc_bandit_guard.prefab", "Bandit guard")
|
||
};
|
||
|
||
/// <summary>One thing an event made. Persisted as it is, so every field is public and plain.</summary>
|
||
private sealed class WorldEntry
|
||
{
|
||
/// <summary>The net id for an entity, as text; the zone id for a zone.</summary>
|
||
public string Id;
|
||
|
||
public string Kind;
|
||
|
||
/// <summary>The allowlist key, for a crate or an NPC.</summary>
|
||
public string Prefab;
|
||
|
||
public string RunId;
|
||
|
||
/// <summary>The website's idempotency key for the step that made it.</summary>
|
||
public string Key;
|
||
|
||
public float X, Y, Z;
|
||
|
||
/// <summary>A zone's radius and name, for re-creating it.</summary>
|
||
public float Radius;
|
||
public string Name;
|
||
|
||
/// <summary>
|
||
/// A zone's ZoneManager flags and settings (PLAN_REDESIGNS §3.1), kept so a re-created
|
||
/// zone gets them back. Null on a zone from before protocol 13's options.
|
||
/// </summary>
|
||
public List<string> Flags;
|
||
public Dictionary<string, string> Settings;
|
||
|
||
/// <summary>What a player is told on the way in and out, and how (D193). Said by this plugin, not ZoneManager.</summary>
|
||
public string EnterMessage;
|
||
public string LeaveMessage;
|
||
public string Delivery;
|
||
public string Format;
|
||
|
||
/// <summary>A ZoneDomes dome over the zone (D194): its type (0–4) and how many spheres are stacked.</summary>
|
||
public bool Dome;
|
||
public int DomeType;
|
||
public int DomeStack;
|
||
|
||
/// <summary>When a zone is erased without being asked, on this host's clock (D96). Zero for an entity.</summary>
|
||
public long DeadlineMs;
|
||
|
||
public string BootId;
|
||
public string WipeId;
|
||
public long TakenMs;
|
||
}
|
||
|
||
/// <summary>Everything events have made on this map, by id. Main thread only.</summary>
|
||
private Dictionary<string, WorldEntry> _world = new Dictionary<string, WorldEntry>();
|
||
|
||
/// <summary>A counter for zone ids, so two zones one run opens in one tick never collide.</summary>
|
||
private int _zoneSerial;
|
||
|
||
/// <summary>
|
||
/// Whether the world is loaded for THIS load of the plugin — set at
|
||
/// <c>OnServerInitialized</c>, which fires at the end of a real start and at once on a
|
||
/// hotload. Until then every world command is refused <c>not-ready</c>.
|
||
///
|
||
/// <para>
|
||
/// Found by the phase 13a walk, and it is the worst kind of wrong: the link starts in
|
||
/// <c>Init</c>, BEFORE the save loads, so a website that saw the new boot id asked
|
||
/// <c>world.owned</c> into an empty world — and the plugin, finding no entity behind
|
||
/// any net id, pruned three live crates from its own registry. They stayed in the
|
||
/// world owned by nothing, and a revert would have refused them. An empty world at
|
||
/// boot is not an answer; it is the question arriving too early.
|
||
/// </para>
|
||
/// </summary>
|
||
private bool _worldReady;
|
||
|
||
private string _lastWorldSummary = "none yet";
|
||
|
||
private Timer _worldTimer;
|
||
|
||
private static WorldPrefab FindWorldPrefab(string key)
|
||
{
|
||
foreach (WorldPrefab p in WorldPrefabs)
|
||
{
|
||
if (string.Equals(p.Key, key, StringComparison.OrdinalIgnoreCase))
|
||
return p;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
private Plugin ZoneManagerPlugin()
|
||
{
|
||
return plugins.Find("ZoneManager");
|
||
}
|
||
|
||
// ---- the ZoneManager helper (F12, D181, D182) ----
|
||
//
|
||
// ZoneManager counts a player in a zone only when its trigger fires on entry, so a zone
|
||
// created, updated or re-created around somebody standing there holds nobody. The helper,
|
||
// `RunicGatewayZones.cs`, ships beside this file and patches that. It is optional: without it,
|
||
// or when it loaded and could not find what it patches, the tally and the kill credit ask
|
||
// ZoneManager's public `IsPositionInZone` instead of its membership — so a missing helper
|
||
// costs ZoneManager's own flags for somebody already inside, never their score.
|
||
|
||
private Plugin ZoneHelperPlugin()
|
||
{
|
||
return plugins.Find("RunicGatewayZones");
|
||
}
|
||
|
||
/// <summary>Main thread. <c>patched</c>, <c>unsupported</c>, <c>no-zonemanager</c> or <c>missing</c>.</summary>
|
||
private string ZoneHelperState()
|
||
{
|
||
Plugin helper = ZoneHelperPlugin();
|
||
return helper == null ? "missing" : helper.Call("State") as string ?? "unsupported";
|
||
}
|
||
|
||
/// <summary>
|
||
/// Main thread. Whether ZoneManager's own membership can be trusted for a zone that may
|
||
/// have been made around somebody. Asked once per tally tick or kill, not per player.
|
||
/// </summary>
|
||
private bool ZoneMembershipTrusted()
|
||
{
|
||
return ZoneHelperState() == "patched";
|
||
}
|
||
|
||
/// <summary>
|
||
/// Main thread. Is this player in the zone: ZoneManager's membership when the helper has
|
||
/// made it right, and the zone's shape otherwise (D182).
|
||
/// </summary>
|
||
private static bool InZone(Plugin zm, string zoneId, BasePlayer player, bool trusted)
|
||
{
|
||
if (zm == null || player == null)
|
||
return false;
|
||
|
||
object inside = trusted
|
||
? zm.Call("IsPlayerInZone", zoneId, player)
|
||
: zm.Call("IsPositionInZone", zoneId, player.transform.position);
|
||
|
||
return inside is bool && (bool)inside;
|
||
}
|
||
|
||
// ---- a zone's options (PLAN_REDESIGNS §3.1, D166, D211) ----
|
||
//
|
||
// ZoneManager's flags are whatever the installed version declares, so the list is read from
|
||
// it, never kept here. `ZoneFieldListRaw` is in ZoneManager's own API region and answers
|
||
// the zone fields followed by every flag name; the fields this plugin controls or does not
|
||
// offer are dropped from the front. Plan §0.6 believed only a plugin that references
|
||
// ZoneManager's types could read the list, so it put the read in the zone helper. That was
|
||
// wrong for 3.1.14 (D211), and a server without the helper still gets every flag.
|
||
|
||
/// <summary>The fields `ZoneFieldListRaw` answers that are not flags, lower-cased.</summary>
|
||
private static readonly HashSet<string> ZoneNonFlagFields = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||
{
|
||
"name", "id", "radius", "rotation", "size", "location", "enter_message", "leave_message",
|
||
"radiation", "comfort", "temperature", "safezone", "permission", "ejectspawns", "parentid", "enabled", "enable"
|
||
};
|
||
|
||
/// <summary>
|
||
/// The settings a step may give a zone, and their bounds. Every other ZoneManager field
|
||
/// (rotation, parent, eject spawns, size) changes what this plugin believes the zone is,
|
||
/// so it stays out (§3.1). The messages are this plugin's own (D193).
|
||
/// </summary>
|
||
private static readonly string[] ZoneSettingKeys = { "radiation", "comfort", "temperature", "safezone", "permission" };
|
||
|
||
private const float ZoneRadiationMax = 500f;
|
||
private const float ZoneComfortMax = 1f;
|
||
private const float ZoneTemperatureLimit = 100f;
|
||
private const int ZoneMessageMax = 256;
|
||
private const int ZoneFlagsMax = 80;
|
||
|
||
/// <summary>ZoneDomes' own sphere types: Standard, Red, Blue, Green, Purple.</summary>
|
||
private const int DomeTypeMax = 4;
|
||
|
||
/// <summary>The most spheres one dome stacks. Each is an entity; ZoneDomes itself has no limit.</summary>
|
||
private const int DomeStackMax = 10;
|
||
|
||
/// <summary>A zone this plugin just put back says nothing to whoever is inside it for this long.</summary>
|
||
private const long ZoneQuietMs = 5000L;
|
||
|
||
/// <summary>Zone id → the moment its enter and leave messages start again. Main thread.</summary>
|
||
private readonly Dictionary<string, long> _zoneQuietUntil = new Dictionary<string, long>();
|
||
|
||
/// <summary>
|
||
/// Main thread. ZoneManager's flag names, as it spells them, or null when it is not loaded
|
||
/// or does not answer. Asked when needed: an operator can update ZoneManager at any time.
|
||
/// </summary>
|
||
private static List<string> ZoneFlagNames(Plugin zm)
|
||
{
|
||
var fields = zm == null ? null : zm.Call("ZoneFieldListRaw") as List<string>;
|
||
|
||
if (fields == null)
|
||
return null;
|
||
|
||
var flags = new List<string>();
|
||
|
||
foreach (string field in fields)
|
||
{
|
||
if (!string.IsNullOrEmpty(field) && !ZoneNonFlagFields.Contains(field))
|
||
flags.Add(field);
|
||
}
|
||
|
||
return flags;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Main thread. Reads a step's flags and settings against the live ZoneManager. Each flag
|
||
/// is returned as ZoneManager spells it; each setting is checked and written as
|
||
/// ZoneManager parses it. False with a refusal on anything unknown or out of range.
|
||
/// </summary>
|
||
private static bool ReadZoneOptions(Plugin zm, Dictionary<string, object> cmd,
|
||
out List<string> flags, out Dictionary<string, string> settings, out string refusal)
|
||
{
|
||
flags = new List<string>();
|
||
settings = new Dictionary<string, string>(StringComparer.Ordinal);
|
||
refusal = null;
|
||
|
||
object raw;
|
||
|
||
if (cmd.TryGetValue("flags", out raw) && raw != null)
|
||
{
|
||
var list = raw as Newtonsoft.Json.Linq.JArray;
|
||
|
||
if (list == null)
|
||
{
|
||
refusal = "a zone's flags are a list of names";
|
||
return false;
|
||
}
|
||
|
||
if (list.Count > ZoneFlagsMax)
|
||
{
|
||
refusal = $"a zone takes at most {ZoneFlagsMax} flags, not {list.Count}";
|
||
return false;
|
||
}
|
||
|
||
List<string> known = ZoneFlagNames(zm);
|
||
|
||
if (known == null)
|
||
{
|
||
refusal = "this server's ZoneManager did not say which flags it has";
|
||
return false;
|
||
}
|
||
|
||
var byName = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||
|
||
foreach (string k in known)
|
||
byName[k] = k;
|
||
|
||
foreach (Newtonsoft.Json.Linq.JToken token in list)
|
||
{
|
||
string name = token.Type == Newtonsoft.Json.Linq.JTokenType.String ? (string)token : null;
|
||
string spelled;
|
||
|
||
if (string.IsNullOrEmpty(name) || !byName.TryGetValue(name.Trim(), out spelled))
|
||
{
|
||
refusal = $"'{name}' is not a flag this server's ZoneManager has";
|
||
return false;
|
||
}
|
||
|
||
if (!flags.Contains(spelled))
|
||
flags.Add(spelled);
|
||
}
|
||
}
|
||
|
||
if (cmd.TryGetValue("settings", out raw) && raw != null)
|
||
{
|
||
var obj = raw as Newtonsoft.Json.Linq.JObject;
|
||
|
||
if (obj == null)
|
||
{
|
||
refusal = "a zone's settings are an object of name and value";
|
||
return false;
|
||
}
|
||
|
||
foreach (Newtonsoft.Json.Linq.JProperty p in obj.Properties())
|
||
{
|
||
string key = p.Name.ToLowerInvariant();
|
||
|
||
if (Array.IndexOf(ZoneSettingKeys, key) < 0)
|
||
{
|
||
refusal = $"'{p.Name}' is not a zone setting this server offers (radiation, comfort, temperature, safezone, permission)";
|
||
return false;
|
||
}
|
||
|
||
// Invariant, because a JSON 0.5 read under the host's culture can come back "0,5".
|
||
var jv = p.Value as Newtonsoft.Json.Linq.JValue;
|
||
string value = jv == null ? null : Convert.ToString(jv.Value, System.Globalization.CultureInfo.InvariantCulture);
|
||
string written;
|
||
|
||
if (!ZoneSettingValue(key, value, out written, out refusal))
|
||
return false;
|
||
|
||
settings[key] = written;
|
||
}
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
private static bool ZoneSettingValue(string key, string value, out string written, out string refusal)
|
||
{
|
||
written = null;
|
||
refusal = null;
|
||
var inv = System.Globalization.CultureInfo.InvariantCulture;
|
||
|
||
if (key == "safezone")
|
||
{
|
||
bool on;
|
||
|
||
if (!bool.TryParse(value, out on))
|
||
{
|
||
refusal = $"safezone is true or false, not '{value}'";
|
||
return false;
|
||
}
|
||
|
||
written = on ? "true" : "false";
|
||
return true;
|
||
}
|
||
|
||
if (key == "permission")
|
||
{
|
||
string perm = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||
|
||
if (perm.StartsWith("zonemanager.", StringComparison.Ordinal))
|
||
perm = perm.Substring("zonemanager.".Length);
|
||
|
||
if (perm.Length == 0 || perm.Length > 32)
|
||
{
|
||
refusal = "a zone's permission is 1 to 32 characters";
|
||
return false;
|
||
}
|
||
|
||
foreach (char c in perm)
|
||
{
|
||
if (!(c >= 'a' && c <= 'z') && !(c >= '0' && c <= '9') && c != '_')
|
||
{
|
||
refusal = $"a zone's permission is letters, digits and _, not '{value}'";
|
||
return false;
|
||
}
|
||
}
|
||
|
||
written = perm;
|
||
return true;
|
||
}
|
||
|
||
float number;
|
||
|
||
if (!float.TryParse(value, System.Globalization.NumberStyles.Float, inv, out number) || float.IsNaN(number) || float.IsInfinity(number))
|
||
{
|
||
refusal = $"{key} is a number, not '{value}'";
|
||
return false;
|
||
}
|
||
|
||
float low = key == "temperature" ? -ZoneTemperatureLimit : 0f;
|
||
float high = key == "radiation" ? ZoneRadiationMax : key == "comfort" ? ZoneComfortMax : ZoneTemperatureLimit;
|
||
|
||
if (number < low || number > high)
|
||
{
|
||
refusal = $"{key} is {low} to {high}, not {number.ToString(inv)}";
|
||
return false;
|
||
}
|
||
|
||
written = number.ToString(inv);
|
||
return true;
|
||
}
|
||
|
||
// ---- the dome (PLAN_REDESIGNS §3.2, D167, D194) ----
|
||
//
|
||
// ZoneDomes draws the dome. Two things it lacks are made up here and in the domes helper,
|
||
// `RunicGatewayDomes.cs`: its API ends in a chat message to the player who asked, which
|
||
// throws for a call with none (the helper patches that), and at its own start it drops
|
||
// every dome whose zone does not exist yet, which is every zone this plugin re-creates.
|
||
// So this plugin puts its domes back after it puts its zones back, and only once ZoneDomes
|
||
// has finished its own start: a dome added before that is drawn twice, and the first set
|
||
// of spheres is left in the world where nothing can remove it. The helper says when.
|
||
|
||
private Plugin ZoneDomesPlugin()
|
||
{
|
||
return plugins.Find("ZoneDomes");
|
||
}
|
||
|
||
private Plugin DomesHelperPlugin()
|
||
{
|
||
return plugins.Find("RunicGatewayDomes");
|
||
}
|
||
|
||
/// <summary>Main thread. <c>patched</c>, <c>unsupported</c>, <c>no-zonedomes</c> or <c>missing</c>.</summary>
|
||
private string DomesHelperState()
|
||
{
|
||
Plugin helper = DomesHelperPlugin();
|
||
return helper == null ? "missing" : helper.Call("State") as string ?? "unsupported";
|
||
}
|
||
|
||
/// <summary>Main thread. Whether a dome may be added now: ZoneDomes loaded, patched, and past its own start.</summary>
|
||
private bool DomesReady()
|
||
{
|
||
Plugin helper = DomesHelperPlugin();
|
||
return ZoneDomesPlugin() != null && helper != null
|
||
&& helper.Call("State") as string == "patched" && helper.Call("Ready") is bool ready && ready;
|
||
}
|
||
|
||
/// <summary>Main thread. Why a dome cannot be added now, or null when it can.</summary>
|
||
private string DomeRefusal()
|
||
{
|
||
if (ZoneDomesPlugin() == null)
|
||
return "ZoneDomes is not loaded on this server, so a zone cannot have a dome";
|
||
|
||
string state = DomesHelperState();
|
||
|
||
if (state == "missing")
|
||
return "RunicGatewayDomes.cs is not installed beside the bridge, and ZoneDomes cannot be called without it";
|
||
|
||
if (state != "patched")
|
||
return $"RunicGatewayDomes.cs could not patch this ZoneDomes ({state})";
|
||
|
||
return DomesReady() ? null : "ZoneDomes has not finished starting";
|
||
}
|
||
|
||
/// <summary>Main thread. Adds a zone's dome. True when ZoneDomes made it, or already had it.</summary>
|
||
private bool AddDome(WorldEntry e)
|
||
{
|
||
Plugin zd = ZoneDomesPlugin();
|
||
|
||
if (zd == null || !e.Dome)
|
||
return false;
|
||
|
||
object made = zd.Call("AddNewDome", null, e.Id, e.DomeType, e.DomeStack);
|
||
return made is bool && (bool)made;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Main thread. Takes a zone's dome out of the world and out of ZoneDomes' data file.
|
||
/// Called BEFORE the zone is erased, so a dome never outlives its zone.
|
||
/// </summary>
|
||
private void RemoveDome(WorldEntry e)
|
||
{
|
||
Plugin zd = ZoneDomesPlugin();
|
||
|
||
if (zd == null || !e.Dome)
|
||
return;
|
||
|
||
// Without the helper the call still removes the dome and then throws on its message;
|
||
// the framework logs that, and the dome is gone either way.
|
||
zd.Call("RemoveExistingDome", null, e.Id);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Main thread. Puts back the dome of every standing zone that has one. ZoneDomes answers
|
||
/// false for a dome it already has, so this is safe to repeat.
|
||
/// </summary>
|
||
private void RestoreDomes(string why)
|
||
{
|
||
if (!_worldReady || !DomesReady())
|
||
return;
|
||
|
||
Plugin zm = ZoneManagerPlugin();
|
||
|
||
if (zm == null)
|
||
return;
|
||
|
||
int added = 0;
|
||
|
||
foreach (WorldEntry e in _world.Values)
|
||
{
|
||
if (e.Kind != WorldKindZone || !e.Dome || ZoneExpired(e) || zm.Call("CheckZoneID", e.Id) == null)
|
||
continue;
|
||
|
||
if (AddDome(e))
|
||
added++;
|
||
}
|
||
|
||
if (added > 0)
|
||
{
|
||
_lastWorldSummary = $"domes after {why}: {added} put back";
|
||
Puts($"world: {_lastWorldSummary}");
|
||
}
|
||
}
|
||
|
||
/// <summary>Raised by the domes helper when ZoneDomes has finished its own start.</summary>
|
||
private void OnRgDomesReady()
|
||
{
|
||
MarkHook("OnRgDomesReady");
|
||
NextTick(() => RestoreDomes("ZoneDomes started"));
|
||
}
|
||
|
||
// ---- the zone's messages (D193) ----
|
||
//
|
||
// ZoneManager can say a zone's enter and leave messages itself, but only to chat unless its
|
||
// own config turns popups on for every zone on the server. So this plugin never gives it
|
||
// the messages for its own zones, and says them here, as each zone's step chose.
|
||
|
||
private void OnEnterZone(string zoneId, BasePlayer player)
|
||
{
|
||
MarkHook("OnEnterZone");
|
||
SayZoneMessage(zoneId, player, true);
|
||
}
|
||
|
||
private void OnExitZone(string zoneId, BasePlayer player)
|
||
{
|
||
MarkHook("OnExitZone");
|
||
SayZoneMessage(zoneId, player, false);
|
||
}
|
||
|
||
private void SayZoneMessage(string zoneId, BasePlayer player, bool entering)
|
||
{
|
||
WorldEntry e;
|
||
|
||
if (zoneId == null || player == null || !player.IsConnected || !_world.TryGetValue(zoneId, out e) || e.Kind != WorldKindZone)
|
||
return;
|
||
|
||
string message = entering ? e.EnterMessage : e.LeaveMessage;
|
||
|
||
if (string.IsNullOrEmpty(message))
|
||
return;
|
||
|
||
// A zone this plugin just put back (a restart, a ZoneManager reload) re-enters whoever
|
||
// is standing in it. They were told when they walked in.
|
||
long quiet;
|
||
|
||
if (_zoneQuietUntil.TryGetValue(zoneId, out quiet))
|
||
{
|
||
if (NowMs() < quiet)
|
||
return;
|
||
|
||
_zoneQuietUntil.Remove(zoneId);
|
||
}
|
||
|
||
// A popup where PopupNotifications is loaded, and chat where it is not (§2.4): the
|
||
// step was accepted long before this moment, and a player is told either way.
|
||
Plugin popup = e.Delivery == DeliveryPopup ? PopupPlugin() : null;
|
||
|
||
if (popup != null)
|
||
popup.Call("CreatePopupNotification", message, player, 0f);
|
||
else
|
||
player.ChatMessage(e.Format == null ? message : Voice(e.Format, message));
|
||
}
|
||
|
||
/// <summary>
|
||
/// The map's monuments in the one order every caller agrees on: grouped by prefab short
|
||
/// name, then by position. <c>#n</c> in a value counts within its group, so it names the
|
||
/// same monument every time it is asked on this map (D93).
|
||
/// </summary>
|
||
private static List<KeyValuePair<string, List<MonumentInfo>>> MonumentGroups()
|
||
{
|
||
var byKind = new SortedDictionary<string, List<MonumentInfo>>(StringComparer.Ordinal);
|
||
List<MonumentInfo> all = TerrainMeta.Path != null ? TerrainMeta.Path.Monuments : null;
|
||
|
||
if (all != null)
|
||
{
|
||
foreach (MonumentInfo m in all)
|
||
{
|
||
if (m == null || string.IsNullOrEmpty(m.name))
|
||
continue;
|
||
|
||
string kind = Path.GetFileNameWithoutExtension(m.name).ToLowerInvariant();
|
||
List<MonumentInfo> list;
|
||
|
||
if (!byKind.TryGetValue(kind, out list))
|
||
{
|
||
list = new List<MonumentInfo>();
|
||
byKind[kind] = list;
|
||
}
|
||
|
||
list.Add(m);
|
||
}
|
||
}
|
||
|
||
var groups = new List<KeyValuePair<string, List<MonumentInfo>>>();
|
||
|
||
foreach (KeyValuePair<string, List<MonumentInfo>> kv in byKind)
|
||
{
|
||
kv.Value.Sort((a, b) =>
|
||
{
|
||
int cx = a.transform.position.x.CompareTo(b.transform.position.x);
|
||
return cx != 0 ? cx : a.transform.position.z.CompareTo(b.transform.position.z);
|
||
});
|
||
|
||
groups.Add(kv);
|
||
}
|
||
|
||
return groups;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Main thread. A monument value — <c>kind</c> or <c>kind#n</c> — to the monument it
|
||
/// names on this map, or null with a reason. A bare kind that repeats is its first
|
||
/// instance, so a step written against a map where it was unique still means something
|
||
/// on one where it is not.
|
||
/// </summary>
|
||
private static MonumentInfo ResolveMonument(string value, out string refusal)
|
||
{
|
||
refusal = null;
|
||
string text = Lower(value);
|
||
|
||
if (string.IsNullOrEmpty(text))
|
||
{
|
||
refusal = "no monument was named";
|
||
return null;
|
||
}
|
||
|
||
int instance = 1;
|
||
string kind = text;
|
||
int hash = text.LastIndexOf('#');
|
||
|
||
if (hash > 0)
|
||
{
|
||
kind = text.Substring(0, hash);
|
||
|
||
if (!int.TryParse(text.Substring(hash + 1), out instance) || instance < 1)
|
||
{
|
||
refusal = $"'{value}' is not a monument, followed by an instance number";
|
||
return null;
|
||
}
|
||
}
|
||
|
||
foreach (KeyValuePair<string, List<MonumentInfo>> kv in MonumentGroups())
|
||
{
|
||
if (kv.Key != kind)
|
||
continue;
|
||
|
||
if (instance > kv.Value.Count)
|
||
{
|
||
refusal = $"this map has {kv.Value.Count} {kind}, so there is no #{instance}";
|
||
return null;
|
||
}
|
||
|
||
return kv.Value[instance - 1];
|
||
}
|
||
|
||
refusal = $"this map has no {kind}";
|
||
return null;
|
||
}
|
||
|
||
private static double? DoubleField(Dictionary<string, object> row, string key)
|
||
{
|
||
object value;
|
||
|
||
if (row == null || !row.TryGetValue(key, out value) || value == null)
|
||
return null;
|
||
|
||
double parsed;
|
||
return double.TryParse(
|
||
value.ToString(), System.Globalization.NumberStyles.Float,
|
||
System.Globalization.CultureInfo.InvariantCulture, out parsed) && !double.IsNaN(parsed) && !double.IsInfinity(parsed)
|
||
? parsed
|
||
: (double?)null;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Main thread. Where a step puts its thing: a monument (plus an offset), or raw world
|
||
/// coordinates — exactly one of the two (D87). Returns false with a reason and the
|
||
/// refusal code that goes with it.
|
||
/// </summary>
|
||
private bool ResolveWorldPoint(Dictionary<string, object> cmd, out UnityEngine.Vector3 point, out string reason, out string refusal)
|
||
{
|
||
point = UnityEngine.Vector3.zero;
|
||
reason = null;
|
||
refusal = null;
|
||
|
||
string monument = StringField(cmd, "monument");
|
||
double? x = DoubleField(cmd, "x");
|
||
double? z = DoubleField(cmd, "z");
|
||
double? y = DoubleField(cmd, "y");
|
||
bool byMonument = !string.IsNullOrEmpty(monument);
|
||
bool byCoords = x.HasValue || z.HasValue;
|
||
|
||
if (byMonument == byCoords)
|
||
{
|
||
reason = "malformed";
|
||
refusal = "a location is a monument or x and z, and exactly one of them";
|
||
return false;
|
||
}
|
||
|
||
if (byMonument)
|
||
{
|
||
MonumentInfo m = ResolveMonument(monument, out refusal);
|
||
|
||
if (m == null)
|
||
{
|
||
reason = "no-monument";
|
||
return false;
|
||
}
|
||
|
||
double dx = DoubleField(cmd, "offsetX") ?? 0.0;
|
||
double dz = DoubleField(cmd, "offsetZ") ?? 0.0;
|
||
|
||
if (Math.Sqrt(dx * dx + dz * dz) > WorldMaxOffset)
|
||
{
|
||
reason = "out-of-range";
|
||
refusal = $"an offset from a monument is at most {WorldMaxOffset} m";
|
||
return false;
|
||
}
|
||
|
||
point = m.transform.position + new UnityEngine.Vector3((float)dx, 0f, (float)dz);
|
||
point.y = TerrainMeta.HeightMap.GetHeight(point);
|
||
return true;
|
||
}
|
||
|
||
if (!x.HasValue || !z.HasValue)
|
||
{
|
||
reason = "malformed";
|
||
refusal = "coordinates need both x and z";
|
||
return false;
|
||
}
|
||
|
||
float half = ConVar.Server.worldsize / 2f;
|
||
|
||
if (Math.Abs(x.Value) > half || Math.Abs(z.Value) > half)
|
||
{
|
||
reason = "off-map";
|
||
refusal = $"({x.Value:0}, {z.Value:0}) is off this map, which runs from -{half:0} to {half:0}";
|
||
return false;
|
||
}
|
||
|
||
point = new UnityEngine.Vector3((float)x.Value, 0f, (float)z.Value);
|
||
point.y = y.HasValue ? (float)y.Value : TerrainMeta.HeightMap.GetHeight(point);
|
||
return true;
|
||
}
|
||
|
||
/// <summary>Main thread. Refuses a world command until the world is loaded (see <c>_worldReady</c>).</summary>
|
||
private bool WorldReady(string reqId)
|
||
{
|
||
if (_worldReady)
|
||
return true;
|
||
|
||
EmitWorldError(reqId, "not-ready",
|
||
"the server is still loading its world; ask again once it has started");
|
||
return false;
|
||
}
|
||
|
||
private bool WorldWritesAllowed(string reqId)
|
||
{
|
||
if (!WorldReady(reqId))
|
||
return false;
|
||
|
||
if (_config != null && _config.EventsEnabled)
|
||
return true;
|
||
|
||
// One consent for everything an event does to this world unattended (D94). Giving
|
||
// back, listing and asking what is still out there are never behind it.
|
||
EmitWorldError(reqId, "events-disabled",
|
||
"events are switched off on this server — set EventsEnabled in the RunicGateway plugin config");
|
||
return false;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Main thread. The ids a key already made, or null. A repeat of a key the registry has
|
||
/// seen is a retry whose first answer was lost, and it is answered with the first
|
||
/// call's ids rather than a second set of things (§28.1).
|
||
/// </summary>
|
||
private List<WorldEntry> EntriesForKey(string key)
|
||
{
|
||
if (string.IsNullOrEmpty(key))
|
||
return null;
|
||
|
||
List<WorldEntry> found = null;
|
||
|
||
foreach (WorldEntry e in _world.Values)
|
||
{
|
||
if (e.Key != key)
|
||
continue;
|
||
|
||
if (found == null)
|
||
found = new List<WorldEntry>();
|
||
|
||
found.Add(e);
|
||
}
|
||
|
||
return found;
|
||
}
|
||
|
||
private void HandleWorldMonuments(string reqId)
|
||
{
|
||
if (!WorldReady(reqId))
|
||
return;
|
||
|
||
var rows = new List<object>();
|
||
|
||
foreach (KeyValuePair<string, List<MonumentInfo>> kv in MonumentGroups())
|
||
{
|
||
for (int i = 0; i < kv.Value.Count; i++)
|
||
{
|
||
MonumentInfo m = kv.Value[i];
|
||
UnityEngine.Vector3 p = m.transform.position;
|
||
string phrase = m.displayPhrase != null ? m.displayPhrase.english : null;
|
||
|
||
var row = new Dictionary<string, object>
|
||
{
|
||
{ "value", kv.Value.Count > 1 ? kv.Key + "#" + (i + 1) : kv.Key },
|
||
{ "kind", kv.Key },
|
||
{ "instance", i + 1 },
|
||
{ "of", kv.Value.Count },
|
||
{ "label", string.IsNullOrEmpty(phrase) ? kv.Key : phrase },
|
||
{ "x", Math.Round(p.x, 1) },
|
||
{ "z", Math.Round(p.z, 1) },
|
||
{ "grid", Grid(p) }
|
||
};
|
||
|
||
rows.Add(row);
|
||
}
|
||
}
|
||
|
||
Dictionary<string, object> reply = Frame("world.monuments", "reply");
|
||
reply["worldSize"] = ConVar.Server.worldsize;
|
||
reply["monuments"] = rows;
|
||
reply["prefabs"] = WorldPrefabRows();
|
||
reply["eventsEnabled"] = _config != null && _config.EventsEnabled;
|
||
reply["maxCrates"] = _config != null ? _config.EventsMaxCrates : WorldHardMaxCrates;
|
||
reply["maxNpcs"] = _config != null ? _config.EventsMaxNpcs : WorldHardMaxNpcs;
|
||
reply["zoneManager"] = ZoneManagerPlugin() != null;
|
||
|
||
if (reqId != null)
|
||
reply["reqId"] = reqId;
|
||
|
||
EmitFrame(reply);
|
||
}
|
||
|
||
private static List<object> WorldPrefabRows()
|
||
{
|
||
var rows = new List<object>();
|
||
|
||
foreach (WorldPrefab p in WorldPrefabs)
|
||
{
|
||
rows.Add(new Dictionary<string, object>
|
||
{
|
||
{ "key", p.Key },
|
||
{ "kind", p.Kind },
|
||
{ "label", p.Label }
|
||
});
|
||
}
|
||
|
||
return rows;
|
||
}
|
||
|
||
/// <summary>Main thread. Open a zone an event owns.</summary>
|
||
private void HandleWorldZone(Dictionary<string, object> cmd, string reqId)
|
||
{
|
||
if (!WorldWritesAllowed(reqId))
|
||
return;
|
||
|
||
string key = StringField(cmd, "key");
|
||
string runId = StringField(cmd, "runId");
|
||
List<WorldEntry> repeat = EntriesForKey(key);
|
||
|
||
if (repeat != null)
|
||
{
|
||
EmitWorldPlaced(reqId, repeat, true);
|
||
return;
|
||
}
|
||
|
||
if (string.IsNullOrEmpty(runId))
|
||
{
|
||
EmitWorldError(reqId, "malformed", "a zone is opened for a run");
|
||
return;
|
||
}
|
||
|
||
Plugin zm = ZoneManagerPlugin();
|
||
|
||
if (zm == null)
|
||
{
|
||
EmitWorldError(reqId, "zonemanager-missing", "ZoneManager is not loaded on this server");
|
||
return;
|
||
}
|
||
|
||
double radius = DoubleField(cmd, "radius") ?? 0.0;
|
||
|
||
if (radius < ZoneMinRadius || radius > ZoneMaxRadius)
|
||
{
|
||
EmitWorldError(reqId, "out-of-range",
|
||
$"a zone's radius is {ZoneMinRadius} to {ZoneMaxRadius} m, not {radius}");
|
||
return;
|
||
}
|
||
|
||
UnityEngine.Vector3 point;
|
||
string reason, refusal;
|
||
|
||
if (!ResolveWorldPoint(cmd, out point, out reason, out refusal))
|
||
{
|
||
EmitWorldError(reqId, reason, refusal);
|
||
return;
|
||
}
|
||
|
||
long holdMs = LongField(cmd, "holdMs");
|
||
|
||
if (holdMs <= 0 || holdMs > ZoneMaxHoldMs)
|
||
{
|
||
EmitWorldError(reqId, "out-of-range",
|
||
$"a zone stands for 1 minute to {ZoneMaxHoldMs / 60000L} minutes, not {holdMs / 60000L}");
|
||
return;
|
||
}
|
||
|
||
string name = StringField(cmd, "name");
|
||
|
||
if (string.IsNullOrEmpty(name))
|
||
name = "Event zone";
|
||
|
||
if (name.Length > 64)
|
||
name = name.Substring(0, 64);
|
||
|
||
// PLAN_REDESIGNS §3.1: the flags and settings, each checked against this server's own
|
||
// ZoneManager. An unknown one is refused, never dropped: a zone that quietly lacks the
|
||
// "no building" its author chose is worse than a step that fails and says why.
|
||
List<string> flags;
|
||
Dictionary<string, string> settings;
|
||
string optionRefusal;
|
||
|
||
if (!ReadZoneOptions(zm, cmd, out flags, out settings, out optionRefusal))
|
||
{
|
||
EmitWorldError(reqId, "bad-option", optionRefusal);
|
||
return;
|
||
}
|
||
|
||
// D193: what a player is told on the way in and out, and whether as chat or a popup.
|
||
string enterMessage = StringField(cmd, "enterMessage");
|
||
string leaveMessage = StringField(cmd, "leaveMessage");
|
||
|
||
if ((enterMessage != null && enterMessage.Length > ZoneMessageMax) || (leaveMessage != null && leaveMessage.Length > ZoneMessageMax))
|
||
{
|
||
EmitWorldError(reqId, "out-of-range", $"a zone's message is at most {ZoneMessageMax} characters");
|
||
return;
|
||
}
|
||
|
||
string delivery = StringField(cmd, "delivery");
|
||
|
||
if (string.IsNullOrEmpty(delivery))
|
||
delivery = DeliveryChat;
|
||
|
||
if (delivery != DeliveryChat && delivery != DeliveryPopup)
|
||
{
|
||
EmitWorldError(reqId, "malformed", $"a zone's messages go to chat or to a popup, not to '{delivery}'");
|
||
return;
|
||
}
|
||
|
||
string format = StringField(cmd, "format");
|
||
|
||
if (format != null && (format.Length > ChatFormatMax || Occurrences(format, "{message}") != 1))
|
||
{
|
||
EmitWorldError(reqId, "malformed", $"a chat format holds exactly one {{message}} and at most {ChatFormatMax} characters");
|
||
return;
|
||
}
|
||
|
||
// D194: a dome, where ZoneDomes and the domes helper are both here.
|
||
bool dome = false;
|
||
int domeType = 0, domeStack = 1;
|
||
object domeRaw;
|
||
|
||
if (cmd.TryGetValue("dome", out domeRaw) && domeRaw != null)
|
||
{
|
||
var d = domeRaw as Newtonsoft.Json.Linq.JObject;
|
||
|
||
if (d == null)
|
||
{
|
||
EmitWorldError(reqId, "malformed", "a zone's dome is an object of type and stack");
|
||
return;
|
||
}
|
||
|
||
Newtonsoft.Json.Linq.JToken t = d["type"], s = d["stack"];
|
||
// No type is Standard, the one full dome; the colours show only where they meet
|
||
// the ground (D212).
|
||
domeType = t == null ? 0 : t.Type == Newtonsoft.Json.Linq.JTokenType.Integer ? (int)t : -1;
|
||
domeStack = s == null ? 1 : s.Type == Newtonsoft.Json.Linq.JTokenType.Integer ? (int)s : -1;
|
||
|
||
if (domeType < 0 || domeType > DomeTypeMax || domeStack < 1 || domeStack > DomeStackMax)
|
||
{
|
||
EmitWorldError(reqId, "out-of-range", $"a dome's type is 0 to {DomeTypeMax} and its stack 1 to {DomeStackMax}");
|
||
return;
|
||
}
|
||
|
||
string noDome = DomeRefusal();
|
||
|
||
if (noDome != null)
|
||
{
|
||
EmitWorldError(reqId, "dome-unavailable", noDome);
|
||
return;
|
||
}
|
||
|
||
dome = true;
|
||
}
|
||
|
||
var entry = new WorldEntry
|
||
{
|
||
Id = $"rg-{ShortRun(runId)}-{NowMs() % 100000000L}-{++_zoneSerial}",
|
||
Kind = WorldKindZone,
|
||
RunId = runId,
|
||
Key = key,
|
||
X = point.x,
|
||
Y = point.y,
|
||
Z = point.z,
|
||
Radius = (float)radius,
|
||
Name = name,
|
||
Flags = flags.Count > 0 ? flags : null,
|
||
Settings = settings.Count > 0 ? settings : null,
|
||
EnterMessage = string.IsNullOrEmpty(enterMessage) ? null : enterMessage,
|
||
LeaveMessage = string.IsNullOrEmpty(leaveMessage) ? null : leaveMessage,
|
||
Delivery = delivery,
|
||
Format = delivery == DeliveryChat ? format : null,
|
||
Dome = dome,
|
||
DomeType = domeType,
|
||
DomeStack = domeStack,
|
||
DeadlineMs = NowMs() + holdMs,
|
||
BootId = _bootId,
|
||
WipeId = _wipeId,
|
||
TakenMs = NowMs()
|
||
};
|
||
|
||
if (!CreateZone(zm, entry))
|
||
{
|
||
EmitWorldError(reqId, "refused", "ZoneManager did not create the zone");
|
||
return;
|
||
}
|
||
|
||
// The dome after the zone, because ZoneDomes reads the zone's place and size from
|
||
// ZoneManager. A dome that is not made takes its zone with it: the step asked for both.
|
||
if (dome && !AddDome(entry))
|
||
{
|
||
zm.Call("EraseTemporaryZone", this, entry.Id);
|
||
EmitWorldError(reqId, "dome-unavailable", "ZoneDomes did not make the dome, so the zone was not opened");
|
||
return;
|
||
}
|
||
|
||
_world[entry.Id] = entry;
|
||
SaveWorld();
|
||
EnsureWorldTimer();
|
||
|
||
_lastWorldSummary = $"zone {entry.Id} r={radius:0} for {holdMs / 60000L} min, {flags.Count} flag(s){(dome ? ", domed" : "")}, run {runId}";
|
||
Puts($"world: {_lastWorldSummary}");
|
||
EmitWorldPlaced(reqId, new List<WorldEntry> { entry }, false);
|
||
}
|
||
|
||
private static string ShortRun(string runId)
|
||
{
|
||
var chars = new StringBuilder();
|
||
|
||
foreach (char c in runId ?? "")
|
||
{
|
||
if (char.IsLetterOrDigit(c))
|
||
chars.Append(c);
|
||
|
||
if (chars.Length >= 12)
|
||
break;
|
||
}
|
||
|
||
return chars.Length > 0 ? chars.ToString() : "run";
|
||
}
|
||
|
||
/// <summary>
|
||
/// Main thread. Creates, or re-creates, one zone from its record, and says whether
|
||
/// ZoneManager now has it. Our plugin is the owner, so ZoneManager itself refuses to
|
||
/// let another plugin's zone be overwritten under the same id.
|
||
/// </summary>
|
||
private bool CreateZone(Plugin zm, WorldEntry entry)
|
||
{
|
||
var args = new List<string>
|
||
{
|
||
"name", entry.Name ?? "Event zone",
|
||
"radius", entry.Radius.ToString(System.Globalization.CultureInfo.InvariantCulture)
|
||
};
|
||
|
||
// ZoneManager's own key/value arguments (§3.1): each setting by its key, and each flag
|
||
// as "<flag>", "true". Kept on the record, so a zone put back after a restart or a
|
||
// ZoneManager reload gets them back. A flag a newer ZoneManager no longer has is
|
||
// ignored by ZoneManager rather than failing the zone.
|
||
if (entry.Settings != null)
|
||
{
|
||
foreach (KeyValuePair<string, string> kv in entry.Settings)
|
||
{
|
||
args.Add(kv.Key);
|
||
args.Add(kv.Value);
|
||
}
|
||
}
|
||
|
||
if (entry.Flags != null)
|
||
{
|
||
foreach (string flag in entry.Flags)
|
||
{
|
||
args.Add(flag);
|
||
args.Add("true");
|
||
}
|
||
}
|
||
|
||
object created = zm.Call("CreateOrUpdateTemporaryZone", this, entry.Id, args.ToArray(), new UnityEngine.Vector3(entry.X, entry.Y, entry.Z));
|
||
|
||
return created is bool && (bool)created && zm.Call("CheckZoneID", entry.Id) != null;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Main thread. Puts back every zone the registry holds that ZoneManager does not (D92).
|
||
/// Called on this plugin's load, on ZoneManager's, and from <c>world.owned</c>.
|
||
/// </summary>
|
||
private void RecreateZones(string why)
|
||
{
|
||
// At a real start ZoneManager loads before the world does and initialises its own
|
||
// zones at OnTerrainInitialized; ours wait for OnServerInitialized, which calls here.
|
||
if (!_worldReady)
|
||
return;
|
||
|
||
Plugin zm = ZoneManagerPlugin();
|
||
|
||
if (zm == null)
|
||
return;
|
||
|
||
int restored = 0, failed = 0;
|
||
|
||
foreach (WorldEntry e in _world.Values)
|
||
{
|
||
// A zone past its deadline is the timer's to end, not this loop's to revive.
|
||
if (e.Kind != WorldKindZone || ZoneExpired(e) || zm.Call("CheckZoneID", e.Id) != null)
|
||
continue;
|
||
|
||
if (CreateZone(zm, e))
|
||
{
|
||
restored++;
|
||
_zoneQuietUntil[e.Id] = NowMs() + ZoneQuietMs;
|
||
}
|
||
else
|
||
{
|
||
failed++;
|
||
}
|
||
}
|
||
|
||
if (restored > 0 || failed > 0)
|
||
{
|
||
_lastWorldSummary = $"zones after {why}: {restored} re-created, {failed} failed";
|
||
Puts($"world: {_lastWorldSummary}");
|
||
}
|
||
|
||
// The domes after the zones (D194). At a real start ZoneDomes has not finished its own
|
||
// start yet, and the helper's ready hook brings this back when it has.
|
||
RestoreDomes(why);
|
||
}
|
||
|
||
/// <summary>Main thread. Place crates or NPCs an event owns, one registry entry per net id.</summary>
|
||
private void HandleWorldPlace(Dictionary<string, object> cmd, string reqId)
|
||
{
|
||
if (!WorldWritesAllowed(reqId))
|
||
return;
|
||
|
||
string key = StringField(cmd, "key");
|
||
string runId = StringField(cmd, "runId");
|
||
List<WorldEntry> repeat = EntriesForKey(key);
|
||
|
||
if (repeat != null)
|
||
{
|
||
EmitWorldPlaced(reqId, repeat, true);
|
||
return;
|
||
}
|
||
|
||
if (string.IsNullOrEmpty(runId))
|
||
{
|
||
EmitWorldError(reqId, "malformed", "a placement is made for a run");
|
||
return;
|
||
}
|
||
|
||
WorldPrefab prefab = FindWorldPrefab(StringField(cmd, "prefab"));
|
||
|
||
if (prefab == null)
|
||
{
|
||
EmitWorldError(reqId, "unknown-prefab",
|
||
$"'{StringField(cmd, "prefab")}' is not something this server places for events");
|
||
return;
|
||
}
|
||
|
||
int count = IntField(cmd, "count");
|
||
int max = prefab.Kind == WorldKindNpc
|
||
? (_config != null ? _config.EventsMaxNpcs : WorldHardMaxNpcs)
|
||
: (_config != null ? _config.EventsMaxCrates : WorldHardMaxCrates);
|
||
|
||
if (count < 1 || count > max)
|
||
{
|
||
EmitWorldError(reqId, "out-of-range",
|
||
$"this server places 1 to {max} {(prefab.Kind == WorldKindNpc ? "NPCs" : "crates")} at a time, not {count}");
|
||
return;
|
||
}
|
||
|
||
double spread = DoubleField(cmd, "spread") ?? (count > 1 ? 10.0 : 0.0);
|
||
|
||
if (spread < 0 || spread > WorldMaxSpread)
|
||
{
|
||
EmitWorldError(reqId, "out-of-range", $"a scatter is 0 to {WorldMaxSpread} m, not {spread}");
|
||
return;
|
||
}
|
||
|
||
UnityEngine.Vector3 center;
|
||
string reason, refusal;
|
||
|
||
if (!ResolveWorldPoint(cmd, out center, out reason, out refusal))
|
||
{
|
||
EmitWorldError(reqId, reason, refusal);
|
||
return;
|
||
}
|
||
|
||
bool explicitY = DoubleField(cmd, "y").HasValue;
|
||
var made = new List<WorldEntry>();
|
||
|
||
for (int i = 0; i < count; i++)
|
||
{
|
||
UnityEngine.Vector3 at = center;
|
||
|
||
if (count > 1)
|
||
{
|
||
UnityEngine.Vector2 jitter = UnityEngine.Random.insideUnitCircle * (float)spread;
|
||
at += new UnityEngine.Vector3(jitter.x, 0f, jitter.y);
|
||
|
||
if (!explicitY)
|
||
at.y = TerrainMeta.HeightMap.GetHeight(at);
|
||
}
|
||
|
||
BaseEntity entity = GameManager.server.CreateEntity(prefab.Path, at);
|
||
|
||
if (entity == null)
|
||
{
|
||
// All or nothing: a group half of which exists is not what the step asked for,
|
||
// and the half that does exist has no record yet to be given back through.
|
||
foreach (WorldEntry e in made)
|
||
KillEntity(e.Id);
|
||
|
||
EmitWorldError(reqId, "refused", $"the game could not create {prefab.Label}");
|
||
return;
|
||
}
|
||
|
||
entity.Spawn();
|
||
|
||
made.Add(new WorldEntry
|
||
{
|
||
Id = entity.net.ID.Value.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||
Kind = prefab.Kind,
|
||
Prefab = prefab.Key,
|
||
RunId = runId,
|
||
Key = key,
|
||
X = at.x,
|
||
Y = at.y,
|
||
Z = at.z,
|
||
BootId = _bootId,
|
||
WipeId = _wipeId,
|
||
TakenMs = NowMs()
|
||
});
|
||
}
|
||
|
||
foreach (WorldEntry e in made)
|
||
_world[e.Id] = e;
|
||
|
||
SaveWorld();
|
||
|
||
_lastWorldSummary = $"placed {count} {prefab.Key} for run {runId}";
|
||
Puts($"world: {_lastWorldSummary}");
|
||
EmitWorldPlaced(reqId, made, false);
|
||
}
|
||
|
||
private static BaseNetworkable FindEntity(string id)
|
||
{
|
||
ulong value;
|
||
|
||
if (!ulong.TryParse(id, out value))
|
||
return null;
|
||
|
||
BaseNetworkable found = BaseNetworkable.serverEntities.Find(new NetworkableId(value));
|
||
return found != null && !found.IsDestroyed ? found : null;
|
||
}
|
||
|
||
/// <summary>Main thread. Whether the entity was there to kill; either way it is not now.</summary>
|
||
private static bool KillEntity(string id)
|
||
{
|
||
BaseNetworkable found = FindEntity(id);
|
||
|
||
if (found == null)
|
||
return false;
|
||
|
||
found.Kill();
|
||
return true;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Main thread. Give back what a run owns. Never behind <c>EventsEnabled</c>: switching
|
||
/// events off must not strand anything already placed (D94).
|
||
///
|
||
/// <para>
|
||
/// Named ids first; else everything under the key (core lost the answer to a dispatch
|
||
/// and knows only its key); else everything the run owns. An id already gone is a
|
||
/// success. <b>An id the registry does not hold for this run is refused</b> — this
|
||
/// bridge erases only what it made, whatever ZoneManager would allow (§12.4).
|
||
/// </para>
|
||
/// </summary>
|
||
private void HandleWorldRevert(Dictionary<string, object> cmd, string reqId)
|
||
{
|
||
// Before the world loads, every entity looks gone, and a revert would forget things
|
||
// that are about to exist again.
|
||
if (!WorldReady(reqId))
|
||
return;
|
||
|
||
string runId = StringField(cmd, "runId");
|
||
string key = StringField(cmd, "key");
|
||
var ids = new List<string>(Strings(cmd, "ids"));
|
||
|
||
if (string.IsNullOrEmpty(runId))
|
||
{
|
||
EmitWorldError(reqId, "malformed", "a revert names the run whose things to give back");
|
||
return;
|
||
}
|
||
|
||
var targets = new List<WorldEntry>();
|
||
var refused = new List<object>();
|
||
var gone = new List<object>();
|
||
Plugin zm = ZoneManagerPlugin();
|
||
|
||
if (ids.Count > 0)
|
||
{
|
||
foreach (string id in ids)
|
||
{
|
||
WorldEntry e;
|
||
|
||
if (_world.TryGetValue(id, out e))
|
||
{
|
||
if (e.RunId == runId)
|
||
targets.Add(e);
|
||
else
|
||
refused.Add(id);
|
||
|
||
continue;
|
||
}
|
||
|
||
// Not on record. That is either something a wipe or a prune already took —
|
||
// gone, which is a success — or something this bridge never made. Only a
|
||
// thing that is actually THERE can be the second, and only that is refused.
|
||
bool exists = id.StartsWith("rg-", StringComparison.Ordinal)
|
||
? zm != null && zm.Call("CheckZoneID", id) != null
|
||
: FindEntity(id) != null;
|
||
|
||
(exists ? refused : gone).Add(id);
|
||
}
|
||
}
|
||
else
|
||
{
|
||
foreach (WorldEntry e in _world.Values)
|
||
{
|
||
if (e.RunId == runId && (string.IsNullOrEmpty(key) || e.Key == key))
|
||
targets.Add(e);
|
||
}
|
||
}
|
||
|
||
var removed = new List<object>();
|
||
|
||
foreach (WorldEntry e in targets)
|
||
{
|
||
bool wasThere;
|
||
|
||
if (e.Kind == WorldKindZone)
|
||
{
|
||
// A zone ZoneManager does not have (unloaded, or already erased) is gone —
|
||
// it lives in memory only — and its record is all there is left to remove.
|
||
wasThere = zm != null && zm.Call("CheckZoneID", e.Id) != null;
|
||
|
||
// The dome first: ZoneDomes keeps it in its own data file, and a dome
|
||
// left there outlives the zone it was drawn over (D194).
|
||
RemoveDome(e);
|
||
|
||
if (wasThere)
|
||
zm.Call("EraseTemporaryZone", this, e.Id);
|
||
|
||
_zoneQuietUntil.Remove(e.Id);
|
||
}
|
||
else
|
||
{
|
||
wasThere = KillEntity(e.Id);
|
||
}
|
||
|
||
_world.Remove(e.Id);
|
||
(wasThere ? removed : gone).Add(e.Id);
|
||
}
|
||
|
||
if (targets.Count > 0)
|
||
SaveWorld();
|
||
|
||
_lastWorldSummary = $"revert run {runId}: {removed.Count} removed, {gone.Count} already gone, {refused.Count} refused";
|
||
Puts($"world: {_lastWorldSummary}");
|
||
|
||
Dictionary<string, object> reply = Frame("world.ok", "reply");
|
||
reply["removed"] = removed;
|
||
reply["gone"] = gone;
|
||
reply["refused"] = refused;
|
||
|
||
if (reqId != null)
|
||
reply["reqId"] = reqId;
|
||
|
||
EmitFrame(reply);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Main thread. What the world still holds of what events made — looked at, never
|
||
/// inferred (§28.1). An entity that is gone is pruned as the walk passes it. A zone is
|
||
/// put back if ZoneManager has lost it; while ZoneManager is not loaded a zone is
|
||
/// reported held, because "I cannot ask" is not "it is gone", and its load will put
|
||
/// the zone back.
|
||
/// </summary>
|
||
private void HandleWorldOwned(Dictionary<string, object> cmd, string reqId)
|
||
{
|
||
if (!WorldReady(reqId))
|
||
return;
|
||
|
||
string runId = StringField(cmd, "runId");
|
||
RecreateZones("owned");
|
||
|
||
Plugin zm = ZoneManagerPlugin();
|
||
var rows = new List<object>();
|
||
List<string> pruned = null;
|
||
|
||
foreach (WorldEntry e in _world.Values)
|
||
{
|
||
if (!string.IsNullOrEmpty(runId) && e.RunId != runId)
|
||
continue;
|
||
|
||
bool held;
|
||
|
||
if (e.Kind == WorldKindZone)
|
||
held = zm == null || zm.Call("CheckZoneID", e.Id) != null;
|
||
else
|
||
held = FindEntity(e.Id) != null;
|
||
|
||
if (!held)
|
||
{
|
||
if (pruned == null)
|
||
pruned = new List<string>();
|
||
|
||
pruned.Add(e.Id);
|
||
continue;
|
||
}
|
||
|
||
rows.Add(WorldRow(e));
|
||
}
|
||
|
||
if (pruned != null)
|
||
{
|
||
foreach (string id in pruned)
|
||
_world.Remove(id);
|
||
|
||
SaveWorld();
|
||
}
|
||
|
||
Dictionary<string, object> reply = Frame("world.owned", "reply");
|
||
reply["owned"] = rows;
|
||
reply["pruned"] = pruned != null ? pruned.Count : 0;
|
||
|
||
if (reqId != null)
|
||
reply["reqId"] = reqId;
|
||
|
||
EmitFrame(reply);
|
||
}
|
||
|
||
private static Dictionary<string, object> WorldRow(WorldEntry e)
|
||
{
|
||
var row = new Dictionary<string, object>
|
||
{
|
||
{ "id", e.Id },
|
||
{ "kind", e.Kind },
|
||
{ "runId", e.RunId },
|
||
{ "x", Math.Round(e.X, 1) },
|
||
{ "y", Math.Round(e.Y, 1) },
|
||
{ "z", Math.Round(e.Z, 1) }
|
||
};
|
||
|
||
if (!string.IsNullOrEmpty(e.Prefab))
|
||
row["prefab"] = e.Prefab;
|
||
|
||
if (e.Kind == WorldKindZone)
|
||
{
|
||
row["radius"] = e.Radius;
|
||
row["name"] = e.Name;
|
||
row["remainingMs"] = Math.Max(0L, e.DeadlineMs - NowMs());
|
||
}
|
||
|
||
return row;
|
||
}
|
||
|
||
private void EmitWorldPlaced(string reqId, List<WorldEntry> entries, bool repeat)
|
||
{
|
||
var placed = new List<object>();
|
||
|
||
foreach (WorldEntry e in entries)
|
||
placed.Add(WorldRow(e));
|
||
|
||
Dictionary<string, object> reply = Frame("world.ok", "reply");
|
||
reply["placed"] = placed;
|
||
reply["repeat"] = repeat;
|
||
|
||
if (reqId != null)
|
||
reply["reqId"] = reqId;
|
||
|
||
EmitFrame(reply);
|
||
}
|
||
|
||
private void EmitWorldError(string reqId, string reason, string message)
|
||
{
|
||
Dictionary<string, object> frame = Frame("world.error", "reply");
|
||
frame["reason"] = reason;
|
||
|
||
if (!string.IsNullOrEmpty(message))
|
||
frame["message"] = message;
|
||
|
||
if (reqId != null)
|
||
frame["reqId"] = reqId;
|
||
|
||
_lastWorldSummary = $"refused ({reason}): {message}";
|
||
EmitFrame(frame);
|
||
}
|
||
|
||
private string WorldPath()
|
||
{
|
||
return Path.Combine(Interface.Oxide.DataDirectory, Name, "world.json");
|
||
}
|
||
|
||
/// <summary>Main thread. Written on every change, like the lease holds and for the same reason.</summary>
|
||
private void SaveWorld()
|
||
{
|
||
try
|
||
{
|
||
string path = WorldPath();
|
||
Directory.CreateDirectory(Path.GetDirectoryName(path));
|
||
|
||
var rows = new List<WorldEntry>(_world.Values);
|
||
string tmp = path + ".tmp";
|
||
File.WriteAllText(tmp, JsonConvert.SerializeObject(rows, Formatting.Indented), Utf8NoBom);
|
||
|
||
if (File.Exists(path))
|
||
File.Delete(path);
|
||
|
||
File.Move(tmp, path);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Puts($"could not save the world registry: {Flatten(ex.Message)}");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Main thread, once per load. Reads the registry and nothing more: whether each thing is
|
||
/// still out there is asked when somebody wants to know, because at a real server start
|
||
/// this runs before the save has loaded and every entity would look gone.
|
||
/// </summary>
|
||
private void LoadWorld()
|
||
{
|
||
_world = new Dictionary<string, WorldEntry>();
|
||
List<WorldEntry> rows = null;
|
||
|
||
try
|
||
{
|
||
string path = WorldPath();
|
||
|
||
if (File.Exists(path))
|
||
rows = JsonConvert.DeserializeObject<List<WorldEntry>>(File.ReadAllText(path));
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Puts($"could not read the world registry: {Flatten(ex.Message)}");
|
||
}
|
||
|
||
if (rows == null)
|
||
return;
|
||
|
||
foreach (WorldEntry e in rows)
|
||
{
|
||
if (e != null && !string.IsNullOrEmpty(e.Id))
|
||
_world[e.Id] = e;
|
||
}
|
||
|
||
Puts($"world: {_world.Count} things events made are on record");
|
||
|
||
// Due now or later; the first tick ends any zone whose deadline passed while the
|
||
// server was down, before anything could re-create it.
|
||
EnsureWorldTimer();
|
||
}
|
||
|
||
private static bool ZoneExpired(WorldEntry e)
|
||
{
|
||
return e.Kind == WorldKindZone && e.DeadlineMs > 0 && e.DeadlineMs <= NowMs();
|
||
}
|
||
|
||
private void EnsureWorldTimer()
|
||
{
|
||
if (_worldTimer != null)
|
||
return;
|
||
|
||
foreach (WorldEntry e in _world.Values)
|
||
{
|
||
if (e.Kind == WorldKindZone)
|
||
{
|
||
_worldTimer = timer.Every(LeaseCheckSeconds, CheckZoneDeadlines);
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
private void StopWorldTimer()
|
||
{
|
||
if (_worldTimer != null)
|
||
{
|
||
_worldTimer.Destroy();
|
||
_worldTimer = null;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Main thread, every second while a zone stands. The fail-safe D96 bought: a zone whose
|
||
/// time is up is erased without anybody asking, and says so — the same promise a lease
|
||
/// makes, for the same reason.
|
||
/// </summary>
|
||
private void CheckZoneDeadlines()
|
||
{
|
||
List<WorldEntry> due = null;
|
||
bool anyZone = false;
|
||
|
||
foreach (WorldEntry e in _world.Values)
|
||
{
|
||
if (e.Kind != WorldKindZone)
|
||
continue;
|
||
|
||
anyZone = true;
|
||
|
||
if (!ZoneExpired(e))
|
||
continue;
|
||
|
||
if (due == null)
|
||
due = new List<WorldEntry>();
|
||
|
||
due.Add(e);
|
||
}
|
||
|
||
if (!anyZone)
|
||
{
|
||
StopWorldTimer();
|
||
return;
|
||
}
|
||
|
||
if (due == null)
|
||
return;
|
||
|
||
Plugin zm = ZoneManagerPlugin();
|
||
|
||
foreach (WorldEntry e in due)
|
||
{
|
||
RemoveDome(e);
|
||
|
||
if (zm != null && zm.Call("CheckZoneID", e.Id) != null)
|
||
zm.Call("EraseTemporaryZone", this, e.Id);
|
||
|
||
_world.Remove(e.Id);
|
||
_zoneQuietUntil.Remove(e.Id);
|
||
|
||
Dictionary<string, object> frame = Frame("world.expired", "event");
|
||
frame["id"] = e.Id;
|
||
|
||
// `what`, never `kind` (F13, protocol 13). Until 13 this line wrote the entity's
|
||
// kind over the FRAME's, so every expiry left the plugin filed as `kind: "zone"`
|
||
// and nothing listening for `world.expired` ever heard one. `what` is the name the
|
||
// site's resource rows already use for the same thing.
|
||
frame["what"] = e.Kind;
|
||
|
||
if (!string.IsNullOrEmpty(e.RunId))
|
||
frame["runId"] = e.RunId;
|
||
|
||
EmitFrame(frame);
|
||
}
|
||
|
||
SaveWorld();
|
||
_lastWorldSummary = $"{due.Count} zone(s) reached their deadline and were erased";
|
||
Puts($"world: {_lastWorldSummary}");
|
||
}
|
||
|
||
/// <summary>
|
||
/// Main thread. Drops everything recorded against another wipe — that map, and all of
|
||
/// it, is gone. Only callable once the wipe is known, which a real server start does not
|
||
/// know until <c>OnServerInitialized</c>.
|
||
/// </summary>
|
||
private void DropWorldFromOtherWipes()
|
||
{
|
||
if (string.IsNullOrEmpty(_wipeId) || _world.Count == 0)
|
||
return;
|
||
|
||
var stale = new List<string>();
|
||
|
||
foreach (WorldEntry e in _world.Values)
|
||
{
|
||
if (!string.IsNullOrEmpty(e.WipeId) && e.WipeId != _wipeId)
|
||
stale.Add(e.Id);
|
||
}
|
||
|
||
if (stale.Count == 0)
|
||
return;
|
||
|
||
foreach (string id in stale)
|
||
_world.Remove(id);
|
||
|
||
SaveWorld();
|
||
_lastWorldSummary = $"a new wipe dropped {stale.Count} things from the old map";
|
||
Puts($"world: {_lastWorldSummary}");
|
||
}
|
||
|
||
/// <summary>What events have made on this map, from the server console.</summary>
|
||
[ConsoleCommand("rg.world")]
|
||
private void CmdWorld(ConsoleSystem.Arg arg)
|
||
{
|
||
int zones = 0, crates = 0, npcs = 0;
|
||
|
||
foreach (WorldEntry e in _world.Values)
|
||
{
|
||
if (e.Kind == WorldKindZone)
|
||
zones++;
|
||
else if (e.Kind == WorldKindNpc)
|
||
npcs++;
|
||
else
|
||
crates++;
|
||
}
|
||
|
||
arg.ReplyWith(
|
||
$"protocol={ProtocolVersion} eventsEnabled={(_config != null && _config.EventsEnabled)} " +
|
||
$"zones={zones} crates={crates} npcs={npcs} zoneManager={(ZoneManagerPlugin() != null)} " +
|
||
$"wipeId={(_wipeId ?? "none")}" + Environment.NewLine +
|
||
$"last: {_lastWorldSummary}");
|
||
}
|
||
|
||
// ---- the rewards (protocol 10, PLAN.md §29) ----
|
||
//
|
||
// Three things, and the website counts none of them:
|
||
//
|
||
// * **The tally** — who took part in a run. It is counted here because only the game
|
||
// sees a player stand in a zone or land the last hit (D81), and it is persisted,
|
||
// because a restart in the middle of an event must not forget who was there.
|
||
// * **The credits** — each reward is one more use of a kit (D103). The site authors how
|
||
// many a player has earned and pushes them with `perm.sync`; this plugin spends them
|
||
// LAST, from `OnKitRedeemed`, because Kits cannot be told in advance: its
|
||
// `SetPlayerKitUses` does nothing for a player with no usage record of the kit.
|
||
// * **The chat line** — `chat.say`, for a run's `rust.announce` and for the news leg.
|
||
//
|
||
// The zone a tally counts in is asked of ZoneManager on every tick (`GetPlayersInZone`,
|
||
// `IsPlayerInZone`) rather than mirrored from `OnEnterZone`/`OnExitZone`. ZoneManager
|
||
// already holds each zone's members, and a mirror would have to be reseeded every time a
|
||
// zone is re-created (D92) — the tick asks at most `EventsMaxTallies` questions every five
|
||
// seconds, and no chatty hook is subscribed at all (R17).
|
||
|
||
/// <summary>The most tallies that may count at once. <c>EventsMaxTallies</c> may lower it.</summary>
|
||
private const int TallyHardMax = 8;
|
||
|
||
/// <summary>
|
||
/// The most people one reward step may name. The plugin publishes it and the website
|
||
/// refuses a step over it (§29.5): a reward is priced before its tally is read, so this
|
||
/// is the price of every mode that is not a count.
|
||
/// </summary>
|
||
private const int RecipientsHardMax = 100;
|
||
|
||
/// <summary>The longest chat line, in characters. <c>EventsMaxChat</c> may lower it.</summary>
|
||
private const int ChatHardMax = 256;
|
||
|
||
private const float TallyTickSeconds = 5f;
|
||
|
||
/// <summary>
|
||
/// The longest a tally counts, and the longest it is kept at all: seven days from
|
||
/// opening, the lease and zone ceiling (D108). A tally that stopped counting earlier is
|
||
/// kept for the steps that read it after — collect and the reward — until then, and a
|
||
/// teardown closes it sooner. One ceiling for everything, so a website that went away
|
||
/// never leaves a tally on disk for ever.
|
||
/// </summary>
|
||
private const long TallyMaxHoldMs = 7L * 24L * 60L * 60L * 1000L;
|
||
|
||
/// <summary>Core files at most 5 000 participants per step; a tally holds no more.</summary>
|
||
private const int TallyMaxPeople = 5000;
|
||
|
||
/// <summary>
|
||
/// The most one tick may credit. A tick that arrives late accrues the time that really
|
||
/// passed, so a longer interval never rewrites history — but a clock that jumps is not
|
||
/// time anybody spent standing in a zone.
|
||
/// </summary>
|
||
private const long TallyMaxStepMs = 60000L;
|
||
|
||
/// <summary>Every twelfth tick (a minute) writes the file, when anything changed.</summary>
|
||
private const int TallySaveEveryTicks = 12;
|
||
|
||
private const long ChatKeyMemoryMs = 10L * 60L * 1000L;
|
||
|
||
private const string ScoreSeconds = "seconds";
|
||
private const string ScoreKills = "kills";
|
||
private const string ScoreBoth = "both";
|
||
|
||
/// <summary>One person in one tally. Persisted as it is.</summary>
|
||
private sealed class Participant
|
||
{
|
||
public string SteamId;
|
||
public string Name;
|
||
public double Seconds;
|
||
public int Kills;
|
||
public long JoinedMs;
|
||
}
|
||
|
||
/// <summary>One run's tally on this server. There is at most one per run (§29.5).</summary>
|
||
private sealed class RunTally
|
||
{
|
||
public string RunId;
|
||
|
||
/// <summary>The idempotency key of the step that opened it; a repeat is answered, not refused.</summary>
|
||
public string Key;
|
||
|
||
/// <summary>The zone step's <c>name</c>, as the step gave it, or null for the whole server.</summary>
|
||
public string Zone;
|
||
|
||
/// <summary>What that name resolved to when the tally opened (D100).</summary>
|
||
public string ZoneId;
|
||
|
||
public string Score;
|
||
public string KillsOf;
|
||
public double KillWeight;
|
||
|
||
public long OpenedMs;
|
||
|
||
/// <summary>When counting stops. What was counted is kept until seven days after opening (D108).</summary>
|
||
public long DeadlineMs;
|
||
|
||
/// <summary>People who took part after the tally was full, counted so a result can say so.</summary>
|
||
public int Overflow;
|
||
|
||
public Dictionary<string, Participant> People = new Dictionary<string, Participant>();
|
||
|
||
/// <summary>Not persisted: a load starts accruing from the load, never from before a restart.</summary>
|
||
[JsonIgnore]
|
||
public long LastAccrueMs;
|
||
}
|
||
|
||
/// <summary>
|
||
/// One player's credits for one kit (D103). <c>Earned</c> is the site's; the other two
|
||
/// are this plugin's own record of what it did with them.
|
||
/// </summary>
|
||
private sealed class KitCredit
|
||
{
|
||
public string SteamId;
|
||
public string Kit;
|
||
|
||
/// <summary>How many the site says this player has earned. Authored there, pushed here.</summary>
|
||
public int Earned;
|
||
|
||
/// <summary>How many have been moved into Kits' counter — each one a use count lowered by one.</summary>
|
||
public int Applied;
|
||
|
||
/// <summary>How many of those moved uses have since been redeemed.</summary>
|
||
public int Consumed;
|
||
}
|
||
|
||
private Dictionary<string, RunTally> _runTallies = new Dictionary<string, RunTally>();
|
||
private Dictionary<string, KitCredit> _credits = new Dictionary<string, KitCredit>();
|
||
|
||
/// <summary>Recently said chat keys, and when. Main thread only.</summary>
|
||
private readonly Dictionary<string, long> _chatKeys = new Dictionary<string, long>();
|
||
|
||
private Timer _tallyTimer;
|
||
private int _tallyTicks;
|
||
private bool _talliesDirty;
|
||
private string _lastRewardSummary = "none yet";
|
||
|
||
private int MaxTallies()
|
||
{
|
||
return _config != null ? _config.EventsMaxTallies : TallyHardMax;
|
||
}
|
||
|
||
private int MaxRecipients()
|
||
{
|
||
return _config != null ? _config.EventsMaxRecipients : RecipientsHardMax;
|
||
}
|
||
|
||
private int MaxChat()
|
||
{
|
||
return _config != null ? _config.EventsMaxChat : ChatHardMax;
|
||
}
|
||
|
||
private static bool Counting(RunTally t, long now)
|
||
{
|
||
return now < t.DeadlineMs;
|
||
}
|
||
|
||
/// <summary>Whether a player is somebody a tally counts: a real, connected, living, awake person.</summary>
|
||
private static bool CountsAsPresent(BasePlayer p)
|
||
{
|
||
return p != null && !p.IsNpc && p.IsConnected && !p.IsDead() && !p.IsSleeping();
|
||
}
|
||
|
||
private static Participant PersonIn(RunTally t, BasePlayer player, long now)
|
||
{
|
||
string id = ((ulong)player.userID).ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||
Participant person;
|
||
|
||
if (t.People.TryGetValue(id, out person))
|
||
{
|
||
person.Name = player.displayName;
|
||
return person;
|
||
}
|
||
|
||
if (t.People.Count >= TallyMaxPeople)
|
||
{
|
||
t.Overflow++;
|
||
return null;
|
||
}
|
||
|
||
person = new Participant { SteamId = id, Name = player.displayName, JoinedMs = now };
|
||
t.People[id] = person;
|
||
return person;
|
||
}
|
||
|
||
/// <summary>The D99 score: seconds, kills, or minutes present plus the weight times kills.</summary>
|
||
private static double ScoreOf(RunTally t, Participant p)
|
||
{
|
||
switch (t.Score)
|
||
{
|
||
case ScoreSeconds:
|
||
return Math.Floor(p.Seconds);
|
||
case ScoreKills:
|
||
return p.Kills;
|
||
default:
|
||
return Math.Round(p.Seconds / 60.0 + t.KillWeight * p.Kills, 2);
|
||
}
|
||
}
|
||
|
||
/// <summary>Main thread. Start counting who takes part in a run.</summary>
|
||
private void HandleTallyOpen(Dictionary<string, object> cmd, string reqId)
|
||
{
|
||
string runId = StringField(cmd, "runId");
|
||
string key = StringField(cmd, "key");
|
||
|
||
if (string.IsNullOrEmpty(runId))
|
||
{
|
||
EmitRewardError("tally.error", reqId, "malformed", "a tally is opened for a run");
|
||
return;
|
||
}
|
||
|
||
RunTally existing;
|
||
|
||
if (_runTallies.TryGetValue(runId, out existing))
|
||
{
|
||
// A retry whose first answer was lost repeats the key; anything else is a second
|
||
// tally for one run, which would split its people across two answers (§29.5).
|
||
if (!string.IsNullOrEmpty(key) && existing.Key == key)
|
||
EmitTallyOk(reqId, existing, true);
|
||
else
|
||
EmitRewardError("tally.error", reqId, "already-open",
|
||
"this run already has a tally on this server, and a run holds one");
|
||
|
||
return;
|
||
}
|
||
|
||
// The same consent as the world verbs (D94): a tally is an event doing something on
|
||
// this server unattended. Reading and closing one are never behind it.
|
||
if (_config == null || !_config.EventsEnabled)
|
||
{
|
||
EmitRewardError("tally.error", reqId, "events-disabled",
|
||
"events are switched off on this server — set EventsEnabled in the RunicGateway plugin config");
|
||
return;
|
||
}
|
||
|
||
string score = Lower(StringField(cmd, "score"));
|
||
|
||
if (score != ScoreSeconds && score != ScoreKills && score != ScoreBoth)
|
||
{
|
||
EmitRewardError("tally.error", reqId, "malformed", "a tally scores seconds, kills or both");
|
||
return;
|
||
}
|
||
|
||
string killsOf = Lower(StringField(cmd, "killsOf"));
|
||
|
||
if (score == ScoreSeconds)
|
||
{
|
||
killsOf = null;
|
||
}
|
||
else if (killsOf != "players" && killsOf != "npcs" && killsOf != "both")
|
||
{
|
||
EmitRewardError("tally.error", reqId, "malformed", "a tally that counts kills says whose: players, npcs or both");
|
||
return;
|
||
}
|
||
|
||
double weight = score == ScoreBoth ? (DoubleField(cmd, "killWeight") ?? 5.0) : 0.0;
|
||
|
||
if (weight < 0 || weight > 1000)
|
||
{
|
||
EmitRewardError("tally.error", reqId, "out-of-range", $"a kill is worth 0 to 1000 minutes, not {weight}");
|
||
return;
|
||
}
|
||
|
||
long holdMs = LongField(cmd, "holdMs");
|
||
|
||
if (holdMs == 0)
|
||
holdMs = TallyMaxHoldMs;
|
||
|
||
if (holdMs < 60000L || holdMs > TallyMaxHoldMs)
|
||
{
|
||
EmitRewardError("tally.error", reqId, "out-of-range",
|
||
$"a tally counts for 1 minute to {TallyMaxHoldMs / 60000L} minutes, not {holdMs / 60000L}");
|
||
return;
|
||
}
|
||
|
||
long now = NowMs();
|
||
int live = 0;
|
||
|
||
foreach (RunTally t in _runTallies.Values)
|
||
{
|
||
if (Counting(t, now))
|
||
live++;
|
||
}
|
||
|
||
if (live >= MaxTallies())
|
||
{
|
||
EmitRewardError("tally.error", reqId, "too-many",
|
||
$"this server counts at most {MaxTallies()} tallies at once, and {live} are counting");
|
||
return;
|
||
}
|
||
|
||
string zone = StringField(cmd, "zone");
|
||
string zoneId = null;
|
||
|
||
if (!string.IsNullOrEmpty(zone))
|
||
{
|
||
// Only a zone THIS run opened (D100) — never an operator's arena, never another
|
||
// run's. Matched on the name its step gave it, the way the step's author wrote it.
|
||
var matches = new List<WorldEntry>();
|
||
|
||
foreach (WorldEntry e in _world.Values)
|
||
{
|
||
if (e.Kind == WorldKindZone && e.RunId == runId &&
|
||
string.Equals((e.Name ?? "").Trim(), zone.Trim(), StringComparison.OrdinalIgnoreCase))
|
||
matches.Add(e);
|
||
}
|
||
|
||
if (matches.Count == 0)
|
||
{
|
||
EmitRewardError("tally.error", reqId, "no-zone",
|
||
$"this run has opened no zone named '{zone}' on this server — open it in an earlier step");
|
||
return;
|
||
}
|
||
|
||
if (matches.Count > 1)
|
||
{
|
||
EmitRewardError("tally.error", reqId, "ambiguous-zone",
|
||
$"this run has opened {matches.Count} zones named '{zone}' — give each its own name");
|
||
return;
|
||
}
|
||
|
||
zoneId = matches[0].Id;
|
||
}
|
||
|
||
var tally = new RunTally
|
||
{
|
||
RunId = runId,
|
||
Key = key,
|
||
Zone = zoneId != null ? zone.Trim() : null,
|
||
ZoneId = zoneId,
|
||
Score = score,
|
||
KillsOf = killsOf,
|
||
KillWeight = weight,
|
||
OpenedMs = now,
|
||
DeadlineMs = now + holdMs,
|
||
LastAccrueMs = now
|
||
};
|
||
|
||
_runTallies[runId] = tally;
|
||
SaveTallies();
|
||
EnsureTallyTimer();
|
||
|
||
_lastRewardSummary = $"tally opened for run {runId} ({score}, {(zoneId ?? "whole server")}) for {holdMs / 60000L} min";
|
||
Puts($"rewards: {_lastRewardSummary}");
|
||
EmitTallyOk(reqId, tally, false);
|
||
}
|
||
|
||
/// <summary>Main thread. Who has taken part in a run so far. Never behind <c>EventsEnabled</c>.</summary>
|
||
private void HandleTallySnapshot(Dictionary<string, object> cmd, string reqId)
|
||
{
|
||
string runId = StringField(cmd, "runId");
|
||
RunTally t;
|
||
|
||
if (string.IsNullOrEmpty(runId) || !_runTallies.TryGetValue(runId, out t))
|
||
{
|
||
EmitRewardError("tally.error", reqId, "no-tally", "this run has no tally on this server");
|
||
return;
|
||
}
|
||
|
||
// Brought up to this moment first, so a reward read at the end of an event includes
|
||
// its last few seconds.
|
||
Accrue(t, NowMs(), null);
|
||
|
||
var people = new List<object>();
|
||
|
||
foreach (Participant p in t.People.Values)
|
||
{
|
||
people.Add(new Dictionary<string, object>
|
||
{
|
||
{ "steamId", p.SteamId },
|
||
{ "name", p.Name },
|
||
{ "seconds", (long)Math.Floor(p.Seconds) },
|
||
{ "kills", p.Kills },
|
||
{ "score", ScoreOf(t, p) },
|
||
{ "joinedAt", p.JoinedMs }
|
||
});
|
||
}
|
||
|
||
Dictionary<string, object> reply = Frame("tally.snapshot", "reply");
|
||
reply["runId"] = t.RunId;
|
||
reply["score"] = t.Score;
|
||
reply["killWeight"] = t.KillWeight;
|
||
reply["counting"] = Counting(t, NowMs());
|
||
reply["remainingMs"] = Math.Max(0L, t.DeadlineMs - NowMs());
|
||
reply["overflow"] = t.Overflow;
|
||
reply["people"] = people;
|
||
reply["maxRecipients"] = MaxRecipients();
|
||
|
||
if (t.KillsOf != null)
|
||
reply["killsOf"] = t.KillsOf;
|
||
|
||
if (t.Zone != null)
|
||
{
|
||
reply["zone"] = t.Zone;
|
||
Plugin zm = ZoneManagerPlugin();
|
||
reply["zoneStanding"] = zm != null && zm.Call("CheckZoneID", t.ZoneId) != null;
|
||
}
|
||
|
||
if (reqId != null)
|
||
reply["reqId"] = reqId;
|
||
|
||
EmitFrame(reply);
|
||
}
|
||
|
||
/// <summary>Main thread. Stop counting and forget. A tally already gone is a success.</summary>
|
||
private void HandleTallyClose(Dictionary<string, object> cmd, string reqId)
|
||
{
|
||
string runId = StringField(cmd, "runId");
|
||
|
||
if (string.IsNullOrEmpty(runId))
|
||
{
|
||
EmitRewardError("tally.error", reqId, "malformed", "a close names the run whose tally to forget");
|
||
return;
|
||
}
|
||
|
||
bool closed = _runTallies.Remove(runId);
|
||
|
||
if (closed)
|
||
SaveTallies();
|
||
|
||
_lastRewardSummary = closed ? $"tally for run {runId} closed" : $"tally for run {runId} was already gone";
|
||
|
||
Dictionary<string, object> reply = Frame("tally.ok", "reply");
|
||
reply["runId"] = runId;
|
||
reply["closed"] = closed;
|
||
|
||
if (reqId != null)
|
||
reply["reqId"] = reqId;
|
||
|
||
EmitFrame(reply);
|
||
}
|
||
|
||
private void EmitTallyOk(string reqId, RunTally t, bool repeat)
|
||
{
|
||
Dictionary<string, object> reply = Frame("tally.ok", "reply");
|
||
reply["runId"] = t.RunId;
|
||
reply["score"] = t.Score;
|
||
reply["remainingMs"] = Math.Max(0L, t.DeadlineMs - NowMs());
|
||
reply["repeat"] = repeat;
|
||
|
||
if (t.Zone != null)
|
||
{
|
||
reply["zone"] = t.Zone;
|
||
reply["zoneId"] = t.ZoneId;
|
||
}
|
||
|
||
if (reqId != null)
|
||
reply["reqId"] = reqId;
|
||
|
||
EmitFrame(reply);
|
||
}
|
||
|
||
private void EmitRewardError(string kind, string reqId, string reason, string message)
|
||
{
|
||
Dictionary<string, object> frame = Frame(kind, "reply");
|
||
frame["reason"] = reason;
|
||
|
||
if (!string.IsNullOrEmpty(message))
|
||
frame["message"] = message;
|
||
|
||
if (reqId != null)
|
||
frame["reqId"] = reqId;
|
||
|
||
_lastRewardSummary = $"refused ({reason}): {message}";
|
||
EmitFrame(frame);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Main thread. Credits presence up to <paramref name="now"/> — and no further than the
|
||
/// tally's deadline. <paramref name="zm"/> is looked up once per tick by the caller.
|
||
/// </summary>
|
||
private void Accrue(RunTally t, long now, Plugin zm)
|
||
{
|
||
long until = Math.Min(now, t.DeadlineMs);
|
||
long from = t.LastAccrueMs > 0 ? t.LastAccrueMs : until;
|
||
|
||
if (until <= from)
|
||
{
|
||
t.LastAccrueMs = Math.Max(t.LastAccrueMs, until);
|
||
return;
|
||
}
|
||
|
||
t.LastAccrueMs = until;
|
||
double seconds = Math.Min(until - from, TallyMaxStepMs) / 1000.0;
|
||
IEnumerable<BasePlayer> present;
|
||
|
||
if (t.ZoneId != null)
|
||
{
|
||
zm = zm ?? ZoneManagerPlugin();
|
||
|
||
// A zone that is not standing — ZoneManager unloaded, or a wipe took it — gains
|
||
// nobody, and keeps what it has.
|
||
if (zm == null || zm.Call("CheckZoneID", t.ZoneId) == null)
|
||
return;
|
||
|
||
if (ZoneMembershipTrusted())
|
||
{
|
||
present = zm.Call("GetPlayersInZone", t.ZoneId) as List<BasePlayer>;
|
||
}
|
||
else
|
||
{
|
||
// No helper (D182): ZoneManager's membership misses whoever was standing here
|
||
// when the zone was made, so the zone's own shape decides.
|
||
var inside = new List<BasePlayer>();
|
||
|
||
foreach (BasePlayer player in BasePlayer.activePlayerList)
|
||
{
|
||
if (InZone(zm, t.ZoneId, player, false))
|
||
inside.Add(player);
|
||
}
|
||
|
||
present = inside;
|
||
}
|
||
}
|
||
else
|
||
{
|
||
present = BasePlayer.activePlayerList;
|
||
}
|
||
|
||
if (present == null)
|
||
return;
|
||
|
||
foreach (BasePlayer player in present)
|
||
{
|
||
if (!CountsAsPresent(player))
|
||
continue;
|
||
|
||
Participant person = PersonIn(t, player, now);
|
||
|
||
if (person == null)
|
||
continue;
|
||
|
||
person.Seconds += seconds;
|
||
_talliesDirty = true;
|
||
}
|
||
}
|
||
|
||
/// <summary>Main thread, every five seconds while any tally is held.</summary>
|
||
private void TallyTick()
|
||
{
|
||
long now = NowMs();
|
||
List<string> forget = null;
|
||
Plugin zm = ZoneManagerPlugin();
|
||
|
||
foreach (RunTally t in _runTallies.Values)
|
||
{
|
||
// Forgotten seven days after it opened, whatever its minutes (D108): a website
|
||
// that went away must not leave a tally on disk for ever.
|
||
if (now >= t.OpenedMs + TallyMaxHoldMs)
|
||
{
|
||
if (forget == null)
|
||
forget = new List<string>();
|
||
|
||
forget.Add(t.RunId);
|
||
continue;
|
||
}
|
||
|
||
if (Counting(t, now) || t.LastAccrueMs < t.DeadlineMs)
|
||
Accrue(t, now, zm);
|
||
}
|
||
|
||
if (forget != null)
|
||
{
|
||
foreach (string id in forget)
|
||
_runTallies.Remove(id);
|
||
|
||
_talliesDirty = true;
|
||
_lastRewardSummary = $"{forget.Count} tally(s) seven days old were forgotten";
|
||
Puts($"rewards: {_lastRewardSummary}");
|
||
}
|
||
|
||
if (_talliesDirty && (++_tallyTicks % TallySaveEveryTicks == 0 || forget != null))
|
||
SaveTallies();
|
||
|
||
if (_runTallies.Count == 0)
|
||
StopTallyTimer();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Main thread. The last hit's kill, for every tally counting kills of that kind whose area
|
||
/// the killer is standing in when the victim dies (D98).
|
||
/// </summary>
|
||
private void CreditKill(BasePlayer killer, bool victimIsPlayer)
|
||
{
|
||
if (_runTallies.Count == 0 || killer == null || killer.IsNpc)
|
||
return;
|
||
|
||
long now = NowMs();
|
||
Plugin zm = null;
|
||
bool zmLooked = false;
|
||
bool trusted = false;
|
||
|
||
foreach (RunTally t in _runTallies.Values)
|
||
{
|
||
if (!Counting(t, now) || t.KillsOf == null)
|
||
continue;
|
||
|
||
if (victimIsPlayer ? t.KillsOf == "npcs" : t.KillsOf == "players")
|
||
continue;
|
||
|
||
if (t.ZoneId != null)
|
||
{
|
||
if (!zmLooked)
|
||
{
|
||
zm = ZoneManagerPlugin();
|
||
trusted = ZoneMembershipTrusted();
|
||
zmLooked = true;
|
||
}
|
||
|
||
// Membership with the helper, the zone's shape without it (D182).
|
||
if (!InZone(zm, t.ZoneId, killer, trusted))
|
||
continue;
|
||
}
|
||
|
||
Participant person = PersonIn(t, killer, now);
|
||
|
||
if (person == null)
|
||
continue;
|
||
|
||
person.Kills++;
|
||
_talliesDirty = true;
|
||
}
|
||
}
|
||
|
||
private void EnsureTallyTimer()
|
||
{
|
||
if (_tallyTimer == null && _runTallies.Count > 0)
|
||
_tallyTimer = timer.Every(TallyTickSeconds, TallyTick);
|
||
}
|
||
|
||
private void StopTallyTimer()
|
||
{
|
||
if (_tallyTimer != null)
|
||
{
|
||
_tallyTimer.Destroy();
|
||
_tallyTimer = null;
|
||
}
|
||
}
|
||
|
||
private string TalliesPath()
|
||
{
|
||
return Path.Combine(Interface.Oxide.DataDirectory, Name, "tallies.json");
|
||
}
|
||
|
||
private string CreditsPath()
|
||
{
|
||
return Path.Combine(Interface.Oxide.DataDirectory, Name, "credits.json");
|
||
}
|
||
|
||
/// <summary>Main thread. Written at most once a minute while counting, and on every open and close.</summary>
|
||
private void SaveTallies()
|
||
{
|
||
WriteDataFile(TalliesPath(), new List<RunTally>(_runTallies.Values), "tallies");
|
||
_talliesDirty = false;
|
||
}
|
||
|
||
private void SaveCredits()
|
||
{
|
||
WriteDataFile(CreditsPath(), new List<KitCredit>(_credits.Values), "kit credits");
|
||
}
|
||
|
||
private void WriteDataFile(string path, object rows, string what)
|
||
{
|
||
try
|
||
{
|
||
Directory.CreateDirectory(Path.GetDirectoryName(path));
|
||
|
||
string tmp = path + ".tmp";
|
||
File.WriteAllText(tmp, JsonConvert.SerializeObject(rows, Formatting.Indented), Utf8NoBom);
|
||
|
||
if (File.Exists(path))
|
||
File.Delete(path);
|
||
|
||
File.Move(tmp, path);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Puts($"could not save the {what}: {Flatten(ex.Message)}");
|
||
}
|
||
}
|
||
|
||
private List<T> ReadDataFile<T>(string path, string what)
|
||
{
|
||
try
|
||
{
|
||
if (File.Exists(path))
|
||
return JsonConvert.DeserializeObject<List<T>>(File.ReadAllText(path));
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Puts($"could not read the {what}: {Flatten(ex.Message)}");
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Main thread, once per load. A tally survives a restart, a reload and a wipe: it records
|
||
/// people, not the map. Presence accrues from now — the time the server was down is not
|
||
/// time anybody spent in a zone.
|
||
/// </summary>
|
||
private void LoadRewards()
|
||
{
|
||
_runTallies = new Dictionary<string, RunTally>();
|
||
_credits = new Dictionary<string, KitCredit>();
|
||
long now = NowMs();
|
||
|
||
List<RunTally> tallies = ReadDataFile<RunTally>(TalliesPath(), "tallies");
|
||
|
||
if (tallies != null)
|
||
{
|
||
foreach (RunTally t in tallies)
|
||
{
|
||
if (t == null || string.IsNullOrEmpty(t.RunId))
|
||
continue;
|
||
|
||
if (t.People == null)
|
||
t.People = new Dictionary<string, Participant>();
|
||
|
||
t.LastAccrueMs = Math.Min(now, t.DeadlineMs);
|
||
_runTallies[t.RunId] = t;
|
||
}
|
||
}
|
||
|
||
List<KitCredit> credits = ReadDataFile<KitCredit>(CreditsPath(), "kit credits");
|
||
|
||
if (credits != null)
|
||
{
|
||
foreach (KitCredit c in credits)
|
||
{
|
||
if (c != null && !string.IsNullOrEmpty(c.SteamId) && !string.IsNullOrEmpty(c.Kit))
|
||
_credits[CreditKey(c.SteamId, c.Kit)] = c;
|
||
}
|
||
}
|
||
|
||
Puts($"rewards: {_runTallies.Count} tallies and {_credits.Count} kit credits on record");
|
||
EnsureTallyTimer();
|
||
}
|
||
|
||
// ── Kits ──────────────────────────────────────────────────────────────────
|
||
|
||
private Plugin KitsPlugin()
|
||
{
|
||
return plugins.Find("Kits");
|
||
}
|
||
|
||
/// <summary>Main thread. The kits a reward can name, and what gates each (the kit source).</summary>
|
||
private void HandleKitsList(string reqId)
|
||
{
|
||
Plugin kits = KitsPlugin();
|
||
|
||
if (kits == null)
|
||
{
|
||
EmitRewardError("kits.error", reqId, "kits-missing", "Kits is not loaded on this server");
|
||
return;
|
||
}
|
||
|
||
var names = new List<string>();
|
||
kits.Call("GetKitNames", names);
|
||
|
||
var rows = new List<object>();
|
||
|
||
foreach (string name in names)
|
||
{
|
||
if (string.IsNullOrEmpty(name))
|
||
continue;
|
||
|
||
var info = kits.Call("GetKitInfo", name) as Newtonsoft.Json.Linq.JObject;
|
||
|
||
if (info == null)
|
||
continue;
|
||
|
||
rows.Add(new Dictionary<string, object>
|
||
{
|
||
{ "name", name },
|
||
{ "permission", (string)info["permission"] ?? "" },
|
||
{ "max", info["max"] != null ? (int)info["max"] : 0 },
|
||
{ "cooldown", info["cooldown"] != null ? (int)info["cooldown"] : 0 },
|
||
{ "hidden", info["hide"] != null && (bool)info["hide"] },
|
||
{ "description", Flatten((string)info["description"] ?? "") }
|
||
});
|
||
}
|
||
|
||
Dictionary<string, object> reply = Frame("kits.list", "reply");
|
||
reply["kits"] = rows;
|
||
reply["maxRecipients"] = MaxRecipients();
|
||
reply["maxTallies"] = MaxTallies();
|
||
reply["maxChat"] = MaxChat();
|
||
reply["eventsEnabled"] = _config != null && _config.EventsEnabled;
|
||
|
||
if (reqId != null)
|
||
reply["reqId"] = reqId;
|
||
|
||
EmitFrame(reply);
|
||
}
|
||
|
||
private static string CreditKey(string steamId, string kit)
|
||
{
|
||
return steamId + "|" + Lower(kit);
|
||
}
|
||
|
||
private static int CallInt(Plugin plugin, string method, params object[] args)
|
||
{
|
||
object value = plugin.Call(method, args);
|
||
return value is int ? (int)value : 0;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Main thread. Brings one player's credits for one kit into line with what the site says
|
||
/// they earned and what Kits says they have used. Three steps, in this order:
|
||
///
|
||
/// <list type="number">
|
||
/// <item><description>
|
||
/// <b>Notice a redemption.</b> A moved credit lowered the use count to one below the
|
||
/// kit's limit; if the count is back at the limit, the player has redeemed it. This is
|
||
/// how <c>OnKitRedeemed</c> is read, and it also heals a redemption made while this
|
||
/// plugin was unloaded.
|
||
/// </description></item>
|
||
/// <item><description>
|
||
/// <b>Withdraw what the site took back</b> (a revert). Only a moved credit not yet
|
||
/// redeemed is put back; a redemption already made stands (R16). A use count is
|
||
/// never raised past the kit's limit.
|
||
/// </description></item>
|
||
/// <item><description>
|
||
/// <b>Spend the next one, last.</b> Only when the player has used the kit up, and one
|
||
/// at a time, so the credit is always the use after the kit's own.
|
||
/// </description></item>
|
||
/// </list>
|
||
/// </summary>
|
||
private void SettleCredit(Plugin kits, KitCredit c, ref int applied, ref int withdrawn)
|
||
{
|
||
if (kits == null)
|
||
return;
|
||
|
||
int max = CallInt(kits, "GetKitMaxUses", c.Kit);
|
||
|
||
// A kit with no use limit has no "one more use" to give; its permission is the reward.
|
||
if (max <= 0)
|
||
return;
|
||
|
||
ulong id;
|
||
|
||
if (!ulong.TryParse(c.SteamId, out id))
|
||
return;
|
||
|
||
int uses = CallInt(kits, "GetPlayerKitUses", id, c.Kit);
|
||
|
||
if (c.Applied > c.Consumed && uses >= max)
|
||
c.Consumed = c.Applied;
|
||
|
||
if (c.Applied > c.Earned)
|
||
{
|
||
int withdraw = Math.Min(c.Applied - c.Earned, c.Applied - c.Consumed);
|
||
|
||
if (withdraw > 0)
|
||
{
|
||
kits.Call("SetPlayerKitUses", id, c.Kit, Math.Min(uses + withdraw, max));
|
||
uses = CallInt(kits, "GetPlayerKitUses", id, c.Kit);
|
||
c.Applied -= withdraw;
|
||
withdrawn += withdraw;
|
||
}
|
||
|
||
// What is left over was redeemed and stands. It is forgiven rather than held
|
||
// against the next credit this player earns: the reward it came from is gone.
|
||
c.Applied = Math.Min(c.Applied, c.Earned);
|
||
c.Consumed = Math.Min(c.Consumed, c.Applied);
|
||
}
|
||
|
||
if (c.Applied < c.Earned && c.Applied == c.Consumed && uses >= max)
|
||
{
|
||
// `SetPlayerKitUses` is a no-op for a player with no usage record of this kit, so
|
||
// the read-back is what makes the count true — the same rule as the grants (D85).
|
||
kits.Call("SetPlayerKitUses", id, c.Kit, max - 1);
|
||
|
||
if (CallInt(kits, "GetPlayerKitUses", id, c.Kit) == max - 1)
|
||
{
|
||
c.Applied++;
|
||
applied++;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Main thread, at the end of a <c>perm.sync</c> that carried <c>credits</c>. The field is
|
||
/// the site's whole set for this server: a player and kit it no longer names has earned
|
||
/// nothing, and their unredeemed credit is withdrawn.
|
||
/// </summary>
|
||
private void ReconcileCredits(PermSyncJob job)
|
||
{
|
||
var desired = new Dictionary<string, KitCredit>();
|
||
|
||
foreach (KitCredit row in job.Credits)
|
||
{
|
||
string key = CreditKey(row.SteamId, row.Kit);
|
||
KitCredit have;
|
||
|
||
if (desired.TryGetValue(key, out have))
|
||
have.Earned += row.Earned;
|
||
else
|
||
desired[key] = row;
|
||
}
|
||
|
||
foreach (KitCredit c in _credits.Values)
|
||
{
|
||
if (!desired.ContainsKey(CreditKey(c.SteamId, c.Kit)))
|
||
c.Earned = 0;
|
||
}
|
||
|
||
foreach (KeyValuePair<string, KitCredit> kv in desired)
|
||
{
|
||
KitCredit c;
|
||
|
||
if (!_credits.TryGetValue(kv.Key, out c))
|
||
{
|
||
c = new KitCredit { SteamId = kv.Value.SteamId, Kit = kv.Value.Kit };
|
||
_credits[kv.Key] = c;
|
||
}
|
||
|
||
c.Earned = kv.Value.Earned;
|
||
}
|
||
|
||
Plugin kits = KitsPlugin();
|
||
var unknown = new HashSet<string>();
|
||
|
||
foreach (KitCredit c in _credits.Values)
|
||
{
|
||
if (kits == null)
|
||
continue;
|
||
|
||
object isKit = kits.Call("IsKit", c.Kit);
|
||
|
||
if (!(isKit is bool) || !(bool)isKit)
|
||
{
|
||
if (c.Earned > 0 && unknown.Add(c.Kit))
|
||
job.CreditsUnresolved.Add(c.Kit);
|
||
|
||
continue;
|
||
}
|
||
|
||
SettleCredit(kits, c, ref job.CreditsApplied, ref job.CreditsWithdrawn);
|
||
}
|
||
|
||
var empty = new List<string>();
|
||
|
||
foreach (KeyValuePair<string, KitCredit> kv in _credits)
|
||
{
|
||
if (kv.Value.Earned == 0 && kv.Value.Applied == 0)
|
||
empty.Add(kv.Key);
|
||
}
|
||
|
||
foreach (string key in empty)
|
||
_credits.Remove(key);
|
||
|
||
job.CreditsWaiting = kits == null;
|
||
SaveCredits();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Void, like every hook here: Kits raises this after a redemption has been counted, which
|
||
/// is the only moment a player's usage record of the kit is certain to exist.
|
||
/// </summary>
|
||
private void OnKitRedeemed(BasePlayer player, string kitName)
|
||
{
|
||
MarkHook("OnKitRedeemed");
|
||
|
||
if (player == null || string.IsNullOrEmpty(kitName) || _credits.Count == 0)
|
||
return;
|
||
|
||
KitCredit c;
|
||
|
||
if (!_credits.TryGetValue(CreditKey(SteamIdOf(player), kitName), out c))
|
||
return;
|
||
|
||
int applied = 0, withdrawn = 0;
|
||
SettleCredit(KitsPlugin(), c, ref applied, ref withdrawn);
|
||
SaveCredits();
|
||
|
||
if (applied > 0)
|
||
_lastRewardSummary = $"{player.displayName} redeemed {kitName}; one more use applied from a reward";
|
||
}
|
||
|
||
/// <summary>
|
||
/// Whether Kits clears its usage data at a wipe (its own <c>Wipe player data when the
|
||
/// server is wiped</c>, off by default). Read from its config file, because the switch
|
||
/// is not in its API — and on Carbon that file is under <c>carbon/configs</c>, which
|
||
/// is why the directory comes from the framework and never from a literal (R19).
|
||
/// </summary>
|
||
private bool KitsWipesPlayerData()
|
||
{
|
||
try
|
||
{
|
||
string path = Path.Combine(Interface.Oxide.ConfigDirectory, "Kits.json");
|
||
|
||
if (!File.Exists(path))
|
||
return false;
|
||
|
||
var config = Newtonsoft.Json.Linq.JObject.Parse(File.ReadAllText(path));
|
||
Newtonsoft.Json.Linq.JToken wipe = config["Wipe player data when the server is wiped"];
|
||
|
||
return wipe != null && wipe.Type == Newtonsoft.Json.Linq.JTokenType.Boolean && (bool)wipe;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Puts($"could not read Kits' wipe setting: {Flatten(ex.Message)}");
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Main thread, from <c>OnNewSave</c>. When Kits has wiped every use count, a credit this
|
||
/// plugin moved into one is gone with it — so the record of moving it goes too, or the
|
||
/// next redemption would be read as spending a credit that no longer exists. What the
|
||
/// site says was earned stays: a wipe does not take a reward away.
|
||
/// </summary>
|
||
private void ResetCreditsForWipe()
|
||
{
|
||
if (_credits.Count == 0 || !KitsWipesPlayerData())
|
||
return;
|
||
|
||
foreach (KitCredit c in _credits.Values)
|
||
{
|
||
c.Applied = 0;
|
||
c.Consumed = 0;
|
||
}
|
||
|
||
SaveCredits();
|
||
Puts($"rewards: Kits wipes use counts at a new save, so {_credits.Count} kit credits start over");
|
||
}
|
||
|
||
// ── Chat ──────────────────────────────────────────────────────────────────
|
||
|
||
/// <summary>
|
||
/// Main thread. One line to everybody on the server. A key said in the last ten minutes
|
||
/// is answered <c>ok</c> and not said again, so a retried step never repeats itself.
|
||
/// A run's line is behind <c>EventsEnabled</c>; a news post is behind the site's own
|
||
/// switch instead (D104), because news is not an event.
|
||
/// </summary>
|
||
private void HandleChatSay(Dictionary<string, object> cmd, string reqId)
|
||
{
|
||
string key = StringField(cmd, "key");
|
||
bool fromEvent = cmd.ContainsKey("event") && string.Equals(StringField(cmd, "event"), "true", StringComparison.OrdinalIgnoreCase);
|
||
string message = Flatten(StringField(cmd, "message"));
|
||
long now = NowMs();
|
||
|
||
if (fromEvent && (_config == null || !_config.EventsEnabled))
|
||
{
|
||
EmitRewardError("chat.error", reqId, "events-disabled",
|
||
"events are switched off on this server — set EventsEnabled in the RunicGateway plugin config");
|
||
return;
|
||
}
|
||
|
||
if (string.IsNullOrEmpty(key) || string.IsNullOrEmpty(message))
|
||
{
|
||
EmitRewardError("chat.error", reqId, "malformed", "a chat line has a key and a message");
|
||
return;
|
||
}
|
||
|
||
if (message.Length > MaxChat())
|
||
{
|
||
EmitRewardError("chat.error", reqId, "too-long",
|
||
$"this server says at most {MaxChat()} characters in one line, not {message.Length}");
|
||
return;
|
||
}
|
||
|
||
// Protocol 12: where the line goes, and the voice it is said in (D140, D141). A caller
|
||
// from protocol 11 sends neither and is answered exactly as before.
|
||
string delivery = StringField(cmd, "delivery");
|
||
|
||
if (string.IsNullOrEmpty(delivery))
|
||
delivery = DeliveryChat;
|
||
|
||
if (delivery != DeliveryChat && delivery != DeliveryPopup)
|
||
{
|
||
EmitRewardError("chat.error", reqId, "malformed",
|
||
$"a line is delivered to chat or to a popup, not to '{delivery}'");
|
||
return;
|
||
}
|
||
|
||
string format = StringField(cmd, "format");
|
||
|
||
if (format != null && (format.Length > ChatFormatMax || Occurrences(format, "{message}") != 1))
|
||
{
|
||
EmitRewardError("chat.error", reqId, "malformed",
|
||
$"a chat format holds exactly one {{message}} and at most {ChatFormatMax} characters");
|
||
return;
|
||
}
|
||
|
||
Plugin popup = null;
|
||
|
||
if (delivery == DeliveryPopup)
|
||
{
|
||
popup = PopupPlugin();
|
||
|
||
// R3: a refusal with a reason, never a line silently said somewhere else.
|
||
if (popup == null)
|
||
{
|
||
EmitRewardError("chat.error", reqId, "popup-unavailable",
|
||
"PopupNotifications is not loaded on this server, so a popup cannot be shown — install it, or deliver this line to chat");
|
||
return;
|
||
}
|
||
}
|
||
|
||
var stale = new List<string>();
|
||
|
||
foreach (KeyValuePair<string, long> kv in _chatKeys)
|
||
{
|
||
if (now - kv.Value > ChatKeyMemoryMs)
|
||
stale.Add(kv.Key);
|
||
}
|
||
|
||
foreach (string s in stale)
|
||
_chatKeys.Remove(s);
|
||
|
||
bool repeat = _chatKeys.ContainsKey(key);
|
||
|
||
if (!repeat)
|
||
{
|
||
// A popup ignores the format: its panel is not a chat line. A null player is
|
||
// everybody connected, and a duration of 0 is the plugin's own default. Nothing
|
||
// queues for a player who is offline (§33.1).
|
||
string line = popup != null || format == null ? message : Voice(format, message);
|
||
|
||
if (popup != null)
|
||
popup.Call("CreatePopupNotification", line, null, 0f);
|
||
else
|
||
Server.Broadcast(line);
|
||
|
||
// Logged, because the game logs neither a broadcast nor a popup, and a line the
|
||
// website put in front of every player deserves a record on the host (§33.3).
|
||
Puts($"said ({delivery}): {line}");
|
||
|
||
_chatKeys[key] = now;
|
||
_lastRewardSummary = $"said a line ({message.Length} chars, {delivery}{(format != null ? ", voiced" : "")}) to {BasePlayer.activePlayerList.Count} players";
|
||
}
|
||
|
||
Dictionary<string, object> reply = Frame("chat.ok", "reply");
|
||
reply["said"] = !repeat;
|
||
reply["repeat"] = repeat;
|
||
reply["players"] = BasePlayer.activePlayerList.Count;
|
||
reply["delivery"] = delivery;
|
||
|
||
if (reqId != null)
|
||
reply["reqId"] = reqId;
|
||
|
||
EmitFrame(reply);
|
||
}
|
||
|
||
// ── The optional mods (protocol 12) ───────────────────────────────────────
|
||
//
|
||
// BetterChat and PopupNotifications are optional (PLAN.md §33, D141). Each is looked up at
|
||
// the moment it is needed, never held from our load, because an operator can load or
|
||
// unload either at any time; and every call into one that is missing answers with a
|
||
// reason instead of doing nothing quietly (R3).
|
||
|
||
private const string DeliveryChat = "chat";
|
||
private const string DeliveryPopup = "popup";
|
||
|
||
/// <summary>The most titles one set carries. The site has its own, lower, limit.</summary>
|
||
private const int TitlesMax = 500;
|
||
|
||
/// <summary>
|
||
/// The longest one player's title string may be. The site caps a title's text at 24
|
||
/// characters (§33.4); this bounds what a player with every title, in markup, can hold.
|
||
/// </summary>
|
||
private const int TitleTextMax = 1024;
|
||
|
||
/// <summary>The longest voice format a chat line may carry (§33.2).</summary>
|
||
private const int ChatFormatMax = 512;
|
||
|
||
/// <summary>
|
||
/// The chat titles players have earned on this server, by Steam id, already in
|
||
/// BetterChat's markup. The website composes them from the wipe's standings and pushes
|
||
/// the whole set (<c>titles.set</c>).
|
||
///
|
||
/// <para>
|
||
/// <b>Kept on disk as well (D144).</b> The site re-sends on a new boot id or wipe id, and
|
||
/// a hot reload of this plugin — an update, an <c>oxide.reload</c> — is neither: the boot
|
||
/// id names the server process. Without the file a reload empties every title until the
|
||
/// standings next move. A file from another wipe is not loaded, because titles rank the
|
||
/// wipe they were earned on.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// <b>Swapped, never edited.</b> BetterChat reads this through <see cref="TitleFor"/> on
|
||
/// every chat line, and another plugin can make it do so from its own thread through
|
||
/// BetterChat's API. A dictionary written while it is read can throw or answer wrong; a
|
||
/// reference replaced whole is read either before the swap or after it.
|
||
/// </para>
|
||
/// </summary>
|
||
private volatile Dictionary<ulong, string> _titles = new Dictionary<ulong, string>();
|
||
|
||
/// <summary>The getter BetterChat holds, kept so every registration hands it the same delegate.</summary>
|
||
private Func<Oxide.Core.Libraries.Covalence.IPlayer, string> _titleGetter;
|
||
|
||
/// <summary>
|
||
/// Whether the BetterChat loaded right now holds our getter. Cleared when BetterChat
|
||
/// unloads: its registrations live on the instance, so a reloaded BetterChat has
|
||
/// forgotten us until <see cref="OnPluginLoaded"/> registers again (§33.1).
|
||
/// </summary>
|
||
private bool _titlesRegistered;
|
||
|
||
/// <summary>The last title set, for <c>rg.titles</c>. Diagnostics only.</summary>
|
||
private string _lastTitlesSummary = "none yet";
|
||
|
||
/// <summary>
|
||
/// The twelve fields of a BetterChat group (D138), by the name its API takes, with
|
||
/// BetterChat 5.2.15's default for each. <c>Title</c> is absent: its default is the
|
||
/// group's own name in brackets (<see cref="ChatDefault"/>).
|
||
/// </summary>
|
||
private static readonly Dictionary<string, string> ChatFieldDefaults =
|
||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||
{
|
||
{ "Priority", "0" },
|
||
{ "Title", null },
|
||
{ "TitleColor", "#55aaff" },
|
||
{ "TitleSize", "15" },
|
||
{ "TitleHidden", "false" },
|
||
{ "TitleHiddenIfNotPrimary", "false" },
|
||
{ "UsernameColor", "#55aaff" },
|
||
{ "UsernameSize", "15" },
|
||
{ "MessageColor", "white" },
|
||
{ "MessageSize", "15" },
|
||
{ "ChatFormat", "{Title} {Username}: {Message}" },
|
||
{ "ConsoleFormat", "{Title} {Username}: {Message}" }
|
||
};
|
||
|
||
private Plugin BetterChatPlugin()
|
||
{
|
||
return plugins.Find("BetterChat");
|
||
}
|
||
|
||
private Plugin PopupPlugin()
|
||
{
|
||
return plugins.Find("PopupNotifications");
|
||
}
|
||
|
||
/// <summary>
|
||
/// BetterChat's third-party title getter. It runs <b>on the chat path</b>, once per line
|
||
/// and again whenever another plugin asks BetterChat to format one, so it does one
|
||
/// lookup and nothing else: no socket, no file, no allocation (R15). A player with no
|
||
/// title gets null, which BetterChat skips.
|
||
/// </summary>
|
||
private string TitleFor(Oxide.Core.Libraries.Covalence.IPlayer player)
|
||
{
|
||
if (player == null)
|
||
return null;
|
||
|
||
ulong id;
|
||
string title;
|
||
|
||
return ulong.TryParse(player.Id, out id) && _titles.TryGetValue(id, out title) ? title : null;
|
||
}
|
||
|
||
/// <summary>Main thread. Hands BetterChat our title getter, if BetterChat is here.</summary>
|
||
private void RegisterTitles(string why)
|
||
{
|
||
Plugin betterChat = BetterChatPlugin();
|
||
|
||
if (betterChat == null)
|
||
{
|
||
_titlesRegistered = false;
|
||
return;
|
||
}
|
||
|
||
if (_titleGetter == null)
|
||
_titleGetter = TitleFor;
|
||
|
||
// Void, so the answer is null either way. `rg.titles <steamId>` is how a rig proves
|
||
// BetterChat renders what this handed it.
|
||
betterChat.Call("API_RegisterThirdPartyTitle", this, _titleGetter);
|
||
_titlesRegistered = true;
|
||
|
||
if (why != "load")
|
||
Puts($"titles: registered with BetterChat again ({why}), holding {_titles.Count}");
|
||
}
|
||
|
||
/// <summary>
|
||
/// Main thread. Replaces the titles this server holds with the set the site sent. A
|
||
/// malformed entry refuses the whole set rather than being skipped: the set is whole by
|
||
/// contract, and half of one is a set nobody sent.
|
||
/// </summary>
|
||
private void HandleTitlesSet(Dictionary<string, object> cmd, string reqId)
|
||
{
|
||
object raw;
|
||
var list = cmd.TryGetValue("titles", out raw) ? raw as Newtonsoft.Json.Linq.JArray : null;
|
||
|
||
if (list == null)
|
||
{
|
||
EmitTitlesError(reqId, "malformed", "a title set carries a titles array");
|
||
return;
|
||
}
|
||
|
||
if (list.Count > TitlesMax)
|
||
{
|
||
EmitTitlesError(reqId, "too-large", $"this server holds at most {TitlesMax} titles, not {list.Count}");
|
||
return;
|
||
}
|
||
|
||
var next = new Dictionary<ulong, string>();
|
||
|
||
foreach (Newtonsoft.Json.Linq.JToken item in list)
|
||
{
|
||
var row = item as Newtonsoft.Json.Linq.JObject;
|
||
string steamId = row != null ? JsonText(row["steamId"]) : null;
|
||
string text = row != null ? JsonText(row["text"]) : null;
|
||
ulong id;
|
||
|
||
if (!ulong.TryParse(steamId, out id) || string.IsNullOrEmpty(text) || text.Length > TitleTextMax)
|
||
{
|
||
EmitTitlesError(reqId, "malformed",
|
||
$"every title names a Steam id and carries text of at most {TitleTextMax} characters");
|
||
return;
|
||
}
|
||
|
||
next[id] = text;
|
||
}
|
||
|
||
_titles = next;
|
||
SaveTitles();
|
||
|
||
// A BetterChat that arrived without us hearing it load is picked up here, which also
|
||
// makes a restart of either plugin heal on the site's next push.
|
||
if (!_titlesRegistered && BetterChatPlugin() != null)
|
||
RegisterTitles("title set");
|
||
|
||
bool shown = _titlesRegistered && BetterChatPlugin() != null;
|
||
_lastTitlesSummary = $"{next.Count} titles, betterChat={shown}, at={DateTime.UtcNow:HH:mm:ss}Z";
|
||
|
||
Dictionary<string, object> reply = Frame("titles.ok", "reply");
|
||
reply["count"] = next.Count;
|
||
reply["betterChat"] = shown;
|
||
|
||
string setId = StringField(cmd, "setId");
|
||
|
||
if (!string.IsNullOrEmpty(setId))
|
||
reply["setId"] = setId;
|
||
|
||
if (reqId != null)
|
||
reply["reqId"] = reqId;
|
||
|
||
EmitFrame(reply);
|
||
}
|
||
|
||
/// <summary>What <c>titles.json</c> holds: the set, and the wipe it was pushed on.</summary>
|
||
private sealed class TitleFile
|
||
{
|
||
public string WipeId;
|
||
public Dictionary<string, string> Titles = new Dictionary<string, string>();
|
||
}
|
||
|
||
private string TitlesPath()
|
||
{
|
||
return Path.Combine(Interface.Oxide.DataDirectory, Name, "titles.json");
|
||
}
|
||
|
||
/// <summary>Main thread. Written on every set the site pushes — a set arrives only when it changed.</summary>
|
||
private void SaveTitles()
|
||
{
|
||
var file = new TitleFile { WipeId = _wipeId };
|
||
|
||
foreach (KeyValuePair<ulong, string> kv in _titles)
|
||
file.Titles[kv.Key.ToString()] = kv.Value;
|
||
|
||
WriteDataFile(TitlesPath(), file, "titles");
|
||
}
|
||
|
||
/// <summary>
|
||
/// Main thread, from <c>OnServerInitialized</c> — the first moment the wipe id is real.
|
||
/// A file from another wipe, or with no wipe, is left unread: the site sends this wipe's.
|
||
/// </summary>
|
||
private void LoadTitles()
|
||
{
|
||
try
|
||
{
|
||
string path = TitlesPath();
|
||
|
||
if (!File.Exists(path))
|
||
return;
|
||
|
||
var file = JsonConvert.DeserializeObject<TitleFile>(File.ReadAllText(path));
|
||
|
||
if (file == null || file.Titles == null || string.IsNullOrEmpty(_wipeId) || file.WipeId != _wipeId)
|
||
return;
|
||
|
||
var loaded = new Dictionary<ulong, string>();
|
||
|
||
foreach (KeyValuePair<string, string> kv in file.Titles)
|
||
{
|
||
ulong id;
|
||
if (ulong.TryParse(kv.Key, out id) && !string.IsNullOrEmpty(kv.Value))
|
||
loaded[id] = kv.Value;
|
||
}
|
||
|
||
_titles = loaded;
|
||
_lastTitlesSummary = $"{loaded.Count} titles from disk, at={DateTime.UtcNow:HH:mm:ss}Z";
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Puts($"could not read the titles: {Flatten(ex.Message)}");
|
||
}
|
||
}
|
||
|
||
private void EmitTitlesError(string reqId, string reason, string message)
|
||
{
|
||
Dictionary<string, object> frame = Frame("titles.error", "reply");
|
||
frame["reason"] = reason;
|
||
frame["message"] = message;
|
||
|
||
if (reqId != null)
|
||
frame["reqId"] = reqId;
|
||
|
||
_lastTitlesSummary = $"refused ({reason}): {message}";
|
||
EmitFrame(frame);
|
||
}
|
||
|
||
/// <summary>A JSON string or number as text, or null for anything else.</summary>
|
||
private static string JsonText(Newtonsoft.Json.Linq.JToken token)
|
||
{
|
||
if (token == null)
|
||
return null;
|
||
|
||
switch (token.Type)
|
||
{
|
||
case Newtonsoft.Json.Linq.JTokenType.String:
|
||
return (string)token;
|
||
case Newtonsoft.Json.Linq.JTokenType.Integer:
|
||
return token.ToString();
|
||
case Newtonsoft.Json.Linq.JTokenType.Boolean:
|
||
return (bool)token ? "true" : "false";
|
||
default:
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// A chat line in a voice (D140): the site's format with the words put into it, turned
|
||
/// into the game's rich text. Said by us, with no player as its sender, so it works
|
||
/// whether or not BetterChat is loaded.
|
||
/// </summary>
|
||
private string Voice(string format, string message)
|
||
{
|
||
// BetterChat's own rule for a player's words (its GetOutput), for the same reason: the
|
||
// line is ours to style and the words inside it are not.
|
||
if (message.Contains("[#") || message.Contains("[+"))
|
||
message = message.Replace("[", string.Empty).Replace("]", string.Empty);
|
||
|
||
return covalence.FormatText(format.Replace("{message}", message));
|
||
}
|
||
|
||
private static int Occurrences(string text, string needle)
|
||
{
|
||
int count = 0;
|
||
|
||
for (int at = text.IndexOf(needle, StringComparison.Ordinal); at >= 0; at = text.IndexOf(needle, at + needle.Length, StringComparison.Ordinal))
|
||
count++;
|
||
|
||
return count;
|
||
}
|
||
|
||
private Dictionary<string, object> IntegrationsBody()
|
||
{
|
||
return new Dictionary<string, object>
|
||
{
|
||
{ "betterChat", IntegrationBody(BetterChatPlugin()) },
|
||
{ "popupNotifications", IntegrationBody(PopupPlugin()) },
|
||
{ "zoneDomes", ZoneDomesBody() }
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// ZoneDomes and the domes helper (D194), live on every status frame like the other
|
||
/// optional mods: the site offers a dome only where both are here and patched.
|
||
/// </summary>
|
||
private Dictionary<string, object> ZoneDomesBody()
|
||
{
|
||
Dictionary<string, object> body = IntegrationBody(ZoneDomesPlugin());
|
||
var helper = new Dictionary<string, object> { { "state", ZoneDomesPlugin() == null && DomesHelperPlugin() != null ? "no-zonedomes" : DomesHelperState() } };
|
||
Plugin domes = DomesHelperPlugin();
|
||
|
||
if (domes != null)
|
||
{
|
||
helper["version"] = domes.Version.ToString();
|
||
|
||
if (domes.Call("StateReason") is string reason)
|
||
helper["reason"] = reason;
|
||
}
|
||
|
||
body["helper"] = helper;
|
||
body["ready"] = DomesReady();
|
||
return body;
|
||
}
|
||
|
||
/// <summary>A version only for a plugin that is loaded. Absent, not empty.</summary>
|
||
private static Dictionary<string, object> IntegrationBody(Plugin plugin)
|
||
{
|
||
var body = new Dictionary<string, object> { { "loaded", plugin != null } };
|
||
|
||
if (plugin != null)
|
||
body["version"] = plugin.Version.ToString();
|
||
|
||
return body;
|
||
}
|
||
|
||
// ---- group styles (D138, D139, D143) ----
|
||
|
||
/// <summary>
|
||
/// The BetterChat fields a sync asks for on one group. A field this build does not
|
||
/// know, or a value that is not a string, number or boolean, is reported failed and
|
||
/// left out rather than guessed at.
|
||
/// </summary>
|
||
private List<ChatFieldWant> ChatStyle(PermSyncJob job, string group, Dictionary<string, object> row)
|
||
{
|
||
var style = new List<ChatFieldWant>();
|
||
object raw;
|
||
|
||
if (!row.TryGetValue("chat", out raw))
|
||
return style;
|
||
|
||
var fields = raw as Newtonsoft.Json.Linq.JObject;
|
||
|
||
if (fields == null)
|
||
return style;
|
||
|
||
foreach (Newtonsoft.Json.Linq.JProperty field in fields.Properties())
|
||
{
|
||
var want = field.Value as Newtonsoft.Json.Linq.JObject;
|
||
string value = want != null ? JsonText(want["value"]) : null;
|
||
|
||
if (!ChatFieldDefaults.ContainsKey(field.Name) || value == null)
|
||
{
|
||
job.ChatFailed.Add(new Dictionary<string, object>
|
||
{
|
||
{ "group", group },
|
||
{ "field", field.Name },
|
||
{ "reason", "malformed" }
|
||
});
|
||
continue;
|
||
}
|
||
|
||
style.Add(new ChatFieldWant
|
||
{
|
||
Field = field.Name,
|
||
Value = value,
|
||
Expect = JsonText(want["expect"])
|
||
});
|
||
}
|
||
|
||
return style;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Main thread. Puts the site's style on one BetterChat group, a field at a time, and
|
||
/// leaves alone any field somebody changed by hand.
|
||
///
|
||
/// <para>
|
||
/// A field is written when the game still holds what the site last pushed there, or
|
||
/// holds BetterChat's default and the site has never pushed it. Anything else is a hand
|
||
/// edit: it is not written, and it is reported with the game's value (D138, R2). A group
|
||
/// this sync had to create is written whole, because nobody can have edited it yet.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// <b>The save is the part the API does not do.</b> <c>API_SetGroupField</c> changes a
|
||
/// field in memory and never writes BetterChat's file, so on its next reload the style
|
||
/// would revert and read as drift. BetterChat's own <c>chat group set</c> does write
|
||
/// it, and writes every group, so it is run once per group touched with a value that
|
||
/// has not changed (D143).
|
||
/// </para>
|
||
/// </summary>
|
||
private void ApplyChatStyle(PermSyncJob job, string group)
|
||
{
|
||
Plugin betterChat = BetterChatPlugin();
|
||
|
||
if (betterChat == null)
|
||
{
|
||
ChatFailure(job, group, null, "betterchat-unloaded");
|
||
return;
|
||
}
|
||
|
||
bool created = false;
|
||
|
||
if (!ChatGroupExists(betterChat, group))
|
||
{
|
||
// Saves on its own.
|
||
betterChat.Call("API_AddGroup", group);
|
||
|
||
if (!ChatGroupExists(betterChat, group))
|
||
{
|
||
ChatFailure(job, group, null, "not-created");
|
||
return;
|
||
}
|
||
|
||
created = true;
|
||
}
|
||
|
||
Dictionary<string, object> game = ChatGroupFields(betterChat, group);
|
||
bool touched = false;
|
||
|
||
foreach (ChatFieldWant want in job.ChatStyles[group])
|
||
{
|
||
string now = ChatFieldValue(game, want.Field);
|
||
|
||
if (now == want.Value)
|
||
{
|
||
job.ChatSame++;
|
||
continue;
|
||
}
|
||
|
||
bool ours = created || now == want.Expect || (want.Expect == null && now == ChatDefault(group, want.Field));
|
||
|
||
if (!ours)
|
||
{
|
||
job.ChatDrift.Add(new Dictionary<string, object>
|
||
{
|
||
{ "group", group },
|
||
{ "field", want.Field },
|
||
{ "game", now }
|
||
});
|
||
continue;
|
||
}
|
||
|
||
// The typed answer is why each field goes through the API (D143): `Success`,
|
||
// `InvalidField` or `InvalidValue`, as a BetterChat enum this plugin cannot name —
|
||
// so read by its name. Null is a group that is not there, or a value BetterChat's
|
||
// parser threw on without catching (a number too big for an int).
|
||
object result = betterChat.Call("API_SetGroupField", group, want.Field, want.Value);
|
||
string answer = result != null ? result.ToString() : null;
|
||
|
||
if (answer != "Success")
|
||
{
|
||
ChatFailure(job, group, want.Field, answer ?? "no-answer");
|
||
continue;
|
||
}
|
||
|
||
job.ChatApplied++;
|
||
touched = true;
|
||
}
|
||
|
||
if (!touched)
|
||
return;
|
||
|
||
game = ChatGroupFields(betterChat, group);
|
||
string priority = ChatFieldValue(game, "Priority");
|
||
|
||
ConsoleSystem.Run(ConsoleSystem.Option.Server, "chat", "group", "set", group, "Priority", priority);
|
||
|
||
// Read back rather than trusted: the command has no answer a plugin can read, and a
|
||
// console path that did something else would otherwise be counted as a save.
|
||
if (ChatFieldValue(ChatGroupFields(betterChat, group), "Priority") == priority)
|
||
job.ChatSaved++;
|
||
else
|
||
ChatFailure(job, group, "Priority", "not-saved");
|
||
}
|
||
|
||
/// <summary>
|
||
/// Main thread. Removes a BetterChat group the site no longer styles (D139). There is
|
||
/// no API for it, only BetterChat's own <c>chat group remove</c>, run as the server
|
||
/// console. A group already gone is removed: the retirement is idempotent.
|
||
/// </summary>
|
||
private void RemoveChatGroup(PermSyncJob job, string group)
|
||
{
|
||
Plugin betterChat = BetterChatPlugin();
|
||
|
||
if (betterChat == null)
|
||
{
|
||
ChatFailure(job, group, null, "betterchat-unloaded");
|
||
return;
|
||
}
|
||
|
||
if (ChatGroupExists(betterChat, group))
|
||
ConsoleSystem.Run(ConsoleSystem.Option.Server, "chat", "group", "remove", group);
|
||
|
||
if (ChatGroupExists(betterChat, group))
|
||
ChatFailure(job, group, null, "still-present");
|
||
else
|
||
job.ChatRemoved.Add(group);
|
||
}
|
||
|
||
private static void ChatFailure(PermSyncJob job, string group, string field, string reason)
|
||
{
|
||
var row = new Dictionary<string, object> { { "group", group }, { "reason", reason } };
|
||
|
||
if (field != null)
|
||
row["field"] = field;
|
||
|
||
job.ChatFailed.Add(row);
|
||
}
|
||
|
||
private static bool ChatGroupExists(Plugin betterChat, string group)
|
||
{
|
||
object exists = betterChat.Call("API_GroupExists", group);
|
||
return exists is bool && (bool)exists;
|
||
}
|
||
|
||
private static Dictionary<string, object> ChatGroupFields(Plugin betterChat, string group)
|
||
{
|
||
return betterChat.Call("API_GetGroupFields", group) as Dictionary<string, object>
|
||
?? new Dictionary<string, object>();
|
||
}
|
||
|
||
/// <summary>
|
||
/// A field's value as the text the site compares: <c>true</c>/<c>false</c> for a
|
||
/// boolean, never C#'s <c>True</c>, and an invariant number.
|
||
/// </summary>
|
||
private static string ChatFieldValue(Dictionary<string, object> fields, string field)
|
||
{
|
||
object value = null;
|
||
|
||
foreach (KeyValuePair<string, object> kv in fields)
|
||
{
|
||
if (string.Equals(kv.Key, field, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
value = kv.Value;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (value == null)
|
||
return null;
|
||
|
||
if (value is bool)
|
||
return (bool)value ? "true" : "false";
|
||
|
||
if (value is IFormattable)
|
||
return ((IFormattable)value).ToString(null, System.Globalization.CultureInfo.InvariantCulture);
|
||
|
||
return value.ToString();
|
||
}
|
||
|
||
/// <summary>
|
||
/// BetterChat's default for a field on a new group. A group's title defaults to its own
|
||
/// name in brackets, except <c>default</c>'s, which is <c>[Player]</c>.
|
||
/// </summary>
|
||
private static string ChatDefault(string group, string field)
|
||
{
|
||
if (string.Equals(field, "Title", StringComparison.OrdinalIgnoreCase))
|
||
return group == "default" ? "[Player]" : "[" + group + "]";
|
||
|
||
string value;
|
||
return ChatFieldDefaults.TryGetValue(field, out value) ? value : null;
|
||
}
|
||
|
||
/// <summary>
|
||
/// The titles this server holds, from the server console. Given a Steam id it also
|
||
/// prints what BetterChat would render for that player — which is how a rig proves a
|
||
/// title without a person typing in chat (§33.3).
|
||
/// </summary>
|
||
[ConsoleCommand("rg.titles")]
|
||
private void CmdTitles(ConsoleSystem.Arg arg)
|
||
{
|
||
Plugin betterChat = BetterChatPlugin();
|
||
|
||
var lines = new List<string>
|
||
{
|
||
$"protocol={ProtocolVersion} titles={_titles.Count} " +
|
||
$"betterChat={(betterChat != null ? betterChat.Version.ToString() : "absent")} " +
|
||
$"registered={(_titlesRegistered && betterChat != null)}",
|
||
$"last set: {_lastTitlesSummary}"
|
||
};
|
||
|
||
// `GetString`, not `Args[0]`: current Rust hands a console command StringViews.
|
||
string steamId = arg.GetString(0, null);
|
||
|
||
if (!string.IsNullOrEmpty(steamId))
|
||
{
|
||
ulong id;
|
||
string held;
|
||
|
||
lines.Add(ulong.TryParse(steamId, out id) && _titles.TryGetValue(id, out held)
|
||
? $"held: {held}"
|
||
: "held: (none)");
|
||
|
||
Oxide.Core.Libraries.Covalence.IPlayer known = covalence.Players.FindPlayerById(steamId);
|
||
|
||
if (betterChat == null)
|
||
lines.Add("rendered: (BetterChat is not loaded)");
|
||
else if (known == null)
|
||
lines.Add("rendered: (this server has never seen that player)");
|
||
else
|
||
lines.Add("rendered: " + (betterChat.Call("API_GetFormattedMessage", known, "(rg.titles)", false) as string ?? "(no answer)"));
|
||
}
|
||
|
||
arg.ReplyWith(string.Join(Environment.NewLine, lines.ToArray()));
|
||
}
|
||
|
||
/// <summary>What events are counting and what rewards are held, from the server console.</summary>
|
||
[ConsoleCommand("rg.rewards")]
|
||
private void CmdRewards(ConsoleSystem.Arg arg)
|
||
{
|
||
var lines = new List<string>
|
||
{
|
||
$"protocol={ProtocolVersion} tallies={_runTallies.Count} credits={_credits.Count} " +
|
||
$"kits={(KitsPlugin() != null)} maxTallies={MaxTallies()} maxRecipients={MaxRecipients()}"
|
||
};
|
||
|
||
long now = NowMs();
|
||
|
||
foreach (RunTally t in _runTallies.Values)
|
||
{
|
||
lines.Add(
|
||
$" run {t.RunId}: {t.Score}{(t.KillsOf != null ? "/" + t.KillsOf : "")} " +
|
||
$"{(t.Zone != null ? "zone '" + t.Zone + "'" : "whole server")} people={t.People.Count} " +
|
||
$"{(Counting(t, now) ? "counting, " + (t.DeadlineMs - now) / 1000L + "s left" : "closed")}");
|
||
}
|
||
|
||
foreach (KitCredit c in _credits.Values)
|
||
lines.Add($" credit {c.SteamId} {c.Kit}: earned={c.Earned} applied={c.Applied} redeemed={c.Consumed}");
|
||
|
||
lines.Add($"last: {_lastRewardSummary}");
|
||
arg.ReplyWith(string.Join(Environment.NewLine, lines.ToArray()));
|
||
}
|
||
|
||
// ---- the map (protocol 11, PLAN.md §30) ----
|
||
//
|
||
// The picture of this wipe's map and everything that moves on it, in four verbs. Five rules
|
||
// shape the section, and each came from the rig (§30.0):
|
||
//
|
||
// * **Nothing renders unasked.** The game's renderer holds the main thread for 8.5 s on a
|
||
// 3000 map and 21 s at full scale. The picture comes from Rust+'s cache, which the game
|
||
// fills at boot when `app.port` is set; only an admin's `map.render` ever draws one, and
|
||
// then on a later frame than the one that answered (D109).
|
||
// * **The picture is read, never copied, on the main thread.** Hashing it and cutting it
|
||
// into base64 slices happen on the thread pool. The bytes are never mutated once made, so
|
||
// handing the array to another thread is safe.
|
||
// * **No sweep per ask.** What moves is kept in sets by `OnEntitySpawned` / `OnEntityKill`,
|
||
// seeded by one sweep when the world is ready. An ask copies positions out of the sets.
|
||
// * **Filtering is the website's job** (§8.5). `map.live` answers every layer, and which of
|
||
// them a viewer may see is decided by the module, never here.
|
||
// * **Every map verb is outside `EventsEnabled`.** Nothing here changes the world.
|
||
|
||
/// <summary>
|
||
/// One slice of the picture on the wire. Base64 grows it by a third, and the result must
|
||
/// fit the game link's 1 MiB line cap with its envelope (the asset bridge's phase 7 shape).
|
||
/// </summary>
|
||
private const int MapChunkBytes = 512 * 1024;
|
||
|
||
/// <summary>
|
||
/// The render's geometry, which is the Rust+ cache's geometry exactly: half scale, and an
|
||
/// ocean margin of 500 PIXELS on each side, unscaled (§30.0).
|
||
/// </summary>
|
||
private const float MapRenderScale = 0.5f;
|
||
|
||
private const int MapOceanMargin = 500;
|
||
|
||
/// <summary>The default most sleepers, and the most bases, one live answer carries.</summary>
|
||
private const int MapDefaultMax = 2000;
|
||
|
||
private const string MapSourceCompanion = "companion";
|
||
private const string MapSourceRendered = "rendered";
|
||
private const string MapSourceNone = "none";
|
||
|
||
/// <summary>What this plugin knows about the picture it can serve. Replaced whole, never edited.</summary>
|
||
private sealed class MapPicture
|
||
{
|
||
public string MapKey;
|
||
public string Source;
|
||
public byte[] Bytes;
|
||
public int Width;
|
||
public int Height;
|
||
public string Background;
|
||
|
||
/// <summary>Null until the thread pool has hashed <see cref="Bytes"/>.</summary>
|
||
public volatile string Sha256;
|
||
}
|
||
|
||
/// <summary>A rendered picture's companion file: which map it is a picture of.</summary>
|
||
private sealed class RenderedMapMeta
|
||
{
|
||
public string MapKey;
|
||
public string Background;
|
||
public long RenderedMs;
|
||
public long TookMs;
|
||
}
|
||
|
||
/// <summary>The current picture, or null. Main thread writes; the pool only reads what it is handed.</summary>
|
||
private MapPicture _mapPicture;
|
||
|
||
/// <summary>A picture this plugin rendered for the current map, read from disk at most once per map.</summary>
|
||
private MapPicture _mapRendered;
|
||
|
||
private string _mapRenderedCheckedFor;
|
||
|
||
/// <summary>Set between a `map.render` being accepted and the render finishing.</summary>
|
||
private bool _mapRendering;
|
||
|
||
private readonly HashSet<BaseEntity> _mapWorld = new HashSet<BaseEntity>();
|
||
private readonly HashSet<BaseEntity> _mapBases = new HashSet<BaseEntity>();
|
||
|
||
private string _lastMapSummary = "none yet";
|
||
private long _mapLiveAsks, _mapLiveLastMs, _mapLiveMaxMs;
|
||
|
||
private int MapMaxSleepers()
|
||
{
|
||
return _config != null ? _config.MapMaxSleepers : MapDefaultMax;
|
||
}
|
||
|
||
private int MapMaxBases()
|
||
{
|
||
return _config != null ? _config.MapMaxBases : MapDefaultMax;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Which map this is. A procedural map is its size, seed and salt; a custom map is the
|
||
/// hash of the URL it was downloaded from. The key says WHICH map, and the picture's own
|
||
/// SHA-256 says which picture of it (§30.2).
|
||
/// </summary>
|
||
private static string MapKey()
|
||
{
|
||
if (World.Procedural)
|
||
{
|
||
return string.Format(System.Globalization.CultureInfo.InvariantCulture,
|
||
"{0}.{1}.{2}", World.Size, World.Seed, World.Salt);
|
||
}
|
||
|
||
string url = !string.IsNullOrEmpty(World.Url) ? World.Url : ConVar.Server.levelurl;
|
||
return "custom." + Sha256Hex(Encoding.UTF8.GetBytes(url ?? string.Empty));
|
||
}
|
||
|
||
private static string Sha256Hex(byte[] bytes)
|
||
{
|
||
using (var sha = System.Security.Cryptography.SHA256.Create())
|
||
{
|
||
byte[] hash = sha.ComputeHash(bytes);
|
||
var sb = new StringBuilder(hash.Length * 2);
|
||
|
||
foreach (byte b in hash)
|
||
sb.Append(b.ToString("x2", System.Globalization.CultureInfo.InvariantCulture));
|
||
|
||
return sb.ToString();
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// A JPEG's own width and height, read from its frame header. The picture says what size
|
||
/// it is; a private field that says so as well is a second opinion that can drift.
|
||
/// </summary>
|
||
private static bool JpegSize(byte[] bytes, out int width, out int height)
|
||
{
|
||
width = 0;
|
||
height = 0;
|
||
|
||
if (bytes == null || bytes.Length < 4 || bytes[0] != 0xFF || bytes[1] != 0xD8)
|
||
return false;
|
||
|
||
int i = 2;
|
||
|
||
while (i + 9 < bytes.Length)
|
||
{
|
||
if (bytes[i] != 0xFF)
|
||
return false;
|
||
|
||
byte marker = bytes[i + 1];
|
||
int length = (bytes[i + 2] << 8) | bytes[i + 3];
|
||
|
||
// SOF0..SOF15 carry the frame's size — except DHT (C4), JPG (C8) and DAC (CC),
|
||
// which share the range and do not.
|
||
if (marker >= 0xC0 && marker <= 0xCF && marker != 0xC4 && marker != 0xC8 && marker != 0xCC)
|
||
{
|
||
height = (bytes[i + 5] << 8) | bytes[i + 6];
|
||
width = (bytes[i + 7] << 8) | bytes[i + 8];
|
||
return width > 0 && height > 0;
|
||
}
|
||
|
||
i += 2 + length;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
private static System.Reflection.PropertyInfo _companionImage;
|
||
private static System.Reflection.FieldInfo _companionBackground;
|
||
private static bool _companionResolved;
|
||
|
||
/// <summary>
|
||
/// Main thread. The Rust+ cache's picture, or null. Read by reflection, by NAME, and only
|
||
/// the two members this needs: <c>CompanionServer</c> types hold the server's Rust+
|
||
/// secret, and a probe that enumerated one printed it into the log (§30.0). A Facepunch
|
||
/// rename makes this answer null, which is <c>none</c> — never an exception.
|
||
/// </summary>
|
||
private static byte[] CompanionImage(out string background)
|
||
{
|
||
background = null;
|
||
|
||
try
|
||
{
|
||
if (!_companionResolved)
|
||
{
|
||
_companionResolved = true;
|
||
Type map = typeof(BasePlayer).Assembly.GetType("CompanionServer.Handlers.Map", false);
|
||
|
||
if (map != null)
|
||
{
|
||
const System.Reflection.BindingFlags Static =
|
||
System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic |
|
||
System.Reflection.BindingFlags.Static;
|
||
|
||
_companionImage = map.GetProperty("ImageData", Static);
|
||
_companionBackground = map.GetField("_background", Static);
|
||
}
|
||
}
|
||
|
||
if (_companionImage == null)
|
||
return null;
|
||
|
||
byte[] bytes = _companionImage.GetValue(null, null) as byte[];
|
||
|
||
if (bytes == null || bytes.Length == 0)
|
||
return null;
|
||
|
||
if (_companionBackground != null)
|
||
background = _companionBackground.GetValue(null) as string;
|
||
|
||
return bytes;
|
||
}
|
||
catch (Exception)
|
||
{
|
||
return null;
|
||
}
|
||
}
|
||
|
||
private string MapDir()
|
||
{
|
||
return Path.Combine(Interface.Oxide.DataDirectory, Name);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Main thread. A picture this plugin rendered earlier for THIS map, from disk. Read once
|
||
/// per map key, so a restart within the map does not ask for another render (§30.2).
|
||
/// </summary>
|
||
private MapPicture RenderedFromDisk(string key)
|
||
{
|
||
if (_mapRenderedCheckedFor == key)
|
||
return _mapRendered;
|
||
|
||
_mapRenderedCheckedFor = key;
|
||
_mapRendered = null;
|
||
|
||
try
|
||
{
|
||
string meta = Path.Combine(MapDir(), "map.json");
|
||
string jpeg = Path.Combine(MapDir(), "map.jpg");
|
||
|
||
if (!File.Exists(meta) || !File.Exists(jpeg))
|
||
return null;
|
||
|
||
RenderedMapMeta m = JsonConvert.DeserializeObject<RenderedMapMeta>(File.ReadAllText(meta));
|
||
|
||
if (m == null || m.MapKey != key)
|
||
return null;
|
||
|
||
byte[] bytes = File.ReadAllBytes(jpeg);
|
||
int w, h;
|
||
|
||
if (!JpegSize(bytes, out w, out h))
|
||
return null;
|
||
|
||
_mapRendered = new MapPicture
|
||
{
|
||
MapKey = key,
|
||
Source = MapSourceRendered,
|
||
Bytes = bytes,
|
||
Width = w,
|
||
Height = h,
|
||
Background = m.Background
|
||
};
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Puts($"map: could not read the rendered picture: {Flatten(ex.Message)}");
|
||
}
|
||
|
||
return _mapRendered;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Main thread. The picture to serve now: the Rust+ cache when it has one, else a render
|
||
/// of this map kept on disk, else null. Rebuilt only when the key or the cache's array
|
||
/// changes, and hashed on the pool the first time a picture is seen.
|
||
/// </summary>
|
||
private MapPicture CurrentPicture()
|
||
{
|
||
string key = MapKey();
|
||
string background;
|
||
byte[] companion = CompanionImage(out background);
|
||
MapPicture current = _mapPicture;
|
||
|
||
if (companion != null)
|
||
{
|
||
if (current != null && current.MapKey == key && ReferenceEquals(current.Bytes, companion))
|
||
return current;
|
||
|
||
int w, h;
|
||
|
||
if (!JpegSize(companion, out w, out h))
|
||
return null;
|
||
|
||
current = new MapPicture
|
||
{
|
||
MapKey = key,
|
||
Source = MapSourceCompanion,
|
||
Bytes = companion,
|
||
Width = w,
|
||
Height = h,
|
||
Background = background
|
||
};
|
||
}
|
||
else
|
||
{
|
||
current = RenderedFromDisk(key);
|
||
}
|
||
|
||
_mapPicture = current;
|
||
HashLater(current);
|
||
return current;
|
||
}
|
||
|
||
private static void HashLater(MapPicture picture)
|
||
{
|
||
if (picture == null || picture.Sha256 != null)
|
||
return;
|
||
|
||
ThreadPool.QueueUserWorkItem(_ =>
|
||
{
|
||
try
|
||
{
|
||
picture.Sha256 = Sha256Hex(picture.Bytes);
|
||
}
|
||
catch (Exception)
|
||
{
|
||
// Left null: `map.info` keeps answering `hashing`, and a later ask tries again.
|
||
}
|
||
});
|
||
}
|
||
|
||
/// <summary>
|
||
/// Main thread. The game's own grid, from its own function: how many cells a side has and
|
||
/// how wide one is. <c>MapHelper</c> is what the in-game map labels with, so the website
|
||
/// draws the game's grid by construction rather than from a constant somebody measured
|
||
/// once (§30.3).
|
||
/// </summary>
|
||
private static void GameGrid(out int cells, out float cellSize)
|
||
{
|
||
float size = ConVar.Server.worldsize;
|
||
float half = size / 2f;
|
||
|
||
Vector2i last = MapHelper.PositionToGrid(new UnityEngine.Vector3(half - 0.5f, 0f, -half + 0.5f));
|
||
cells = Math.Max(1, last.x + 1);
|
||
cellSize = size / cells;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Main thread. The monuments the in-game map shows: those with a display phrase, in the
|
||
/// same <c>kind#n</c> vocabulary as <c>world.monuments</c>, so a monument a step names and
|
||
/// a monument on the map are the same token.
|
||
/// </summary>
|
||
private static List<object> MapMonuments()
|
||
{
|
||
var rows = new List<object>();
|
||
|
||
foreach (KeyValuePair<string, List<MonumentInfo>> kv in MonumentGroups())
|
||
{
|
||
for (int i = 0; i < kv.Value.Count; i++)
|
||
{
|
||
MonumentInfo m = kv.Value[i];
|
||
string phrase = m.displayPhrase != null ? m.displayPhrase.english : null;
|
||
|
||
if (string.IsNullOrEmpty(phrase))
|
||
continue;
|
||
|
||
UnityEngine.Vector3 p = m.transform.position;
|
||
|
||
rows.Add(new Dictionary<string, object>
|
||
{
|
||
{ "value", kv.Value.Count > 1 ? kv.Key + "#" + (i + 1) : kv.Key },
|
||
{ "kind", kv.Key },
|
||
{ "label", phrase },
|
||
{ "x", Math.Round(p.x, 1) },
|
||
{ "z", Math.Round(p.z, 1) },
|
||
{ "grid", MapHelper.PositionToString(p) }
|
||
});
|
||
}
|
||
}
|
||
|
||
return rows;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Stage one: what exists. Reads the cache, and hashes it on the pool once per map; no
|
||
/// other work. <c>sha256</c> is absent while the hash is still being computed, and the
|
||
/// answer then says <c>hashing</c> so the caller asks again.
|
||
/// </summary>
|
||
private void HandleMapInfo(string reqId)
|
||
{
|
||
if (!MapReady(reqId))
|
||
return;
|
||
|
||
MapPicture picture = CurrentPicture();
|
||
int cells;
|
||
float cellSize;
|
||
GameGrid(out cells, out cellSize);
|
||
|
||
Dictionary<string, object> reply = Frame("map.info", "reply");
|
||
reply["mapKey"] = MapKey();
|
||
reply["worldSize"] = ConVar.Server.worldsize;
|
||
reply["oceanMargin"] = MapOceanMargin;
|
||
reply["gridCells"] = cells;
|
||
reply["gridCellSize"] = Math.Round(cellSize, 3);
|
||
reply["monuments"] = MapMonuments();
|
||
reply["rendering"] = _mapRendering;
|
||
|
||
if (picture == null)
|
||
{
|
||
reply["source"] = MapSourceNone;
|
||
}
|
||
else
|
||
{
|
||
reply["source"] = picture.Source;
|
||
reply["bytes"] = picture.Bytes.Length;
|
||
reply["width"] = picture.Width;
|
||
reply["height"] = picture.Height;
|
||
reply["chunks"] = (picture.Bytes.Length + MapChunkBytes - 1) / MapChunkBytes;
|
||
reply["chunkBytes"] = MapChunkBytes;
|
||
|
||
if (!string.IsNullOrEmpty(picture.Background))
|
||
reply["background"] = picture.Background;
|
||
|
||
string hash = picture.Sha256;
|
||
|
||
if (hash != null)
|
||
reply["sha256"] = hash;
|
||
else
|
||
reply["hashing"] = true;
|
||
}
|
||
|
||
if (reqId != null)
|
||
reply["reqId"] = reqId;
|
||
|
||
EmitFrame(reply);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Stage two: one slice of the picture. Refused <c>stale</c> when the map or the picture
|
||
/// moved since the caller asked stage one, so a fetch that straddles a wipe cannot splice
|
||
/// two maps. The slice is base64'd and serialized on the pool: at 512 KiB that is work the
|
||
/// main thread has no business doing.
|
||
/// </summary>
|
||
private void HandleMapFetch(Dictionary<string, object> cmd, string reqId)
|
||
{
|
||
if (!MapReady(reqId))
|
||
return;
|
||
|
||
if (_mapRendering)
|
||
{
|
||
EmitMapError(reqId, "busy", "the server is drawing its map; ask again when map.info says it is done");
|
||
return;
|
||
}
|
||
|
||
string key = StringField(cmd, "mapKey");
|
||
string sha = StringField(cmd, "sha256");
|
||
int n = IntField(cmd, "chunk");
|
||
MapPicture picture = CurrentPicture();
|
||
|
||
if (picture == null)
|
||
{
|
||
EmitMapError(reqId, "no-picture", "this server has no picture of its map");
|
||
return;
|
||
}
|
||
|
||
string hash = picture.Sha256;
|
||
|
||
if (key != picture.MapKey || hash == null || !string.Equals(sha, hash, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
EmitMapError(reqId, "stale", "the map or its picture changed since it was described; ask map.info again");
|
||
return;
|
||
}
|
||
|
||
int chunks = (picture.Bytes.Length + MapChunkBytes - 1) / MapChunkBytes;
|
||
|
||
if (n < 0 || n >= chunks)
|
||
{
|
||
EmitMapError(reqId, "malformed", $"this picture has {chunks} slices, numbered from 0");
|
||
return;
|
||
}
|
||
|
||
Dictionary<string, object> reply = Frame("map.chunk", "reply");
|
||
reply["mapKey"] = picture.MapKey;
|
||
reply["sha256"] = hash;
|
||
reply["chunk"] = n;
|
||
reply["chunks"] = chunks;
|
||
|
||
if (reqId != null)
|
||
reply["reqId"] = reqId;
|
||
|
||
byte[] bytes = picture.Bytes;
|
||
int offset = n * MapChunkBytes;
|
||
int length = Math.Min(MapChunkBytes, bytes.Length - offset);
|
||
|
||
ThreadPool.QueueUserWorkItem(_ =>
|
||
{
|
||
try
|
||
{
|
||
reply["data"] = Convert.ToBase64String(bytes, offset, length);
|
||
Emit(JsonConvert.SerializeObject(reply, Formatting.None));
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
// Nothing is sent, and the caller's ask times out: a slice is re-asked, never trusted half-made.
|
||
Interface.Oxide.NextTick(() => Puts($"map: could not encode slice {n}: {Flatten(ex.Message)}"));
|
||
}
|
||
});
|
||
}
|
||
|
||
/// <summary>
|
||
/// The admin fallback (D109): draw the map with the game's own renderer, once. Refused
|
||
/// unless there is no picture at all, and while the world is loading. Answers
|
||
/// <c>accepted</c> AT ONCE and renders on a later frame, because the render outlasts the
|
||
/// sidecar's reply budget on any map larger than the rig's; the caller polls
|
||
/// <c>map.info</c> until its source is <c>rendered</c>.
|
||
/// </summary>
|
||
private void HandleMapRender(Dictionary<string, object> cmd, string reqId)
|
||
{
|
||
if (!MapReady(reqId))
|
||
return;
|
||
|
||
if (_mapRendering)
|
||
{
|
||
EmitMapError(reqId, "busy", "the server is already drawing its map");
|
||
return;
|
||
}
|
||
|
||
MapPicture picture = CurrentPicture();
|
||
|
||
if (picture != null)
|
||
{
|
||
EmitMapError(reqId, "has-picture",
|
||
$"this server already has a picture of its map ({picture.Source}); nothing needs drawing");
|
||
return;
|
||
}
|
||
|
||
_mapRendering = true;
|
||
string actor = StringField(cmd, "actor");
|
||
string key = MapKey();
|
||
|
||
Dictionary<string, object> reply = Frame("map.render", "reply");
|
||
reply["accepted"] = true;
|
||
reply["mapKey"] = key;
|
||
reply["worldSize"] = ConVar.Server.worldsize;
|
||
|
||
if (reqId != null)
|
||
reply["reqId"] = reqId;
|
||
|
||
EmitFrame(reply);
|
||
|
||
Puts($"map: drawing the map for {key} at an admin's request{(string.IsNullOrEmpty(actor) ? "" : " (" + actor + ")")}; " +
|
||
"the server will stall while it does");
|
||
|
||
// A timer rather than NextTick, so the reply above is on the wire before the stall.
|
||
timer.Once(0.5f, () => RenderMap(key));
|
||
}
|
||
|
||
/// <summary>Main thread, deliberately: the renderer reads the terrain, which is main-thread-only.</summary>
|
||
private void RenderMap(string key)
|
||
{
|
||
var clock = System.Diagnostics.Stopwatch.StartNew();
|
||
|
||
try
|
||
{
|
||
if (MapKey() != key)
|
||
{
|
||
_lastMapSummary = "a render was abandoned: the map changed before it started";
|
||
return;
|
||
}
|
||
|
||
System.Reflection.MethodInfo render = null;
|
||
|
||
foreach (System.Reflection.MethodInfo m in typeof(MapImageRenderer).GetMethods(
|
||
System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static))
|
||
{
|
||
if (m.Name == "Render" && m.ReturnType == typeof(byte[]))
|
||
{
|
||
render = m;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (render == null)
|
||
{
|
||
_lastMapSummary = "a render failed: the game has no MapImageRenderer.Render";
|
||
Puts("map: " + _lastMapSummary);
|
||
return;
|
||
}
|
||
|
||
// Called by parameter NAME, as the probe did: a reordered signature must not swap
|
||
// the scale for the margin.
|
||
System.Reflection.ParameterInfo[] ps = render.GetParameters();
|
||
object[] args = new object[ps.Length];
|
||
|
||
for (int i = 0; i < ps.Length; i++)
|
||
{
|
||
if (ps[i].IsOut)
|
||
continue;
|
||
|
||
if (ps[i].Name == "scale")
|
||
args[i] = MapRenderScale;
|
||
else if (ps[i].Name == "oceanMargin")
|
||
args[i] = MapOceanMargin;
|
||
else if (ps[i].Name == "lossy")
|
||
args[i] = true;
|
||
else if (ps[i].Name == "transparent")
|
||
args[i] = false;
|
||
else if (ps[i].HasDefaultValue)
|
||
args[i] = ps[i].DefaultValue;
|
||
}
|
||
|
||
byte[] bytes = render.Invoke(null, args) as byte[];
|
||
clock.Stop();
|
||
|
||
int w, h;
|
||
|
||
if (bytes == null || !JpegSize(bytes, out w, out h))
|
||
{
|
||
_lastMapSummary = $"a render produced no picture after {clock.ElapsedMilliseconds} ms";
|
||
Puts("map: " + _lastMapSummary);
|
||
return;
|
||
}
|
||
|
||
string background = null;
|
||
|
||
for (int i = 0; i < ps.Length; i++)
|
||
{
|
||
if (ps[i].IsOut && args[i] is UnityEngine.Color)
|
||
background = "#" + UnityEngine.ColorUtility.ToHtmlStringRGB((UnityEngine.Color)args[i]);
|
||
}
|
||
|
||
Directory.CreateDirectory(MapDir());
|
||
File.WriteAllBytes(Path.Combine(MapDir(), "map.jpg"), bytes);
|
||
File.WriteAllText(Path.Combine(MapDir(), "map.json"), JsonConvert.SerializeObject(new RenderedMapMeta
|
||
{
|
||
MapKey = key,
|
||
Background = background,
|
||
RenderedMs = NowMs(),
|
||
TookMs = clock.ElapsedMilliseconds
|
||
}, Formatting.Indented));
|
||
|
||
// Read back through the path a restart would take, so what is served now is exactly
|
||
// what a restart would serve.
|
||
_mapRenderedCheckedFor = null;
|
||
_mapPicture = null;
|
||
CurrentPicture();
|
||
|
||
_lastMapSummary = $"drew the map for {key}: {w}x{h}, {bytes.Length} bytes, {clock.ElapsedMilliseconds} ms of stall";
|
||
Puts("map: " + _lastMapSummary);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_lastMapSummary = $"a render threw after {clock.ElapsedMilliseconds} ms: {Flatten((ex.InnerException ?? ex).Message)}";
|
||
Puts("map: " + _lastMapSummary);
|
||
}
|
||
finally
|
||
{
|
||
_mapRendering = false;
|
||
}
|
||
}
|
||
|
||
/// <summary>Refuses a map command until the world is loaded, as a <c>map.error</c>.</summary>
|
||
private bool MapReady(string reqId)
|
||
{
|
||
if (_worldReady)
|
||
return true;
|
||
|
||
EmitMapError(reqId, "not-ready", "the server is still loading its world; ask again once it has started");
|
||
return false;
|
||
}
|
||
|
||
// ── What moves ────────────────────────────────────────────────────────────
|
||
|
||
/// <summary>The world-event types the map follows (D112). A cheap type test: this runs on every spawn.</summary>
|
||
private static bool IsMapWorldThing(BaseNetworkable entity)
|
||
{
|
||
return entity is CargoShip || entity is PatrolHelicopter || entity is CH47Helicopter ||
|
||
entity is BradleyAPC || entity is SupplyDrop || entity is HackableLockedCrate;
|
||
}
|
||
|
||
/// <summary>Tool cupboards, and a player's vending machine — never a monument's (NPC) one.</summary>
|
||
private static bool IsMapBase(BaseNetworkable entity)
|
||
{
|
||
return entity is BuildingPrivlidge || (entity is VendingMachine && !(entity is NPCVendingMachine));
|
||
}
|
||
|
||
/// <summary>
|
||
/// Runs on EVERY entity spawn on the server, tens of thousands at boot: two type tests and,
|
||
/// rarely, a set insert. Void, so it can never veto a spawn.
|
||
/// </summary>
|
||
private void OnEntitySpawned(BaseNetworkable entity)
|
||
{
|
||
MarkHook("OnEntitySpawned");
|
||
|
||
if (entity == null)
|
||
return;
|
||
|
||
if (IsMapWorldThing(entity))
|
||
_mapWorld.Add((BaseEntity)entity);
|
||
else if (IsMapBase(entity))
|
||
_mapBases.Add((BaseEntity)entity);
|
||
}
|
||
|
||
private void OnEntityKill(BaseNetworkable entity)
|
||
{
|
||
MarkHook("OnEntityKill");
|
||
|
||
var e = entity as BaseEntity;
|
||
|
||
if (e == null)
|
||
return;
|
||
|
||
if (!_mapWorld.Remove(e))
|
||
_mapBases.Remove(e);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Main thread, once per load when the world is there: the one sweep that seeds the sets.
|
||
/// The spawn hook keeps them after that; a hotload has no spawns to hear, which is why this
|
||
/// runs at all.
|
||
/// </summary>
|
||
private void SeedMapSets()
|
||
{
|
||
var clock = System.Diagnostics.Stopwatch.StartNew();
|
||
_mapWorld.Clear();
|
||
_mapBases.Clear();
|
||
|
||
foreach (BaseNetworkable n in BaseNetworkable.serverEntities)
|
||
{
|
||
if (IsMapWorldThing(n))
|
||
_mapWorld.Add((BaseEntity)n);
|
||
else if (IsMapBase(n))
|
||
_mapBases.Add((BaseEntity)n);
|
||
}
|
||
|
||
clock.Stop();
|
||
Puts($"map: following {_mapWorld.Count} world things and {_mapBases.Count} bases ({clock.ElapsedMilliseconds} ms sweep)");
|
||
|
||
// Hashed now rather than at the first ask, so the first `map.info` usually has it.
|
||
CurrentPicture();
|
||
}
|
||
|
||
/// <summary>
|
||
/// A player's Steam id as text, from <c>userID</c> itself. <c>UserIDString</c> is a
|
||
/// cached string the game fills for a player it has seen connect, and the phase 14 walk
|
||
/// read it back empty from a sleeper made on the server: an id the map could not name
|
||
/// is an own dot and a clan mate the site can never find.
|
||
/// </summary>
|
||
/// <summary>
|
||
/// Whether the player placed this piece or is authorised on the cupboard that covers
|
||
/// it — the test for "their own base" (F3). Main thread: it reads the building's
|
||
/// privilege. A piece with no cupboard is judged on its owner alone.
|
||
/// </summary>
|
||
private static bool OwnsOrAuthed(BasePlayer player, BaseEntity entity)
|
||
{
|
||
if (player == null || entity == null)
|
||
return false;
|
||
|
||
if (entity.OwnerID == (ulong)player.userID)
|
||
return true;
|
||
|
||
// `authorizedPlayers` rather than `IsAuthed`, for AddAuthorization's reason: it is the
|
||
// set this plugin already reads for the raid frame, proven on both frameworks.
|
||
BuildingPrivlidge privilege = entity.GetBuildingPrivilege();
|
||
return privilege != null && privilege.authorizedPlayers != null
|
||
&& privilege.authorizedPlayers.Contains(player.userID);
|
||
}
|
||
|
||
private static string SteamIdOf(BasePlayer p)
|
||
{
|
||
ulong id = p.userID;
|
||
return id.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||
}
|
||
|
||
private static bool Gone(BaseEntity e)
|
||
{
|
||
return e == null || e.IsDestroyed || e.transform == null;
|
||
}
|
||
|
||
private static Dictionary<string, object> At(UnityEngine.Vector3 p)
|
||
{
|
||
return new Dictionary<string, object>
|
||
{
|
||
{ "x", Math.Round(p.x, 1) },
|
||
{ "z", Math.Round(p.z, 1) }
|
||
};
|
||
}
|
||
|
||
private static string WorldKind(BaseEntity e)
|
||
{
|
||
if (e is CargoShip) return "cargo";
|
||
if (e is PatrolHelicopter) return "heli";
|
||
if (e is CH47Helicopter) return "chinook";
|
||
if (e is BradleyAPC) return "bradley";
|
||
if (e is SupplyDrop) return "supply";
|
||
return "crate";
|
||
}
|
||
|
||
/// <summary>
|
||
/// What moves, every layer at once. Positions only, and nothing a player typed about a
|
||
/// base: no owner, no authorised list, no shop name (§30.5). The two unbounded layers —
|
||
/// sleepers and bases — stop at their bound and say so with <c>truncated</c>; a read has
|
||
/// nobody to refuse.
|
||
/// </summary>
|
||
private void HandleMapLive(string reqId)
|
||
{
|
||
if (!MapReady(reqId))
|
||
return;
|
||
|
||
var clock = System.Diagnostics.Stopwatch.StartNew();
|
||
|
||
var world = new List<object>();
|
||
var dead = new List<BaseEntity>();
|
||
|
||
foreach (BaseEntity e in _mapWorld)
|
||
{
|
||
if (Gone(e))
|
||
{
|
||
dead.Add(e);
|
||
continue;
|
||
}
|
||
|
||
Dictionary<string, object> row = At(e.transform.position);
|
||
row["kind"] = WorldKind(e);
|
||
|
||
var crate = e as HackableLockedCrate;
|
||
|
||
if (crate != null)
|
||
{
|
||
if (crate.IsFullyHacked())
|
||
row["hacked"] = true;
|
||
else if (crate.IsBeingHacked())
|
||
row["hackLeftSec"] = (int)Math.Max(0f, HackableLockedCrate.requiredHackSeconds - crate.hackSeconds);
|
||
}
|
||
|
||
world.Add(row);
|
||
}
|
||
|
||
foreach (BaseEntity e in dead)
|
||
_mapWorld.Remove(e);
|
||
|
||
var events = new List<object>();
|
||
|
||
foreach (WorldEntry w in _world.Values)
|
||
{
|
||
Dictionary<string, object> row;
|
||
|
||
if (w.Kind == WorldKindZone)
|
||
{
|
||
row = At(new UnityEngine.Vector3(w.X, 0f, w.Z));
|
||
row["radius"] = Math.Round(w.Radius, 1);
|
||
|
||
if (!string.IsNullOrEmpty(w.Name))
|
||
row["name"] = w.Name;
|
||
}
|
||
else
|
||
{
|
||
// Where it is NOW: an NPC walks, and a crate can be knocked about.
|
||
var found = FindEntity(w.Id) as BaseEntity;
|
||
|
||
if (Gone(found))
|
||
continue;
|
||
|
||
row = At(found.transform.position);
|
||
row["prefab"] = w.Prefab;
|
||
}
|
||
|
||
row["kind"] = w.Kind;
|
||
row["runId"] = w.RunId;
|
||
events.Add(row);
|
||
}
|
||
|
||
var players = new List<object>();
|
||
|
||
foreach (BasePlayer p in BasePlayer.activePlayerList)
|
||
{
|
||
if (Gone(p) || p.IsDead())
|
||
continue;
|
||
|
||
Dictionary<string, object> row = At(p.transform.position);
|
||
row["steamId"] = SteamIdOf(p);
|
||
row["name"] = p.displayName;
|
||
row["sleeping"] = p.IsSleeping();
|
||
row["online"] = true;
|
||
players.Add(row);
|
||
}
|
||
|
||
int maxSleepers = MapMaxSleepers();
|
||
int sleepers = 0;
|
||
bool sleepersTruncated = false;
|
||
|
||
foreach (BasePlayer p in BasePlayer.sleepingPlayerList)
|
||
{
|
||
if (Gone(p) || p.IsDead() || p.IsConnected)
|
||
continue;
|
||
|
||
if (sleepers >= maxSleepers)
|
||
{
|
||
sleepersTruncated = true;
|
||
break;
|
||
}
|
||
|
||
Dictionary<string, object> row = At(p.transform.position);
|
||
row["steamId"] = SteamIdOf(p);
|
||
row["name"] = p.displayName;
|
||
row["sleeping"] = true;
|
||
row["online"] = false;
|
||
players.Add(row);
|
||
sleepers++;
|
||
}
|
||
|
||
int maxBases = MapMaxBases();
|
||
var bases = new List<object>();
|
||
bool basesTruncated = false;
|
||
dead.Clear();
|
||
|
||
foreach (BaseEntity e in _mapBases)
|
||
{
|
||
if (Gone(e))
|
||
{
|
||
dead.Add(e);
|
||
continue;
|
||
}
|
||
|
||
if (bases.Count >= maxBases)
|
||
{
|
||
basesTruncated = true;
|
||
continue;
|
||
}
|
||
|
||
Dictionary<string, object> row = At(e.transform.position);
|
||
row["kind"] = e is BuildingPrivlidge ? "tc" : "vending";
|
||
bases.Add(row);
|
||
}
|
||
|
||
foreach (BaseEntity e in dead)
|
||
_mapBases.Remove(e);
|
||
|
||
clock.Stop();
|
||
_mapLiveAsks++;
|
||
_mapLiveLastMs = clock.ElapsedMilliseconds;
|
||
_mapLiveMaxMs = Math.Max(_mapLiveMaxMs, _mapLiveLastMs);
|
||
|
||
Dictionary<string, object> reply = Frame("map.live", "reply");
|
||
reply["mapKey"] = MapKey();
|
||
reply["world"] = world;
|
||
reply["events"] = events;
|
||
reply["players"] = players;
|
||
reply["bases"] = bases;
|
||
reply["tookMs"] = clock.ElapsedMilliseconds;
|
||
|
||
if (sleepersTruncated)
|
||
reply["playersTruncated"] = true;
|
||
|
||
if (basesTruncated)
|
||
reply["basesTruncated"] = true;
|
||
|
||
if (reqId != null)
|
||
reply["reqId"] = reqId;
|
||
|
||
EmitFrame(reply);
|
||
}
|
||
|
||
/// <summary>The map's one refusal frame. Every reason is a word the module can switch on.</summary>
|
||
private void EmitMapError(string reqId, string reason, string message)
|
||
{
|
||
Dictionary<string, object> frame = Frame("map.error", "reply");
|
||
frame["reason"] = reason;
|
||
|
||
if (!string.IsNullOrEmpty(message))
|
||
frame["message"] = message;
|
||
|
||
if (reqId != null)
|
||
frame["reqId"] = reqId;
|
||
|
||
_lastMapSummary = $"refused ({reason}): {message}";
|
||
EmitFrame(frame);
|
||
}
|
||
|
||
/// <summary>What the map can serve and what asking it costs, from the server console.</summary>
|
||
[ConsoleCommand("rg.map")]
|
||
private void CmdMap(ConsoleSystem.Arg arg)
|
||
{
|
||
// Re-read rather than print the last answer: the cache can empty, or a render land,
|
||
// between asks, and a console that shows yesterday's source misleads the one person
|
||
// looking at it.
|
||
MapPicture picture = _worldReady ? CurrentPicture() : _mapPicture;
|
||
int cells;
|
||
float cellSize;
|
||
GameGrid(out cells, out cellSize);
|
||
string hash = picture != null ? picture.Sha256 : null;
|
||
|
||
arg.ReplyWith(
|
||
$"protocol={ProtocolVersion} mapKey={MapKey()} source={(picture != null ? picture.Source : MapSourceNone)} " +
|
||
$"bytes={(picture != null ? picture.Bytes.Length : 0)} size={(picture != null ? picture.Width + "x" + picture.Height : "-")} " +
|
||
$"sha256={(hash != null ? hash.Substring(0, 12) : "-")} rendering={_mapRendering} " +
|
||
$"grid={cells}x{cellSize:0.##}m" + Environment.NewLine +
|
||
$"following world={_mapWorld.Count} bases={_mapBases.Count} " +
|
||
$"live asks={_mapLiveAsks} last={_mapLiveLastMs}ms max={_mapLiveMaxMs}ms" + Environment.NewLine +
|
||
$"last: {_lastMapSummary}");
|
||
}
|
||
|
||
// ---- reading a command's fields ----
|
||
//
|
||
// `JsonConvert` hands back `object`, and every field below arrives as one. These are the
|
||
// three shapes this protocol uses, read defensively: a malformed field is a missing field,
|
||
// never an exception on the main thread.
|
||
|
||
private static string StringField(Dictionary<string, object> row, string key)
|
||
{
|
||
object value;
|
||
return row != null && row.TryGetValue(key, out value) && value != null
|
||
? value.ToString()
|
||
: null;
|
||
}
|
||
|
||
private static int IntField(Dictionary<string, object> row, string key)
|
||
{
|
||
object value;
|
||
|
||
if (row == null || !row.TryGetValue(key, out value) || value == null)
|
||
return 0;
|
||
|
||
int parsed;
|
||
return int.TryParse(value.ToString(), out parsed) ? parsed : 0;
|
||
}
|
||
|
||
private static long LongField(Dictionary<string, object> row, string key)
|
||
{
|
||
object value;
|
||
|
||
if (row == null || !row.TryGetValue(key, out value) || value == null)
|
||
return 0L;
|
||
|
||
long parsed;
|
||
return long.TryParse(value.ToString(), out parsed) ? parsed : 0L;
|
||
}
|
||
|
||
/// <summary>The objects in an array field, skipping anything that is not one.</summary>
|
||
private static IEnumerable<Dictionary<string, object>> Rows(Dictionary<string, object> row, string key)
|
||
{
|
||
object value;
|
||
|
||
if (row == null || !row.TryGetValue(key, out value))
|
||
yield break;
|
||
|
||
var list = value as Newtonsoft.Json.Linq.JArray;
|
||
|
||
if (list == null)
|
||
yield break;
|
||
|
||
foreach (Newtonsoft.Json.Linq.JToken item in list)
|
||
{
|
||
var obj = item as Newtonsoft.Json.Linq.JObject;
|
||
|
||
if (obj == null)
|
||
continue;
|
||
|
||
yield return obj.ToObject<Dictionary<string, object>>();
|
||
}
|
||
}
|
||
|
||
/// <summary>The strings in an array field.</summary>
|
||
private static IEnumerable<string> Strings(Dictionary<string, object> row, string key)
|
||
{
|
||
object value;
|
||
|
||
if (row == null || !row.TryGetValue(key, out value))
|
||
yield break;
|
||
|
||
var list = value as Newtonsoft.Json.Linq.JArray;
|
||
|
||
if (list == null)
|
||
yield break;
|
||
|
||
foreach (Newtonsoft.Json.Linq.JToken item in list)
|
||
{
|
||
if (item == null || item.Type == Newtonsoft.Json.Linq.JTokenType.Null)
|
||
continue;
|
||
|
||
yield return item.ToString();
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Permission and group names are compared case-insensitively because both frameworks
|
||
/// store them lowered. Comparing what the site sent against what the store holds
|
||
/// without this makes `Kits.VIP` and `kits.vip` two different privileges, one of which
|
||
/// is granted for ever and never found again.
|
||
/// </summary>
|
||
private static string Lower(string value)
|
||
{
|
||
return value == null ? null : value.Trim().ToLowerInvariant();
|
||
}
|
||
|
||
// ---- diagnostics ----
|
||
|
||
/// <summary>
|
||
/// The link's own counters, from the server console. This is how an operator answers
|
||
/// "is the bridge working" without reading a log, and it is the first thing to ask for
|
||
/// when the website says a server is offline.
|
||
/// </summary>
|
||
[ConsoleCommand("rg.link")]
|
||
private void CmdLink(ConsoleSystem.Arg arg)
|
||
{
|
||
arg.ReplyWith(
|
||
$"protocol={ProtocolVersion} serverId={(_config != null ? _config.ServerId : "?")} " +
|
||
$"connected={_connected} depth={Volatile.Read(ref _depth)} " +
|
||
$"sent={Interlocked.Read(ref _sent)} dropped={Interlocked.Read(ref _dropped)} " +
|
||
$"received={Interlocked.Read(ref _received)} connects={Interlocked.Read(ref _connects)} " +
|
||
$"writeErrors={Interlocked.Read(ref _writeErrors)} bootId={_bootId} " +
|
||
$"wipeId={(_wipeId ?? "none")}");
|
||
}
|
||
|
||
/// <summary>
|
||
/// What the last permission sync did, from the server console.
|
||
///
|
||
/// <para>
|
||
/// It is the fastest way to tell the two silent failures apart on a rig: a sync that
|
||
/// reported `unresolved` names granted nothing because no loaded plugin registered
|
||
/// them, and a sync that reported `pending` members could not put somebody in a group
|
||
/// because the store has never seen them. Both look like "the website says they have
|
||
/// it and the game disagrees" from the other end.
|
||
/// </para>
|
||
/// </summary>
|
||
[ConsoleCommand("rg.perms")]
|
||
private void CmdPerms(ConsoleSystem.Arg arg)
|
||
{
|
||
string[] groups = permission.GetGroups();
|
||
string[] registered = permission.GetPermissions();
|
||
|
||
arg.ReplyWith(
|
||
$"protocol={ProtocolVersion} registered={(registered != null ? registered.Length : 0)} " +
|
||
$"groups={(groups != null ? groups.Length : 0)} " +
|
||
$"running={(_permJob != null)} inventoryBuilding={(_inventoryJob != null)}" + Environment.NewLine +
|
||
$"last sync: {_lastPermSummary}" + Environment.NewLine +
|
||
$"last inventory: {_lastInventorySummary}");
|
||
}
|
||
|
||
/// <summary>
|
||
/// Where this framework keeps configuration, and what the last write from the website
|
||
/// did.
|
||
///
|
||
/// <para>
|
||
/// The root is the interesting half. It is <c>oxide/config</c> on one rig and
|
||
/// <c>carbon/configs</c> on the other, from this same source file, and an operator
|
||
/// wondering why the website is offering a tree they do not recognise is usually
|
||
/// looking at a server whose directories were moved.
|
||
/// </para>
|
||
/// </summary>
|
||
[ConsoleCommand("rg.config")]
|
||
private void CmdConfig(ConsoleSystem.Arg arg)
|
||
{
|
||
string root = ConfigRoot();
|
||
ConfigWriteJob job = _configJob;
|
||
string waiting = "no";
|
||
|
||
if (job != null)
|
||
{
|
||
double seconds = (DateTime.UtcNow - job.PhaseStarted).TotalSeconds;
|
||
waiting = $"{job.Reload} ({job.Phase.ToString().ToLowerInvariant()}, " +
|
||
$"{seconds:0}s of {ConfigReloadCeilingSeconds:0}s)";
|
||
}
|
||
|
||
arg.ReplyWith(
|
||
$"protocol={ProtocolVersion} framework={(IsCarbon() ? "carbon" : "oxide")} " +
|
||
$"root={(string.IsNullOrEmpty(root) ? "(none)" : root)}" + Environment.NewLine +
|
||
$"log={NewestLogFile() ?? "(none: a failed reload waits out the ceiling)"}" + Environment.NewLine +
|
||
$"waiting={waiting}" + Environment.NewLine +
|
||
$"last write: {_lastConfigSummary}");
|
||
}
|
||
|
||
/// <summary>
|
||
/// Which of this plugin's hooks have actually fired, and how often.
|
||
///
|
||
/// <para>
|
||
/// Both frameworks bind hooks by name and arity through reflection, with no
|
||
/// compile-time check and no warning when a name matches nothing. So a hook Facepunch
|
||
/// renames on a Thursday, and a hook one framework's catalogue does not list, both
|
||
/// present identically: silence. This command is the standing answer to both, on
|
||
/// whichever framework the server is actually running — and it outranks either
|
||
/// catalogue, because it is a measurement rather than a document.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// A zero is not automatically a fault: <c>OnNewSave</c> fires on wipe day and
|
||
/// <c>OnUserBanned</c> only when somebody is banned. It is a fault when the thing it
|
||
/// names has demonstrably happened.
|
||
/// </para>
|
||
/// </summary>
|
||
[ConsoleCommand("rg.hooks")]
|
||
private void CmdHooks(ConsoleSystem.Arg arg)
|
||
{
|
||
var fired = new List<string>();
|
||
var silent = new List<string>();
|
||
|
||
foreach (string hook in ExpectedHooks)
|
||
{
|
||
long count;
|
||
_hookCounts.TryGetValue(hook, out count);
|
||
|
||
if (count > 0L)
|
||
fired.Add($"{hook}={count}");
|
||
else
|
||
silent.Add(hook);
|
||
}
|
||
|
||
string firedText = fired.Count > 0 ? string.Join(" ", fired.ToArray()) : "(none)";
|
||
string silentText = silent.Count > 0 ? string.Join(" ", silent.ToArray()) : "(none)";
|
||
|
||
arg.ReplyWith(
|
||
$"protocol={ProtocolVersion} hooks={ExpectedHooks.Length} " +
|
||
$"fired={fired.Count} silent={silent.Count}" + Environment.NewLine +
|
||
$"fired: {firedText}" + Environment.NewLine +
|
||
$"silent: {silentText}");
|
||
}
|
||
}
|
||
}
|