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

@@ -129,6 +129,12 @@ export const playerPermissions = {
list: () => req('/player/rust/permissions'),
}
// Your coin balances on every server, per linked account (PLAN_REDESIGNS §11.2.2):
// live where a server answers, the last one it reported where it does not.
export const playerEconomics = {
list: () => req('/player/rust/economics'),
}
// ── admin ─────────────────────────────────────────────────────────────────
// **`sidecarToken` goes up and never comes back.** The list answers `hasToken`,
// and a save that omits the field leaves the stored credential alone — so an
@@ -323,6 +329,14 @@ export const adminUserPermissions = {
),
}
// The same panel's Economics half (§11.2.2): this person's coins, and staff's
// hand on them. `key` is one per submission, so a resent form moves nothing.
export const adminUserEconomics = {
list: (userId) => req(`/admin/users/${encodeURIComponent(userId)}/rust/economics`),
adjust: (userId, body) =>
req(`/admin/users/${encodeURIComponent(userId)}/rust/economics`, { method: 'POST', body }),
}
// ── core's public event calendar ───────────────────────────────────────────
// The one CORE route this module reads, and core published `runId` on it for
// exactly this: a map marker holds a run id and nothing else, and the calendar is
@@ -342,6 +356,7 @@ export default {
playerServers,
playerLinks,
playerPermissions,
playerEconomics,
admin,
adminPermissions,
adminConfig,
@@ -351,6 +366,7 @@ export default {
playerNpcKills,
adminUserLinks,
adminUserPermissions,
adminUserEconomics,
coreEvents,
BASE,
}

View File

@@ -18,7 +18,7 @@ import { useState } from 'react'
import { ErrorState, Loading, useAsync } from '../core.js'
import Empty from './Empty.jsx'
import { ago, contrastInk, count, duration, shortId } from '../lib/format.js'
import { ago, coins, contrastInk, count, duration, shortId } from '../lib/format.js'
import api from '../api.js'
// `sort` is the API's own vocabulary (`kills`, `deaths`, `npcKills`, `playtime`),
@@ -32,6 +32,13 @@ const COLUMNS = [
{ key: 'playtimeSec', label: 'Played', sort: 'playtime', value: (r) => duration(r.playtimeSec) },
]
// The richest (PLAN_REDESIGNS §11.2.2): the last known Economics balance. The
// server adds it only for one wipe on a server that reports Economics, and says
// so with `economics` on the answer — so the column is there exactly when the
// rows carry it. Sorting by it ranks only balances above the starting balance
// (D336), which is why a fresh wipe's sort shows nobody.
const BALANCE = { key: 'balance', label: 'Coins', sort: 'balance', value: (r) => coins(r.balance) }
export default function Leaderboard({ serverId, wipeId, sort, onSort }) {
const { data: npc } = useAsync(() => api.servers.npcProfiles(serverId).catch(() => ({ profiles: [] })), [serverId])
const [profileId, setProfileId] = useState('')
@@ -85,6 +92,7 @@ function Overall({ serverId, wipeId, sort, onSort }) {
)
const rows = data ? data.leaderboard : []
const columns = data && data.economics ? [...COLUMNS, BALANCE] : COLUMNS
// Present on every row or on none — the server decides per request.
const showLastSeen = rows.some((row) => 'lastSeen' in row)
@@ -96,9 +104,11 @@ function Overall({ serverId, wipeId, sort, onSort }) {
<Empty
title="No scores yet"
message={
wipeId
? 'Nobody has done anything countable on this wipe yet.'
: 'This server has not reported anything countable yet.'
sort === 'balance' && wipeId
? 'Nobody holds more coins than the starting balance on this wipe yet.'
: wipeId
? 'Nobody has done anything countable on this wipe yet.'
: 'This server has not reported anything countable yet.'
}
/>
)
@@ -110,7 +120,7 @@ function Overall({ serverId, wipeId, sort, onSort }) {
<thead>
<tr style={{ textAlign: 'left', color: 'var(--dim)', fontSize: '0.72rem', letterSpacing: '0.08em' }}>
<th style={{ ...cell, textTransform: 'uppercase' }}>Player</th>
{COLUMNS.map((column) => (
{columns.map((column) => (
<th key={column.key} style={{ ...cell, textAlign: 'right', textTransform: 'uppercase' }}>
{column.sort ? (
<button
@@ -179,7 +189,7 @@ function Overall({ serverId, wipeId, sort, onSort }) {
</span>
))}
</td>
{COLUMNS.map((column) => (
{columns.map((column) => (
<td key={column.key} style={{ ...cell, textAlign: 'right' }}>
{column.value(row)}
</td>
@@ -190,7 +200,7 @@ function Overall({ serverId, wipeId, sort, onSort }) {
</tr>,
open === row.steamId && (
<tr key={`${row.steamId}-npcs`}>
<td colSpan={COLUMNS.length + 1 + (showLastSeen ? 1 : 0)} style={{ ...cell, whiteSpace: 'normal', paddingLeft: 34 }}>
<td colSpan={columns.length + 1 + (showLastSeen ? 1 : 0)} style={{ ...cell, whiteSpace: 'normal', paddingLeft: 34 }}>
<PlayerNpcKills serverId={serverId} steamId={row.steamId} wipeId={wipeId} />
</td>
</tr>

View File

@@ -105,6 +105,17 @@ export function count(value) {
return Number.isFinite(n) ? n.toLocaleString() : '0'
}
/**
* An Economics balance (PLAN_REDESIGNS §11.2.2): up to two places, and a dash
* for one the server never reported. Not `count`'s zero — "0 coins" is a claim
* about somebody's balance, and an unknown one is not it.
*/
export function coins(value) {
if (value === null || value === undefined || value === '') return '—'
const n = Number(value)
return Number.isFinite(n) ? n.toLocaleString(undefined, { maximumFractionDigits: 2 }) : '—'
}
/**
* A prefab short name as something readable — `patrolhelicopter` stays itself,
* `rifle.ak` becomes `rifle ak`.

View File

@@ -259,7 +259,23 @@ export function integrationsLine(r) {
: 'This server’s plugin is older than protocol 12, so it cannot say which optional mods it has.'
}
const one = (name, x, without) => (x && x.loaded ? `${name} ${x.version || ''} is loaded`.trim() : `${name} is not loaded — ${without}`)
return `${one('BetterChat', r.integrations.betterChat, 'titles and group styles wait for it')}. ${one('PopupNotifications', r.integrations.popupNotifications, 'a popup is refused, and chat still works')}.`
const line = `${one('BetterChat', r.integrations.betterChat, 'titles and group styles wait for it')}. ${one('PopupNotifications', r.integrations.popupNotifications, 'a popup is refused, and chat still works')}.`
return r.integrations.economics ? `${line} ${economicsLine(r.integrations.economics)}` : line
}
/**
* Economics (PLAN_REDESIGNS §11.2.2), from protocol 14. Its starting balance
* decides who the richest column ranks (D336), so a config the plugin could not
* read is said: the site then assumes Economics' default of 1000.
*/
export function economicsLine(e) {
if (!e.loaded) return 'Economics is not loaded — coin rewards, balances and the richest column wait for it.'
const name = `Economics ${e.version || ''}`.trim()
if (e.startingBalance === undefined || e.startingBalance === null) {
return `${name} is loaded, but its config could not be read — the site assumes a starting balance of 1,000.`
}
const wipes = e.wipesWithMap === true ? 'balances wipe with the map' : e.wipesWithMap === false ? 'balances carry over a wipe' : null
return `${name} is loaded — starting balance ${Number(e.startingBalance).toLocaleString()}${wipes ? `, ${wipes}` : ''}.`
}
export function VoiceCard() {

View File

@@ -14,8 +14,8 @@
// majority of them. Silence is the honest answer to "what does the Rust module
// know about this person" when it is nothing.
import { useCallback, useState } from 'react'
import { ago, count, duration } from '../../lib/format.js'
import { useCallback, useRef, useState } from 'react'
import { ago, coins, count, duration } from '../../lib/format.js'
import { useAsync } from '../../core.js'
import api from '../../api.js'
@@ -229,6 +229,130 @@ function PermissionsPanel({ userId, data, onChanged }) {
)
}
/** One submission's key, so a resent form moves nothing (§11.2.2). */
function submissionKey() {
const c = globalThis.crypto
return c && typeof c.randomUUID === 'function' ? c.randomUUID() : `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`
}
/** One server's coins for this person, and the form that changes them. */
function EconomicsServer({ userId, server, onChanged }) {
const [steamId, setSteamId] = useState(server.accounts.length ? server.accounts[0].steamId : '')
const [op, setOp] = useState('deposit')
const [amount, setAmount] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [done, setDone] = useState('')
// D199: said, not hidden — staff asking why the form is missing are owed it.
if (!server.economics) {
return (
<div className="sans" style={{ fontSize: '0.84rem', padding: '4px 0' }}>
<span style={{ color: 'var(--head)' }}>{server.server}</span>{' '}
<span className="dim" style={{ fontSize: '0.76rem' }}>{server.reason}</span>
</div>
)
}
async function submit(event) {
event.preventDefault()
if (busy || !steamId || amount === '') return
setBusy(true)
setError('')
setDone('')
try {
const result = await api.adminUserEconomics.adjust(userId, { serverId: server.serverId, steamId, op, amount: Number(amount), key: submissionKey() })
setDone(`${coins(result.before)} → ${coins(result.after)}`)
setAmount('')
await onChanged()
} catch (err) {
// "They have only 340 coins on Main" is the server's sentence, and the useful one.
setError(err.message || 'That did not work.')
} finally {
setBusy(false)
}
}
return (
<div style={{ padding: '6px 0', borderTop: '1px solid var(--line-soft)' }}>
<div className="sans" style={{ fontSize: '0.84rem' }}>
<span style={{ color: 'var(--head)' }}>{server.server}</span>{' '}
{server.accounts.map((a, i) => (
<span key={a.steamId}>
{i > 0 && <span className="dim"> · </span>}
{server.accounts.length > 1 && <span className="dim">{a.name || a.steamId} </span>}
<strong>{coins(a.balance)}</strong>
{a.source === 'last-known' && <span className="dim"> (last known)</span>}
</span>
))}
{!server.startingKnown && (
<span className="dim" style={{ fontSize: '0.74rem' }}>
{' '}· its Economics config could not be read, so a starting balance of {coins(server.startingBalance)} is assumed
</span>
)}
</div>
<form onSubmit={submit} style={{ display: 'flex', gap: 8, marginTop: 6, flexWrap: 'wrap' }}>
{server.accounts.length > 1 && (
<select className="input" value={steamId} onChange={(e) => setSteamId(e.target.value)} aria-label="Steam account">
{server.accounts.map((a) => <option key={a.steamId} value={a.steamId}>{a.name || a.steamId}</option>)}
</select>
)}
<select className="input" value={op} onChange={(e) => setOp(e.target.value)} aria-label="What to do">
<option value="deposit">Deposit</option>
<option value="withdraw">Withdraw</option>
<option value="set">Set to</option>
</select>
<input
className="input"
type="number"
min={op === 'set' ? 0 : 0.01}
max={1000000}
step="0.01"
placeholder="500"
value={amount}
onChange={(e) => setAmount(e.target.value)}
aria-label="Coins"
style={{ width: 120 }}
/>
<button type="submit" className="btn" disabled={busy || amount === ''}>
{busy ? 'Working…' : 'Apply'}
</button>
</form>
{done && <p className="sans dim" style={{ fontSize: '0.78rem', margin: '6px 0 0' }}>Done: {done}. Recorded in the activity log.</p>}
{error && <p className="sans" style={{ color: '#e05a5a', fontSize: '0.8rem', margin: '6px 0 0' }}>{error}</p>}
</div>
)
}
/**
* Economics (PLAN_REDESIGNS §11.2.2): this person's coins on every server, and
* staff's hand on them — the only way coins come back after an event paid them
* (D335). Its own reload, and it keeps showing the last answer while it
* re-reads, so a deposit's "1,000 → 1,500" is not blanked by the read it caused.
*/
function EconomicsPanel({ userId }) {
const [reloads, setReloads] = useState(0)
const { data } = useAsync(() => api.adminUserEconomics.list(userId), [userId, reloads])
const last = useRef(null)
if (data) last.current = data
const shown = data || last.current
const onChanged = useCallback(() => setReloads((n) => n + 1), [])
const list = (shown && shown.servers) || []
if (!list.length) return null
return (
<div className="panel" style={{ padding: '14px 16px' }}>
<div className="field-label" style={{ marginBottom: 8 }}>Coins</div>
{list.map((server) => (
<EconomicsServer key={server.serverId} userId={userId} server={server} onChanged={onChanged} />
))}
</div>
)
}
export default function UserRustSections({ userId }) {
// Core's `useAsync` has no refresh, so a counter in the deps is how this
// re-reads after its own write (the same shape the player page uses).
@@ -278,6 +402,9 @@ export default function UserRustSections({ userId }) {
above belongs to the links, so it sits with them rather than under
the panel it would otherwise appear to describe. */}
<PermissionsPanel userId={userId} data={permissions} onChanged={reload} />
{/* Coins belong to a Steam account, so only with one linked. */}
{data.links.length > 0 && <EconomicsPanel userId={userId} />}
</div>
</section>
)

View File

@@ -16,7 +16,7 @@
import { useCallback, useState } from 'react'
import { ErrorState, Loading, useAsync } from '../../core.js'
import { ago, shortId } from '../../lib/format.js'
import { ago, coins, shortId } from '../../lib/format.js'
import api from '../../api.js'
/** The code field, and the four answers it can produce. */
@@ -292,6 +292,41 @@ function NpcKills() {
)
}
/**
* Economics (PLAN_REDESIGNS §11.2.2): your coins on each server, per linked
* account. Live where the server answers; otherwise the last balance it
* reported, marked so. A server without Economics says so (D199) rather than
* vanishing — a player asking "where are my coins" is owed the reason.
*/
function Coins() {
const { data, loading, error } = useAsync(() => api.playerEconomics.list(), [])
if (loading) return <Loading />
if (error) return <ErrorState error={error} />
const list = (data && data.servers) || []
if (list.length === 0) return <p className="sans dim" style={{ fontSize: '0.8rem', margin: 0 }}>No servers yet.</p>
return (
<ul className="sans" style={{ listStyle: 'none', margin: 0, padding: 0, fontSize: '0.86rem', display: 'grid', gap: 6 }}>
{list.map((server) => (
<li key={server.serverId}>
<span className="dim">{server.server}: </span>
{!server.economics ? (
<span className="dim">{server.reason}</span>
) : (
server.accounts.map((a, i) => (
<span key={a.steamId}>
{i > 0 && <span className="dim"> · </span>}
{server.accounts.length > 1 && <span className="dim">{a.name || shortId(a.steamId)} </span>}
<strong>{coins(a.balance)}</strong>
{a.source === 'last-known' && <span className="dim"> (last known — the server did not answer)</span>}
</span>
))
)}
</li>
))}
</ul>
)
}
export default function Account() {
// `useAsync` rather than this module's `usePolled`: nothing here changes unless
// the person looking at it changes it, and a page that re-asked every twenty
@@ -360,6 +395,9 @@ export default function Account() {
<div className="field-label" style={{ margin: '30px 0 12px' }}>NPCs you have killed</div>
{data && links.length > 0 ? <NpcKills /> : <p className="sans dim" style={{ fontSize: '0.8rem', margin: 0 }}>Link a Steam account to see them.</p>}
<div className="field-label" style={{ margin: '30px 0 12px' }}>Coins</div>
{data && links.length > 0 ? <Coins /> : <p className="sans dim" style={{ fontSize: '0.8rem', margin: 0 }}>Link a Steam account to see your balance.</p>}
</div>
)
}

View File

@@ -11,7 +11,7 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { ago, attacker, clock, contrastInk, count, day, duration, nextWipe, prefab, shortId } from '../src/lib/format.js'
import { ago, attacker, clock, coins, contrastInk, count, day, duration, nextWipe, prefab, shortId } from '../src/lib/format.js'
const NOW = Date.parse('2026-09-16T12:00:00Z')
@@ -70,6 +70,13 @@ test('a count that is not a number is zero, never NaN on the page', () => {
assert.equal(count(null), '0')
})
test('a balance never reported is a dash, not zero coins (PLAN_REDESIGNS §11.2.2)', () => {
assert.equal(coins(null), '—')
assert.equal(coins(undefined), '—')
assert.equal(coins(0), (0).toLocaleString())
assert.equal(coins(1500.456), (1500.46).toLocaleString(undefined, { maximumFractionDigits: 2 }))
})
test("a feed row from another day carries its date, not just a time", () => {
// Found by the page walk: with the feed filtered to the previous wipe, three
// events from six weeks ago rendered as `02:03 PM` and read as this afternoon.