Files
Module-Rust/server/test/economics.test.js
wtclaude 845caef817
All checks were successful
PR Checks / server-tests (pull_request) Successful in 31s
PR Checks / client-build (pull_request) Successful in 29s
PR Checks / frozen-manifest (pull_request) Successful in -1m29s
feat(rust): Economics — pay coins, balances, staff adjust, the richest (PLAN_REDESIGNS §11.2)
The site half of §11.2.2, on protocol 14:

- `rust.coins.pay` ("Pay coins"): irreversible, reversible none (D335). The
  recipient modes of rust.kit.entitle including `linked`, one payment per
  website user (D103's rule), an optional message with {amount}, a new
  `rust.coins` budget. Refused with "Economics is not installed on this
  server" in the dry run too (D199). Core's key goes to the plugin, so a
  retried step answers `repeat` and pays nobody twice.
- `rust_player_wipe_stats.balance`: the tally's balance, written as a value.
  A nameless balance frame (an offline player) moves nobody's last-seen.
  `economics.wiped` clears the wipe's balances, except at a new save.
- The richest: a Coins leaderboard column on one wipe of a server that
  reports Economics, `sort=balance`, and a `richest` title category
  (Magnate); both rank only above the starting balance (D336).
- GET /player/rust/economics (live, falling back to the last known), and
  GET/POST /admin/users/:id/rust/economics for staff — scoped to the user's
  own links, keyed per submission, an activity row with before and after.
- Client: the account page's Coins, the admin user page's Coins panel with
  its form, the leaderboard column, and Economics in the server's mod line
  (saying when its config could not be read).

Server 535/535 (14 new in test/economics.test.js), client 74/74,
routes.manifest regenerated against the pinned core (87 routes), swagger
fragment regenerated.

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

310 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── 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()
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.deepStrictEqual(result.detail.missed, ['p3'])
assert.strictEqual(result.resources, undefined, 'nothing is ledgered')
})
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 }])
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',
)
})