From b52b8a8f807af4932aca6f563d6f07f6056dfa7a Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 16 Oct 2026 02:30:59 -0500 Subject: [PATCH 1/2] =?UTF-8?q?feat(bridge):=20console=20commands=20as=20e?= =?UTF-8?q?vent=20steps=20=E2=80=94=20command.run=20(PLAN=5FREDESIGNS=20?= =?UTF-8?q?=C2=A716)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `command.run` frame (protocol 14, PROTOCOL.md §20.11) runs the console commands a step's author typed: once, or once per online recipient with {steamid} and {name} filled in (the name quoted the way the game's own BuildCommand quotes an argument). D382-D384: no allowlist; offline recipients are skipped and named. - At most once per key: the key is written to commands-run.json before anything runs; a repeat answers from the record, a repeat while running joins the running job, a record a reload interrupted answers `interrupted`. Kept 7 days, at most 500 records. - Spread over frames: whole recipients per frame, up to 30 commands or 8 ms. - unknown-command: ConsoleSystem.Run returns null both for an unknown command and for one that replies nothing, so the run listens to Unity's log for the game's own "Command '...' not found" line while each command runs (spiked on Oxide 2.0.7830 and Carbon 2.0.262, Rust 25902425). The same capture is the reply when Run returns nothing. - rg.world shows commandKeys and commandsRunning. Compiled on both rigs and driven through a throwaway plugin (repeat key, unknown command, offline recipients, malformed requests); rigs restored to the protocol-13 bridge. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- README.md | 9 + overlay/oxide/plugins/RunicGateway.cs | 594 +++++++++++++++++++++++++- 2 files changed, 602 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 12533ec..d7e5982 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,15 @@ nivex's `ZLevels Remastered` is optional, and the bridge only says whether it is never read or sent. Its XP boosts are its own permissions (`zlevelsremastered.vip1` to `vip3`), so an event lends one as it lends any other permission, and it takes effect at once. +An event can also run console commands an admin typed (`command.run`): once, as a step, or once for each +recipient of a reward who is online, with `{steamid}` and `{name}` filled in (the name as one quoted +argument). That is how a plugin with no support of its own here is used from an event, for example +RaidableBases' `rbe`. There is no allowlist: the site's switchboard is the switch, and `EventsEnabled` +here is the server's. Each request runs at most once: its key is written to +`data/RunicGateway/commands-run.json` before anything runs, and is kept for seven days. A command the +server does not know stops the request there as `unknown-command`. On a framework with +`consolePlugins=False`, that is every plugin's command. + Rust's own world events need no plugin at all. The patrol helicopter, Bradley, the cargo ship, the Chinook, airdrops, locked crates, the Travelling Vendor, the satellite and the Deep Sea reach the site's feed and its event triggers on every server, and an event can call one of them (`world.call`). diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index e40a715..0241808 100644 --- a/overlay/oxide/plugins/RunicGateway.cs +++ b/overlay/oxide/plugins/RunicGateway.cs @@ -78,7 +78,9 @@ namespace Oxide.Plugins /// §14.3.5): gc.started, gc.ended and gc.failed, gc.place, /// map.live's guarded rows, the tally's guardedWon and /// integrations.guardedCrate with NpcSpawn beside it; and ZLevels Remastered - /// (§20.10, PLAN_REDESIGNS.md §14.3.6): integrations.zLevels, and nothing more. + /// (§20.10, PLAN_REDESIGNS.md §14.3.6): integrations.zLevels, and nothing more; + /// and console commands as event steps (§20.11, PLAN_REDESIGNS.md §16): + /// command.run, answered command.ran or command.error. /// /// private const int ProtocolVersion = 14; @@ -584,6 +586,7 @@ namespace Oxide.Plugins LoadWorldCalls(); LoadDtStarts(); LoadGcPlacements(); + LoadCommandRecords(); } // ---- the outbound queue ---- @@ -1094,6 +1097,12 @@ namespace Oxide.Plugins HandleGcPlace(cmd, reqId); break; + case "command.run": + // Protocol 14 (PROTOCOL.md §20.11, D382-D384). Console commands a step's author + // typed: once, or once per online recipient; at most once per key. + HandleCommandRun(cmd, reqId); + break; + case "tally.open": // Protocol 10 (PLAN.md §29). Who takes part in a run, counted by the game. HandleTallyOpen(cmd, reqId); @@ -11428,6 +11437,7 @@ namespace Oxide.Plugins $"gcEvents={(GuardedCrateEventNames() != null ? string.Join("|", GuardedCrateEventNames().ToArray()) : "none")} gcCommand={GuardedCrateCommand()} gcListReadable={_gcListReadable} " + $"zLevels={(ZLevelsPlugin() != null ? ZLevelsPlugin().Version.ToString() : "absent")} " + $"wipeId={(_wipeId ?? "none")} calls={_worldCalls.Count} consolePlugins={_consolePlugins} " + + $"commandKeys={_commandRecords.Count} commandsRunning={_commandJobs.Count} " + $"credits={_vehicleCredit.Count} chinookCrates={_chinookCrates.Count} said={_worldEventsSaid} " + $"worldEvents={(WorldEventsLive() ? "live" : $"quiet (ready={_worldReady} quitting={Rust.Application.isQuitting} loadingSave={Rust.Application.isLoadingSave})")}" + Environment.NewLine + $"last: {_lastWorldSummary}"); @@ -18156,5 +18166,587 @@ namespace Oxide.Plugins $"fired: {firedText}" + Environment.NewLine + $"silent: {silentText}"); } + + // ---- console commands an admin typed (PROTOCOL.md §20.11, PLAN_REDESIGNS §16, D382-D384) ---- + // + // `command.run` runs the commands a step's author typed, as the server console would: once + // (a *Run console commands* step and its teardown), or once per recipient who is online with + // `{steamid}` and `{name}` filled in (a *Reward by console command*). There is no allowlist + // (D384); the site's switchboard is the switch, as it is for every world write. + // + // **At most once, by the key.** A command cannot be asked whether it already ran, so the key + // is written to `commands-run.json` BEFORE anything runs. A repeat answers from that record + // and runs nothing; a repeat while the first is still running waits for it and gets its + // answer. A record left running by a restart or a reload is answered `interrupted`: some of + // its commands may have run, and none is run again. + // + // **Spread over frames.** A full reward is 100 recipients by 10 commands. A recipient's + // commands run together in one frame, and a frame stops taking recipients past + // `CommandsPerFrame` commands or `CommandFrameMs`, so the game never stalls on a step. + // + // **`unknown-command`.** `ConsoleSystem.Run` answers null both for a command it does not + // know and for one that replies nothing. The game's own "Command '...' not found" line is + // logged synchronously inside the call on Oxide and on Carbon (spike, 2026-10-16), so the + // run listens to the log while it runs, and stops at the first such line, before running + // the command for anyone else. What the command logged while it ran is its reply when + // `Run` returns nothing. + + /// At most this many commands in one request (§16.2). + private const int CommandsMax = 10; + + /// The longest command, in characters (§16.2). + private const int CommandLengthMax = 512; + + /// The most recipients one reward names (the kit reward's bound, §16.3). + private const int CommandRecipientsMax = 100; + + /// A console reply is trimmed to this many characters (§16.6). + private const int CommandReplyMax = 200; + + /// At most this many replies go back with an answer and stay in its record. + private const int CommandRepliesMax = 20; + + /// At most this many skipped names go back; the rest are counted. + private const int CommandSkippedMax = 50; + + /// How long a key is remembered: seven days, as a payment's is (§16.4). + private const long CommandMemoryMs = 7L * 24L * 60L * 60L * 1000L; + + /// The most records kept, oldest forgotten first, so the file stays small. + private const int CommandRecordsMax = 500; + + /// A frame takes no more recipients once it has run this many commands… + private const int CommandsPerFrame = 30; + + /// …or spent this long on them. + private const double CommandFrameMs = 8.0; + + private static readonly System.Text.RegularExpressions.Regex SteamIdPlaceholder = + new System.Text.RegularExpressions.Regex(@"\{steamid\}", System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.CultureInvariant); + + private static readonly System.Text.RegularExpressions.Regex NamePlaceholder = + new System.Text.RegularExpressions.Regex(@"\{name\}", System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.CultureInvariant); + + /// One command that ran, and what the console said. Persisted with its record. + private sealed class CommandReply + { + public string Command; + public string SteamId; + public string Reply; + } + + /// One key's run: written before anything runs, and again when it is done. + private sealed class CommandRecord + { + public string Key; + public long AtMs; + public bool Done; + /// Null when the run finished; a reason (`unknown-command`) when it stopped. + public string Reason; + public string Message; + public int Ran; + public int Rewarded; + public List Replies = new List(); + public List Skipped = new List(); + public int SkippedCount; + } + + /// One recipient a reward names: the Steam id, and the name the site knows. + private sealed class CommandRecipient + { + public ulong Id; + public string Name; + } + + /// A run in progress. Main thread only; never persisted (its record is). + private sealed class CommandJob + { + public CommandRecord Record; + public List Commands; + /// Null for a run without recipients: the commands run once. + public List Recipients; + public int Next; + public readonly List Waiting = new List(); + } + + private Dictionary _commandRecords = new Dictionary(StringComparer.Ordinal); + + private readonly Dictionary _commandJobs = new Dictionary(StringComparer.Ordinal); + + private string CommandRecordsPath() + { + return Path.Combine(Interface.Oxide.DataDirectory, Name, "commands-run.json"); + } + + private void SaveCommandRecords() + { + WriteDataFile(CommandRecordsPath(), new List(_commandRecords.Values), "console command keys"); + } + + /// Main thread, once per load. A record still running belongs to a load that is gone. + private void LoadCommandRecords() + { + _commandRecords = new Dictionary(StringComparer.Ordinal); + List records = ReadDataFile(CommandRecordsPath(), "console command keys"); + + if (records != null) + foreach (CommandRecord r in records) + if (r != null && !string.IsNullOrEmpty(r.Key)) + _commandRecords[r.Key] = r; + + ForgetOldCommandRecords(); + } + + private void ForgetOldCommandRecords() + { + long now = NowMs(); + var stale = new List(); + + foreach (KeyValuePair kv in _commandRecords) + if (now >= kv.Value.AtMs + CommandMemoryMs && !_commandJobs.ContainsKey(kv.Key)) + stale.Add(kv.Key); + + if (_commandRecords.Count - stale.Count > CommandRecordsMax) + { + var oldest = new List(_commandRecords.Values); + oldest.Sort((a, b) => a.AtMs.CompareTo(b.AtMs)); + + foreach (CommandRecord r in oldest) + { + if (_commandRecords.Count - stale.Count <= CommandRecordsMax) + break; + + if (!stale.Contains(r.Key) && !_commandJobs.ContainsKey(r.Key)) + stale.Add(r.Key); + } + } + + foreach (string key in stale) + _commandRecords.Remove(key); + + if (stale.Count > 0) + SaveCommandRecords(); + } + + /// + /// Main thread. { key, commands, recipients? }. Checks everything before the key + /// is recorded, so a refusal for a malformed request is not remembered as a run. + /// + private void HandleCommandRun(Dictionary cmd, string reqId) + { + if (!WorldWritesAllowed(reqId)) + return; + + string key = StringField(cmd, "key"); + + if (string.IsNullOrEmpty(key) || key.Length > PaymentKeyMax) + { + EmitCommandError(reqId, "malformed", "commands are run under a key of at most 128 characters", null); + return; + } + + CommandJob running; + + if (_commandJobs.TryGetValue(key, out running)) + { + // The site retried while the first is still spreading over frames: one answer each. + if (reqId != null) + running.Waiting.Add(reqId); + + return; + } + + ForgetOldCommandRecords(); + + CommandRecord seen; + + if (_commandRecords.TryGetValue(key, out seen)) + { + if (!seen.Done) + { + EmitCommandError(reqId, "interrupted", + "these commands began before the plugin last reloaded or the server restarted, so some of them may have run; none is run again", seen); + return; + } + + EmitCommandAnswer(reqId, seen, true); + return; + } + + var commands = new List(); + string refusal = null; + + foreach (string raw in Strings(cmd, "commands")) + { + string line = (raw ?? string.Empty).Trim(); + + if (line.Length == 0) + continue; + + if (line.Length > CommandLengthMax) + refusal = $"a command is at most {CommandLengthMax} characters, and one is {line.Length}"; + else if (HasControl(line)) + refusal = "a command is one line, with no line breaks or control characters in it"; + + commands.Add(line); + } + + if (refusal == null && commands.Count == 0) + refusal = "there are no commands to run"; + else if (refusal == null && commands.Count > CommandsMax) + refusal = $"at most {CommandsMax} commands are run at a time, not {commands.Count}"; + + List recipients = null; + + if (refusal == null && cmd.ContainsKey("recipients")) + { + recipients = new List(); + var ids = new HashSet(); + + foreach (Dictionary row in Rows(cmd, "recipients")) + { + ulong id; + + if (!ulong.TryParse(StringField(row, "steamId"), System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out id) || id == 0UL) + { + refusal = $"'{StringField(row, "steamId")}' is not a Steam id"; + break; + } + + if (ids.Add(id)) + recipients.Add(new CommandRecipient { Id = id, Name = StringField(row, "name") }); + } + + if (refusal == null && recipients.Count > CommandRecipientsMax) + refusal = $"a reward runs for at most {CommandRecipientsMax} people at a time, not {recipients.Count}"; + } + + if (refusal == null && recipients == null) + { + // Nobody to fill the placeholders with: refused rather than sent with the braces in. + foreach (string line in commands) + { + if (SteamIdPlaceholder.IsMatch(line) || NamePlaceholder.IsMatch(line)) + { + refusal = "{steamid} and {name} are filled in only when commands are run for recipients"; + break; + } + } + } + + if (refusal != null) + { + EmitCommandError(reqId, "malformed", refusal, null); + return; + } + + // The key is on disk before the first command runs (§16.4). + var record = new CommandRecord { Key = key, AtMs = NowMs() }; + _commandRecords[key] = record; + SaveCommandRecords(); + + var job = new CommandJob { Record = record, Commands = commands, Recipients = recipients }; + + if (reqId != null) + job.Waiting.Add(reqId); + + _commandJobs[key] = job; + StepCommandJob(job); + } + + private static bool HasControl(string text) + { + foreach (char c in text) + if (char.IsControl(c)) + return true; + + return false; + } + + /// + /// Main thread. One frame's share of a job: whole recipients, until the frame has run + /// commands or spent . The rest + /// wait for the next frame. + /// + private void StepCommandJob(CommandJob job) + { + if (!_commandJobs.ContainsKey(job.Record.Key)) + return; + + var clock = System.Diagnostics.Stopwatch.StartNew(); + int ranThisFrame = 0; + + try + { + if (job.Recipients == null) + { + // One run, without recipients: every command, now. + if (!RunCommandSet(job, job.Commands, null)) + return; + + FinishCommandJob(job); + return; + } + + while (job.Next < job.Recipients.Count) + { + if (ranThisFrame >= CommandsPerFrame || clock.Elapsed.TotalMilliseconds >= CommandFrameMs) + { + NextTick(() => StepCommandJob(job)); + return; + } + + CommandRecipient who = job.Recipients[job.Next++]; + BasePlayer player = OnlinePlayer(who.Id); + + if (player == null) + { + // D383: skipped and named, never held until they join. + string name = string.IsNullOrEmpty(who.Name) ? who.Id.ToString(System.Globalization.CultureInfo.InvariantCulture) : who.Name; + + if (job.Record.Skipped.Count < CommandSkippedMax) + job.Record.Skipped.Add(name); + + job.Record.SkippedCount++; + continue; + } + + var filled = new List(job.Commands.Count); + + foreach (string line in job.Commands) + filled.Add(FillPlaceholders(line, player)); + + if (!RunCommandSet(job, filled, player)) + return; + + job.Record.Rewarded++; + ranThisFrame += filled.Count; + } + + FinishCommandJob(job); + } + catch (Exception ex) + { + StopCommandJob(job, "failed", $"the commands stopped: {Flatten(ex.Message)}"); + } + } + + /// A connected player by Steam id, or null: a sleeper is not online. + private static BasePlayer OnlinePlayer(ulong id) + { + foreach (BasePlayer p in BasePlayer.activePlayerList) + if (p != null && (ulong)p.userID == id && p.IsConnected) + return p; + + return null; + } + + /// + /// {steamid} as digits; {name} as ONE quoted argument, the way the game's own + /// BuildCommand quotes one, so spaces, quotes and semicolons in a name stay inside it + /// (spike, 2026-10-16). Control characters are dropped first. + /// + private static string FillPlaceholders(string line, BasePlayer player) + { + string id = ((ulong)player.userID).ToString(System.Globalization.CultureInfo.InvariantCulture); + var clean = new StringBuilder(); + + foreach (char c in player.displayName ?? string.Empty) + if (!char.IsControl(c)) + clean.Append(c); + + string quoted = clean.ToString().QuoteSafe(); + string filled = SteamIdPlaceholder.Replace(line, m => id); + return NamePlaceholder.Replace(filled, m => quoted); + } + + /// + /// Main thread. One set of commands, in order. False when the job stopped: a command the + /// server does not know ends it there, before anyone else's turn. + /// + private bool RunCommandSet(CommandJob job, List lines, BasePlayer player) + { + foreach (string line in lines) + { + bool notFound; + string reply = RunConsoleLine(line, out notFound); + + if (notFound) + { + string verb = line.Split(' ')[0]; + string why = $"the server does not know the command '{verb}'"; + + if (!_consolePlugins) + why += "; this framework does not run a plugin's console command from code, so a plugin's command is not found here (D363)"; + else + why += ", or the plugin that adds it is not loaded"; + + StopCommandJob(job, "unknown-command", why); + return false; + } + + job.Record.Ran++; + + if (job.Record.Replies.Count < CommandRepliesMax) + { + job.Record.Replies.Add(new CommandReply + { + Command = line, + SteamId = player != null ? ((ulong)player.userID).ToString(System.Globalization.CultureInfo.InvariantCulture) : null, + Reply = reply, + }); + } + } + + return true; + } + + /// + /// Main thread. One line, as the server console runs it, and what it answered: its reply, + /// or what it logged while it ran when it replies nothing. when + /// the game logged that it does not know the command. + /// + private string RunConsoleLine(string line, out bool notFound) + { + string unknown = "Command '" + line + "' not found"; + string verb = line.Split(' ')[0]; + var heard = new List(); + bool missing = false; + + UnityEngine.Application.LogCallback listen = (message, trace, type) => + { + if (message == null) + return; + + // Exactly the line, as the game prints it; or its first word, should a framework + // print only that. A command the line's own command runs is not this line. + if (message == unknown || message == "Command '" + verb + "' not found") + { + missing = true; + return; + } + + if (heard.Count < 8) + heard.Add(message); + }; + + string reply; + UnityEngine.Application.logMessageReceived += listen; + + try + { + // Not Quiet: the operator's console shows each command and its answer, as typed. + reply = ConsoleSystem.Run(ConsoleSystem.Option.Server, line); + } + catch (Exception ex) + { + reply = "threw: " + ex.Message; + } + finally + { + UnityEngine.Application.logMessageReceived -= listen; + } + + notFound = missing; + + if (string.IsNullOrEmpty(reply) && heard.Count > 0) + reply = string.Join(" | ", heard.ToArray()); + + reply = Flatten(reply ?? string.Empty); + return reply.Length > CommandReplyMax ? reply.Substring(0, CommandReplyMax) : reply; + } + + private void FinishCommandJob(CommandJob job) + { + CommandRecord r = job.Record; + r.Done = true; + _commandJobs.Remove(r.Key); + SaveCommandRecords(); + + _lastWorldSummary = job.Recipients == null + ? $"ran {r.Ran} console command(s) for key {r.Key}" + : $"ran console commands for {r.Rewarded} recipient(s), {r.SkippedCount} offline, for key {r.Key}"; + Puts($"world: {_lastWorldSummary}"); + + foreach (string waiting in job.Waiting) + EmitCommandAnswer(waiting, r, false); + } + + private void StopCommandJob(CommandJob job, string reason, string message) + { + CommandRecord r = job.Record; + r.Done = true; + r.Reason = reason; + r.Message = message; + _commandJobs.Remove(r.Key); + SaveCommandRecords(); + + Puts($"world: console commands for key {r.Key} stopped ({reason}) after {r.Ran}: {message}"); + + foreach (string waiting in job.Waiting) + EmitCommandAnswer(waiting, r, false); + } + + /// A finished record: command.ran, or command.error for one that stopped. + private void EmitCommandAnswer(string reqId, CommandRecord r, bool repeat) + { + if (r.Reason != null) + { + Dictionary error = CommandFrame("command.error", reqId, r); + error["reason"] = r.Reason; + error["message"] = r.Message; + error["repeat"] = repeat; + _lastWorldSummary = $"console commands refused ({r.Reason}): {r.Message}"; + EmitFrame(error); + return; + } + + Dictionary frame = CommandFrame("command.ran", reqId, r); + frame["repeat"] = repeat; + EmitFrame(frame); + } + + private void EmitCommandError(string reqId, string reason, string message, CommandRecord r) + { + Dictionary frame = CommandFrame("command.error", reqId, r); + frame["reason"] = reason; + frame["message"] = message; + _lastWorldSummary = $"console commands refused ({reason}): {message}"; + EmitFrame(frame); + } + + /// What every answer carries: the key, how many ran, the replies and who was skipped. + private Dictionary CommandFrame(string kind, string reqId, CommandRecord r) + { + Dictionary frame = Frame(kind, "reply"); + + if (r != null) + { + frame["key"] = r.Key; + frame["ran"] = r.Ran; + frame["rewarded"] = r.Rewarded; + + var replies = new List(); + + foreach (CommandReply reply in r.Replies) + { + var row = new Dictionary { { "command", reply.Command }, { "reply", reply.Reply ?? string.Empty } }; + + if (reply.SteamId != null) + row["steamId"] = reply.SteamId; + + replies.Add(row); + } + + frame["replies"] = replies; + frame["skipped"] = new List(r.Skipped); + frame["skippedCount"] = r.SkippedCount; + } + + if (reqId != null) + frame["reqId"] = reqId; + + return frame; + } } } From 5104e0dbcd7c12869f99194932e8edc060a7b42e Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 16 Oct 2026 02:36:49 -0500 Subject: [PATCH 2/2] =?UTF-8?q?feat(bridge):=20keep=20a=20step's=20teardow?= =?UTF-8?q?n=20with=20its=20key=20=E2=80=94=20teardownOf=20(D385)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core records nothing for a step that failed or whose answer was lost, and then reverts it with only the key: no server, no teardown. So a run request now carries its `teardown`, stored in the key's record and never run with it, and `{ key, teardownOf }` runs the teardown stored under `teardownOf`, under its own key, at most once: - only when something there may have run (some commands ran, or a reload cut the run off); otherwise answered with nothing run and nothing kept; - `busy` while the run it is for is still going; - a reward carries no teardown (refused `malformed`), and a teardown takes no placeholders. Re-compiled and driven on both rigs (a failed run's teardown ran once, a repeat ran nothing, nothing-ran and unknown keys ran nothing); rigs restored to the protocol-13 bridge. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- README.md | 4 +- overlay/oxide/plugins/RunicGateway.cs | 116 +++++++++++++++++++++----- 2 files changed, 99 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index d7e5982..ef11d55 100644 --- a/README.md +++ b/README.md @@ -143,7 +143,9 @@ recipient of a reward who is online, with `{steamid}` and `{name}` filled in (th argument). That is how a plugin with no support of its own here is used from an event, for example RaidableBases' `rbe`. There is no allowlist: the site's switchboard is the switch, and `EventsEnabled` here is the server's. Each request runs at most once: its key is written to -`data/RunicGateway/commands-run.json` before anything runs, and is kept for seven days. A command the +`data/RunicGateway/commands-run.json` before anything runs, and is kept for seven days. A step's +teardown commands are kept with its key too, so the site can have them run even for a step that failed +part-way or whose answer was lost (`teardownOf`). A command the server does not know stops the request there as `unknown-command`. On a framework with `consolePlugins=False`, that is every plugin's command. diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index 0241808..79fda21 100644 --- a/overlay/oxide/plugins/RunicGateway.cs +++ b/overlay/oxide/plugins/RunicGateway.cs @@ -18180,6 +18180,12 @@ namespace Oxide.Plugins // answer. A record left running by a restart or a reload is answered `interrupted`: some of // its commands may have run, and none is run again. // + // **The teardown is kept with the key (D385).** A step's teardown commands come with its + // run and are stored, not run. The site's ledger keeps its own copy and sends it back at + // teardown; but core records nothing for a step that FAILED, or whose answer was lost, and + // then asks with only the key. So `{ key, teardownOf }` runs the teardown stored under + // `teardownOf`, when anything there may have run, under its own key, once. + // // **Spread over frames.** A full reward is 100 recipients by 10 commands. A recipient's // commands run together in one frame, and a frame stops taking recipients past // `CommandsPerFrame` commands or `CommandFrameMs`, so the game never stalls on a step. @@ -18249,6 +18255,8 @@ namespace Oxide.Plugins public List Replies = new List(); public List Skipped = new List(); public int SkippedCount; + /// The step's teardown commands, kept for teardownOf; never run with the record (D385). + public List Teardown = new List(); } /// One recipient a reward names: the Steam id, and the name the site knows. @@ -18373,28 +18381,20 @@ namespace Oxide.Plugins return; } - var commands = new List(); - string refusal = null; + string teardownOf = StringField(cmd, "teardownOf"); - foreach (string raw in Strings(cmd, "commands")) + if (!string.IsNullOrEmpty(teardownOf)) { - string line = (raw ?? string.Empty).Trim(); - - if (line.Length == 0) - continue; - - if (line.Length > CommandLengthMax) - refusal = $"a command is at most {CommandLengthMax} characters, and one is {line.Length}"; - else if (HasControl(line)) - refusal = "a command is one line, with no line breaks or control characters in it"; - - commands.Add(line); + StartTeardownOf(key, teardownOf, reqId); + return; } + string refusal = null; + List commands = CommandLines(cmd, "commands", ref refusal); + List teardown = CommandLines(cmd, "teardown", ref refusal); + if (refusal == null && commands.Count == 0) refusal = "there are no commands to run"; - else if (refusal == null && commands.Count > CommandsMax) - refusal = $"at most {CommandsMax} commands are run at a time, not {commands.Count}"; List recipients = null; @@ -18419,12 +18419,20 @@ namespace Oxide.Plugins if (refusal == null && recipients.Count > CommandRecipientsMax) refusal = $"a reward runs for at most {CommandRecipientsMax} people at a time, not {recipients.Count}"; + + if (refusal == null && teardown.Count > 0) + refusal = "a reward has no teardown: nothing it runs is undone"; } - if (refusal == null && recipients == null) + if (refusal == null) { // Nobody to fill the placeholders with: refused rather than sent with the braces in. - foreach (string line in commands) + var unfilled = new List(teardown); + + if (recipients == null) + unfilled.AddRange(commands); + + foreach (string line in unfilled) { if (SteamIdPlaceholder.IsMatch(line) || NamePlaceholder.IsMatch(line)) { @@ -18440,8 +18448,8 @@ namespace Oxide.Plugins return; } - // The key is on disk before the first command runs (§16.4). - var record = new CommandRecord { Key = key, AtMs = NowMs() }; + // The key is on disk before the first command runs (§16.4), and the teardown with it. + var record = new CommandRecord { Key = key, AtMs = NowMs(), Teardown = teardown }; _commandRecords[key] = record; SaveCommandRecords(); @@ -18454,6 +18462,74 @@ namespace Oxide.Plugins StepCommandJob(job); } + /// + /// The command lines in one array field, trimmed, blanks dropped. A refusal is set on the + /// first line too long or with a control character in it, or on more than + /// ; an earlier refusal is kept. + /// + private static List CommandLines(Dictionary cmd, string field, ref string refusal) + { + var lines = new List(); + + foreach (string raw in Strings(cmd, field)) + { + string line = (raw ?? string.Empty).Trim(); + + if (line.Length == 0) + continue; + + if (refusal == null && line.Length > CommandLengthMax) + refusal = $"a command is at most {CommandLengthMax} characters, and one is {line.Length}"; + else if (refusal == null && HasControl(line)) + refusal = "a command is one line, with no line breaks or control characters in it"; + + lines.Add(line); + } + + if (refusal == null && lines.Count > CommandsMax) + refusal = $"at most {CommandsMax} {field} commands are run at a time, not {lines.Count}"; + + return lines; + } + + /// + /// Main thread. D385: run the teardown stored with , under + /// , when anything there may have run — some of its commands ran, + /// or a reload cut it off. Nothing stored, or nothing ran: answered with nothing run and + /// nothing recorded, which is a server this step never reached. + /// + private void StartTeardownOf(string key, string of, string reqId) + { + if (_commandJobs.ContainsKey(of)) + { + EmitCommandError(reqId, "busy", "the commands this teardown is for are still running; ask again in a moment", null); + return; + } + + CommandRecord first; + bool due = _commandRecords.TryGetValue(of, out first) + && first.Teardown != null && first.Teardown.Count > 0 + && (first.Ran > 0 || !first.Done); + + if (!due) + { + EmitCommandAnswer(reqId, new CommandRecord { Key = key, Done = true }, false); + return; + } + + var record = new CommandRecord { Key = key, AtMs = NowMs() }; + _commandRecords[key] = record; + SaveCommandRecords(); + + var job = new CommandJob { Record = record, Commands = new List(first.Teardown) }; + + if (reqId != null) + job.Waiting.Add(reqId); + + _commandJobs[key] = job; + StepCommandJob(job); + } + private static bool HasControl(string text) { foreach (char c in text)