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