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:
300
server/test/commandSteps.test.js
Normal file
300
server/test/commandSteps.test.js
Normal 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 })
|
||||
})
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user