feat(rust): Run console commands and Reward by console command (PLAN_REDESIGNS §16) #50

Merged
whitlocktech merged 1 commits from feat/command-steps into edge 2026-10-16 02:47:30 -05:00
7 changed files with 808 additions and 7 deletions

View File

@@ -150,6 +150,17 @@ one now, kept when the run ends: an airdrop or a patrol helicopter can be sent t
step, which takes effect at once and is taken back when the run ends
(`docs/modules/rust/PLAN_REDESIGNS.md` §14.3.6).
**Any other plugin, by console command.** **Run console commands** runs the commands an admin types
(at most ten) once on one server, such as RaidableBases' `rbe`, and optional teardown commands when
the run ends or is cancelled. **Reward by console command** runs them once for each person the kit
reward's picker picks, with `{steamid}` and `{name}` filled in by the bridge, such as `sr add {steamid} 100`.
Anyone offline at that moment is skipped and named in the run log. There is no allowlist. Both steps are
rated `irreversible`, so they are off until an admin switches them on, only an admin can author them,
and each needs its "I am sure" toggle ticked. A command the server does not know stops the step as
`unknown-command`. The run log shows what the console answered: "it ran" is not "it worked". The
site learns nothing afterwards, so none of it reaches the feed, the map or a title
(`docs/modules/rust/PLAN_REDESIGNS.md` §16).
News and event lines said in chat can wear one styled group's title and colours — the
**announcement voice**, chosen in Admin → Rust servers. The line is said by the bridge plugin with no
player as its sender, so it looks the same with or without BetterChat.

View File

@@ -35,6 +35,7 @@
"dangerousTreasures.js",
"db",
"dynamicPvp.js",
"eventCommands.js",
"engagement",
"eventDangerousTreasures.js",
"eventGuardedCrate.js",

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

View File

