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:
@@ -5,8 +5,11 @@
|
||||
// every statement here is scoped by it, so a panel opened on one user cannot
|
||||
// read or write another's rows by editing a path segment.
|
||||
|
||||
const crypto = require('node:crypto')
|
||||
|
||||
const core = require('../../core')
|
||||
|
||||
const economics = require('../../model/economics/economics.model')
|
||||
const links = require('../../model/links/links.model')
|
||||
const permissionsDb = require('../../model/permissions/permissions.db')
|
||||
const permissions = require('../../model/permissions/permissions.model')
|
||||
@@ -208,4 +211,68 @@ async function removeGrant(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { listLinks, removeLink, listPermissions, addGrant, removeGrant }
|
||||
/** GET /admin/users/:id/rust/economics — this user's balances (§11.2.2), as the player sees them. */
|
||||
async function listEconomics(req, res) {
|
||||
try {
|
||||
const held = await links.listForUser(Number(req.params.id))
|
||||
res.json({ servers: await economics.balancesOf(held.map((l) => ({ steamId: l.steamId, name: l.name }))) })
|
||||
} catch (err) {
|
||||
log.error('failed to read a user’s balances', { error: err.message })
|
||||
res.status(500).json({ message: 'Failed to read this user’s balances' })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /admin/users/:id/rust/economics — staff deposit, withdraw or set coins.
|
||||
*
|
||||
* **Scoped by the user**, like every write in this panel: the Steam id must be
|
||||
* one this user has linked, so a wrong id on the form moves nobody's coins
|
||||
* rather than a stranger's. The key is the form's, one per submission; a form
|
||||
* that sent none gets one, and is then only as safe as a single click.
|
||||
*/
|
||||
async function adjustEconomics(req, res) {
|
||||
const userId = Number(req.params.id)
|
||||
const { serverId, steamId, op, amount } = req.body
|
||||
const key = req.body.key ? `adj-${req.body.key}` : `adj-${crypto.randomUUID()}`
|
||||
|
||||
try {
|
||||
const held = await links.listForUser(userId)
|
||||
const link = held.find((l) => String(l.steamId) === String(steamId))
|
||||
if (!link) return res.status(404).json({ message: 'That Steam account is not linked to this user' })
|
||||
|
||||
const result = await economics.adjust({ serverId, steamId: String(steamId), op, amount, key })
|
||||
if (!result.ok) return res.status(result.status).json({ message: result.message, ...(result.reason ? { reason: result.reason } : {}) })
|
||||
|
||||
// A repeat moved nothing, so it is not a second row in the log.
|
||||
if (!result.repeat) {
|
||||
await core.activity.log({
|
||||
req,
|
||||
action: 'rust.economics.adjust',
|
||||
detail: {
|
||||
userId,
|
||||
serverId,
|
||||
steamId: String(steamId),
|
||||
op,
|
||||
amount: result.amount,
|
||||
before: result.before,
|
||||
after: result.after,
|
||||
summary: economics.adjustmentSummary({
|
||||
op,
|
||||
amount: result.amount,
|
||||
name: link.name || String(steamId),
|
||||
server: result.server,
|
||||
before: result.before,
|
||||
after: result.after,
|
||||
}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return res.json({ before: result.before, after: result.after, repeat: result.repeat })
|
||||
} catch (err) {
|
||||
log.error('failed to adjust a balance', { userId, serverId, op, error: err.message })
|
||||
return res.status(500).json({ message: 'Failed to adjust that balance' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { listLinks, removeLink, listPermissions, addGrant, removeGrant, listEconomics, adjustEconomics }
|
||||
|
||||
@@ -128,4 +128,47 @@ usersRustRouter.delete(
|
||||
usersRust.removeGrant,
|
||||
)
|
||||
|
||||
// ── Economics (PLAN_REDESIGNS §11.2.2): a person's coins, and staff's hand ──
|
||||
//
|
||||
// Beside their links, because "how many coins does this player have" is asked
|
||||
// about a person. The write is the only way coins come back after an event
|
||||
// paid them (D335): staff withdraw them by hand, and each adjustment is an
|
||||
// activity row with the balance before and after.
|
||||
|
||||
usersRustRouter.get(
|
||||
'/rust/economics',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
// #swagger.summary = 'A user’s coin balances (admin only)'
|
||||
// #swagger.description = 'This user’s Economics balance on every server, per linked Steam account — live where the server answers, else the last balance it reported. A server without Economics is listed with the reason. The same shape as GET /player/rust/economics.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||
/* #swagger.responses[200] = { description: 'The balances', content: { "application/json": { schema: { $ref: "#/components/schemas/RustEconomicsBalances" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
usersRust.listEconomics,
|
||||
)
|
||||
|
||||
usersRustRouter.post(
|
||||
'/rust/economics',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
// #swagger.summary = 'Deposit, withdraw or set a user’s coins on one server (admin only)'
|
||||
// #swagger.description = 'Through the server’s Economics, for one Steam account this user has linked. A withdrawal above the balance is refused with the balance, because Economics takes all or nothing. `key` makes a resent form harmless: the game answers a key it has seen from the first reply and moves nothing. Recorded in the activity log with the balance before and after.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/RustEconomicsAdjust" } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Adjusted', content: { "application/json": { schema: { $ref: "#/components/schemas/RustEconomicsAdjusted" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'A bad op or amount', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such server, or that Steam account is not linked to this user', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[409] = { description: 'Economics is not installed there, or the balance is too low to withdraw that much', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[503] = { description: 'The server could not be reached', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
body('serverId').isString().isLength({ min: 1, max: 64 }),
|
||||
body('steamId').isString().matches(STEAM_ID_RE),
|
||||
body('op').isIn(['deposit', 'withdraw', 'set']),
|
||||
body('amount').isFloat({ min: 0, max: 1000000 }).toFloat(),
|
||||
body('key').optional().isString().matches(/^[A-Za-z0-9-]{8,64}$/),
|
||||
validate,
|
||||
usersRust.adjustEconomics,
|
||||
)
|
||||
|
||||
module.exports = usersRustRouter
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
|
||||
const core = require('../../core')
|
||||
|
||||
const economics = require('../../model/economics/economics.model')
|
||||
const links = require('../../model/links/links.model')
|
||||
const npcs = require('../../model/npcs/npcs.model')
|
||||
const npcsDb = require('../../model/npcs/npcs.db')
|
||||
@@ -55,6 +56,20 @@ async function ownNpcKills(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /player/rust/economics — the caller's coin balances (§11.2.2): every
|
||||
* server, each linked account, live where the server answers.
|
||||
*/
|
||||
async function ownEconomics(req, res) {
|
||||
try {
|
||||
const held = await links.listForUser(req.user.id)
|
||||
res.json({ servers: await economics.balancesOf(held.map((l) => ({ steamId: l.steamId, name: l.name }))) })
|
||||
} catch (err) {
|
||||
log.error('failed to read a player’s balances', { error: err.message })
|
||||
res.status(500).json({ message: 'Failed to read your balances' })
|
||||
}
|
||||
}
|
||||
|
||||
/** GET /player/rust/links — the Steam accounts the caller holds. */
|
||||
async function listLinks(req, res) {
|
||||
try {
|
||||
@@ -187,4 +202,4 @@ async function listPermissions(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { listServers, listLinks, confirmLink, removeLink, listPermissions, ownNpcKills }
|
||||
module.exports = { listServers, listLinks, confirmLink, removeLink, listPermissions, ownNpcKills, ownEconomics }
|
||||
|
||||
@@ -67,6 +67,16 @@ playerRustRouter.get(
|
||||
rust.ownNpcKills,
|
||||
)
|
||||
|
||||
playerRustRouter.get(
|
||||
'/economics',
|
||||
// #swagger.tags = ['Player · Rust']
|
||||
// #swagger.summary = 'Your coin balances'
|
||||
// #swagger.description = 'The caller’s Economics balance on every server, per linked Steam account (PLAN_REDESIGNS §11.2.2). Read live from each server; where a server does not answer, the last balance it reported (`source: last-known`), and null where neither knows. A server without Economics is listed with `economics: false` and the reason. `startingKnown: false` means the server’s Economics config could not be read and the starting balance shown is Economics’ default. Empty `accounts` with no linked account. Requires a session.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The balances', content: { "application/json": { schema: { $ref: "#/components/schemas/RustEconomicsBalances" } } } } */
|
||||
rust.ownEconomics,
|
||||
)
|
||||
|
||||
playerRustRouter.get(
|
||||
'/servers',
|
||||
// #swagger.tags = ['Player · Rust']
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
const core = require('../../core')
|
||||
|
||||
const clans = require('../../model/clans/clans.model')
|
||||
const economics = require('../../model/economics/economics.model')
|
||||
const events = require('../../model/events/events.model')
|
||||
const map = require('../../model/map/map.model')
|
||||
const mapDb = require('../../model/map/map.db')
|
||||
@@ -108,13 +109,22 @@ async function listEvents(req, res) {
|
||||
async function listLeaderboard(req, res) {
|
||||
try {
|
||||
const presence = await visibility.canSeePresence(req, req.params.id)
|
||||
const wipeId = req.query.wipe || null
|
||||
|
||||
// The richest column (§11.2.2): one wipe at a time, on a server that
|
||||
// reports Economics. `economics` is null otherwise, and the rows carry no
|
||||
// `balance`, so a page shows the column exactly when there is one.
|
||||
const econ = await economics.forServer(req.params.id)
|
||||
const withBalance = econ.loaded && wipeId ? { startingBalance: econ.startingBalance } : null
|
||||
|
||||
const [rows, held] = await Promise.all([
|
||||
events.leaderboard({
|
||||
serverId: req.params.id,
|
||||
wipeId: req.query.wipe || null,
|
||||
wipeId,
|
||||
sort: req.query.sort,
|
||||
limit: req.query.limit,
|
||||
presence: presence.visible,
|
||||
economics: withBalance,
|
||||
}),
|
||||
titles.currentFor(req.params.id),
|
||||
])
|
||||
@@ -125,6 +135,7 @@ async function listLeaderboard(req, res) {
|
||||
// this page is showing, because a title is what a player is, not what a
|
||||
// past wipe was.
|
||||
leaderboard: rows.map((row) => ({ ...row, titles: held.get(row.steamId) || [] })),
|
||||
economics: withBalance,
|
||||
})
|
||||
} catch (err) {
|
||||
log.error('failed to read the leaderboard', { server: req.params.id, error: err.message })
|
||||
|
||||
@@ -82,10 +82,10 @@ rustRouter.get(
|
||||
'/servers/:id/leaderboard',
|
||||
// #swagger.tags = ['Public · Rust']
|
||||
// #swagger.summary = 'The leaderboard for one Rust server'
|
||||
// #swagger.description = 'Per-wipe when `wipe` is given, all-time otherwise. All-time is the per-wipe rows summed rather than a second set of counters, so a wipe splits a player’s history without ending it. `lastSeen` is withheld below the operator’s presence audience: a gather tally refreshes it every minute a player is on, so it would name who is online. Each row carries `titles`: the chat titles that player holds now, as `[{ text, color }]` in the order the game shows them, and an empty list for a player with none. Titles rank the CURRENT wipe whichever wipe the page asks for, and are set by the operator’s rules.'
|
||||
// #swagger.description = 'Per-wipe when `wipe` is given, all-time otherwise. All-time is the per-wipe rows summed rather than a second set of counters, so a wipe splits a player’s history without ending it. `lastSeen` is withheld below the operator’s presence audience: a gather tally refreshes it every minute a player is on, so it would name who is online. Each row carries `titles`: the chat titles that player holds now, as `[{ text, color }]` in the order the game shows them, and an empty list for a player with none. Titles rank the CURRENT wipe whichever wipe the page asks for, and are set by the operator’s rules. On a server that reports Economics, and only for one wipe, each row also carries `balance` (its last known coin balance, null when none was reported) and the answer carries `economics: { startingBalance }` (PLAN_REDESIGNS §11.2.2); `economics` is null otherwise. `sort=balance` ranks the richest, and only balances above the starting balance are on it (D336).'
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } }
|
||||
// #swagger.parameters['wipe'] = { in: 'query', required: false, description: 'Restrict to one wipe id', schema: { type: 'string' } }
|
||||
// #swagger.parameters['sort'] = { in: 'query', required: false, description: 'kills, deaths, npcKills or playtime', schema: { type: 'string' } }
|
||||
// #swagger.parameters['sort'] = { in: 'query', required: false, description: 'kills, deaths, npcKills, playtime, or balance (one wipe on a server with Economics)', schema: { type: 'string' } }
|
||||
// #swagger.parameters['limit'] = { in: 'query', required: false, description: 'Rows to return, capped at 200', schema: { type: 'integer' } }
|
||||
/* #swagger.responses[200] = { description: 'The leaderboard' } */
|
||||
siteMode,
|
||||
|
||||
Reference in New Issue
Block a user