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
219 lines
8.3 KiB
JavaScript
219 lines
8.3 KiB
JavaScript
// ── 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,
|
|
}
|