@@ -63,6 +63,7 @@ module.exports = function register(ctx, api) {
const eventDangerousTreasures = require('./eventDangerousTreasures')
const eventGuardedCrate = require('./eventGuardedCrate')
const eventPermRewards = require('./eventPermRewards')
const eventCommands = require('./eventCommands')
const eventTemplates = require('./eventTemplates')
const boot = require('./boot')
const commands = require('./commands')
@@ -180,9 +181,10 @@ module.exports = function register(ctx, api) {
// Calling one of Rust's own world events (PLAN_REDESIGNS.md §14.3.1, protocol
// 14) beside them: kept when the run ends, on a budget of its own. Starting a
// Dangerous Treasures event (§14.3.4) and placing a guarded crate (§14.3.5)
// likewise, each on its own budget.
api.registerEventBudgets([...eventWorld.BUDGETS, ...eventRewards.BUDGETS, ...eventPermRewards.BUDGETS, ...eventWorldCall.BUDGETS, ...eventDangerousTreasures.BUDGETS, ...eventGuardedCrate.BUDGETS])
api.registerEventActions([...eventWorld.ACTIONS, ...eventRewards.ACTIONS, ...eventPermRewards.ACTIONS, ...eventWorldCall.ACTIONS, ...eventDangerousTreasures.ACTIONS, ...eventGuardedCrate.ACTIONS])
// likewise, each on its own budget; and console commands an author typed
// (§16, D382-D385), priced in commands.
api.registerEventBudgets([...eventWorld.BUDGETS, ...eventRewards.BUDGETS, ...eventPermRewards.BUDGETS, ...eventWorldCall.BUDGETS, ...eventDangerousTreasures.BUDGETS, ...eventGuardedCrate.BUDGETS, ...eventCommands.BUDGETS])
api.registerEventActions([...eventWorld.ACTIONS, ...eventRewards.ACTIONS, ...eventPermRewards.ACTIONS, ...eventWorldCall.ACTIONS, ...eventDangerousTreasures.ACTIONS, ...eventGuardedCrate.ACTIONS, ...eventCommands.ACTIONS])
// The templates (PLAN_REDESIGNS.md §2.5, MODULE_API 1.13.0): a kit weekend
// as a starting point on core's New event screen. Core keeps nothing about a
@@ -222,8 +224,8 @@ module.exports = function register(ctx, api) {
streams: STREAMS.length,
audiences: AUDIENCES.length,
leases: eventLeases.LEASES.length,
actions: eventWorld.ACTIONS.length + eventRewards.ACTIONS.length + eventPermRewards.ACTIONS.length + eventWorldCall.ACTIONS.length + eventDangerousTreasures.ACTIONS.length + eventGuardedCrate.ACTIONS.length,
budgets: eventWorld.BUDGETS.length + eventRewards.BUDGETS.length + eventPermRewards.BUDGETS.length + eventWorldCall.BUDGETS.length + eventDangerousTreasures.BUDGETS.length + eventGuardedCrate.BUDGETS.length,
actions: eventWorld.ACTIONS.length + eventRewards.ACTIONS.length + eventPermRewards.ACTIONS.length + eventWorldCall.ACTIONS.length + eventDangerousTreasures.ACTIONS.length + eventGuardedCrate.ACTIONS.length + eventCommands.ACTIONS.length,
budgets: eventWorld.BUDGETS.length + eventRewards.BUDGETS.length + eventPermRewards.BUDGETS.length + eventWorldCall.BUDGETS.length + eventDangerousTreasures.BUDGETS.length + eventGuardedCrate.BUDGETS.length + eventCommands.BUDGETS.length,
announceLeg: eventRewards.LEG.leg,
commands: commands.map((cmd) => cmd.name).join(' '),
optionSources: eventLeases.OPTION_SOURCES.length + eventWorld.OPTION_SOURCES.length + eventRewards.OPTION_SOURCES.length + eventPermRewards.OPTION_SOURCES.length + eventWorldCall.OPTION_SOURCES.length + eventDangerousTreasures.OPTION_SOURCES.length + eventGuardedCrate.OPTION_SOURCES.length,

View File

@@ -452,6 +452,17 @@ const dtStart = (server, body) => request(server, '/dt/start', { method: 'POST',
*/
const gcPlace = (server, body) => request(server, '/gc/place', { method: 'POST', body })
/**
* Run console commands a step's author typed (protocol 14, §20.11): `{ key,
* commands, teardown? }` once, or `{ key, commands, recipients }` once per
* online recipient with `{steamid}` and `{name}` filled in by the plugin; `{
* key, teardownOf }` runs the teardown the plugin kept under another key
* (D385). `data.kind` is `command.ran` (`ran`, `rewarded`, `replies`,
* `skipped`, `skippedCount`, and `repeat: true` for a key the plugin has seen)
* or `command.error` with a reason, `unknown-command` among them.
*/
const commandsRun = (server, body) => request(server, '/commands/run', { method: 'POST', body })
/**
* Give back what a run owns: named ids, else everything under a key, else the
* whole run. `data` lists `removed`, `gone` (already not there — a success)
@@ -591,6 +602,7 @@ module.exports = {
worldCall,
dtStart,
gcPlace,
commandsRun,
worldRevert,
tallyOpen,
tallySnapshot,

View File

@@ -0,0 +1,300 @@
// ── Console commands as event steps and rewards (PLAN_REDESIGNS §16, D382–D385) ──
//
// Each test is one way the feature could look right and be wrong:
//
// both steps are irreversible, on one `rust.commands` budget, and run is the
// one with a revert (its teardown); the price counts commands, teardown
// included, and commands × people for a reward
// the commands are core's list, at most ten, 512 characters, one line each
// unticked, both steps refuse in the dry run (D384's bound is the toggle)
// {steamid} and {name} are refused in a step that has nobody to fill them
// the run sends the key and the teardown with it (D385), files the ledger's
// own copy of the teardown, and shows the console's replies
// a misspelled command is permanent `unknown-command`, saying what ran before
// a reward sends Steam ids (no website account needed), never a site-side
// filled command, and names who was skipped as offline (D383)
// a revert runs the ledger's copy once, under its own key; one with only the
// key asks every enabled server to run what it kept (D385)
const test = require('node:test')
const assert = require('node:assert')
const { fakeCtx } = require('./_fakes')
require('../core')._reset()
require('../core').init(fakeCtx({ db: { query: () => Promise.resolve([]), pool: {} } }))
const client = require('../sidecarClient')
const serversDb = require('../model/servers/servers.db')
const servers = require('../model/servers/servers.model')
const linksDb = require('../model/links/links.db')
const rewards = require('../eventRewards')
const cmds = require('../eventCommands')
const ok = (data) => ({ ok: true, status: 'ok', data })
const A = '76561198000000001'
const B = '76561198000000002'
const C = '76561198000000003'
const ROW = { id: 'main', name: 'Main', sidecarBaseUrl: 'http://main:1', sidecarTokenEnc: null, enabled: 1 }
const run = cmds.ACTIONS.find((a) => a.id === 'rust.command.run')
const reward = cmds.ACTIONS.find((a) => a.id === 'rust.command.reward')
const person = (steamId, score, name) => ({ steamId, name: name || `p${steamId.slice(-1)}`, seconds: 60, kills: 0, score, joinedAt: 1 })
/** Every call the steps make, recorded; `answer` is what the bridge says to each. */
function stub(t, { answer = () => ok({ kind: 'command.ran', ran: 1, replies: [] }), people = [], linked = [], enabled = [ROW] } = {}) {
const calls = { sent: [] }
const saved = {
getServer: serversDb.getServer,
commandsRun: client.commandsRun,
tallySnapshot: client.tallySnapshot,
linkedOnServer: linksDb.linkedOnServer,
listForPolling: servers.listForPolling,
}
serversDb.getServer = async (id) => enabled.find((s) => s.id === id) || null
client.commandsRun = async (server, body) => {
calls.sent.push({ server: server.id, body })
return answer(body, server)
}
client.tallySnapshot = async (server, runId) => ok({ kind: 'tally.snapshot', runId, people, maxRecipients: 100 })
linksDb.linkedOnServer = async () => linked
servers.listForPolling = async () => enabled
t.after(() => {
Object.assign(serversDb, { getServer: saved.getServer })
Object.assign(client, { commandsRun: saved.commandsRun, tallySnapshot: saved.tallySnapshot })
Object.assign(linksDb, { linkedOnServer: saved.linkedOnServer })
Object.assign(servers, { listForPolling: saved.listForPolling })
})
return calls
}
const RUN = { server: 'main', commands: ['rbe 1', 'say a raid base is up'], teardown: ['rbe stop'], confirm: true }
const REWARD = { server: 'main', recipients: 'everyone', commands: ['sr add {steamid} 100'], confirm: true }
const CTX = { runId: 7, stepId: 3, idempotencyKey: 'k'.repeat(40) }
// ── The declarations ───────────────────────────────────────────────────────
test('both steps are irreversible on one budget; only the run has a teardown to revert', () => {
assert.deepStrictEqual([run.risk, run.reversible, typeof run.revert, typeof run.reconcile], ['irreversible', 'ledger', 'function', 'function'])
assert.deepStrictEqual([reward.risk, reward.reversible, reward.revert], ['irreversible', 'none', undefined])
assert.deepStrictEqual(cmds.BUDGETS.map((b) => b.id), ['rust.commands'])
// One unit per command, start and teardown together (§16.2)…
assert.deepStrictEqual(run.cost(RUN), { 'rust.commands': 3 })
assert.deepStrictEqual(run.cost({ commands: ['rbe 1'] }), { 'rust.commands': 1 })
// …and commands × people for a reward, the bound for a mode that is not a count (§16.3).
assert.deepStrictEqual(reward.cost({ recipients: 'top', count: 3, commands: ['a', 'b'] }), { 'rust.commands': 6 })
assert.deepStrictEqual(reward.cost({ recipients: 'everyone', commands: ['a'] }), { 'rust.commands': rewards.MAX_RECIPIENTS })
// The switchboard prices the declared example, a list of one.
assert.deepStrictEqual(run.cost({ commands: run.params.find((p) => p.name === 'commands').example }), { 'rust.commands': 1 })
})
test('the commands are core\'s list of at most ten, and confirm is a required toggle', () => {
for (const action of [run, reward]) {
const list = action.params.find((p) => p.name === 'commands')
assert.deepStrictEqual([list.type, list.multiple, list.max, list.required], ['string', true, cmds.MAX_COMMANDS, true])
assert.strictEqual(Array.isArray(list.example), false)
assert.strictEqual(list.source, undefined, 'a free text box: no allowlist (D384)')
const confirm = action.params.find((p) => p.name === 'confirm')
assert.deepStrictEqual([confirm.type, confirm.required, confirm.example], ['boolean', true, false])
}
const teardown = run.params.find((p) => p.name === 'teardown')
assert.deepStrictEqual([teardown.multiple, teardown.required], [true, false])
assert.strictEqual(reward.params.find((p) => p.name === 'teardown'), undefined)
})
test('a command is trimmed, one line, at most 512 characters, and eleven are refused', () => {
assert.deepStrictEqual(cmds.checkCommands([' rbe 1 ', '', 'say hi']).value, ['rbe 1', 'say hi'])
assert.deepStrictEqual(cmds.checkCommands('rbe 1').value, ['rbe 1'])
assert.match(cmds.checkCommands([]).error, /at least one command/)
assert.deepStrictEqual(cmds.checkCommands([], { required: false }).value, [])
assert.match(cmds.checkCommands(['say a\nquit']).error, /one line/)
assert.match(cmds.checkCommands(['x'.repeat(513)]).error, /at most 512 characters/)
assert.ok(cmds.checkCommands(['x'.repeat(512)]).ok)
assert.match(cmds.checkCommands(Array.from({ length: 11 }, (_, i) => `c${i}`)).error, /at most 10 commands, not 11/)
})
// ── Refusals before anything is sent ───────────────────────────────────────
test('unticked, both steps refuse in the dry run, and send nothing', async (t) => {
const calls = stub(t)
const a = await run.perform({ ...CTX, params: { ...RUN, confirm: false }, verify: true })
assert.deepStrictEqual([a.ok, a.retry], [false, false])
assert.match(a.error, /I am sure I want to run these commands on the server/)
// A string is not a tick.
const b = await reward.perform({ ...CTX, params: { ...REWARD, confirm: 'true' }, verify: true })
assert.deepStrictEqual([b.ok, b.retry], [false, false])
assert.match(b.error, /for every recipient/)
assert.strictEqual(calls.sent.length, 0)
})
test('{steamid} and {name} are refused in a run, teardown included: nobody fills them there', async (t) => {
stub(t)
const a = await run.perform({ ...CTX, params: { ...RUN, commands: ['sr add {steamid} 1'] }, verify: true })
assert.deepStrictEqual([a.ok, a.retry], [false, false])
assert.match(a.error, /Reward by console command/)
const b = await run.perform({ ...CTX, params: { ...RUN, teardown: ['say bye {Name}'] }, verify: true })
assert.strictEqual(b.ok, false)
})
test('a dry run that passes checks the server and sends nothing', async (t) => {
const calls = stub(t)
assert.deepStrictEqual(await run.perform({ ...CTX, params: RUN, verify: true }), { ok: true })
assert.deepStrictEqual(await reward.perform({ ...CTX, params: REWARD, verify: true }), { ok: true })
const off = await run.perform({ ...CTX, params: { ...RUN, server: 'nowhere' }, verify: true })
assert.deepStrictEqual([off.ok, off.retry], [false, false])
assert.strictEqual(calls.sent.length, 0)
})
// ── Run console commands ───────────────────────────────────────────────────
test('a run sends its key and its teardown, and files the ledger\'s own copy of the teardown (D385)', async (t) => {
const calls = stub(t, {
answer: () => ok({ kind: 'command.ran', ran: 2, replies: [{ command: 'rbe 1', reply: 'Raid base spawned' }, { command: 'say a raid base is up', reply: '' }] }),
})
const result = await run.perform({ ...CTX, params: RUN })
assert.deepStrictEqual(calls.sent, [{ server: 'main', body: { key: CTX.idempotencyKey, commands: RUN.commands, teardown: ['rbe stop'] } }])
assert.strictEqual(result.ok, true)
assert.deepStrictEqual(result.resources, [
{ kind: cmds.TEARDOWN_KIND, ref: 'main:7:3', payload: { serverId: 'main', key: CTX.idempotencyKey, teardown: ['rbe stop'] } },
])
assert.strictEqual(result.detail.ran, 2)
assert.deepStrictEqual(result.detail.replies[0], { command: 'rbe 1', reply: 'Raid base spawned' })
assert.match(result.detail.teardown, /1 command/)
})
test('a run with no teardown leaves nothing to undo and sends none', async (t) => {
const calls = stub(t)
const result = await run.perform({ ...CTX, params: { ...RUN, teardown: [] } })
assert.strictEqual(result.ok, true)
assert.strictEqual(result.resources, undefined)
assert.strictEqual('teardown' in calls.sent[0].body, false)
})
test('a repeat is answered from the first attempt, and says so', async (t) => {
stub(t, { answer: () => ok({ kind: 'command.ran', ran: 2, repeat: true, replies: [] }) })
const result = await run.perform({ ...CTX, params: RUN })
assert.strictEqual(result.ok, true)
assert.strictEqual(result.detail.repeat, true)
assert.match(result.detail.note, /nothing was run twice/)
})
test('a misspelled command is permanent unknown-command, saying how many ran before it', async (t) => {
stub(t, { answer: () => ok({ kind: 'command.error', reason: 'unknown-command', message: "the server does not know the command 'rbee'", ran: 1 }) })
const result = await run.perform({ ...CTX, params: RUN })
assert.deepStrictEqual([result.ok, result.retry], [false, false])
assert.match(result.error, /^Main: the server does not know the command 'rbee' \(1 ran before it\)$/)
})
test('events switched off on the server is permanent; a world still loading and a lost answer are not', async (t) => {
let answer = ok({ kind: 'world.error', reason: 'events-disabled', message: 'events are switched off on this server' })
stub(t, { answer: () => answer })
const off = await run.perform({ ...CTX, params: RUN })
assert.deepStrictEqual([off.ok, off.retry], [false, false])
answer = ok({ kind: 'world.error', reason: 'not-ready', message: 'the server is still loading its world' })
const loading = await run.perform({ ...CTX, params: RUN })
assert.deepStrictEqual([loading.ok, loading.retry], [false, undefined])
answer = { ok: false, status: 'http-504' }
const lost = await run.perform({ ...CTX, params: RUN })
assert.deepStrictEqual([lost.ok, lost.retry], [false, undefined])
assert.match(lost.error, /did not answer/)
})
// ── The teardown (§16.2, D385) ─────────────────────────────────────────────
test('a revert runs the ledger\'s copy once, under its own key, on the server it names', async (t) => {
const calls = stub(t, { answer: () => ok({ kind: 'command.ran', ran: 1, replies: [] }) })
const resource = { kind: cmds.TEARDOWN_KIND, ref: 'main:7:3', payload: { serverId: 'main', key: CTX.idempotencyKey, teardown: ['rbe stop'] } }
assert.deepStrictEqual(await run.revert({ runId: 7, resources: [resource], idempotencyKey: CTX.idempotencyKey }), { ok: true })
assert.deepStrictEqual(calls.sent, [{ server: 'main', body: { key: cmds.teardownKey(CTX.idempotencyKey), commands: ['rbe stop'] } }])
})
test('a teardown the server refuses leaves the row failed and visible', async (t) => {
stub(t, { answer: () => ok({ kind: 'command.error', reason: 'unknown-command', message: "the server does not know the command 'rbe'", ran: 0 }) })
const resource = { kind: cmds.TEARDOWN_KIND, ref: 'main:7:3', payload: { serverId: 'main', key: CTX.idempotencyKey, teardown: ['rbe stop'] } }
const result = await run.revert({ runId: 7, resources: [resource], idempotencyKey: CTX.idempotencyKey })
assert.strictEqual(result.ok, false)
assert.match(result.error, /does not know the command 'rbe'/)
})
test('a revert with only the key asks every enabled server to run what it kept (D385)', async (t) => {
const PVE = { ...ROW, id: 'pve', name: 'PvE' }
const calls = stub(t, { enabled: [ROW, PVE], answer: () => ok({ kind: 'command.ran', ran: 0, replies: [] }) })
assert.deepStrictEqual(await run.revert({ runId: 7, resources: [], idempotencyKey: CTX.idempotencyKey }), { ok: true })
assert.deepStrictEqual(calls.sent.map((c) => c.server), ['main', 'pve'])
for (const c of calls.sent) assert.deepStrictEqual(c.body, { key: cmds.teardownKey(CTX.idempotencyKey), teardownOf: CTX.idempotencyKey })
})
test('a server that cannot be asked about a lost answer keeps the row visible', async (t) => {
stub(t, { answer: () => ({ ok: false, status: 'http-503' }) })
const result = await run.revert({ runId: 7, resources: [], idempotencyKey: CTX.idempotencyKey })
assert.strictEqual(result.ok, false)
assert.match(result.error, /no game connected/)
})
test('a teardown not yet run is always in force: the site cannot look', async () => {
const resources = [{ ref: 'main:7:3' }, { ref: 'pve:7:4' }]
assert.deepStrictEqual(await run.reconcile({ runId: 7, resources }), { ok: true, inForce: ['main:7:3', 'pve:7:4'] })
})
// ── Reward by console command ──────────────────────────────────────────────
test('a reward sends Steam ids and names to the bridge, which fills the placeholders, not the site', async (t) => {
const calls = stub(t, {
people: [person(A, 5, 'Some "One"'), person(B, 0), person(C, 2)],
answer: () => ok({ kind: 'command.ran', ran: 1, rewarded: 1, skipped: ['p3'], skippedCount: 1, replies: [{ command: `sr add ${A} 100`, reply: 'added', steamId: A }] }),
})
const result = await reward.perform({ ...CTX, params: REWARD })
// Everyone who scored: A and C, never B. The command goes untouched.
assert.deepStrictEqual(calls.sent, [{
server: 'main',
body: { key: CTX.idempotencyKey, commands: ['sr add {steamid} 100'], recipients: [{ steamId: A, name: 'Some "One"' }, { steamId: C, name: 'p3' }] },
}])
assert.strictEqual(result.ok, true)
assert.deepStrictEqual([result.detail.people, result.detail.rewarded], [2, 1])
assert.deepStrictEqual([result.detail.skipped, result.detail.skippedCount], [['p3'], 1])
assert.match(result.detail.skippedNote, /D383/)
assert.strictEqual(result.resources, undefined)
})
test('linked reads no tally and needs no website user beyond the link', async (t) => {
const calls = stub(t, { linked: [{ steamId: A, userId: 1, name: 'Ann' }, { steamId: B, userId: 2, name: null }] })
const result = await reward.perform({ ...CTX, params: { ...REWARD, recipients: 'linked' } })
assert.strictEqual(result.ok, true)
assert.deepStrictEqual(calls.sent[0].body.recipients, [{ steamId: A, name: 'Ann' }, { steamId: B, name: B }])
})
test('nobody qualifying runs nothing, and the bridge is not asked', async (t) => {
const calls = stub(t, { people: [person(A, 0)] })
const result = await reward.perform({ ...CTX, params: REWARD })
assert.strictEqual(result.ok, true)
assert.strictEqual(result.detail.ran, 0)
assert.strictEqual(calls.sent.length, 0)
})
test('more than a hundred is refused before anything runs', async (t) => {
const many = Array.from({ length: 101 }, (_, i) => ({ steamId: String(76561198000001000n + BigInt(i)), userId: i + 1, name: `n${i}` }))
const calls = stub(t, { linked: many })
const result = await reward.perform({ ...CTX, params: { ...REWARD, recipients: 'linked' } })
assert.deepStrictEqual([result.ok, result.retry], [false, false])
assert.match(result.error, /more than 100 linked players/)
assert.strictEqual(calls.sent.length, 0)
const top = await reward.perform({ ...CTX, params: { ...REWARD, recipients: 'top', count: 101 }, verify: true })
assert.strictEqual(top.ok, false)
})
test('the run log shows a bounded, plain view of the answer', () => {
const detail = cmds.replyDetail({ ran: '3', replies: [{ command: 'a', reply: 5 }], skipped: ['x'], skippedCount: 4, repeat: true })
assert.deepStrictEqual(detail.replies, [{ command: 'a', reply: '5' }])
assert.deepStrictEqual([detail.ran, detail.skippedCount, detail.repeat], [3, 4, true])
assert.deepStrictEqual(cmds.replyDetail({}), { ran: 0 })
})

View File

@@ -158,6 +158,8 @@ test('the event verbs are registered, and every budget has a verb that spends it
'rust.announce',
'rust.backpack.size',
'rust.coins.pay',
'rust.command.reward',
'rust.command.run',
'rust.crate.place',
'rust.dt.start',
'rust.gc.place',
@@ -174,6 +176,7 @@ test('the event verbs are registered, and every budget has a verb that spends it
'rust.announcements',
'rust.backpacks',
'rust.coins',
'rust.commands',
'rust.dt.events',
'rust.gc.crates',
'rust.grants',
@@ -209,8 +212,9 @@ test('the event verbs are registered, and every budget has a verb that spends it
// not undone, and nor is a world event called (D354): a cargo ship or a drop
// may already hold players or their loot; nor a Dangerous Treasures event
// started, which DT despawns itself (§14.3.4); nor a guarded crate placed,
// which Guarded Crate cannot end alone (§14.3.5, D373).
const once = new Set(['rust.announce', 'rust.participation.collect', 'rust.coins.pay', 'rust.perm.give', 'rust.world.call', 'rust.dt.start', 'rust.gc.place'])
// which Guarded Crate cannot end alone (§14.3.5, D373); nor a reward run by
// console command, which no command can run backwards (§16.3).
const once = new Set(['rust.announce', 'rust.participation.collect', 'rust.coins.pay', 'rust.perm.give', 'rust.world.call', 'rust.dt.start', 'rust.gc.place', 'rust.command.reward'])
for (const a of actions) {
assert.strictEqual(a.reversible, once.has(a.id) ? 'none' : 'ledger', a.id)
// Every param source is one this module registers.