feat(rust): Run console commands and Reward by console command (PLAN_REDESIGNS §16)
Two event steps in server/eventCommands.js, on one new budget,
`rust.commands` (D382-D385):
- rust.command.run: up to 10 typed commands once on one server, with
optional teardown commands. irreversible / ledger: the ledger row keeps
the teardown as it was, and core reverts it when the run ends or is
cancelled. Priced one unit per command, teardown included.
- rust.command.reward: the kit reward's recipient picker (tally modes and
linked), by Steam id, so no website account is needed. The bridge fills
{steamid} and {name}; offline recipients are skipped and named (D383).
irreversible / none. Priced commands x people, the bound for a mode that
is not a count.
Both take core's `multiple` string field as a free text box (no
allowlist, D384) and a required "I am sure" toggle. {steamid}/{name} in a
run step are refused. unknown-command, malformed, events-disabled,
interrupted are permanent; the error says how many ran before it.
D385: the run also hands its teardown to the bridge, which keeps it with
the key. A revert with only the key (the step failed, or its answer was
lost, so core recorded nothing) asks every enabled server to run what it
kept; a server the step never reached runs nothing.
sidecarClient.commandsRun -> POST /commands/run; ci/bundle.json lists the
new file; entry.test.js pins the new ids; 21 tests in
test/commandSteps.test.js.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
471
server/eventCommands.js
Normal file
471
server/eventCommands.js
Normal 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,
|
||||
}
|
||||
Reference in New Issue
Block a user