feat(rust): Economics — pay coins, balances, staff adjust, the richest (PLAN_REDESIGNS §11.2)
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:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user