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
310 lines
16 KiB
JavaScript
310 lines
16 KiB
JavaScript
// ── 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',
|
||
)
|
||
})
|