From 47692e88325be9489ea0415099fb65ea2fd41c25 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Thu, 17 Sep 2026 07:33:45 -0500 Subject: [PATCH 01/18] =?UTF-8?q?feat(bridge):=20protocol=203=20=E2=80=94?= =?UTF-8?q?=20/link,=20/unlink,=20and=20the=20first=20command=20the=20site?= =?UTF-8?q?=20sends?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R1's identity flow, in the shape the UO bridge already proved: the player asks in game, the plugin mints a one-time code and hands it to them, and the website confirms it back through the sidecar. The site is the author of record; this plugin holds no link at all. **`/link` is chat, not console, and the reply is private** (D21). Both frameworks consume a `/` command rather than broadcasting it and `SendReply` addresses one player, so neither the request nor the code reaches anybody else — a code read off a stream is a code somebody else can spend. **Pending codes live in memory and nowhere else** (D22, matching module-uo): six characters from an alphabet with no O/0/I/1, five-minute TTL, a thirty-second cooldown per player, one outstanding code each, and a purge timer because an unconfirmed code is never looked up. A plugin reload drops them — phase 7b's config editor will do that routinely — and the cost is typing `/link` again, which is cheaper than a credential living in a second process. **The code is deliberately NOT in `account.link.requested`.** That event exists so an operator can see linking being used; the code travels through the player, which is what makes typing it proof they are the one who asked. `/unlink` emits rather than deletes, because there is nothing here to delete. It is the escape hatch that makes the site's refusal to move an already-linked Steam id safe: a player who linked the wrong account walks to a computer they are already sitting at. `link.confirm` consumes a code on the FIRST lookup whether or not it turns out to be expired, so a code cannot be probed twice. `unknown` and `expired` stay separate in the reply — an operator wants to know whether codes are being guessed or going stale — and the website says the same sentence for both. `checkPlugin.js` gains a check for the new surface, because a chat command binds by reflection exactly like a hook and fails the same silent way. It asserts the signature, the void return and the absence of duplicates, and — like the hook parser before it — a test reads the real plugin so a regex that matches nothing cannot pass. Verified by breaking it five ways. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4 --- overlay.toml | 2 +- overlay/oxide/plugins/RunicGateway.cs | 353 +++++++++++++++++++++++++- scripts/checkPlugin.js | 67 ++++- scripts/checkPlugin.test.js | 86 ++++++- 4 files changed, 502 insertions(+), 6 deletions(-) diff --git a/overlay.toml b/overlay.toml index d3a3ca7..97adc01 100644 --- a/overlay.toml +++ b/overlay.toml @@ -27,7 +27,7 @@ # PROTOCOL_VERSION and the module bumps its own constant. # # Current: 2 — the transport plus the read path (docs/rust-link/PROTOCOL.md §8). -protocol = 2 +protocol = 3 # ── Oxide compatibility ────────────────────────────────────────────────────── # diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index dd1fb84..520dcc2 100644 --- a/overlay/oxide/plugins/RunicGateway.cs +++ b/overlay/oxide/plugins/RunicGateway.cs @@ -56,7 +56,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 = 2; + private const int ProtocolVersion = 3; /// /// Loopback reconnects are cheap, so the ceiling is low. A sidecar restart should cost @@ -243,6 +243,10 @@ namespace Oxide.Plugins _wipeId = ResolveWipeId(); _running = true; + // Expired link codes are dropped on a timer, not on lookup: an unconfirmed code is + // never looked up, so nothing else would ever remove one (protocol 3). + timer.Every(60f, PurgeExpiredCodes); + _link = new Thread(LinkLoop) { Name = "RunicGateway Link", @@ -743,6 +747,13 @@ namespace Oxide.Plugins 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; + default: Puts($"unknown command '{verb}'"); break; @@ -1645,6 +1656,346 @@ namespace Oxide.Plugins .TotalMilliseconds; } + // ---- account linking ---- + + /// + /// The alphabet a link code is drawn from. + /// + /// + /// No O, 0, I or 1. A player reads this code off their screen and types it into + /// a browser, often on a phone, sometimes reading it aloud to somebody else. Those four + /// glyphs are the ones that get transcribed wrong, and a code that fails for a reason + /// the player cannot see is worse than a longer one. + /// + /// + private const string CodeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + + private const int CodeLength = 6; + + /// + /// How long a code is good for. Short on purpose: R1 made this an authorization path, + /// not a convenience — the site becomes the author of who may do what in game, so a + /// code that lingers is a privilege waiting to be picked up. + /// + private static readonly TimeSpan CodeTtl = TimeSpan.FromMinutes(5); + + /// How long a player must wait between /link requests. + private static readonly TimeSpan RequestCooldown = TimeSpan.FromSeconds(30); + + /// + /// An unconfirmed code, and the Steam identity it will bind. + /// + /// + /// These live in memory and nowhere else, which is the same call the UO bridge + /// made and it is the right one for the same reason: the secret exists only on the + /// server that minted it, for five minutes, and nothing writes it to disk. A plugin + /// reload drops every pending code — and phase 7b's config editor reloads plugins + /// routinely — but the cost of that is a player typing /link again, which is + /// cheaper than a credential in a second process. + /// + /// + private sealed class PendingLink + { + public string SteamId; + public string Name; + public DateTime Expires; + } + + /// code -> pending link. Main thread only, like every world read here. + private readonly Dictionary _codes = + new Dictionary(StringComparer.OrdinalIgnoreCase); + + /// steam id -> when they last asked, so /link cannot be spammed. + private readonly Dictionary _lastLinkRequest = + new Dictionary(StringComparer.Ordinal); + + private readonly System.Random _codeRandom = new System.Random(); + + /// + /// /link — mint a one-time code for the caller and tell the sidecar about it. + /// + /// + /// Chat, not console, and the reply is private. Both frameworks consume a + /// / command rather than broadcasting it, and SendReply addresses one + /// player — so neither the request nor the code reaches anybody else's chat. That is + /// load-bearing rather than polish: a code read off a stream is a code somebody else + /// can spend. + /// + /// + /// + /// The plugin does not record who is linked. The website is the author of + /// record for that (R2), and a second copy here would be a second thing to keep in + /// step across a wipe. So there is no "you are already linked" check on this path — + /// the site refuses a Steam id it already holds, where it can say which account holds + /// it. + /// + /// + [ChatCommand("link")] + private void CmdLinkChat(BasePlayer player, string command, string[] args) + { + if (player == null) + return; + + string steamId = player.UserIDString; + if (string.IsNullOrEmpty(steamId)) + return; + + DateTime last; + if (_lastLinkRequest.TryGetValue(steamId, out last) && DateTime.UtcNow - last < RequestCooldown) + { + SendReply(player, "Please wait a moment before asking for another link code."); + return; + } + + // One outstanding code per player: drop any earlier one so only the newest works. + // Without this a player who ran /link three times leaves three live codes behind. + DropCodesFor(steamId); + + string code = MintCode(); + _codes[code] = new PendingLink + { + SteamId = steamId, + Name = player.displayName, + Expires = DateTime.UtcNow + CodeTtl + }; + _lastLinkRequest[steamId] = DateTime.UtcNow; + + Dictionary frame = Frame("account.link.requested", "event"); + frame["steamId"] = steamId; + frame["name"] = player.displayName; + frame["ttlSec"] = (long)CodeTtl.TotalSeconds; + + // **The code is NOT in this frame, and that is the point.** The event exists so an + // operator can see that linking is being used and so the site can rate-limit a player + // who is fishing; it is not how the code travels. The code travels through the player, + // which is what makes typing it proof that they are the one who asked. + EmitFrame(frame); + + SendReply(player, "Your link code is " + code + "."); + SendReply( + player, + "Enter it on the website within " + (int)CodeTtl.TotalMinutes + + " minutes to link this Steam account. It works once."); + } + + /// + /// /unlink — ask the website to sever this Steam id's link. + /// + /// + /// It emits rather than deletes, because the plugin holds no link to delete. The + /// site owns the record and applies this like any other event off the feed. + /// + /// + /// + /// It exists because the site refuses to move a Steam id that another account + /// already holds. Without a way out, a player who linked the wrong account while logged + /// in as it would need staff; with this they walk to a computer they are already sitting + /// at. The authority is the Steam id itself — whoever is connected as it is who it is. + /// + /// + [ChatCommand("unlink")] + private void CmdUnlinkChat(BasePlayer player, string command, string[] args) + { + if (player == null) + return; + + string steamId = player.UserIDString; + if (string.IsNullOrEmpty(steamId)) + return; + + DropCodesFor(steamId); + + Dictionary frame = Frame("account.unlinked", "event"); + frame["steamId"] = steamId; + frame["name"] = player.displayName; + frame["origin"] = "in-game"; + EmitFrame(frame); + + SendReply( + player, + "Asked the website to unlink this Steam account. If it was linked, it is not now."); + } + + /// + /// Inbound link.confirm — the website has been given a code and wants to know + /// whose it is. + /// + /// + /// This is the first command the website originates in this bridge; everything + /// before protocol 3 was the game talking. The reply carries the Steam id and nothing + /// the caller did not already prove — there is no session here, no account creation, + /// and no write to the game. + /// + /// + /// + /// A code is consumed on the first lookup whether or not it turns out to be + /// expired, so a code cannot be probed twice. That is why the removal happens before + /// the expiry check rather than after it. + /// + /// + private void HandleLinkConfirm(Dictionary cmd, string reqId) + { + object codeObj; + string code = cmd.TryGetValue("code", out codeObj) && codeObj != null + ? codeObj.ToString().Trim() + : null; + + if (string.IsNullOrEmpty(code)) + { + EmitLinkError(reqId, "malformed"); + return; + } + + PendingLink pending; + if (!_codes.TryGetValue(code, out pending)) + { + EmitLinkError(reqId, "unknown"); + return; + } + + _codes.Remove(code); + + if (DateTime.UtcNow > pending.Expires) + { + EmitLinkError(reqId, "expired"); + return; + } + + Dictionary ok = Frame("link.ok", "reply"); + ok["steamId"] = pending.SteamId; + ok["name"] = pending.Name; + if (reqId != null) + ok["reqId"] = reqId; + + EmitFrame(ok); + } + + /// + /// A refusal, with a reason the website can turn into a sentence. + /// + /// + /// unknown and expired are deliberately separate here and + /// deliberately the same to the player: an operator reading a log wants to know + /// whether codes are being guessed or merely going stale, and a stranger typing codes + /// must not learn which of the two they hit. + /// + /// + private void EmitLinkError(string reqId, string reason) + { + Dictionary frame = Frame("link.error", "reply"); + frame["reason"] = reason; + if (reqId != null) + frame["reqId"] = reqId; + + EmitFrame(frame); + } + + /// Drops every outstanding code belonging to one Steam id. + private void DropCodesFor(string steamId) + { + List stale = null; + + foreach (KeyValuePair entry in _codes) + { + if (entry.Value != null && entry.Value.SteamId == steamId) + { + if (stale == null) + stale = new List(); + + stale.Add(entry.Key); + } + } + + if (stale == null) + return; + + foreach (string key in stale) + _codes.Remove(key); + } + + /// + /// Drops expired codes and stale cooldowns. + /// + /// + /// Run on a timer rather than only on lookup, because an unconfirmed code is never + /// looked up — a player who runs /link and wanders off leaves an entry that + /// nothing else would ever remove. + /// + /// + private void PurgeExpiredCodes() + { + DateTime now = DateTime.UtcNow; + List dead = null; + + foreach (KeyValuePair entry in _codes) + { + if (entry.Value == null || now > entry.Value.Expires) + { + if (dead == null) + dead = new List(); + + dead.Add(entry.Key); + } + } + + if (dead != null) + { + foreach (string key in dead) + _codes.Remove(key); + } + + List cold = null; + + foreach (KeyValuePair entry in _lastLinkRequest) + { + if (now - entry.Value > RequestCooldown) + { + if (cold == null) + cold = new List(); + + cold.Add(entry.Key); + } + } + + if (cold == null) + return; + + foreach (string key in cold) + _lastLinkRequest.Remove(key); + } + + /// + /// A code from , retried until it does not collide. + /// + /// + /// System.Random rather than a cryptographic source, and the reason it is + /// defensible is the shape of the guess rather than the shape of the generator: a code + /// lives five minutes, is consumed on first lookup, and the website rate-limits the + /// confirm endpoint. An attacker who could predict the sequence would still have to + /// reach a code before the player does. + /// + /// + private string MintCode() + { + for (int attempt = 0; attempt < 16; attempt++) + { + var chars = new char[CodeLength]; + + for (int i = 0; i < CodeLength; i++) + chars[i] = CodeAlphabet[_codeRandom.Next(CodeAlphabet.Length)]; + + string candidate = new string(chars); + + if (!_codes.ContainsKey(candidate)) + return candidate; + } + + // Sixteen collisions in a row against a table this small is not bit-space exhaustion, + // it is something being wrong. A code that cannot collide is better than a loop that + // might not end. + return Guid.NewGuid().ToString("N").Substring(0, CodeLength).ToUpperInvariant(); + } + // ---- diagnostics ---- /// diff --git a/scripts/checkPlugin.js b/scripts/checkPlugin.js index 56b04eb..d7ac002 100644 --- a/scripts/checkPlugin.js +++ b/scripts/checkPlugin.js @@ -62,6 +62,12 @@ const ANSWERS_DELIBERATELY = Object.create(null) /** Anything shaped like this is a game hook, by both frameworks' own convention. */ const HOOK_NAME = /^(?:On|Can)[A-Z]\w*$/ +/** + * The parameter list both frameworks bind a chat command on: the caller, the command word, and + * whatever followed it. Names are the author's; the types are not. + */ +const CHAT_SIGNATURE = /^BasePlayer\s+\w+,\s*string\s+\w+,\s*string\[\]\s+\w+$/ + /** * Method declarations, as this file cares about them: the return type and the * name. Deliberately narrow — it matches the plugin's own single style @@ -82,6 +88,31 @@ function readExpectedHooks(source) { .map((m) => m[1]) } +/** + * Every `[ChatCommand("x")]` and the signature of the method under it. + * + * **A chat command binds by reflection, exactly like a hook**, and fails the same silent way: a + * method with the wrong parameter list is never called and neither framework says a word. The + * player types `/link`, nothing happens, and there is no log line to find. So the signature is + * checked here rather than discovered on a rig. + */ +function readChatCommands(source) { + const found = [] + const attribute = /\[ChatCommand\("([^"]+)"\)\]\s*(?:\/\/[^\n]*\n\s*)*(?:private|public|protected|internal)\s+(?:static\s+)?([\w.<>[\],\s]+?)\s+(\w+)\s*\(([^)]*)\)/g + + let match + while ((match = attribute.exec(source)) !== null) { + found.push({ + command: match[1], + returns: match[2].trim(), + name: match[3], + params: match[4].replace(/\s+/g, ' ').trim(), + }) + } + + return found +} + function readMethods(source) { const found = [] let m @@ -133,7 +164,39 @@ function check(source, toml) { } } - // 4. The two declaration sites this repository owns must agree. + // 4. Every chat command is bound by reflection and must have the signature the + // frameworks call. A wrong one is never invoked and says nothing. + const chat = readChatCommands(source) + + for (const cmd of chat) { + if (cmd.returns !== 'void') { + problems.push( + `/${cmd.command} (${cmd.name}) returns ${cmd.returns}, not void — a chat command's return ` + + 'value is not read, and a non-void signature is the shape that silently binds nothing.' + ) + } + + if (!CHAT_SIGNATURE.test(cmd.params)) { + problems.push( + `/${cmd.command} (${cmd.name}) takes (${cmd.params}), not (BasePlayer, string, string[]). ` + + 'Both frameworks bind a chat command by reflection on that exact signature, so this one ' + + 'would never be called and neither would log it — the player types the command and ' + + 'nothing happens at all.' + ) + } + } + + // Two commands answering to one name is one of them never running, and which one is + // whichever the framework happened to register second. + const seen = new Set() + for (const cmd of chat) { + if (seen.has(cmd.command)) { + problems.push(`/${cmd.command} is declared twice; only one of them can ever be bound`) + } + seen.add(cmd.command) + } + + // 5. The two declaration sites this repository owns must agree. const inCode = /ProtocolVersion\s*=\s*(\d+)\s*;/.exec(source) const inToml = /^\s*protocol\s*=\s*(\d+)\s*$/m.exec(toml) @@ -171,6 +234,6 @@ function main() { ) } -module.exports = { check, readExpectedHooks, readMethods, HOOK_NAME } +module.exports = { check, readExpectedHooks, readMethods, readChatCommands, HOOK_NAME } if (require.main === module) main() diff --git a/scripts/checkPlugin.test.js b/scripts/checkPlugin.test.js index 1e9cbbf..ebb8eff 100644 --- a/scripts/checkPlugin.test.js +++ b/scripts/checkPlugin.test.js @@ -1,4 +1,4 @@ -// A check is worth what it catches, so this breaks it seven ways. +// A check is worth what it catches, so this breaks it thirteen ways. // // The case that matters most is the last one: `checkPlugin.js` finds hooks with a // deliberately narrow regex, and a regex that silently matches NOTHING passes @@ -16,7 +16,7 @@ const assert = require('node:assert') const fs = require('node:fs') const path = require('node:path') -const { check, readExpectedHooks, readMethods, HOOK_NAME } = require('./checkPlugin') +const { check, readExpectedHooks, readMethods, readChatCommands, HOOK_NAME } = require('./checkPlugin') /** A minimal plugin that passes, as the baseline every case below deviates from. */ function source({ expected = ['OnPlayerDeath'], methods, version = 2 } = {}) { @@ -128,3 +128,85 @@ test('the parser actually reads the real plugin, rather than quietly matching no const expected = readExpectedHooks(real) assert.ok(expected.length >= 15, `only found ${expected.length} entries in ExpectedHooks`) }) + +// ── Chat commands (protocol 3) ───────────────────────────────────────────── +// +// A chat command binds by reflection on an exact signature, and a wrong one is +// never invoked and never logged: the player types `/link` and nothing happens. +// That is the same failure mode the hook checks exist for, one surface along. + +/** A plugin whose only body is one chat command. */ +function withChat(attribute, signature, returns = 'void') { + return source({ + expected: [], + methods: ` [ChatCommand("${attribute}")] + private ${returns} CmdThing(${signature}) + { + }`, + }) +} + +const GOOD_SIGNATURE = 'BasePlayer player, string command, string[] args' + +test('a correctly shaped chat command passes', () => { + assert.deepEqual(check(withChat('link', GOOD_SIGNATURE), toml()), []) +}) + +test('a chat command taking the wrong player type is caught', () => { + // The plausible mistake: Covalence's `IPlayer` is what a Universal plugin + // takes, and this plugin is not one. It compiles and binds nothing. + const problems = check(withChat('link', 'IPlayer player, string command, string[] args'), toml()) + + assert.equal(problems.length, 1) + assert.match(problems[0], /not \(BasePlayer, string, string\[\]\)/) +}) + +test('a chat command missing the trailing args is caught', () => { + const problems = check(withChat('link', 'BasePlayer player, string command'), toml()) + + assert.equal(problems.length, 1) + assert.match(problems[0], /nothing happens at all/) +}) + +test('a chat command that returns something is caught', () => { + const problems = check(withChat('link', GOOD_SIGNATURE, 'object'), toml()) + + assert.equal(problems.length, 1) + assert.match(problems[0], /returns object, not void/) +}) + +test('two chat commands answering to one name is caught', () => { + const both = source({ + expected: [], + methods: ` [ChatCommand("link")] + private void CmdOne(${GOOD_SIGNATURE}) + { + } + + [ChatCommand("link")] + private void CmdTwo(${GOOD_SIGNATURE}) + { + }`, + }) + + const problems = check(both, toml()) + + assert.equal(problems.length, 1) + assert.match(problems[0], /declared twice/) +}) + +test('the chat-command parser reads the real plugin, rather than quietly matching nothing', () => { + // The same trap the method parser has, and the reason this file's last test + // exists: a regex that matches NOTHING passes every case above. + const real = fs.readFileSync( + path.resolve(__dirname, '..', 'overlay', 'oxide', 'plugins', 'RunicGateway.cs'), + 'utf8' + ) + + const commands = readChatCommands(real) + const names = new Set(commands.map((c) => c.command)) + + assert.ok(commands.length >= 2, `only found ${commands.length} chat commands in the real plugin`) + assert.ok(names.has('link'), '/link was not found by the chat-command parser') + assert.ok(names.has('unlink'), '/unlink was not found by the chat-command parser') +}) -- 2.49.1 From e041089080b89296422a74d4d23f7d3e80653a4b Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 21 Sep 2026 18:27:08 -0500 Subject: [PATCH 02/18] =?UTF-8?q?feat(bridge):=20protocol=204=20=E2=80=94?= =?UTF-8?q?=20the=20permission=20mirror,=20and=20the=20first=20command=20t?= =?UTF-8?q?hat=20writes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R2, the game half. The website is the author of record for groups and grants; this plugin makes the framework's own permission store match what it is sent. **One verb, and the plugin does the diffing (D32).** `perm.sync` carries the whole set the site authors for this server. The plugin compares it against the live store and writes only what differs, then answers with a report: what it applied, what was already right, the names it could not resolve, the memberships waiting on a first connection, and every holder the site did not author. `perm.catalogue` is the other half — a live read of what the loaded plugins have registered, which is the option source the authoring form is built from. **The first command whose work is unbounded, so it is drained rather than run.** A community with two thousand linked players sends thousands of operations in one frame, and applying them in the tick the frame arrives is a freeze an operator will blame on the game. A sync compiles into a list of single-store operations and drains two hundred at a time on a timer. That is §5's rule — nothing the far side sends may cost the main thread an unbounded amount of work — applied to the inbound half for the first time. **The pre-check §12.2 rule 1 exists for.** `GrantUserPermission` returns void, throws nothing and logs nothing for a name no plugin registered, so every grant naming an unloaded plugin's permission would disappear without a trace. The plugin checks `PermissionExists` first and reports the name as unresolved; it does NOT register the name itself (D33), because that fabricates a permission the operator never installed. **`UserHasPermission` cannot answer the question the diff asks.** It is true for a member of a group that carries the permission, so a direct grant would look already applied the moment a group covered it — and a revoke already done. `GetPermissionUsers` reads the user's own set, which is what a direct grant is. Six permission hooks join the read path, all void, all in `ExpectedHooks`. They are not the drift record — only the site's desired set can say whether a change is foreign — they are a reason to reconcile sooner, and they are suppressed while this plugin is writing, or every sync would announce itself as drift. `rg.perms` reports the last report from the console, which is the fastest way to tell "no plugin registered that name" from "the store has never seen that player". Protocol bumped to 4 here and in overlay.toml, in the same change as the emitters. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM --- overlay.toml | 5 +- overlay/oxide/plugins/RunicGateway.cs | 1026 ++++++++++++++++++++++++- 2 files changed, 1027 insertions(+), 4 deletions(-) diff --git a/overlay.toml b/overlay.toml index 97adc01..e80c61a 100644 --- a/overlay.toml +++ b/overlay.toml @@ -26,8 +26,9 @@ # the same change that alters the emitters, exactly as the sidecar bumps # PROTOCOL_VERSION and the module bumps its own constant. # -# Current: 2 — the transport plus the read path (docs/rust-link/PROTOCOL.md §8). -protocol = 3 +# Current: 4 — the transport, the read path, identity, and the permission mirror +# (docs/rust-link/PROTOCOL.md §10). +protocol = 4 # ── Oxide compatibility ────────────────────────────────────────────────────── # diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index 520dcc2..c32cdf7 100644 --- a/overlay/oxide/plugins/RunicGateway.cs +++ b/overlay/oxide/plugins/RunicGateway.cs @@ -56,7 +56,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 = 3; + private const int ProtocolVersion = 4; /// /// Loopback reconnects are cheap, so the ceiling is low. A sidecar restart should cost @@ -231,7 +231,10 @@ namespace Oxide.Plugins "OnPlayerConnected", "OnPlayerDisconnected", "OnPlayerRespawned", "OnPlayerDeath", "OnPlayerChat", "OnDispenserGather", "OnEntityDeath", "OnPlayerReported", "OnUserBanned", "OnUserUnbanned", - "CanUserLogin", "OnUserApproved" + "CanUserLogin", "OnUserApproved", + "OnUserPermissionGranted", "OnUserPermissionRevoked", + "OnUserGroupAdded", "OnUserGroupRemoved", + "OnGroupPermissionGranted", "OnGroupPermissionRevoked" }; private void Init() @@ -286,6 +289,13 @@ namespace Oxide.Plugins _cadence = 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(); + _running = false; _stopping.Set(); Wake.Set(); @@ -754,6 +764,19 @@ namespace Oxide.Plugins 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; + default: Puts($"unknown command '{verb}'"); break; @@ -1996,6 +2019,981 @@ namespace Oxide.Plugins return Guid.NewGuid().ToString("N").Substring(0, CodeLength).ToUpperInvariant(); } + // ---- site-owned permissions (protocol 4) ---- + // + // R2: the website is the author of record for groups and grants, and this framework's own + // permission store is an ENFORCEMENT CACHE. Every third-party plugin honours a site grant + // with no adapter, because they all already call `UserHasPermission`. + // + // ── One verb, and the plugin does the diffing (D32) ─────────────────────── + // + // The site sends the whole set it authors for this server; this plugin compares it against + // the live store and writes only what differs. It is the same shape as a board, pointed the + // other way: one producer, re-sent on connect, reconciled by the receiver. + // + // The alternative — shipping this server's permission store to the website and diffing + // there — was rejected for two reasons. The store is the bigger of the two sets and would + // cross the wire constantly, and a website holding a copy of it has a second source of + // truth that is stale the moment it lands. + // + // ── The site must not be able to stall the game, either ─────────────────── + // + // This is the first command whose work is UNBOUNDED: a community with two thousand linked + // players has thousands of grants in one frame, and applying them in the tick the frame + // arrives is a freeze the operator will blame on the game. So a sync is compiled into a + // list of single-store operations and drained a few hundred at a time on a timer, and the + // report goes back when the last one lands. + // + // That is the same rule the outbound queue lives under (§5), applied to the inbound half: + // **nothing the far side sends may cost the main thread an unbounded amount of work.** + // + // ── What it reports, and what it will not do ────────────────────────────── + // + // • A permission this server has not REGISTERED is reported `unresolved` and skipped. + // `GrantUserPermission` silently no-ops for an unregistered name — void, no throw, no + // log (PLAN.md §12.2 rule 1) — so without the pre-check a re-push into a server missing + // one plugin is a silent partial. Registering the name here instead would fabricate a + // permission the operator never installed (D33). + // • A holder the site does not author is reported `foreign` and never touched (D31). + // Somebody typing `oxide.grant` during an incident is drift, not an error, and the + // website offers an operator two answers to it. + // • A group member who has never connected is reported `pending`. The store has no user + // record to put in a group yet (§12.2 rule 4), and the membership lands on their first + // connection — a direct grant would have reached them immediately, and pretending the + // two are the same thing is how a site promises access it did not give. + + /// + /// How many store operations one timer step performs. + /// + /// + /// Each is a dictionary lookup and, at most, one write to an in-memory structure the + /// framework flushes on its own schedule — microseconds. Two hundred of them is well + /// under a frame at 60fps, and it drains ten thousand operations in about two and a + /// half seconds, which is inside the sidecar's reply timeout. + /// + /// + private const int PermOpsPerStep = 200; + + /// How often the sync drain runs while a job is in flight. + private const float PermStepSeconds = 0.05f; + + /// + /// The ceiling on one sync, in operations. + /// + /// + /// A frame larger than this is refused whole rather than applied for a while and then + /// abandoned. The website has its own limit below this one, so reaching it means + /// something is wrong rather than something is big — and a half-applied permission set + /// is the state nobody can reason about. + /// + /// + private const int PermMaxOps = 20000; + + /// The operations a compiled sync is made of. One store question each. + private enum PermOpKind + { + EnsureGroup, + EnsureGroupPermission, + EnsureMember, + EnsureGrant, + RetireGroupPermission, + RetireMember, + RetireGrant, + RetireGroup, + ScanPermission, + ScanGroup + } + + private struct PermOp + { + public PermOpKind Kind; + + /// A Steam id, or a group name — whichever the op is about. + public string Subject; + + /// A permission name, a group name, or null. + public string Object; + + /// Only uses these. + public string Title; + + public int Rank; + } + + /// + /// One sync in flight. There is at most one: a second arriving while this one drains is + /// answered `busy` rather than interleaved, because two writers reconciling the same + /// store against two different desired sets is a race with no correct outcome. + /// + private sealed class PermSyncJob + { + public string ReqId; + public string SetId; + public List Ops = new List(); + public int Next; + + public int Grants; + public int Revokes; + public int GroupsCreated; + public int GroupsRemoved; + public int GroupPermissions; + public int GroupPermissionsRemoved; + public int Members; + public int MembersRemoved; + public int AlreadyCorrect; + public int Absent; + + public readonly List Unresolved = new List(); + public readonly List Foreign = new List(); + public readonly List Pending = new List(); + + /// Permission names already reported unresolved, so each is reported once. + public readonly HashSet UnresolvedSeen = new HashSet(); + + /// Who the site says should hold each managed permission, for the foreign scan. + public readonly Dictionary> DesiredHolders = + new Dictionary>(); + + /// Who the site says is in each managed group, and what it should carry. + public readonly Dictionary> DesiredMembers = + new Dictionary>(); + + public readonly Dictionary> DesiredGroupPermissions = + new Dictionary>(); + } + + private PermSyncJob _permJob; + private Timer _permTimer; + + /// + /// True while this plugin is writing to the permission store. + /// + /// + /// The framework raises OnUserPermissionGranted and friends for every write, + /// including ours — so without this flag every sync would announce itself as drift and + /// the website would chase its own tail. It is set around each drain step rather than + /// for the whole job, so a write by somebody else BETWEEN steps is still seen. + /// + /// + private bool _applyingPermissions; + + /// The last report, for rg.perms. Diagnostics only. + private string _lastPermSummary = "none yet"; + + /// + /// Main thread. Everything this server knows about permissions and groups — the option + /// source the website's authoring form is built from (D33). + /// + /// + /// It is a live read rather than anything cached, because the answer changes when an + /// operator loads a plugin and the whole point of asking is to offer names that will + /// actually resolve. + /// + /// + private void HandlePermCatalogue(string reqId) + { + Dictionary reply = Frame("perm.catalogue", "reply"); + + var permissions = new List(); + string[] registered = permission.GetPermissions(); + + if (registered != null) + { + foreach (string name in registered) + { + if (!string.IsNullOrEmpty(name)) + permissions.Add(name); + } + } + + var groups = new List(); + string[] groupNames = permission.GetGroups(); + + if (groupNames != null) + { + foreach (string name in groupNames) + { + if (string.IsNullOrEmpty(name)) + continue; + + var row = new Dictionary + { + { "name", name }, + { "title", permission.GetGroupTitle(name) }, + { "rank", permission.GetGroupRank(name) } + }; + + var granted = new List(); + string[] groupPerms = permission.GetGroupPermissions(name, false); + + if (groupPerms != null) + { + foreach (string perm in groupPerms) + { + if (!string.IsNullOrEmpty(perm)) + granted.Add(perm); + } + } + + row["permissions"] = granted; + groups.Add(row); + } + } + + reply["permissions"] = permissions; + reply["groups"] = groups; + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + } + + /// + /// Main thread. Compiles a desired set into operations and starts draining it. + /// + /// + /// Nothing is written here. Compiling is pure bookkeeping over the frame, so a + /// malformed or enormous sync is refused before it has touched the store — there is no + /// state to unwind because none was made. + /// + /// + private void HandlePermSync(Dictionary cmd, string reqId) + { + if (_permJob != null) + { + EmitPermError(reqId, "busy"); + return; + } + + var job = new PermSyncJob + { + ReqId = reqId, + SetId = StringField(cmd, "setId") + }; + + // ── The desired set ──────────────────────────────────────────────── + // + // Order matters and it is not cosmetic: a group has to exist before anything can be + // put in it, and a permission has to be on the group before a member inherits it. + + foreach (Dictionary group in Rows(cmd, "groups")) + { + string name = Lower(StringField(group, "name")); + if (string.IsNullOrEmpty(name)) + continue; + + job.Ops.Add(new PermOp + { + Kind = PermOpKind.EnsureGroup, + Subject = name, + Title = StringField(group, "title") ?? name, + Rank = IntField(group, "rank") + }); + + HashSet wanted = Bucket(job.DesiredGroupPermissions, name); + + foreach (string perm in Strings(group, "permissions")) + { + string lowered = Lower(perm); + if (string.IsNullOrEmpty(lowered)) + continue; + + wanted.Add(lowered); + job.Ops.Add(new PermOp + { + Kind = PermOpKind.EnsureGroupPermission, + Subject = name, + Object = lowered + }); + } + + foreach (string member in Strings(group, "members")) + { + if (string.IsNullOrEmpty(member)) + continue; + + Bucket(job.DesiredMembers, name).Add(member); + job.Ops.Add(new PermOp + { + Kind = PermOpKind.EnsureMember, + Subject = member, + Object = name + }); + } + + // A group with no members still has to be scanned: the interesting case is a + // member the site did not put there. + Bucket(job.DesiredMembers, name); + } + + foreach (Dictionary grant in Rows(cmd, "grants")) + { + string steamId = StringField(grant, "steamId"); + if (string.IsNullOrEmpty(steamId)) + continue; + + foreach (string perm in Strings(grant, "permissions")) + { + string lowered = Lower(perm); + if (string.IsNullOrEmpty(lowered)) + continue; + + Bucket(job.DesiredHolders, lowered).Add(steamId); + job.Ops.Add(new PermOp + { + Kind = PermOpKind.EnsureGrant, + Subject = steamId, + Object = lowered + }); + } + } + + // ── What the site has withdrawn ──────────────────────────────────── + // + // The site remembers what it put here, so a grant it has since removed arrives as an + // explicit retirement. It cannot be inferred from the desired set: a name that is not + // in it is either something the site withdrew or something a human granted, and those + // two have opposite correct answers (D31). + + foreach (Dictionary row in Rows(cmd, "retire")) + { + string kind = StringField(row, "kind"); + string subject = StringField(row, "subject"); + string target = Lower(StringField(row, "object")); + + if (string.IsNullOrEmpty(kind) || string.IsNullOrEmpty(subject)) + continue; + + switch (kind) + { + case "grant": + job.Ops.Add(new PermOp { Kind = PermOpKind.RetireGrant, Subject = subject, Object = target }); + break; + case "member": + job.Ops.Add(new PermOp { Kind = PermOpKind.RetireMember, Subject = subject, Object = target }); + break; + case "group-permission": + job.Ops.Add(new PermOp { Kind = PermOpKind.RetireGroupPermission, Subject = Lower(subject), Object = target }); + break; + case "group": + job.Ops.Add(new PermOp { Kind = PermOpKind.RetireGroup, Subject = Lower(subject) }); + break; + } + } + + // ── The namespace the site claims ────────────────────────────────── + // + // Foreign holders can only be found for names the site says it manages. A permission it + // does not claim is somebody else's business entirely and is never enumerated — which + // also bounds this scan by the site's own set rather than by the size of the store. + + foreach (string perm in Strings(cmd, "managed")) + { + string lowered = Lower(perm); + if (string.IsNullOrEmpty(lowered)) + continue; + + Bucket(job.DesiredHolders, lowered); + job.Ops.Add(new PermOp { Kind = PermOpKind.ScanPermission, Subject = lowered }); + } + + foreach (string group in job.DesiredMembers.Keys) + job.Ops.Add(new PermOp { Kind = PermOpKind.ScanGroup, Subject = group }); + + if (job.Ops.Count > PermMaxOps) + { + EmitPermError(reqId, "too-large"); + Puts($"permission sync refused: {job.Ops.Count} operations exceeds {PermMaxOps}"); + return; + } + + _permJob = job; + _permTimer = timer.Every(PermStepSeconds, StepPermSync); + + // Run the first slice now rather than waiting out a tick: a small sync — which is every + // ordinary one — then finishes inside the call the website is waiting on. + StepPermSync(); + } + + /// + /// Main thread. Drains up to operations and, on the last + /// one, sends the report. + /// + private void StepPermSync() + { + PermSyncJob job = _permJob; + + if (job == null) + { + StopPermTimer(); + return; + } + + _applyingPermissions = true; + + try + { + int budget = PermOpsPerStep; + + while (budget-- > 0 && job.Next < job.Ops.Count) + { + PermOp op = job.Ops[job.Next++]; + + try + { + Apply(job, op); + } + catch (Exception ex) + { + // One bad operation must not abandon the set. The report still goes back, + // and the next sync retries what this one could not do. + Puts($"permission op {op.Kind} on '{op.Subject}' threw: {ex.Message}"); + } + } + } + finally + { + _applyingPermissions = false; + } + + if (job.Next < job.Ops.Count) + return; + + _permJob = null; + StopPermTimer(); + SendPermReport(job); + } + + private void StopPermTimer() + { + if (_permTimer == null) + return; + + _permTimer.Destroy(); + _permTimer = null; + } + + /// Main thread. One store question, and at most one store write. + private void Apply(PermSyncJob job, PermOp op) + { + switch (op.Kind) + { + case PermOpKind.EnsureGroup: + if (permission.GroupExists(op.Subject)) + { + job.AlreadyCorrect++; + } + else + { + permission.CreateGroup(op.Subject, op.Title, op.Rank); + job.GroupsCreated++; + } + + break; + + case PermOpKind.EnsureGroupPermission: + if (!Registered(job, op.Object)) + break; + + if (permission.GroupHasPermission(op.Subject, op.Object)) + { + job.AlreadyCorrect++; + } + else + { + permission.GrantGroupPermission(op.Subject, op.Object, this); + job.GroupPermissions++; + } + + 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++; + } + else + { + permission.GrantUserPermission(op.Subject, op.Object, this); + job.Grants++; + } + + 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 (permission.GroupHasPermission(op.Subject, op.Object)) + { + permission.RevokeGroupPermission(op.Subject, op.Object); + job.GroupPermissionsRemoved++; + } + else + { + job.Absent++; + } + + break; + + case PermOpKind.RetireGroup: + if (permission.GroupExists(op.Subject)) + { + permission.RemoveGroup(op.Subject); + job.GroupsRemoved++; + } + else + { + job.Absent++; + } + + break; + + case PermOpKind.ScanPermission: + ScanPermission(job, op.Subject); + break; + + case PermOpKind.ScanGroup: + ScanGroup(job, op.Subject); + break; + } + } + + /// + /// Everybody holding a managed permission DIRECTLY, minus everybody the site says + /// should — the rest is a hand edit, and it is reported rather than undone. + /// + private void ScanPermission(PermSyncJob job, string perm) + { + if (!permission.PermissionExists(perm)) + return; + + HashSet desired; + if (!job.DesiredHolders.TryGetValue(perm, out desired)) + desired = new HashSet(); + + string[] holders = permission.GetPermissionUsers(perm); + + if (holders == null) + return; + + foreach (string entry in holders) + { + string id = UserId(entry); + + if (string.IsNullOrEmpty(id) || desired.Contains(id)) + continue; + + job.Foreign.Add(new Dictionary + { + { "kind", "grant" }, + { "subject", id }, + { "object", perm } + }); + } + } + + /// + /// The same question about a managed group, asked twice: who is in it that the site did + /// not put there, and what does it carry that the site did not put on it. + /// + private void ScanGroup(PermSyncJob job, string group) + { + if (!permission.GroupExists(group)) + return; + + HashSet members; + if (!job.DesiredMembers.TryGetValue(group, out members)) + members = new HashSet(); + + string[] present = permission.GetUsersInGroup(group); + + if (present != null) + { + foreach (string entry in present) + { + string id = UserId(entry); + + if (string.IsNullOrEmpty(id) || members.Contains(id)) + continue; + + job.Foreign.Add(new Dictionary + { + { "kind", "member" }, + { "subject", id }, + { "object", group } + }); + } + } + + HashSet wanted; + if (!job.DesiredGroupPermissions.TryGetValue(group, out wanted)) + wanted = new HashSet(); + + string[] carried = permission.GetGroupPermissions(group, false); + + if (carried == null) + return; + + foreach (string perm in carried) + { + string lowered = Lower(perm); + + if (string.IsNullOrEmpty(lowered) || wanted.Contains(lowered)) + continue; + + job.Foreign.Add(new Dictionary + { + { "kind", "group-permission" }, + { "subject", group }, + { "object", lowered } + }); + } + } + + /// + /// Whether this server has a plugin that registered the name, recording the answer once + /// per sync when it has not. + /// + /// + /// This is the pre-check PLAN.md §12.2 rule 1 exists for. Without it the grant below + /// would return void, throw nothing, log nothing and do nothing, and the website would + /// record a privilege it never gave. + /// + /// + private bool Registered(PermSyncJob job, string perm) + { + if (string.IsNullOrEmpty(perm)) + return false; + + if (permission.PermissionExists(perm)) + return true; + + if (job.UnresolvedSeen.Add(perm)) + job.Unresolved.Add(perm); + + return false; + } + + /// + /// Whether a player holds a permission through a grant of their own, as opposed to + /// through a group. + /// + /// + /// UserHasPermission cannot answer this — it is true for a member of a group + /// that carries the permission, so using it would make every direct grant look already + /// applied the moment a group covered it, and a revoke look already done. + /// GetPermissionUsers reads the user's own set, which is the question. + /// + /// + private bool HoldsDirectly(string steamId, string perm) + { + string[] holders = permission.GetPermissionUsers(perm); + + if (holders == null) + return false; + + foreach (string entry in holders) + { + if (UserId(entry) == steamId) + return true; + } + + return false; + } + + /// + /// The id out of a store listing entry. + /// + /// + /// Both GetPermissionUsers and GetUsersInGroup answer with the id and the + /// player's last known name stuck together — 76561198…(Wanderer) — and the + /// spacing differs between the two calls and between the frameworks. So the id is + /// everything before the first bracket, and a name containing one cannot confuse it + /// because only the first is looked for. + /// + /// + private static string UserId(string entry) + { + if (string.IsNullOrEmpty(entry)) + return null; + + int bracket = entry.IndexOf('('); + string id = bracket >= 0 ? entry.Substring(0, bracket) : entry; + + return id.Trim(); + } + + private void SendPermReport(PermSyncJob job) + { + var applied = new Dictionary + { + { "grants", job.Grants }, + { "revokes", job.Revokes }, + { "groupsCreated", job.GroupsCreated }, + { "groupsRemoved", job.GroupsRemoved }, + { "groupPermissions", job.GroupPermissions }, + { "groupPermissionsRemoved", job.GroupPermissionsRemoved }, + { "members", job.Members }, + { "membersRemoved", job.MembersRemoved } + }; + + Dictionary report = Frame("perm.report", "reply"); + report["applied"] = applied; + report["alreadyCorrect"] = job.AlreadyCorrect; + report["absent"] = job.Absent; + report["unresolved"] = job.Unresolved; + report["foreign"] = job.Foreign; + report["pending"] = job.Pending; + report["operations"] = job.Ops.Count; + + if (!string.IsNullOrEmpty(job.SetId)) + report["setId"] = job.SetId; + + if (job.ReqId != null) + report["reqId"] = job.ReqId; + + _lastPermSummary = + $"ops={job.Ops.Count} grants={job.Grants} revokes={job.Revokes} " + + $"groups={job.GroupsCreated} members={job.Members} same={job.AlreadyCorrect} " + + $"unresolved={job.Unresolved.Count} foreign={job.Foreign.Count} " + + $"pending={job.Pending.Count} at={DateTime.UtcNow:HH:mm:ss}Z"; + + EmitFrame(report); + } + + /// A refusal of the whole sync, with a reason the website turns into a sentence. + private void EmitPermError(string reqId, string reason) + { + Dictionary frame = Frame("perm.error", "reply"); + frame["reason"] = reason; + + if (reqId != null) + frame["reqId"] = reqId; + + EmitFrame(frame); + } + + // ---- drift, as it happens ---- + // + // Both frameworks raise a hook for every permission write, ours included. They are NOT the + // drift record — the sync report is, because only the site's desired set can say whether a + // grant is foreign. What they are is a reason to reconcile SOON: an operator typing + // `oxide.grant` at the console shows up on the website in seconds instead of at the next + // scheduled audit, and the authoritative answer still comes from a sync. + // + // That division is what makes them safe to trust at exactly the weight they deserve. A hook + // that stops firing on a framework upgrade costs latency, not correctness. + + private void OnUserPermissionGranted(string id, string permName) + { + MarkHook("OnUserPermissionGranted"); + EmitPermDrift("granted", id, permName, null); + } + + private void OnUserPermissionRevoked(string id, string permName) + { + MarkHook("OnUserPermissionRevoked"); + EmitPermDrift("revoked", id, permName, null); + } + + private void OnUserGroupAdded(string id, string groupName) + { + MarkHook("OnUserGroupAdded"); + EmitPermDrift("group-added", id, null, groupName); + } + + private void OnUserGroupRemoved(string id, string groupName) + { + MarkHook("OnUserGroupRemoved"); + EmitPermDrift("group-removed", id, null, groupName); + } + + private void OnGroupPermissionGranted(string name, string permName) + { + MarkHook("OnGroupPermissionGranted"); + EmitPermDrift("group-permission-granted", null, permName, name); + } + + private void OnGroupPermissionRevoked(string name, string permName) + { + MarkHook("OnGroupPermissionRevoked"); + EmitPermDrift("group-permission-revoked", null, permName, name); + } + + /// + /// Main thread. Says that the store changed under us, and by how much it is worth + /// caring. + /// + /// + /// Staff class (§8.5): it names a Steam id beside a privilege, which is a fact about a + /// person's standing rather than about what happened on the server. + /// + /// + private void EmitPermDrift(string action, string steamId, string perm, string group) + { + // Our own writes raise these hooks too. Suppressing them here rather than filtering on + // the website is the only version that works: the site cannot tell its own grant from + // a human's by looking at one, which is the entire reason drift needs a desired set. + if (_applyingPermissions) + return; + + Dictionary frame = Frame("perm.drift", "event"); + frame["action"] = action; + + if (!string.IsNullOrEmpty(steamId)) + frame["steamId"] = steamId; + + if (!string.IsNullOrEmpty(perm)) + frame["permission"] = perm; + + if (!string.IsNullOrEmpty(group)) + frame["group"] = group; + + EmitFrame(frame); + } + + // ---- reading a command's fields ---- + // + // `JsonConvert` hands back `object`, and every field below arrives as one. These are the + // three shapes this protocol uses, read defensively: a malformed field is a missing field, + // never an exception on the main thread. + + private static string StringField(Dictionary row, string key) + { + object value; + return row != null && row.TryGetValue(key, out value) && value != null + ? value.ToString() + : null; + } + + private static int IntField(Dictionary row, string key) + { + object value; + + if (row == null || !row.TryGetValue(key, out value) || value == null) + return 0; + + int parsed; + return int.TryParse(value.ToString(), out parsed) ? parsed : 0; + } + + /// The objects in an array field, skipping anything that is not one. + private static IEnumerable> Rows(Dictionary row, string key) + { + object value; + + if (row == null || !row.TryGetValue(key, out value)) + yield break; + + var list = value as Newtonsoft.Json.Linq.JArray; + + if (list == null) + yield break; + + foreach (Newtonsoft.Json.Linq.JToken item in list) + { + var obj = item as Newtonsoft.Json.Linq.JObject; + + if (obj == null) + continue; + + yield return obj.ToObject>(); + } + } + + /// The strings in an array field. + private static IEnumerable Strings(Dictionary row, string key) + { + object value; + + if (row == null || !row.TryGetValue(key, out value)) + yield break; + + var list = value as Newtonsoft.Json.Linq.JArray; + + if (list == null) + yield break; + + foreach (Newtonsoft.Json.Linq.JToken item in list) + { + if (item == null || item.Type == Newtonsoft.Json.Linq.JTokenType.Null) + continue; + + yield return item.ToString(); + } + } + + /// + /// Permission and group names are compared case-insensitively because both frameworks + /// store them lowered. Comparing what the site sent against what the store holds + /// without this makes `Kits.VIP` and `kits.vip` two different privileges, one of which + /// is granted for ever and never found again. + /// + private static string Lower(string value) + { + return value == null ? null : value.Trim().ToLowerInvariant(); + } + + private static HashSet Bucket(Dictionary> map, string key) + { + HashSet bucket; + + if (!map.TryGetValue(key, out bucket)) + { + bucket = new HashSet(); + map[key] = bucket; + } + + return bucket; + } + // ---- diagnostics ---- /// @@ -2015,6 +3013,30 @@ namespace Oxide.Plugins $"wipeId={(_wipeId ?? "none")}"); } + /// + /// What the last permission sync did, from the server console. + /// + /// + /// It is the fastest way to tell the two silent failures apart on a rig: a sync that + /// reported `unresolved` names granted nothing because no loaded plugin registered + /// them, and a sync that reported `pending` members could not put somebody in a group + /// because the store has never seen them. Both look like "the website says they have + /// it and the game disagrees" from the other end. + /// + /// + [ConsoleCommand("rg.perms")] + private void CmdPerms(ConsoleSystem.Arg arg) + { + string[] groups = permission.GetGroups(); + string[] registered = permission.GetPermissions(); + + arg.ReplyWith( + $"protocol={ProtocolVersion} registered={(registered != null ? registered.Length : 0)} " + + $"groups={(groups != null ? groups.Length : 0)} " + + $"running={(_permJob != null)}" + Environment.NewLine + + $"last sync: {_lastPermSummary}"); + } + /// /// Which of this plugin's hooks have actually fired, and how often. /// -- 2.49.1 From 48eeb5630022ac7dcde4e6d9fd216284455fa77b Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 22 Sep 2026 08:53:45 -0500 Subject: [PATCH 03/18] =?UTF-8?q?feat(bridge):=20protocol=205=20=E2=80=94?= =?UTF-8?q?=20configuration=20from=20the=20site,=20and=20the=20reload=20th?= =?UTF-8?q?at=20undoes=20itself?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R18. Three request/reply verbs — `config.list`, `config.read`, `config.write` — and the first command on this bridge that writes to the game host's filesystem. The walk is rooted at `Interface.Oxide.ConfigDirectory`, never a literal, so it is right on Oxide, on Carbon and on a server whose operator moved it; the data directory is never walked, because it holds live state and both frameworks' permission stores, which is protocol 4's mirror one directory over. A write is resolved, version-checked and parse-checked before the first byte lands, then backed up, written and reloaded — and the reload is WATCHED. If `OnPluginLoaded` does not arrive within four seconds the whole set is restored, reloaded again, and reported with the tail of the newest log file. Without that this is a web form that takes a required plugin off a production server one typo at a time. Four seconds is arithmetic, not taste: the worst path is two windows while a caller holds the sidecar's ten-second RPC budget. This plugin will not reload itself — the reload would close the link carrying the answer — and the site composes the file's bytes, so nothing here has an opinion about content beyond "it parses". Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM --- overlay.toml | 6 +- overlay/oxide/plugins/RunicGateway.cs | 1141 ++++++++++++++++++++++++- 2 files changed, 1140 insertions(+), 7 deletions(-) diff --git a/overlay.toml b/overlay.toml index e80c61a..31b2da0 100644 --- a/overlay.toml +++ b/overlay.toml @@ -26,9 +26,9 @@ # the same change that alters the emitters, exactly as the sidecar bumps # PROTOCOL_VERSION and the module bumps its own constant. # -# Current: 4 — the transport, the read path, identity, and the permission mirror -# (docs/rust-link/PROTOCOL.md §10). -protocol = 4 +# Current: 5 — the transport, the read path, identity, the permission mirror, and +# configuration from the site (docs/rust-link/PROTOCOL.md §11). +protocol = 5 # ── Oxide compatibility ────────────────────────────────────────────────────── # diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index c32cdf7..55732b4 100644 --- a/overlay/oxide/plugins/RunicGateway.cs +++ b/overlay/oxide/plugins/RunicGateway.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.IO; using System.Net.Sockets; using System.Text; using System.Threading; @@ -56,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 = 4; + private const int ProtocolVersion = 5; /// /// Loopback reconnects are cheap, so the ceiling is low. A sidecar restart should cost @@ -234,7 +235,8 @@ namespace Oxide.Plugins "CanUserLogin", "OnUserApproved", "OnUserPermissionGranted", "OnUserPermissionRevoked", "OnUserGroupAdded", "OnUserGroupRemoved", - "OnGroupPermissionGranted", "OnGroupPermissionRevoked" + "OnGroupPermissionGranted", "OnGroupPermissionRevoked", + "OnPluginLoaded", "OnPluginUnloaded" }; private void Init() @@ -296,6 +298,13 @@ namespace Oxide.Plugins _permJob = null; StopPermTimer(); + // A config write waiting on a reload dies here too, and its files stay as written. + // That is the honest outcome: the rollback exists to undo an edit that took a plugin + // down, and this plugin being unloaded is not evidence of either — it is usually THIS + // plugin being reloaded by somebody. The caller times out and re-reads. + StopConfigWatch(); + _configJob = null; + _running = false; _stopping.Set(); Wake.Set(); @@ -777,6 +786,22 @@ namespace Oxide.Plugins HandlePermSync(cmd, reqId); break; + case "config.list": + // Protocol 5 (R18). What settings files exist on this host, and which plugins + // are loaded to reload them. A description of the tree, never its contents. + HandleConfigList(reqId); + break; + + case "config.read": + HandleConfigRead(cmd, reqId); + break; + + case "config.write": + // The first command that writes to the game HOST's filesystem, which is the + // most powerful thing the website can do to a server. + HandleConfigWrite(cmd, reqId); + break; + default: Puts($"unknown command '{verb}'"); break; @@ -1675,8 +1700,7 @@ namespace Oxide.Plugins private static long NowMs() { - return (long)(DateTime.UtcNow - new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)) - .TotalMilliseconds; + return ToUnixMs(DateTime.UtcNow); } // ---- account linking ---- @@ -2867,6 +2891,45 @@ namespace Oxide.Plugins EmitPermDrift("group-permission-revoked", null, permName, name); } + // ---- the reload, watched ---- + // + // These two are why an automatic rollback is possible at all (R18): a reload either + // produces `OnPluginLoaded` for the target or it does not, and the second case is a plugin + // that is DOWN because of an edit the website just made. + // + // They are deliberately not a general plugin-lifecycle feed. Nothing is emitted from + // either: a busy server loads and unloads plugins for reasons that have nothing to do with + // us, and a frame per event would be noise on the wire and a trigger nobody asked for. + + private void OnPluginLoaded(Plugin plugin) + { + MarkHook("OnPluginLoaded"); + + ConfigWriteJob job = _configJob; + + if (job == null || plugin == null) + return; + + if (string.Equals(plugin.Name, job.Reload, StringComparison.OrdinalIgnoreCase)) + ConfigReloadArrived(); + } + + private void OnPluginUnloaded(Plugin plugin) + { + MarkHook("OnPluginUnloaded"); + + ConfigWriteJob job = _configJob; + + if (job == null || plugin == null) + return; + + // Only ever used to word the failure. "It unloaded and did not come back" is a + // compile error in the edited file; "it never reloaded" is a framework that did not + // act on the request at all, and those send an operator to different places. + if (string.Equals(plugin.Name, job.Reload, StringComparison.OrdinalIgnoreCase)) + job.Unloaded = true; + } + /// /// Main thread. Says that the store changed under us, and by how much it is worth /// caring. @@ -2899,6 +2962,1053 @@ namespace Oxide.Plugins EmitFrame(frame); } + // ---- protocol 5: plugin configuration ---- + // + // R18. An admin edits a loaded plugin's settings on the website and the plugin reloads. + // Three request/reply verbs: `config.list` describes the tree, `config.read` returns one + // file, `config.write` replaces a set and reloads whatever owns it. + // + // Four properties live on this side because only the game host can hold them: + // + // * **The roots come from the framework, never from a literal.** `ConfigDirectory` is + // `oxide/config` on Oxide, `carbon/configs` on Carbon, and neither on a server whose + // operator moved it (`CARBON.md` §3). That amendment was proven the best way it could + // have been: this plugin's own config landed in both places, from this same file. + // * **`DataDirectory` is never walked.** It holds live state — kit cooldowns, zone + // definitions — and both frameworks' permission stores, which is R2's mirror one + // directory over. A settings editor that strayed there would be editing phase 7 + // underneath itself. + // * **A reload is observable.** `OnPluginLoaded` is a real hook, so "did that work" has + // an answer rather than an assumption — and an answer is the whole reason an automatic + // rollback is possible at all. + // * **The site composes the bytes; this plugin writes them.** It receives whole file + // text, not a key and a value, because JavaScript cannot tell `1` from `1.0` and Oxide + // deserializes into typed C# classes (D35/D36). Everything this end does to a document + // is check that it parses. + + /// How deep the walk goes before it stops trying to describe a tree. + private const int ConfigMaxDepth = 6; + + /// How many files the walk will report. + private const int ConfigMaxFiles = 500; + + /// + /// The largest file this plugin will read or write. + /// + /// + /// A file past it is reported and marked un-editable with the reason, never + /// omitted: an operator who cannot find a file they know exists goes looking for a bug + /// in the bridge, and one who can see why it was refused does not. + /// + /// + private const int ConfigMaxFileBytes = 256 * 1024; + + /// Files one write may touch. A plugin can own several; ten of them is not a tree. + private const int ConfigMaxWriteFiles = 10; + + /// + /// How long a reload has to produce OnPluginLoaded before the write is undone. + /// + /// + /// It is under half the sidecar's RPC timeout, and that is arithmetic rather than + /// taste. The worst path through one write is two of these windows — wait for the + /// edit's reload, give up, restore, wait for the restore's reload — with a caller + /// holding a socket for the whole of it (REPLY_TIMEOUT, 10s, rust-link + /// sidecar/src/rpc.rs). A window that does not leave room for both makes the + /// rollback report arrive after the only thing waiting to hear it has given up, which + /// is the one outcome worse than no rollback: the plugin is fine and nobody knows. + /// + /// + private const float ConfigReloadWindowSeconds = 4f; + + /// How much of the newest log file is read back when a reload does not arrive. + private const int ConfigLogTailBytes = 8 * 1024; + + /// One file, resolved and checked, on its way to disk. + private sealed class StagedConfigWrite + { + public string RelPath; + public string FullPath; + public string Text; + + /// What was there before, held for the rollback that may never be needed. + public string Original; + + public bool Written; + } + + /// + /// A write that has landed and is waiting to hear whether the plugin came back. + /// + /// + /// At most one exists at a time. A second write arriving mid-window is refused rather + /// than queued: two overlapping rollbacks over intersecting file sets can restore a + /// file to a state that was never on disk. + /// + /// + private sealed class ConfigWriteJob + { + public string ReqId; + public string Reload; + public List Staged; + public Timer Watch; + + /// Whether the target was seen leaving. It separates "never started" from "did not come back". + public bool Unloaded; + } + + private ConfigWriteJob _configJob; + + private string _lastConfigSummary = "none yet"; + + /// Whether this is a Carbon host. Resolved once, by looking, never by compiling twice (R19). + private static bool? _carbon; + + private void HandleConfigList(string reqId) + { + string root = ConfigRoot(); + + if (string.IsNullOrEmpty(root) || !Directory.Exists(root)) + { + EmitConfigError(reqId, "no-root", "this framework reports no configuration directory"); + return; + } + + var files = new List(); + bool truncated = false; + + try + { + WalkConfig(root, root, 0, files, ref truncated); + } + catch (Exception ex) + { + EmitConfigError(reqId, "unreadable", ex.Message); + return; + } + + Dictionary reply = Frame("config.catalogue", "reply"); + reply["root"] = root; + reply["files"] = files; + reply["plugins"] = LoadedPlugins(); + reply["truncated"] = truncated; + + // Named rather than inferred, so the website's locked-key guard (D38) is written + // against what this plugin calls itself instead of against a filename somebody may + // rename. The file carrying the link is not a file the site may break from a form. + reply["self"] = Name; + + reply["limits"] = new Dictionary + { + { "depth", ConfigMaxDepth }, + { "files", ConfigMaxFiles }, + { "fileBytes", ConfigMaxFileBytes }, + { "writeFiles", ConfigMaxWriteFiles } + }; + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + } + + /// + /// Main thread, and deliberately cheap: it reads directory entries, never file + /// contents. A tree of 500 files costs 500 stat calls here; hashing all of them would + /// cost up to 128 MB of reads in one frame, which is exactly the unbounded work §10.5 + /// forbids. A file's version is therefore produced by config.read, on the one + /// file somebody actually opened. + /// + private void WalkConfig(string root, string dir, int depth, List into, ref bool truncated) + { + if (depth > ConfigMaxDepth) + { + truncated = true; + return; + } + + string[] entries; + + try + { + entries = Directory.GetFiles(dir, "*.json"); + } + catch (Exception) + { + truncated = true; + return; + } + + Array.Sort(entries, StringComparer.OrdinalIgnoreCase); + + foreach (string full in entries) + { + if (into.Count >= ConfigMaxFiles) + { + truncated = true; + return; + } + + FileInfo info; + + try + { + info = new FileInfo(full); + } + catch (Exception) + { + continue; + } + + // A link is not followed even when it points somewhere legitimate. Resolving one + // is how a tree that looks bounded turns out not to be, and how a path that + // validates as "under the root" writes somewhere else. + if ((info.Attributes & FileAttributes.ReparsePoint) != 0) + continue; + + string rel = RelativeConfigPath(root, full); + + var row = new Dictionary + { + { "path", rel }, + { "bytes", info.Length }, + { "modified", ToUnixMs(info.LastWriteTimeUtc) }, + { "plugin", PluginGuess(rel) } + }; + + if (info.Length > ConfigMaxFileBytes) + { + row["editable"] = false; + row["reason"] = "larger than this bridge will carry"; + } + else + { + row["editable"] = true; + } + + into.Add(row); + } + + string[] dirs; + + try + { + dirs = Directory.GetDirectories(dir); + } + catch (Exception) + { + truncated = true; + return; + } + + Array.Sort(dirs, StringComparer.OrdinalIgnoreCase); + + foreach (string sub in dirs) + { + if (into.Count >= ConfigMaxFiles) + { + truncated = true; + return; + } + + try + { + if ((new DirectoryInfo(sub).Attributes & FileAttributes.ReparsePoint) != 0) + continue; + } + catch (Exception) + { + continue; + } + + WalkConfig(root, sub, depth + 1, into, ref truncated); + } + } + + private void HandleConfigRead(Dictionary cmd, string reqId) + { + string rel = StringField(cmd, "path"); + string full; + string refusal; + + if (!ResolveConfigPath(rel, out full, out refusal)) + { + EmitConfigError(reqId, "path", refusal); + return; + } + + if (!File.Exists(full)) + { + EmitConfigError(reqId, "missing", "no such configuration file"); + return; + } + + string text; + long bytes; + DateTime modified; + + try + { + var info = new FileInfo(full); + bytes = info.Length; + modified = info.LastWriteTimeUtc; + + if (bytes > ConfigMaxFileBytes) + { + EmitConfigError(reqId, "too-large", "larger than this bridge will carry"); + return; + } + + text = File.ReadAllText(full, Encoding.UTF8); + } + catch (Exception ex) + { + EmitConfigError(reqId, "unreadable", ex.Message); + return; + } + + Dictionary reply = Frame("config.file", "reply"); + reply["path"] = rel; + reply["text"] = text; + reply["version"] = VersionOf(text); + reply["bytes"] = bytes; + reply["modified"] = ToUnixMs(modified); + reply["plugin"] = PluginGuess(rel); + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + } + + /// + /// Main thread. Replaces a set of files and reloads what owns them. + /// + /// + /// Everything is resolved, version-checked and parsed before the first byte is + /// written — the same posture as perm.sync, and for the same reason: a + /// refusal that has touched nothing has nothing to unwind. What cannot be checked in + /// advance is whether the plugin will come back, which is what the window and the + /// rollback are for. + /// + /// + private void HandleConfigWrite(Dictionary cmd, string reqId) + { + if (_configJob != null) + { + EmitConfigError(reqId, "busy", "another configuration write is still waiting for its reload"); + return; + } + + var rows = new List>(Rows(cmd, "files")); + + if (rows.Count == 0) + { + EmitConfigError(reqId, "empty", "a write must name at least one file"); + return; + } + + if (rows.Count > ConfigMaxWriteFiles) + { + EmitConfigError(reqId, "too-many", $"a write may touch at most {ConfigMaxWriteFiles} files"); + return; + } + + string reload = StringField(cmd, "reload"); + + // The site may not reload the bridge. The reload would unload this plugin, close the + // link the answer travels on, and leave a rollback with nothing watching it — so the + // one failure this whole mechanism exists to prevent would be the one it could not + // report. Our own settings are still editable (D38); they take effect the next time + // somebody reloads this plugin deliberately. + if (!string.IsNullOrEmpty(reload) && reload.Equals(Name, StringComparison.OrdinalIgnoreCase)) + { + EmitConfigError(reqId, "reload-self", + "this bridge will not reload itself: the reload would close the link carrying the answer"); + return; + } + + var staged = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (Dictionary row in rows) + { + string rel = StringField(row, "path"); + string version = StringField(row, "version"); + string text = StringField(row, "text"); + string full; + string refusal; + + if (!ResolveConfigPath(rel, out full, out refusal)) + { + EmitConfigError(reqId, "path", refusal); + return; + } + + if (!seen.Add(full)) + { + EmitConfigError(reqId, "duplicate", $"'{rel}' is named twice in one write"); + return; + } + + if (text == null) + { + EmitConfigError(reqId, "empty", $"'{rel}' carries no text"); + return; + } + + if (Encoding.UTF8.GetByteCount(text) > ConfigMaxFileBytes) + { + EmitConfigError(reqId, "too-large", $"'{rel}' is larger than this bridge will carry"); + return; + } + + // A config file is edited, never created. A plugin writes its own defaults, and a + // file this walk has never seen is not a settings file somebody forgot — it is a + // path we have no reason to believe in. + if (!File.Exists(full)) + { + EmitConfigError(reqId, "missing", $"'{rel}' does not exist"); + return; + } + + string current; + + try + { + current = File.ReadAllText(full, Encoding.UTF8); + } + catch (Exception ex) + { + EmitConfigError(reqId, "unreadable", $"'{rel}': {ex.Message}"); + return; + } + + string actual = VersionOf(current); + + if (version != actual) + { + Dictionary conflict = Frame("config.error", "reply"); + conflict["reason"] = "conflict"; + conflict["detail"] = $"'{rel}' changed on disk since it was read"; + conflict["path"] = rel; + conflict["version"] = actual; + + if (reqId != null) + conflict["reqId"] = reqId; + + EmitFrame(conflict); + return; + } + + try + { + Newtonsoft.Json.Linq.JToken.Parse(text); + } + catch (Exception ex) + { + EmitConfigError(reqId, "invalid", $"'{rel}' is not valid JSON: {ex.Message}"); + return; + } + + staged.Add(new StagedConfigWrite + { + RelPath = rel, + FullPath = full, + Text = text, + Original = current + }); + } + + foreach (StagedConfigWrite file in staged) + { + try + { + BackupConfig(file); + File.WriteAllText(file.FullPath, file.Text, Utf8NoBom); + file.Written = true; + } + catch (Exception ex) + { + // Half a set on disk is the state nothing can reason about. Put back whatever + // did land before answering. + RestoreConfig(staged); + EmitConfigError(reqId, "unwritable", $"'{file.RelPath}': {ex.Message}"); + return; + } + } + + if (string.IsNullOrEmpty(reload)) + { + SendConfigReport(reqId, staged, false, false, "no reload was asked for", null); + return; + } + + if (plugins.Find(reload) == null) + { + // The file is written and the plugin is not loaded, so there is nothing to reload + // and nothing to roll back from: the change applies when it is next loaded. Said + // plainly, because "saved" and "in effect" are different claims. + SendConfigReport(reqId, staged, false, false, $"'{reload}' is not loaded", null); + return; + } + + _configJob = new ConfigWriteJob + { + ReqId = reqId, + Reload = reload, + Staged = staged + }; + + _configJob.Watch = timer.Once(ConfigReloadWindowSeconds, ConfigReloadTimedOut); + + if (!ReloadPluginByName(reload)) + { + StopConfigWatch(); + _configJob = null; + RestoreConfig(staged); + EmitConfigError(reqId, "reload-failed", $"this framework refused to reload '{reload}'"); + } + } + + /// Main thread. The reload arrived: the plugin is back, so the write stands. + private void ConfigReloadArrived() + { + ConfigWriteJob job = _configJob; + + if (job == null) + return; + + StopConfigWatch(); + _configJob = null; + + SendConfigReport(job.ReqId, job.Staged, true, false, null, null); + } + + /// + /// Main thread. The window closed with no OnPluginLoaded, which means the plugin + /// is down and the edit is why. Put every file back, reload again, and say so. + /// + /// + /// 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. + /// + /// + private void ConfigReloadTimedOut() + { + ConfigWriteJob job = _configJob; + + if (job == null) + return; + + _configJob = null; + StopConfigWatch(); + + RestoreConfig(job.Staged); + + string reason = job.Unloaded + ? $"'{job.Reload}' unloaded and did not come back within {ConfigReloadWindowSeconds:0}s" + : $"'{job.Reload}' did not reload within {ConfigReloadWindowSeconds:0}s"; + + // Fire and forget: the restore is on disk either way, and waiting for a second window + // would spend the caller's whole RPC budget to learn something the operator can see. + ReloadPluginByName(job.Reload); + + SendConfigReport(job.ReqId, job.Staged, false, true, reason, LogTail()); + } + + private void StopConfigWatch() + { + ConfigWriteJob job = _configJob; + + if (job == null || job.Watch == null) + return; + + job.Watch.Destroy(); + job.Watch = null; + } + + /// + /// Main thread. What happened, and what is on disk now. + /// + /// + /// The versions are re-read rather than assumed, because both frameworks merge missing + /// defaults into a config on load and save it back — so the file after a successful + /// reload is regularly not the file we wrote, and a site holding our version would + /// conflict with itself on the next save. + /// + /// + private void SendConfigReport( + string reqId, + List staged, + bool reloaded, + bool rolledBack, + string reason, + string log) + { + var files = new List(); + + foreach (StagedConfigWrite file in staged) + { + var row = new Dictionary { { "path", file.RelPath } }; + + try + { + string now = File.ReadAllText(file.FullPath, Encoding.UTF8); + row["version"] = VersionOf(now); + row["bytes"] = Encoding.UTF8.GetByteCount(now); + row["rewritten"] = !rolledBack && now != file.Text; + } + catch (Exception) + { + row["version"] = null; + } + + files.Add(row); + } + + Dictionary report = Frame("config.report", "reply"); + report["ok"] = !rolledBack; + report["reloaded"] = reloaded; + report["rolledBack"] = rolledBack; + report["files"] = files; + + if (!string.IsNullOrEmpty(reason)) + report["reason"] = reason; + + if (!string.IsNullOrEmpty(log)) + report["log"] = log; + + if (reqId != null) + report["reqId"] = reqId; + + _lastConfigSummary = + $"files={staged.Count} reloaded={reloaded} rolledBack={rolledBack} " + + $"at={DateTime.UtcNow:HH:mm:ss}Z" + + (string.IsNullOrEmpty(reason) ? "" : $" reason={reason}"); + + EmitFrame(report); + } + + private void EmitConfigError(string reqId, string reason, string detail) + { + Dictionary frame = Frame("config.error", "reply"); + frame["reason"] = reason; + + if (!string.IsNullOrEmpty(detail)) + frame["detail"] = detail; + + if (reqId != null) + frame["reqId"] = reqId; + + EmitFrame(frame); + } + + /// Main thread. Puts every file that landed back the way it was. + private void RestoreConfig(List staged) + { + foreach (StagedConfigWrite file in staged) + { + if (!file.Written) + continue; + + try + { + File.WriteAllText(file.FullPath, file.Original, Utf8NoBom); + } + catch (Exception ex) + { + // Nothing else can be done from here, and it must be loud: the file on disk is + // now the edit that took a plugin down, and the operator is the only one who + // can fix it. + Puts($"could not restore '{file.RelPath}': {ex.Message}"); + } + } + } + + /// + /// Keeps a copy of a file about to be overwritten, and the nine before it. + /// + /// + /// Under DataDirectory, which is this plugin writing its own state — not the + /// editor reaching into the tree it must never walk. The rollback itself does not + /// depend on these files; it holds the original in memory. They are for the operator + /// who wants last Tuesday's config back. + /// + /// + private void BackupConfig(StagedConfigWrite file) + { + string dir = Path.Combine(Interface.Oxide.DataDirectory, Name, "config-backups"); + Directory.CreateDirectory(dir); + + string flat = file.RelPath.Replace('/', '_'); + string stamp = DateTime.UtcNow.ToString("yyyyMMdd-HHmmss"); + + File.WriteAllText(Path.Combine(dir, $"{flat}.{stamp}.bak"), file.Original, Utf8NoBom); + + string[] kept; + + try + { + kept = Directory.GetFiles(dir, $"{flat}.*.bak"); + } + catch (Exception) + { + return; + } + + if (kept.Length <= 10) + return; + + Array.Sort(kept, StringComparer.Ordinal); + + for (int i = 0; i < kept.Length - 10; i++) + { + try + { + File.Delete(kept[i]); + } + catch (Exception) + { + } + } + } + + /// + /// The tail of the newest log file, read only when a reload failed. + /// + /// + /// A rollback that says "it did not come back" and nothing else sends the operator to a + /// console to find the line that says why. This carries the line instead. Bounded, and + /// paid for only on the failure path. + /// + /// + private string LogTail() + { + try + { + string dir = Interface.Oxide.LogDirectory; + + if (string.IsNullOrEmpty(dir) || !Directory.Exists(dir)) + return null; + + string[] logs = Directory.GetFiles(dir, "*.txt"); + + if (logs.Length == 0) + return null; + + string newest = null; + DateTime newestAt = DateTime.MinValue; + + foreach (string candidate in logs) + { + DateTime at = File.GetLastWriteTimeUtc(candidate); + + if (at <= newestAt) + continue; + + newest = candidate; + newestAt = at; + } + + if (newest == null) + return null; + + using (var stream = new FileStream(newest, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) + { + int take = (int)Math.Min(stream.Length, ConfigLogTailBytes); + stream.Seek(-take, SeekOrigin.End); + + var buffer = new byte[take]; + int read = stream.Read(buffer, 0, take); + + return Encoding.UTF8.GetString(buffer, 0, read); + } + } + catch (Exception) + { + return null; + } + } + + /// + /// Reloads a plugin through the framework rather than by composing a console command. + /// + /// + /// The console is the one place the two frameworks genuinely differ — Carbon's commands + /// are c.-prefixed and an alias for the Oxide names is opt-in — and a wrong + /// prefix on Carbon prints nothing at all, so it looks exactly like a command that + /// worked (`CARBON.md` §5). Reflection rather than #if CARBON, because one + /// byte-identical source file on both frameworks is the property R19 proved and is + /// worth keeping. + /// + /// + private bool ReloadPluginByName(string name) + { + try + { + System.Reflection.MethodInfo reload = Interface.Oxide.GetType() + .GetMethod("ReloadPlugin", new[] { typeof(string) }); + + if (reload != null) + { + reload.Invoke(Interface.Oxide, new object[] { name }); + return true; + } + } + catch (Exception ex) + { + Puts($"framework reload of '{name}' threw: {ex.Message}"); + } + + try + { + covalence.Server.Command(IsCarbon() ? "c.reload" : "oxide.reload", name); + return true; + } + catch (Exception ex) + { + Puts($"console reload of '{name}' threw: {ex.Message}"); + return false; + } + } + + private static bool IsCarbon() + { + if (_carbon.HasValue) + return _carbon.Value; + + bool found = false; + + try + { + foreach (System.Reflection.Assembly loaded in AppDomain.CurrentDomain.GetAssemblies()) + { + string assembly = loaded.GetName().Name; + + if (assembly == null) + continue; + + if (assembly == "Carbon" || assembly.StartsWith("Carbon.", StringComparison.Ordinal)) + { + found = true; + break; + } + } + } + catch (Exception) + { + } + + _carbon = found; + return found; + } + + private static string ConfigRoot() + { + try + { + return Interface.Oxide.ConfigDirectory; + } + catch (Exception) + { + return null; + } + } + + /// + /// Turns a path from a web form into a path on this host, or refuses it with a reason. + /// + /// + /// Phase 7 addressed things by name; this phase addresses them by path, which is + /// exactly the change that introduces this bug class. Four refusals, and the last is the + /// one that matters: a resolved path must genuinely sit under the config root, checked + /// after canonicalisation rather than before. + /// + /// + private static bool ResolveConfigPath(string rel, out string full, out string refusal) + { + full = null; + refusal = null; + + if (string.IsNullOrEmpty(rel)) + { + refusal = "no path"; + return false; + } + + if (rel.IndexOf('\0') >= 0 || rel.IndexOf("..", StringComparison.Ordinal) >= 0) + { + refusal = "a path may not step outside the configuration directory"; + return false; + } + + if (rel.StartsWith("/", StringComparison.Ordinal) + || rel.StartsWith("\\", StringComparison.Ordinal) + || Path.IsPathRooted(rel) + || rel.IndexOf(':') >= 0) + { + refusal = "a path must be relative to the configuration directory"; + return false; + } + + if (!rel.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) + { + refusal = "only .json files are configuration"; + return false; + } + + string root = ConfigRoot(); + + if (string.IsNullOrEmpty(root)) + { + refusal = "this framework reports no configuration directory"; + return false; + } + + string rooted; + + try + { + rooted = Path.GetFullPath(root); + full = Path.GetFullPath(Path.Combine(rooted, rel.Replace('/', Path.DirectorySeparatorChar))); + } + catch (Exception ex) + { + refusal = ex.Message; + full = null; + return false; + } + + string prefix = rooted.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal) + ? rooted + : rooted + Path.DirectorySeparatorChar; + + if (!full.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + refusal = "that path is not inside the configuration directory"; + full = null; + return false; + } + + try + { + var info = new FileInfo(full); + + if (info.Exists && (info.Attributes & FileAttributes.ReparsePoint) != 0) + { + refusal = "that file is a link, and a link is not followed"; + full = null; + return false; + } + } + catch (Exception) + { + } + + return true; + } + + private static string RelativeConfigPath(string root, string full) + { + string prefix = root.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal) + ? root + : root + Path.DirectorySeparatorChar; + + string rel = full.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) + ? full.Substring(prefix.Length) + : Path.GetFileName(full); + + return rel.Replace(Path.DirectorySeparatorChar, '/').Replace('\\', '/'); + } + + /// + /// Which plugin a file probably belongs to: the folder for a nested file, the file's own + /// name otherwise. + /// + /// + /// A guess, and it is labelled one all the way to the form. A folder name is + /// convention, not contract. Infer it silently and the failure is the nastiest available + /// here: we reload the wrong plugin, watch OnPluginLoaded fire for it, and + /// report success while the plugin that was actually edited never re-read a thing. + /// + /// + private static string PluginGuess(string rel) + { + int slash = rel.IndexOf('/'); + + return slash > 0 + ? rel.Substring(0, slash) + : Path.GetFileNameWithoutExtension(rel); + } + + private List LoadedPlugins() + { + var rows = new List(); + Plugin[] all; + + try + { + all = plugins.GetAll(); + } + catch (Exception) + { + return rows; + } + + if (all == null) + return rows; + + foreach (Plugin loaded in all) + { + if (loaded == null || string.IsNullOrEmpty(loaded.Name)) + continue; + + rows.Add(new Dictionary + { + { "name", loaded.Name }, + { "title", loaded.Title }, + { "version", loaded.Version.ToString() }, + { "author", loaded.Author } + }); + } + + return rows; + } + + /// + /// A file's identity for optimistic concurrency: its length and an FNV-1a hash of its + /// text. + /// + /// + /// Deliberately not a cryptographic digest. Nothing here is a security claim — the site + /// never computes one of these, it only echoes back the one it was given — and + /// System.Security.Cryptography is a namespace whose availability under a plugin + /// compiler is one more thing that would have to be true on two frameworks. + /// + /// + private static string VersionOf(string text) + { + byte[] bytes = Encoding.UTF8.GetBytes(text ?? string.Empty); + ulong hash = 14695981039346656037UL; + + foreach (byte b in bytes) + { + hash ^= b; + hash *= 1099511628211UL; + } + + return $"{bytes.Length:x}-{hash:x16}"; + } + + /// UTF-8 with no byte-order mark, which is what both frameworks write. + private static readonly UTF8Encoding Utf8NoBom = new UTF8Encoding(false); + + private static long ToUnixMs(DateTime utc) + { + return (long)(utc - new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)).TotalMilliseconds; + } + // ---- reading a command's fields ---- // // `JsonConvert` hands back `object`, and every field below arrives as one. These are the @@ -3037,6 +4147,29 @@ namespace Oxide.Plugins $"last sync: {_lastPermSummary}"); } + /// + /// Where this framework keeps configuration, and what the last write from the website + /// did. + /// + /// + /// The root is the interesting half. It is oxide/config on one rig and + /// carbon/configs on the other, from this same source file, and an operator + /// wondering why the website is offering a tree they do not recognise is usually + /// looking at a server whose directories were moved. + /// + /// + [ConsoleCommand("rg.config")] + private void CmdConfig(ConsoleSystem.Arg arg) + { + string root = ConfigRoot(); + + arg.ReplyWith( + $"protocol={ProtocolVersion} framework={(IsCarbon() ? "carbon" : "oxide")} " + + $"root={(string.IsNullOrEmpty(root) ? "(none)" : root)}" + Environment.NewLine + + $"waiting={(_configJob != null ? _configJob.Reload : "no")}" + Environment.NewLine + + $"last write: {_lastConfigSummary}"); + } + /// /// Which of this plugin's hooks have actually fired, and how often. /// -- 2.49.1 From 568fc873b31051c2f7e6ac756d554b8be1906a98 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 23 Sep 2026 05:14:18 -0500 Subject: [PATCH 04/18] =?UTF-8?q?feat(bridge):=20protocol=206=20=E2=80=94?= =?UTF-8?q?=20first-party=20clans,=20one=20board=20and=20five=20events?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `clans` board carries every clan the game will list, with its roster and roles, plus the facts the website needs to trust it: enabled, backend, supported, truncated, and whether the optional uMod Clans plugin is loaded. It is re-sent on connect and on the 60-second cadence, and a few seconds after any membership change. The five events are clan.created, clan.disbanded, clan.member.added, clan.member.left and clan.member.kicked. What the game's assemblies dictated: - The game has no enumerate-all call. The clan leaderboard is the only listing, and the game clamps it to 100 clans. A board at that ceiling says `truncated`, as does one that would not fit in 768 KiB (the sidecar drops lines over 1 MiB). - The local backend is synchronous behind ValueTask. A task that has not completed means another backend (Nexus), which is refused with a reason rather than waited on from the main thread. - A clan id is not an identity, so every frame carries createdMs. - OnClanMemberAdded hands over a bare id before the game's cache refreshes, so the clan is read back, with a fallback to the id alone. - uMod Clans raises OnClanDisbanded(string, List), which has the same name and arity as the game's hook, so the bridge types it exactly. A member's LastSeen is never sent: it is presence (PLAN.md §23). overlay.toml moves to protocol 6 and drops Clans from requires_plugins (D47): Teams come from the game's own clans. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- README.md | 8 +- overlay.toml | 15 +- overlay/oxide/plugins/RunicGateway.cs | 446 +++++++++++++++++++++++++- 3 files changed, 460 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 88eecf3..16da40f 100644 --- a/README.md +++ b/README.md @@ -58,11 +58,15 @@ tarball and installs the sidecar alongside it. ### Prerequisites -The bridge itself needs nothing but Oxide. The features that follow it read four third-party plugins -an operator installs from uMod — `Clans`, `Kits`, `PopupNotifications` and `ZoneManager`. They are +The bridge itself needs nothing but Oxide. The features that follow it read three third-party +plugins an operator installs from uMod — `Kits`, `PopupNotifications` and `ZoneManager`. They are listed in `overlay.toml` so the installer's `doctor` can report a missing one by name rather than leaving the site quietly short of a feature. +Clans on the website come from Rust's **own** clan system, not from the uMod `Clans` plugin, which +is optional. The two share no data: a server running the plugin has two unrelated clan systems, and +only the game's own clans become Teams on the site. + ## Diagnosing it ``` diff --git a/overlay.toml b/overlay.toml index 31b2da0..b62e4ca 100644 --- a/overlay.toml +++ b/overlay.toml @@ -26,9 +26,9 @@ # the same change that alters the emitters, exactly as the sidecar bumps # PROTOCOL_VERSION and the module bumps its own constant. # -# Current: 5 — the transport, the read path, identity, the permission mirror, and -# configuration from the site (docs/rust-link/PROTOCOL.md §11). -protocol = 5 +# Current: 6 — the transport, the read path, identity, the permission mirror, +# configuration from the site, and first-party clans (docs/rust-link/PROTOCOL.md §12). +protocol = 6 # ── Oxide compatibility ────────────────────────────────────────────────────── # @@ -45,6 +45,11 @@ min_oxide_version = "2.0.7585" # The `oxide/plugins/` files this overlay expects to find already installed. They # are not shipped here — they are third-party plugins an operator installs from # uMod — and the installer's `doctor` reports a missing one rather than -# installing it. Listing them is what turns "the site shows no clans" into a +# installing it. Listing them is what turns a missing reward or zone into a # named prerequisite. -requires_plugins = ["Clans", "Kits", "PopupNotifications", "ZoneManager"] +# +# The uMod **Clans** plugin is deliberately NOT listed (docs/modules/rust/PLAN.md +# D47). Teams come from Rust's own clan system, which that plugin never touches, +# so requiring it would split a server's players across two unrelated clan +# systems. It belongs to phase 17's optional tier. +requires_plugins = ["Kits", "PopupNotifications", "ZoneManager"] diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index 55732b4..1de3153 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 = 5; + private const int ProtocolVersion = 6; /// /// Loopback reconnects are cheap, so the ceiling is low. A sidecar restart should cost @@ -236,7 +236,9 @@ namespace Oxide.Plugins "OnUserPermissionGranted", "OnUserPermissionRevoked", "OnUserGroupAdded", "OnUserGroupRemoved", "OnGroupPermissionGranted", "OnGroupPermissionRevoked", - "OnPluginLoaded", "OnPluginUnloaded" + "OnPluginLoaded", "OnPluginUnloaded", + "OnClanCreated", "OnClanDisbanded", "OnClanMemberAdded", + "OnClanMemberLeft", "OnClanMemberKicked", "OnClanColorChanged" }; private void Init() @@ -291,6 +293,12 @@ namespace Oxide.Plugins _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 @@ -865,6 +873,18 @@ namespace Oxide.Plugins { SendHello(); SendPlayersBoard(); + + // Last, and fenced: the clan board reads the game's clan database, which is the one + // board here built from something other than a list in memory. A failure there must + // cost the clans and nothing else — the two boards above are what every page renders. + try + { + SendClansBoard(); + } + catch (Exception ex) + { + Puts($"could not build the clans board: {Flatten(ex.Message)}"); + } } /// Main thread. @@ -1508,6 +1528,428 @@ namespace Oxide.Plugins EmitFrame(frame); } + // ---- clans (protocol 6) ---- + // + // Rust's FIRST-PARTY clan system, which the website turns into core's Teams (R5, + // PROTOCOL.md §12). Not the uMod Clans plugin: that is a separate system that never + // touches `ClanManager` (D47), and this region reads nothing of it except whether it is + // loaded, so the website can warn that its clans are not Teams. + // + // One board and five events, the same split as presence. The events carry the story — + // founded, joined, left, removed, disbanded — and the board is the reconciliation point + // that corrects anything missed within a minute. Leadership travels ONLY on the board, + // because the game raises no hook when somebody is promoted or demoted (§12.3). + // + // Three facts about the game shape everything below, and all three were read from its + // assemblies rather than from a catalogue: + // + // 1. **There is no "list every clan" call.** The backend offers get-by-id and + // get-by-member; the only listing is the clan leaderboard, and the game clamps that + // to 100 clans by score. That clamp is the board's ceiling (D55), and a board at it + // says `truncated` rather than pretending to be the whole answer. + // + // 2. **A clan id is not an identity.** The database file's version number is + // hard-coded, so a game update that bumps it starts a fresh file whose ids restart at + // 1. Every frame therefore carries the clan's creation time beside its id, and the + // pair is what the website keys a Team on (D52). + // + // 3. **The local backend is synchronous behind an async signature.** Every `ValueTask` + // it returns has already completed, which is what makes reading it from a hook + // legal. A task that has NOT completed means some other backend, and it is refused + // rather than waited on — waiting would be the main thread blocking on a database. + + /// + /// The most clans one board can carry, and it is the GAME's number: the clan leaderboard + /// clamps its limit to 10..100. Asking for more returns 100. + /// + private const int MaxClansOnBoard = 100; + + /// + /// How many bytes of clan rows one board may carry. Well inside the sidecar's 1 MiB line + /// cap, which discards an over-long line outright: a board that silently never arrived + /// would look like a server with no clans. 100 clans of 100 members each is about a + /// megabyte, so the cap is reachable, and a board that meets it says so. + /// + private const int ClanBoardBudgetBytes = 768 * 1024; + + /// + /// How long after a membership change the clan board is re-sent. The event reaches the + /// sidecar at once; the roster that follows it waits for the game to refresh its own + /// cached clan, which `OnClanMemberAdded` fires before. + /// + private const float ClanBoardSettleSeconds = 3f; + + /// A pending early re-send of the clan board, so a burst of changes sends one. + private Timer _clansBoardSoon; + + /// + /// Main thread. Every clan on this server, with its roster and roles, as the board the + /// website reconciles Teams against. + /// + /// + /// The board is ALWAYS sent, including when there is nothing to read — with + /// supported: false and a reason. Saying nothing would leave the website holding + /// the last board it saw and no way to know it had stopped being true. + /// + /// + private void SendClansBoard() + { + Dictionary frame = Frame("clans", "snapshot"); + var clans = new List(); + + frame["enabled"] = ConVar.Clan.enabled; + frame["umodClans"] = plugins.Find("Clans") != null; + frame["clans"] = clans; + frame["count"] = 0; + frame["truncated"] = false; + + // Switched off by the operator. That is an authoritative answer — this server has no + // clans — and different from "could not read them", so it is supported and empty. + if (!ConVar.Clan.enabled) + { + frame["supported"] = true; + EmitFrame(frame); + return; + } + + IClanBackend backend = ClanManager.ServerInstance != null ? ClanManager.ServerInstance.Backend : null; + + if (backend == null) + { + frame["supported"] = false; + frame["reason"] = "the game's clan backend has not started yet"; + EmitFrame(frame); + return; + } + + frame["backend"] = backend.GetType().Name; + + // A Nexus server keeps its clans on Facepunch's side of the Nexus, behind a backend + // whose calls do not complete on the spot. Refused with a reason rather than guessed + // at (D55): half-reading it would be a board that is wrong in a way nobody can see. + if (!(backend is LocalClanBackend)) + { + frame["supported"] = false; + frame["reason"] = $"this server's clans are held by a {backend.GetType().Name}, which the bridge does not read"; + EmitFrame(frame); + return; + } + + System.Threading.Tasks.ValueTask>> listing = backend.GetLeaderboard(MaxClansOnBoard); + + if (!listing.IsCompleted || !listing.Result.IsSuccess) + { + frame["supported"] = false; + frame["reason"] = listing.IsCompleted + ? $"the game's clan list could not be read ({listing.Result.Result})" + : "the game's clan list did not answer at once"; + EmitFrame(frame); + return; + } + + List entries = listing.Result.Value; + + // At the clamp there may be more clans than the game will list. It cannot be told + // apart from exactly 100, so it is reported as possibly more — the honest direction, + // since the website then only ADDS Teams and never removes one on this answer. + bool truncated = entries.Count >= MaxClansOnBoard; + int bytes = 0; + + foreach (ClanLeaderboardEntry entry in entries) + { + IClan clan = ReadClan(backend, entry.ClanId); + + if (clan == null) + continue; + + Dictionary row = DescribeClan(clan); + int size = Encoding.UTF8.GetByteCount(JsonConvert.SerializeObject(row, Formatting.None)); + + if (bytes + size > ClanBoardBudgetBytes) + { + truncated = true; + break; + } + + bytes += size; + clans.Add(row); + } + + frame["supported"] = true; + frame["truncated"] = truncated; + frame["count"] = clans.Count; + + EmitFrame(frame); + } + + /// + /// Main thread. One clan by id, from the game's cache if it is there and from its + /// database if not, or null. Never waits: see fact 3 at the top of this region. + /// + private static IClan ReadClan(IClanBackend backend, long clanId) + { + IClan clan; + + if (backend.TryGet(clanId, out clan) && clan != null) + return clan; + + System.Threading.Tasks.ValueTask> read = backend.Get(clanId); + + if (!read.IsCompleted || !read.Result.IsSuccess) + return null; + + return read.Result.Value; + } + + /// + /// Main thread. A clan as the board carries it. + /// + /// + /// A member's LastSeen is left off on purpose. It says when somebody was last on + /// the server, which is presence, and the website's rule is that nothing names who is on + /// by default (PLAN.md §23). Not sending it is simpler than trusting every reader to + /// withhold it. + /// + /// + private Dictionary DescribeClan(IClan clan) + { + var roles = new Dictionary(); + + if (clan.Roles != null) + { + foreach (ClanRole role in clan.Roles) + roles[role.RoleId] = role; + } + + var members = new List(); + + if (clan.Members != null) + { + foreach (ClanMember member in clan.Members) + { + var row = new Dictionary + { + { "steamId", member.SteamId.ToString() }, + { "joinedMs", member.Joined } + }; + + ClanRole role; + + // A member whose role id matches no role is left without a rank rather than + // given one: "not known" must never read as "leads this clan", and rank 1 is + // exactly what a default of zero-plus-one would produce. + if (roles.TryGetValue(member.RoleId, out role)) + { + row["rank"] = role.Rank; + row["role"] = role.Name; + } + + string name = PlayerName(member.SteamId); + if (!string.IsNullOrEmpty(name)) + row["name"] = name; + + members.Add(row); + } + } + + UnityEngine.Color32 color = clan.Color; + + return new Dictionary + { + { "clanId", clan.ClanId }, + { "createdMs", clan.Created }, + { "name", clan.Name }, + { "color", $"#{color.r:x2}{color.g:x2}{color.b:x2}" }, + { "score", clan.Score }, + { "maxMembers", clan.MaxMemberCount }, + { "members", members } + }; + } + + /// + /// Main thread. A player's name, whether or not they are on, or null. The framework + /// remembers everybody who has ever joined, which is what lets an offline clan member + /// have a name on a roster. + /// + private string PlayerName(ulong steamId) + { + BasePlayer player = BasePlayer.FindByID(steamId) ?? BasePlayer.FindSleeping(steamId); + + if (player != null) + return player.displayName; + + Oxide.Core.Libraries.Covalence.IPlayer known = covalence.Players.FindPlayerById(steamId.ToString()); + return known != null ? known.Name : null; + } + + /// + /// Main thread. The envelope every clan event shares: which clan, identified the way + /// the website keys it (D52), and its name so a feed line can say something without a + /// lookup. + /// + private Dictionary ClanFrame(string kind, IClan clan) + { + Dictionary frame = Frame(kind, "event"); + frame["clanId"] = clan.ClanId; + frame["createdMs"] = clan.Created; + frame["clanName"] = clan.Name; + return frame; + } + + /// + /// Main thread. Puts a player onto a clan event as steamId and name — the + /// same two fields every player event carries, so the website's player table learns the + /// name the same way it does from a connect. + /// + private void PutPlayer(Dictionary frame, string idKey, string nameKey, ulong steamId) + { + if (steamId == 0UL) + return; + + frame[idKey] = steamId.ToString(); + + string name = PlayerName(steamId); + if (!string.IsNullOrEmpty(name)) + frame[nameKey] = name; + } + + /// + /// Main thread. Re-sends the clan board shortly, once, however many changes arrive in + /// the meantime. The cadence would get there within a minute anyway; this is what makes + /// a roster follow the event that changed it. + /// + private void ScheduleClansBoard() + { + if (_clansBoardSoon != null) + return; + + _clansBoardSoon = timer.Once(ClanBoardSettleSeconds, () => + { + _clansBoardSoon = null; + + if (!_connected) + return; + + try + { + SendClansBoard(); + } + catch (Exception ex) + { + Puts($"could not build the clans board: {Flatten(ex.Message)}"); + } + }); + } + + /// + /// The founder's own membership raises no OnClanMemberAdded — the game adds them + /// inside the creation — so this event carries them, and the website counts them in. + /// + private void OnClanCreated(LocalClan clan, ulong steamId) + { + MarkHook("OnClanCreated"); + + if (clan == null) + return; + + Dictionary frame = ClanFrame("clan.created", clan); + PutPlayer(frame, "steamId", "name", steamId); + EmitFrame(frame); + + ScheduleClansBoard(); + } + + /// + /// Typed exactly, and that matters here more than anywhere: the uMod Clans plugin raises + /// a hook with this SAME NAME and arity — OnClanDisbanded(string, List<ulong>) + /// — about its own, unrelated clans. Both frameworks match a call to a method by its + /// argument types, so a loosely typed signature here would file the plugin's clans as + /// the game's. + /// + private void OnClanDisbanded(LocalClan clan, ulong steamId) + { + MarkHook("OnClanDisbanded"); + + if (clan == null) + return; + + Dictionary frame = ClanFrame("clan.disbanded", clan); + PutPlayer(frame, "steamId", "name", steamId); + EmitFrame(frame); + + ScheduleClansBoard(); + } + + /// + /// The one clan hook that hands over a bare id rather than a clan: the game raises it + /// from inside its database layer, before the cached clan has been refreshed. The id is + /// enough to find the clan and its creation time; if even that fails, the frame goes + /// without createdMs and the website matches it on the id alone. + /// + private void OnClanMemberAdded(long clanId, ulong steamId) + { + MarkHook("OnClanMemberAdded"); + + IClanBackend backend = ClanManager.ServerInstance != null ? ClanManager.ServerInstance.Backend : null; + IClan clan = backend != null ? ReadClan(backend, clanId) : null; + + Dictionary frame; + + if (clan != null) + { + frame = ClanFrame("clan.member.added", clan); + } + else + { + frame = Frame("clan.member.added", "event"); + frame["clanId"] = clanId; + } + + PutPlayer(frame, "steamId", "name", steamId); + EmitFrame(frame); + + ScheduleClansBoard(); + } + + private void OnClanMemberLeft(LocalClan clan, ulong steamId) + { + MarkHook("OnClanMemberLeft"); + + if (clan == null) + return; + + Dictionary frame = ClanFrame("clan.member.left", clan); + PutPlayer(frame, "steamId", "name", steamId); + EmitFrame(frame); + + ScheduleClansBoard(); + } + + private void OnClanMemberKicked(LocalClan clan, ulong steamId, ulong bySteamId) + { + MarkHook("OnClanMemberKicked"); + + if (clan == null) + return; + + Dictionary frame = ClanFrame("clan.member.kicked", clan); + PutPlayer(frame, "steamId", "name", steamId); + PutPlayer(frame, "bySteamId", "byName", bySteamId); + EmitFrame(frame); + + ScheduleClansBoard(); + } + + /// + /// Not an event: a colour is a property of the clan, so it travels on the board. The + /// hook only brings the next board forward. + /// + private void OnClanColorChanged(LocalClan clan, UnityEngine.Color32 color, ulong steamId) + { + MarkHook("OnClanColorChanged"); + ScheduleClansBoard(); + } + // ---- tallies ---- /// -- 2.49.1 From a3899f336f32f6b5fa8320802a483282119b8b41 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 23 Sep 2026 05:51:59 -0500 Subject: [PATCH 05/18] =?UTF-8?q?feat(bridge):=20protocol=207=20=E2=80=94?= =?UTF-8?q?=20a=20raid=20frame=20that=20names=20who=20lives=20there?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit entity.destroyed now covers doors, external walls and gates, and the tool cupboard itself as well as building blocks. A door was never a BuildingBlock, so a door-only raid reached nobody. The frame gains `structure`, the cupboard's `buildingId`, and `authorized`: each Steam id on the cupboard with whether they are online now, bounded at 64. Both fields are absent when there is no cupboard. The structures tally still counts building blocks only, so the leaderboard column keeps its meaning. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- overlay.toml | 7 +- overlay/oxide/plugins/RunicGateway.cs | 114 ++++++++++++++++++++++++-- 2 files changed, 111 insertions(+), 10 deletions(-) diff --git a/overlay.toml b/overlay.toml index b62e4ca..cebf872 100644 --- a/overlay.toml +++ b/overlay.toml @@ -26,9 +26,10 @@ # the same change that alters the emitters, exactly as the sidecar bumps # PROTOCOL_VERSION and the module bumps its own constant. # -# Current: 6 — the transport, the read path, identity, the permission mirror, -# configuration from the site, and first-party clans (docs/rust-link/PROTOCOL.md §12). -protocol = 6 +# Current: 7 — 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 # ── Oxide compatibility ────────────────────────────────────────────────────── # diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index 1de3153..c21244b 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 = 6; + private const int ProtocolVersion = 7; /// /// Loopback reconnects are cheap, so the ceiling is low. A sidecar restart should cost @@ -1426,26 +1426,126 @@ namespace Oxide.Plugins return; } - BuildingBlock block = entity as BuildingBlock; + // 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); - if (block == null || block.OwnerID == 0UL) + // `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; - TallyFor(attacker).Structures++; + // The tally's meaning is unchanged since protocol 2 — building blocks only — so a + // leaderboard column does not quietly change what it counts in the middle of a wipe. + if (structure == "block") + TallyFor(attacker).Structures++; Dictionary frame = Frame("entity.destroyed", "event"); - frame["ownerId"] = block.OwnerID.ToString(); - frame["prefab"] = block.ShortPrefabName; + frame["ownerId"] = entity.OwnerID.ToString(); + frame["prefab"] = entity.ShortPrefabName; + frame["structure"] = structure; frame["attackerId"] = attacker.UserIDString; frame["attackerName"] = attacker.displayName; - string grid = GridOf(block); + string grid = GridOf(entity); if (grid != null) frame["grid"] = grid; + AddAuthorization(frame, entity); + EmitFrame(frame); } + /// + /// The most people one entity.destroyed frame names as authorised. A cupboard + /// has no hard cap on vanilla, and an unbounded list is an unbounded line on the game + /// link; 64 is several full teams, and a frame cut short says so. + /// + private const int MaxAuthorizedOnFrame = 64; + + /// + /// Which of the four things a base is made of this entity is, or null for anything else. + /// Ordered by how often each dies, which is also cheapest-first. + /// + private static string StructureKind(BaseEntity entity) + { + if (entity is BuildingBlock) + return "block"; + + // External gates are Doors too, which is right: a gate is a door in a wall. + if (entity is Door) + return "door"; + + if (entity is SimpleBuildingBlock) + return "wall"; + + if (entity is BuildingPrivlidge) + return "cupboard"; + + return null; + } + + /// + /// Who lives here, by the game's own definition: everyone authorised on the building's + /// tool cupboard. Both fields are ABSENT when there is no cupboard, which is a different + /// answer from an empty list — "nobody is authorised" and "there is nothing to be + /// authorised on" — and the module alerts nobody in either case (PLAN.md D67). + /// + /// + /// authorizedPlayers is the set, and nothing else is. The cupboard also holds + /// recentGroupMembers, which LOOKS like residents and is not: it counts code-lock + /// users towards group upkeep and prunes itself on a timer. + /// + /// + /// + /// The lookup resolves through the entity's building, which is cheap. An external wall + /// has no building and falls back to a sphere query on this thread — acceptable because + /// it runs only after every cheap filter above has passed, at the rate walls fall rather + /// than the rate this hook fires. + /// + /// + private static void AddAuthorization(Dictionary frame, BaseEntity entity) + { + // A cupboard being destroyed still holds its list: OnEntityDeath runs before the kill. + BuildingPrivlidge cupboard = entity as BuildingPrivlidge ?? entity.GetBuildingPrivilege(); + + if (cupboard == null || cupboard.net == null || cupboard.authorizedPlayers == null) + return; + + HashSet online = new HashSet(); + + if (BasePlayer.activePlayerList != null) + { + foreach (BasePlayer player in BasePlayer.activePlayerList) + { + if (player != null) + online.Add(player.userID); + } + } + + List authorized = new List(); + + foreach (ulong steamId in cupboard.authorizedPlayers) + { + if (authorized.Count >= MaxAuthorizedOnFrame) + { + frame["authorizedTruncated"] = true; + break; + } + + authorized.Add(new Dictionary + { + { "steamId", steamId.ToString() }, + { "online", online.Contains(steamId) }, + }); + } + + frame["buildingId"] = cupboard.net.ID.Value.ToString(); + frame["authorized"] = authorized; + } + private void OnPlayerReported( BasePlayer reporter, string targetName, string targetId, string subject, string message, string type) -- 2.49.1 From 272e9e0f651e288a80d8f1b5d442b89013d52d4c Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 23 Sep 2026 05:55:52 -0500 Subject: [PATCH 06/18] feat(bridge): clan.disbanded carries the roster it ended The website tells a disbanded clan's members, but the clan board that follows the event (and, after an outage, precedes the backlog) has already removed that roster from its store. The game walks Members to drop each membership and never empties the list, so it is still whole when OnClanDisbanded fires. Part of protocol 7. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- overlay/oxide/plugins/RunicGateway.cs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index c21244b..6c7560c 100644 --- a/overlay/oxide/plugins/RunicGateway.cs +++ b/overlay/oxide/plugins/RunicGateway.cs @@ -1975,6 +1975,23 @@ namespace Oxide.Plugins Dictionary frame = ClanFrame("clan.disbanded", clan); PutPlayer(frame, "steamId", "name", steamId); + + // Protocol 7: who was in it. The website tells a disbanded clan's members, and by the + // time it reads this frame the next board may already have removed the roster it would + // otherwise ask — the board is re-sent seconds after this, and after an outage it is + // applied before the backlog. The game has deleted the clan from its database by now, + // and walked `Members` to drop each one's membership, but it never empties the list + // itself — so it is still whole here, bounded by the clan's own member limit. + if (clan.Members != null) + { + List members = new List(); + + foreach (ClanMember member in clan.Members) + members.Add(member.SteamId.ToString()); + + frame["members"] = members; + } + EmitFrame(frame); ScheduleClansBoard(); -- 2.49.1 From 77e9863e9b0519c3a450b1df8b420051818e6004 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 23 Sep 2026 13:37:05 -0500 Subject: [PATCH 07/18] fix(bridge): the raid frame's attackerId comes from userID UserIDString is filled in only when a player connects, loads as a sleeper, or is an engine bot. A player another plugin spawned has none, and the phase-10 walk's raid arrived with attackerId null. The website skips the raid alert when the attacker is authorised on the cupboard, so a null would quietly defeat that exclusion. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- overlay/oxide/plugins/RunicGateway.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index 6c7560c..4083667 100644 --- a/overlay/oxide/plugins/RunicGateway.cs +++ b/overlay/oxide/plugins/RunicGateway.cs @@ -1446,7 +1446,12 @@ namespace Oxide.Plugins frame["ownerId"] = entity.OwnerID.ToString(); frame["prefab"] = entity.ShortPrefabName; frame["structure"] = structure; - frame["attackerId"] = attacker.UserIDString; + + // 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); -- 2.49.1 From 594fc091418ca2e3b98bce54c939481a8da923f2 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 23 Sep 2026 21:14:00 -0500 Subject: [PATCH 08/18] =?UTF-8?q?feat(bridge):=20protocol=208=20=E2=80=94?= =?UTF-8?q?=20the=20leases=20(PLAN.md=20=C2=A727)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lease.list / lease.apply / lease.release and a lease.expired event. The plugin holds the allowlist (decay.scale, 18 populations, the two minimum spawn scalars, and group permissions), the seven-day ceiling (refused, never clamped) and the deadline, which it enforces on its own. Release is compare-and-set. Holds persist in the data directory with their boot: a restart ends a convar hold, while a group-permission hold survives it and is re-armed. EventsEnabled (default off) gates apply only, and a write from the site's config editor now takes effect at once. The permission mirror defers to a leased pair: it is reported as leased, never foreign. Grants pass a null owner: with an owner, Oxide silently refuses any permission another plugin registered. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- overlay.toml | 4 +- overlay/oxide/plugins/RunicGateway.cs | 1217 ++++++++++++++++++++++++- 2 files changed, 1217 insertions(+), 4 deletions(-) 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) { -- 2.49.1 From c106ba826bf57b11ddcee5f4d3e933816562fc53 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Thu, 24 Sep 2026 00:37:45 -0500 Subject: [PATCH 09/18] fix(bridge): grant with a null owner and read every grant back (D85) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EnsureGrant and EnsureGroupPermission passed `this` as the owner. Given an owner, Oxide first asks PermissionExists(perm, owner) — whether THAT plugin registered the name — and returns silently when it did not, so every site-authored grant of another plugin's permission was a no-op on Oxide that the report counted as applied (PLAN.md §27.6). Both now pass null and read the store back. A grant the store does not hold afterwards is reported in the new `notLanded` list instead of counted. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- overlay/oxide/plugins/RunicGateway.cs | 40 +++++++++++++++++++++------ 1 file changed, 31 insertions(+), 9 deletions(-) diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index 7350e89..0fa0ad1 100644 --- a/overlay/oxide/plugins/RunicGateway.cs +++ b/overlay/oxide/plugins/RunicGateway.cs @@ -2775,6 +2775,13 @@ namespace Oxide.Plugins public readonly List Foreign = new List(); public readonly List Pending = new List(); + /// + /// Grants the store did not hold after being told to (subject:permission). + /// Reported rather than counted, so the site can never again be told a grant + /// landed that did not (D85). + /// + public readonly List NotLanded = new List(); + /// /// Group permissions an event lease holds (protocol 8). Neither applied to nor /// reported foreign; what the site asked for is kept on the hold instead. @@ -3138,12 +3145,22 @@ namespace Oxide.Plugins if (permission.GroupHasPermission(op.Subject, op.Object)) { job.AlreadyCorrect++; + break; } - else - { - permission.GrantGroupPermission(op.Subject, op.Object, this); + + // **`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; @@ -3173,12 +3190,16 @@ namespace Oxide.Plugins if (HoldsDirectly(op.Subject, op.Object)) { job.AlreadyCorrect++; + break; } - else - { - permission.GrantUserPermission(op.Subject, op.Object, this); + + // 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; @@ -3478,6 +3499,7 @@ namespace Oxide.Plugins report["foreign"] = job.Foreign; report["leased"] = job.Leased; report["pending"] = job.Pending; + report["notLanded"] = job.NotLanded; report["operations"] = job.Ops.Count; if (!string.IsNullOrEmpty(job.SetId)) @@ -3490,7 +3512,7 @@ namespace Oxide.Plugins $"ops={job.Ops.Count} grants={job.Grants} revokes={job.Revokes} " + $"groups={job.GroupsCreated} members={job.Members} same={job.AlreadyCorrect} " + $"unresolved={job.Unresolved.Count} foreign={job.Foreign.Count} " + - $"pending={job.Pending.Count} at={DateTime.UtcNow:HH:mm:ss}Z"; + $"pending={job.Pending.Count} notLanded={job.NotLanded.Count} at={DateTime.UtcNow:HH:mm:ss}Z"; EmitFrame(report); } -- 2.49.1 From bbaa7f346e06d33e32359836a61bba7ef0b77b8a Mon Sep 17 00:00:00 2001 From: wtclaude Date: Thu, 24 Sep 2026 01:22:06 -0500 Subject: [PATCH 10/18] =?UTF-8?q?feat(bridge):=20protocol=209=20=E2=80=94?= =?UTF-8?q?=20the=20world=20verbs=20events=20place=20and=20give=20back=20(?= =?UTF-8?q?phase=2013a)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit world.monuments, world.zone, world.place, world.revert and world.owned (PLAN.md §28, D86-D96): - Zones through ZoneManager's temporary zones, owned by the bridge, with a required duration the plugin enforces itself (D96) and re-created from the bridge's own registry on every load and every ZoneManager reload (D92), because ZoneManager erases a plugin's zones when that plugin unloads. - Crates and NPCs from a plugin-held allowlist (D88) at a monument by kind and instance, or at raw coordinates (D87, D93), bounded 25/20 per step (D95). - A registry file keyed by the website's idempotency key, since this bridge has no at-most-once store: a repeat answers with the first call's ids. - world.owned LOOKS by net id: crates survive a restart, NPCs do not, so a boot id proves nothing. A wipe drops the registry whole. - EventsEnabled gates every world write; revert, owned and monuments never (D94). Revert refuses only a thing that exists and is not this run's. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- overlay.toml | 9 +- overlay/oxide/plugins/RunicGateway.cs | 1216 ++++++++++++++++++++++++- 2 files changed, 1217 insertions(+), 8 deletions(-) diff --git a/overlay.toml b/overlay.toml index 1b3315f..76fff64 100644 --- a/overlay.toml +++ b/overlay.toml @@ -26,10 +26,11 @@ # the same change that alters the emitters, exactly as the sidecar bumps # PROTOCOL_VERSION and the module bumps its own constant. # -# 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 = 8 +# Current: 9 — the transport, the read path, identity, the permission mirror, +# configuration from the site, first-party clans, a raid frame that names who +# lives there, the event leases, and the world verbs events place and give back +# (docs/rust-link/PROTOCOL.md §15). +protocol = 9 # ── Oxide compatibility ────────────────────────────────────────────────────── # diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index 0fa0ad1..85c9a41 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 = 8; + private const int ProtocolVersion = 9; /// /// Loopback reconnects are cheap, so the ceiling is low. A sidecar restart should cost @@ -317,6 +317,10 @@ namespace Oxide.Plugins // them: a plugin reload is not the end of an event (§27.3). StopLeaseTimer(); + // Zones are not erased here by us — ZoneManager erases them itself as this plugin + // unloads — and they are NOT forgotten: the next load re-creates them (D92). + StopWorldTimer(); + _running = false; _stopping.Set(); Wake.Set(); @@ -372,6 +376,16 @@ namespace Oxide.Plugins /// turning it off never strands a value somebody already borrowed. /// public bool EventsEnabled = false; + + /// + /// The most crates one event step may place (protocol 9, D95). An operator may + /// lower it; is the ceiling it cannot be raised + /// past. A step over the bound is refused, never trimmed to fit. + /// + public int EventsMaxCrates = WorldHardMaxCrates; + + /// The most NPCs one event step may place (D95). Lowerable, like the above. + public int EventsMaxNpcs = WorldHardMaxNpcs; } protected override void LoadDefaultConfig() @@ -400,7 +414,13 @@ namespace Oxide.Plugins // 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) + // The two bounds are clamped to their ceilings rather than refused: a config asking + // for MORE than the plugin will ever place is an operator's typo, and the ceiling is + // what the website mirrors (D95). + _config.EventsMaxCrates = Math.Max(0, Math.Min(_config.EventsMaxCrates, WorldHardMaxCrates)); + _config.EventsMaxNpcs = Math.Max(0, Math.Min(_config.EventsMaxNpcs, WorldHardMaxNpcs)); + + if (Config["EventsEnabled"] == null || Config["EventsMaxCrates"] == null || Config["EventsMaxNpcs"] == null) Config.WriteObject(_config, true); } @@ -413,6 +433,7 @@ namespace Oxide.Plugins // lease command can arrive before the world has finished loading, and it must find // the holds already in place rather than have them loaded over it afterwards. LoadLeases(); + LoadWorld(); } // ---- the outbound queue ---- @@ -850,6 +871,28 @@ namespace Oxide.Plugins HandleLeaseRelease(cmd, reqId); break; + case "world.monuments": + // Protocol 9 (PLAN.md §28). The monuments this wipe's map has, for the + // authoring form — and the same vocabulary a step's location is written in. + HandleWorldMonuments(reqId); + break; + + case "world.zone": + HandleWorldZone(cmd, reqId); + break; + + case "world.place": + HandleWorldPlace(cmd, reqId); + break; + + case "world.revert": + HandleWorldRevert(cmd, reqId); + break; + + case "world.owned": + HandleWorldOwned(cmd, reqId); + break; + default: Puts($"unknown command '{verb}'"); break; @@ -1254,6 +1297,12 @@ namespace Oxide.Plugins // 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). + DropWorldFromOtherWipes(); + RecreateZones("load"); + // Started here rather than in Init: this hook fires both at the end of server startup // and immediately on a hotload, so it is the one place that runs exactly once per // load with the world available either way. @@ -1294,11 +1343,14 @@ namespace Oxide.Plugins EmitFrame(frame); - // Nothing from the old world is still true. The tallies are the only such state this - // plugin holds; everything else lives on the far side of the link. + // Nothing from the old world is still true. The tallies are one such piece of state + // this plugin holds. _tallies.Clear(); _connectedAt.Clear(); + // Nor is anything an event placed: the old map, and everything on it, is gone. + DropWorldFromOtherWipes(); + Puts($"new save; wipe is now {(_wipeId ?? "unknown")}"); } @@ -3590,6 +3642,11 @@ namespace Oxide.Plugins { MarkHook("OnPluginLoaded"); + // ZoneManager keeps temporary zones in memory, so a reload of it loses every zone an + // event opened. They are put back from this plugin's own record (D92). + if (plugin != null && plugin != this && plugin.Name == "ZoneManager") + NextTick(() => RecreateZones("ZoneManager reloaded")); + ConfigWriteJob job = _configJob; if (job == null || plugin == null) @@ -5797,6 +5854,1157 @@ namespace Oxide.Plugins arg.ReplyWith(string.Join(Environment.NewLine, lines.ToArray())); } + // ---- the world verbs (protocol 9, PLAN.md §28) ---- + // + // Two things an event makes that did not exist — a zone, and a crate or NPC placed in the + // world — and one registry that remembers which run made what. Four rules shape all of it, + // and each came from the rig rather than from a document (§28.1): + // + // * **A restart is not proof a placed thing is gone.** Crates are saved by the game and + // come back with the same net id; NPCs are not saved and do not. So nothing here is + // inferred from a boot id. `world.owned` LOOKS, by net id, every time. + // * **A wipe is proof everything is gone.** The registry is dropped whole on a new save + // and at load when it names another wipe. + // * **ZoneManager forgets.** It holds temporary zones in memory and erases every zone a + // plugin owns when that plugin unloads — so an `oxide.reload` of this bridge deletes + // every event zone mid-run. The registry is the record, and zones are put back from it + // on every load and every ZoneManager reload (D92). + // * **There is no at-most-once store on this bridge.** The registry is keyed by the + // website's idempotency key instead: a repeat answers with the ids the first call made + // and places nothing, which is the whole of what the key is for. + // + // What may be erased is decided by the registry and never by ZoneManager, whose + // `EraseTemporaryZone` deletes an operator's own hand-made zone and returns true (§12.4). + + /// The ceiling on crates per step. EventsMaxCrates may lower it, never raise it. + private const int WorldHardMaxCrates = 25; + + /// The ceiling on NPCs per step (D95). + private const int WorldHardMaxNpcs = 20; + + /// How far a group may be scattered around its point, in metres. + private const float WorldMaxSpread = 50f; + + /// How far from a monument's centre a step may put its point, in metres. + private const float WorldMaxOffset = 150f; + + private const float ZoneMinRadius = 5f; + private const float ZoneMaxRadius = 150f; + + /// + /// The longest a zone may stand: seven days, the lease ceiling (D96). A zone is priced + /// by its minutes, so it has a deadline, and the deadline is kept HERE — a zone never + /// outlives its event even when the website that opened it has gone. + /// + private const long ZoneMaxHoldMs = 7L * 24L * 60L * 60L * 1000L; + + private const string WorldKindZone = "zone"; + private const string WorldKindCrate = "crate"; + private const string WorldKindNpc = "npc"; + + /// One thing this plugin will place, by the key the website names it with. + private sealed class WorldPrefab + { + public readonly string Key; + public readonly string Kind; + public readonly string Path; + public readonly string Label; + + public WorldPrefab(string key, string kind, string path, string label) + { + Key = key; + Kind = kind; + Path = path; + Label = label; + } + } + + /// + /// The allowlist (D88), and the only one: the sidecar forwards and the website offers + /// what this answers. Crates and NPCs, never vehicles — a player can take a vehicle, and + /// a revert would kill something a person may be sitting in. Every path here was + /// spawned on the rig and reported its type before it was listed (§28.1). + /// + private static readonly WorldPrefab[] WorldPrefabs = + { + new WorldPrefab("crate.basic", WorldKindCrate, "assets/bundled/prefabs/radtown/crate_basic.prefab", "Basic crate"), + new WorldPrefab("crate.normal", WorldKindCrate, "assets/bundled/prefabs/radtown/crate_normal.prefab", "Military crate"), + new WorldPrefab("crate.normal2", WorldKindCrate, "assets/bundled/prefabs/radtown/crate_normal_2.prefab", "Crate"), + new WorldPrefab("crate.elite", WorldKindCrate, "assets/bundled/prefabs/radtown/crate_elite.prefab", "Elite crate"), + new WorldPrefab("crate.tools", WorldKindCrate, "assets/bundled/prefabs/radtown/crate_tools.prefab", "Tool box"), + new WorldPrefab("crate.hackable", WorldKindCrate, "assets/prefabs/deployable/chinooklockedcrate/codelockedhackablecrate.prefab", "Locked crate (hackable)"), + new WorldPrefab("supply.drop", WorldKindCrate, "assets/prefabs/misc/supply drop/supply_drop.prefab", "Supply drop"), + new WorldPrefab("barrel.loot", WorldKindCrate, "assets/bundled/prefabs/radtown/loot_barrel_1.prefab", "Loot barrel"), + new WorldPrefab("npc.scientist", WorldKindNpc, "assets/rust.ai/agents/npcplayer/humannpc/scientist/scientistnpc_roam.prefab", "Scientist"), + new WorldPrefab("npc.scientist.heavy", WorldKindNpc, "assets/rust.ai/agents/npcplayer/humannpc/scientist/scientistnpc_heavy.prefab", "Heavy scientist"), + new WorldPrefab("npc.scientist.tethered", WorldKindNpc, "assets/rust.ai/agents/npcplayer/humannpc/scientist/scientistnpc_roamtethered.prefab", "Scientist (stays put)"), + new WorldPrefab("npc.bandit.guard", WorldKindNpc, "assets/rust.ai/agents/npcplayer/humannpc/banditguard/npc_bandit_guard.prefab", "Bandit guard") + }; + + /// One thing an event made. Persisted as it is, so every field is public and plain. + private sealed class WorldEntry + { + /// The net id for an entity, as text; the zone id for a zone. + public string Id; + + public string Kind; + + /// The allowlist key, for a crate or an NPC. + public string Prefab; + + public string RunId; + + /// The website's idempotency key for the step that made it. + public string Key; + + public float X, Y, Z; + + /// A zone's radius and name, for re-creating it. + public float Radius; + public string Name; + + /// When a zone is erased without being asked, on this host's clock (D96). Zero for an entity. + public long DeadlineMs; + + public string BootId; + public string WipeId; + public long TakenMs; + } + + /// Everything events have made on this map, by id. Main thread only. + private Dictionary _world = new Dictionary(); + + /// A counter for zone ids, so two zones one run opens in one tick never collide. + private int _zoneSerial; + + 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 map's monuments in the one order every caller agrees on: grouped by prefab short + /// name, then by position. #n in a value counts within its group, so it names the + /// same monument every time it is asked on this map (D93). + /// + private static List>> MonumentGroups() + { + var byKind = new SortedDictionary>(StringComparer.Ordinal); + List all = TerrainMeta.Path != null ? TerrainMeta.Path.Monuments : null; + + if (all != null) + { + foreach (MonumentInfo m in all) + { + if (m == null || string.IsNullOrEmpty(m.name)) + continue; + + string kind = Path.GetFileNameWithoutExtension(m.name).ToLowerInvariant(); + List list; + + if (!byKind.TryGetValue(kind, out list)) + { + list = new List(); + byKind[kind] = list; + } + + list.Add(m); + } + } + + var groups = new List>>(); + + foreach (KeyValuePair> kv in byKind) + { + kv.Value.Sort((a, b) => + { + int cx = a.transform.position.x.CompareTo(b.transform.position.x); + return cx != 0 ? cx : a.transform.position.z.CompareTo(b.transform.position.z); + }); + + groups.Add(kv); + } + + return groups; + } + + /// + /// Main thread. A monument value — kind or kind#n — to the monument it + /// names on this map, or null with a reason. A bare kind that repeats is its first + /// instance, so a step written against a map where it was unique still means something + /// on one where it is not. + /// + private static MonumentInfo ResolveMonument(string value, out string refusal) + { + refusal = null; + string text = Lower(value); + + if (string.IsNullOrEmpty(text)) + { + refusal = "no monument was named"; + return null; + } + + int instance = 1; + string kind = text; + int hash = text.LastIndexOf('#'); + + if (hash > 0) + { + kind = text.Substring(0, hash); + + if (!int.TryParse(text.Substring(hash + 1), out instance) || instance < 1) + { + refusal = $"'{value}' is not a monument, followed by an instance number"; + return null; + } + } + + foreach (KeyValuePair> kv in MonumentGroups()) + { + if (kv.Key != kind) + continue; + + if (instance > kv.Value.Count) + { + refusal = $"this map has {kv.Value.Count} {kind}, so there is no #{instance}"; + return null; + } + + return kv.Value[instance - 1]; + } + + refusal = $"this map has no {kind}"; + return null; + } + + private static double? DoubleField(Dictionary row, string key) + { + object value; + + if (row == null || !row.TryGetValue(key, out value) || value == null) + return null; + + double parsed; + return double.TryParse( + value.ToString(), System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out parsed) && !double.IsNaN(parsed) && !double.IsInfinity(parsed) + ? parsed + : (double?)null; + } + + /// + /// Main thread. Where a step puts its thing: a monument (plus an offset), or raw world + /// coordinates — exactly one of the two (D87). Returns false with a reason and the + /// refusal code that goes with it. + /// + private bool ResolveWorldPoint(Dictionary cmd, out UnityEngine.Vector3 point, out string reason, out string refusal) + { + point = UnityEngine.Vector3.zero; + reason = null; + refusal = null; + + string monument = StringField(cmd, "monument"); + double? x = DoubleField(cmd, "x"); + double? z = DoubleField(cmd, "z"); + double? y = DoubleField(cmd, "y"); + bool byMonument = !string.IsNullOrEmpty(monument); + bool byCoords = x.HasValue || z.HasValue; + + if (byMonument == byCoords) + { + reason = "malformed"; + refusal = "a location is a monument or x and z, and exactly one of them"; + return false; + } + + if (byMonument) + { + MonumentInfo m = ResolveMonument(monument, out refusal); + + if (m == null) + { + reason = "no-monument"; + return false; + } + + double dx = DoubleField(cmd, "offsetX") ?? 0.0; + double dz = DoubleField(cmd, "offsetZ") ?? 0.0; + + if (Math.Sqrt(dx * dx + dz * dz) > WorldMaxOffset) + { + reason = "out-of-range"; + refusal = $"an offset from a monument is at most {WorldMaxOffset} m"; + return false; + } + + point = m.transform.position + new UnityEngine.Vector3((float)dx, 0f, (float)dz); + point.y = TerrainMeta.HeightMap.GetHeight(point); + return true; + } + + if (!x.HasValue || !z.HasValue) + { + reason = "malformed"; + refusal = "coordinates need both x and z"; + return false; + } + + float half = ConVar.Server.worldsize / 2f; + + if (Math.Abs(x.Value) > half || Math.Abs(z.Value) > half) + { + reason = "off-map"; + refusal = $"({x.Value:0}, {z.Value:0}) is off this map, which runs from -{half:0} to {half:0}"; + return false; + } + + point = new UnityEngine.Vector3((float)x.Value, 0f, (float)z.Value); + point.y = y.HasValue ? (float)y.Value : TerrainMeta.HeightMap.GetHeight(point); + return true; + } + + private bool WorldWritesAllowed(string reqId) + { + if (_config != null && _config.EventsEnabled) + return true; + + // One consent for everything an event does to this world unattended (D94). Giving + // back, listing and asking what is still out there are never behind it. + EmitWorldError(reqId, "events-disabled", + "events are switched off on this server — set EventsEnabled in the RunicGateway plugin config"); + return false; + } + + /// + /// Main thread. The ids a key already made, or null. A repeat of a key the registry has + /// seen is a retry whose first answer was lost, and it is answered with the first + /// call's ids rather than a second set of things (§28.1). + /// + private List EntriesForKey(string key) + { + if (string.IsNullOrEmpty(key)) + return null; + + List found = null; + + foreach (WorldEntry e in _world.Values) + { + if (e.Key != key) + continue; + + if (found == null) + found = new List(); + + found.Add(e); + } + + return found; + } + + private void HandleWorldMonuments(string reqId) + { + var rows = new List(); + + foreach (KeyValuePair> kv in MonumentGroups()) + { + for (int i = 0; i < kv.Value.Count; i++) + { + MonumentInfo m = kv.Value[i]; + UnityEngine.Vector3 p = m.transform.position; + string phrase = m.displayPhrase != null ? m.displayPhrase.english : null; + + var row = new Dictionary + { + { "value", kv.Value.Count > 1 ? kv.Key + "#" + (i + 1) : kv.Key }, + { "kind", kv.Key }, + { "instance", i + 1 }, + { "of", kv.Value.Count }, + { "label", string.IsNullOrEmpty(phrase) ? kv.Key : phrase }, + { "x", Math.Round(p.x, 1) }, + { "z", Math.Round(p.z, 1) }, + { "grid", Grid(p) } + }; + + rows.Add(row); + } + } + + Dictionary reply = Frame("world.monuments", "reply"); + reply["worldSize"] = ConVar.Server.worldsize; + reply["monuments"] = rows; + reply["prefabs"] = WorldPrefabRows(); + reply["eventsEnabled"] = _config != null && _config.EventsEnabled; + reply["maxCrates"] = _config != null ? _config.EventsMaxCrates : WorldHardMaxCrates; + reply["maxNpcs"] = _config != null ? _config.EventsMaxNpcs : WorldHardMaxNpcs; + reply["zoneManager"] = ZoneManagerPlugin() != null; + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + } + + private static List WorldPrefabRows() + { + var rows = new List(); + + foreach (WorldPrefab p in WorldPrefabs) + { + rows.Add(new Dictionary + { + { "key", p.Key }, + { "kind", p.Kind }, + { "label", p.Label } + }); + } + + return rows; + } + + /// Main thread. Open a zone an event owns. + private void HandleWorldZone(Dictionary cmd, string reqId) + { + if (!WorldWritesAllowed(reqId)) + return; + + string key = StringField(cmd, "key"); + string runId = StringField(cmd, "runId"); + List repeat = EntriesForKey(key); + + if (repeat != null) + { + EmitWorldPlaced(reqId, repeat, true); + return; + } + + if (string.IsNullOrEmpty(runId)) + { + EmitWorldError(reqId, "malformed", "a zone is opened for a run"); + return; + } + + Plugin zm = ZoneManagerPlugin(); + + if (zm == null) + { + EmitWorldError(reqId, "zonemanager-missing", "ZoneManager is not loaded on this server"); + return; + } + + double radius = DoubleField(cmd, "radius") ?? 0.0; + + if (radius < ZoneMinRadius || radius > ZoneMaxRadius) + { + EmitWorldError(reqId, "out-of-range", + $"a zone's radius is {ZoneMinRadius} to {ZoneMaxRadius} m, not {radius}"); + return; + } + + UnityEngine.Vector3 point; + string reason, refusal; + + if (!ResolveWorldPoint(cmd, out point, out reason, out refusal)) + { + EmitWorldError(reqId, reason, refusal); + return; + } + + long holdMs = LongField(cmd, "holdMs"); + + if (holdMs <= 0 || holdMs > ZoneMaxHoldMs) + { + EmitWorldError(reqId, "out-of-range", + $"a zone stands for 1 minute to {ZoneMaxHoldMs / 60000L} minutes, not {holdMs / 60000L}"); + return; + } + + string name = StringField(cmd, "name"); + + if (string.IsNullOrEmpty(name)) + name = "Event zone"; + + if (name.Length > 64) + name = name.Substring(0, 64); + + var entry = new WorldEntry + { + Id = $"rg-{ShortRun(runId)}-{NowMs() % 100000000L}-{++_zoneSerial}", + Kind = WorldKindZone, + RunId = runId, + Key = key, + X = point.x, + Y = point.y, + Z = point.z, + Radius = (float)radius, + Name = name, + DeadlineMs = NowMs() + holdMs, + BootId = _bootId, + WipeId = _wipeId, + TakenMs = NowMs() + }; + + if (!CreateZone(zm, entry)) + { + EmitWorldError(reqId, "refused", "ZoneManager did not create the zone"); + return; + } + + _world[entry.Id] = entry; + SaveWorld(); + EnsureWorldTimer(); + + _lastWorldSummary = $"zone {entry.Id} r={radius:0} for {holdMs / 60000L} min, run {runId}"; + Puts($"world: {_lastWorldSummary}"); + EmitWorldPlaced(reqId, new List { entry }, false); + } + + private static string ShortRun(string runId) + { + var chars = new StringBuilder(); + + foreach (char c in runId ?? "") + { + if (char.IsLetterOrDigit(c)) + chars.Append(c); + + if (chars.Length >= 12) + break; + } + + return chars.Length > 0 ? chars.ToString() : "run"; + } + + /// + /// Main thread. Creates, or re-creates, one zone from its record, and says whether + /// ZoneManager now has it. Our plugin is the owner, so ZoneManager itself refuses to + /// let another plugin's zone be overwritten under the same id. + /// + private bool CreateZone(Plugin zm, WorldEntry entry) + { + string[] args = + { + "name", entry.Name ?? "Event zone", + "radius", entry.Radius.ToString(System.Globalization.CultureInfo.InvariantCulture) + }; + + object created = zm.Call("CreateOrUpdateTemporaryZone", this, entry.Id, args, new UnityEngine.Vector3(entry.X, entry.Y, entry.Z)); + + return created is bool && (bool)created && zm.Call("CheckZoneID", entry.Id) != null; + } + + /// + /// Main thread. Puts back every zone the registry holds that ZoneManager does not (D92). + /// Called on this plugin's load, on ZoneManager's, and from world.owned. + /// + private void RecreateZones(string why) + { + Plugin zm = ZoneManagerPlugin(); + + if (zm == null) + return; + + int restored = 0, failed = 0; + + foreach (WorldEntry e in _world.Values) + { + // A zone past its deadline is the timer's to end, not this loop's to revive. + if (e.Kind != WorldKindZone || ZoneExpired(e) || zm.Call("CheckZoneID", e.Id) != null) + continue; + + if (CreateZone(zm, e)) + restored++; + else + failed++; + } + + if (restored > 0 || failed > 0) + { + _lastWorldSummary = $"zones after {why}: {restored} re-created, {failed} failed"; + Puts($"world: {_lastWorldSummary}"); + } + } + + /// Main thread. Place crates or NPCs an event owns, one registry entry per net id. + private void HandleWorldPlace(Dictionary cmd, string reqId) + { + if (!WorldWritesAllowed(reqId)) + return; + + string key = StringField(cmd, "key"); + string runId = StringField(cmd, "runId"); + List repeat = EntriesForKey(key); + + if (repeat != null) + { + EmitWorldPlaced(reqId, repeat, true); + return; + } + + if (string.IsNullOrEmpty(runId)) + { + EmitWorldError(reqId, "malformed", "a placement is made for a run"); + return; + } + + 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(); + + for (int i = 0; i < count; i++) + { + UnityEngine.Vector3 at = center; + + if (count > 1) + { + UnityEngine.Vector2 jitter = UnityEngine.Random.insideUnitCircle * (float)spread; + at += new UnityEngine.Vector3(jitter.x, 0f, jitter.y); + + if (!explicitY) + at.y = TerrainMeta.HeightMap.GetHeight(at); + } + + BaseEntity entity = GameManager.server.CreateEntity(prefab.Path, at); + + if (entity == null) + { + // All or nothing: a group half of which exists is not what the step asked for, + // and the half that does exist has no record yet to be given back through. + foreach (WorldEntry e in made) + KillEntity(e.Id); + + EmitWorldError(reqId, "refused", $"the game could not create {prefab.Label}"); + return; + } + + entity.Spawn(); + + made.Add(new WorldEntry + { + Id = entity.net.ID.Value.ToString(System.Globalization.CultureInfo.InvariantCulture), + Kind = prefab.Kind, + Prefab = prefab.Key, + RunId = runId, + Key = key, + X = at.x, + Y = at.y, + Z = at.z, + BootId = _bootId, + WipeId = _wipeId, + TakenMs = NowMs() + }); + } + + foreach (WorldEntry e in made) + _world[e.Id] = e; + + SaveWorld(); + + _lastWorldSummary = $"placed {count} {prefab.Key} for run {runId}"; + Puts($"world: {_lastWorldSummary}"); + EmitWorldPlaced(reqId, made, false); + } + + private static BaseNetworkable FindEntity(string id) + { + ulong value; + + if (!ulong.TryParse(id, out value)) + return null; + + BaseNetworkable found = BaseNetworkable.serverEntities.Find(new NetworkableId(value)); + return found != null && !found.IsDestroyed ? found : null; + } + + /// Main thread. Whether the entity was there to kill; either way it is not now. + private static bool KillEntity(string id) + { + BaseNetworkable found = FindEntity(id); + + if (found == null) + return false; + + found.Kill(); + return true; + } + + /// + /// Main thread. Give back what a run owns. Never behind EventsEnabled: switching + /// events off must not strand anything already placed (D94). + /// + /// + /// Named ids first; else everything under the key (core lost the answer to a dispatch + /// and knows only its key); else everything the run owns. An id already gone is a + /// success. An id the registry does not hold for this run is refused — this + /// bridge erases only what it made, whatever ZoneManager would allow (§12.4). + /// + /// + private void HandleWorldRevert(Dictionary cmd, string reqId) + { + string runId = StringField(cmd, "runId"); + string key = StringField(cmd, "key"); + var ids = new List(Strings(cmd, "ids")); + + if (string.IsNullOrEmpty(runId)) + { + EmitWorldError(reqId, "malformed", "a revert names the run whose things to give back"); + return; + } + + var targets = new List(); + var refused = new List(); + var gone = new List(); + Plugin zm = ZoneManagerPlugin(); + + if (ids.Count > 0) + { + foreach (string id in ids) + { + WorldEntry e; + + if (_world.TryGetValue(id, out e)) + { + if (e.RunId == runId) + targets.Add(e); + else + refused.Add(id); + + continue; + } + + // Not on record. That is either something a wipe or a prune already took — + // gone, which is a success — or something this bridge never made. Only a + // thing that is actually THERE can be the second, and only that is refused. + bool exists = id.StartsWith("rg-", StringComparison.Ordinal) + ? zm != null && zm.Call("CheckZoneID", id) != null + : FindEntity(id) != null; + + (exists ? refused : gone).Add(id); + } + } + else + { + foreach (WorldEntry e in _world.Values) + { + if (e.RunId == runId && (string.IsNullOrEmpty(key) || e.Key == key)) + targets.Add(e); + } + } + + var removed = new List(); + + foreach (WorldEntry e in targets) + { + bool wasThere; + + if (e.Kind == WorldKindZone) + { + // A zone ZoneManager does not have (unloaded, or already erased) is gone — + // it lives in memory only — and its record is all there is left to remove. + wasThere = zm != null && zm.Call("CheckZoneID", e.Id) != null; + + if (wasThere) + zm.Call("EraseTemporaryZone", this, e.Id); + } + else + { + wasThere = KillEntity(e.Id); + } + + _world.Remove(e.Id); + (wasThere ? removed : gone).Add(e.Id); + } + + if (targets.Count > 0) + SaveWorld(); + + _lastWorldSummary = $"revert run {runId}: {removed.Count} removed, {gone.Count} already gone, {refused.Count} refused"; + Puts($"world: {_lastWorldSummary}"); + + Dictionary reply = Frame("world.ok", "reply"); + reply["removed"] = removed; + reply["gone"] = gone; + reply["refused"] = refused; + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + } + + /// + /// Main thread. What the world still holds of what events made — looked at, never + /// inferred (§28.1). An entity that is gone is pruned as the walk passes it. A zone is + /// put back if ZoneManager has lost it; while ZoneManager is not loaded a zone is + /// reported held, because "I cannot ask" is not "it is gone", and its load will put + /// the zone back. + /// + private void HandleWorldOwned(Dictionary cmd, string reqId) + { + string runId = StringField(cmd, "runId"); + RecreateZones("owned"); + + Plugin zm = ZoneManagerPlugin(); + var rows = new List(); + List pruned = null; + + foreach (WorldEntry e in _world.Values) + { + if (!string.IsNullOrEmpty(runId) && e.RunId != runId) + continue; + + bool held; + + if (e.Kind == WorldKindZone) + held = zm == null || zm.Call("CheckZoneID", e.Id) != null; + else + held = FindEntity(e.Id) != null; + + if (!held) + { + if (pruned == null) + pruned = new List(); + + pruned.Add(e.Id); + continue; + } + + rows.Add(WorldRow(e)); + } + + if (pruned != null) + { + foreach (string id in pruned) + _world.Remove(id); + + SaveWorld(); + } + + Dictionary reply = Frame("world.owned", "reply"); + reply["owned"] = rows; + reply["pruned"] = pruned != null ? pruned.Count : 0; + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + } + + private static Dictionary WorldRow(WorldEntry e) + { + var row = new Dictionary + { + { "id", e.Id }, + { "kind", e.Kind }, + { "runId", e.RunId }, + { "x", Math.Round(e.X, 1) }, + { "y", Math.Round(e.Y, 1) }, + { "z", Math.Round(e.Z, 1) } + }; + + if (!string.IsNullOrEmpty(e.Prefab)) + row["prefab"] = e.Prefab; + + if (e.Kind == WorldKindZone) + { + row["radius"] = e.Radius; + row["name"] = e.Name; + row["remainingMs"] = Math.Max(0L, e.DeadlineMs - NowMs()); + } + + return row; + } + + private void EmitWorldPlaced(string reqId, List entries, bool repeat) + { + var placed = new List(); + + foreach (WorldEntry e in entries) + placed.Add(WorldRow(e)); + + Dictionary reply = Frame("world.ok", "reply"); + reply["placed"] = placed; + reply["repeat"] = repeat; + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + } + + private void EmitWorldError(string reqId, string reason, string message) + { + Dictionary frame = Frame("world.error", "reply"); + frame["reason"] = reason; + + if (!string.IsNullOrEmpty(message)) + frame["message"] = message; + + if (reqId != null) + frame["reqId"] = reqId; + + _lastWorldSummary = $"refused ({reason}): {message}"; + EmitFrame(frame); + } + + private string WorldPath() + { + return Path.Combine(Interface.Oxide.DataDirectory, Name, "world.json"); + } + + /// Main thread. Written on every change, like the lease holds and for the same reason. + private void SaveWorld() + { + try + { + string path = WorldPath(); + Directory.CreateDirectory(Path.GetDirectoryName(path)); + + var rows = new List(_world.Values); + string tmp = path + ".tmp"; + File.WriteAllText(tmp, JsonConvert.SerializeObject(rows, Formatting.Indented), Utf8NoBom); + + if (File.Exists(path)) + File.Delete(path); + + File.Move(tmp, path); + } + catch (Exception ex) + { + Puts($"could not save the world registry: {Flatten(ex.Message)}"); + } + } + + /// + /// Main thread, once per load. Reads the registry and nothing more: whether each thing is + /// still out there is asked when somebody wants to know, because at a real server start + /// this runs before the save has loaded and every entity would look gone. + /// + private void LoadWorld() + { + _world = new Dictionary(); + List rows = null; + + try + { + string path = WorldPath(); + + if (File.Exists(path)) + rows = JsonConvert.DeserializeObject>(File.ReadAllText(path)); + } + catch (Exception ex) + { + Puts($"could not read the world registry: {Flatten(ex.Message)}"); + } + + if (rows == null) + return; + + foreach (WorldEntry e in rows) + { + if (e != null && !string.IsNullOrEmpty(e.Id)) + _world[e.Id] = e; + } + + Puts($"world: {_world.Count} things events made are on record"); + + // Due now or later; the first tick ends any zone whose deadline passed while the + // server was down, before anything could re-create it. + EnsureWorldTimer(); + } + + private static bool ZoneExpired(WorldEntry e) + { + return e.Kind == WorldKindZone && e.DeadlineMs > 0 && e.DeadlineMs <= NowMs(); + } + + private void EnsureWorldTimer() + { + if (_worldTimer != null) + return; + + foreach (WorldEntry e in _world.Values) + { + if (e.Kind == WorldKindZone) + { + _worldTimer = timer.Every(LeaseCheckSeconds, CheckZoneDeadlines); + return; + } + } + } + + private void StopWorldTimer() + { + if (_worldTimer != null) + { + _worldTimer.Destroy(); + _worldTimer = null; + } + } + + /// + /// Main thread, every second while a zone stands. The fail-safe D96 bought: a zone whose + /// time is up is erased without anybody asking, and says so — the same promise a lease + /// makes, for the same reason. + /// + private void CheckZoneDeadlines() + { + List due = null; + bool anyZone = false; + + foreach (WorldEntry e in _world.Values) + { + if (e.Kind != WorldKindZone) + continue; + + anyZone = true; + + if (!ZoneExpired(e)) + continue; + + if (due == null) + due = new List(); + + due.Add(e); + } + + if (!anyZone) + { + StopWorldTimer(); + return; + } + + if (due == null) + return; + + Plugin zm = ZoneManagerPlugin(); + + foreach (WorldEntry e in due) + { + if (zm != null && zm.Call("CheckZoneID", e.Id) != null) + zm.Call("EraseTemporaryZone", this, e.Id); + + _world.Remove(e.Id); + + Dictionary frame = Frame("world.expired", "event"); + frame["id"] = e.Id; + frame["kind"] = e.Kind; + + if (!string.IsNullOrEmpty(e.RunId)) + frame["runId"] = e.RunId; + + EmitFrame(frame); + } + + SaveWorld(); + _lastWorldSummary = $"{due.Count} zone(s) reached their deadline and were erased"; + Puts($"world: {_lastWorldSummary}"); + } + + /// + /// Main thread. Drops everything recorded against another wipe — that map, and all of + /// it, is gone. Only callable once the wipe is known, which a real server start does not + /// know until OnServerInitialized. + /// + private void DropWorldFromOtherWipes() + { + if (string.IsNullOrEmpty(_wipeId) || _world.Count == 0) + return; + + var stale = new List(); + + foreach (WorldEntry e in _world.Values) + { + if (!string.IsNullOrEmpty(e.WipeId) && e.WipeId != _wipeId) + stale.Add(e.Id); + } + + if (stale.Count == 0) + return; + + foreach (string id in stale) + _world.Remove(id); + + SaveWorld(); + _lastWorldSummary = $"a new wipe dropped {stale.Count} things from the old map"; + Puts($"world: {_lastWorldSummary}"); + } + + /// What events have made on this map, from the server console. + [ConsoleCommand("rg.world")] + private void CmdWorld(ConsoleSystem.Arg arg) + { + int zones = 0, crates = 0, npcs = 0; + + foreach (WorldEntry e in _world.Values) + { + if (e.Kind == WorldKindZone) + zones++; + else if (e.Kind == WorldKindNpc) + npcs++; + else + crates++; + } + + arg.ReplyWith( + $"protocol={ProtocolVersion} eventsEnabled={(_config != null && _config.EventsEnabled)} " + + $"zones={zones} crates={crates} npcs={npcs} zoneManager={(ZoneManagerPlugin() != null)} " + + $"wipeId={(_wipeId ?? "none")}" + Environment.NewLine + + $"last: {_lastWorldSummary}"); + } + // ---- reading a command's fields ---- // // `JsonConvert` hands back `object`, and every field below arrives as one. These are the -- 2.49.1 From 0a7ad570f7fb4f92369cf0daa745b800eb14490c Mon Sep 17 00:00:00 2001 From: wtclaude Date: Thu, 24 Sep 2026 02:16:03 -0500 Subject: [PATCH 11/18] fix(bridge): refuse world commands until the world has loaded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The link starts in Init, before the save loads. The phase 13a walk restarted the rig mid-run: the website saw the new boot id at once, asked world.owned into an empty world, and the plugin — finding no entity behind any net id — pruned three live crates from its own registry. They stayed in the world, owned by nothing, and a revert would have refused them. world.owned, world.revert, world.monuments and every world write now answer `not-ready` until OnServerInitialized, zones are not re-created before it, and server.hello carries `worldReady` so the website can hold its reconcile until the world it would be asking about exists. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- overlay/oxide/plugins/RunicGateway.cs | 56 +++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index 85c9a41..70b44af 100644 --- a/overlay/oxide/plugins/RunicGateway.cs +++ b/overlay/oxide/plugins/RunicGateway.cs @@ -981,6 +981,10 @@ namespace Oxide.Plugins hello["bootId"] = _bootId; hello["plugin"] = Version.ToString(); + // The website compares boot ids to decide when to reconcile the events ledger, and + // must not do it until the world it would be asking about exists (§28.6). + hello["worldReady"] = _worldReady; + EmitFrame(hello); } @@ -1300,6 +1304,7 @@ namespace Oxide.Plugins // Only now is the wipe known, so only now can what an earlier map owned be dropped; // and this hook also fires on a hotload, which is exactly when ZoneManager has just // erased every zone the previous load of this plugin owned (D92). + _worldReady = true; DropWorldFromOtherWipes(); RecreateZones("load"); @@ -1309,6 +1314,11 @@ namespace Oxide.Plugins if (_cadence == null) _cadence = timer.Every(BoardCadenceSeconds, Cadence); + // Say the world is ready now rather than at the next tick: the website holds its + // reconcile until it hears this. + if (_connected) + SendHello(); + Dictionary frame = Frame("server.initialized", "event"); frame["initial"] = initial; EmitFrame(frame); @@ -5977,6 +5987,22 @@ namespace Oxide.Plugins /// A counter for zone ids, so two zones one run opens in one tick never collide. private int _zoneSerial; + /// + /// Whether the world is loaded for THIS load of the plugin — set at + /// OnServerInitialized, which fires at the end of a real start and at once on a + /// hotload. Until then every world command is refused not-ready. + /// + /// + /// Found by the phase 13a walk, and it is the worst kind of wrong: the link starts in + /// Init, BEFORE the save loads, so a website that saw the new boot id asked + /// world.owned into an empty world — and the plugin, finding no entity behind + /// any net id, pruned three live crates from its own registry. They stayed in the + /// world owned by nothing, and a revert would have refused them. An empty world at + /// boot is not an answer; it is the question arriving too early. + /// + /// + private bool _worldReady; + private string _lastWorldSummary = "none yet"; private Timer _worldTimer; @@ -6179,8 +6205,22 @@ namespace Oxide.Plugins return true; } + /// Main thread. Refuses a world command until the world is loaded (see _worldReady). + private bool WorldReady(string reqId) + { + if (_worldReady) + return true; + + EmitWorldError(reqId, "not-ready", + "the server is still loading its world; ask again once it has started"); + return false; + } + private bool WorldWritesAllowed(string reqId) { + if (!WorldReady(reqId)) + return false; + if (_config != null && _config.EventsEnabled) return true; @@ -6219,6 +6259,9 @@ namespace Oxide.Plugins private void HandleWorldMonuments(string reqId) { + if (!WorldReady(reqId)) + return; + var rows = new List(); foreach (KeyValuePair> kv in MonumentGroups()) @@ -6414,6 +6457,11 @@ namespace Oxide.Plugins /// 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) @@ -6593,6 +6641,11 @@ namespace Oxide.Plugins /// private void HandleWorldRevert(Dictionary cmd, string reqId) { + // Before the world loads, every entity looks gone, and a revert would forget things + // that are about to exist again. + if (!WorldReady(reqId)) + return; + string runId = StringField(cmd, "runId"); string key = StringField(cmd, "key"); var ids = new List(Strings(cmd, "ids")); @@ -6693,6 +6746,9 @@ namespace Oxide.Plugins /// private void HandleWorldOwned(Dictionary cmd, string reqId) { + if (!WorldReady(reqId)) + return; + string runId = StringField(cmd, "runId"); RecreateZones("owned"); -- 2.49.1 From 3d9998ce64015f498a316d98cc9f4772f28a3124 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Thu, 24 Sep 2026 07:00:44 -0500 Subject: [PATCH 12/18] =?UTF-8?q?feat(bridge):=20protocol=2010=20=E2=80=94?= =?UTF-8?q?=20the=20tally,=20kit=20credits=20and=20chat=20(phase=2013b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tally.open / .snapshot / .close: a run's participation tally, persisted in tallies.json. Presence accrues every 5 s from ZoneManager's own membership (or the whole server); kills are the last hit, gated by killsOf and the killer being inside the area (D98); the score is seconds, kills, or minutes + killWeight x kills (D99). Only a zone this run opened counts (D100). Gated by EventsEnabled (D107); forgotten seven days after opening (D108). - kits.list: the kit source, from Kits' GetKitNames/GetKitInfo. - perm.sync `credits`: one extra use of a kit per reward (D103), held in credits.json and spent LAST from OnKitRedeemed, because Kits' SetPlayerKitUses does nothing for a player with no usage record. Withdrawn when unredeemed; reset when Kits wipes its use counts. - chat.say: one line to the server, remembered by key for 10 minutes. - EventsMaxRecipients / EventsMaxTallies / EventsMaxChat bounds. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- overlay.toml | 8 +- overlay/oxide/plugins/RunicGateway.cs | 1232 ++++++++++++++++++++++++- 2 files changed, 1233 insertions(+), 7 deletions(-) diff --git a/overlay.toml b/overlay.toml index 76fff64..7b6c852 100644 --- a/overlay.toml +++ b/overlay.toml @@ -26,11 +26,11 @@ # the same change that alters the emitters, exactly as the sidecar bumps # PROTOCOL_VERSION and the module bumps its own constant. # -# Current: 9 — the transport, the read path, identity, the permission mirror, +# Current: 10 — the transport, the read path, identity, the permission mirror, # configuration from the site, first-party clans, a raid frame that names who -# lives there, the event leases, and the world verbs events place and give back -# (docs/rust-link/PROTOCOL.md §15). -protocol = 9 +# lives there, the event leases, the world verbs events place and give back, and +# the rewards: a run's participation tally, kit credits and chat (PROTOCOL.md §16). +protocol = 10 # ── Oxide compatibility ────────────────────────────────────────────────────── # diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index 70b44af..da708ad 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 = 9; + private const int ProtocolVersion = 10; /// /// Loopback reconnects are cheap, so the ceiling is low. A sidecar restart should cost @@ -238,7 +238,8 @@ namespace Oxide.Plugins "OnGroupPermissionGranted", "OnGroupPermissionRevoked", "OnPluginLoaded", "OnPluginUnloaded", "OnClanCreated", "OnClanDisbanded", "OnClanMemberAdded", - "OnClanMemberLeft", "OnClanMemberKicked", "OnClanColorChanged" + "OnClanMemberLeft", "OnClanMemberKicked", "OnClanColorChanged", + "OnKitRedeemed" }; private void Init() @@ -321,6 +322,14 @@ namespace Oxide.Plugins // 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(); @@ -386,6 +395,18 @@ namespace Oxide.Plugins /// The most NPCs one event step may place (D95). Lowerable, like the above. public int EventsMaxNpcs = WorldHardMaxNpcs; + + /// + /// The most people one reward step may name (protocol 10, §29.5). The website reads + /// it and refuses a step over it; it is never trimmed to fit. + /// + public int EventsMaxRecipients = RecipientsHardMax; + + /// The most tallies that may count at once on this server (§29.5). + public int EventsMaxTallies = TallyHardMax; + + /// The longest line an event or a news post may say in chat, in characters. + public int EventsMaxChat = ChatHardMax; } protected override void LoadDefaultConfig() @@ -419,8 +440,12 @@ namespace Oxide.Plugins // 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)); - if (Config["EventsEnabled"] == null || Config["EventsMaxCrates"] == null || Config["EventsMaxNpcs"] == null) + if (Config["EventsEnabled"] == null || Config["EventsMaxCrates"] == null || Config["EventsMaxNpcs"] == null || + Config["EventsMaxRecipients"] == null || Config["EventsMaxTallies"] == null || Config["EventsMaxChat"] == null) Config.WriteObject(_config, true); } @@ -434,6 +459,7 @@ namespace Oxide.Plugins // the holds already in place rather than have them loaded over it afterwards. LoadLeases(); LoadWorld(); + LoadRewards(); } // ---- the outbound queue ---- @@ -893,6 +919,27 @@ namespace Oxide.Plugins 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; + default: Puts($"unknown command '{verb}'"); break; @@ -1328,6 +1375,9 @@ namespace Oxide.Plugins { MarkHook("OnServerShutdown"); FlushTallies(); + + if (_talliesDirty) + SaveTallies(); EmitFrame(Frame("server.shutdown", "event")); } @@ -1361,6 +1411,11 @@ namespace Oxide.Plugins // 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")}"); } @@ -1435,6 +1490,12 @@ namespace Oxide.Plugins 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); + Dictionary frame = Frame("player.death", "event"); frame["steamId"] = player.UserIDString; frame["name"] = player.displayName; @@ -1517,14 +1578,20 @@ namespace Oxide.Plugins // A real player's death is OnPlayerDeath's, not ours — emitting here too would // double every kill in the feed. Only the NPC kind is ours to count. if (victimPlayer.IsNpc) + { TallyFor(attacker).NpcKills++; + 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". if (entity is BaseNpc) { TallyFor(attacker).NpcKills++; + CreditKill(attacker, false); return; } @@ -2865,6 +2932,17 @@ namespace Oxide.Plugins public readonly Dictionary> DesiredGroupPermissions = new Dictionary>(); + + /// + /// The site's kit credits for this server (protocol 10, D103), or null when the sync + /// carried none — an older website, whose silence must not withdraw anybody's credit. + /// + public List Credits; + + public int CreditsApplied; + public int CreditsWithdrawn; + public bool CreditsWaiting; + public readonly List CreditsUnresolved = new List(); } private PermSyncJob _permJob; @@ -3106,6 +3184,27 @@ namespace Oxide.Plugins foreach (string group in job.DesiredMembers.Keys) job.Ops.Add(new PermOp { Kind = PermOpKind.ScanGroup, Subject = group }); + // ── The kit credits (protocol 10) ────────────────────────────────── + // + // Not store operations, so not drained with them: they are settled once, after the + // grants have landed, and counted in the same report. + if (cmd.ContainsKey("credits")) + { + job.Credits = new List(); + + foreach (Dictionary row in Rows(cmd, "credits")) + { + string steamId = StringField(row, "steamId"); + string kit = StringField(row, "kit"); + int count = IntField(row, "count"); + + if (string.IsNullOrEmpty(steamId) || string.IsNullOrEmpty(kit) || count <= 0) + continue; + + job.Credits.Add(new KitCredit { SteamId = steamId, Kit = kit, Earned = count }); + } + } + if (job.Ops.Count > PermMaxOps) { EmitPermError(reqId, "too-large"); @@ -3167,6 +3266,21 @@ namespace Oxide.Plugins _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); } @@ -3564,6 +3678,16 @@ namespace Oxide.Plugins 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; + } + if (!string.IsNullOrEmpty(job.SetId)) report["setId"] = job.SetId; @@ -7061,6 +7185,1108 @@ namespace Oxide.Plugins $"last: {_lastWorldSummary}"); } + // ---- the rewards (protocol 10, PLAN.md §29) ---- + // + // Three things, and the website counts none of them: + // + // * **The tally** — who took part in a run. It is counted here because only the game + // sees a player stand in a zone or land the last hit (D81), and it is persisted, + // because a restart in the middle of an event must not forget who was there. + // * **The credits** — each reward is one more use of a kit (D103). The site authors how + // many a player has earned and pushes them with `perm.sync`; this plugin spends them + // LAST, from `OnKitRedeemed`, because Kits cannot be told in advance: its + // `SetPlayerKitUses` does nothing for a player with no usage record of the kit. + // * **The chat line** — `chat.say`, for a run's `rust.announce` and for the news leg. + // + // The zone a tally counts in is asked of ZoneManager on every tick (`GetPlayersInZone`, + // `IsPlayerInZone`) rather than mirrored from `OnEnterZone`/`OnExitZone`. ZoneManager + // already holds each zone's members, and a mirror would have to be reseeded every time a + // zone is re-created (D92) — the tick asks at most `EventsMaxTallies` questions every five + // seconds, and no chatty hook is subscribed at all (R17). + + /// The most tallies that may count at once. EventsMaxTallies may lower it. + private const int TallyHardMax = 8; + + /// + /// The most people one reward step may name. The plugin publishes it and the website + /// refuses a step over it (§29.5): a reward is priced before its tally is read, so this + /// is the price of every mode that is not a count. + /// + private const int RecipientsHardMax = 100; + + /// The longest chat line, in characters. EventsMaxChat may lower it. + private const int ChatHardMax = 256; + + private const float TallyTickSeconds = 5f; + + /// + /// The longest a tally counts, and the longest it is kept at all: seven days from + /// opening, the lease and zone ceiling (D108). A tally that stopped counting earlier is + /// kept for the steps that read it after — collect and the reward — until then, and a + /// teardown closes it sooner. One ceiling for everything, so a website that went away + /// never leaves a tally on disk for ever. + /// + private const long TallyMaxHoldMs = 7L * 24L * 60L * 60L * 1000L; + + /// Core files at most 5 000 participants per step; a tally holds no more. + private const int TallyMaxPeople = 5000; + + /// + /// The most one tick may credit. A tick that arrives late accrues the time that really + /// passed, so a longer interval never rewrites history — but a clock that jumps is not + /// time anybody spent standing in a zone. + /// + private const long TallyMaxStepMs = 60000L; + + /// Every twelfth tick (a minute) writes the file, when anything changed. + private const int TallySaveEveryTicks = 12; + + private const long ChatKeyMemoryMs = 10L * 60L * 1000L; + + private const string ScoreSeconds = "seconds"; + private const string ScoreKills = "kills"; + private const string ScoreBoth = "both"; + + /// One person in one tally. Persisted as it is. + private sealed class Participant + { + public string SteamId; + public string Name; + public double Seconds; + public int Kills; + public long JoinedMs; + } + + /// One run's tally on this server. There is at most one per run (§29.5). + private sealed class RunTally + { + public string RunId; + + /// The idempotency key of the step that opened it; a repeat is answered, not refused. + public string Key; + + /// The zone step's name, as the step gave it, or null for the whole server. + public string Zone; + + /// What that name resolved to when the tally opened (D100). + public string ZoneId; + + public string Score; + public string KillsOf; + public double KillWeight; + + public long OpenedMs; + + /// When counting stops. What was counted is kept until seven days after opening (D108). + public long DeadlineMs; + + /// People who took part after the tally was full, counted so a result can say so. + public int Overflow; + + public Dictionary People = new Dictionary(); + + /// Not persisted: a load starts accruing from the load, never from before a restart. + [JsonIgnore] + public long LastAccrueMs; + } + + /// + /// One player's credits for one kit (D103). Earned is the site's; the other two + /// are this plugin's own record of what it did with them. + /// + private sealed class KitCredit + { + public string SteamId; + public string Kit; + + /// How many the site says this player has earned. Authored there, pushed here. + public int Earned; + + /// How many have been moved into Kits' counter — each one a use count lowered by one. + public int Applied; + + /// How many of those moved uses have since been redeemed. + public int Consumed; + } + + private Dictionary _runTallies = new Dictionary(); + private Dictionary _credits = new Dictionary(); + + /// Recently said chat keys, and when. Main thread only. + private readonly Dictionary _chatKeys = new Dictionary(); + + private Timer _tallyTimer; + private int _tallyTicks; + private bool _talliesDirty; + private string _lastRewardSummary = "none yet"; + + private int MaxTallies() + { + return _config != null ? _config.EventsMaxTallies : TallyHardMax; + } + + private int MaxRecipients() + { + return _config != null ? _config.EventsMaxRecipients : RecipientsHardMax; + } + + private int MaxChat() + { + return _config != null ? _config.EventsMaxChat : ChatHardMax; + } + + private static bool Counting(RunTally t, long now) + { + return now < t.DeadlineMs; + } + + /// Whether a player is somebody a tally counts: a real, connected, living, awake person. + private static bool CountsAsPresent(BasePlayer p) + { + return p != null && !p.IsNpc && p.IsConnected && !p.IsDead() && !p.IsSleeping(); + } + + private static Participant PersonIn(RunTally t, BasePlayer player, long now) + { + string id = ((ulong)player.userID).ToString(System.Globalization.CultureInfo.InvariantCulture); + Participant person; + + if (t.People.TryGetValue(id, out person)) + { + person.Name = player.displayName; + return person; + } + + if (t.People.Count >= TallyMaxPeople) + { + t.Overflow++; + return null; + } + + person = new Participant { SteamId = id, Name = player.displayName, JoinedMs = now }; + t.People[id] = person; + return person; + } + + /// The D99 score: seconds, kills, or minutes present plus the weight times kills. + private static double ScoreOf(RunTally t, Participant p) + { + switch (t.Score) + { + case ScoreSeconds: + return Math.Floor(p.Seconds); + case ScoreKills: + return p.Kills; + default: + return Math.Round(p.Seconds / 60.0 + t.KillWeight * p.Kills, 2); + } + } + + /// Main thread. Start counting who takes part in a run. + private void HandleTallyOpen(Dictionary cmd, string reqId) + { + string runId = StringField(cmd, "runId"); + string key = StringField(cmd, "key"); + + if (string.IsNullOrEmpty(runId)) + { + EmitRewardError("tally.error", reqId, "malformed", "a tally is opened for a run"); + return; + } + + RunTally existing; + + if (_runTallies.TryGetValue(runId, out existing)) + { + // A retry whose first answer was lost repeats the key; anything else is a second + // tally for one run, which would split its people across two answers (§29.5). + if (!string.IsNullOrEmpty(key) && existing.Key == key) + EmitTallyOk(reqId, existing, true); + else + EmitRewardError("tally.error", reqId, "already-open", + "this run already has a tally on this server, and a run holds one"); + + return; + } + + // The same consent as the world verbs (D94): a tally is an event doing something on + // this server unattended. Reading and closing one are never behind it. + if (_config == null || !_config.EventsEnabled) + { + EmitRewardError("tally.error", reqId, "events-disabled", + "events are switched off on this server — set EventsEnabled in the RunicGateway plugin config"); + return; + } + + string score = Lower(StringField(cmd, "score")); + + if (score != ScoreSeconds && score != ScoreKills && score != ScoreBoth) + { + EmitRewardError("tally.error", reqId, "malformed", "a tally scores seconds, kills or both"); + return; + } + + string killsOf = Lower(StringField(cmd, "killsOf")); + + if (score == ScoreSeconds) + { + killsOf = null; + } + else if (killsOf != "players" && killsOf != "npcs" && killsOf != "both") + { + EmitRewardError("tally.error", reqId, "malformed", "a tally that counts kills says whose: players, npcs or both"); + return; + } + + double weight = score == ScoreBoth ? (DoubleField(cmd, "killWeight") ?? 5.0) : 0.0; + + if (weight < 0 || weight > 1000) + { + EmitRewardError("tally.error", reqId, "out-of-range", $"a kill is worth 0 to 1000 minutes, not {weight}"); + return; + } + + long holdMs = LongField(cmd, "holdMs"); + + if (holdMs == 0) + holdMs = TallyMaxHoldMs; + + if (holdMs < 60000L || holdMs > TallyMaxHoldMs) + { + EmitRewardError("tally.error", reqId, "out-of-range", + $"a tally counts for 1 minute to {TallyMaxHoldMs / 60000L} minutes, not {holdMs / 60000L}"); + return; + } + + long now = NowMs(); + int live = 0; + + foreach (RunTally t in _runTallies.Values) + { + if (Counting(t, now)) + live++; + } + + if (live >= MaxTallies()) + { + EmitRewardError("tally.error", reqId, "too-many", + $"this server counts at most {MaxTallies()} tallies at once, and {live} are counting"); + return; + } + + string zone = StringField(cmd, "zone"); + string zoneId = null; + + if (!string.IsNullOrEmpty(zone)) + { + // Only a zone THIS run opened (D100) — never an operator's arena, never another + // run's. Matched on the name its step gave it, the way the step's author wrote it. + var matches = new List(); + + foreach (WorldEntry e in _world.Values) + { + if (e.Kind == WorldKindZone && e.RunId == runId && + string.Equals((e.Name ?? "").Trim(), zone.Trim(), StringComparison.OrdinalIgnoreCase)) + matches.Add(e); + } + + if (matches.Count == 0) + { + EmitRewardError("tally.error", reqId, "no-zone", + $"this run has opened no zone named '{zone}' on this server — open it in an earlier step"); + return; + } + + if (matches.Count > 1) + { + EmitRewardError("tally.error", reqId, "ambiguous-zone", + $"this run has opened {matches.Count} zones named '{zone}' — give each its own name"); + return; + } + + zoneId = matches[0].Id; + } + + var tally = new RunTally + { + RunId = runId, + Key = key, + Zone = zoneId != null ? zone.Trim() : null, + ZoneId = zoneId, + Score = score, + KillsOf = killsOf, + KillWeight = weight, + OpenedMs = now, + DeadlineMs = now + holdMs, + LastAccrueMs = now + }; + + _runTallies[runId] = tally; + SaveTallies(); + EnsureTallyTimer(); + + _lastRewardSummary = $"tally opened for run {runId} ({score}, {(zoneId ?? "whole server")}) for {holdMs / 60000L} min"; + Puts($"rewards: {_lastRewardSummary}"); + EmitTallyOk(reqId, tally, false); + } + + /// Main thread. Who has taken part in a run so far. Never behind EventsEnabled. + private void HandleTallySnapshot(Dictionary cmd, string reqId) + { + string runId = StringField(cmd, "runId"); + RunTally t; + + if (string.IsNullOrEmpty(runId) || !_runTallies.TryGetValue(runId, out t)) + { + EmitRewardError("tally.error", reqId, "no-tally", "this run has no tally on this server"); + return; + } + + // Brought up to this moment first, so a reward read at the end of an event includes + // its last few seconds. + Accrue(t, NowMs(), null); + + var people = new List(); + + foreach (Participant p in t.People.Values) + { + people.Add(new Dictionary + { + { "steamId", p.SteamId }, + { "name", p.Name }, + { "seconds", (long)Math.Floor(p.Seconds) }, + { "kills", p.Kills }, + { "score", ScoreOf(t, p) }, + { "joinedAt", p.JoinedMs } + }); + } + + Dictionary reply = Frame("tally.snapshot", "reply"); + reply["runId"] = t.RunId; + reply["score"] = t.Score; + reply["killWeight"] = t.KillWeight; + reply["counting"] = Counting(t, NowMs()); + reply["remainingMs"] = Math.Max(0L, t.DeadlineMs - NowMs()); + reply["overflow"] = t.Overflow; + reply["people"] = people; + reply["maxRecipients"] = MaxRecipients(); + + if (t.KillsOf != null) + reply["killsOf"] = t.KillsOf; + + if (t.Zone != null) + { + reply["zone"] = t.Zone; + Plugin zm = ZoneManagerPlugin(); + reply["zoneStanding"] = zm != null && zm.Call("CheckZoneID", t.ZoneId) != null; + } + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + } + + /// Main thread. Stop counting and forget. A tally already gone is a success. + private void HandleTallyClose(Dictionary cmd, string reqId) + { + string runId = StringField(cmd, "runId"); + + if (string.IsNullOrEmpty(runId)) + { + EmitRewardError("tally.error", reqId, "malformed", "a close names the run whose tally to forget"); + return; + } + + bool closed = _runTallies.Remove(runId); + + if (closed) + SaveTallies(); + + _lastRewardSummary = closed ? $"tally for run {runId} closed" : $"tally for run {runId} was already gone"; + + Dictionary reply = Frame("tally.ok", "reply"); + reply["runId"] = runId; + reply["closed"] = closed; + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + } + + private void EmitTallyOk(string reqId, RunTally t, bool repeat) + { + Dictionary reply = Frame("tally.ok", "reply"); + reply["runId"] = t.RunId; + reply["score"] = t.Score; + reply["remainingMs"] = Math.Max(0L, t.DeadlineMs - NowMs()); + reply["repeat"] = repeat; + + if (t.Zone != null) + { + reply["zone"] = t.Zone; + reply["zoneId"] = t.ZoneId; + } + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + } + + private void EmitRewardError(string kind, string reqId, string reason, string message) + { + Dictionary frame = Frame(kind, "reply"); + frame["reason"] = reason; + + if (!string.IsNullOrEmpty(message)) + frame["message"] = message; + + if (reqId != null) + frame["reqId"] = reqId; + + _lastRewardSummary = $"refused ({reason}): {message}"; + EmitFrame(frame); + } + + /// + /// Main thread. Credits presence up to — and no further than the + /// tally's deadline. is looked up once per tick by the caller. + /// + private void Accrue(RunTally t, long now, Plugin zm) + { + long until = Math.Min(now, t.DeadlineMs); + long from = t.LastAccrueMs > 0 ? t.LastAccrueMs : until; + + if (until <= from) + { + t.LastAccrueMs = Math.Max(t.LastAccrueMs, until); + return; + } + + t.LastAccrueMs = until; + double seconds = Math.Min(until - from, TallyMaxStepMs) / 1000.0; + IEnumerable present; + + if (t.ZoneId != null) + { + zm = zm ?? ZoneManagerPlugin(); + + // A zone that is not standing — ZoneManager unloaded, or a wipe took it — gains + // nobody, and keeps what it has. + if (zm == null || zm.Call("CheckZoneID", t.ZoneId) == null) + return; + + present = zm.Call("GetPlayersInZone", t.ZoneId) as List; + } + else + { + present = BasePlayer.activePlayerList; + } + + if (present == null) + return; + + foreach (BasePlayer player in present) + { + if (!CountsAsPresent(player)) + continue; + + Participant person = PersonIn(t, player, now); + + if (person == null) + continue; + + person.Seconds += seconds; + _talliesDirty = true; + } + } + + /// Main thread, every five seconds while any tally is held. + private void TallyTick() + { + long now = NowMs(); + List forget = null; + Plugin zm = ZoneManagerPlugin(); + + foreach (RunTally t in _runTallies.Values) + { + // Forgotten seven days after it opened, whatever its minutes (D108): a website + // that went away must not leave a tally on disk for ever. + if (now >= t.OpenedMs + TallyMaxHoldMs) + { + if (forget == null) + forget = new List(); + + forget.Add(t.RunId); + continue; + } + + if (Counting(t, now) || t.LastAccrueMs < t.DeadlineMs) + Accrue(t, now, zm); + } + + if (forget != null) + { + foreach (string id in forget) + _runTallies.Remove(id); + + _talliesDirty = true; + _lastRewardSummary = $"{forget.Count} tally(s) seven days old were forgotten"; + Puts($"rewards: {_lastRewardSummary}"); + } + + if (_talliesDirty && (++_tallyTicks % TallySaveEveryTicks == 0 || forget != null)) + SaveTallies(); + + if (_runTallies.Count == 0) + StopTallyTimer(); + } + + /// + /// Main thread. The last hit's kill, for every tally counting kills of that kind whose area + /// the killer is standing in when the victim dies (D98). + /// + private void CreditKill(BasePlayer killer, bool victimIsPlayer) + { + if (_runTallies.Count == 0 || killer == null || killer.IsNpc) + return; + + long now = NowMs(); + Plugin zm = null; + bool zmLooked = false; + + 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(); + zmLooked = true; + } + + object inside = zm != null ? zm.Call("IsPlayerInZone", t.ZoneId, killer) : null; + + if (!(inside is bool) || !(bool)inside) + continue; + } + + Participant person = PersonIn(t, killer, now); + + if (person == null) + continue; + + person.Kills++; + _talliesDirty = true; + } + } + + private void EnsureTallyTimer() + { + if (_tallyTimer == null && _runTallies.Count > 0) + _tallyTimer = timer.Every(TallyTickSeconds, TallyTick); + } + + private void StopTallyTimer() + { + if (_tallyTimer != null) + { + _tallyTimer.Destroy(); + _tallyTimer = null; + } + } + + private string TalliesPath() + { + return Path.Combine(Interface.Oxide.DataDirectory, Name, "tallies.json"); + } + + private string CreditsPath() + { + return Path.Combine(Interface.Oxide.DataDirectory, Name, "credits.json"); + } + + /// Main thread. Written at most once a minute while counting, and on every open and close. + private void SaveTallies() + { + WriteDataFile(TalliesPath(), new List(_runTallies.Values), "tallies"); + _talliesDirty = false; + } + + private void SaveCredits() + { + WriteDataFile(CreditsPath(), new List(_credits.Values), "kit credits"); + } + + private void WriteDataFile(string path, object rows, string what) + { + try + { + Directory.CreateDirectory(Path.GetDirectoryName(path)); + + string tmp = path + ".tmp"; + File.WriteAllText(tmp, JsonConvert.SerializeObject(rows, Formatting.Indented), Utf8NoBom); + + if (File.Exists(path)) + File.Delete(path); + + File.Move(tmp, path); + } + catch (Exception ex) + { + Puts($"could not save the {what}: {Flatten(ex.Message)}"); + } + } + + private List ReadDataFile(string path, string what) + { + try + { + if (File.Exists(path)) + return JsonConvert.DeserializeObject>(File.ReadAllText(path)); + } + catch (Exception ex) + { + Puts($"could not read the {what}: {Flatten(ex.Message)}"); + } + + return null; + } + + /// + /// Main thread, once per load. A tally survives a restart, a reload and a wipe: it records + /// people, not the map. Presence accrues from now — the time the server was down is not + /// time anybody spent in a zone. + /// + private void LoadRewards() + { + _runTallies = new Dictionary(); + _credits = new Dictionary(); + long now = NowMs(); + + List tallies = ReadDataFile(TalliesPath(), "tallies"); + + if (tallies != null) + { + foreach (RunTally t in tallies) + { + if (t == null || string.IsNullOrEmpty(t.RunId)) + continue; + + if (t.People == null) + t.People = new Dictionary(); + + t.LastAccrueMs = Math.Min(now, t.DeadlineMs); + _runTallies[t.RunId] = t; + } + } + + List credits = ReadDataFile(CreditsPath(), "kit credits"); + + if (credits != null) + { + foreach (KitCredit c in credits) + { + if (c != null && !string.IsNullOrEmpty(c.SteamId) && !string.IsNullOrEmpty(c.Kit)) + _credits[CreditKey(c.SteamId, c.Kit)] = c; + } + } + + Puts($"rewards: {_runTallies.Count} tallies and {_credits.Count} kit credits on record"); + EnsureTallyTimer(); + } + + // ── Kits ────────────────────────────────────────────────────────────────── + + private Plugin KitsPlugin() + { + return plugins.Find("Kits"); + } + + /// Main thread. The kits a reward can name, and what gates each (the kit source). + private void HandleKitsList(string reqId) + { + Plugin kits = KitsPlugin(); + + if (kits == null) + { + EmitRewardError("kits.error", reqId, "kits-missing", "Kits is not loaded on this server"); + return; + } + + var names = new List(); + kits.Call("GetKitNames", names); + + var rows = new List(); + + foreach (string name in names) + { + if (string.IsNullOrEmpty(name)) + continue; + + var info = kits.Call("GetKitInfo", name) as Newtonsoft.Json.Linq.JObject; + + if (info == null) + continue; + + rows.Add(new Dictionary + { + { "name", name }, + { "permission", (string)info["permission"] ?? "" }, + { "max", info["max"] != null ? (int)info["max"] : 0 }, + { "cooldown", info["cooldown"] != null ? (int)info["cooldown"] : 0 }, + { "hidden", info["hide"] != null && (bool)info["hide"] }, + { "description", Flatten((string)info["description"] ?? "") } + }); + } + + Dictionary reply = Frame("kits.list", "reply"); + reply["kits"] = rows; + reply["maxRecipients"] = MaxRecipients(); + reply["maxTallies"] = MaxTallies(); + reply["maxChat"] = MaxChat(); + reply["eventsEnabled"] = _config != null && _config.EventsEnabled; + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + } + + private static string CreditKey(string steamId, string kit) + { + return steamId + "|" + Lower(kit); + } + + private static int CallInt(Plugin plugin, string method, params object[] args) + { + object value = plugin.Call(method, args); + return value is int ? (int)value : 0; + } + + /// + /// Main thread. Brings one player's credits for one kit into line with what the site says + /// they earned and what Kits says they have used. Three steps, in this order: + /// + /// + /// + /// Notice a redemption. A moved credit lowered the use count to one below the + /// kit's limit; if the count is back at the limit, the player has redeemed it. This is + /// how OnKitRedeemed is read, and it also heals a redemption made while this + /// plugin was unloaded. + /// + /// + /// Withdraw what the site took back (a revert). Only a moved credit not yet + /// redeemed is put back; a redemption already made stands (R16). A use count is + /// never raised past the kit's limit. + /// + /// + /// Spend the next one, last. Only when the player has used the kit up, and one + /// at a time, so the credit is always the use after the kit's own. + /// + /// + /// + private void SettleCredit(Plugin kits, KitCredit c, ref int applied, ref int withdrawn) + { + if (kits == null) + return; + + int max = CallInt(kits, "GetKitMaxUses", c.Kit); + + // A kit with no use limit has no "one more use" to give; its permission is the reward. + if (max <= 0) + return; + + ulong id; + + if (!ulong.TryParse(c.SteamId, out id)) + return; + + int uses = CallInt(kits, "GetPlayerKitUses", id, c.Kit); + + if (c.Applied > c.Consumed && uses >= max) + c.Consumed = c.Applied; + + if (c.Applied > c.Earned) + { + int withdraw = Math.Min(c.Applied - c.Earned, c.Applied - c.Consumed); + + if (withdraw > 0) + { + kits.Call("SetPlayerKitUses", id, c.Kit, Math.Min(uses + withdraw, max)); + uses = CallInt(kits, "GetPlayerKitUses", id, c.Kit); + c.Applied -= withdraw; + withdrawn += withdraw; + } + + // What is left over was redeemed and stands. It is forgiven rather than held + // against the next credit this player earns: the reward it came from is gone. + c.Applied = Math.Min(c.Applied, c.Earned); + c.Consumed = Math.Min(c.Consumed, c.Applied); + } + + if (c.Applied < c.Earned && c.Applied == c.Consumed && uses >= max) + { + // `SetPlayerKitUses` is a no-op for a player with no usage record of this kit, so + // the read-back is what makes the count true — the same rule as the grants (D85). + kits.Call("SetPlayerKitUses", id, c.Kit, max - 1); + + if (CallInt(kits, "GetPlayerKitUses", id, c.Kit) == max - 1) + { + c.Applied++; + applied++; + } + } + } + + /// + /// Main thread, at the end of a perm.sync that carried credits. The field is + /// the site's whole set for this server: a player and kit it no longer names has earned + /// nothing, and their unredeemed credit is withdrawn. + /// + private void ReconcileCredits(PermSyncJob job) + { + var desired = new Dictionary(); + + foreach (KitCredit row in job.Credits) + { + string key = CreditKey(row.SteamId, row.Kit); + KitCredit have; + + if (desired.TryGetValue(key, out have)) + have.Earned += row.Earned; + else + desired[key] = row; + } + + foreach (KitCredit c in _credits.Values) + { + if (!desired.ContainsKey(CreditKey(c.SteamId, c.Kit))) + c.Earned = 0; + } + + foreach (KeyValuePair kv in desired) + { + KitCredit c; + + if (!_credits.TryGetValue(kv.Key, out c)) + { + c = new KitCredit { SteamId = kv.Value.SteamId, Kit = kv.Value.Kit }; + _credits[kv.Key] = c; + } + + c.Earned = kv.Value.Earned; + } + + Plugin kits = KitsPlugin(); + var unknown = new HashSet(); + + foreach (KitCredit c in _credits.Values) + { + if (kits == null) + continue; + + object isKit = kits.Call("IsKit", c.Kit); + + if (!(isKit is bool) || !(bool)isKit) + { + if (c.Earned > 0 && unknown.Add(c.Kit)) + job.CreditsUnresolved.Add(c.Kit); + + continue; + } + + SettleCredit(kits, c, ref job.CreditsApplied, ref job.CreditsWithdrawn); + } + + var empty = new List(); + + foreach (KeyValuePair kv in _credits) + { + if (kv.Value.Earned == 0 && kv.Value.Applied == 0) + empty.Add(kv.Key); + } + + foreach (string key in empty) + _credits.Remove(key); + + job.CreditsWaiting = kits == null; + SaveCredits(); + } + + /// + /// Void, like every hook here: Kits raises this after a redemption has been counted, which + /// is the only moment a player's usage record of the kit is certain to exist. + /// + private void OnKitRedeemed(BasePlayer player, string kitName) + { + MarkHook("OnKitRedeemed"); + + if (player == null || string.IsNullOrEmpty(kitName) || _credits.Count == 0) + return; + + KitCredit c; + + if (!_credits.TryGetValue(CreditKey(player.UserIDString, kitName), out c)) + return; + + int applied = 0, withdrawn = 0; + SettleCredit(KitsPlugin(), c, ref applied, ref withdrawn); + SaveCredits(); + + if (applied > 0) + _lastRewardSummary = $"{player.displayName} redeemed {kitName}; one more use applied from a reward"; + } + + /// + /// Whether Kits clears its usage data at a wipe (its own Wipe player data when the + /// server is wiped, off by default). Read from its config file, because the switch + /// is not in its API — and on Carbon that file is under carbon/configs, which + /// is why the directory comes from the framework and never from a literal (R19). + /// + private bool KitsWipesPlayerData() + { + try + { + string path = Path.Combine(Interface.Oxide.ConfigDirectory, "Kits.json"); + + if (!File.Exists(path)) + return false; + + var config = Newtonsoft.Json.Linq.JObject.Parse(File.ReadAllText(path)); + Newtonsoft.Json.Linq.JToken wipe = config["Wipe player data when the server is wiped"]; + + return wipe != null && wipe.Type == Newtonsoft.Json.Linq.JTokenType.Boolean && (bool)wipe; + } + catch (Exception ex) + { + Puts($"could not read Kits' wipe setting: {Flatten(ex.Message)}"); + return false; + } + } + + /// + /// Main thread, from OnNewSave. When Kits has wiped every use count, a credit this + /// plugin moved into one is gone with it — so the record of moving it goes too, or the + /// next redemption would be read as spending a credit that no longer exists. What the + /// site says was earned stays: a wipe does not take a reward away. + /// + private void ResetCreditsForWipe() + { + if (_credits.Count == 0 || !KitsWipesPlayerData()) + return; + + foreach (KitCredit c in _credits.Values) + { + c.Applied = 0; + c.Consumed = 0; + } + + SaveCredits(); + Puts($"rewards: Kits wipes use counts at a new save, so {_credits.Count} kit credits start over"); + } + + // ── Chat ────────────────────────────────────────────────────────────────── + + /// + /// Main thread. One line to everybody on the server. A key said in the last ten minutes + /// is answered ok and not said again, so a retried step never repeats itself. + /// A run's line is behind EventsEnabled; a news post is behind the site's own + /// switch instead (D104), because news is not an event. + /// + private void HandleChatSay(Dictionary cmd, string reqId) + { + string key = StringField(cmd, "key"); + bool fromEvent = cmd.ContainsKey("event") && string.Equals(StringField(cmd, "event"), "true", StringComparison.OrdinalIgnoreCase); + string message = Flatten(StringField(cmd, "message")); + long now = NowMs(); + + if (fromEvent && (_config == null || !_config.EventsEnabled)) + { + EmitRewardError("chat.error", reqId, "events-disabled", + "events are switched off on this server — set EventsEnabled in the RunicGateway plugin config"); + return; + } + + if (string.IsNullOrEmpty(key) || string.IsNullOrEmpty(message)) + { + EmitRewardError("chat.error", reqId, "malformed", "a chat line has a key and a message"); + return; + } + + if (message.Length > MaxChat()) + { + EmitRewardError("chat.error", reqId, "too-long", + $"this server says at most {MaxChat()} characters in one line, not {message.Length}"); + return; + } + + var stale = new List(); + + foreach (KeyValuePair kv in _chatKeys) + { + if (now - kv.Value > ChatKeyMemoryMs) + stale.Add(kv.Key); + } + + foreach (string s in stale) + _chatKeys.Remove(s); + + bool repeat = _chatKeys.ContainsKey(key); + + if (!repeat) + { + Server.Broadcast(message); + _chatKeys[key] = now; + _lastRewardSummary = $"said a line ({message.Length} chars) to {BasePlayer.activePlayerList.Count} players"; + } + + Dictionary reply = Frame("chat.ok", "reply"); + reply["said"] = !repeat; + reply["repeat"] = repeat; + reply["players"] = BasePlayer.activePlayerList.Count; + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + } + + /// What events are counting and what rewards are held, from the server console. + [ConsoleCommand("rg.rewards")] + private void CmdRewards(ConsoleSystem.Arg arg) + { + var lines = new List + { + $"protocol={ProtocolVersion} tallies={_runTallies.Count} credits={_credits.Count} " + + $"kits={(KitsPlugin() != null)} maxTallies={MaxTallies()} maxRecipients={MaxRecipients()}" + }; + + long now = NowMs(); + + foreach (RunTally t in _runTallies.Values) + { + lines.Add( + $" run {t.RunId}: {t.Score}{(t.KillsOf != null ? "/" + t.KillsOf : "")} " + + $"{(t.Zone != null ? "zone '" + t.Zone + "'" : "whole server")} people={t.People.Count} " + + $"{(Counting(t, now) ? "counting, " + (t.DeadlineMs - now) / 1000L + "s left" : "closed")}"); + } + + foreach (KitCredit c in _credits.Values) + lines.Add($" credit {c.SteamId} {c.Kit}: earned={c.Earned} applied={c.Applied} redeemed={c.Consumed}"); + + lines.Add($"last: {_lastRewardSummary}"); + arg.ReplyWith(string.Join(Environment.NewLine, lines.ToArray())); + } + // ---- reading a command's fields ---- // // `JsonConvert` hands back `object`, and every field below arrives as one. These are the -- 2.49.1 From 210c35f904a34e9e4b5f0051437ce2c1c7f66219 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 25 Sep 2026 00:26:11 -0500 Subject: [PATCH 13/18] =?UTF-8?q?feat(bridge):=20protocol=2011=20=E2=80=94?= =?UTF-8?q?=20the=20map=20(phase=2014)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four verbs (PLAN.md §30.2), all outside EventsEnabled because none of them changes the world: - map.info: the map key, the picture's source (the Rust+ cache, a render kept on disk, or none), its size, SHA-256 and slice count, the game's own grid, and the monuments the in-game map shows. The cache is read by reflection by name only, never enumerated (it holds the Rust+ secret). The hash is computed on the thread pool. - map.fetch: one 512 KiB base64 slice, encoded on the thread pool, refused `stale` when the key or hash moved. - map.render: the admin fallback (D109). Answers `accepted` at once and renders on a later frame, keeping the result in data/RunicGateway/map.jpg beside its key so a restart within the map does not render again. - map.live: every layer at once (world events, event-owned zones/crates/NPCs, players and sleepers, TCs and player vending machines as positions only). The sets are kept by OnEntitySpawned/OnEntityKill and seeded by one sweep at OnServerInitialized. MapMaxSleepers/MapMaxBases bound the two layers. Also fixes Grid() (D119): it used a 146.3 m cell, but the game fits whole cells to the world (20 x 150 m on a 3000 map), so rows could be one off. It now asks MapHelper.PositionToString, the in-game map's own labeller. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- overlay.toml | 5 +- overlay/oxide/plugins/RunicGateway.cs | 1063 +++++++++++++++++++++++-- 2 files changed, 1017 insertions(+), 51 deletions(-) diff --git a/overlay.toml b/overlay.toml index 7b6c852..89d7c24 100644 --- a/overlay.toml +++ b/overlay.toml @@ -29,8 +29,9 @@ # Current: 10 — the transport, the read path, identity, the permission mirror, # configuration from the site, first-party clans, a raid frame that names who # lives there, the event leases, the world verbs events place and give back, and -# the rewards: a run's participation tally, kit credits and chat (PROTOCOL.md §16). -protocol = 10 +# the rewards: a run's participation tally, kit credits and chat (PROTOCOL.md §16), +# and the map: its picture and everything that moves on it (PROTOCOL.md §17). +protocol = 11 # ── Oxide compatibility ────────────────────────────────────────────────────── # diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index da708ad..cc4e961 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 = 10; + private const int ProtocolVersion = 11; /// /// Loopback reconnects are cheap, so the ceiling is low. A sidecar restart should cost @@ -95,12 +95,6 @@ namespace Oxide.Plugins /// private const float BoardCadenceSeconds = 60f; - /// - /// The side of one map grid cell, in world units. Rust's own grid, as every community - /// map and every server console uses it. - /// - private const float GridCellSize = 146.3f; - private static readonly ConcurrentQueue Outbound = new ConcurrentQueue(); private static readonly AutoResetEvent Wake = new AutoResetEvent(false); @@ -239,7 +233,8 @@ namespace Oxide.Plugins "OnPluginLoaded", "OnPluginUnloaded", "OnClanCreated", "OnClanDisbanded", "OnClanMemberAdded", "OnClanMemberLeft", "OnClanMemberKicked", "OnClanColorChanged", - "OnKitRedeemed" + "OnKitRedeemed", + "OnEntitySpawned", "OnEntityKill" }; private void Init() @@ -407,6 +402,15 @@ namespace Oxide.Plugins /// The longest line an event or a news post may say in chat, in characters. public int EventsMaxChat = ChatHardMax; + + /// + /// The most offline sleepers one map answer carries (protocol 11). Over it, the layer + /// answers the first this many and says it was cut short. + /// + public int MapMaxSleepers = MapDefaultMax; + + /// The most tool cupboards and vending machines one map answer carries. + public int MapMaxBases = MapDefaultMax; } protected override void LoadDefaultConfig() @@ -443,9 +447,12 @@ namespace Oxide.Plugins _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["EventsMaxRecipients"] == null || Config["EventsMaxTallies"] == null || Config["EventsMaxChat"] == null || + Config["MapMaxSleepers"] == null || Config["MapMaxBases"] == null) Config.WriteObject(_config, true); } @@ -940,6 +947,23 @@ namespace Oxide.Plugins HandleChatSay(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; @@ -1206,7 +1230,7 @@ namespace Oxide.Plugins /// /// An entity's position as Rust's own map reference (H7), or null if it has no - /// position to read. A reference, never a coordinate: a grid cell is 146.3 units + /// 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. /// @@ -1218,51 +1242,31 @@ namespace Oxide.Plugins return Grid(entity.transform.position); } + /// + /// The game's own label for a position, from MapHelper — the function the in-game + /// map draws its grid with (D119). + /// + /// + /// Phases 3 to 13 computed this here from a 146.3 m cell, the number community tools + /// quote. It is wrong: the game fits a whole number of cells to the world, so a 3000 map + /// is 20 cells of exactly 150 m, and the constant put every row below the first a row + /// too far south (the ferry terminal read O17 where the game says O16). Nobody could + /// see it without a second opinion, and the map's grid was the second opinion. + /// + /// private static string Grid(UnityEngine.Vector3 position) { - float size = ConVar.Server.worldsize; - - if (size <= 0f) + if (ConVar.Server.worldsize <= 0) return null; - float half = size / 2f; - int columns = (int)Math.Ceiling(size / GridCellSize); - - int x = (int)((position.x + half) / GridCellSize); - - // Rows are numbered from the NORTH edge downwards, which is why this is a subtraction - // and not the same expression as the column. Getting it wrong mirrors every grid on - // the map and is invisible without a second opinion. - int z = (int)((half - position.z) / GridCellSize); - - x = Clamp(x, 0, columns - 1); - z = Clamp(z, 0, columns - 1); - - return Column(x) + z.ToString(System.Globalization.CultureInfo.InvariantCulture); - } - - private static int Clamp(int value, int low, int high) - { - if (high < low) - return low; - - return value < low ? low : (value > high ? high : value); - } - - /// A column index as Rust spells it: 0 is A, 25 is Z, 26 is AA. - private static string Column(int index) - { - string name = string.Empty; - int n = index + 1; - - while (n > 0) + try { - int remainder = (n - 1) % 26; - name = (char)('A' + remainder) + name; - n = (n - 1) / 26; + return MapHelper.PositionToString(position); + } + catch (Exception) + { + return null; } - - return name; } // ---- identity of the world ---- @@ -1355,6 +1359,9 @@ namespace Oxide.Plugins DropWorldFromOtherWipes(); RecreateZones("load"); + // The map's sets are seeded here for the same reason: a hotload hears no spawns. + SeedMapSets(); + // 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. @@ -8287,6 +8294,964 @@ namespace Oxide.Plugins arg.ReplyWith(string.Join(Environment.NewLine, lines.ToArray())); } + // ---- the map (protocol 11, PLAN.md §30) ---- + // + // The picture of this wipe's map and everything that moves on it, in four verbs. Five rules + // shape the section, and each came from the rig (§30.0): + // + // * **Nothing renders unasked.** The game's renderer holds the main thread for 8.5 s on a + // 3000 map and 21 s at full scale. The picture comes from Rust+'s cache, which the game + // fills at boot when `app.port` is set; only an admin's `map.render` ever draws one, and + // then on a later frame than the one that answered (D109). + // * **The picture is read, never copied, on the main thread.** Hashing it and cutting it + // into base64 slices happen on the thread pool. The bytes are never mutated once made, so + // handing the array to another thread is safe. + // * **No sweep per ask.** What moves is kept in sets by `OnEntitySpawned` / `OnEntityKill`, + // seeded by one sweep when the world is ready. An ask copies positions out of the sets. + // * **Filtering is the website's job** (§8.5). `map.live` answers every layer, and which of + // them a viewer may see is decided by the module, never here. + // * **Every map verb is outside `EventsEnabled`.** Nothing here changes the world. + + /// + /// One slice of the picture on the wire. Base64 grows it by a third, and the result must + /// fit the game link's 1 MiB line cap with its envelope (the asset bridge's phase 7 shape). + /// + private const int MapChunkBytes = 512 * 1024; + + /// + /// The render's geometry, which is the Rust+ cache's geometry exactly: half scale, and an + /// ocean margin of 500 PIXELS on each side, unscaled (§30.0). + /// + private const float MapRenderScale = 0.5f; + + private const int MapOceanMargin = 500; + + /// The default most sleepers, and the most bases, one live answer carries. + private const int MapDefaultMax = 2000; + + private const string MapSourceCompanion = "companion"; + private const string MapSourceRendered = "rendered"; + private const string MapSourceNone = "none"; + + /// What this plugin knows about the picture it can serve. Replaced whole, never edited. + private sealed class MapPicture + { + public string MapKey; + public string Source; + public byte[] Bytes; + public int Width; + public int Height; + public string Background; + + /// Null until the thread pool has hashed . + public volatile string Sha256; + } + + /// A rendered picture's companion file: which map it is a picture of. + private sealed class RenderedMapMeta + { + public string MapKey; + public string Background; + public long RenderedMs; + public long TookMs; + } + + /// The current picture, or null. Main thread writes; the pool only reads what it is handed. + private MapPicture _mapPicture; + + /// A picture this plugin rendered for the current map, read from disk at most once per map. + private MapPicture _mapRendered; + + private string _mapRenderedCheckedFor; + + /// Set between a `map.render` being accepted and the render finishing. + private bool _mapRendering; + + private readonly HashSet _mapWorld = new HashSet(); + private readonly HashSet _mapBases = new HashSet(); + + private string _lastMapSummary = "none yet"; + private long _mapLiveAsks, _mapLiveLastMs, _mapLiveMaxMs; + + private int MapMaxSleepers() + { + return _config != null ? _config.MapMaxSleepers : MapDefaultMax; + } + + private int MapMaxBases() + { + return _config != null ? _config.MapMaxBases : MapDefaultMax; + } + + /// + /// Which map this is. A procedural map is its size, seed and salt; a custom map is the + /// hash of the URL it was downloaded from. The key says WHICH map, and the picture's own + /// SHA-256 says which picture of it (§30.2). + /// + private static string MapKey() + { + if (World.Procedural) + { + return string.Format(System.Globalization.CultureInfo.InvariantCulture, + "{0}.{1}.{2}", World.Size, World.Seed, World.Salt); + } + + string url = !string.IsNullOrEmpty(World.Url) ? World.Url : ConVar.Server.levelurl; + return "custom." + Sha256Hex(Encoding.UTF8.GetBytes(url ?? string.Empty)); + } + + private static string Sha256Hex(byte[] bytes) + { + using (var sha = System.Security.Cryptography.SHA256.Create()) + { + byte[] hash = sha.ComputeHash(bytes); + var sb = new StringBuilder(hash.Length * 2); + + foreach (byte b in hash) + sb.Append(b.ToString("x2", System.Globalization.CultureInfo.InvariantCulture)); + + return sb.ToString(); + } + } + + /// + /// A JPEG's own width and height, read from its frame header. The picture says what size + /// it is; a private field that says so as well is a second opinion that can drift. + /// + private static bool JpegSize(byte[] bytes, out int width, out int height) + { + width = 0; + height = 0; + + if (bytes == null || bytes.Length < 4 || bytes[0] != 0xFF || bytes[1] != 0xD8) + return false; + + int i = 2; + + while (i + 9 < bytes.Length) + { + if (bytes[i] != 0xFF) + return false; + + byte marker = bytes[i + 1]; + int length = (bytes[i + 2] << 8) | bytes[i + 3]; + + // SOF0..SOF15 carry the frame's size — except DHT (C4), JPG (C8) and DAC (CC), + // which share the range and do not. + if (marker >= 0xC0 && marker <= 0xCF && marker != 0xC4 && marker != 0xC8 && marker != 0xCC) + { + height = (bytes[i + 5] << 8) | bytes[i + 6]; + width = (bytes[i + 7] << 8) | bytes[i + 8]; + return width > 0 && height > 0; + } + + i += 2 + length; + } + + return false; + } + + private static System.Reflection.PropertyInfo _companionImage; + private static System.Reflection.FieldInfo _companionBackground; + private static bool _companionResolved; + + /// + /// Main thread. The Rust+ cache's picture, or null. Read by reflection, by NAME, and only + /// the two members this needs: CompanionServer types hold the server's Rust+ + /// secret, and a probe that enumerated one printed it into the log (§30.0). A Facepunch + /// rename makes this answer null, which is none — never an exception. + /// + private static byte[] CompanionImage(out string background) + { + background = null; + + try + { + if (!_companionResolved) + { + _companionResolved = true; + Type map = typeof(BasePlayer).Assembly.GetType("CompanionServer.Handlers.Map", false); + + if (map != null) + { + const System.Reflection.BindingFlags Static = + System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic | + System.Reflection.BindingFlags.Static; + + _companionImage = map.GetProperty("ImageData", Static); + _companionBackground = map.GetField("_background", Static); + } + } + + if (_companionImage == null) + return null; + + byte[] bytes = _companionImage.GetValue(null, null) as byte[]; + + if (bytes == null || bytes.Length == 0) + return null; + + if (_companionBackground != null) + background = _companionBackground.GetValue(null) as string; + + return bytes; + } + catch (Exception) + { + return null; + } + } + + private string MapDir() + { + return Path.Combine(Interface.Oxide.DataDirectory, Name); + } + + /// + /// Main thread. A picture this plugin rendered earlier for THIS map, from disk. Read once + /// per map key, so a restart within the map does not ask for another render (§30.2). + /// + private MapPicture RenderedFromDisk(string key) + { + if (_mapRenderedCheckedFor == key) + return _mapRendered; + + _mapRenderedCheckedFor = key; + _mapRendered = null; + + try + { + string meta = Path.Combine(MapDir(), "map.json"); + string jpeg = Path.Combine(MapDir(), "map.jpg"); + + if (!File.Exists(meta) || !File.Exists(jpeg)) + return null; + + RenderedMapMeta m = JsonConvert.DeserializeObject(File.ReadAllText(meta)); + + if (m == null || m.MapKey != key) + return null; + + byte[] bytes = File.ReadAllBytes(jpeg); + int w, h; + + if (!JpegSize(bytes, out w, out h)) + return null; + + _mapRendered = new MapPicture + { + MapKey = key, + Source = MapSourceRendered, + Bytes = bytes, + Width = w, + Height = h, + Background = m.Background + }; + } + catch (Exception ex) + { + Puts($"map: could not read the rendered picture: {Flatten(ex.Message)}"); + } + + return _mapRendered; + } + + /// + /// Main thread. The picture to serve now: the Rust+ cache when it has one, else a render + /// of this map kept on disk, else null. Rebuilt only when the key or the cache's array + /// changes, and hashed on the pool the first time a picture is seen. + /// + private MapPicture CurrentPicture() + { + string key = MapKey(); + string background; + byte[] companion = CompanionImage(out background); + MapPicture current = _mapPicture; + + if (companion != null) + { + if (current != null && current.MapKey == key && ReferenceEquals(current.Bytes, companion)) + return current; + + int w, h; + + if (!JpegSize(companion, out w, out h)) + return null; + + current = new MapPicture + { + MapKey = key, + Source = MapSourceCompanion, + Bytes = companion, + Width = w, + Height = h, + Background = background + }; + } + else + { + current = RenderedFromDisk(key); + } + + _mapPicture = current; + HashLater(current); + return current; + } + + private static void HashLater(MapPicture picture) + { + if (picture == null || picture.Sha256 != null) + return; + + ThreadPool.QueueUserWorkItem(_ => + { + try + { + picture.Sha256 = Sha256Hex(picture.Bytes); + } + catch (Exception) + { + // Left null: `map.info` keeps answering `hashing`, and a later ask tries again. + } + }); + } + + /// + /// Main thread. The game's own grid, from its own function: how many cells a side has and + /// how wide one is. MapHelper is what the in-game map labels with, so the website + /// draws the game's grid by construction rather than from a constant somebody measured + /// once (§30.3). + /// + private static void GameGrid(out int cells, out float cellSize) + { + float size = ConVar.Server.worldsize; + float half = size / 2f; + + Vector2i last = MapHelper.PositionToGrid(new UnityEngine.Vector3(half - 0.5f, 0f, -half + 0.5f)); + cells = Math.Max(1, last.x + 1); + cellSize = size / cells; + } + + /// + /// Main thread. The monuments the in-game map shows: those with a display phrase, in the + /// same kind#n vocabulary as world.monuments, so a monument a step names and + /// a monument on the map are the same token. + /// + private static List MapMonuments() + { + var rows = new List(); + + foreach (KeyValuePair> kv in MonumentGroups()) + { + for (int i = 0; i < kv.Value.Count; i++) + { + MonumentInfo m = kv.Value[i]; + string phrase = m.displayPhrase != null ? m.displayPhrase.english : null; + + if (string.IsNullOrEmpty(phrase)) + continue; + + UnityEngine.Vector3 p = m.transform.position; + + rows.Add(new Dictionary + { + { "value", kv.Value.Count > 1 ? kv.Key + "#" + (i + 1) : kv.Key }, + { "kind", kv.Key }, + { "label", phrase }, + { "x", Math.Round(p.x, 1) }, + { "z", Math.Round(p.z, 1) }, + { "grid", MapHelper.PositionToString(p) } + }); + } + } + + return rows; + } + + /// + /// Stage one: what exists. Reads the cache, and hashes it on the pool once per map; no + /// other work. sha256 is absent while the hash is still being computed, and the + /// answer then says hashing so the caller asks again. + /// + private void HandleMapInfo(string reqId) + { + if (!MapReady(reqId)) + return; + + MapPicture picture = CurrentPicture(); + int cells; + float cellSize; + GameGrid(out cells, out cellSize); + + Dictionary reply = Frame("map.info", "reply"); + reply["mapKey"] = MapKey(); + reply["worldSize"] = ConVar.Server.worldsize; + reply["oceanMargin"] = MapOceanMargin; + reply["gridCells"] = cells; + reply["gridCellSize"] = Math.Round(cellSize, 3); + reply["monuments"] = MapMonuments(); + reply["rendering"] = _mapRendering; + + if (picture == null) + { + reply["source"] = MapSourceNone; + } + else + { + reply["source"] = picture.Source; + reply["bytes"] = picture.Bytes.Length; + reply["width"] = picture.Width; + reply["height"] = picture.Height; + reply["chunks"] = (picture.Bytes.Length + MapChunkBytes - 1) / MapChunkBytes; + reply["chunkBytes"] = MapChunkBytes; + + if (!string.IsNullOrEmpty(picture.Background)) + reply["background"] = picture.Background; + + string hash = picture.Sha256; + + if (hash != null) + reply["sha256"] = hash; + else + reply["hashing"] = true; + } + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + } + + /// + /// Stage two: one slice of the picture. Refused stale when the map or the picture + /// moved since the caller asked stage one, so a fetch that straddles a wipe cannot splice + /// two maps. The slice is base64'd and serialized on the pool: at 512 KiB that is work the + /// main thread has no business doing. + /// + private void HandleMapFetch(Dictionary cmd, string reqId) + { + if (!MapReady(reqId)) + return; + + if (_mapRendering) + { + EmitMapError(reqId, "busy", "the server is drawing its map; ask again when map.info says it is done"); + return; + } + + string key = StringField(cmd, "mapKey"); + string sha = StringField(cmd, "sha256"); + int n = IntField(cmd, "chunk"); + MapPicture picture = CurrentPicture(); + + if (picture == null) + { + EmitMapError(reqId, "no-picture", "this server has no picture of its map"); + return; + } + + string hash = picture.Sha256; + + if (key != picture.MapKey || hash == null || !string.Equals(sha, hash, StringComparison.OrdinalIgnoreCase)) + { + EmitMapError(reqId, "stale", "the map or its picture changed since it was described; ask map.info again"); + return; + } + + int chunks = (picture.Bytes.Length + MapChunkBytes - 1) / MapChunkBytes; + + if (n < 0 || n >= chunks) + { + EmitMapError(reqId, "malformed", $"this picture has {chunks} slices, numbered from 0"); + return; + } + + Dictionary reply = Frame("map.chunk", "reply"); + reply["mapKey"] = picture.MapKey; + reply["sha256"] = hash; + reply["chunk"] = n; + reply["chunks"] = chunks; + + if (reqId != null) + reply["reqId"] = reqId; + + byte[] bytes = picture.Bytes; + int offset = n * MapChunkBytes; + int length = Math.Min(MapChunkBytes, bytes.Length - offset); + + ThreadPool.QueueUserWorkItem(_ => + { + try + { + reply["data"] = Convert.ToBase64String(bytes, offset, length); + Emit(JsonConvert.SerializeObject(reply, Formatting.None)); + } + catch (Exception ex) + { + // Nothing is sent, and the caller's ask times out: a slice is re-asked, never trusted half-made. + Interface.Oxide.NextTick(() => Puts($"map: could not encode slice {n}: {Flatten(ex.Message)}")); + } + }); + } + + /// + /// The admin fallback (D109): draw the map with the game's own renderer, once. Refused + /// unless there is no picture at all, and while the world is loading. Answers + /// accepted AT ONCE and renders on a later frame, because the render outlasts the + /// sidecar's reply budget on any map larger than the rig's; the caller polls + /// map.info until its source is rendered. + /// + private void HandleMapRender(Dictionary cmd, string reqId) + { + if (!MapReady(reqId)) + return; + + if (_mapRendering) + { + EmitMapError(reqId, "busy", "the server is already drawing its map"); + return; + } + + MapPicture picture = CurrentPicture(); + + if (picture != null) + { + EmitMapError(reqId, "has-picture", + $"this server already has a picture of its map ({picture.Source}); nothing needs drawing"); + return; + } + + _mapRendering = true; + string actor = StringField(cmd, "actor"); + string key = MapKey(); + + Dictionary reply = Frame("map.render", "reply"); + reply["accepted"] = true; + reply["mapKey"] = key; + reply["worldSize"] = ConVar.Server.worldsize; + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + + Puts($"map: drawing the map for {key} at an admin's request{(string.IsNullOrEmpty(actor) ? "" : " (" + actor + ")")}; " + + "the server will stall while it does"); + + // A timer rather than NextTick, so the reply above is on the wire before the stall. + timer.Once(0.5f, () => RenderMap(key)); + } + + /// Main thread, deliberately: the renderer reads the terrain, which is main-thread-only. + private void RenderMap(string key) + { + var clock = System.Diagnostics.Stopwatch.StartNew(); + + try + { + if (MapKey() != key) + { + _lastMapSummary = "a render was abandoned: the map changed before it started"; + return; + } + + System.Reflection.MethodInfo render = null; + + foreach (System.Reflection.MethodInfo m in typeof(MapImageRenderer).GetMethods( + System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static)) + { + if (m.Name == "Render" && m.ReturnType == typeof(byte[])) + { + render = m; + break; + } + } + + if (render == null) + { + _lastMapSummary = "a render failed: the game has no MapImageRenderer.Render"; + Puts("map: " + _lastMapSummary); + return; + } + + // Called by parameter NAME, as the probe did: a reordered signature must not swap + // the scale for the margin. + System.Reflection.ParameterInfo[] ps = render.GetParameters(); + object[] args = new object[ps.Length]; + + for (int i = 0; i < ps.Length; i++) + { + if (ps[i].IsOut) + continue; + + if (ps[i].Name == "scale") + args[i] = MapRenderScale; + else if (ps[i].Name == "oceanMargin") + args[i] = MapOceanMargin; + else if (ps[i].Name == "lossy") + args[i] = true; + else if (ps[i].Name == "transparent") + args[i] = false; + else if (ps[i].HasDefaultValue) + args[i] = ps[i].DefaultValue; + } + + byte[] bytes = render.Invoke(null, args) as byte[]; + clock.Stop(); + + int w, h; + + if (bytes == null || !JpegSize(bytes, out w, out h)) + { + _lastMapSummary = $"a render produced no picture after {clock.ElapsedMilliseconds} ms"; + Puts("map: " + _lastMapSummary); + return; + } + + string background = null; + + for (int i = 0; i < ps.Length; i++) + { + if (ps[i].IsOut && args[i] is UnityEngine.Color) + background = "#" + UnityEngine.ColorUtility.ToHtmlStringRGB((UnityEngine.Color)args[i]); + } + + Directory.CreateDirectory(MapDir()); + File.WriteAllBytes(Path.Combine(MapDir(), "map.jpg"), bytes); + File.WriteAllText(Path.Combine(MapDir(), "map.json"), JsonConvert.SerializeObject(new RenderedMapMeta + { + MapKey = key, + Background = background, + RenderedMs = NowMs(), + TookMs = clock.ElapsedMilliseconds + }, Formatting.Indented)); + + // Read back through the path a restart would take, so what is served now is exactly + // what a restart would serve. + _mapRenderedCheckedFor = null; + _mapPicture = null; + CurrentPicture(); + + _lastMapSummary = $"drew the map for {key}: {w}x{h}, {bytes.Length} bytes, {clock.ElapsedMilliseconds} ms of stall"; + Puts("map: " + _lastMapSummary); + } + catch (Exception ex) + { + _lastMapSummary = $"a render threw after {clock.ElapsedMilliseconds} ms: {Flatten((ex.InnerException ?? ex).Message)}"; + Puts("map: " + _lastMapSummary); + } + finally + { + _mapRendering = false; + } + } + + /// Refuses a map command until the world is loaded, as a map.error. + private bool MapReady(string reqId) + { + if (_worldReady) + return true; + + EmitMapError(reqId, "not-ready", "the server is still loading its world; ask again once it has started"); + return false; + } + + // ── What moves ──────────────────────────────────────────────────────────── + + /// The world-event types the map follows (D112). A cheap type test: this runs on every spawn. + private static bool IsMapWorldThing(BaseNetworkable entity) + { + return entity is CargoShip || entity is PatrolHelicopter || entity is CH47Helicopter || + entity is BradleyAPC || entity is SupplyDrop || entity is HackableLockedCrate; + } + + /// Tool cupboards, and a player's vending machine — never a monument's (NPC) one. + private static bool IsMapBase(BaseNetworkable entity) + { + return entity is BuildingPrivlidge || (entity is VendingMachine && !(entity is NPCVendingMachine)); + } + + /// + /// Runs on EVERY entity spawn on the server, tens of thousands at boot: two type tests and, + /// rarely, a set insert. Void, so it can never veto a spawn. + /// + private void OnEntitySpawned(BaseNetworkable entity) + { + MarkHook("OnEntitySpawned"); + + if (entity == null) + return; + + if (IsMapWorldThing(entity)) + _mapWorld.Add((BaseEntity)entity); + else if (IsMapBase(entity)) + _mapBases.Add((BaseEntity)entity); + } + + private void OnEntityKill(BaseNetworkable entity) + { + MarkHook("OnEntityKill"); + + var e = entity as BaseEntity; + + if (e == null) + return; + + if (!_mapWorld.Remove(e)) + _mapBases.Remove(e); + } + + /// + /// Main thread, once per load when the world is there: the one sweep that seeds the sets. + /// The spawn hook keeps them after that; a hotload has no spawns to hear, which is why this + /// runs at all. + /// + private void SeedMapSets() + { + var clock = System.Diagnostics.Stopwatch.StartNew(); + _mapWorld.Clear(); + _mapBases.Clear(); + + foreach (BaseNetworkable n in BaseNetworkable.serverEntities) + { + if (IsMapWorldThing(n)) + _mapWorld.Add((BaseEntity)n); + else if (IsMapBase(n)) + _mapBases.Add((BaseEntity)n); + } + + clock.Stop(); + Puts($"map: following {_mapWorld.Count} world things and {_mapBases.Count} bases ({clock.ElapsedMilliseconds} ms sweep)"); + + // Hashed now rather than at the first ask, so the first `map.info` usually has it. + CurrentPicture(); + } + + private static bool Gone(BaseEntity e) + { + return e == null || e.IsDestroyed || e.transform == null; + } + + private static Dictionary At(UnityEngine.Vector3 p) + { + return new Dictionary + { + { "x", Math.Round(p.x, 1) }, + { "z", Math.Round(p.z, 1) } + }; + } + + private static string WorldKind(BaseEntity e) + { + if (e is CargoShip) return "cargo"; + if (e is PatrolHelicopter) return "heli"; + if (e is CH47Helicopter) return "chinook"; + if (e is BradleyAPC) return "bradley"; + if (e is SupplyDrop) return "supply"; + return "crate"; + } + + /// + /// What moves, every layer at once. Positions only, and nothing a player typed about a + /// base: no owner, no authorised list, no shop name (§30.5). The two unbounded layers — + /// sleepers and bases — stop at their bound and say so with truncated; a read has + /// nobody to refuse. + /// + private void HandleMapLive(string reqId) + { + if (!MapReady(reqId)) + return; + + var clock = System.Diagnostics.Stopwatch.StartNew(); + + var world = new List(); + var dead = new List(); + + foreach (BaseEntity e in _mapWorld) + { + if (Gone(e)) + { + dead.Add(e); + continue; + } + + Dictionary row = At(e.transform.position); + row["kind"] = WorldKind(e); + + var crate = e as HackableLockedCrate; + + if (crate != null) + { + if (crate.IsFullyHacked()) + row["hacked"] = true; + else if (crate.IsBeingHacked()) + row["hackLeftSec"] = (int)Math.Max(0f, HackableLockedCrate.requiredHackSeconds - crate.hackSeconds); + } + + world.Add(row); + } + + foreach (BaseEntity e in dead) + _mapWorld.Remove(e); + + var events = new List(); + + foreach (WorldEntry w in _world.Values) + { + Dictionary row; + + if (w.Kind == WorldKindZone) + { + row = At(new UnityEngine.Vector3(w.X, 0f, w.Z)); + row["radius"] = Math.Round(w.Radius, 1); + + if (!string.IsNullOrEmpty(w.Name)) + row["name"] = w.Name; + } + else + { + // Where it is NOW: an NPC walks, and a crate can be knocked about. + var found = FindEntity(w.Id) as BaseEntity; + + if (Gone(found)) + continue; + + row = At(found.transform.position); + row["prefab"] = w.Prefab; + } + + row["kind"] = w.Kind; + row["runId"] = w.RunId; + events.Add(row); + } + + var players = new List(); + + foreach (BasePlayer p in BasePlayer.activePlayerList) + { + if (Gone(p) || p.IsDead()) + continue; + + Dictionary row = At(p.transform.position); + row["steamId"] = p.UserIDString; + row["name"] = p.displayName; + row["sleeping"] = p.IsSleeping(); + row["online"] = true; + players.Add(row); + } + + int maxSleepers = MapMaxSleepers(); + int sleepers = 0; + bool sleepersTruncated = false; + + foreach (BasePlayer p in BasePlayer.sleepingPlayerList) + { + if (Gone(p) || p.IsDead() || p.IsConnected) + continue; + + if (sleepers >= maxSleepers) + { + sleepersTruncated = true; + break; + } + + Dictionary row = At(p.transform.position); + row["steamId"] = p.UserIDString; + row["name"] = p.displayName; + row["sleeping"] = true; + row["online"] = false; + players.Add(row); + sleepers++; + } + + int maxBases = MapMaxBases(); + var bases = new List(); + bool basesTruncated = false; + dead.Clear(); + + foreach (BaseEntity e in _mapBases) + { + if (Gone(e)) + { + dead.Add(e); + continue; + } + + if (bases.Count >= maxBases) + { + basesTruncated = true; + continue; + } + + Dictionary row = At(e.transform.position); + row["kind"] = e is BuildingPrivlidge ? "tc" : "vending"; + bases.Add(row); + } + + foreach (BaseEntity e in dead) + _mapBases.Remove(e); + + clock.Stop(); + _mapLiveAsks++; + _mapLiveLastMs = clock.ElapsedMilliseconds; + _mapLiveMaxMs = Math.Max(_mapLiveMaxMs, _mapLiveLastMs); + + Dictionary 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); + } + + /// The map's one refusal frame. Every reason is a word the module can switch on. + private void EmitMapError(string reqId, string reason, string message) + { + Dictionary frame = Frame("map.error", "reply"); + frame["reason"] = reason; + + if (!string.IsNullOrEmpty(message)) + frame["message"] = message; + + if (reqId != null) + frame["reqId"] = reqId; + + _lastMapSummary = $"refused ({reason}): {message}"; + EmitFrame(frame); + } + + /// What the map can serve and what asking it costs, from the server console. + [ConsoleCommand("rg.map")] + private void CmdMap(ConsoleSystem.Arg arg) + { + MapPicture picture = _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 -- 2.49.1 From de5f0928bd2fd4b0b2f2d8f8e472aa4ed88abbd7 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 25 Sep 2026 01:05:51 -0500 Subject: [PATCH 14/18] fix(bridge): map.live names a player by userID, not UserIDString The phase 14 walk read `steamId: null` for every sleeper made on the server: UserIDString is a cached string the game fills for a player it has seen connect. An id the map cannot name is an own dot and a clan mate the site can never match, so the map reads the id from userID itself. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- overlay/oxide/plugins/RunicGateway.cs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index cc4e961..be20606 100644 --- a/overlay/oxide/plugins/RunicGateway.cs +++ b/overlay/oxide/plugins/RunicGateway.cs @@ -9027,6 +9027,18 @@ namespace Oxide.Plugins CurrentPicture(); } + /// + /// A player's Steam id as text, from userID itself. UserIDString is a + /// cached string the game fills for a player it has seen connect, and the phase 14 walk + /// read it back empty from a sleeper made on the server: an id the map could not name + /// is an own dot and a clan mate the site can never find. + /// + 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; @@ -9133,7 +9145,7 @@ namespace Oxide.Plugins continue; Dictionary row = At(p.transform.position); - row["steamId"] = p.UserIDString; + row["steamId"] = SteamIdOf(p); row["name"] = p.displayName; row["sleeping"] = p.IsSleeping(); row["online"] = true; @@ -9156,7 +9168,7 @@ namespace Oxide.Plugins } Dictionary row = At(p.transform.position); - row["steamId"] = p.UserIDString; + row["steamId"] = SteamIdOf(p); row["name"] = p.displayName; row["sleeping"] = true; row["online"] = false; -- 2.49.1 From ef78da8ce67660b997af41fab266491cca994640 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 25 Sep 2026 06:31:42 -0500 Subject: [PATCH 15/18] fix(bridge): rg.map re-reads the picture rather than printing the last answer The walk emptied the Rust+ cache and rg.map still said `companion`: it printed the picture the last map.info had seen. It now re-reads while the world is ready. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- overlay/oxide/plugins/RunicGateway.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index be20606..a55577e 100644 --- a/overlay/oxide/plugins/RunicGateway.cs +++ b/overlay/oxide/plugins/RunicGateway.cs @@ -9248,7 +9248,10 @@ namespace Oxide.Plugins [ConsoleCommand("rg.map")] private void CmdMap(ConsoleSystem.Arg arg) { - MapPicture picture = _mapPicture; + // 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); -- 2.49.1 From 8c6146259ea13fa400f2cfaabc7390d54964bcf7 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 25 Sep 2026 17:48:05 -0500 Subject: [PATCH 16/18] =?UTF-8?q?feat(bridge):=20protocol=2012=20=E2=80=94?= =?UTF-8?q?=20BetterChat=20and=20popups=20(phase=2017)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - titles.set: a whole set of chat titles, swapped in by reference and read by a BetterChat third-party title getter (one lookup, no socket). The getter is registered at load and again whenever BetterChat loads. - perm.sync: a group may carry its BetterChat style. Each field is written through API_SetGroupField only when the game still holds what the site last pushed (or BetterChat's default); anything else is reported as drift. One `chat group set` per touched group saves it, because the API never does (D143). A retired style removes the group with `chat group remove` (D139). The report gains `chat`. - chat.say: delivery chat|popup and an optional voice format. A popup with PopupNotifications absent is refused `popup-unavailable`. Every line said is logged, since the game logs neither a broadcast nor a popup. - server.hello/status carry `integrations`; `rg.titles [steamId]` prints what BetterChat would render for a player. - overlay.toml: protocol 12; PopupNotifications leaves requires_plugins (D141). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- overlay.toml | 15 +- overlay/oxide/plugins/RunicGateway.cs | 724 +++++++++++++++++++++++++- 2 files changed, 729 insertions(+), 10 deletions(-) diff --git a/overlay.toml b/overlay.toml index 89d7c24..6193f2f 100644 --- a/overlay.toml +++ b/overlay.toml @@ -26,12 +26,13 @@ # the same change that alters the emitters, exactly as the sidecar bumps # PROTOCOL_VERSION and the module bumps its own constant. # -# Current: 10 — the transport, the read path, identity, the permission mirror, +# Current: 12 — the transport, the read path, identity, the permission mirror, # configuration from the site, first-party clans, a raid frame that names who # lives there, the event leases, the world verbs events place and give back, and # the rewards: a run's participation tally, kit credits and chat (PROTOCOL.md §16), -# and the map: its picture and everything that moves on it (PROTOCOL.md §17). -protocol = 11 +# the map: its picture and everything that moves on it (PROTOCOL.md §17), and the +# optional mods: BetterChat titles and group styles, and popups (PROTOCOL.md §18). +protocol = 12 # ── Oxide compatibility ────────────────────────────────────────────────────── # @@ -54,5 +55,9 @@ min_oxide_version = "2.0.7585" # The uMod **Clans** plugin is deliberately NOT listed (docs/modules/rust/PLAN.md # D47). Teams come from Rust's own clan system, which that plugin never touches, # so requiring it would split a server's players across two unrelated clan -# systems. It belongs to phase 17's optional tier. -requires_plugins = ["Kits", "PopupNotifications", "ZoneManager"] +# systems. Phase 17 dropped the adapter it was meant to get (D134). +# +# **PopupNotifications is not listed either** (D141). It is optional: a popup +# asked of a server without it is refused with a reason, and chat is the default. +# BetterChat is optional in the same way — titles and group styles wait for it. +requires_plugins = ["Kits", "ZoneManager"] diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index a55577e..d9c8f63 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 = 11; + private const int ProtocolVersion = 12; /// /// Loopback reconnects are cheap, so the ceiling is low. A sidecar restart should cost @@ -947,6 +947,12 @@ namespace Oxide.Plugins 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); @@ -1130,6 +1136,10 @@ namespace Oxide.Plugins // 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; } @@ -1362,6 +1372,10 @@ namespace Oxide.Plugins // The map's sets are seeded here for the same reason: a hotload hears no spawns. SeedMapSets(); + // Hand BetterChat the title getter, if it is here. When it loads later, or reloads and + // forgets us, OnPluginLoaded does this again (§33.1). + 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. @@ -2865,7 +2879,13 @@ namespace Oxide.Plugins RetireGrant, RetireGroup, ScanPermission, - ScanGroup + ScanGroup, + + /// Protocol 12: a group's BetterChat style (D138). + EnsureChatStyle, + + /// Protocol 12: a styled group's BetterChat group, gone from the site (D139). + RetireChatGroup } private struct PermOp @@ -2950,6 +2970,42 @@ namespace Oxide.Plugins public int CreditsWithdrawn; public bool CreditsWaiting; public readonly List CreditsUnresolved = new List(); + + /// + /// Whether BetterChat was loaded when this sync was compiled (protocol 12). When it + /// was not, no style op is compiled and the report says loaded: false and + /// nothing else: the permissions sync exactly as they did before. + /// + public bool ChatLoaded; + + public string ChatVersion; + + /// The fields the site wants on each styled group. + public readonly Dictionary> ChatStyles = + new Dictionary>(); + + public int ChatApplied; + public int ChatSaved; + public int ChatSame; + public readonly List ChatDrift = new List(); + public readonly List ChatRemoved = new List(); + public readonly List ChatFailed = new List(); + } + + /// + /// One BetterChat field the site wants on a group (protocol 12): the value, and the + /// value this site last pushed there. + /// + private sealed class ChatFieldWant + { + public string Field; + public string Value; + + /// + /// What the site last pushed, or null for a field it has never pushed. A game value + /// that is neither this nor the one wanted is somebody's hand edit (D138). + /// + public string Expect; } private PermSyncJob _permJob; @@ -3062,6 +3118,10 @@ namespace Oxide.Plugins 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 @@ -3115,6 +3175,20 @@ namespace Oxide.Plugins // A group with no members still has to be scanned: the interesting case is a // member the site did not put there. Bucket(job.DesiredMembers, name); + + // Protocol 12: the group's BetterChat style. After the group's own ops, so the + // Oxide group BetterChat's members are read from exists first. With BetterChat + // absent nothing is compiled, and the report says why (§33.2). + if (job.ChatLoaded) + { + List style = ChatStyle(job, name, group); + + if (style.Count > 0) + { + job.ChatStyles[name] = style; + job.Ops.Add(new PermOp { Kind = PermOpKind.EnsureChatStyle, Subject = name }); + } + } } foreach (Dictionary grant in Rows(cmd, "grants")) @@ -3169,6 +3243,12 @@ namespace Oxide.Plugins 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; } } @@ -3448,6 +3528,14 @@ namespace Oxide.Plugins case PermOpKind.ScanGroup: ScanGroup(job, op.Subject); break; + + case PermOpKind.EnsureChatStyle: + ApplyChatStyle(job, op.Subject); + break; + + case PermOpKind.RetireChatGroup: + RemoveChatGroup(job, op.Subject); + break; } } @@ -3695,6 +3783,23 @@ namespace Oxide.Plugins 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 { { "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; @@ -3705,7 +3810,9 @@ namespace Oxide.Plugins $"ops={job.Ops.Count} grants={job.Grants} revokes={job.Revokes} " + $"groups={job.GroupsCreated} members={job.Members} same={job.AlreadyCorrect} " + $"unresolved={job.Unresolved.Count} foreign={job.Foreign.Count} " + - $"pending={job.Pending.Count} notLanded={job.NotLanded.Count} at={DateTime.UtcNow:HH:mm:ss}Z"; + $"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); } @@ -3788,6 +3895,11 @@ namespace Oxide.Plugins if (plugin != null && plugin != this && plugin.Name == "ZoneManager") NextTick(() => RecreateZones("ZoneManager reloaded")); + // 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) @@ -3801,6 +3913,9 @@ namespace Oxide.Plugins { MarkHook("OnPluginUnloaded"); + if (plugin != null && plugin.Name == "BetterChat") + _titlesRegistered = false; + ConfigWriteJob job = _configJob; if (job == null || plugin == null) @@ -8236,6 +8351,44 @@ namespace Oxide.Plugins return; } + // Protocol 12: where the line goes, and the voice it is said in (D140, D141). A caller + // from protocol 11 sends neither and is answered exactly as before. + string delivery = StringField(cmd, "delivery"); + + if (string.IsNullOrEmpty(delivery)) + delivery = DeliveryChat; + + if (delivery != DeliveryChat && delivery != DeliveryPopup) + { + EmitRewardError("chat.error", reqId, "malformed", + $"a line is delivered to chat or to a popup, not to '{delivery}'"); + return; + } + + string format = StringField(cmd, "format"); + + if (format != null && (format.Length > ChatFormatMax || Occurrences(format, "{message}") != 1)) + { + EmitRewardError("chat.error", reqId, "malformed", + $"a chat format holds exactly one {{message}} and at most {ChatFormatMax} characters"); + return; + } + + Plugin popup = null; + + if (delivery == DeliveryPopup) + { + popup = PopupPlugin(); + + // R3: a refusal with a reason, never a line silently said somewhere else. + if (popup == null) + { + EmitRewardError("chat.error", reqId, "popup-unavailable", + "PopupNotifications is not loaded on this server, so a popup cannot be shown — install it, or deliver this line to chat"); + return; + } + } + var stale = new List(); foreach (KeyValuePair kv in _chatKeys) @@ -8251,15 +8404,29 @@ namespace Oxide.Plugins if (!repeat) { - Server.Broadcast(message); + // 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) to {BasePlayer.activePlayerList.Count} players"; + _lastRewardSummary = $"said a line ({message.Length} chars, {delivery}{(format != null ? ", voiced" : "")}) to {BasePlayer.activePlayerList.Count} players"; } Dictionary reply = Frame("chat.ok", "reply"); reply["said"] = !repeat; reply["repeat"] = repeat; reply["players"] = BasePlayer.activePlayerList.Count; + reply["delivery"] = delivery; if (reqId != null) reply["reqId"] = reqId; @@ -8267,6 +8434,553 @@ namespace Oxide.Plugins EmitFrame(reply); } + // ── The optional mods (protocol 12) ─────────────────────────────────────── + // + // BetterChat and PopupNotifications are optional (PLAN.md §33, D141). Each is looked up at + // the moment it is needed, never held from our load, because an operator can load or + // unload either at any time; and every call into one that is missing answers with a + // reason instead of doing nothing quietly (R3). + + private const string DeliveryChat = "chat"; + private const string DeliveryPopup = "popup"; + + /// The most titles one set carries. The site has its own, lower, limit. + private const int TitlesMax = 500; + + /// + /// The longest one player's title string may be. The site caps a title's text at 24 + /// characters (§33.4); this bounds what a player with every title, in markup, can hold. + /// + private const int TitleTextMax = 1024; + + /// The longest voice format a chat line may carry (§33.2). + private const int ChatFormatMax = 512; + + /// + /// The chat titles players have earned on this server, by Steam id, already in + /// BetterChat's markup. The website composes them from the wipe's standings and pushes + /// the whole set (titles.set); nothing here is persisted, because the site sends + /// it again on every boot it has not sent to. + /// + /// + /// Swapped, never edited. BetterChat reads this through on + /// every chat line, and another plugin can make it do so from its own thread through + /// BetterChat's API. A dictionary written while it is read can throw or answer wrong; a + /// reference replaced whole is read either before the swap or after it. + /// + /// + private volatile Dictionary _titles = new Dictionary(); + + /// The getter BetterChat holds, kept so every registration hands it the same delegate. + private Func _titleGetter; + + /// + /// Whether the BetterChat loaded right now holds our getter. Cleared when BetterChat + /// unloads: its registrations live on the instance, so a reloaded BetterChat has + /// forgotten us until registers again (§33.1). + /// + private bool _titlesRegistered; + + /// The last title set, for rg.titles. Diagnostics only. + private string _lastTitlesSummary = "none yet"; + + /// + /// The twelve fields of a BetterChat group (D138), by the name its API takes, with + /// BetterChat 5.2.15's default for each. Title is absent: its default is the + /// group's own name in brackets (). + /// + private static readonly Dictionary ChatFieldDefaults = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "Priority", "0" }, + { "Title", null }, + { "TitleColor", "#55aaff" }, + { "TitleSize", "15" }, + { "TitleHidden", "false" }, + { "TitleHiddenIfNotPrimary", "false" }, + { "UsernameColor", "#55aaff" }, + { "UsernameSize", "15" }, + { "MessageColor", "white" }, + { "MessageSize", "15" }, + { "ChatFormat", "{Title} {Username}: {Message}" }, + { "ConsoleFormat", "{Title} {Username}: {Message}" } + }; + + private Plugin BetterChatPlugin() + { + return plugins.Find("BetterChat"); + } + + private Plugin PopupPlugin() + { + return plugins.Find("PopupNotifications"); + } + + /// + /// BetterChat's third-party title getter. It runs on the chat path, once per line + /// and again whenever another plugin asks BetterChat to format one, so it does one + /// lookup and nothing else: no socket, no file, no allocation (R15). A player with no + /// title gets null, which BetterChat skips. + /// + private string TitleFor(Oxide.Core.Libraries.Covalence.IPlayer player) + { + if (player == null) + return null; + + ulong id; + string title; + + return ulong.TryParse(player.Id, out id) && _titles.TryGetValue(id, out title) ? title : null; + } + + /// Main thread. Hands BetterChat our title getter, if BetterChat is here. + private void RegisterTitles(string why) + { + Plugin betterChat = BetterChatPlugin(); + + if (betterChat == null) + { + _titlesRegistered = false; + return; + } + + if (_titleGetter == null) + _titleGetter = TitleFor; + + // Void, so the answer is null either way. `rg.titles ` is how a rig proves + // BetterChat renders what this handed it. + betterChat.Call("API_RegisterThirdPartyTitle", this, _titleGetter); + _titlesRegistered = true; + + if (why != "load") + Puts($"titles: registered with BetterChat again ({why}), holding {_titles.Count}"); + } + + /// + /// Main thread. Replaces the titles this server holds with the set the site sent. A + /// malformed entry refuses the whole set rather than being skipped: the set is whole by + /// contract, and half of one is a set nobody sent. + /// + private void HandleTitlesSet(Dictionary cmd, string reqId) + { + object raw; + var list = cmd.TryGetValue("titles", out raw) ? raw as Newtonsoft.Json.Linq.JArray : null; + + if (list == null) + { + EmitTitlesError(reqId, "malformed", "a title set carries a titles array"); + return; + } + + if (list.Count > TitlesMax) + { + EmitTitlesError(reqId, "too-large", $"this server holds at most {TitlesMax} titles, not {list.Count}"); + return; + } + + var next = new Dictionary(); + + foreach (Newtonsoft.Json.Linq.JToken item in list) + { + var row = item as Newtonsoft.Json.Linq.JObject; + string steamId = row != null ? JsonText(row["steamId"]) : null; + string text = row != null ? JsonText(row["text"]) : null; + ulong id; + + if (!ulong.TryParse(steamId, out id) || string.IsNullOrEmpty(text) || text.Length > TitleTextMax) + { + EmitTitlesError(reqId, "malformed", + $"every title names a Steam id and carries text of at most {TitleTextMax} characters"); + return; + } + + next[id] = text; + } + + _titles = next; + + // A BetterChat that arrived without us hearing it load is picked up here, which also + // makes a restart of either plugin heal on the site's next push. + if (!_titlesRegistered && BetterChatPlugin() != null) + RegisterTitles("title set"); + + bool shown = _titlesRegistered && BetterChatPlugin() != null; + _lastTitlesSummary = $"{next.Count} titles, betterChat={shown}, at={DateTime.UtcNow:HH:mm:ss}Z"; + + Dictionary reply = Frame("titles.ok", "reply"); + reply["count"] = next.Count; + reply["betterChat"] = shown; + + string setId = StringField(cmd, "setId"); + + if (!string.IsNullOrEmpty(setId)) + reply["setId"] = setId; + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + } + + private void EmitTitlesError(string reqId, string reason, string message) + { + Dictionary frame = Frame("titles.error", "reply"); + frame["reason"] = reason; + frame["message"] = message; + + if (reqId != null) + frame["reqId"] = reqId; + + _lastTitlesSummary = $"refused ({reason}): {message}"; + EmitFrame(frame); + } + + /// A JSON string or number as text, or null for anything else. + private static string JsonText(Newtonsoft.Json.Linq.JToken token) + { + if (token == null) + return null; + + switch (token.Type) + { + case Newtonsoft.Json.Linq.JTokenType.String: + return (string)token; + case Newtonsoft.Json.Linq.JTokenType.Integer: + return token.ToString(); + case Newtonsoft.Json.Linq.JTokenType.Boolean: + return (bool)token ? "true" : "false"; + default: + return null; + } + } + + /// + /// A chat line in a voice (D140): the site's format with the words put into it, turned + /// into the game's rich text. Said by us, with no player as its sender, so it works + /// whether or not BetterChat is loaded. + /// + private string Voice(string format, string message) + { + // BetterChat's own rule for a player's words (its GetOutput), for the same reason: the + // line is ours to style and the words inside it are not. + if (message.Contains("[#") || message.Contains("[+")) + message = message.Replace("[", string.Empty).Replace("]", string.Empty); + + return covalence.FormatText(format.Replace("{message}", message)); + } + + private static int Occurrences(string text, string needle) + { + int count = 0; + + for (int at = text.IndexOf(needle, StringComparison.Ordinal); at >= 0; at = text.IndexOf(needle, at + needle.Length, StringComparison.Ordinal)) + count++; + + return count; + } + + private Dictionary IntegrationsBody() + { + return new Dictionary + { + { "betterChat", IntegrationBody(BetterChatPlugin()) }, + { "popupNotifications", IntegrationBody(PopupPlugin()) } + }; + } + + /// A version only for a plugin that is loaded. Absent, not empty. + private static Dictionary IntegrationBody(Plugin plugin) + { + var body = new Dictionary { { "loaded", plugin != null } }; + + if (plugin != null) + body["version"] = plugin.Version.ToString(); + + return body; + } + + // ---- group styles (D138, D139, D143) ---- + + /// + /// The BetterChat fields a sync asks for on one group. A field this build does not + /// know, or a value that is not a string, number or boolean, is reported failed and + /// left out rather than guessed at. + /// + private List ChatStyle(PermSyncJob job, string group, Dictionary row) + { + var style = new List(); + object raw; + + if (!row.TryGetValue("chat", out raw)) + return style; + + var fields = raw as Newtonsoft.Json.Linq.JObject; + + if (fields == null) + return style; + + foreach (Newtonsoft.Json.Linq.JProperty field in fields.Properties()) + { + var want = field.Value as Newtonsoft.Json.Linq.JObject; + string value = want != null ? JsonText(want["value"]) : null; + + if (!ChatFieldDefaults.ContainsKey(field.Name) || value == null) + { + job.ChatFailed.Add(new Dictionary + { + { "group", group }, + { "field", field.Name }, + { "reason", "malformed" } + }); + continue; + } + + style.Add(new ChatFieldWant + { + Field = field.Name, + Value = value, + Expect = JsonText(want["expect"]) + }); + } + + return style; + } + + /// + /// Main thread. Puts the site's style on one BetterChat group, a field at a time, and + /// leaves alone any field somebody changed by hand. + /// + /// + /// A field is written when the game still holds what the site last pushed there, or + /// holds BetterChat's default and the site has never pushed it. Anything else is a hand + /// edit: it is not written, and it is reported with the game's value (D138, R2). A group + /// this sync had to create is written whole, because nobody can have edited it yet. + /// + /// + /// + /// The save is the part the API does not do. API_SetGroupField changes a + /// field in memory and never writes BetterChat's file, so on its next reload the style + /// would revert and read as drift. BetterChat's own chat group set does write + /// it, and writes every group, so it is run once per group touched with a value that + /// has not changed (D143). + /// + /// + private void ApplyChatStyle(PermSyncJob job, string group) + { + Plugin betterChat = BetterChatPlugin(); + + if (betterChat == null) + { + ChatFailure(job, group, null, "betterchat-unloaded"); + return; + } + + bool created = false; + + if (!ChatGroupExists(betterChat, group)) + { + // Saves on its own. + betterChat.Call("API_AddGroup", group); + + if (!ChatGroupExists(betterChat, group)) + { + ChatFailure(job, group, null, "not-created"); + return; + } + + created = true; + } + + Dictionary game = ChatGroupFields(betterChat, group); + bool touched = false; + + foreach (ChatFieldWant want in job.ChatStyles[group]) + { + string now = ChatFieldValue(game, want.Field); + + if (now == want.Value) + { + job.ChatSame++; + continue; + } + + bool ours = created || now == want.Expect || (want.Expect == null && now == ChatDefault(group, want.Field)); + + if (!ours) + { + job.ChatDrift.Add(new Dictionary + { + { "group", group }, + { "field", want.Field }, + { "game", now } + }); + continue; + } + + // The typed answer is why each field goes through the API (D143): `Success`, + // `InvalidField` or `InvalidValue`, as a BetterChat enum this plugin cannot name — + // so read by its name. Null is a group that is not there, or a value BetterChat's + // parser threw on without catching (a number too big for an int). + object result = betterChat.Call("API_SetGroupField", group, want.Field, want.Value); + string answer = result != null ? result.ToString() : null; + + if (answer != "Success") + { + ChatFailure(job, group, want.Field, answer ?? "no-answer"); + continue; + } + + job.ChatApplied++; + touched = true; + } + + if (!touched) + return; + + game = ChatGroupFields(betterChat, group); + string priority = ChatFieldValue(game, "Priority"); + + ConsoleSystem.Run(ConsoleSystem.Option.Server, "chat", "group", "set", group, "Priority", priority); + + // Read back rather than trusted: the command has no answer a plugin can read, and a + // console path that did something else would otherwise be counted as a save. + if (ChatFieldValue(ChatGroupFields(betterChat, group), "Priority") == priority) + job.ChatSaved++; + else + ChatFailure(job, group, "Priority", "not-saved"); + } + + /// + /// Main thread. Removes a BetterChat group the site no longer styles (D139). There is + /// no API for it, only BetterChat's own chat group remove, run as the server + /// console. A group already gone is removed: the retirement is idempotent. + /// + private void RemoveChatGroup(PermSyncJob job, string group) + { + Plugin betterChat = BetterChatPlugin(); + + if (betterChat == null) + { + ChatFailure(job, group, null, "betterchat-unloaded"); + return; + } + + if (ChatGroupExists(betterChat, group)) + ConsoleSystem.Run(ConsoleSystem.Option.Server, "chat", "group", "remove", group); + + if (ChatGroupExists(betterChat, group)) + ChatFailure(job, group, null, "still-present"); + else + job.ChatRemoved.Add(group); + } + + private static void ChatFailure(PermSyncJob job, string group, string field, string reason) + { + var row = new Dictionary { { "group", group }, { "reason", reason } }; + + if (field != null) + row["field"] = field; + + job.ChatFailed.Add(row); + } + + private static bool ChatGroupExists(Plugin betterChat, string group) + { + object exists = betterChat.Call("API_GroupExists", group); + return exists is bool && (bool)exists; + } + + private static Dictionary ChatGroupFields(Plugin betterChat, string group) + { + return betterChat.Call("API_GetGroupFields", group) as Dictionary + ?? new Dictionary(); + } + + /// + /// A field's value as the text the site compares: true/false for a + /// boolean, never C#'s True, and an invariant number. + /// + private static string ChatFieldValue(Dictionary fields, string field) + { + object value = null; + + foreach (KeyValuePair kv in fields) + { + if (string.Equals(kv.Key, field, StringComparison.OrdinalIgnoreCase)) + { + value = kv.Value; + break; + } + } + + if (value == null) + return null; + + if (value is bool) + return (bool)value ? "true" : "false"; + + if (value is IFormattable) + return ((IFormattable)value).ToString(null, System.Globalization.CultureInfo.InvariantCulture); + + return value.ToString(); + } + + /// + /// BetterChat's default for a field on a new group. A group's title defaults to its own + /// name in brackets, except default's, which is [Player]. + /// + private static string ChatDefault(string group, string field) + { + if (string.Equals(field, "Title", StringComparison.OrdinalIgnoreCase)) + return group == "default" ? "[Player]" : "[" + group + "]"; + + string value; + return ChatFieldDefaults.TryGetValue(field, out value) ? value : null; + } + + /// + /// The titles this server holds, from the server console. Given a Steam id it also + /// prints what BetterChat would render for that player — which is how a rig proves a + /// title without a person typing in chat (§33.3). + /// + [ConsoleCommand("rg.titles")] + private void CmdTitles(ConsoleSystem.Arg arg) + { + Plugin betterChat = BetterChatPlugin(); + + var lines = new List + { + $"protocol={ProtocolVersion} titles={_titles.Count} " + + $"betterChat={(betterChat != null ? betterChat.Version.ToString() : "absent")} " + + $"registered={(_titlesRegistered && betterChat != null)}", + $"last set: {_lastTitlesSummary}" + }; + + // `GetString`, not `Args[0]`: current Rust hands a console command StringViews. + string steamId = arg.GetString(0, null); + + if (!string.IsNullOrEmpty(steamId)) + { + ulong id; + string held; + + lines.Add(ulong.TryParse(steamId, out id) && _titles.TryGetValue(id, out held) + ? $"held: {held}" + : "held: (none)"); + + Oxide.Core.Libraries.Covalence.IPlayer known = covalence.Players.FindPlayerById(steamId); + + if (betterChat == null) + lines.Add("rendered: (BetterChat is not loaded)"); + else if (known == null) + lines.Add("rendered: (this server has never seen that player)"); + else + lines.Add("rendered: " + (betterChat.Call("API_GetFormattedMessage", known, "(rg.titles)", false) as string ?? "(no answer)")); + } + + arg.ReplyWith(string.Join(Environment.NewLine, lines.ToArray())); + } + /// What events are counting and what rewards are held, from the server console. [ConsoleCommand("rg.rewards")] private void CmdRewards(ConsoleSystem.Arg arg) -- 2.49.1 From 5ff88610a5c040ebb4b7f7b9c2f0c5aa19d5df8b Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 25 Sep 2026 18:23:28 -0500 Subject: [PATCH 17/18] fix(bridge): keep the chat titles across a reload (D144) The walk found that a hot reload of this plugin emptied every title, and the site never re-sent them: it re-sends on a new boot id or wipe id, and a reload is neither, because the boot id names the server process. The set is now written to titles.json with the wipe it was pushed on, and read at OnServerInitialized only when that wipe is still the current one. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- overlay/oxide/plugins/RunicGateway.cs | 77 +++++++++++++++++++++++++-- 1 file changed, 73 insertions(+), 4 deletions(-) diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index d9c8f63..3ffa50b 100644 --- a/overlay/oxide/plugins/RunicGateway.cs +++ b/overlay/oxide/plugins/RunicGateway.cs @@ -1372,8 +1372,10 @@ namespace Oxide.Plugins // The map's sets are seeded here for the same reason: a hotload hears no spawns. SeedMapSets(); - // Hand BetterChat the title getter, if it is here. When it loads later, or reloads and - // forgets us, OnPluginLoaded does this again (§33.1). + // 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 @@ -8459,8 +8461,15 @@ namespace Oxide.Plugins /// /// The chat titles players have earned on this server, by Steam id, already in /// BetterChat's markup. The website composes them from the wipe's standings and pushes - /// the whole set (titles.set); nothing here is persisted, because the site sends - /// it again on every boot it has not sent to. + /// the whole set (titles.set). + /// + /// + /// Kept on disk as well (D144). The site re-sends on a new boot id or wipe id, and + /// a hot reload of this plugin — an update, an oxide.reload — is neither: the boot + /// id names the server process. Without the file a reload empties every title until the + /// standings next move. A file from another wipe is not loaded, because titles rank the + /// wipe they were earned on. + /// /// /// /// Swapped, never edited. BetterChat reads this through on @@ -8598,6 +8607,7 @@ namespace Oxide.Plugins } _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. @@ -8622,6 +8632,65 @@ namespace Oxide.Plugins EmitFrame(reply); } + /// What titles.json holds: the set, and the wipe it was pushed on. + private sealed class TitleFile + { + public string WipeId; + public Dictionary Titles = new Dictionary(); + } + + private string TitlesPath() + { + return Path.Combine(Interface.Oxide.DataDirectory, Name, "titles.json"); + } + + /// Main thread. Written on every set the site pushes — a set arrives only when it changed. + private void SaveTitles() + { + var file = new TitleFile { WipeId = _wipeId }; + + foreach (KeyValuePair kv in _titles) + file.Titles[kv.Key.ToString()] = kv.Value; + + WriteDataFile(TitlesPath(), file, "titles"); + } + + /// + /// Main thread, from OnServerInitialized — the first moment the wipe id is real. + /// A file from another wipe, or with no wipe, is left unread: the site sends this wipe's. + /// + private void LoadTitles() + { + try + { + string path = TitlesPath(); + + if (!File.Exists(path)) + return; + + var file = JsonConvert.DeserializeObject(File.ReadAllText(path)); + + if (file == null || file.Titles == null || string.IsNullOrEmpty(_wipeId) || file.WipeId != _wipeId) + return; + + var loaded = new Dictionary(); + + foreach (KeyValuePair kv in file.Titles) + { + ulong id; + if (ulong.TryParse(kv.Key, out id) && !string.IsNullOrEmpty(kv.Value)) + loaded[id] = kv.Value; + } + + _titles = loaded; + _lastTitlesSummary = $"{loaded.Count} titles from disk, at={DateTime.UtcNow:HH:mm:ss}Z"; + } + catch (Exception ex) + { + Puts($"could not read the titles: {Flatten(ex.Message)}"); + } + } + private void EmitTitlesError(string reqId, string reason, string message) { Dictionary frame = Frame("titles.error", "reply"); -- 2.49.1 From 497b7c4f64294aecf7a001653720008441ad5cbc Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 25 Sep 2026 22:54:38 -0500 Subject: [PATCH 18/18] feat(bridge): server id from the environment, and the first release workflow (phase 18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Module-rust phase 18, step 3 of docs/modules/rust/PLAN.md §34.2.7. The environment seam (D150): when the plugin writes its default config and RUSTLINK_SERVER_ID holds a valid id (the site's ^[a-z0-9][a-z0-9-]{0,63}$), the file's ServerId is that value. It is read nowhere else: after that write the file is canonical and the site's lock applies. An invalid value is logged and ignored. This is how the egg names a server; not a message change, so the protocol stays at 12. The release (D145, §34.2.1): release.yml reuses servuo-plugins' release engine unchanged (tag-only, secrets preflighted, orphan tags recovered and swept, the installer's bundle.yml dispatched at the end). The adapter re-runs checkPlugin.js and packages runicgateway-rust-plugin-.tar.gz with a framework-neutral layout: RunicGateway.cs, its [Info] version stamped to the release, and a manifest.json folding in overlay.toml. overlay.toml gains min_carbon_version = "2.0.259", the build R19 was proven on. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- .gitea/workflows/release.yml | 516 ++++++++++++++++++++++++++ README.md | 22 +- overlay.toml | 11 + overlay/oxide/plugins/RunicGateway.cs | 40 +- 4 files changed, 586 insertions(+), 3 deletions(-) create mode 100644 .gitea/workflows/release.yml diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..14c8eb9 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,516 @@ +# Automated release for the Rust bridge plugin. +# +# Trigger: every push to `main` (i.e. every merged PR — in practice the +# edge→main cutover), and by hand. +# +# Why this exists: the Runic Gateway installer (`--game rust`) and the +# Pterodactyl egg both deploy the plugin from a release tarball, never from git +# — a game host gets no git and no Gitea credentials. Until module-rust phase 18 +# (docs/modules/rust/PLAN.md §34.2.1, D145) this repository had never released, +# so there was nothing for either to fetch. +# +# Flow — the same two halves as servuo-plugins' release.yml, whose release +# engine is reused here unchanged: +# +# ┌── RELEASE ENGINE (language-agnostic) ─────────────────────────────┐ +# │ reads: latest v* git tag + conventional-commit subjects │ +# │ produces: next version, changelog, and (at the end) the release │ +# └───────────────────────────────────────────────────────────────────┘ +# ┌── PLUGIN ADAPTER (the only repo-specific part) ───────────────────┐ +# │ consumes: the version │ +# │ produces: runicgateway-rust-plugin-.tar.gz + SHA256SUMS │ +# └───────────────────────────────────────────────────────────────────┘ +# +# ── What differs from servuo-plugins' copy ─────────────────────────────────── +# +# 1. NO BUILD, for the same reason: the plugin ships as C# source and Oxide or +# Carbon compiles it against assemblies that exist only on a Rust server. +# The gates are the static checks PR Checks already runs, re-run on the +# exact commit being released. +# +# 2. A FRAMEWORK-NEUTRAL LAYOUT. The tarball holds one directory, +# `runicgateway-rust-plugin/`, with `RunicGateway.cs` and `manifest.json` at +# its top. It does NOT mirror `oxide/plugins/`: the same file goes to +# `oxide/plugins/` or `carbon/plugins/`, and the installer and the egg each +# decide which (§34.2.1). The fixed, unversioned prefix is so a reader never +# has to parse the version out of a path to find the manifest that states it. +# +# 3. THE VERSION IS STAMPED into the shipped copy's `[Info(…)]` attribute, so +# `oxide.plugins` / `c.plugins` on a server names the release it runs. The +# committed file keeps its placeholder; the manifest records the commit. +# +# The version is never the protocol. The protocol lives in overlay.toml and +# moves only when a message shape does; the version moves on every releasable +# commit. +# +# Version bump (conventional commits since the last v* tag): +# feat!: / BREAKING CHANGE -> major feat: -> minor fix|perf: -> patch +# nothing releasable -> no release is cut +# (first ever run, no tag) -> releases SEED_VERSION below (§34.4: the first +# release takes what the engine derives) +# +# Prerequisites (Settings → Actions → Secrets on RunicGateway/Rust-Plugins): +# REGISTRY_TOKEN — Gitea access token with `write:repository`, to push the +# tag and create the release. The final step also dispatches +# RunicGateway/installer's bundle workflow, so the token +# ideally has write there too — without it the step warns +# and that repo's nightly cron picks the release up instead. +# REGISTRY_USER — the Gitea username that token belongs to. + +name: Release plugin + +on: + push: + branches: [main] + workflow_dispatch: {} + +concurrency: + group: release-plugin + cancel-in-progress: false + +env: + GITEA_HOST: gitea.whitlocktech.com + REPO: RunicGateway/Rust-Plugins + ARTIFACT: runicgateway-rust-plugin + PLUGIN: overlay/oxide/plugins/RunicGateway.cs + SEED_VERSION: "0.1.0" + INSTALLER_REPO: RunicGateway/installer + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Check out full history (need tags + commit log for the bump) + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # ── RELEASE ENGINE: decide the next version + changelog ────────────── + - name: Plan the release (version + changelog) + id: plan + env: + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + set -euo pipefail + mkdir -p dist + git fetch --tags --force >/dev/null 2>&1 || true + + LAST_TAG="$(git describe --tags --match 'v*' --abbrev=0 2>/dev/null || true)" + if [ -n "$LAST_TAG" ]; then RANGE="${LAST_TAG}..HEAD"; else RANGE="HEAD"; fi + + SUBJECTS="$(git log --no-merges --format='%s' $RANGE || true)" + BODIES="$(git log --no-merges --format='%B' $RANGE || true)" + + BUMP=none + if echo "$BODIES" | grep -qE 'BREAKING[ -]CHANGE' ; then BUMP=major; fi + if echo "$SUBJECTS" | grep -qE '^[a-z]+(\([^)]+\))?!:' ; then BUMP=major; fi + if [ "$BUMP" = none ] && echo "$SUBJECTS" | grep -qE '^feat(\([^)]+\))?:' ; then BUMP=minor; fi + if [ "$BUMP" = none ] && echo "$SUBJECTS" | grep -qE '^(fix|perf)(\([^)]+\))?:'; then BUMP=patch; fi + + bump() { # -> bumped + IFS=. read -r MA MI PA <<< "$1" + case "$2" in + major) echo "$((MA+1)).0.0" ;; + minor) echo "${MA}.$((MI+1)).0" ;; + patch) echo "${MA}.${MI}.$((PA+1))" ;; + esac + } + + RELEASE=true + if [ -z "$LAST_TAG" ]; then + VERSION="$SEED_VERSION" # first release: seed + elif [ "$BUMP" = none ]; then + RELEASE=false # no feat/fix/breaking since last tag + VERSION="${LAST_TAG#v}" + else + VERSION="$(bump "${LAST_TAG#v}" "$BUMP")" + fi + + # An existing tag is NOT automatically "nothing to do". A tag with no + # release behind it means a previous run tagged and then died before + # publishing — which is exactly what happened on servuo-plugins' first + # run, when missing REGISTRY_* secrets took the release API call to 401 + # after the tag had already been pushed. Standing down on the tag alone + # would make that state permanent: every later run would see the tag, + # set RELEASE=false, and the release would never appear. So distinguish + # the two cases and finish the job the earlier run started. + # Note this OVERRIDES the RELEASE=false decided just above. With the tag + # already in place there are no releasable commits after it, so the + # normal path stands down — which is precisely why the stuck state + # could never clear itself. Recovery has to be able to say "yes, + # publish" for a version the bump logic considers already done. + REUSE_TAG=false + if git rev-parse -q --verify "refs/tags/v${VERSION}" >/dev/null; then + REL_HTTP="$(curl -s -o /dev/null -w '%{http_code}' \ + -H "Authorization: token $(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')" \ + "https://${GITEA_HOST}/api/v1/repos/${REPO}/releases/tags/v${VERSION}" || echo 000)" + if [ "$REL_HTTP" = "200" ]; then + echo "Tag v${VERSION} already has a release — nothing to do." + RELEASE=false + elif [ "$REL_HTTP" = "404" ]; then + echo "::warning::Tag v${VERSION} exists but has no release — a previous run failed after tagging. Reusing the tag and publishing the release it is missing." + REUSE_TAG=true + RELEASE=true + else + # Anything else (000 from a network failure, 401/403 from a bad + # token) is not evidence of absence. Guessing "no release" here + # would re-publish over a good one, so refuse instead. + echo "::error::Could not determine whether a release exists for v${VERSION} (HTTP ${REL_HTTP}). Refusing to guess." + exit 1 + fi + fi + + # ── Orphan sweep ──────────────────────────────────────────────── + # + # The check above is VERSION-SCOPED: it only ever asks about the one + # version this run computed. That is enough to recover an orphan on + # the very next run, and useless afterwards — once any releasable + # commit lands, the next run computes a NEW version, never looks at + # the old tag again, and the orphan becomes permanent and silent. + # + # servuo-plugins v0.1.0 is the proof: the commit that ADDED the + # recovery above was itself a `fix:`, so it bumped to v0.1.1 and the + # run that introduced the recovery stepped straight past the tag it + # was written to rescue. + # + # So every v* tag is checked, and anything missing a release is + # WARNED about. Deliberately not recovered: publishing an old version + # would mean building today's tree and shipping it under a tag whose + # tree it is not, which is worse than the inconsistency it fixes. + # A human decides whether to recover or drop it. + # + # Never fails the run. A sweep that can break a good release is a + # sweep someone will delete. + ORPHANS="" + for T in $(git tag -l 'v*' --sort=-v:refname); do + T_HTTP="$(curl -s -o /dev/null -w '%{http_code}' \ + -H "Authorization: token $(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')" \ + "https://${GITEA_HOST}/api/v1/repos/${REPO}/releases/tags/${T}" || echo 000)" + [ "$T_HTTP" = "404" ] && ORPHANS="${ORPHANS} ${T}" + done + if [ -n "${ORPHANS}" ]; then + echo "::warning::Tags with no release:${ORPHANS} — a run failed after tagging. Publish or delete them; this job will not do either." + fi + + # Changelog range. A recovery run has nothing after the tag, so + # summarize what the tag itself contains rather than emitting an empty + # list: the range that produced it, i.e. previous-tag..this-tag. + if [ "$REUSE_TAG" = true ]; then + PREV_TAG="$(git describe --tags --match 'v*' --abbrev=0 "v${VERSION}^" 2>/dev/null || true)" + if [ -n "$PREV_TAG" ]; then CL_RANGE="${PREV_TAG}..v${VERSION}"; else CL_RANGE="v${VERSION}"; fi + SINCE="$PREV_TAG" + else + CL_RANGE="$RANGE" + SINCE="$LAST_TAG" + fi + CL_SUBJECTS="$(git log --no-merges --format='%s' $CL_RANGE || true)" + + { + echo "## ${ARTIFACT} v${VERSION}" + echo + FEATS="$(echo "$CL_SUBJECTS" | grep -E '^feat' || true)" + FIXES="$(echo "$CL_SUBJECTS" | grep -E '^(fix|perf)' || true)" + [ -n "$FEATS" ] && { echo "### Features"; echo "$FEATS" | sed 's/^/- /'; echo; } + [ -n "$FIXES" ] && { echo "### Fixes"; echo "$FIXES" | sed 's/^/- /'; echo; } + echo "### All changes" + if [ -n "$SINCE" ]; then echo "Since ${SINCE}:"; fi + echo "$CL_SUBJECTS" | sed 's/^/- /' + } > dist/CHANGELOG.md + + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT" + echo "release=${RELEASE}" >> "$GITHUB_OUTPUT" + echo "bump=${BUMP}" >> "$GITHUB_OUTPUT" + echo "reuse_tag=${REUSE_TAG}" >> "$GITHUB_OUTPUT" + echo "==> release=${RELEASE} version=${VERSION} bump=${BUMP} reuse_tag=${REUSE_TAG} last_tag=${LAST_TAG:-}" + + # ── Credential preflight ───────────────────────────────────────────── + # Runs BEFORE anything is built or pushed, and only when this run intends + # to publish, so a docs:/chore:-only merge stays green on a repo that has + # no secrets. + # + # This exists because of how servuo-plugins' first run failed. REGISTRY_USER and + # REGISTRY_TOKEN were empty, but the tag push SUCCEEDED anyway: + # actions/checkout leaves an `http..extraheader` credential in the + # local git config, so `git remote set-url` to a URL with empty + # credentials still authenticated through that leftover header. The + # release API call had no such fallback and returned 401 — so the run + # tagged the repo and then failed, which is the worst of both outcomes. + # Checking the secrets up front turns that into an immediate, legible + # failure instead of a half-published release. + - name: Verify release credentials are configured + if: ${{ steps.plan.outputs.release == 'true' }} + env: + REGISTRY_USER: ${{ secrets.REGISTRY_USER }} + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + set -euo pipefail + MISSING="" + [ -n "$(printf '%s' "${REGISTRY_USER:-}" | tr -d '\r\n')" ] || MISSING="${MISSING} REGISTRY_USER" + [ -n "$(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')" ] || MISSING="${MISSING} REGISTRY_TOKEN" + if [ -n "$MISSING" ]; then + echo "::error::Missing Actions secret(s):${MISSING}. Set them under Settings → Actions → Secrets on ${REPO}. REGISTRY_TOKEN needs the write:repository scope to push the tag and create the release." + exit 1 + fi + echo "Release credentials present." + + - name: Install jq + if: ${{ steps.plan.outputs.release == 'true' }} + run: | + set -euo pipefail + command -v jq >/dev/null 2>&1 && exit 0 + SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo" + $SUDO apt-get update -qq + $SUDO apt-get install -y -qq --no-install-recommends jq + + # ── PLUGIN ADAPTER: gates ──────────────────────────────────────────── + # No compiler exists for this plugin outside a Rust server, so the gates + # are the ones PR Checks runs, re-run on the exact commit being released: + # every hook listed in ExpectedHooks, every hook void, and ProtocolVersion + # equal to overlay.toml. A cutover merge commit is a tree no PR check ran + # on as such, so it is worth asking again. + - uses: actions/setup-node@v4 + if: ${{ steps.plan.outputs.release == 'true' }} + with: + node-version: 20 + + - name: Validate the plugin + if: ${{ steps.plan.outputs.release == 'true' }} + run: | + set -euo pipefail + [ -f "$PLUGIN" ] || { echo "::error::${PLUGIN} is missing"; exit 1; } + [ -f overlay.toml ] || { echo "::error::overlay.toml is missing (protocol + framework declarations)"; exit 1; } + node scripts/checkPlugin.js + # The stamp below rewrites exactly one [Info(...)] line; zero or two + # would ship a plugin whose reported version is a lie. + COUNT="$(grep -cE '^[[:space:]]*\[Info\("Runic Gateway", "RunicGateway", "[^"]*"\)\]' "$PLUGIN" || true)" + [ "$COUNT" = 1 ] || { echo "::error::expected exactly one [Info(\"Runic Gateway\", \"RunicGateway\", ...)] attribute in ${PLUGIN}, found ${COUNT}"; exit 1; } + + # ── PLUGIN ADAPTER: stage, manifest, package ───────────────────────── + # tar flags pin ownership, mtime and member order so the same tree produces + # a byte-identical tarball — a checksum that changes only when content + # changes is worth more than one that changes every run. + - name: Build manifest.json and the release tarball + id: package + if: ${{ steps.plan.outputs.release == 'true' }} + run: | + set -euo pipefail + VERSION="${{ steps.plan.outputs.version }}" + STAGE="dist/stage/${ARTIFACT}" + mkdir -p "${STAGE}" + + sed -E 's/^([[:space:]]*\[Info\("Runic Gateway", "RunicGateway", ")[^"]*("\)\])/\1'"${VERSION}"'\2/' \ + "$PLUGIN" > "${STAGE}/RunicGateway.cs" + grep -qF "[Info(\"Runic Gateway\", \"RunicGateway\", \"${VERSION}\")]" "${STAGE}/RunicGateway.cs" \ + || { echo "::error::stamping the version into [Info(...)] did not take"; exit 1; } + + # Declarations from overlay.toml. Read, don't hardcode — the point of + # that file is that the protocol number lives in one place. + toml_str() { grep -m1 -E "^$1[[:space:]]*=" overlay.toml | sed -E 's/.*"([^"]+)".*/\1/'; } + PROTOCOL="$(grep -m1 -E '^protocol[[:space:]]*=' overlay.toml | sed -E 's/[^0-9]//g')" + MIN_OXIDE="$(toml_str min_oxide_version)" + MIN_CARBON="$(toml_str min_carbon_version)" + # requires_plugins = ["Kits", "ZoneManager"] is already a JSON array. + # One line, quoted strings only; anything else fails the check below. + REQUIRES="$(grep -m1 -E '^requires_plugins[[:space:]]*=' overlay.toml | sed -E 's/^[^=]*=[[:space:]]*//')" + [ -n "$PROTOCOL" ] || { echo "::error::could not read protocol from overlay.toml"; exit 1; } + [ -n "$MIN_OXIDE" ] || { echo "::error::could not read min_oxide_version from overlay.toml"; exit 1; } + [ -n "$MIN_CARBON" ] || { echo "::error::could not read min_carbon_version from overlay.toml"; exit 1; } + jq -e 'type == "array" and all(type == "string")' <<<"$REQUIRES" >/dev/null \ + || { echo "::error::requires_plugins in overlay.toml is not a one-line array of strings"; exit 1; } + echo "==> protocol=${PROTOCOL} oxide>=${MIN_OXIDE} carbon>=${MIN_CARBON} requires=${REQUIRES}" + + jq -n \ + --arg component "rust-plugin" \ + --arg version "${VERSION}" \ + --arg commit "${GITHUB_SHA}" \ + --arg repo "${REPO}" \ + --argjson protocol "${PROTOCOL}" \ + --arg min_oxide "${MIN_OXIDE}" \ + --arg min_carbon "${MIN_CARBON}" \ + --argjson requires "${REQUIRES}" \ + --arg sha "$(sha256sum "${STAGE}/RunicGateway.cs" | cut -d' ' -f1)" \ + '{ + component: $component, + version: $version, + commit: $commit, + repo: $repo, + protocol: $protocol, + min_oxide_version: $min_oxide, + min_carbon_version: $min_carbon, + requires_plugins: $requires, + files: { "RunicGateway.cs": $sha } + }' > "${STAGE}/manifest.json" + echo "----- manifest.json -----" + cat "${STAGE}/manifest.json" + + TARBALL="${ARTIFACT}-${VERSION}.tar.gz" + tar --sort=name --mtime='UTC 1970-01-01' \ + --owner=0 --group=0 --numeric-owner \ + -czf "dist/${TARBALL}" -C dist/stage "${ARTIFACT}" + + ( cd dist && sha256sum "${TARBALL}" > SHA256SUMS ) + echo "tarball=${TARBALL}" >> "$GITHUB_OUTPUT" + ls -l dist && echo "----" && cat dist/SHA256SUMS + + # ── RELEASE ENGINE: tag ────────────────────────────────────────────── + # Tag only — no bump commit, so `main` is never pushed to (see header). + - name: Push the release tag + if: ${{ steps.plan.outputs.release == 'true' }} + env: + REGISTRY_USER: ${{ secrets.REGISTRY_USER }} + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + set -euo pipefail + TAG="${{ steps.plan.outputs.tag }}" + # Secrets can arrive with a trailing newline (depending on how they were + # pasted); a stray CR/LF corrupts the remote URL ("credential url cannot + # be parsed"). Strip line breaks before building the URL. + CI_USER="$(printf '%s' "${REGISTRY_USER}" | tr -d '\r\n')" + CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')" + git config user.name "rust-plugins-ci" + git config user.email "ci@whitlocktech.com" + git remote set-url origin \ + "https://${CI_USER}:${CI_TOKEN}@${GITEA_HOST}/${REPO}.git" + + # The tag may already exist when we are finishing a run that died after + # tagging (see the plan step). `git tag` on an existing name fails under + # `set -e`, and pushing an identical existing tag is a harmless no-op — + # so create it only if it is new, then push either way. A push that + # fails here means the remote tag points somewhere else, which SHOULD + # stop the run. + if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then + echo "Tag ${TAG} already exists — reusing it." + else + git tag "${TAG}" + fi + git push origin "${TAG}" + + # ── RELEASE ENGINE: create the Gitea release + upload assets ───────── + - name: Create Gitea release and upload assets + if: ${{ steps.plan.outputs.release == 'true' }} + env: + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + set -euo pipefail + TAG="${{ steps.plan.outputs.tag }}" + TARBALL="${{ steps.package.outputs.tarball }}" + API="https://${GITEA_HOST}/api/v1/repos/${REPO}" + BODY="$(cat dist/CHANGELOG.md)" + # Same newline hygiene as the tag step: a stray CR/LF in the token would + # corrupt the Authorization header. + CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')" + + PAYLOAD="$(jq -n --arg tag "$TAG" --arg body "$BODY" \ + '{tag_name:$tag, name:$tag, body:$body, draft:false, prerelease:false}')" + + # installer#22's release run failed exactly here: it landed one second + # after the tag push and Gitea answered 500, having not finished + # processing the pushed tag. Re-running published the same artifacts + # untouched, so it was a race, not a bad request — but the tag sat + # orphaned until a human noticed. + # + # Two things made that worse than it needed to be. + # + # 1. `curl -sSf` prints NO response body on an error status, so all the + # log carried was "curl: (22) ... error: 500" and the cause had to be + # inferred from timestamps. Capture the body and print it. + # 2. Nothing retried, so a transient 5xx became a permanent orphan. + # + # 4xx is deliberately NOT retried: a bad token or a malformed body does + # not improve by being sent again, and retrying only turns a clear + # failure into a slow one. + REL_ID="" + for attempt in 1 2 3 4 5; do + HTTP="$(curl -s -o /tmp/rel.json -w '%{http_code}' -X POST "${API}/releases" \ + -H "Authorization: token ${CI_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "${PAYLOAD}" || echo 000)" + + if [ "$HTTP" = "201" ] || [ "$HTTP" = "200" ]; then + REL_ID="$(jq -r '.id' /tmp/rel.json)" + break + fi + + echo "::warning::POST /releases attempt ${attempt} returned HTTP ${HTTP}" + echo "--- response body ---" + cat /tmp/rel.json || true + echo + echo "---------------------" + + case "$HTTP" in + 4*) echo "::error::HTTP ${HTTP} is a client error - not retrying."; exit 1 ;; + esac + + if [ "$attempt" = 5 ]; then + echo "::error::POST /releases still failing after 5 attempts. Tag ${TAG} is pushed but has no release." + echo "::error::Re-run this workflow - the plan step detects the orphan tag and republishes it." + exit 1 + fi + sleep $(( attempt * 5 )) + done + + if [ -z "$REL_ID" ] || [ "$REL_ID" = "null" ]; then + echo "::error::Release created but no id came back; refusing to upload assets blind." + exit 1 + fi + echo "Created release ${TAG} (id=${REL_ID})" + + for f in "${TARBALL}" SHA256SUMS; do + # Same treatment. An upload that fails quietly leaves a release whose + # SHA256SUMS does not cover every artifact it advertises, which is + # worse than no release at all -- that file is the trust anchor. + HTTP="$(curl -s -o /tmp/asset.json -w '%{http_code}' -X POST "${API}/releases/${REL_ID}/assets?name=${f}" \ + -H "Authorization: token ${CI_TOKEN}" \ + -F "attachment=@dist/${f}" || echo 000)" + if [ "$HTTP" != "201" ] && [ "$HTTP" != "200" ]; then + echo "::error::uploading ${f} returned HTTP ${HTTP}" + cat /tmp/asset.json || true + exit 1 + fi + echo " uploaded ${f}" + done + + # ── Recompose the installer's bundle manifest ──────────────────────── + # Neither the installer nor the egg resolves "latest" at run time — both + # deploy the exact plugin named by a published bundle + # (docs/modules/rust/PLAN.md §34.2.2). A plugin release that nobody + # recomposes around is therefore a release no operator will ever be + # offered. This tells the installer repo to + # rebuild that manifest now rather than leaving the new version invisible + # until its nightly cron. + # + # That job re-reads this tarball's manifest.json and checks its declared + # `protocol` against Rust-Link's PROTOCOL_VERSION before publishing + # anything (gate 1) — the check this repo cannot perform for itself, and + # the one that matters most here: the game link has no 409, so a + # mismatched plugin would mis-parse rather than be refused. + # + # DISPATCH, DON'T WAIT (PLAN.md §7.3). Gitea's workflow-dispatch endpoint + # returns no run handle, so there is nothing to poll: a waiting step would + # have to guess which run is its own and hold a runner idle to do it. + # + # A failure here is a WARNING, never a failure of this job. The release is + # already published and correct by this point, and failing the run would + # misreport that. The installer's nightly cron recomposes from whatever the + # latest releases actually are, so a dropped dispatch costs latency, not + # correctness. + - name: Ask the installer repo to recompose its bundle + if: ${{ steps.plan.outputs.release == 'true' }} + env: + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + set -euo pipefail + CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')" + HTTP="$(curl -s -o /dev/null -w '%{http_code}' -X POST \ + -H "Authorization: token ${CI_TOKEN}" \ + -H "Content-Type: application/json" \ + -d '{"ref":"main"}' \ + "https://${GITEA_HOST}/api/v1/repos/${INSTALLER_REPO}/actions/workflows/bundle.yml/dispatches" || echo 000)" + case "$HTTP" in + 20*) echo "Dispatched ${INSTALLER_REPO} bundle.yml (HTTP ${HTTP}) — not waiting for it." ;; + 403|404) + echo "::warning::Could not dispatch ${INSTALLER_REPO} bundle.yml (HTTP ${HTTP}). REGISTRY_TOKEN likely lacks write:repository on that repo. Release ${{ steps.plan.outputs.tag }} is published and fine; its bundle will be composed by the installer's nightly cron instead." ;; + *) + echo "::warning::Dispatching ${INSTALLER_REPO} bundle.yml returned HTTP ${HTTP}. Release ${{ steps.plan.outputs.tag }} is published and fine; the nightly cron will recompose the bundle." ;; + esac diff --git a/README.md b/README.md index 16da40f..bdb5b7f 100644 --- a/README.md +++ b/README.md @@ -52,9 +52,27 @@ Oxide compiles and loads it on the write, and writes `oxide/config/RunicGateway. is deliberately **not** derived from the hostname: an operator renames a server for a season, and the site must not lose its history for it. +**When that first config is written**, and only then, a valid `RUSTLINK_SERVER_ID` in the server's +environment becomes its `ServerId` (an invalid one is logged and ignored). That is how the +Pterodactyl egg names a server, since an egg can set a variable but cannot reliably write a +framework's config directory. After that the file is canonical and the website locks the value, so +changing the variable later changes nothing. + +On Carbon the same file goes to `carbon/plugins/` and its config to `carbon/configs/`. + That is the developer's loop. An operator uses the -[installer](https://gitea.whitlocktech.com/RunicGateway/installer), which syncs the released overlay -tarball and installs the sidecar alongside it. +[installer](https://gitea.whitlocktech.com/RunicGateway/installer) (`--game rust`) or the Pterodactyl +egg, both of which deploy a **released** plugin and install the sidecar beside it. + +### Releases + +Every merge to `main` that carries a `feat`, `fix` or `perf` commit cuts a release +(`.gitea/workflows/release.yml`): `runicgateway-rust-plugin-.tar.gz` and `SHA256SUMS`. The +tarball holds one directory, `runicgateway-rust-plugin/`, with `RunicGateway.cs` (its `[Info]` +version stamped to the release) and a `manifest.json` folding in `overlay.toml`: the protocol, the +Oxide and Carbon floors, and `requires_plugins`. The layout names no framework; the installer and +the egg decide where the file goes. A release then asks the installer repo to recompose its Rust +bundle. ### Prerequisites diff --git a/overlay.toml b/overlay.toml index 6193f2f..2ba4501 100644 --- a/overlay.toml +++ b/overlay.toml @@ -46,6 +46,17 @@ protocol = 12 # simpler than the ServUO one. min_oxide_version = "2.0.7585" +# ── Carbon compatibility ───────────────────────────────────────────────────── +# +# The same file runs unchanged on Carbon (docs/modules/rust/PLAN.md R19); the +# installer and the egg decide whether it lands in `oxide/plugins/` or +# `carbon/plugins/`. This is the build R19 was proven on (PLAN.md §34.4). +# +# Neither floor is measured from a DLL by `doctor`: a plugin that compiled and +# connected on this framework is the fact a floor only approximates, so a +# connected plugin is the pass, and the floors are printed with any failure. +min_carbon_version = "2.0.259" + # The `oxide/plugins/` files this overlay expects to find already installed. They # are not shipped here — they are third-party plugins an operator installs from # uMod — and the installer's `doctor` reports a missing one rather than diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index 3ffa50b..d16d02e 100644 --- a/overlay/oxide/plugins/RunicGateway.cs +++ b/overlay/oxide/plugins/RunicGateway.cs @@ -413,9 +413,47 @@ namespace Oxide.Plugins public int MapMaxBases = MapDefaultMax; } + /// + /// Runs only when this plugin has no config file yet — so it is the one moment a server id + /// can arrive from outside the file (D150). A Pterodactyl egg cannot write a framework's + /// config directory (Carbon lets an operator move it), but it can set an environment + /// variable, and `RUSTLINK_SERVER_ID` is the one the sidecar already reads for its + /// cross-check. After this write the file is canonical and the website's lock applies, + /// so changing the variable later changes nothing — which is what keeps a server's + /// history attached to one id. + /// protected override void LoadDefaultConfig() { - Config.WriteObject(new PluginConfig(), true); + var config = new PluginConfig(); + + string fromEnv = Environment.GetEnvironmentVariable("RUSTLINK_SERVER_ID"); + if (!string.IsNullOrEmpty(fromEnv)) + { + if (IsValidServerId(fromEnv)) + config.ServerId = fromEnv; + else + PrintWarning($"RUSTLINK_SERVER_ID '{fromEnv}' is not a valid server id " + + $"(lowercase letters, digits and '-', 1-64, not starting with '-'); " + + $"the new config keeps '{config.ServerId}'"); + } + + Config.WriteObject(config, true); + } + + /// The website's rule for a server id, `^[a-z0-9][a-z0-9-]{0,63}$`, without a Regex. + private static bool IsValidServerId(string id) + { + if (string.IsNullOrEmpty(id) || id.Length > 64 || id[0] == '-') + return false; + + foreach (char c in id) + { + bool ok = (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-'; + if (!ok) + return false; + } + + return true; } private void LoadConfigValues() -- 2.49.1