Files
Module-Rust/server/eventCommands.js
wtclaude 5686d9fd17
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
feat(rust): Run console commands and Reward by console command (PLAN_REDESIGNS §16)
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
2026-10-16 02:41:10 -05:00

472 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── 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,
}