From 5104e0dbcd7c12869f99194932e8edc060a7b42e Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 16 Oct 2026 02:36:49 -0500 Subject: [PATCH] =?UTF-8?q?feat(bridge):=20keep=20a=20step's=20teardown=20?= =?UTF-8?q?with=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)