// ── 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 demo = require('./demo') 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 `multiple` param as a list: a bare value is a list of one, nothing is none. */ const asList = (raw) => (raw === undefined || raw === null || raw === '' ? [] : Array.isArray(raw) ? raw : [raw]) /** 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' } : {}), ...(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 } // The demo's deny list (DEMO_MODE.md §3.4), at run: core asked at save // (demoRefuses), but a definition can predate the list. const denied = demo.deniedCommand([...commands.value, ...teardown.value]) if (denied) return { ok: false, retry: false, error: denied } 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 demo's deny list at save (MODULE_API 1.19.0, DM18). Core asks only on the // demo; null lets the step through. demoRefuses: (p) => demo.deniedCommand([...asList(p.commands), ...asList(p.teardown)]), // 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) }), demoRefuses: (p) => demo.deniedCommand(asList(p.commands)), async perform({ runId, stepId, idempotencyKey, params, verify }) { const commands = checkCommands(params.commands) if (!commands.ok) return { ok: false, retry: false, error: commands.error } const denied = demo.deniedCommand(commands.value) if (denied) return { ok: false, retry: false, error: denied } 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, }