Files
Module-Rust/server/test/rewards.test.js
wtclaude cc185db26b feat(rust): the rewards — tally, kit reward, chat and the news leg (phase 13b, protocol 10)
Four event verbs and the announce leg, per PLAN.md §29:

- rust.participation.open / .collect: the plugin counts who takes part
  (seconds, kills or both, in a zone this run opened or the whole server)
  and collect files them as the run's participants, keyed by Steam id.
- rust.kit.entitle: the five recipient modes (D101), rows in the new
  rust_perm_run_grants (D84) unioned into the permission push, one extra
  use of the kit per reward as site-held credits on perm.sync (D103),
  and the rust.kit.entitled notice deferred from phase 10 (D64).
- rust.announce: one server or every server (D105).
- rust.chat announce leg, speaking only on servers whose new news switch
  is on (D104) - a card on Admin -> Rust visibility (D106).

Budgets rust.grants and rust.announcements; the kit source and four
fixed-choice sources (core has no enum param type). rust_perm_run_grants
carries core's idempotency key so a revert of a lost answer can find its
rows. Protocol 10.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-24 07:00:44 -05:00

420 lines
21 KiB
JavaScript

// ── The rewards (PLAN.md §29, protocol 10) ────────────────────────────────
//
// Who took part, what they may redeem, and a line in chat. Every test here is
// one of the ways the contract's half can look right and be wrong:
//
// each recipient mode picks what D101 says, ties in, and a retried draw is the same draw
// a reward is priced before the tally is read, so at the most it could grant
// a kit that rewards nothing is refused, and a mode's count is checked
// one person with two accounts is one reward, and an unlinked winner is named
// a repeated key writes nothing new; a revert deletes the step's rows and pushes
// a lost answer is reverted by its key
// an entitlement is always in force — the site holds it
// a tally's teardown closes it; "cannot ask" is not "gone"
// a line to every server succeeds for those that took it and names the rest
// the news leg speaks only where the switch is on, and retries only when all are down
const test = require('node:test')
const assert = require('node:assert')
const { fakeCtx } = require('./_fakes')
require('../core')._reset()
require('../core').init(fakeCtx())
const client = require('../sidecarClient')
const serversDb = require('../model/servers/servers.db')
const servers = require('../model/servers/servers.model')
const permDb = require('../model/permissions/permissions.db')
const linksDb = require('../model/links/links.db')
const emit = require('../engagement/emit')
const rewards = require('../eventRewards')
const action = (id) => rewards.ACTIONS.find((a) => a.id === id)
const source = (id) => rewards.OPTION_SOURCES.find((s) => s.id === id)
const ROWS = {
main: { id: 'main', name: 'Main', sidecarBaseUrl: 'http://main:1', sidecarTokenEnc: null, enabled: 1 },
alt: { id: 'alt', name: 'Alt', sidecarBaseUrl: 'http://alt:1', sidecarTokenEnc: null, enabled: 1 },
}
const ok = (data) => ({ ok: true, status: 'ok', data })
const person = (steamId, score, extra = {}) => ({ steamId, name: `p${steamId}`, seconds: 60, kills: 0, score, joinedAt: Number(steamId), ...extra })
const KIT = { name: 'vip', permission: 'kits.vip', max: 1, cooldown: 0 }
/** Replace the module's collaborators for one test, and put them back after. */
function stub(t, { kits, snapshot, tallyOpen, tallyClose, chat, polling, links, existing } = {}) {
const calls = { grants: [], dirty: [], deleted: [], emitted: [], chat: [], open: [], close: [] }
const saved = {
getServer: serversDb.getServer,
listForPolling: servers.listForPolling,
kits: client.kits,
tallySnapshot: client.tallySnapshot,
tallyOpen: client.tallyOpen,
tallyClose: client.tallyClose,
chat: client.chat,
insertRunGrants: permDb.insertRunGrants,
listRunGrantsForStep: permDb.listRunGrantsForStep,
deleteRunGrantsForStep: permDb.deleteRunGrantsForStep,
deleteRunGrantsForKey: permDb.deleteRunGrantsForKey,
markDirty: permDb.markDirty,
userIdsForSteamIds: linksDb.userIdsForSteamIds,
entitled: emit.entitled,
}
serversDb.getServer = async (id) => ROWS[id] || null
servers.listForPolling = async () =>
(polling || [{ id: 'main' }]).map((s) => ({ name: ROWS[s.id] ? ROWS[s.id].name : s.id, baseUrl: `http://${s.id}:1`, token: 't', ...s }))
client.kits = async (server) => (kits ? kits(server) : ok({ kind: 'kits.list', kits: [KIT], maxRecipients: 100 }))
client.tallySnapshot = async (server, runId) =>
snapshot ? snapshot(server, runId) : ok({ kind: 'tally.snapshot', runId, people: [], maxRecipients: 100 })
client.tallyOpen = async (server, body) => {
calls.open.push({ server: server.id, body })
return tallyOpen ? tallyOpen(server, body) : ok({ kind: 'tally.ok', runId: body.runId })
}
client.tallyClose = async (server, body) => {
calls.close.push({ server: server.id, body })
return tallyClose ? tallyClose(server, body) : ok({ kind: 'tally.ok', closed: true })
}
client.chat = async (server, body) => {
calls.chat.push({ server: server.id, body })
return chat ? chat(server, body) : ok({ kind: 'chat.ok', said: true })
}
permDb.insertRunGrants = async (rows) => {
calls.grants.push(...rows)
return rows.length
}
permDb.listRunGrantsForStep = async () => existing || []
permDb.deleteRunGrantsForStep = async (runId, stepId) => {
calls.deleted.push({ runId, stepId })
return ['main']
}
permDb.deleteRunGrantsForKey = async (runId, key) => {
calls.deleted.push({ runId, key })
return key ? ['main'] : []
}
permDb.markDirty = async (scope) => calls.dirty.push(scope)
linksDb.userIdsForSteamIds = async (ids) =>
ids.filter((id) => links && links[id]).map((id) => ({ steamId: id, userId: links[id] }))
emit.entitled = (args) => {
calls.emitted.push(args)
return (args.userIds || []).length
}
t.after(() => {
serversDb.getServer = saved.getServer
servers.listForPolling = saved.listForPolling
Object.assign(client, {
kits: saved.kits,
tallySnapshot: saved.tallySnapshot,
tallyOpen: saved.tallyOpen,
tallyClose: saved.tallyClose,
chat: saved.chat,
})
Object.assign(permDb, {
insertRunGrants: saved.insertRunGrants,
listRunGrantsForStep: saved.listRunGrantsForStep,
deleteRunGrantsForStep: saved.deleteRunGrantsForStep,
deleteRunGrantsForKey: saved.deleteRunGrantsForKey,
markDirty: saved.markDirty,
})
linksDb.userIdsForSteamIds = saved.userIdsForSteamIds
emit.entitled = saved.entitled
})
return calls
}
// ── Picking ──────────────────────────────────────────────────────────────────
const TALLY = [person('1', 50), person('2', 40), person('3', 40), person('4', 10), person('5', 0)]
const ids = (list) => list.map((p) => p.steamId)
test('every verb outlives the client, which outlives the sidecar', () => {
assert.ok(10000 < client.TIMEOUT_MS)
for (const a of rewards.ACTIONS) assert.ok(client.TIMEOUT_MS < a.budgetMs, `${a.id} budgetMs`)
})
test('everyone is everyone who scored; a score of zero earns nothing (D101)', () => {
assert.deepStrictEqual(ids(rewards.pickRecipients(TALLY, 'everyone', null, 'k')), ['1', '2', '3', '4'])
})
test('top N keeps everybody tied with the last one in', () => {
assert.deepStrictEqual(ids(rewards.pickRecipients(TALLY, 'top', 1, 'k')), ['1'])
assert.deepStrictEqual(ids(rewards.pickRecipients(TALLY, 'top', 2, 'k')), ['1', '2', '3'])
assert.deepStrictEqual(ids(rewards.pickRecipients(TALLY, 'top', 99, 'k')), ['1', '2', '3', '4'])
})
test('top per cent rounds up and keeps ties — 10% of 11 is 2 (§29.2)', () => {
const eleven = Array.from({ length: 11 }, (_, i) => person(String(i + 1), 100 - i))
assert.deepStrictEqual(ids(rewards.pickRecipients(eleven, 'topPercent', 10, 'k')), ['1', '2'])
// 25% of the four who scored is one, and the tie at 40 does not arise — but
// 50% is two, which lands on a tie and takes both.
assert.deepStrictEqual(ids(rewards.pickRecipients(TALLY, 'topPercent', 25, 'k')), ['1'])
assert.deepStrictEqual(ids(rewards.pickRecipients(TALLY, 'topPercent', 50, 'k')), ['1', '2', '3'])
})
test('a minimum score is at least X, and zero means everyone who took part', () => {
assert.deepStrictEqual(ids(rewards.pickRecipients(TALLY, 'minScore', 40, 'k')), ['1', '2', '3'])
assert.strictEqual(rewards.pickRecipients(TALLY, 'minScore', 0, 'k').length, 5)
})
test('a retried draw draws the same winners, and another key draws differently', () => {
const many = Array.from({ length: 40 }, (_, i) => person(String(1000 + i), 0))
const first = ids(rewards.pickRecipients(many, 'random', 5, 'key-a'))
assert.strictEqual(first.length, 5)
assert.deepStrictEqual(ids(rewards.pickRecipients([...many].reverse(), 'random', 5, 'key-a')), first)
assert.notDeepStrictEqual(ids(rewards.pickRecipients(many, 'random', 5, 'key-b')), first)
})
test('a count is checked against its mode', () => {
assert.strictEqual(rewards.checkCount('everyone', undefined).ok, true)
assert.strictEqual(rewards.checkCount('top', 0).ok, false)
assert.strictEqual(rewards.checkCount('top', 101).ok, false)
assert.strictEqual(rewards.checkCount('random', 2.5).ok, false)
assert.strictEqual(rewards.checkCount('topPercent', 0).ok, false)
assert.strictEqual(rewards.checkCount('topPercent', 100).ok, true)
assert.strictEqual(rewards.checkCount('minScore', -1).ok, false)
assert.strictEqual(rewards.checkCount('minScore', 12.5).ok, true)
})
test('a reward is priced at the most it could grant, before the tally is read (§29.2)', () => {
const cost = action('rust.kit.entitle').cost
assert.deepStrictEqual(cost({ recipients: 'top', count: 3 }), { 'rust.grants': 3 })
assert.deepStrictEqual(cost({ recipients: 'random', count: 7 }), { 'rust.grants': 7 })
for (const mode of ['everyone', 'minScore', 'topPercent']) {
assert.deepStrictEqual(cost({ recipients: mode, count: 10 }), { 'rust.grants': rewards.MAX_RECIPIENTS }, mode)
}
})
test('the kit source says what a reward of each kit gives, and flags one that gives nothing', async (t) => {
stub(t, {
kits: () => ok({
kind: 'kits.list',
kits: [
{ name: 'vip', permission: 'kits.vip', max: 0 },
{ name: 'starter', permission: '', max: 3 },
{ name: 'free', permission: '', max: 0 },
],
}),
})
const rows = await source('rust.options.kits').resolve({})
assert.deepStrictEqual(rows.map((r) => r.value), ['main/vip', 'main/starter', 'main/free'])
assert.strictEqual(rows[0].label, 'vip')
assert.match(rows[1].label, /open to everyone · 3 uses/)
assert.match(rows[2].label, /rewards nothing/)
})
// ── rust.kit.entitle ─────────────────────────────────────────────────────────
const ENTITLE = { runId: 41, stepId: 7, idempotencyKey: 'key-41-7', params: { kit: 'main/vip', recipients: 'top', count: 2 } }
test('the reward writes one row per linked winner, credits the account that played, and pushes', async (t) => {
const calls = stub(t, {
snapshot: () => ok({ kind: 'tally.snapshot', people: TALLY, maxRecipients: 100 }),
links: { 1: 10, 2: 20 },
})
const res = await action('rust.kit.entitle').perform(ENTITLE)
assert.strictEqual(res.ok, true)
assert.deepStrictEqual(res.resources, [{ kind: 'entitlement', ref: 'main:41:7', payload: { serverId: 'main', kit: 'vip' } }])
assert.deepStrictEqual(calls.grants.map((r) => [r.userId, r.steamId, r.permission, r.credit, r.idemKey]), [
[10, '1', 'kits.vip', true, 'key-41-7'],
[20, '2', 'kits.vip', true, 'key-41-7'],
])
assert.deepStrictEqual(calls.dirty, ['main'])
// Top 2 is three people with the tie; the third linked nothing and is named.
assert.strictEqual(res.detail.granted, 2)
assert.deepStrictEqual(res.detail.missed, ['p3'])
assert.deepStrictEqual(calls.emitted[0].userIds, [10, 20])
})
test('two accounts one person holds are one reward, on the higher-scoring account', async (t) => {
const calls = stub(t, {
snapshot: () => ok({ kind: 'tally.snapshot', people: [person('1', 5), person('2', 9)] }),
links: { 1: 10, 2: 10 },
})
await action('rust.kit.entitle').perform({ ...ENTITLE, params: { kit: 'main/vip', recipients: 'everyone' } })
assert.deepStrictEqual(calls.grants.map((r) => r.steamId), ['2'])
})
test('a kit with no use limit is a permission and no credit; a kit that rewards nothing is refused', async (t) => {
let calls = stub(t, {
kits: () => ok({ kind: 'kits.list', kits: [{ name: 'vip', permission: 'kits.vip', max: 0 }] }),
snapshot: () => ok({ kind: 'tally.snapshot', people: [person('1', 5)] }),
links: { 1: 10 },
})
await action('rust.kit.entitle').perform(ENTITLE)
assert.strictEqual(calls.grants[0].credit, false)
calls = stub(t, { kits: () => ok({ kind: 'kits.list', kits: [{ name: 'vip', permission: '', max: 0 }] }) })
const res = await action('rust.kit.entitle').perform(ENTITLE)
assert.strictEqual(res.ok, false)
assert.strictEqual(res.retry, false)
assert.match(res.error, /gives nobody anything/)
})
test('a dry run checks the kit and the mode and reads no tally', async (t) => {
let read = false
const calls = stub(t, { snapshot: () => { read = true; return ok({ kind: 'tally.snapshot', people: [] }) } })
const res = await action('rust.kit.entitle').perform({ ...ENTITLE, verify: true })
assert.deepStrictEqual(res, { ok: true })
assert.strictEqual(read, false)
assert.strictEqual(calls.grants.length, 0)
const bad = await action('rust.kit.entitle').perform({ ...ENTITLE, verify: true, params: { kit: 'vip', recipients: 'top', count: 2 } })
assert.strictEqual(bad.retry, false)
assert.match(bad.error, /server\/kit/)
})
test('a repeated key finds its rows and writes nothing new', async (t) => {
const calls = stub(t, { existing: [{ userId: 10 }, { userId: 20 }] })
const res = await action('rust.kit.entitle').perform(ENTITLE)
assert.strictEqual(res.ok, true)
assert.strictEqual(res.detail.repeat, true)
assert.strictEqual(calls.grants.length, 0)
assert.strictEqual(calls.emitted.length, 0)
})
test('more qualifying than the server rewards is refused for good, never trimmed (§29.5)', async (t) => {
stub(t, {
snapshot: () => ok({ kind: 'tally.snapshot', people: TALLY, maxRecipients: 3 }),
links: { 1: 1, 2: 2, 3: 3, 4: 4 },
})
const res = await action('rust.kit.entitle').perform({ ...ENTITLE, params: { kit: 'main/vip', recipients: 'everyone' } })
assert.strictEqual(res.ok, false)
assert.strictEqual(res.retry, false)
assert.match(res.error, /4 people qualify/)
})
test('no tally on the server is refused for good, and names the verb that opens one', async (t) => {
stub(t, { snapshot: () => ok({ kind: 'tally.error', reason: 'no-tally' }) })
const res = await action('rust.kit.entitle').perform(ENTITLE)
assert.strictEqual(res.retry, false)
assert.match(res.error, /rust\.participation\.open/)
})
test('a revert deletes the step\'s rows and pushes; a lost answer is found by its key', async (t) => {
const calls = stub(t)
const a = action('rust.kit.entitle')
assert.deepStrictEqual(await a.revert({ runId: 41, resources: [{ kind: 'entitlement', ref: 'main:41:7' }] }), { ok: true })
assert.deepStrictEqual(calls.deleted[0], { runId: '41', stepId: '7' })
assert.deepStrictEqual(calls.dirty, ['main'])
assert.deepStrictEqual(await a.revert({ runId: 41, resources: [], idempotencyKey: 'key-41-7' }), { ok: true })
assert.deepStrictEqual(calls.deleted[1], { runId: 41, key: 'key-41-7' })
})
test('an entitlement is always in force: the site holds it, and a wipe cannot take it', async () => {
const res = await action('rust.kit.entitle').reconcile({ runId: 41, resources: [{ ref: 'main:41:7' }] })
assert.deepStrictEqual(res, { ok: true, inForce: ['main:41:7'] })
})
// ── The tally ────────────────────────────────────────────────────────────────
test('a tally crosses with its key, its score and its zone; kills need a whose', async (t) => {
const calls = stub(t)
const open = action('rust.participation.open')
const res = await open.perform({
runId: 41,
idempotencyKey: 'k',
params: { server: 'main', zone: 'Arena', score: 'Both', killsOf: 'npcs', minutes: 30 },
})
assert.strictEqual(res.ok, true)
assert.deepStrictEqual(calls.open[0].body, { runId: '41', key: 'k', score: 'both', killsOf: 'npcs', killWeight: 5, holdMs: 1800000, zone: 'Arena' })
assert.strictEqual(res.resources[0].ref, 'main:41')
const missing = await open.perform({ runId: 41, params: { server: 'main', score: 'kills' } })
assert.strictEqual(missing.retry, false)
})
test('the plugin\'s permanent refusals stay refused; the switch is named', async (t) => {
stub(t, { tallyOpen: () => ok({ kind: 'tally.error', reason: 'events-disabled', message: 'events are switched off' }) })
const res = await action('rust.participation.open').perform({ runId: 1, params: { server: 'main', score: 'seconds' } })
assert.strictEqual(res.retry, false)
assert.match(res.error, /switched off/)
})
test('collect files each person by Steam id, with the website user where linked', async (t) => {
stub(t, {
snapshot: () => ok({ kind: 'tally.snapshot', people: [person('1', 3.5, { kills: 1 }), person('2', 1)] }),
links: { 1: 10 },
})
const res = await action('rust.participation.collect').perform({ runId: 41, params: { server: 'main' } })
assert.strictEqual(res.participants.length, 2)
assert.deepStrictEqual(res.participants[0], {
memberKey: '1',
userId: 10,
score: 3.5,
joinedAt: new Date(1).toISOString(),
meta: { name: 'p1', seconds: 60, kills: 1 },
})
assert.strictEqual(res.participants[1].userId, undefined)
})
test('teardown closes the tally; a lost answer closes it on every server; "cannot ask" keeps it in force', async (t) => {
const calls = stub(t, { polling: [{ id: 'main' }, { id: 'alt' }] })
const open = action('rust.participation.open')
assert.deepStrictEqual(await open.revert({ runId: 41, resources: [{ ref: 'main:41', payload: { serverId: 'main' } }] }), { ok: true })
assert.deepStrictEqual(calls.close.map((c) => c.server), ['main'])
await open.revert({ runId: 41, resources: [] })
assert.deepStrictEqual(calls.close.map((c) => c.server), ['main', 'main', 'alt'])
stub(t, { snapshot: () => ({ ok: false, status: 'http-503' }) })
assert.deepStrictEqual(await open.reconcile({ runId: 41, resources: [{ ref: 'main:41' }] }), { ok: true, inForce: ['main:41'] })
stub(t, { snapshot: () => ok({ kind: 'tally.error', reason: 'no-tally' }) })
assert.deepStrictEqual(await open.reconcile({ runId: 41, resources: [{ ref: 'main:41' }] }), { ok: true, inForce: [] })
})
// ── Chat ─────────────────────────────────────────────────────────────────────
test('one server: its answer is the step\'s, and a line carries the key and says it is an event\'s', async (t) => {
const calls = stub(t, { chat: () => ({ ok: false, status: 'http-503' }) })
const res = await action('rust.announce').perform({ runId: 1, idempotencyKey: 'k1', params: { server: 'main', message: ' Go\n now ' } })
assert.strictEqual(res.ok, false)
assert.notStrictEqual(res.retry, false)
assert.deepStrictEqual(calls.chat[0].body, { key: 'k1', message: 'Go now', event: true })
})
test('every server: a success for those that took it, the rest named (D104, D105)', async (t) => {
stub(t, {
polling: [{ id: 'main' }, { id: 'alt' }],
chat: (server) => (server.id === 'alt' ? { ok: false, status: 'http-503' } : ok({ kind: 'chat.ok', said: true })),
})
const res = await action('rust.announce').perform({ runId: 1, idempotencyKey: 'k', params: { server: '*', message: 'hi' } })
assert.strictEqual(res.ok, true)
assert.deepStrictEqual(res.detail, { said: ['Main'], down: ['Alt'] })
})
test('a line too long is refused on the form', async (t) => {
stub(t)
const res = await action('rust.announce').perform({ runId: 1, params: { server: 'main', message: 'x'.repeat(rewards.MAX_CHAT + 1) }, verify: true })
assert.strictEqual(res.retry, false)
})
test('the news leg speaks only where the switch is on, keyed by the post', async (t) => {
const calls = stub(t, { polling: [{ id: 'main', announceNews: true }, { id: 'alt', announceNews: false }] })
const result = await rewards.LEG.dispatch({ id: 9, title: 'Wipe tonight', excerpt: 'Long text' })
assert.deepStrictEqual(calls.chat, [{ server: 'main', body: { key: 'news:9', message: 'Wipe tonight' } }])
assert.deepStrictEqual(rewards.LEG.classify(result), { outcome: 'done' })
})
test('the leg: nobody switched on is done; all down is retry; some down is done and named', () => {
const { classify } = rewards.LEG
assert.deepStrictEqual(classify({ ok: true, outcomes: [] }), { outcome: 'done' })
assert.strictEqual(classify({ ok: true, outcomes: [{ server: 'A', state: 'down' }] }).outcome, 'retry')
const some = classify({ ok: true, outcomes: [{ server: 'A', state: 'down' }, { server: 'B', state: 'said' }] })
assert.strictEqual(some.outcome, 'done')
assert.match(some.error, /skipped \(down\): A/)
assert.strictEqual(classify({ ok: false, empty: true, outcomes: [] }).outcome, 'terminal')
})
test('a post with no id is keyed by what it says; a long title is bounded to one line', () => {
const line = rewards.chatLine({ title: 'x'.repeat(400) })
assert.strictEqual(line.length, rewards.MAX_CHAT)
assert.match(rewards.chatKey({ title: 'Hi' }, 'Hi'), /^news:[0-9a-f]{40}$/)
assert.strictEqual(rewards.chatLine({ title: null, excerpt: 'Body\ntext' }), 'Body text')
})