feat(rust): Economics — pay coins, balances, staff adjust, the richest (PLAN_REDESIGNS §11.2)
All checks were successful
PR Checks / server-tests (pull_request) Successful in 31s
PR Checks / client-build (pull_request) Successful in 29s
PR Checks / frozen-manifest (pull_request) Successful in -1m29s

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
This commit is contained in:
2026-10-12 07:07:05 -05:00
parent 1777cb6ce1
commit 845caef817
32 changed files with 1907 additions and 35 deletions

View File

@@ -0,0 +1,36 @@
// ── SQL for Economics (PLAN_REDESIGNS §11.2.2) ────────────────────────────
//
// What each server's last hello said about Economics. The balances themselves
// are rows of `rust_player_wipe_stats` and are written by ingest through
// `events.db.js`; this file only reads the report the plugin makes about the
// plugin.
const core = require('../../core')
const STATE = 'rust_server_state'
/**
* Every server's report: `{ serverId, wipeId, economics }`, with `economics`
* as the driver hands JSON_EXTRACT back — text, or null for a server whose
* plugin never said (no state yet, or a plugin before protocol 14).
*/
async function reports() {
return core.query(
`SELECT server_id AS serverId, wipe_id AS wipeId,
JSON_EXTRACT(raw, '$.integrations.economics') AS economics
FROM ${STATE}`,
)
}
async function reportFor(serverId) {
const rows = await core.query(
`SELECT server_id AS serverId, wipe_id AS wipeId,
JSON_EXTRACT(raw, '$.integrations.economics') AS economics
FROM ${STATE}
WHERE server_id = ?`,
[serverId],
)
return rows[0] || null
}
module.exports = { reports, reportFor }

View File

@@ -0,0 +1,218 @@
// ── 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,
}

View File

