diff --git a/overlay.toml b/overlay.toml
index cebf872..1b3315f 100644
--- a/overlay.toml
+++ b/overlay.toml
@@ -26,10 +26,10 @@
# the same change that alters the emitters, exactly as the sidecar bumps
# PROTOCOL_VERSION and the module bumps its own constant.
#
-# Current: 7 — the transport, the read path, identity, the permission mirror,
+# Current: 8 — the transport, the read path, identity, the permission mirror,
# configuration from the site, first-party clans, and a raid frame that names who
# lives there (docs/rust-link/PROTOCOL.md §13).
-protocol = 7
+protocol = 8
# ── Oxide compatibility ──────────────────────────────────────────────────────
#
diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs
index 4083667..7350e89 100644
--- a/overlay/oxide/plugins/RunicGateway.cs
+++ b/overlay/oxide/plugins/RunicGateway.cs
@@ -57,7 +57,7 @@ namespace Oxide.Plugins
/// overlay's overlay.toml carries this same number so the installer can refuse
/// to pair a sidecar and an overlay that disagree.
///
- private const int ProtocolVersion = 7;
+ private const int ProtocolVersion = 8;
///
/// Loopback reconnects are cheap, so the ceiling is low. A sidecar restart should cost
@@ -313,6 +313,10 @@ namespace Oxide.Plugins
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();
+
_running = false;
_stopping.Set();
Wake.Set();
@@ -358,6 +362,16 @@ namespace Oxide.Plugins
/// a season and the site must not lose its history for it.
///
public string ServerId = "main";
+
+ ///
+ /// Whether the website may borrow values on this server for events (protocol 8,
+ /// D76). Off until an operator turns it on. Letting staff moderate from a
+ /// screen they are looking at, and letting a schedule change the world at four in
+ /// the morning, are different consents, and one switch cannot honestly express
+ /// both. It gates taking a lease only: listing and giving back always work, so
+ /// turning it off never strands a value somebody already borrowed.
+ ///
+ public bool EventsEnabled = false;
}
protected override void LoadDefaultConfig()
@@ -382,12 +396,23 @@ namespace Oxide.Plugins
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.
+ if (Config["EventsEnabled"] == null)
+ Config.WriteObject(_config, true);
}
private void Loaded()
{
LoadConfigValues();
Puts($"protocol {ProtocolVersion}, serverId '{_config.ServerId}', sidecar {_config.Host}:{_config.Port}");
+
+ // Here rather than at `OnServerInitialized`: the link thread starts in `Init`, so a
+ // lease command can arrive before the world has finished loading, and it must find
+ // the holds already in place rather than have them loaded over it afterwards.
+ LoadLeases();
}
// ---- the outbound queue ----
@@ -810,6 +835,21 @@ namespace Oxide.Plugins
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;
+
default:
Puts($"unknown command '{verb}'");
break;
@@ -2735,6 +2775,14 @@ namespace Oxide.Plugins
public readonly List Foreign = new List();
public readonly List Pending = new List();
+ ///
+ /// Group permissions an event lease holds (protocol 8). Neither applied to nor
+ /// reported foreign; what the site asked for is kept on the hold instead.
+ ///
+ public readonly List Leased = new List();
+
+ public readonly HashSet LeasedSeen = new HashSet();
+
/// Permission names already reported unresolved, so each is reported once.
public readonly HashSet UnresolvedSeen = new HashSet();
@@ -3084,6 +3132,9 @@ namespace Oxide.Plugins
if (!Registered(job, op.Object))
break;
+ if (DeferToLease(job, op, true))
+ break;
+
if (permission.GroupHasPermission(op.Subject, op.Object))
{
job.AlreadyCorrect++;
@@ -3158,6 +3209,9 @@ namespace Oxide.Plugins
break;
case PermOpKind.RetireGroupPermission:
+ if (DeferToLease(job, op, false))
+ break;
+
if (permission.GroupHasPermission(op.Subject, op.Object))
{
permission.RevokeGroupPermission(op.Subject, op.Object);
@@ -3276,6 +3330,13 @@ namespace Oxide.Plugins
if (string.IsNullOrEmpty(lowered) || wanted.Contains(lowered))
continue;
+ // A pair an event has borrowed is not somebody's console grant (§27.3).
+ if (LeasedGroupPermission(group, lowered) != null)
+ {
+ NoteLeased(job, group, lowered);
+ continue;
+ }
+
job.Foreign.Add(new Dictionary
{
{ "kind", "group-permission" },
@@ -3285,6 +3346,43 @@ namespace Oxide.Plugins
}
}
+ ///
+ /// A sync op about a pair an event lease holds. The store is left to the lease; what
+ /// the site wants is recorded on the hold and becomes the value the lease gives back
+ /// (§27.3). Returns whether it deferred.
+ ///
+ private bool DeferToLease(PermSyncJob job, PermOp op, bool wants)
+ {
+ LeaseHold hold = LeasedGroupPermission(op.Subject, op.Object);
+
+ if (hold == null)
+ return false;
+
+ if (hold.SiteWants != wants)
+ {
+ hold.SiteWants = wants;
+ SaveLeases();
+ }
+
+ NoteLeased(job, op.Subject, op.Object);
+ return true;
+ }
+
+ private static void NoteLeased(PermSyncJob job, string group, string perm)
+ {
+ string id = Lower(group) + "/" + Lower(perm);
+
+ if (!job.LeasedSeen.Add(id))
+ return;
+
+ job.Leased.Add(new Dictionary
+ {
+ { "kind", "group-permission" },
+ { "subject", Lower(group) },
+ { "object", Lower(perm) }
+ });
+ }
+
///
/// Whether this server has a plugin that registered the name, recording the answer once
/// per sync when it has not.
@@ -3378,6 +3476,7 @@ namespace Oxide.Plugins
report["absent"] = job.Absent;
report["unresolved"] = job.Unresolved;
report["foreign"] = job.Foreign;
+ report["leased"] = job.Leased;
report["pending"] = job.Pending;
report["operations"] = job.Ops.Count;
@@ -4002,9 +4101,21 @@ namespace Oxide.Plugins
}
}
+ // 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, "no reload was asked for", null);
+ 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;
}
@@ -4190,6 +4301,66 @@ namespace Oxide.Plugins
}
}
+ ///
+ /// Main thread. If a write touched this plugin's own config, take the one setting that
+ /// is safe to change live — EventsEnabled — from what was written. Returns
+ /// whether it did.
+ ///
+ private bool ApplyOwnSettings(List staged)
+ {
+ string own;
+
+ try
+ {
+ own = Path.GetFullPath(Path.Combine(Interface.Oxide.ConfigDirectory, Name + ".json"));
+ }
+ catch (Exception)
+ {
+ return false;
+ }
+
+ foreach (StagedConfigWrite file in staged)
+ {
+ string full;
+
+ try
+ {
+ full = Path.GetFullPath(file.FullPath);
+ }
+ catch (Exception)
+ {
+ continue;
+ }
+
+ if (!string.Equals(full, own, StringComparison.OrdinalIgnoreCase))
+ continue;
+
+ try
+ {
+ var root = Newtonsoft.Json.Linq.JObject.Parse(file.Text);
+ Newtonsoft.Json.Linq.JToken flag = root["EventsEnabled"];
+
+ if (flag == null || flag.Type != Newtonsoft.Json.Linq.JTokenType.Boolean || _config == null)
+ return false;
+
+ bool now = (bool)flag;
+
+ if (_config.EventsEnabled != now)
+ Puts($"EventsEnabled is now {now} (set from the website)");
+
+ _config.EventsEnabled = now;
+ return true;
+ }
+ catch (Exception ex)
+ {
+ Puts($"could not re-read own settings: {Flatten(ex.Message)}");
+ return false;
+ }
+ }
+
+ return false;
+ }
+
///
/// Keeps a copy of a file about to be overwritten, and the nine before it.
///
@@ -4573,6 +4744,1037 @@ namespace Oxide.Plugins
return (long)(utc - new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)).TotalMilliseconds;
}
+ // ---- leases (protocol 8, PLAN.md §27) ----
+ //
+ // An event does not edit the world. It borrows a value and gives it back. The website
+ // decides WHEN (core's `core.lease` reads the baseline, reserves the target and schedules
+ // the restore); this plugin decides WHETHER, holds the deadline, and does the writing.
+ //
+ // Four rules, each the answer to a way the obvious version goes wrong:
+ //
+ // • **The deadline lives here.** A hold arms a deadline and the plugin restores the
+ // baseline when it passes, whether or not the website is ever heard from again. Core's
+ // restore is the normal path; this is the fail-safe that makes an unattended change at
+ // 04:00 defensible. `holdMs` is authoritative and `untilMs` is display only — an
+ // absolute time computed on the website and honoured here is a deadline measured
+ // against two clocks.
+ // • **Release is compare-and-set, never a blind write.** A value that is no longer what
+ // the lease applied was moved by somebody on purpose: answer `lease.drifted` with what
+ // is there now and leave it alone.
+ // • **Values cross as text and compare parsed.** `1` and `1.0` are one number and two
+ // strings; a drift check on formatted numbers blames an innocent operator.
+ // • **A hold longer than the ceiling is refused, never clamped.** A clamp gives the
+ // website a shorter lease than it believes it has, and the website is the half that
+ // schedules the restore.
+ //
+ // And one that is Rust's own (§27.1): **a convar hold is memory-only and a restart is a
+ // free restore, but a group permission is persisted by the framework.** So holds are
+ // written to a file under this plugin's data directory with the boot they were taken
+ // under, and on load a convar hold from an earlier boot is dropped (the restart already
+ // gave it back) while a permission hold is re-armed — or restored at once, if its
+ // deadline passed while the server was down.
+
+ ///
+ /// The longest a lease may be held: seven days (D77). The website declares the same
+ /// ceiling; this one is independent of it and exists for the case where the website
+ /// is wrong.
+ ///
+ private const long LeaseMaxHoldMs = 7L * 24L * 60L * 60L * 1000L;
+
+ /// How often held deadlines are checked. A lease ends within a second of its time.
+ private const float LeaseCheckSeconds = 1f;
+
+ /// The wire key of the one lease that is not a convar.
+ private const string GroupPermissionKey = "group.permission";
+
+ ///
+ /// One convar the website may borrow, and the bounds this plugin holds it to. The
+ /// website's declaration carries the same bounds so a bad value is refused on a form;
+ /// these are what is true when the website is wrong.
+ ///
+ private sealed class LeaseKey
+ {
+ public string Key;
+ public string Family;
+ public double Min;
+ public double Max;
+
+ public LeaseKey(string key, string family, double min, double max)
+ {
+ Key = key;
+ Family = family;
+ Min = min;
+ Max = max;
+ }
+ }
+
+ ///
+ /// Every convar a lease may name (D74: the game's own convars, nothing Carbon-only).
+ ///
+ ///
+ /// An allowlist rather than "any convar", because the catalogue is a promise: a key
+ /// here has been set, observed changing something in a running game, and restored. A
+ /// value the server reads once at boot applies cleanly, reads back cleanly and does
+ /// nothing, and nothing but a live walk can tell the two apart (§9).
+ ///
+ ///
+ private static readonly LeaseKey[] LeaseKeys =
+ {
+ new LeaseKey("decay.scale", "decay", 0, 10),
+
+ // Every population is per square kilometre — vehicles too, whatever the game's own
+ // help text says ("active on the server"): on a 9 km² map a value of 3 is a target of
+ // 27. Each was walked: set, seen moving `spawn.report`'s target, and given back.
+ new LeaseKey("bear.population", "population", 0, 50),
+ new LeaseKey("polarbear.population", "population", 0, 50),
+ new LeaseKey("boar.population", "population", 0, 50),
+ new LeaseKey("chicken.population", "population", 0, 50),
+ new LeaseKey("stag.population", "population", 0, 50),
+ new LeaseKey("wolf2.population", "population", 0, 50),
+ new LeaseKey("ridablehorse.population", "population", 0, 50),
+ new LeaseKey("crocodile.population", "population", 0, 50),
+ new LeaseKey("panther.population", "population", 0, 50),
+ new LeaseKey("tiger.population", "population", 0, 50),
+ new LeaseKey("snakehazard.population", "population", 0, 50),
+
+ new LeaseKey("minicopter.population", "population", 0, 50),
+ new LeaseKey("scraptransporthelicopter.population", "population", 0, 50),
+ new LeaseKey("hotairballoon.population", "population", 0, 50),
+ new LeaseKey("modularcar.population", "population", 0, 50),
+ new LeaseKey("motorrowboat.population", "population", 0, 50),
+ new LeaseKey("rhib.rhibpopulation", "population", 0, 50),
+ new LeaseKey("traincar.population", "population", 0, 50),
+
+ // The population system's own scalars. Only the two MINIMUMS: the game scales between
+ // min and max by how full the server is, so on a rig with nobody on it the effective
+ // rate and density ARE the minimums, and `spawn.scalars` showed both moving. The two
+ // maximums only matter with players online, which no walk has had — so they are not
+ // lent until one does (§9: a key that cannot be seen doing something is not advertised).
+ new LeaseKey("spawn.min_rate", "spawn", 0, 10),
+ new LeaseKey("spawn.min_density", "spawn", 0, 10)
+ };
+
+ ///
+ /// One value this plugin is holding. Persisted as it is, so every field is public and
+ /// plain.
+ ///
+ private sealed class LeaseHold
+ {
+ public string Key;
+
+ /// Null for a convar; group/permission for a group permission.
+ public string Target;
+
+ public string Baseline;
+ public string Applied;
+
+ /// When the hold ends, on this host's clock: taken time plus holdMs .
+ public long DeadlineMs;
+
+ /// The website's absolute deadline, for display. Never compared against.
+ public long UntilMs;
+
+ public string RunId;
+ public string BootId;
+ public long TakenMs;
+
+ ///
+ /// What the website's permission set said about this pair while the lease held it:
+ /// null for nothing, else whether the site wants it granted. A group permission
+ /// held by a lease belongs to the lease until it ends; a sync that arrives in the
+ /// meantime is recorded here and honoured at release instead of being applied
+ /// under the lease's feet (§27.3).
+ ///
+ public bool? SiteWants;
+ }
+
+ /// Holds by . Main thread only.
+ private Dictionary _leases = new Dictionary();
+
+ /// Convars by lowered full name, resolved once. Null until first needed.
+ private Dictionary _convars;
+
+ private Timer _leaseTimer;
+
+ /// The last thing a lease did, for rg.leases . Diagnostics only.
+ private string _lastLeaseSummary = "none yet";
+
+ private static string LeaseSlot(string key, string target)
+ {
+ return string.IsNullOrEmpty(target) ? key : key + "#" + target;
+ }
+
+ private static LeaseKey FindLeaseKey(string key)
+ {
+ foreach (LeaseKey k in LeaseKeys)
+ {
+ if (string.Equals(k.Key, key, StringComparison.OrdinalIgnoreCase))
+ return k;
+ }
+
+ return null;
+ }
+
+ ///
+ /// Main thread. The game's convar, or null when this build has no such convar or it
+ /// is one the plugin refuses to hold.
+ ///
+ ///
+ /// A Saved convar is refused (§27.1). The game writes those to
+ /// serverauto.cfg , so a hold on one would survive the restart that is
+ /// supposed to end it — and a lease that outlives its own fail-safe is the one thing
+ /// a lease exists to prevent. None of the allowlist is Saved today; this is
+ /// what keeps a Facepunch change to that from becoming our bug silently.
+ ///
+ ///
+ private ConsoleSystem.Command LeaseConvar(string key, out string refusal)
+ {
+ refusal = null;
+
+ if (_convars == null)
+ {
+ _convars = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ ConsoleSystem.Command[] all = ConsoleSystem.Index.All;
+
+ if (all != null)
+ {
+ foreach (ConsoleSystem.Command c in all)
+ {
+ if (c != null && c.Variable && !string.IsNullOrEmpty(c.FullName))
+ _convars[c.FullName] = c;
+ }
+ }
+ }
+
+ ConsoleSystem.Command command;
+
+ if (!_convars.TryGetValue(key, out command) || command == null || command.SetOveride == null)
+ {
+ refusal = $"this server has no convar '{key}'";
+ return null;
+ }
+
+ if (command.Saved)
+ {
+ refusal = $"'{key}' is saved to serverauto.cfg, so a restart would not give it back";
+ return null;
+ }
+
+ return command;
+ }
+
+ private static bool TryParseNumber(string text, out double value)
+ {
+ return double.TryParse(
+ text, System.Globalization.NumberStyles.Float,
+ System.Globalization.CultureInfo.InvariantCulture, out value);
+ }
+
+ private static bool TryParseFlag(string text, out bool value)
+ {
+ value = false;
+
+ if (text == null)
+ return false;
+
+ string t = text.Trim().ToLowerInvariant();
+
+ if (t == "true" || t == "1")
+ {
+ value = true;
+ return true;
+ }
+
+ if (t == "false" || t == "0")
+ return true;
+
+ return false;
+ }
+
+ ///
+ /// Whether two values are the same value, parsed. A convar reads back through
+ /// float.ToString() , so the tolerance is a float's, not a double's.
+ ///
+ private static bool SameLeaseValue(string key, string a, string b)
+ {
+ if (key == GroupPermissionKey)
+ {
+ bool x, y;
+ return TryParseFlag(a, out x) && TryParseFlag(b, out y) && x == y;
+ }
+
+ double p, q;
+
+ if (!TryParseNumber(a, out p) || !TryParseNumber(b, out q))
+ return string.Equals(a, b, StringComparison.Ordinal);
+
+ return Math.Abs(p - q) <= 1e-4 * Math.Max(1.0, Math.Max(Math.Abs(p), Math.Abs(q)));
+ }
+
+ ///
+ /// Splits a group-permission target at its LAST slash. A permission name never
+ /// contains one; a group name is a free-form string an operator typed, so it is the
+ /// half allowed to.
+ ///
+ private static bool SplitGroupTarget(string target, out string group, out string perm)
+ {
+ group = null;
+ perm = null;
+
+ if (string.IsNullOrEmpty(target))
+ return false;
+
+ int slash = target.LastIndexOf('/');
+
+ if (slash <= 0 || slash >= target.Length - 1)
+ return false;
+
+ group = Lower(target.Substring(0, slash));
+ perm = Lower(target.Substring(slash + 1));
+ return !string.IsNullOrEmpty(group) && !string.IsNullOrEmpty(perm);
+ }
+
+ ///
+ /// Main thread. The value a key holds right now, as text — or null with a reason.
+ /// is set when the target itself no longer exists, which a
+ /// release treats as nothing left to give back rather than as a failure.
+ ///
+ private string ReadLeaseValue(string key, string target, out string refusal, out bool gone)
+ {
+ gone = false;
+
+ if (key == GroupPermissionKey)
+ {
+ string group, perm;
+
+ if (!SplitGroupTarget(target, out group, out perm))
+ {
+ refusal = "a group permission is named as group/permission";
+ return null;
+ }
+
+ if (!permission.GroupExists(group))
+ {
+ gone = true;
+ refusal = $"there is no group '{group}' on this server";
+ return null;
+ }
+
+ refusal = null;
+ return permission.GroupHasPermission(group, perm) ? "true" : "false";
+ }
+
+ if (FindLeaseKey(key) == null)
+ {
+ refusal = $"'{key}' is not a value this server lends";
+ return null;
+ }
+
+ ConsoleSystem.Command command = LeaseConvar(key, out refusal);
+ return command == null ? null : command.String;
+ }
+
+ ///
+ /// Main thread. Writes a value, then reads it back — the read-back is the answer, not
+ /// the call. Returns null on success, or why not.
+ ///
+ private string WriteLeaseValue(string key, string target, string value)
+ {
+ if (key == GroupPermissionKey)
+ {
+ string group, perm;
+
+ if (!SplitGroupTarget(target, out group, out perm))
+ return "a group permission is named as group/permission";
+
+ bool grant;
+
+ if (!TryParseFlag(value, out grant))
+ return $"'{value}' is not true or false";
+
+ // Our own write raises OnGroupPermissionGranted/Revoked. Without this the site
+ // would be told a human had just edited the store, and it would reconcile against
+ // the lease's own change (§27.3).
+ _applyingPermissions = true;
+
+ try
+ {
+ // **`null`, not `this`, as the owner.** With an owner, Oxide's grant first asks
+ // `PermissionExists(perm, owner)` — whether THAT plugin registered the name —
+ // and returns without a word when it did not. Every permission a lease borrows
+ // belongs to some other plugin, so `this` made every grant a silent no-op. The
+ // read-back below is what caught it.
+ if (grant)
+ permission.GrantGroupPermission(group, perm, null);
+ else
+ permission.RevokeGroupPermission(group, perm);
+ }
+ finally
+ {
+ _applyingPermissions = false;
+ }
+
+ if (permission.GroupHasPermission(group, perm) != grant)
+ return $"the store did not {(grant ? "grant" : "revoke")} '{perm}' on '{group}'";
+
+ return null;
+ }
+
+ string refusal;
+ ConsoleSystem.Command command = LeaseConvar(key, out refusal);
+
+ if (command == null)
+ return refusal;
+
+ command.Set(value);
+
+ if (!SameLeaseValue(key, command.String, value))
+ return $"'{key}' reads back as {command.String} after being set to {value}";
+
+ return null;
+ }
+
+ ///
+ /// Main thread. Protocol 8's catalogue: every key this server lends and what it holds
+ /// now, and every hold in force.
+ ///
+ ///
+ /// Narrowed by key (and target ) when asked, because a group permission
+ /// has no single current value — it is one per pair — and a caller asking about one
+ /// pair wants that pair's row, not the store.
+ ///
+ ///
+ private void HandleLeaseList(Dictionary cmd, string reqId)
+ {
+ string onlyKey = Lower(StringField(cmd, "key"));
+ string onlyTarget = StringField(cmd, "target");
+ var rows = new List();
+
+ foreach (LeaseKey k in LeaseKeys)
+ {
+ if (!string.IsNullOrEmpty(onlyKey) && onlyKey != k.Key)
+ continue;
+
+ rows.Add(LeaseRow(k.Key, k.Family, k.Min, k.Max, null));
+ }
+
+ if (onlyKey == GroupPermissionKey && !string.IsNullOrEmpty(onlyTarget))
+ rows.Add(LeaseRow(GroupPermissionKey, "group", 0, 1, onlyTarget));
+
+ var holds = new List();
+
+ foreach (LeaseHold h in _leases.Values)
+ holds.Add(HoldBody(h));
+
+ Dictionary reply = Frame("lease.list", "reply");
+ reply["eventsEnabled"] = _config != null && _config.EventsEnabled;
+ reply["maxHoldMs"] = LeaseMaxHoldMs;
+ reply["leases"] = rows;
+ reply["holds"] = holds;
+
+ if (reqId != null)
+ reply["reqId"] = reqId;
+
+ EmitFrame(reply);
+ }
+
+ private Dictionary LeaseRow(string key, string family, double min, double max, string target)
+ {
+ var row = new Dictionary
+ {
+ { "key", key },
+ { "family", family }
+ };
+
+ if (key != GroupPermissionKey)
+ {
+ row["min"] = min;
+ row["max"] = max;
+ }
+ else
+ {
+ row["target"] = target;
+ }
+
+ string refusal;
+ bool gone;
+ string current = ReadLeaseValue(key, target, out refusal, out gone);
+
+ if (current != null)
+ row["current"] = current;
+ else
+ row["unreadable"] = refusal;
+
+ if (gone)
+ row["targetGone"] = true;
+
+ LeaseHold hold;
+ bool held = _leases.TryGetValue(LeaseSlot(key, target), out hold);
+ row["held"] = held;
+
+ if (held)
+ {
+ row["baseline"] = hold.Baseline;
+ row["applied"] = hold.Applied;
+ row["untilMs"] = hold.UntilMs;
+ row["runId"] = hold.RunId;
+ }
+
+ return row;
+ }
+
+ private static Dictionary HoldBody(LeaseHold h)
+ {
+ var body = new Dictionary
+ {
+ { "key", h.Key },
+ { "baseline", h.Baseline },
+ { "applied", h.Applied },
+ { "untilMs", h.UntilMs },
+ { "remainingMs", Math.Max(0L, h.DeadlineMs - NowMs()) }
+ };
+
+ if (!string.IsNullOrEmpty(h.Target))
+ body["target"] = h.Target;
+
+ if (!string.IsNullOrEmpty(h.RunId))
+ body["runId"] = h.RunId;
+
+ return body;
+ }
+
+ ///
+ /// Main thread. Take a value for a while.
+ ///
+ ///
+ /// A key this plugin already holds keeps its ORIGINAL baseline. Core reserves
+ /// a target before it applies, so a second holder is refused on the website's side;
+ /// a second apply arriving here means the first one's answer was lost, and core is
+ /// trying again. The value to give back at the end is still what was there before
+ /// anybody borrowed it, not the first attempt's value.
+ ///
+ ///
+ private void HandleLeaseApply(Dictionary cmd, string reqId)
+ {
+ string key = Lower(StringField(cmd, "key"));
+ string target = key == GroupPermissionKey ? StringField(cmd, "target") : null;
+ string family = Lower(StringField(cmd, "family"));
+ string value = StringField(cmd, "value");
+ long holdMs = LongField(cmd, "holdMs");
+ long untilMs = LongField(cmd, "untilMs");
+
+ if (_config == null || !_config.EventsEnabled)
+ {
+ EmitLeaseError(reqId, "events-disabled",
+ "events are switched off on this server — set EventsEnabled in the RunicGateway plugin config");
+ return;
+ }
+
+ if (string.IsNullOrEmpty(key) || value == null)
+ {
+ EmitLeaseError(reqId, "malformed", "a lease names a key and a value");
+ return;
+ }
+
+ if (holdMs <= 0)
+ {
+ EmitLeaseError(reqId, "malformed", "a lease is held for a positive number of milliseconds");
+ return;
+ }
+
+ if (holdMs > LeaseMaxHoldMs)
+ {
+ EmitLeaseError(reqId, "too-long",
+ $"this server holds a lease for at most {LeaseMaxHoldMs / 60000L} minutes, not {holdMs / 60000L}");
+ return;
+ }
+
+ if (key == GroupPermissionKey)
+ {
+ bool flag;
+
+ if (!TryParseFlag(value, out flag))
+ {
+ EmitLeaseError(reqId, "out-of-range", $"'{value}' is not true or false");
+ return;
+ }
+
+ string group, perm;
+
+ if (!SplitGroupTarget(target, out group, out perm))
+ {
+ EmitLeaseError(reqId, "malformed", "a group permission is named as group/permission");
+ return;
+ }
+
+ // The same pre-check the permission mirror makes (§12.2 rule 1): a grant of a
+ // name nothing registered returns void and does nothing, and the lease would then
+ // record a hold on a value that never changed.
+ if (!permission.PermissionExists(perm))
+ {
+ EmitLeaseError(reqId, "unresolved", $"no loaded plugin on this server registers '{perm}'");
+ return;
+ }
+
+ target = group + "/" + perm;
+ value = flag ? "true" : "false";
+ }
+ else
+ {
+ LeaseKey k = FindLeaseKey(key);
+
+ if (k == null)
+ {
+ EmitLeaseError(reqId, "unknown-key", $"'{key}' is not a value this server lends");
+ return;
+ }
+
+ if (!string.IsNullOrEmpty(family) && family != k.Family)
+ {
+ EmitLeaseError(reqId, "unknown-key", $"'{key}' is not a {family} value");
+ return;
+ }
+
+ double number;
+
+ if (!TryParseNumber(value, out number) || number < k.Min || number > k.Max)
+ {
+ EmitLeaseError(reqId, "out-of-range", $"{key} takes {k.Min} to {k.Max}, not {value}");
+ return;
+ }
+ }
+
+ string slot = LeaseSlot(key, target);
+ LeaseHold existing;
+ _leases.TryGetValue(slot, out existing);
+
+ string refusal;
+ bool gone;
+ string before = ReadLeaseValue(key, target, out refusal, out gone);
+
+ if (before == null)
+ {
+ EmitLeaseError(reqId, gone ? "target-gone" : "unreadable", refusal);
+ return;
+ }
+
+ string failed = WriteLeaseValue(key, target, value);
+
+ if (failed != null)
+ {
+ // Put back what was there; a half-applied lease is not a state to leave behind.
+ WriteLeaseValue(key, target, before);
+ EmitLeaseError(reqId, "refused", failed);
+ return;
+ }
+
+ long now = NowMs();
+
+ var hold = new LeaseHold
+ {
+ Key = key,
+ Target = target,
+ Baseline = existing != null ? existing.Baseline : before,
+ Applied = value,
+ DeadlineMs = now + holdMs,
+ UntilMs = untilMs > 0 ? untilMs : now + holdMs,
+ RunId = StringField(cmd, "runId"),
+ BootId = _bootId,
+ TakenMs = now,
+ SiteWants = existing != null ? existing.SiteWants : null
+ };
+
+ _leases[slot] = hold;
+ SaveLeases();
+ EnsureLeaseTimer();
+
+ _lastLeaseSummary = $"applied {slot}={value} (baseline {hold.Baseline}) for {holdMs / 1000L}s";
+ Puts($"lease {_lastLeaseSummary}");
+
+ Dictionary reply = Frame("lease.ok", "reply");
+ reply["key"] = key;
+
+ if (!string.IsNullOrEmpty(target))
+ reply["target"] = target;
+
+ reply["baseline"] = hold.Baseline;
+ reply["applied"] = hold.Applied;
+ reply["untilMs"] = hold.UntilMs;
+
+ if (reqId != null)
+ reply["reqId"] = reqId;
+
+ EmitFrame(reply);
+ }
+
+ ///
+ /// Main thread. Give a value back — compare-and-set.
+ ///
+ ///
+ /// The comparison is against what the LEASE applied, from this plugin's own record
+ /// when it has one, else from the website's expected . The record is missing
+ /// when the deadline already fired, or when a restart dropped a convar hold; both of
+ /// those have already given the value back, and the compare then finds baseline there
+ /// and answers success without writing anything.
+ ///
+ ///
+ private void HandleLeaseRelease(Dictionary cmd, string reqId)
+ {
+ string key = Lower(StringField(cmd, "key"));
+ string target = StringField(cmd, "target");
+
+ if (key == GroupPermissionKey)
+ {
+ string group, perm;
+
+ if (SplitGroupTarget(target, out group, out perm))
+ target = group + "/" + perm;
+ }
+ else
+ {
+ target = null;
+ }
+
+ if (string.IsNullOrEmpty(key))
+ {
+ EmitLeaseError(reqId, "malformed", "a release names a key");
+ return;
+ }
+
+ LeaseHold hold;
+ _leases.TryGetValue(LeaseSlot(key, target), out hold);
+
+ string expected = hold != null ? hold.Applied : StringField(cmd, "expected");
+ string baseline = hold != null ? hold.Baseline : StringField(cmd, "baseline");
+
+ Dictionary outcome = Restore(key, target, expected, baseline, hold, "release");
+
+ if (reqId != null)
+ outcome["reqId"] = reqId;
+
+ EmitFrame(outcome);
+ }
+
+ ///
+ /// Main thread. The one place a value is given back, by a release or a deadline. The
+ /// hold is ended either way — a drifted value is left alone, but the lease is over.
+ ///
+ private Dictionary Restore(
+ string key, string target, string expected, string baseline, LeaseHold hold, string why)
+ {
+ string slot = LeaseSlot(key, target);
+
+ // A group permission the site asked for during the hold is given to the site, not
+ // back to the baseline: the lease borrowed the pair, the site owns what it becomes.
+ string restoreTo = baseline;
+
+ if (hold != null && hold.SiteWants.HasValue)
+ restoreTo = hold.SiteWants.Value ? "true" : "false";
+
+ string refusal;
+ bool gone;
+ string current = ReadLeaseValue(key, target, out refusal, out gone);
+
+ if (current == null)
+ {
+ if (gone)
+ {
+ // The group was deleted mid-hold: nothing is owed and nothing is out there.
+ _leases.Remove(slot);
+ SaveLeases();
+ _lastLeaseSummary = $"{why} {slot}: target gone";
+
+ Dictionary goneFrame = Frame("lease.ok", "reply");
+ goneFrame["key"] = key;
+ goneFrame["targetGone"] = true;
+
+ if (!string.IsNullOrEmpty(target))
+ goneFrame["target"] = target;
+
+ return goneFrame;
+ }
+
+ // The value cannot be read, so it cannot be compared. The hold stays: the next
+ // release, or the next deadline check, tries again.
+ Dictionary error = Frame("lease.error", "reply");
+ error["reason"] = "unreadable";
+ error["message"] = refusal;
+ return error;
+ }
+
+ if (restoreTo != null && SameLeaseValue(key, current, restoreTo))
+ {
+ // Already given back — by a deadline, a restart, or somebody who restored it by
+ // hand. Nothing to write, and it is a success.
+ _leases.Remove(slot);
+ SaveLeases();
+ _lastLeaseSummary = $"{why} {slot}: already {current}";
+ return RestoredFrame(key, target, current);
+ }
+
+ if (expected == null || !SameLeaseValue(key, current, expected))
+ {
+ _leases.Remove(slot);
+ SaveLeases();
+ _lastLeaseSummary = $"{why} {slot}: drifted to {current}, left alone";
+ Puts($"lease {_lastLeaseSummary}");
+
+ Dictionary drifted = Frame("lease.drifted", "reply");
+ drifted["key"] = key;
+
+ if (!string.IsNullOrEmpty(target))
+ drifted["target"] = target;
+
+ drifted["current"] = current;
+ drifted["expected"] = expected;
+ return drifted;
+ }
+
+ string failed = restoreTo == null ? "no baseline to restore" : WriteLeaseValue(key, target, restoreTo);
+
+ if (failed != null)
+ {
+ Dictionary error = Frame("lease.error", "reply");
+ error["reason"] = "refused";
+ error["message"] = failed;
+ return error;
+ }
+
+ _leases.Remove(slot);
+ SaveLeases();
+ _lastLeaseSummary = $"{why} {slot}: restored {restoreTo}";
+ Puts($"lease {_lastLeaseSummary}");
+ return RestoredFrame(key, target, restoreTo);
+ }
+
+ private Dictionary RestoredFrame(string key, string target, string value)
+ {
+ Dictionary ok = Frame("lease.ok", "reply");
+ ok["key"] = key;
+
+ if (!string.IsNullOrEmpty(target))
+ ok["target"] = target;
+
+ ok["restored"] = value;
+ return ok;
+ }
+
+ ///
+ /// Main thread, every second while anything is held. The fail-safe: a deadline that
+ /// passes gives the value back without anybody asking, and says so.
+ ///
+ private void CheckLeaseDeadlines()
+ {
+ if (_leases.Count == 0)
+ {
+ StopLeaseTimer();
+ return;
+ }
+
+ long now = NowMs();
+ List due = null;
+
+ foreach (LeaseHold h in _leases.Values)
+ {
+ if (h.DeadlineMs <= now)
+ {
+ if (due == null)
+ due = new List();
+
+ due.Add(h);
+ }
+ }
+
+ if (due == null)
+ return;
+
+ foreach (LeaseHold h in due)
+ Expire(h);
+ }
+
+ private void Expire(LeaseHold hold)
+ {
+ Dictionary outcome = Restore(
+ hold.Key, hold.Target, hold.Applied, hold.Baseline, hold, "expired");
+
+ string kind = StringField(outcome, "kind");
+
+ // An unreadable value keeps the hold; the next tick tries again rather than
+ // announcing an expiry that did not happen.
+ if (kind == "lease.error")
+ return;
+
+ Dictionary frame = Frame("lease.expired", "event");
+ frame["key"] = hold.Key;
+
+ if (!string.IsNullOrEmpty(hold.Target))
+ frame["target"] = hold.Target;
+
+ if (!string.IsNullOrEmpty(hold.RunId))
+ frame["runId"] = hold.RunId;
+
+ frame["drifted"] = kind == "lease.drifted";
+
+ object current;
+ if (outcome.TryGetValue("current", out current))
+ frame["current"] = current;
+
+ EmitFrame(frame);
+ }
+
+ private void EnsureLeaseTimer()
+ {
+ if (_leaseTimer == null && _leases.Count > 0)
+ _leaseTimer = timer.Every(LeaseCheckSeconds, CheckLeaseDeadlines);
+ }
+
+ private void StopLeaseTimer()
+ {
+ if (_leaseTimer != null)
+ {
+ _leaseTimer.Destroy();
+ _leaseTimer = null;
+ }
+ }
+
+ private void EmitLeaseError(string reqId, string reason, string message)
+ {
+ Dictionary frame = Frame("lease.error", "reply");
+ frame["reason"] = reason;
+
+ if (!string.IsNullOrEmpty(message))
+ frame["message"] = message;
+
+ if (reqId != null)
+ frame["reqId"] = reqId;
+
+ _lastLeaseSummary = $"refused ({reason}): {message}";
+ EmitFrame(frame);
+ }
+
+ private string LeasesPath()
+ {
+ return Path.Combine(Interface.Oxide.DataDirectory, Name, "leases.json");
+ }
+
+ ///
+ /// Main thread. Written on every change, because the whole point of the file is the
+ /// crash nobody saw coming. It is a handful of rows.
+ ///
+ private void SaveLeases()
+ {
+ try
+ {
+ string path = LeasesPath();
+ Directory.CreateDirectory(Path.GetDirectoryName(path));
+
+ var rows = new List(_leases.Values);
+ string tmp = path + ".tmp";
+ File.WriteAllText(tmp, JsonConvert.SerializeObject(rows, Formatting.Indented), Utf8NoBom);
+
+ if (File.Exists(path))
+ File.Delete(path);
+
+ File.Move(tmp, path);
+ }
+ catch (Exception ex)
+ {
+ Puts($"could not save leases: {Flatten(ex.Message)}");
+ }
+ }
+
+ ///
+ /// Main thread, once per load, with the world available. Re-arms what is still owed.
+ ///
+ ///
+ /// A convar hold taken under an earlier BOOT is dropped: the restart put the game's
+ /// own value back, and re-applying it would resurrect a lease the restart ended. A
+ /// convar hold from THIS boot survives a plugin reload — the value is still in the
+ /// game's memory, and a config save from the website must not end an event. A group
+ /// permission hold is re-armed whatever the boot, because the framework kept it.
+ ///
+ ///
+ private void LoadLeases()
+ {
+ _leases = new Dictionary();
+
+ List rows = null;
+
+ try
+ {
+ string path = LeasesPath();
+
+ if (File.Exists(path))
+ rows = JsonConvert.DeserializeObject>(File.ReadAllText(path));
+ }
+ catch (Exception ex)
+ {
+ Puts($"could not read leases: {Flatten(ex.Message)}");
+ }
+
+ if (rows == null)
+ return;
+
+ int dropped = 0;
+
+ foreach (LeaseHold h in rows)
+ {
+ if (h == null || string.IsNullOrEmpty(h.Key))
+ continue;
+
+ if (h.Key != GroupPermissionKey && h.BootId != _bootId)
+ {
+ dropped++;
+ continue;
+ }
+
+ _leases[LeaseSlot(h.Key, h.Target)] = h;
+ }
+
+ if (dropped > 0)
+ SaveLeases();
+
+ Puts($"leases: {_leases.Count} held, {dropped} ended by a restart");
+
+ // Due now or later; the first tick restores anything whose deadline passed while the
+ // server was down.
+ EnsureLeaseTimer();
+ }
+
+ ///
+ /// Whether a lease holds this group permission, for the permission mirror. A pair a
+ /// lease has borrowed is the lease's until it ends: a sync neither applies to it nor
+ /// reports it foreign.
+ ///
+ private LeaseHold LeasedGroupPermission(string group, string perm)
+ {
+ LeaseHold hold;
+ return _leases.TryGetValue(LeaseSlot(GroupPermissionKey, Lower(group) + "/" + Lower(perm)), out hold)
+ ? hold
+ : null;
+ }
+
+ /// What every hold is doing, from the server console.
+ [ConsoleCommand("rg.leases")]
+ private void CmdLeases(ConsoleSystem.Arg arg)
+ {
+ var lines = new List
+ {
+ $"protocol={ProtocolVersion} eventsEnabled={(_config != null && _config.EventsEnabled)} " +
+ $"held={_leases.Count} bootId={_bootId}"
+ };
+
+ foreach (LeaseHold h in _leases.Values)
+ {
+ lines.Add(
+ $" {LeaseSlot(h.Key, h.Target)} applied={h.Applied} baseline={h.Baseline} " +
+ $"remaining={Math.Max(0L, h.DeadlineMs - NowMs()) / 1000L}s run={h.RunId ?? "-"}");
+ }
+
+ lines.Add($"last: {_lastLeaseSummary}");
+ arg.ReplyWith(string.Join(Environment.NewLine, lines.ToArray()));
+ }
+
// ---- reading a command's fields ----
//
// `JsonConvert` hands back `object`, and every field below arrives as one. These are the
@@ -4598,6 +5800,17 @@ namespace Oxide.Plugins
return int.TryParse(value.ToString(), out parsed) ? parsed : 0;
}
+ private static long LongField(Dictionary row, string key)
+ {
+ object value;
+
+ if (row == null || !row.TryGetValue(key, out value) || value == null)
+ return 0L;
+
+ long parsed;
+ return long.TryParse(value.ToString(), out parsed) ? parsed : 0L;
+ }
+
/// The objects in an array field, skipping anything that is not one.
private static IEnumerable> Rows(Dictionary row, string key)
{