Merge pull request 'feat(bridge): console commands as event steps — command.run (PLAN_REDESIGNS §16, D382-D385)' (#41) from feat/command-steps into edge
Reviewed-on: #41
This commit is contained in:
11
README.md
11
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`).
|
||||
|
||||
@@ -78,7 +78,9 @@ namespace Oxide.Plugins
|
||||
/// §14.3.5): <c>gc.started</c>, <c>gc.ended</c> and <c>gc.failed</c>, <c>gc.place</c>,
|
||||
/// <c>map.live</c>'s <c>guarded</c> rows, the tally's <c>guardedWon</c> and
|
||||
/// <c>integrations.guardedCrate</c> with NpcSpawn beside it; and ZLevels Remastered
|
||||
/// (§20.10, PLAN_REDESIGNS.md §14.3.6): <c>integrations.zLevels</c>, and nothing more.
|
||||
/// (§20.10, PLAN_REDESIGNS.md §14.3.6): <c>integrations.zLevels</c>, and nothing more;
|
||||
/// and console commands as event steps (§20.11, PLAN_REDESIGNS.md §16):
|
||||
/// <c>command.run</c>, answered <c>command.ran</c> or <c>command.error</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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.
|
||||
|
||||
/// <summary>At most this many commands in one request (§16.2).</summary>
|
||||
private const int CommandsMax = 10;
|
||||
|
||||
/// <summary>The longest command, in characters (§16.2).</summary>
|
||||
private const int CommandLengthMax = 512;
|
||||
|
||||
/// <summary>The most recipients one reward names (the kit reward's bound, §16.3).</summary>
|
||||
private const int CommandRecipientsMax = 100;
|
||||
|
||||
/// <summary>A console reply is trimmed to this many characters (§16.6).</summary>
|
||||
private const int CommandReplyMax = 200;
|
||||
|
||||
/// <summary>At most this many replies go back with an answer and stay in its record.</summary>
|
||||
private const int CommandRepliesMax = 20;
|
||||
|
||||
/// <summary>At most this many skipped names go back; the rest are counted.</summary>
|
||||
private const int CommandSkippedMax = 50;
|
||||
|
||||
/// <summary>How long a key is remembered: seven days, as a payment's is (§16.4).</summary>
|
||||
private const long CommandMemoryMs = 7L * 24L * 60L * 60L * 1000L;
|
||||
|
||||
/// <summary>The most records kept, oldest forgotten first, so the file stays small.</summary>
|
||||
private const int CommandRecordsMax = 500;
|
||||
|
||||
/// <summary>A frame takes no more recipients once it has run this many commands…</summary>
|
||||
private const int CommandsPerFrame = 30;
|
||||
|
||||
/// <summary>…or spent this long on them.</summary>
|
||||
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);
|
||||
|
||||
/// <summary>One command that ran, and what the console said. Persisted with its record.</summary>
|
||||
private sealed class CommandReply
|
||||
{
|
||||
public string Command;
|
||||
public string SteamId;
|
||||
public string Reply;
|
||||
}
|
||||
|
||||
/// <summary>One key's run: written before anything runs, and again when it is done.</summary>
|
||||
private sealed class CommandRecord
|
||||
{
|
||||
public string Key;
|
||||
public long AtMs;
|
||||
public bool Done;
|
||||
/// <summary>Null when the run finished; a reason (`unknown-command`) when it stopped.</summary>
|
||||
public string Reason;
|
||||
public string Message;
|
||||
public int Ran;
|
||||
public int Rewarded;
|
||||
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>
|
||||
private sealed class CommandRecipient
|
||||
{
|
||||
public ulong Id;
|
||||
public string Name;
|
||||
}
|
||||
|
||||
/// <summary>A run in progress. Main thread only; never persisted (its record is).</summary>
|
||||
private sealed class CommandJob
|
||||
{
|
||||
public CommandRecord Record;
|
||||
public List<string> Commands;
|
||||
/// <summary>Null for a run without recipients: the commands run once.</summary>
|
||||
public List<CommandRecipient> Recipients;
|
||||
public int Next;
|
||||
public readonly List<string> Waiting = new List<string>();
|
||||
}
|
||||
|
||||
private Dictionary<string, CommandRecord> _commandRecords = new Dictionary<string, CommandRecord>(StringComparer.Ordinal);
|
||||
|
||||
private readonly Dictionary<string, CommandJob> _commandJobs = new Dictionary<string, CommandJob>(StringComparer.Ordinal);
|
||||
|
||||
private string CommandRecordsPath()
|
||||
{
|
||||
return Path.Combine(Interface.Oxide.DataDirectory, Name, "commands-run.json");
|
||||
}
|
||||
|
||||
private void SaveCommandRecords()
|
||||
{
|
||||
WriteDataFile(CommandRecordsPath(), new List<CommandRecord>(_commandRecords.Values), "console command keys");
|
||||
}
|
||||
|
||||
/// <summary>Main thread, once per load. A record still running belongs to a load that is gone.</summary>
|
||||
private void LoadCommandRecords()
|
||||
{
|
||||
_commandRecords = new Dictionary<string, CommandRecord>(StringComparer.Ordinal);
|
||||
List<CommandRecord> records = ReadDataFile<CommandRecord>(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<string>();
|
||||
|
||||
foreach (KeyValuePair<string, CommandRecord> 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<CommandRecord>(_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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Main thread. <c>{ key, commands, recipients? }</c>. Checks everything before the key
|
||||
/// is recorded, so a refusal for a malformed request is not remembered as a run.
|
||||
/// </summary>
|
||||
private void HandleCommandRun(Dictionary<string, object> 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<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";
|
||||
|
||||
List<CommandRecipient> recipients = null;
|
||||
|
||||
if (refusal == null && cmd.ContainsKey("recipients"))
|
||||
{
|
||||
recipients = new List<CommandRecipient>();
|
||||
var ids = new HashSet<ulong>();
|
||||
|
||||
foreach (Dictionary<string, object> 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<string>(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);
|
||||
}
|
||||
|
||||
/// <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)
|
||||
if (char.IsControl(c))
|
||||
return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Main thread. One frame's share of a job: whole recipients, until the frame has run
|
||||
/// <see cref="CommandsPerFrame"/> commands or spent <see cref="CommandFrameMs"/>. The rest
|
||||
/// wait for the next frame.
|
||||
/// </summary>
|
||||
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<string>(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)}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A connected player by Steam id, or null: a sleeper is not online.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>{steamid}</c> as digits; <c>{name}</c> as ONE quoted argument, the way the game's own
|
||||
/// <c>BuildCommand</c> quotes one, so spaces, quotes and semicolons in a name stay inside it
|
||||
/// (spike, 2026-10-16). Control characters are dropped first.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private bool RunCommandSet(CommandJob job, List<string> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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. <paramref name="notFound"/> when
|
||||
/// the game logged that it does not know the command.
|
||||
/// </summary>
|
||||
private string RunConsoleLine(string line, out bool notFound)
|
||||
{
|
||||
string unknown = "Command '" + line + "' not found";
|
||||
string verb = line.Split(' ')[0];
|
||||
var heard = new List<string>();
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>A finished record: <c>command.ran</c>, or <c>command.error</c> for one that stopped.</summary>
|
||||
private void EmitCommandAnswer(string reqId, CommandRecord r, bool repeat)
|
||||
{
|
||||
if (r.Reason != null)
|
||||
{
|
||||
Dictionary<string, object> 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<string, object> frame = CommandFrame("command.ran", reqId, r);
|
||||
frame["repeat"] = repeat;
|
||||
EmitFrame(frame);
|
||||
}
|
||||
|
||||
private void EmitCommandError(string reqId, string reason, string message, CommandRecord r)
|
||||
{
|
||||
Dictionary<string, object> frame = CommandFrame("command.error", reqId, r);
|
||||
frame["reason"] = reason;
|
||||
frame["message"] = message;
|
||||
_lastWorldSummary = $"console commands refused ({reason}): {message}";
|
||||
EmitFrame(frame);
|
||||
}
|
||||
|
||||
/// <summary>What every answer carries: the key, how many ran, the replies and who was skipped.</summary>
|
||||
private Dictionary<string, object> CommandFrame(string kind, string reqId, CommandRecord r)
|
||||
{
|
||||
Dictionary<string, object> frame = Frame(kind, "reply");
|
||||
|
||||
if (r != null)
|
||||
{
|
||||
frame["key"] = r.Key;
|
||||
frame["ran"] = r.Ran;
|
||||
frame["rewarded"] = r.Rewarded;
|
||||
|
||||
var replies = new List<object>();
|
||||
|
||||
foreach (CommandReply reply in r.Replies)
|
||||
{
|
||||
var row = new Dictionary<string, object> { { "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<string>(r.Skipped);
|
||||
frame["skippedCount"] = r.SkippedCount;
|
||||
}
|
||||
|
||||
if (reqId != null)
|
||||
frame["reqId"] = reqId;
|
||||
|
||||
return frame;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user