feat(rust): Run console commands and Reward by console command (PLAN_REDESIGNS §16)
All checks were successful
PR Checks / client-build (pull_request) Successful in 23s
PR Checks / server-tests (pull_request) Successful in 29s
PR Checks / frozen-manifest (pull_request) Successful in 56s

Two event steps in server/eventCommands.js, on one new budget,
`rust.commands` (D382-D385):

- rust.command.run: up to 10 typed commands once on one server, with
  optional teardown commands. irreversible / ledger: the ledger row keeps
  the teardown as it was, and core reverts it when the run ends or is
  cancelled. Priced one unit per command, teardown included.
- rust.command.reward: the kit reward's recipient picker (tally modes and
  linked), by Steam id, so no website account is needed. The bridge fills
  {steamid} and {name}; offline recipients are skipped and named (D383).
  irreversible / none. Priced commands x people, the bound for a mode that
  is not a count.

Both take core's `multiple` string field as a free text box (no
allowlist, D384) and a required "I am sure" toggle. {steamid}/{name} in a
run step are refused. unknown-command, malformed, events-disabled,
interrupted are permanent; the error says how many ran before it.

D385: the run also hands its teardown to the bridge, which keeps it with
the key. A revert with only the key (the step failed, or its answer was
lost, so core recorded nothing) asks every enabled server to run what it
kept; a server the step never reached runs nothing.

sidecarClient.commandsRun -> POST /commands/run; ci/bundle.json lists the
new file; entry.test.js pins the new ids; 21 tests in
test/commandSteps.test.js.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-10-16 02:41:10 -05:00
parent 619081ec65
commit 5686d9fd17
7 changed files with 808 additions and 7 deletions

471
server/eventCommands.js Normal file
View File