@@ -193,6 +193,45 @@ async function addTitleStats({ serverId, wipeId, steamId }, frame = {}) {
)
}
/** The largest balance a row holds: DECIMAL(14,2). Economics' own limit is far below it. */
const BALANCE_MAX = 999999999999.99
/**
* One player's Economics balance for the wipe (protocol 14, §11.2.2): a VALUE,
* written over and never added. `last_seen` is left alone on purpose — a payment
* or a staff adjustment reaches an offline player, and a balance is not play.
*/
async function setBalance({ serverId, wipeId, steamId }, balance) {
const value = Number(balance)
if (!serverId || !steamId || !Number.isFinite(value)) return
await core.query(
`INSERT INTO ${STATS} (server_id, wipe_id, steam_id, balance)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE balance = VALUES(balance)`,
[serverId, wipeId || '', steamId, Math.max(-BALANCE_MAX, Math.min(Math.round(value * 100) / 100, BALANCE_MAX))],
)
}
/** `ecowipe`: every balance of the wipe is back at the start, so none is known. */
async function clearBalances(serverId, wipeId) {
if (!serverId) return
await core.query(`UPDATE ${STATS} SET balance = NULL WHERE server_id = ? AND wipe_id = ?`, [serverId, wipeId || ''])
}
/** The last known balances of some players in one wipe, as Steam id → number. */
async function balancesFor(serverId, wipeId, steamIds) {
if (!serverId || !steamIds.length) return new Map()
const rows = await core.query(
`SELECT steam_id AS steamId, balance
FROM ${STATS}
WHERE server_id = ? AND wipe_id = ? AND balance IS NOT NULL
AND steam_id IN (${steamIds.map(() => '?').join(', ')})`,
[serverId, wipeId || '', ...steamIds],
)
return new Map(rows.map((r) => [String(r.steamId), Number(r.balance)]))
}
/** One weapon's credited kills (§5.3), added as a delta like a gathered resource. */
async function addWeaponKills({ serverId, wipeId, steamId }, weapon, kills) {
if (!serverId || !steamId || !weapon || !(kills > 0)) return
@@ -291,8 +330,15 @@ async function recentEvents({ serverId, kinds, wipeId = null, limit = 50 }) {
* All-time is a SUM over the per-wipe rows rather than a separate set of
* counters, which is what makes it impossible for the two to disagree — there
* is only ever one number, added up differently.
*
* The balance is the exception (§11.2.2): a value per wipe, so it is read only
* for one wipe, as that wipe's MAX — there is one row per player, so the max is
* the value. `balanceFloor` is the server's starting balance, and only set when
* the caller ranks by balance: then a row at or under it is not on the board
* (D336), because "1,000" cannot tell a new account from one spent back to it.
*/
async function leaderboard({ serverId, wipeId = null, sort = 'kills', limit = 25 }) {
async function leaderboard({ serverId, wipeId = null, sort = 'kills', limit = 25, balanceFloor = null }) {
const byBalance = sort === 'balance' && wipeId && Number.isFinite(balanceFloor)
const column = { kills: 'kills', deaths: 'deaths', npcKills: 'npc_kills', playtime: 'playtime_sec' }[sort] || 'kills'
const params = [serverId]
@@ -303,6 +349,10 @@ async function leaderboard({ serverId, wipeId = null, sort = 'kills', limit = 25
params.push(wipeId)
}
const having = byBalance ? 'HAVING MAX(s.balance) > ?' : ''
if (byBalance) params.push(balanceFloor)
const order = byBalance ? 'MAX(s.balance)' : `SUM(s.${column})`
params.push(limit)
return core.query(
@@ -313,12 +363,14 @@ async function leaderboard({ serverId, wipeId = null, sort = 'kills', limit = 25
SUM(s.npc_kills) AS npcKills,
SUM(s.structures) AS structures,
SUM(s.playtime_sec) AS playtimeSec,
MAX(s.balance) AS balance,
MAX(s.last_seen) AS lastSeen
FROM ${STATS} s
LEFT JOIN ${PLAYERS} p ON p.steam_id = s.steam_id
WHERE ${where}
GROUP BY s.steam_id, p.name
ORDER BY SUM(s.${column}) DESC, MAX(s.last_seen) DESC
${having}
ORDER BY ${order} DESC, MAX(s.last_seen) DESC
LIMIT ?`,
params,
)
@@ -387,6 +439,9 @@ module.exports = {
addGathered,
addTitleStats,
addWeaponKills,
setBalance,
clearBalances,
balancesFor,
replacePresence,
pruneEvents,
recentEvents,

View File

@@ -102,15 +102,20 @@ function shape(row) {
* counters, so the two can never disagree — which is the whole reason R12's
* "per-wipe detail plus all-time rollups" is one table and not two.
*/
async function leaderboard({ serverId, wipeId = null, sort = 'kills', limit, presence = false }) {
async function leaderboard({ serverId, wipeId = null, sort = 'kills', limit, presence = false, economics = null }) {
// The richest column (§11.2.2): only for one wipe — a balance is a value, and
// there is no all-time one — and only where the server reports Economics.
const withBalance = Boolean(economics && wipeId)
const rows = await db.leaderboard({
serverId,
wipeId,
sort,
sort: sort === 'balance' && !withBalance ? 'kills' : sort,
limit: boundedLimit(limit, 25),
balanceFloor: withBalance && sort === 'balance' ? economics.startingBalance : null,
})
return rows.map((r) => ({
...(withBalance ? { balance: r.balance === null || r.balance === undefined ? null : Number(r.balance) } : {}),
steamId: r.steamId,
name: r.name || null,
kills: Number(r.kills) || 0,

View File

@@ -76,9 +76,27 @@ const COLUMN = /^[a-z_]+$/
* @param {object} source a category's `source` from `titles.js`
* @param {object} lists list name → the short names it holds (`lists.js`)
*/
async function standings({ serverId, wipeId, source, lists = {}, limit }) {
async function standings({ serverId, wipeId, source, lists = {}, limit, floor = null }) {
const params = [serverId, wipeId]
// The richest (§11.2.2): the balance is a value, one per row, ranked only
// above `floor`, the server's starting balance (D336). No floor, no ranking —
// the caller passes one only for a server that reports Economics.
if (source.balance) {
if (!COLUMN.test(source.balance)) throw new Error('not a stats column')
if (!Number.isFinite(floor)) return []
return core.query(
`SELECT s.steam_id AS steamId, MAX(s.${source.balance}) AS value
FROM ${STATS} s
WHERE s.server_id = ? AND s.wipe_id = ? AND s.${source.balance} > ?
GROUP BY s.steam_id
ORDER BY value DESC, MAX(s.last_seen) DESC
LIMIT ?`,
[...params, floor, limit],
)
}
if (source.sum || source.diff || source.best) {
let value
if (source.sum) value = `SUM(s.${source.sum})`

View File

@@ -20,8 +20,9 @@ const crypto = require('node:crypto')
/**
* The categories a rule may rank (PLAN_REDESIGNS §5, D172): the twenty-three
* conditions and their shipped titles, plus `playtime`, which predates them and
* has no default title, so a playtime rule always carries its own text.
* conditions and their shipped titles, the richest (§11.2.2, Magnate), plus
* `playtime`, which predates them and has no default title, so a playtime rule
* always carries its own text.
*
* `source` says where the standing is read from (`titles.db.js`):
* `sum` a column of `rust_player_wipe_stats`, summed
@@ -30,6 +31,9 @@ const crypto = require('node:crypto')
* `best` a column's MAX: the longest single kill (D174)
* `weapons` a named list's kills in `rust_weapon_kills` (§5.3)
* `gathered` a named list's amount in `rust_gather_totals` (D159)
* `balance` the Economics balance, a value per wipe (PLAN_REDESIGNS §11.2.2):
* ranked only above the server's starting balance (D336), and
* only on a server that reports Economics
* `npcProfile` one site NPC profile's kills, counted as the profile says
* (runicnpc stage 4, D247, D250). A rule on it names the profile,
* and carries its own text, like playtime.
@@ -61,6 +65,7 @@ const STATS = {
rockets: { label: 'Rockets fired', title: 'Artillerist', source: { sum: 'rockets' } },
explosives: { label: 'Explosives thrown', title: 'Demolitionist', source: { sum: 'explosives' } },
missions: { label: 'Missions completed', title: 'Wayfarer', source: { sum: 'missions' } },
richest: { label: 'Richest (Economics)', title: 'Magnate', source: { balance: 'balance' } },
playtime: { label: 'Playtime', title: null, source: { sum: 'playtime_sec' } },
profilekills: { label: 'Kills of an NPC profile', title: null, source: { npcProfile: true } },
}

View File

@@ -5,6 +5,7 @@
// the app's — so it is remembered briefly: the leaderboard is polled by every
// open page, and each rule is a leaderboard query of its own.
const economics = require('../economics/economics.model')
const serversDb = require('../servers/servers.db')
const db = require('./titles.db')
const lists = require('./lists')
@@ -118,12 +119,21 @@ async function heldFor(serverId, { wipeId, now = Date.now() } = {}) {
ruleBy.set(key, r)
}
// The richest ranks above the server's starting balance (D336), and only
// where the server reports Economics: a balance last known before it was
// removed is not anybody's standing now.
let floor = null
if (depth.has('richest')) {
const econ = await economics.forServer(serverId)
floor = econ.loaded ? econ.startingBalance : null
}
await Promise.all(
[...depth.entries()].map(async ([key, limit]) => {
const r = ruleBy.get(key)
const rows = r.stat === 'profilekills'
? await npcs.standing(serverId, r.profile, wipeId, limit)
: await db.standings({ serverId, wipeId, source: titles.STATS[r.stat].source, lists, limit })
: await db.standings({ serverId, wipeId, source: titles.STATS[r.stat].source, lists, limit, floor })
// SUM() and MAX() come back as strings; the rules compare numbers.
standings[key] = rows.map((row) => ({ steamId: row.steamId, value: Number(row.value) || 0 }))
}),