Files
Module-Rust/server/test/economics.test.js
wtclaude 3aa142bf06
All checks were successful
PR Checks / client-build (pull_request) Successful in 24s
PR Checks / server-tests (pull_request) Successful in 32s
PR Checks / frozen-manifest (pull_request) Successful in 44s
fix(rust): the last fixes' small ones (W3 W6 W7/W11 W8 W10 W13-W18)
Stage 2 of PLAN_LAST_FIXES (docs#366).

- W7/W11: an "is it loaded" refusal is confirmed live before it is given.
  New liveStatus.js asks the sidecar once (boot.refreshOne, as a poll tick
  would), at most once per server per 5 s and waiting at most 3 s, then
  reads the stored status again. Used by Economics (step and staff page),
  Backpacks, Dangerous Treasures, Guarded Crate, RaidableBases, ZoneDomes,
  TruePVE and RunicNPC.
- W3: a world call answered `already-out` (the bridge half comes in stage
  3) succeeds as called 0, saying a Travelling Vendor is already out.
- W6: "A locked crate dropped by a Chinook is being hacked."
- W8: a RaidableBases profile's audit row names RaidableBases, not "@data".
- W10: the backpack sizes picker lists each size once, naming its servers.
- W13: a RaidableBases name both servers describe differently shows each
  server's own description.
- W14: each RaidableBases note after the first starts its own sentence.
- W15: a staff deposit refused at Economics' limit answers 409.
- W16: a payment's failedNote explains only the reasons that happened.
- W17: the sync state counts player grants that did not land
  (waitingToLand), and the Permissions page says "in sync · 1 waits for
  their player to join" instead of plain "in sync".
- W18: Guarded Crate's kept note drops the "30 minutes" it could not know.

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

331 lines
17 KiB
JavaScript
Raw Permalink 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()
// 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',
)
})