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
This commit is contained in:
2026-09-24 07:00:44 -05:00
parent 9753dda4af
commit cc185db26b
27 changed files with 2096 additions and 45 deletions

View File

@@ -140,9 +140,10 @@ test('the ceilings are the ones §25.2 decided', () => {
for (const id of ['rust.clan.member.left', 'rust.clan.member.kicked', 'rust.clan.disbanded']) {
assert.strictEqual(by[id], 'members', id)
}
// D64: core's team.member.joined already covers it, and kits wait for phase 13.
// D64: core's team.member.joined already covers it. The kit reward waited for
// the phase that grants one (13b), and a reward is nobody else's news.
assert.strictEqual(by['rust.clan.member.added'], undefined)
assert.strictEqual(by['rust.kit.entitled'], undefined)
assert.strictEqual(by['rust.kit.entitled'], 'owner')
})
test('a clan path survives core\'s url check, colons and all', () => {

View File

@@ -143,34 +143,58 @@ test('nothing is registered that has nothing behind it yet', () => {
// leases and option sources, and kept budgets here on purpose (D79): a lease
// spends none, and a dimension nothing spends is a dial that does nothing.
// Phase 13a deleted the budgets and actions lines, and registered each budget
// beside the verb that spends it (below). The announce leg is 13b's.
assert.deepStrictEqual(api.record.legs, [])
// beside the verb that spends it (below). Phase 13b registered the announce
// leg (D104) — the post hook is still absent: nothing in game mirrors a post.
assert.deepStrictEqual(api.record.legs.map((l) => l.leg), ['rust.chat'])
assert.strictEqual(api.record.hooks.post, undefined)
})
test('the world verbs are registered, and every budget has a verb that spends it (phase 13a)', () => {
test('the event verbs are registered, and every budget has a verb that spends it (phases 13a, 13b)', () => {
const { api } = register()
const actions = api.record.eventActions
const budgets = api.record.eventBudgets
assert.deepStrictEqual(actions.map((a) => a.id).sort(), ['rust.crate.place', 'rust.npc.place', 'rust.zone.open'])
assert.deepStrictEqual(budgets.map((b) => b.id).sort(), ['rust.npcs', 'rust.prefabs', 'rust.zone.minutes'])
assert.deepStrictEqual(actions.map((a) => a.id).sort(), [
'rust.announce',
'rust.crate.place',
'rust.kit.entitle',
'rust.npc.place',
'rust.participation.collect',
'rust.participation.open',
'rust.zone.open',
])
assert.deepStrictEqual(budgets.map((b) => b.id).sort(), [
'rust.announcements',
'rust.grants',
'rust.npcs',
'rust.prefabs',
'rust.zone.minutes',
])
// D79/D89/D97: every dimension has a verb that spends it, and each verb spends
// exactly ONE — priced from its own declared examples, which is how core
// decides which cap boxes the switchboard shows. A verb whose dimension moved
// with its params would hide the other dial from every operator.
// D79/D89/D97: every dimension has a verb that spends it, and a verb that
// spends anything spends exactly ONE — priced from its own declared examples,
// which is how core decides which cap boxes the switchboard shows. A verb
// whose dimension moved with its params would hide the other dial from every
// operator. The two tally verbs spend nothing: counting people costs no loot.
const spent = new Set()
for (const a of actions) {
const example = Object.fromEntries(a.params.map((p) => [p.name, p.example]))
const dims = Object.keys(a.cost(example)).filter((id) => a.cost(example)[id] > 0)
if (a.id.startsWith('rust.participation.')) {
assert.strictEqual(dims.length, 0, `${a.id} prices ${dims.join(', ')}`)
continue
}
assert.strictEqual(dims.length, 1, `${a.id} prices ${dims.join(', ')}`)
spent.add(dims[0])
}
assert.deepStrictEqual([...spent].sort(), budgets.map((b) => b.id).sort())
// What a teardown can give back is declared honestly: a line said and a
// collect filed are not undone.
const once = new Set(['rust.announce', 'rust.participation.collect'])
for (const a of actions) {
assert.strictEqual(a.reversible, 'ledger')
assert.strictEqual(a.reversible, once.has(a.id) ? 'none' : 'ledger', a.id)
if (once.has(a.id)) continue
assert.strictEqual(typeof a.revert, 'function')
assert.strictEqual(typeof a.reconcile, 'function')
// Every param source is one this module registers.

View File

@@ -320,3 +320,61 @@ test('names are lowered, because the store lowers them', () => {
// push one name, find another, and report its own grant as drift for ever.
assert.deepEqual(payload.grants[0].permissions, ['kits.gold'])
})
// ── What events granted (phase 13b, D84, D102, D103) ─────────────────────────
test('an event grant is unioned with the admin grants, reaches only its server, and credits the account that played', () => {
withCore()
const model = require('../model/permissions/permissions.model')
const set = {
...authored(),
runGrants: [
// User 1 won on main with their second account; the grant reaches both
// accounts (D28), the credit only the one that took part (D103).
{ runId: '41', stepId: '7', userId: 1, serverId: 'main', steamId: '7656099', permission: 'Kits.Event', kit: 'event', credit: 1 },
// The same permission an admin already grants user 1: one row in the game.
{ runId: '41', stepId: '8', userId: 1, serverId: 'main', steamId: '7656001', permission: 'kits.gold', kit: 'gold', credit: 1 },
// A kit anybody may redeem: a credit and no permission at all.
{ runId: '41', stepId: '9', userId: 2, serverId: 'main', steamId: '7656002', permission: '', kit: 'starter', credit: 1 },
// A win on another server reaches nothing here (D102).
{ runId: '42', stepId: '1', userId: 2, serverId: 'creative', steamId: '7656002', permission: 'kits.creative', kit: 'c', credit: 1 },
// An account unlinked since the win earns its credit nowhere.
{ runId: '43', stepId: '1', userId: 2, serverId: 'main', steamId: '7656777', permission: '', kit: 'starter', credit: 1 },
],
}
const { payload, rows, hash } = model.buildDesired('main', set)
const grantsFor = (steamId) => (payload.grants.find((g) => g.steamId === steamId) || { permissions: [] }).permissions.sort()
assert.deepEqual(grantsFor('7656001'), ['kits.event', 'kits.gold'])
assert.deepEqual(grantsFor('7656099'), ['kits.event', 'kits.gold'])
assert.ok(!payload.managed.includes('kits.creative'))
assert.strictEqual(rows.filter((r) => r.kind === 'grant' && r.object === 'kits.gold').length, 2)
assert.deepEqual(payload.credits, [
{ steamId: '7656001', kit: 'gold', count: 1 },
{ steamId: '7656002', kit: 'starter', count: 1 },
{ steamId: '7656099', kit: 'event', count: 1 },
])
// Credits push but never enter the pushed ledger: a use of a kit is not
// something in the permission store to retire.
assert.ok(!rows.some((r) => r.kind === 'credit'))
// A revert of the last reward still moves the digest, so it is pushed.
const withdrawn = model.buildDesired('main', { ...set, runGrants: set.runGrants.filter((r) => r.stepId !== '9') })
assert.notStrictEqual(withdrawn.hash, hash)
})
test('the permission an admin grants survives an event revert of the same one (D84)', () => {
withCore()
const model = require('../model/permissions/permissions.model')
const event = { runId: '41', stepId: '8', userId: 1, serverId: 'main', steamId: '7656001', permission: 'kits.gold', kit: 'gold', credit: 0 }
const before = model.buildDesired('main', { ...authored(), runGrants: [event] })
const after = model.buildDesired('main', { ...authored(), runGrants: [] })
// Nothing to retire: the admin grant still desires every row the event did.
assert.deepEqual(model.retirements(before.rows, after.rows), [])
assert.deepEqual(after.payload.credits, [])
})

419
server/test/rewards.test.js Normal file
View File

@@ -0,0 +1,419 @@
// ── 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')
})

View File

@@ -185,6 +185,31 @@ test('the clan roster audience defaults to members, and a bad one writes nothing
}
})
test('news in game chat is off by default, on or off per server, and validated whole (D104, D106)', async () => {
const { model, written, restore } = setup({ overrides: { main: null } })
const db = require('../model/visibility/visibility.db')
const news = []
db.setServerNews = async (id, on) => news.push({ id, on })
try {
// A row that never had the column set reads as off.
assert.deepEqual((await model.describe()).news.servers, [{ id: 'main', name: 'MAIN', enabled: true, on: false }])
const notBoolean = await model.update({ news: { main: 'yes' } })
assert.equal(notBoolean.status, 400)
const unknown = await model.update({ fleet: 'public', news: { main: true, nope: true } })
assert.equal(unknown.status, 404)
assert.deepEqual(news, [])
assert.deepEqual(written.settings, [], 'the fleet change beside it was not written either')
const ok = await model.update({ news: { main: true } }, { id: 3 })
assert.equal(ok.ok, true)
assert.deepEqual(news, [{ id: 'main', on: true }])
assert.deepEqual(ok.changed.news, { main: true })
} finally {
restore()
}
})
// ── The public routes ─────────────────────────────────────────────────────
/** A response double recording what a handler answered. */