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