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:
@@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user