diff --git a/README.md b/README.md index a2841e0..00a0c56 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/ci/bundle.json b/ci/bundle.json index 2242708..bb3dc3b 100644 --- a/ci/bundle.json +++ b/ci/bundle.json @@ -35,6 +35,7 @@ "dangerousTreasures.js", "db", "dynamicPvp.js", + "eventCommands.js", "engagement", "eventDangerousTreasures.js", "eventGuardedCrate.js", diff --git a/server/eventCommands.js b/server/eventCommands.js new file mode 100644 index 0000000..1375d67 --- /dev/null +++ b/server/eventCommands.js @@ -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, +} diff --git a/server/index.js b/server/index.js index 1e10927..4e6dcd5 100644 --- a/server/index.js +++ b/server/index.js @@ -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, diff --git a/server/sidecarClient.js b/server/sidecarClient.js index 3ca1257..1f049a8 100644 --- a/server/sidecarClient.js +++ b/server/sidecarClient.js @@ -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, diff --git a/server/test/commandSteps.test.js b/server/test/commandSteps.test.js new file mode 100644 index 0000000..6ef9e85 --- /dev/null +++ b/server/test/commandSteps.test.js @@ -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 }) +}) diff --git a/server/test/entry.test.js b/server/test/entry.test.js index bb421e6..b1794bd 100644 --- a/server/test/entry.test.js +++ b/server/test/entry.test.js @@ -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.