@@ -0,0 +1,471 @@
// ── Console commands as event steps and rewards (PLAN_REDESIGNS.md §16, D382–D385) ──
//
// Two steps that run console commands the author types, so a plugin with no
// first-class support can still be used from the scheduler — RaidableBases'
// `rbe …` as a step, `sr add {steamid} 100` as a reward:
//
// rust.command.run runs the commands once, on one server; optional
// teardown commands run when the run ends or is
// cancelled (`irreversible` / `ledger`)
// rust.command.reward runs them once per recipient a kit reward's picker
// picks, `{steamid}` and `{name}` filled in by the
// bridge (`irreversible` / `none`)
//
// One protocol-14 frame carries both, `command.run` through `POST
// /commands/run`. The bridge runs each key at most once: it writes the key down
// before anything runs, so a retried step is answered from the first attempt.
//
// ── The safety is core's, and the author's own "I am sure" (D384) ───────────
//
// No allowlist and no blocklist: the commands are a free text box (core's
// `multiple` string field, MODULE_API 1.14.0). What bounds them is what bounds
// every world-changing step — off until an admin switches it on, authored only
// by an admin, the switchboard's caps on `rust.commands` — and a required toggle
// that says the author meant it. Unticked, the dry run and the run refuse.
//
// ── The teardown has two copies (D385) ─────────────────────────────────────
//
// The ledger row carries the teardown commands as they were when the step ran,
// so an edit to the definition mid-run changes nothing. But core records no
// resource for a step that FAILED (a misspelled second command after a first
// that started a base) or whose answer was lost, and reverts it with only the
// key. So the run also hands the teardown to the bridge, which keeps it with
// the key; a revert that has only the key asks every enabled server to run the
// teardown it kept under that key, which a server the step never reached
// answers with nothing run.
const client = require('./sidecarClient')
const servers = require('./model/servers/servers.model')
const linksDb = require('./model/links/links.db')
const core = require('./core')
const { serverFor, transportError, pluginError } = require('./eventLeases')
const { BUDGET_MS } = require('./eventWorld')
const rewards = require('./eventRewards')
const log = core.logger('commands')
const { MAX_RECIPIENTS, MODES, oneOf, checkCount, pickRecipients, peopleOf, readTally, entitlementRef, refParts } = rewards
/** At most this many commands in one field (§16.2). */
const MAX_COMMANDS = 10
/** The longest command, in characters (§16.2). */
const COMMAND_MAX = 512
/** What the bridge fills in for each recipient, and only for a reward (§16.3). */
const PLACEHOLDER = /\{(steamid|name)\}/i
/** A ledger row's kind: the teardown commands one step left to run. */
const TEARDOWN_KIND = 'command-teardown'
/** The key a step's teardown runs under: its own, so it too runs at most once. */
const teardownKey = (key) => `${key}:teardown`
/** The bridge's refusals a second attempt would repeat. */
const PERMANENT = new Set([
'events-disabled',
'malformed',
'unknown-command',
// Some commands may have run before a reload cut the run off, and none is run
// again: a retry would only be answered the same.
'interrupted',
'failed',
])
const BUDGETS = [
{
id: 'rust.commands',
label: 'Console commands',
unit: 'commands',
description:
'Console commands an event runs, as typed by its author: one per command, teardown commands included, and one per command per recipient for a reward. A mode that is not a count is priced at the most it could reward.',
},
]
// ── The params both steps check ──────────────────────────────────────────────
/**
* One field's commands as a list, checked: trimmed, blanks dropped, each at most
* COMMAND_MAX characters on one line, at most MAX_COMMANDS. Core hands a
* `multiple` param over as an array (MODULE_API 1.14.0); a bare value is a list
* of one. Order is kept: the commands run as listed.
*/
function checkCommands(raw, { field = 'commands', required = true } = {}) {
const items = raw === undefined || raw === null || raw === '' ? [] : Array.isArray(raw) ? raw : [raw]
const out = []
for (const item of items) {
const line = String(item === undefined || item === null ? '' : item).trim()
if (!line) continue
if (line.length > COMMAND_MAX) return { ok: false, error: `a command is at most ${COMMAND_MAX} characters, and one of the ${field} is ${line.length}` }
// eslint-disable-next-line no-control-regex
if (/[\u0000-\u001f\u007f]/.test(line)) return { ok: false, error: `a command is one line: one of the ${field} has a line break or a control character in it` }
out.push(line)
}
if (required && !out.length) return { ok: false, error: 'type at least one command' }
if (out.length > MAX_COMMANDS) return { ok: false, error: `a step runs at most ${MAX_COMMANDS} ${field}, not ${out.length}` }
return { ok: true, value: out }
}
/** The "I am sure" toggle (§16.2, §16.3, D343's). Only a real `true` passes; a string is not a tick. */
function checkConfirm(params, sentence) {
if (params.confirm === true) return { ok: true }
return { ok: false, error: `Tick "${sentence}" to run these commands` }
}
/** The first command that names a placeholder, or null: only a reward has someone to fill one with. */
const withPlaceholder = (lines) => lines.find((line) => PLACEHOLDER.test(line)) || null
/** A command list's price: at least one, so the switchboard sees the dimension. */
function pricedCommands(raw) {
const checked = checkCommands(raw, { required: false })
return checked.ok ? checked.value.length : 1
}
/** How many teardown commands a step names, as its price counts them; none is none. */
function teardownCount(raw) {
const checked = checkCommands(raw, { required: false })
return checked.ok ? checked.value.length : 0
}
/** People priced before the tally is read: the count for a count, the bound for every other mode. */
function pricedPeople(p) {
const mode = oneOf(p.recipients, MODES)
const n = Math.round(Number(p.count) || 0)
return mode === 'top' || mode === 'random' ? Math.max(0, n) : MAX_RECIPIENTS
}
/** A transport failure; permanent only when the server is not set up to be asked at all. */
function transportFailure(server, result, what) {
const permanent = result.status === 'not-configured' || result.status === 'no-token'
return { ok: false, ...(permanent ? { retry: false } : {}), error: transportError(server, result, what) }
}
/**
* The bridge's refusal, in words, with how many commands ran before it: a typo
* in the second command of three says the first one ran.
*/
function refusal(server, data, what) {
const name = server.name || server.id
const ran = Number(data.ran) || 0
const said = pluginError(data, `${name} did not run the ${what}`)
const tail = ran ? ` (${ran} ran before it)` : ''
return {
ok: false,
...(PERMANENT.has(data.reason) ? { retry: false } : {}),
error: `${name}: ${said}${tail}`,
}
}
/**
* What the run log shows of an answer: how many ran, what the console said
* (the bridge keeps the first 20), and who was skipped as offline.
*/
function replyDetail(data) {
const replies = Array.isArray(data.replies) ? data.replies : []
const skipped = Array.isArray(data.skipped) ? data.skipped.map(String) : []
const skippedCount = Number(data.skippedCount) || skipped.length
return {
ran: Number(data.ran) || 0,
...(replies.length
? { replies: replies.map((r) => ({ command: String(r.command || ''), reply: String(r.reply || ''), ...(r.steamId ? { steamId: String(r.steamId) } : {}) })) }
: {}),
...(skippedCount ? { skipped, skippedCount, skippedNote: 'skipped: not on the server when the reward ran (D383)' } : {}),
...(data.repeat ? { repeat: true, note: 'answered from the first attempt; nothing was run twice' } : {}),
}
}
// ── Run console commands (§16.2) ─────────────────────────────────────────────
const RUN_SURE = 'I am sure I want to run these commands on the server'
/**
* Everything the step checks before touching anything, in the order an author
* fixes them: `{ ok, commands, teardown, server }` or a refusal core reads as
* permanent.
*/
async function checkRun(params) {
const commands = checkCommands(params.commands)
if (!commands.ok) return { ok: false, retry: false, error: commands.error }
const teardown = checkCommands(params.teardown, { field: 'teardown commands', required: false })
if (!teardown.ok) return { ok: false, retry: false, error: teardown.error }
const placeholder = withPlaceholder([...commands.value, ...teardown.value])
if (placeholder) {
return { ok: false, retry: false, error: `{steamid} and {name} are filled in only by Reward by console command, and "${placeholder}" has one` }
}
const confirm = checkConfirm(params, RUN_SURE)
if (!confirm.ok) return { ok: false, retry: false, error: confirm.error }
const serverId = String(params.server || '').trim()
if (!serverId) return { ok: false, retry: false, error: 'the step does not say which server — pick one' }
const found = await serverFor(serverId)
if (!found.ok) return found
return { ok: true, commands: commands.value, teardown: teardown.value, server: found.server }
}
/**
* Give back what a step left to undo: run its teardown commands, once.
*
* With a resource, the ledger's own copy goes to the server it names. With
* none — the step failed, or its answer was lost — every enabled server is
* asked to run what it kept under the step's key (D385); a server the step
* never reached, or one where nothing ran, runs nothing and says so. A server
* that cannot be asked leaves the row visible rather than guessing.
*/
async function revertRun({ resources, idempotencyKey }) {
const errors = []
const failed = []
if (!resources || resources.length === 0) {
if (!idempotencyKey) return { ok: true }
for (const server of await servers.listForPolling()) {
const result = await client.commandsRun(server, { key: teardownKey(idempotencyKey), teardownOf: idempotencyKey })
if (!result.ok) errors.push(transportError(server, result, 'teardown commands'))
else if (!result.data || result.data.kind !== 'command.ran') errors.push(refusal(server, result.data || {}, 'teardown commands').error)
}
return errors.length ? { ok: false, error: errors.join('; ') } : { ok: true }
}
for (const r of resources) {
const payload = r.payload || {}
const teardown = Array.isArray(payload.teardown) ? payload.teardown : []
if (!teardown.length) continue
const found = await serverFor(payload.serverId || refParts(r.ref).serverId)
if (!found.ok) {
failed.push(r.ref)
errors.push(found.error)
continue
}
const result = await client.commandsRun(found.server, { key: teardownKey(payload.key || r.ref), commands: teardown })
if (!result.ok) {
failed.push(r.ref)
errors.push(transportError(found.server, result, 'teardown commands'))
continue
}
// A 200 is not a success on this bridge: a refusal (`unknown-command`) comes
// back as one, carrying `command.error`.
if (!result.data || result.data.kind !== 'command.ran') {
failed.push(r.ref)
errors.push(refusal(found.server, result.data || {}, 'teardown commands').error)
continue
}
log.info('teardown commands ran', { server: found.server.id, ref: r.ref, ran: Number(result.data.ran) || 0, repeat: !!result.data.repeat })
}
if (!failed.length) return { ok: true }
if (failed.length === resources.length) return { ok: false, error: errors.join('; ') }
return { ok: true, failed }
}
const commandRun = {
id: 'rust.command.run',
label: 'Run console commands',
description:
'Runs console commands you type, once, on one server, as the server console would: a plugin\'s own command, such as RaidableBases\' rbe, or a game command. Optional teardown commands run when the event ends or is cancelled. The site cannot know what a command does, so the step is rated the most dangerous class: it is off until an admin switches it on. The run log shows what the console answered; "it ran" is not "it worked".',
// The site cannot know what a typed command does (§16.2), so the most
// dangerous class — and with it core's default failure rule: abort the run.
risk: 'irreversible',
// The teardown is the ledger's: core reverts a ledgered step at the end of
// every run, which is exactly when the teardown commands should run.
reversible: 'ledger',
version: 1,
budgetMs: BUDGET_MS,
params: [
{ name: 'server', type: 'string', required: true, example: 'main', source: 'rust.options.servers',
description: 'The server the commands run on. One server per step; add a step per server to reach more.' },
{ name: 'commands', type: 'string', required: true, example: 'rbe 1', multiple: true, max: MAX_COMMANDS,
description: `The commands, one per entry, run in the order listed — at most ${MAX_COMMANDS}, ${COMMAND_MAX} characters each. Type each as you would at the server console. A command the server does not know stops the step there.` },
{ name: 'teardown', type: 'string', required: false, example: 'rbe stop', multiple: true, max: MAX_COMMANDS,
description: 'Optional commands run once when the event ends or is cancelled, such as a plugin\'s own "stop". They are kept as they were when the step ran.' },
{ name: 'confirm', type: 'boolean', required: true, example: false,
description: `${RUN_SURE}. Unticked, the step refuses in the dry run and when it runs.` },
],
// One unit per command, start and teardown together (§16.2).
cost: (p) => ({ 'rust.commands': pricedCommands(p.commands) + teardownCount(p.teardown) }),
async perform({ runId, stepId, idempotencyKey, params, verify }) {
const step = await checkRun(params)
if (!step.ok) return step
if (verify) return { ok: true }
const { commands, teardown, server } = step
const name = server.name || server.id
const result = await client.commandsRun(server, { key: idempotencyKey, commands, ...(teardown.length ? { teardown } : {}) })
if (!result.ok) return transportFailure(server, result, 'console commands')
const data = result.data || {}
if (data.kind !== 'command.ran') return refusal(server, data, 'console commands')
log.info('console commands ran', { server: server.id, run: runId, step: stepId, ran: Number(data.ran) || 0, repeat: !!data.repeat })
const resources = teardown.length
? [{ kind: TEARDOWN_KIND, ref: entitlementRef(server.id, runId, stepId), payload: { serverId: server.id, key: idempotencyKey, teardown } }]
: []
return {
ok: true,
...(resources.length ? { resources } : {}),
detail: {
server: name,
...replyDetail(data),
...(teardown.length ? { teardown: `${teardown.length} command(s) run when the event ends` } : {}),
},
}
},
revert: revertRun,
// The site cannot ask a server whether a command's effect is still there, so
// a teardown not yet run is always in force: "I do not know" is never "gone".
async reconcile({ resources }) {
return { ok: true, inForce: (resources || []).map((r) => r.ref) }
},
}
// ── Reward by console command (§16.3) ────────────────────────────────────────
const REWARD_SURE = 'I am sure I want to run these commands for every recipient'
/**
* The people a mode picks, by Steam id: `{ ok, people }`, each `{ steamId, name
* }`. No website account is needed (§16.3): a command goes by Steam id. `linked`
* reads no tally: every linked player of the server.
*/
async function recipientsOf({ server, mode, count, runId, idempotencyKey }) {
const name = server.name || server.id
if (mode === 'linked') {
// One past the bound, so "too many" is a sentence and not a silent cut.
const linked = await linksDb.linkedOnServer(server.id, MAX_RECIPIENTS + 1)
if (linked.length > MAX_RECIPIENTS) {
return { ok: false, retry: false, error: `${name} has more than ${MAX_RECIPIENTS} linked players, and a reward runs commands for at most ${MAX_RECIPIENTS} in one step — reward from a tally instead` }
}
const seen = new Set()
const people = []
for (const r of linked) {
const steamId = String(r.steamId)
if (seen.has(steamId)) continue
seen.add(steamId)
people.push({ steamId, name: r.name ? String(r.name) : steamId })
}
return { ok: true, people }
}
const tally = await readTally(server, runId)
if (!tally.ok) return tally
const picked = pickRecipients(peopleOf(tally.data), mode, count, idempotencyKey)
const bound = Math.min(Number(tally.data.maxRecipients) || MAX_RECIPIENTS, MAX_RECIPIENTS)
if (picked.length > bound) {
return { ok: false, retry: false, error: `${picked.length} people qualify, and ${name} runs reward commands for at most ${bound} in one step — pick a count-based mode or a higher bar` }
}
return { ok: true, people: picked.map((p) => ({ steamId: p.steamId, name: p.name })) }
}
const commandReward = {
id: 'rust.command.reward',
label: 'Reward by console command',
description:
'Runs console commands you type once for each person a mode picks from this run\'s tally — or every linked player of the server — with {steamid} and {name} filled in for each, such as "sr add {steamid} 100". Only people on the server when it runs are rewarded; anyone offline is skipped and named in the run log. Nothing is taken back when the event ends.',
// Irreversible, as `rust.perm.give` is (D346): core reverts a ledgered step at
// the end of every run, and a reward command cannot be run backwards.
risk: 'irreversible',
reversible: 'none',
version: 1,
budgetMs: BUDGET_MS,
params: [
{ name: 'server', type: 'string', required: true, example: 'main', source: 'rust.options.servers',
description: 'The server the commands run on.' },
{ name: 'recipients', type: 'string', required: true, example: 'top', source: 'rust.options.recipientmodes',
description: 'Who is rewarded: everyone who scored, the top N, a score of at least X, N drawn at random, the top X per cent — or every linked player of the server, with no tally at all.' },
{ name: 'count', type: 'float', required: false, example: 3,
description: 'N for top and random, X for a minimum score, the percentage for top per cent. Not used for everyone or linked.' },
{ name: 'commands', type: 'string', required: true, example: 'sr add {steamid} 100', multiple: true, max: MAX_COMMANDS,
description: `The commands, run in order for each recipient — at most ${MAX_COMMANDS}, ${COMMAND_MAX} characters each. {steamid} is the recipient's Steam id; {name} is their name, passed as one quoted argument (do not put quotes around it).` },
{ name: 'confirm', type: 'boolean', required: true, example: false,
description: `${REWARD_SURE}. Unticked, the step refuses in the dry run and when it runs.` },
],
cost: (p) => ({ 'rust.commands': pricedCommands(p.commands) * pricedPeople(p) }),
async perform({ runId, stepId, idempotencyKey, params, verify }) {
const commands = checkCommands(params.commands)
if (!commands.ok) return { ok: false, retry: false, error: commands.error }
const confirm = checkConfirm(params, REWARD_SURE)
if (!confirm.ok) return { ok: false, retry: false, error: confirm.error }
const mode = oneOf(params.recipients, MODES)
if (!mode) return { ok: false, retry: false, error: `recipients is one of ${MODES.join(', ')}, not "${params.recipients}"` }
const count = checkCount(mode, params.count)
if (!count.ok) return { ok: false, retry: false, error: count.error }
const serverId = String(params.server || '').trim()
if (!serverId) return { ok: false, retry: false, error: 'the step does not say which server — pick one' }
const found = await serverFor(serverId)
if (!found.ok) return found
const server = found.server
const name = server.name || server.id
if ((mode === 'top' || mode === 'random') && count.value > MAX_RECIPIENTS) {
return { ok: false, retry: false, error: `${name} runs reward commands for at most ${MAX_RECIPIENTS} people in one step, not ${count.value}` }
}
if (verify) return { ok: true }
const picked = await recipientsOf({ server, mode, count: count.value, runId, idempotencyKey })
if (!picked.ok) return picked
const base = {
server: name,
mode,
...(count.value === null ? {} : { count: count.value }),
people: picked.people.length,
}
// Nobody qualified: nothing to send, and nothing for the bridge to remember.
if (!picked.people.length) return { ok: true, detail: { ...base, ran: 0, note: 'nobody qualified, so no command ran' } }
const result = await client.commandsRun(server, { key: idempotencyKey, commands: commands.value, recipients: picked.people })
if (!result.ok) return transportFailure(server, result, 'reward commands')
const data = result.data || {}
if (data.kind !== 'command.ran') return refusal(server, data, 'reward commands')
log.info('reward commands ran', { server: server.id, run: runId, step: stepId, mode, people: picked.people.length, rewarded: Number(data.rewarded) || 0, repeat: !!data.repeat })
return {
ok: true,
detail: {
...base,
rewarded: Number(data.rewarded) || 0,
...replyDetail(data),
},
}
},
}
const ACTIONS = [commandRun, commandReward]
module.exports = {
MAX_COMMANDS,
COMMAND_MAX,
TEARDOWN_KIND,
PERMANENT,
BUDGETS,
ACTIONS,
checkCommands,
checkConfirm,
teardownKey,
replyDetail,
}