feat(bridge): console commands as event steps — command.run (PLAN_REDESIGNS §16, D382-D385) #41

Merged
whitlocktech merged 2 commits from feat/command-steps into edge 2026-10-16 02:47:41 -05:00
2 changed files with 99 additions and 21 deletions
Showing only changes of commit 5104e0dbcd - Show all commits

View File

@@ -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.

View File

@@ -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<CommandReply> Replies = new List<CommandReply>();
public List<string> Skipped = new List<string>();
public int SkippedCount;
/// <summary>The step's teardown commands, kept for <c>teardownOf</c>; never run with the record (D385).</summary>
public List<string> Teardown = new List<string>();
}
/// <summary>One recipient a reward names: the Steam id, and the name the site knows.</summary>
@@ -18373,28 +18381,20 @@ namespace Oxide.Plugins
return;
}
var commands = new List<string>();
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<string> commands = CommandLines(cmd, "commands", ref refusal);
List<string> 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<CommandRecipient> 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<string>(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);
}
/// <summary>
/// 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
/// <see cref="CommandsMax"/>; an earlier refusal is kept.
/// </summary>
private static List<string> CommandLines(Dictionary<string, object> cmd, string field, ref string refusal)
{
var lines = new List<string>();
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;
}
/// <summary>
/// Main thread. D385: run the teardown stored with <paramref name="of"/>, under
/// <paramref name="key"/>, 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.
/// </summary>
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<string>(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)