diff --git a/README.md b/README.md
index 12533ec..ef11d55 100644
--- a/README.md
+++ b/README.md
@@ -138,6 +138,17 @@ 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 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.
+
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..79fda21 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,663 @@ 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.
+ //
+ // **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.
+ //
+ // **`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;
+ /// 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.
+ 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;
+ }
+
+ string teardownOf = StringField(cmd, "teardownOf");
+
+ if (!string.IsNullOrEmpty(teardownOf))
+ {
+ 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";
+
+ 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 && teardown.Count > 0)
+ refusal = "a reward has no teardown: nothing it runs is undone";
+ }
+
+ if (refusal == null)
+ {
+ // Nobody to fill the placeholders with: refused rather than sent with the braces in.
+ var unfilled = new List(teardown);
+
+ if (recipients == null)
+ unfilled.AddRange(commands);
+
+ foreach (string line in unfilled)
+ {
+ 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), and the teardown with it.
+ var record = new CommandRecord { Key = key, AtMs = NowMs(), Teardown = teardown };
+ _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);
+ }
+
+ ///
+ /// 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)
+ 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