// ── Economics (PLAN_REDESIGNS §11.2.2, protocol 14, D335, D336) ─────────── // // Each test is one way the feature could look right and be wrong: // // a balance is a VALUE, written over — summed, it would grow every minute // an offline player's balance moves nobody's last-seen // `ecowipe` clears the wipe's balances; a new save's wipe keeps the last one's // the richest ranks only above the starting balance, and only one wipe (D336) // a server without Economics is refused with the reason, in the dry run too (D199) // one payment per website user, with the key, so a retry never pays twice // a withdrawal above the balance is a sentence with the balance in it const test = require('node:test') const assert = require('node:assert') const { fakeCtx } = require('./_fakes') /** Installs a core whose `db.query` records every statement and answers `rows`. */ function withRecorder(rows = []) { const statements = [] const ctx = fakeCtx({ db: { query: (sql, params = []) => { statements.push({ sql, params }) return Promise.resolve(typeof rows === 'function' ? rows(sql, params) : rows) }, pool: {}, }, }) require('../core')._reset() require('../core').init(ctx) return { statements, touching: (table) => statements.filter((s) => s.sql.includes(table)) } } withRecorder() // The stored status is the whole answer in these tests: nothing asks a // sidecar before a refusal (W7, W11). liveStatus.test.js covers the ask. require('../liveStatus').refresh = async () => false 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 economicsDb = require('../model/economics/economics.db') const economics = require('../model/economics/economics.model') const eventsDb = require('../model/events/events.db') const eventsModel = require('../model/events/events.model') const titlesDb = require('../model/titles/titles.db') const rewards = require('../eventRewards') const ok = (data) => ({ ok: true, status: 'ok', data }) const frame = (over = {}) => ({ type: 'event', t: 1, serverId: 'main', wipeId: 'w1', ...over }) const item = (kind, over = {}) => ({ id: 1, t: 1, kind, frame: frame({ kind, ...over }) }) const A = '76561198000000001' const B = '76561198000000002' const C = '76561198000000003' const D = '76561198000000004' // ── What a server reports ────────────────────────────────────────────────── test('a server whose Economics config cannot be read is assumed at 1000, and says so', () => { assert.deepStrictEqual(economics.shape(null), { loaded: false }) assert.deepStrictEqual(economics.shape({ loaded: false }), { loaded: false }) const unread = economics.shape(JSON.stringify({ loaded: true, version: '3.9.2' })) assert.strictEqual(unread.startingBalance, 1000) assert.strictEqual(unread.startingKnown, false) const read = economics.shape({ loaded: true, version: '3.9.2', startingBalance: 0, wipesWithMap: true }) assert.strictEqual(read.startingBalance, 0) assert.strictEqual(read.startingKnown, true) assert.strictEqual(read.wipesWithMap, true) }) // ── Ingest ───────────────────────────────────────────────────────────────── test('a tally’s balance is written over, and an offline one moves nobody’s last-seen', async () => { const rec = withRecorder() const { apply } = require('../ingest') // No name: the plugin names only a connected player. await apply('main', item('player.tally', { steamId: A, balance: 1500.456 })) const stats = rec.touching('rust_player_wipe_stats') assert.strictEqual(stats.length, 1) assert.match(stats[0].sql, /balance = VALUES\(balance\)/) assert.doesNotMatch(stats[0].sql, /balance \+/) assert.doesNotMatch(stats[0].sql, /last_seen/) assert.deepStrictEqual(stats[0].params, ['main', 'w1', A, 1500.46]) assert.strictEqual(rec.touching('rust_players ').length, 0, 'no player row is touched') // A connected player's tally carries the balance AND its play. const live = withRecorder() await apply('main', item('player.tally', { steamId: A, name: 'Wanderer', balance: 900, npcKills: 2 })) assert.ok(live.touching('rust_player_wipe_stats').some((s) => /balance = VALUES\(balance\)/.test(s.sql))) assert.ok(live.touching('rust_player_wipe_stats').some((s) => /npc_kills = npc_kills \+/.test(s.sql))) }) test('ecowipe clears the wipe’s balances; a new save keeps the last wipe’s as its record', async () => { const { apply } = require('../ingest') const rec = withRecorder() await apply('main', item('economics.wiped', { newSave: false })) const cleared = rec.touching('rust_player_wipe_stats') assert.strictEqual(cleared.length, 1) assert.match(cleared[0].sql, /SET balance = NULL WHERE server_id = \? AND wipe_id = \?/) assert.deepStrictEqual(cleared[0].params, ['main', 'w1']) const kept = withRecorder() await apply('main', item('economics.wiped', { newSave: true })) assert.strictEqual(kept.touching('rust_player_wipe_stats').length, 0) }) // ── The richest (D336) ───────────────────────────────────────────────────── test('the richest column ranks one wipe, above the starting balance', async () => { const rec = withRecorder() await eventsDb.leaderboard({ serverId: 'main', wipeId: 'w1', sort: 'balance', limit: 25, balanceFloor: 1000 }) const [q] = rec.statements assert.match(q.sql, /HAVING MAX\(s\.balance\) > \?/) assert.match(q.sql, /ORDER BY MAX\(s\.balance\) DESC/) assert.deepStrictEqual(q.params, ['main', 'w1', 1000, 25]) // All time has no balance: the sort falls back, and no row carries one. const all = withRecorder([{ steamId: A, kills: 3, balance: 5000 }]) const rows = await eventsModel.leaderboard({ serverId: 'main', wipeId: null, sort: 'balance', economics: { startingBalance: 1000 } }) assert.doesNotMatch(all.statements[0].sql, /HAVING/) assert.match(all.statements[0].sql, /ORDER BY SUM\(s\.kills\) DESC/) assert.ok(!('balance' in rows[0])) // One wipe on a server with Economics: every row carries its balance, null when none. withRecorder([{ steamId: A, balance: '2500.00' }, { steamId: B, balance: null }]) const wipe = await eventsModel.leaderboard({ serverId: 'main', wipeId: 'w1', sort: 'kills', economics: { startingBalance: 1000 } }) assert.strictEqual(wipe[0].balance, 2500) assert.strictEqual(wipe[1].balance, null) }) test('Magnate ranks nobody without a floor, and only balances above it with one', async () => { const none = withRecorder() assert.deepStrictEqual(await titlesDb.standings({ serverId: 'main', wipeId: 'w1', source: { balance: 'balance' }, limit: 3 }), []) assert.strictEqual(none.statements.length, 0) const rec = withRecorder() await titlesDb.standings({ serverId: 'main', wipeId: 'w1', source: { balance: 'balance' }, limit: 3, floor: 1000 }) assert.match(rec.statements[0].sql, /s\.balance > \?/) assert.deepStrictEqual(rec.statements[0].params, ['main', 'w1', 1000, 3]) }) // ── The step ─────────────────────────────────────────────────────────────── const ROW = { id: 'main', name: 'Main', sidecarBaseUrl: 'http://main:1', sidecarTokenEnc: null, enabled: 1 } const pay = rewards.ACTIONS.find((a) => a.id === 'rust.coins.pay') function stub(t, { loaded = true, people = [], links = {}, payAnswer } = {}) { const calls = { pay: [] } const saved = { getServer: serversDb.getServer, reportFor: economicsDb.reportFor, tallySnapshot: client.tallySnapshot, economicsPay: client.economicsPay, userIdsForSteamIds: linksDb.userIdsForSteamIds, } serversDb.getServer = async (id) => (id === 'main' ? ROW : null) economicsDb.reportFor = async () => ({ serverId: 'main', wipeId: 'w1', economics: loaded ? JSON.stringify({ loaded: true, startingBalance: 1000 }) : null }) client.tallySnapshot = async (server, runId) => ok({ kind: 'tally.snapshot', runId, people, maxRecipients: 100 }) client.economicsPay = async (server, body) => { calls.pay.push(body) return payAnswer ? payAnswer(body) : ok({ kind: 'economics.paid', key: body.key, repeat: false, paid: [], failed: [] }) } linksDb.userIdsForSteamIds = async (ids) => ids.filter((id) => links[id]).map((id) => ({ steamId: id, userId: links[id] })) t.after(() => { serversDb.getServer = saved.getServer economicsDb.reportFor = saved.reportFor client.tallySnapshot = saved.tallySnapshot client.economicsPay = saved.economicsPay linksDb.userIdsForSteamIds = saved.userIdsForSteamIds }) return calls } const PARAMS = { server: 'main', amount: 500, recipients: 'everyone', message: 'You earned {amount} coins', delivery: 'popup' } test('pay coins is irreversible and kept: no ledger, no revert (D335)', () => { assert.strictEqual(pay.risk, 'irreversible') assert.strictEqual(pay.reversible, 'none') assert.strictEqual(pay.revert, undefined) }) test('a server without Economics is refused in the dry run, with the reason (D199)', async (t) => { stub(t, { loaded: false }) const result = await pay.perform({ runId: 7, stepId: 4, idempotencyKey: 'k-1', params: PARAMS, verify: true }) assert.strictEqual(result.ok, false) assert.strictEqual(result.retry, false) assert.match(result.error, /Economics is not installed on this server/) }) test('coins go once to each website user, with the key, the amount and the message', async (t) => { const person = (steamId, score) => ({ steamId, name: `p${steamId.slice(-1)}`, seconds: 60, kills: 0, score, joinedAt: 1 }) const calls = stub(t, { // A and B are one person (user 1); C has linked nothing; D is user 2. people: [person(A, 9), person(B, 5), person(C, 3), person(D, 1)], links: { [A]: 1, [B]: 1, [D]: 2 }, payAnswer: (body) => ok({ kind: 'economics.paid', key: body.key, repeat: false, paid: [{ steamId: A, before: 1000, after: 1500 }], failed: [{ steamId: D, reason: 'at-limit' }], }), }) const result = await pay.perform({ runId: 7, stepId: 4, idempotencyKey: 'k-1', params: PARAMS, verify: false }) assert.strictEqual(result.ok, true) assert.deepStrictEqual(calls.pay, [ { key: 'k-1', amount: 500, steamIds: [A, D], message: 'You earned {amount} coins', delivery: 'popup' }, ]) assert.strictEqual(result.detail.paid, 1) assert.strictEqual(result.detail.total, 500) assert.deepStrictEqual(result.detail.received, [{ name: 'p1', received: 500 }]) assert.deepStrictEqual(result.detail.failed, [{ name: 'p4', reason: 'at-limit' }]) assert.strictEqual(result.detail.failedNote, "at-limit: the balance is at Economics' limit", 'only the reason that happened (W16)') assert.deepStrictEqual(result.detail.missed, ['p3']) assert.strictEqual(result.resources, undefined, 'nothing is ledgered') }) test('a payment’s failedNote explains each reason that happened, and only those (W16)', () => { const { failedNote } = require('../eventRewards') assert.strictEqual(failedNote([{ reason: 'unmeasured' }, { reason: 'unmeasured' }]), 'unmeasured: Economics did not report the deposit') assert.strictEqual( failedNote([{ reason: 'unmeasured' }, { reason: 'at-limit' }, { reason: 'refused' }]), "at-limit: the balance is at Economics' limit; refused: Economics did not take the deposit; unmeasured: Economics did not report the deposit", ) assert.strictEqual(failedNote([{ reason: 'odd' }]), "odd: Economics' own reason") }) test('a repeated key is answered from the first payment, and a lost answer is retried', async (t) => { const person = { steamId: A, name: 'pa', seconds: 60, kills: 0, score: 1, joinedAt: 1 } stub(t, { people: [person], links: { [A]: 1 }, payAnswer: () => ok({ kind: 'economics.paid', repeat: true, paid: [{ steamId: A, before: 1000, after: 1500 }], failed: [] }) }) const repeat = await pay.perform({ runId: 7, stepId: 4, idempotencyKey: 'k-1', params: PARAMS, verify: false }) assert.strictEqual(repeat.ok, true) assert.strictEqual(repeat.detail.repeat, true) client.economicsPay = async () => ({ ok: false, status: 'timeout' }) const lost = await pay.perform({ runId: 7, stepId: 4, idempotencyKey: 'k-1', params: PARAMS, verify: false }) assert.strictEqual(lost.ok, false) assert.notStrictEqual(lost.retry, false, 'a timeout is retried, and the key keeps it to one payment') client.economicsPay = async () => ok({ kind: 'economics.error', reason: 'economics-missing', message: 'Economics is not installed on this server' }) const missing = await pay.perform({ runId: 7, stepId: 4, idempotencyKey: 'k-1', params: PARAMS, verify: false }) assert.strictEqual(missing.retry, false) }) test('a payment is priced at the most it could pay, and its message is measured with the amount in it', () => { assert.deepStrictEqual(pay.cost({ amount: 500, recipients: 'top', count: 3 }), { 'rust.coins': 1500 }) assert.deepStrictEqual(pay.cost({ amount: 2.5, recipients: 'everyone' }), { 'rust.coins': 250 }) assert.strictEqual(rewards.coinsNotice({ message: '' }, 500).text, null) assert.strictEqual(rewards.coinsNotice({ message: `${'x'.repeat(250)}{amount}` }, 1000000).ok, false) assert.strictEqual(rewards.coinsNotice({ message: 'Got {amount}', delivery: 'carrier pigeon' }, 5).ok, false) const key = rewards.paymentKey('k'.repeat(300), 7, 4) assert.ok(key.startsWith('coins:') && key.length <= 120) assert.strictEqual(rewards.paymentKey('k-1', 7, 4), 'k-1') }) test('an amount outside 0.01 to 1,000,000 is refused before anything is asked', async (t) => { stub(t) for (const amount of [0, -5, 1000001, 'lots']) { const result = await pay.perform({ runId: 7, stepId: 4, idempotencyKey: 'k', params: { ...PARAMS, amount }, verify: true }) assert.strictEqual(result.ok, false, String(amount)) assert.strictEqual(result.retry, false) } }) // ── Staff ────────────────────────────────────────────────────────────────── test('an adjustment checks its op and amount; only a set may be 0', () => { assert.strictEqual(economics.checkAdjustment('steal', 5).ok, false) assert.strictEqual(economics.checkAdjustment('deposit', 0).ok, false) assert.strictEqual(economics.checkAdjustment('set', 0).ok, true) assert.strictEqual(economics.checkAdjustment('withdraw', 1000001).ok, false) assert.strictEqual(economics.checkAdjustment('deposit', 12.345).value, 12.35) }) test('a withdrawal above the balance is refused with the balance', async (t) => { const saved = { getForCalling: servers.getForCalling, reportFor: economicsDb.reportFor, economicsAdjust: client.economicsAdjust } t.after(() => Object.assign(servers, { getForCalling: saved.getForCalling }) && Object.assign(economicsDb, { reportFor: saved.reportFor }) && Object.assign(client, { economicsAdjust: saved.economicsAdjust })) const sent = [] servers.getForCalling = async () => ({ id: 'main', name: 'Main', baseUrl: 'http://main:1', token: 't' }) economicsDb.reportFor = async () => ({ serverId: 'main', wipeId: 'w1', economics: { loaded: true } }) client.economicsAdjust = async (server, body) => { sent.push(body) return ok({ kind: 'economics.error', reason: 'insufficient', balance: 340 }) } const result = await economics.adjust({ serverId: 'main', steamId: A, op: 'withdraw', amount: 500, key: 'adj-1' }) assert.strictEqual(result.ok, false) assert.strictEqual(result.status, 409) assert.match(result.message, /only 340 coins on Main/) assert.deepStrictEqual(sent, [{ key: 'adj-1', steamId: A, op: 'withdraw', amount: 500 }]) // A deposit refused at the balance limit is the game saying no too (W15). client.economicsAdjust = async () => ok({ kind: 'economics.error', reason: 'at-limit', message: "this player's balance is at Economics' limit" }) const capped = await economics.adjust({ serverId: 'main', steamId: A, op: 'deposit', amount: 50, key: 'adj-3' }) assert.strictEqual(capped.status, 409) assert.strictEqual(capped.reason, 'at-limit') economicsDb.reportFor = async () => null const missing = await economics.adjust({ serverId: 'main', steamId: A, op: 'deposit', amount: 5, key: 'adj-2' }) assert.strictEqual(missing.status, 409) assert.strictEqual(missing.message, economics.MISSING) }) test('the activity row says what moved', () => { assert.strictEqual( economics.adjustmentSummary({ op: 'deposit', amount: 500, name: 'Wanderer', server: 'Main', before: 1000, after: 1500 }), 'Deposited 500 coins to Wanderer on Main: 1,000 → 1,500', ) assert.strictEqual( economics.adjustmentSummary({ op: 'set', amount: 0, name: 'Wanderer', server: 'Main', before: 1000, after: 0 }), 'Set the balance of Wanderer on Main to 0: 1,000 → 0', ) })