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

@@ -8,6 +8,8 @@
// rust.kit.entitle the right to redeem a kit, and one more use of
// it (R16, D103), for the people a mode picks
// rust.announce one line in a server's chat, or every server's
// rust.coins.pay Economics coins for the people a mode picks,
// kept once paid (protocol 14, D335)
//
// …and the announce leg, `rust.chat`, which says a published news post in the
// chat of every server whose switch is on (D104).
@@ -40,6 +42,7 @@ const servers = require('./model/servers/servers.model')
const permDb = require('./model/permissions/permissions.db')
const linksDb = require('./model/links/links.db')
const emit = require('./engagement/emit')
const economics = require('./model/economics/economics.model')
const voice = require('./model/permissions/voice')
const { serverFor, transportError, pluginError, perServer, bounded } = require('./eventLeases')
const { BUDGET_MS } = require('./eventWorld')
@@ -81,6 +84,11 @@ const PERMANENT = new Set([
// Protocol 12: a popup on a server without PopupNotifications. Waiting does not
// install it.
'popup-unavailable',
// Protocol 14, Economics (§11.2.2). Nor does it install Economics, and a bad
// amount or too long a list is the step's, not the moment's.
'economics-missing',
'bad-amount',
'too-large',
])
const BUDGETS = [
@@ -97,6 +105,13 @@ const BUDGETS = [
unit: 'lines',
description: 'Lines an event says in a server\'s chat: one per server reached.',
},
{
id: 'rust.coins',
label: 'Coins paid',
unit: 'coins',
description:
'Economics coins an event pays: the amount times the people paid. A mode that is not a count is priced at the most it could pay. Coins paid are kept — nothing takes them back.',
},
]
/** A transport failure, classified. Only a missing configuration is one waiting cannot fix. */
@@ -685,6 +700,210 @@ const kitEntitle = {
},
}
// ── Coins (protocol 14, PLAN_REDESIGNS.md §11.2.2) ──────────────────────────
/** The plugin's bounds on one payment's amount (PROTOCOL.md §20.3). */
const COINS_MIN = economics.AMOUNT_MIN
const COINS_MAX = economics.AMOUNT_MAX
/** The plugin keeps a payment's key in a file; core's key is used as it is unless it is long. */
const PAYMENT_KEY_MAX = 120
/**
* A payment's message, checked like a kit reward's (D333): `{ ok, text,
* delivery }`, text null for a step that says nothing. Measured with the amount
* in place of `{amount}`, against the plugin's line bound. The plugin fills in
* what each player RECEIVED, which is never more than the amount asked.
*/
function coinsNotice(params, amount) {
const text = params.message === undefined || params.message === null ? '' : String(params.message).trim()
if (!text) return { ok: true, text: null, delivery: null }
const rawDelivery = params.delivery === undefined || params.delivery === null || params.delivery === '' ? 'chat' : params.delivery
const delivery = oneOf(rawDelivery, DELIVERIES)
if (!delivery) return { ok: false, error: `a payment's message goes to chat or to a popup, not to "${rawDelivery}"` }
const said = text.replace(/\{amount\}/g, economics.coins(amount))
if (said.length > MAX_CHAT) {
return { ok: false, error: `a payment's message is at most ${MAX_CHAT} characters with the amount in it, not ${said.length}` }
}
return { ok: true, text, delivery }
}
/** The key the plugin remembers a payment by: core's, or a digest of it past the plugin's bound. */
function paymentKey(idempotencyKey, runId, stepId) {
const key = String(idempotencyKey || `coins:${runId}:${stepId}`)
return key.length <= PAYMENT_KEY_MAX ? key : `coins:${crypto.createHash('sha256').update(key).digest('hex').slice(0, 40)}`
}
const coinsPay = {
id: 'rust.coins.pay',
label: 'Pay coins',
description:
'Pays Economics coins to the people a mode picks from this run\'s tally — or to every linked player of the server — and can tell each of them in game. One payment per website account. Coins paid are kept: a run cancelled or aborted afterwards does not take them back.',
// Irreversible, like Module-uo's `uo.item.grant` (D335): coins in a player's
// balance may be spent the moment they land, and the platform reverts a
// ledgered step at the end of EVERY run, a normal finish included — so a
// ledger here would take a prize back. Staff take coins back by hand.
risk: 'irreversible',
reversible: 'none',
version: 1,
budgetMs: BUDGET_MS,
params: [
{ name: 'server', type: 'string', required: true, example: 'main', source: 'rust.options.servers',
description: 'The server whose Economics pays. It needs Economics installed.' },
{ name: 'amount', type: 'float', required: true, example: 500,
description: `How many coins each person receives, ${economics.coins(COINS_MIN)} to ${economics.coins(COINS_MAX)}.` },
{ name: 'recipients', type: 'string', required: true, example: 'top', source: 'rust.options.recipientmodes',
description: 'Who is paid: everyone who scored, the top N, a score of at least X, N drawn at random, the top X per cent — or every linked player of the server, with no tally at all.' },
{ name: 'count', type: 'float', required: false, example: 3,
description: 'N for top and random, X for a minimum score, the percentage for top per cent. Not used for everyone or linked.' },
{ name: 'message', type: 'string', required: false, example: 'You earned {amount} coins',
description: 'Said once to each player paid, in game: at once if they are up, otherwise when they next wake on the server. {amount} is what they received. Leave it empty to say nothing.' },
{ name: 'delivery', type: 'string', required: false, example: 'chat', source: 'rust.options.delivery',
description: 'Chat, or a popup where the server has PopupNotifications (chat where it has not). Chat if left empty.' },
],
// Priced before the tally is read, so at the most it could pay: the amount
// times the count for a count, times the recipient bound for every other mode.
cost: (p) => {
const mode = oneOf(p.recipients, MODES)
const n = Math.round(Number(p.count) || 0)
const people = mode === 'top' || mode === 'random' ? Math.max(0, n) : MAX_RECIPIENTS
return { 'rust.coins': Math.ceil(Math.max(0, Number(p.amount) || 0) * people) }
},
async perform({ runId, stepId, idempotencyKey, params, verify }) {
const amount = Math.round(Number(params.amount) * 100) / 100
if (!Number.isFinite(amount) || amount < COINS_MIN || amount > COINS_MAX) {
return { ok: false, retry: false, error: `a payment is ${economics.coins(COINS_MIN)} to ${economics.coins(COINS_MAX)} coins, not "${params.amount}"` }
}
const mode = oneOf(params.recipients, MODES)
if (!mode) return { ok: false, retry: false, error: `recipients is one of ${MODES.join(', ')}, not "${params.recipients}"` }
const count = checkCount(mode, params.count)
if (!count.ok) return { ok: false, retry: false, error: count.error }
const notice = coinsNotice(params, amount)
if (!notice.ok) return { ok: false, retry: false, error: notice.error }
const found = await serverFor(String(params.server || '').trim())
if (!found.ok) return found
const server = found.server
const name = server.name || server.id
// D199: refused with the reason where Economics is absent, in the dry run
// too — the author learns it on the form, not at the run's reward.
const econ = await economics.forServer(server.id)
if (!econ.loaded) return { ok: false, retry: false, error: `${economics.MISSING} (${name})` }
if ((mode === 'top' || mode === 'random') && count.value > MAX_RECIPIENTS) {
return { ok: false, retry: false, error: `${name} pays at most ${MAX_RECIPIENTS} people in one step, not ${count.value}` }
}
if (verify) return { ok: true }
let picked
let bound = MAX_RECIPIENTS
let users
if (mode === 'linked') {
// Every linked player of the server, as a kit weekend's (§2.5). One past
// the bound, so "too many" is a sentence and not a silent cut.
const linked = await linksDb.linkedOnServer(server.id, bound + 1)
picked = linked.map((r) => ({ steamId: String(r.steamId), name: r.name ? String(r.name) : String(r.steamId) }))
users = new Map(linked.map((r) => [String(r.steamId), Number(r.userId)]))
if (picked.length > bound) {
return {
ok: false,
retry: false,
error: `${name} has more than ${bound} linked players, and pays at most ${bound} in one step — pay from a tally instead`,
}
}
} else {
const tally = await readTally(server, runId)
if (!tally.ok) return tally
picked = pickRecipients(peopleOf(tally.data), mode, count.value, idempotencyKey)
bound = Number(tally.data.maxRecipients) || bound
if (picked.length > bound) {
return {
ok: false,
retry: false,
error: `${picked.length} people qualify, and ${name} pays at most ${bound} in one step — pick a count-based mode or a higher bar`,
}
}
users = await usersFor(picked.map((p) => p.steamId))
}
// One payment per website user (D103's rule): of two linked accounts that
// both took part, the earlier in the list — the higher score — is paid.
const payees = []
const byUser = new Set()
const missed = []
for (const p of picked) {
const userId = users.get(p.steamId)
if (!userId) {
missed.push(p.name)
continue
}
if (byUser.has(userId)) continue
byUser.add(userId)
payees.push(p)
}
const missedDetail = missed.length
? { missed: missed.slice(0, 50), missedCount: missed.length, note: 'missed took part but have linked no website account' }
: {}
if (!payees.length) {
return { ok: true, detail: { server: name, mode, amount, paid: 0, ...missedDetail } }
}
const result = await client.economicsPay(server, {
key: paymentKey(idempotencyKey, runId, stepId),
amount,
steamIds: payees.map((p) => p.steamId),
...(notice.text ? { message: notice.text, delivery: notice.delivery } : {}),
})
// A lost answer is retried with the same key, and the plugin answers it from
// its first reply: nobody is paid twice.
if (!result.ok) return transportFailure(server, result, 'Economics')
const data = result.data || {}
if (data.kind !== 'economics.paid') return refusal(server, data, 'payment')
const named = new Map(payees.map((p) => [p.steamId, p.name]))
const paid = (data.paid || []).map((row) => ({
name: named.get(String(row.steamId)) || String(row.steamId),
received: Math.round((Number(row.after) - Number(row.before)) * 100) / 100,
}))
const failed = (data.failed || []).map((row) => ({ name: named.get(String(row.steamId)) || String(row.steamId), reason: row.reason }))
const total = Math.round(paid.reduce((sum, row) => sum + row.received, 0) * 100) / 100
log.info('coins paid', { server: server.id, run: runId, step: stepId, amount, paid: paid.length, failed: failed.length, repeat: Boolean(data.repeat) })
return {
ok: true,
detail: {
server: name,
mode,
...(count.value === null ? {} : { count: count.value }),
amount,
paid: paid.length,
total,
received: paid.slice(0, 50),
...(failed.length ? { failed: failed.slice(0, 50), failedNote: 'at-limit: the balance is at Economics\' limit; refused or unmeasured: Economics did not take or report the deposit' } : {}),
...(notice.text && paid.length ? { told: `${paid.length} will be told in game, by ${notice.delivery}` } : {}),
...(data.repeat ? { repeat: true, repeatNote: 'answered from the first attempt; nobody was paid twice' } : {}),
...missedDetail,
},
}
},
}
/**
* The body of a chat line: its delivery, and the voice's format when the line
* goes to chat and a voice is chosen (D140). A popup is not a chat line and
@@ -933,7 +1152,7 @@ const OPTION_SOURCES = [
},
]
const ACTIONS = [participationOpen, participationCollect, kitEntitle, announce]
const ACTIONS = [participationOpen, participationCollect, kitEntitle, coinsPay, announce]
module.exports = {
MAX_RECIPIENTS,
@@ -950,6 +1169,8 @@ module.exports = {
kitReward,
readKit,
rewardNotice,
coinsNotice,
paymentKey,
chatLine,
chatKey,
lineBody,