// ── 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') })