// ── Economics, as the site sees it (PLAN_REDESIGNS §11.2.2, D199, D335, D336) ─ // // Wulf's Economics is optional (D199): a server reports it in hello's // `integrations.economics`, and every surface that needs it refuses with a // reason where it is absent, never quietly. Three readers share this file: // // • the step (`rust.coins.pay`, in eventRewards.js), which asks whether the // server has Economics before it pays anybody; // • the player's own page and the app, which read each linked account's // balance live and fall back to the last one the tally carried; // • staff, who deposit, withdraw or set one player's balance. // // The starting balance matters to D336: the richest column and the Magnate // title rank only balances above it, because Economics answers it for an // account it has never seen (§11.2.1). The plugin reads it from Economics' // config; when that file cannot be read the site assumes Economics' default, // 1000, and says so (`startingKnown: false`). const core = require('../../core') const client = require('../../sidecarClient') const db = require('./economics.db') const eventsDb = require('../events/events.db') const servers = require('../servers/servers.model') const { pluginError, transportError } = require('../../eventLeases') const log = core.logger('economics') /** Economics' own default starting balance, assumed when its config cannot be read. */ const DEFAULT_STARTING_BALANCE = 1000 /** The sentence every surface says where a server has no Economics (D199). */ const MISSING = 'Economics is not installed on this server' const OPS = ['deposit', 'withdraw', 'set'] /** The plugin's bounds (PROTOCOL.md §20.3), refused here first so a form says why. */ const AMOUNT_MIN = 0.01 const AMOUNT_MAX = 1000000 /** JSON_EXTRACT comes back as text from the driver; an object passes through. */ function parse(value) { if (value === null || value === undefined) return null if (typeof value === 'object') return value try { return JSON.parse(value) } catch { return null } } /** * A server's Economics as its last hello said: `{ loaded: false }`, or loaded * with its version, its starting balance (assumed where unread) and whether * balances wipe with the map. */ function shape(raw) { const e = parse(raw) if (!e || e.loaded !== true) return { loaded: false } const start = Number(e.startingBalance) const known = e.startingBalance !== undefined && e.startingBalance !== null && Number.isFinite(start) return { loaded: true, version: typeof e.version === 'string' ? e.version : null, startingBalance: known ? start : DEFAULT_STARTING_BALANCE, startingKnown: known, wipesWithMap: typeof e.wipesWithMap === 'boolean' ? e.wipesWithMap : null, } } /** One server's report, with the wipe it is on. `{ loaded: false }` for a server never heard from. */ async function forServer(serverId) { const row = await db.reportFor(serverId) if (!row) return { loaded: false, wipeId: null } return { ...shape(row.economics), wipeId: row.wipeId || null } } /** Every server's report, keyed by id. */ async function byServer() { const rows = await db.reports() return new Map(rows.map((r) => [r.serverId, { ...shape(r.economics), wipeId: r.wipeId || null }])) } /** An amount as a sentence says it: thousands separated, at most two places. */ function coins(n) { return Number(n).toLocaleString('en-US', { maximumFractionDigits: 2 }) } /** * The balances of some Steam accounts on every enabled server: live where the * server answers, the last balance the tally carried where it does not, and * null where neither knows. A server without Economics is listed with the reason, * so a page can say why there is nothing rather than show nothing. * * @param {Array<{steamId, name}>} accounts the accounts to read, a user's links */ async function balancesOf(accounts) { const ids = [...new Set(accounts.map((a) => String(a.steamId)).filter(Boolean))] const [list, reports] = await Promise.all([servers.listForPolling(), byServer()]) return Promise.all( list.map(async (server) => { const econ = reports.get(server.id) || { loaded: false } const base = { serverId: server.id, server: server.name || server.id } if (!econ.loaded) return { ...base, economics: false, reason: MISSING, accounts: [] } const known = await eventsDb.balancesFor(server.id, econ.wipeId, ids) const live = new Map() let answered = false if (ids.length) { const result = await client.economicsBalances(server, ids) const data = (result.ok && result.data) || {} if (data.kind === 'economics.balances') { answered = true for (const row of data.balances || []) { if (row && row.steamId && Number.isFinite(Number(row.balance))) live.set(String(row.steamId), Number(row.balance)) } } } return { ...base, economics: true, startingBalance: econ.startingBalance, startingKnown: econ.startingKnown, live: answered, accounts: accounts.map((a) => { const id = String(a.steamId) const source = live.has(id) ? 'live' : known.has(id) ? 'last-known' : null return { steamId: id, name: a.name || null, balance: source === 'live' ? live.get(id) : source ? known.get(id) : null, source, } }), } }), ) } /** Checks a staff adjustment's op and amount. `{ ok }` or `{ ok: false, message }`. */ function checkAdjustment(op, amount) { if (!OPS.includes(op)) return { ok: false, message: `op is one of ${OPS.join(', ')}` } const value = Number(amount) const min = op === 'set' ? 0 : AMOUNT_MIN if (!Number.isFinite(value) || value < min || value > AMOUNT_MAX) { return { ok: false, message: `an amount is ${op === 'set' ? '0' : coins(AMOUNT_MIN)} to ${coins(AMOUNT_MAX)} coins` } } return { ok: true, value: Math.round(value * 100) / 100 } } /** * Staff deposit, withdraw or set one player's balance on one server. * * Resolves `{ ok: true, before, after, repeat, server }` or `{ ok: false, * status, message, reason? }`. The key makes a resent form harmless: the plugin * answers a key it has seen from the first reply, and moves nothing. */ async function adjust({ serverId, steamId, op, amount, key }) { const checked = checkAdjustment(op, amount) if (!checked.ok) return { ok: false, status: 400, message: checked.message } const server = await servers.getForCalling(serverId) if (!server) return { ok: false, status: 404, message: 'No such server' } const econ = await forServer(server.id) if (!econ.loaded) return { ok: false, status: 409, reason: 'economics-missing', message: MISSING } const result = await client.economicsAdjust(server, { key, steamId, op, amount: checked.value }) if (!result.ok) return { ok: false, status: 503, message: transportError(server, result, 'Economics') } const data = result.data || {} const name = server.name || server.id if (data.kind === 'economics.adjusted') { return { ok: true, before: Number(data.before), after: Number(data.after), repeat: Boolean(data.repeat), server: name, amount: checked.value } } if (data.reason === 'insufficient') { // Economics takes all or nothing (§11.2.1), so the sentence is the balance. return { ok: false, status: 409, reason: 'insufficient', message: `They have only ${coins(data.balance)} coins on ${name}` } } log.warn('Economics refused an adjustment', { server: server.id, op, reason: data.reason }) return { ok: false, status: data.reason === 'economics-missing' ? 409 : 400, reason: data.reason || null, message: pluginError(data, `${name} refused the adjustment`), } } /** The activity row's sentence: "Deposited 500 coins to Wanderer on Main: 1,000 → 1,500". */ function adjustmentSummary({ op, amount, name, server, before, after }) { const verb = { deposit: `Deposited ${coins(amount)} coins to`, withdraw: `Withdrew ${coins(amount)} coins from`, set: `Set the balance of` }[op] return `${verb} ${name} on ${server}${op === 'set' ? ` to ${coins(amount)}` : ''}: ${coins(before)} → ${coins(after)}` } module.exports = { DEFAULT_STARTING_BALANCE, MISSING, OPS, AMOUNT_MIN, AMOUNT_MAX, shape, forServer, byServer, coins, balancesOf, checkAdjustment, adjust, adjustmentSummary, }