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

@@ -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,