chore(release): cut edge over to main — protocol 14 and Steam sign-in (MODULE_API 1.17.0) #58

Merged
whitlocktech merged 42 commits from edge into main 2026-10-22 06:16:14 -05:00
32 changed files with 1907 additions and 35 deletions
Showing only changes of commit 69ec0f6c46 - Show all commits

View File

@@ -38,11 +38,12 @@ rows here; the website core never learns there is more than one.
| Public | `GET /api/v1/public/rust/servers` — every server and what it last reported |
| Public | `GET …/servers/:id` — one server, or a `404`; the only route under `:id` that can say a server does not exist |
| Public | `GET …/servers/:id/events` — the feed, served from a default-deny allowlist (`server/catalogue.js`) |
| Public | `GET …/servers/:id/leaderboard` — per wipe, or all-time as those rows summed; each row carries the player's chat titles |
| Public | `GET …/servers/:id/leaderboard` — per wipe, or all-time as those rows summed; each row carries the player's chat titles, and on one wipe of a server with Economics their coin balance |
| Public | `GET …/servers/:id/wipes` and `…/online` |
| Public | `GET …/servers/:id/clans` — the server's clans, best score first (public: names nobody) |
| Public | `GET /api/v1/public/rust/clans/:externalId` — one clan, and its roster inside the roster audience |
| Player | `GET /api/v1/player/rust/servers` — the server list, on the authenticated tier |
| Player | `GET /api/v1/player/rust/economics` — your coin balances on every server, per linked account |
| Admin | `GET/PUT/DELETE /api/v1/admin/rust/servers` and `POST …/:id/test` — the `PUT` carries the wipe schedule |
| Admin | `GET/PUT /api/v1/admin/rust/visibility` — who may see who is online, fleet-wide and per server |
| Admin | `PUT …/servers/:id/titles`, `GET …/servers/:id/integrations`, `GET/PUT /api/v1/admin/rust/voice` — chat titles, the optional mods a server has, and the announcement voice |
@@ -82,7 +83,7 @@ forced wipe (first Thursday, 19:00 UK time) — plus an optional one-off date th
computed wipe. It is computed on every read and never stored, so it cannot go stale after a wipe.
The server list, the server page, the Android app and `/wipe` all show it.
**Two uMod plugins are optional, and the module works without either** (`docs/modules/rust/PLAN.md`
**Three uMod plugins are optional, and the module works without any of them** (`docs/modules/rust/PLAN.md`
§33). The bridge plugin's hard requirements are **Kits** and **ZoneManager**.
- **BetterChat** (LaserHydra, 5.2.15). With it: **chat titles** an operator sets per server — "top 3
@@ -94,6 +95,11 @@ The server list, the server page, the Android app and `/wipe` all show it.
- **PopupNotifications** (k1lly0u, 0.2.1). With it: `rust.announce` and a server's news posts can be
a popup instead of a chat line. Without it a popup is refused with a sentence saying so, and chat
works as before.
- **Economics** (Wulf, 3.9.2). With it: the **Pay coins** event step (`rust.coins.pay`, kept once
paid), each player's balance on their account page and in the app, staff deposit / withdraw / set
from a user's admin page, a **Coins** leaderboard column and the **Magnate** title — both ranking
only balances above the server's starting balance. Without it each of those says "Economics is not
installed on this server" (`docs/modules/rust/PLAN_REDESIGNS.md` §11.2).
News and event lines said in chat can wear one styled group's title and colours — the
**announcement voice**, chosen in Admin → Rust servers. The line is said by the bridge plugin with no

View File

@@ -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,
}

View File

@@ -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>

View File

@@ -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`.

View File

@@ -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() {

View File

@@ -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>
)

View File

@@ -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>
)
}

View File

@@ -11,7 +11,7 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { ago, attacker, clock, contrastInk, count, day, duration, nextWipe, prefab, shortId } from '../src/lib/format.js'
import { ago, attacker, clock, coins, contrastInk, count, day, duration, nextWipe, prefab, shortId } from '../src/lib/format.js'
const NOW = Date.parse('2026-09-16T12:00:00Z')
@@ -70,6 +70,13 @@ test('a count that is not a number is zero, never NaN on the page', () => {
assert.equal(count(null), '0')
})
test('a balance never reported is a dash, not zero coins (PLAN_REDESIGNS §11.2.2)', () => {
assert.equal(coins(null), '—')
assert.equal(coins(undefined), '—')
assert.equal(coins(0), (0).toLocaleString())
assert.equal(coins(1500.456), (1500.46).toLocaleString(undefined, { maximumFractionDigits: 2 }))
})
test("a feed row from another day carries its date, not just a time", () => {
// Found by the page walk: with the feed filtered to the previous wipe, three
// events from six weeks ago rendered as `02:03 PM` and read as this afternoon.

View File

@@ -126,6 +126,11 @@
"path": "/api/v1/admin/rust/zones",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/admin/users/:id/rust/economics",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/admin/users/:id/rust/links",
@@ -136,6 +141,11 @@
"path": "/api/v1/admin/users/:id/rust/permissions",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/player/rust/economics",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/player/rust/links",
@@ -351,6 +361,11 @@
"path": "/api/v1/admin/rust/zones/presets",
"tier": "public"
},
{
"method": "POST",
"path": "/api/v1/admin/users/:id/rust/economics",
"tier": "public"
},
{
"method": "POST",
"path": "/api/v1/admin/users/:id/rust/permissions/grants",

View File

@@ -1363,3 +1363,11 @@ CREATE TABLE IF NOT EXISTS rust_npc_factions (
-- stage 4, D250). Null for every other stat. No foreign key: a deleted profile
-- leaves a rule that ranks nobody, which the form shows and an admin removes.
ALTER TABLE rust_title_rules ADD COLUMN IF NOT EXISTS profile_id INT UNSIGNED NULL;
-- Economics (PLAN_REDESIGNS §11.2.2, D336): a player's last known coin balance
-- on the server, for the wipe it was reported in. A VALUE, written over, never
-- summed — the tally carries the balance itself, not a change. NULL until the
-- server has reported one, and again after `ecowipe` clears the wipe's. The
-- richest column and the Magnate title rank only balances above the server's
-- starting balance, so the untouched accounts of a fresh wipe rank nobody.
ALTER TABLE rust_player_wipe_stats ADD COLUMN IF NOT EXISTS balance DECIMAL(14,2) NULL;

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,

View File

@@ -138,6 +138,17 @@ async function apply(serverId, item, server = null) {
}
case 'player.tally': {
// Protocol 14 (§11.2.2): the player's Economics balance, a VALUE written
// over rather than added — unlike everything below it.
const hasBalance = frame.balance !== undefined && frame.balance !== null
if (hasBalance) await db.setBalance(at, frame.balance)
// A balance with no name is an offline player's: a payment or a staff
// adjustment reaches somebody who is not there, and the plugin names only
// a connected player. It says nothing about play, so it must not move
// anybody's last-seen — which every writer below does.
if (hasBalance && !frame.name) break
await db.touchPlayer(frame.steamId, frame.name || null)
await db.addStats(at, {
npcKills: Number(frame.npcKills) || 0,
@@ -257,6 +268,16 @@ async function apply(serverId, item, server = null) {
break
}
// ── Protocol 14: Economics wiped every balance (§11.2.2) ────────────────
//
// `ecowipe` puts everyone back at the start in the wipe in play, so none of
// its balances is known any more. A new save's wipe (`newSave`) opens a new
// map whose balances arrive as players connect; the last wipe's stay as the
// record of how it ended.
case 'economics.wiped':
if (!frame.newSave) await db.clearBalances(serverId, wipeId)
break
// ── Protocol 6: first-party clans ──────────────────────────────────────
//
// Each one is told to core as it happens (`ctx.teams.publish`) and written

View File

@@ -0,0 +1,36 @@
// ── SQL for Economics (PLAN_REDESIGNS §11.2.2) ────────────────────────────
//
// What each server's last hello said about Economics. The balances themselves
// are rows of `rust_player_wipe_stats` and are written by ingest through
// `events.db.js`; this file only reads the report the plugin makes about the
// plugin.
const core = require('../../core')
const STATE = 'rust_server_state'
/**
* Every server's report: `{ serverId, wipeId, economics }`, with `economics`
* as the driver hands JSON_EXTRACT back — text, or null for a server whose
* plugin never said (no state yet, or a plugin before protocol 14).
*/
async function reports() {
return core.query(
`SELECT server_id AS serverId, wipe_id AS wipeId,
JSON_EXTRACT(raw, '$.integrations.economics') AS economics
FROM ${STATE}`,
)
}
async function reportFor(serverId) {
const rows = await core.query(
`SELECT server_id AS serverId, wipe_id AS wipeId,
JSON_EXTRACT(raw, '$.integrations.economics') AS economics
FROM ${STATE}
WHERE server_id = ?`,
[serverId],
)
return rows[0] || null
}
module.exports = { reports, reportFor }

View File

@@ -0,0 +1,218 @@
// ── Economics, as the site sees it (PLAN_REDESIGNS §11.2.2, D199, D335, D336) ─
//
// Wulf's Economics is optional (D199): a server reports it in hello's
// `integrations.economics`, and every surface that needs it refuses with a
// reason where it is absent, never quietly. Three readers share this file:
//
// • the step (`rust.coins.pay`, in eventRewards.js), which asks whether the
// server has Economics before it pays anybody;
// • the player's own page and the app, which read each linked account's
// balance live and fall back to the last one the tally carried;
// • staff, who deposit, withdraw or set one player's balance.
//
// The starting balance matters to D336: the richest column and the Magnate
// title rank only balances above it, because Economics answers it for an
// account it has never seen (§11.2.1). The plugin reads it from Economics'
// config; when that file cannot be read the site assumes Economics' default,
// 1000, and says so (`startingKnown: false`).
const core = require('../../core')
const client = require('../../sidecarClient')
const db = require('./economics.db')
const eventsDb = require('../events/events.db')
const servers = require('../servers/servers.model')
const { pluginError, transportError } = require('../../eventLeases')
const log = core.logger('economics')
/** Economics' own default starting balance, assumed when its config cannot be read. */
const DEFAULT_STARTING_BALANCE = 1000
/** The sentence every surface says where a server has no Economics (D199). */
const MISSING = 'Economics is not installed on this server'
const OPS = ['deposit', 'withdraw', 'set']
/** The plugin's bounds (PROTOCOL.md §20.3), refused here first so a form says why. */
const AMOUNT_MIN = 0.01
const AMOUNT_MAX = 1000000
/** JSON_EXTRACT comes back as text from the driver; an object passes through. */
function parse(value) {
if (value === null || value === undefined) return null
if (typeof value === 'object') return value
try {
return JSON.parse(value)
} catch {
return null
}
}
/**
* A server's Economics as its last hello said: `{ loaded: false }`, or loaded
* with its version, its starting balance (assumed where unread) and whether
* balances wipe with the map.
*/
function shape(raw) {
const e = parse(raw)
if (!e || e.loaded !== true) return { loaded: false }
const start = Number(e.startingBalance)
const known = e.startingBalance !== undefined && e.startingBalance !== null && Number.isFinite(start)
return {
loaded: true,
version: typeof e.version === 'string' ? e.version : null,
startingBalance: known ? start : DEFAULT_STARTING_BALANCE,
startingKnown: known,
wipesWithMap: typeof e.wipesWithMap === 'boolean' ? e.wipesWithMap : null,
}
}
/** One server's report, with the wipe it is on. `{ loaded: false }` for a server never heard from. */
async function forServer(serverId) {
const row = await db.reportFor(serverId)
if (!row) return { loaded: false, wipeId: null }
return { ...shape(row.economics), wipeId: row.wipeId || null }
}
/** Every server's report, keyed by id. */
async function byServer() {
const rows = await db.reports()
return new Map(rows.map((r) => [r.serverId, { ...shape(r.economics), wipeId: r.wipeId || null }]))
}
/** An amount as a sentence says it: thousands separated, at most two places. */
function coins(n) {
return Number(n).toLocaleString('en-US', { maximumFractionDigits: 2 })
}
/**
* The balances of some Steam accounts on every enabled server: live where the
* server answers, the last balance the tally carried where it does not, and
* null where neither knows. A server without Economics is listed with the reason,
* so a page can say why there is nothing rather than show nothing.
*
* @param {Array<{steamId, name}>} accounts the accounts to read, a user's links
*/
async function balancesOf(accounts) {
const ids = [...new Set(accounts.map((a) => String(a.steamId)).filter(Boolean))]
const [list, reports] = await Promise.all([servers.listForPolling(), byServer()])
return Promise.all(
list.map(async (server) => {
const econ = reports.get(server.id) || { loaded: false }
const base = { serverId: server.id, server: server.name || server.id }
if (!econ.loaded) return { ...base, economics: false, reason: MISSING, accounts: [] }
const known = await eventsDb.balancesFor(server.id, econ.wipeId, ids)
const live = new Map()
let answered = false
if (ids.length) {
const result = await client.economicsBalances(server, ids)
const data = (result.ok && result.data) || {}
if (data.kind === 'economics.balances') {
answered = true
for (const row of data.balances || []) {
if (row && row.steamId && Number.isFinite(Number(row.balance))) live.set(String(row.steamId), Number(row.balance))
}
}
}
return {
...base,
economics: true,
startingBalance: econ.startingBalance,
startingKnown: econ.startingKnown,
live: answered,
accounts: accounts.map((a) => {
const id = String(a.steamId)
const source = live.has(id) ? 'live' : known.has(id) ? 'last-known' : null
return {
steamId: id,
name: a.name || null,
balance: source === 'live' ? live.get(id) : source ? known.get(id) : null,
source,
}
}),
}
}),
)
}
/** Checks a staff adjustment's op and amount. `{ ok }` or `{ ok: false, message }`. */
function checkAdjustment(op, amount) {
if (!OPS.includes(op)) return { ok: false, message: `op is one of ${OPS.join(', ')}` }
const value = Number(amount)
const min = op === 'set' ? 0 : AMOUNT_MIN
if (!Number.isFinite(value) || value < min || value > AMOUNT_MAX) {
return { ok: false, message: `an amount is ${op === 'set' ? '0' : coins(AMOUNT_MIN)} to ${coins(AMOUNT_MAX)} coins` }
}
return { ok: true, value: Math.round(value * 100) / 100 }
}
/**
* Staff deposit, withdraw or set one player's balance on one server.
*
* Resolves `{ ok: true, before, after, repeat, server }` or `{ ok: false,
* status, message, reason? }`. The key makes a resent form harmless: the plugin
* answers a key it has seen from the first reply, and moves nothing.
*/
async function adjust({ serverId, steamId, op, amount, key }) {
const checked = checkAdjustment(op, amount)
if (!checked.ok) return { ok: false, status: 400, message: checked.message }
const server = await servers.getForCalling(serverId)
if (!server) return { ok: false, status: 404, message: 'No such server' }
const econ = await forServer(server.id)
if (!econ.loaded) return { ok: false, status: 409, reason: 'economics-missing', message: MISSING }
const result = await client.economicsAdjust(server, { key, steamId, op, amount: checked.value })
if (!result.ok) return { ok: false, status: 503, message: transportError(server, result, 'Economics') }
const data = result.data || {}
const name = server.name || server.id
if (data.kind === 'economics.adjusted') {
return { ok: true, before: Number(data.before), after: Number(data.after), repeat: Boolean(data.repeat), server: name, amount: checked.value }
}
if (data.reason === 'insufficient') {
// Economics takes all or nothing (§11.2.1), so the sentence is the balance.
return { ok: false, status: 409, reason: 'insufficient', message: `They have only ${coins(data.balance)} coins on ${name}` }
}
log.warn('Economics refused an adjustment', { server: server.id, op, reason: data.reason })
return {
ok: false,
status: data.reason === 'economics-missing' ? 409 : 400,
reason: data.reason || null,
message: pluginError(data, `${name} refused the adjustment`),
}
}
/** The activity row's sentence: "Deposited 500 coins to Wanderer on Main: 1,000 → 1,500". */
function adjustmentSummary({ op, amount, name, server, before, after }) {
const verb = { deposit: `Deposited ${coins(amount)} coins to`, withdraw: `Withdrew ${coins(amount)} coins from`, set: `Set the balance of` }[op]
return `${verb} ${name} on ${server}${op === 'set' ? ` to ${coins(amount)}` : ''}: ${coins(before)} → ${coins(after)}`
}
module.exports = {
DEFAULT_STARTING_BALANCE,
MISSING,
OPS,
AMOUNT_MIN,
AMOUNT_MAX,
shape,
forServer,
byServer,
coins,
balancesOf,
checkAdjustment,
adjust,
adjustmentSummary,
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -76,9 +76,27 @@ const COLUMN = /^[a-z_]+$/
* @param {object} source a category's `source` from `titles.js`
* @param {object} lists list name → the short names it holds (`lists.js`)
*/
async function standings({ serverId, wipeId, source, lists = {}, limit }) {
async function standings({ serverId, wipeId, source, lists = {}, limit, floor = null }) {
const params = [serverId, wipeId]
// The richest (§11.2.2): the balance is a value, one per row, ranked only
// above `floor`, the server's starting balance (D336). No floor, no ranking —
// the caller passes one only for a server that reports Economics.
if (source.balance) {
if (!COLUMN.test(source.balance)) throw new Error('not a stats column')
if (!Number.isFinite(floor)) return []
return core.query(
`SELECT s.steam_id AS steamId, MAX(s.${source.balance}) AS value
FROM ${STATS} s
WHERE s.server_id = ? AND s.wipe_id = ? AND s.${source.balance} > ?
GROUP BY s.steam_id
ORDER BY value DESC, MAX(s.last_seen) DESC
LIMIT ?`,
[...params, floor, limit],
)
}
if (source.sum || source.diff || source.best) {
let value
if (source.sum) value = `SUM(s.${source.sum})`

View File

@@ -20,8 +20,9 @@ const crypto = require('node:crypto')
/**
* The categories a rule may rank (PLAN_REDESIGNS §5, D172): the twenty-three
* conditions and their shipped titles, plus `playtime`, which predates them and
* has no default title, so a playtime rule always carries its own text.
* conditions and their shipped titles, the richest (§11.2.2, Magnate), plus
* `playtime`, which predates them and has no default title, so a playtime rule
* always carries its own text.
*
* `source` says where the standing is read from (`titles.db.js`):
* `sum` a column of `rust_player_wipe_stats`, summed
@@ -30,6 +31,9 @@ const crypto = require('node:crypto')
* `best` a column's MAX: the longest single kill (D174)
* `weapons` a named list's kills in `rust_weapon_kills` (§5.3)
* `gathered` a named list's amount in `rust_gather_totals` (D159)
* `balance` the Economics balance, a value per wipe (PLAN_REDESIGNS §11.2.2):
* ranked only above the server's starting balance (D336), and
* only on a server that reports Economics
* `npcProfile` one site NPC profile's kills, counted as the profile says
* (runicnpc stage 4, D247, D250). A rule on it names the profile,
* and carries its own text, like playtime.
@@ -61,6 +65,7 @@ const STATS = {
rockets: { label: 'Rockets fired', title: 'Artillerist', source: { sum: 'rockets' } },
explosives: { label: 'Explosives thrown', title: 'Demolitionist', source: { sum: 'explosives' } },
missions: { label: 'Missions completed', title: 'Wayfarer', source: { sum: 'missions' } },
richest: { label: 'Richest (Economics)', title: 'Magnate', source: { balance: 'balance' } },
playtime: { label: 'Playtime', title: null, source: { sum: 'playtime_sec' } },
profilekills: { label: 'Kills of an NPC profile', title: null, source: { npcProfile: true } },
}

View File

@@ -5,6 +5,7 @@
// the app's — so it is remembered briefly: the leaderboard is polled by every
// open page, and each rule is a leaderboard query of its own.
const economics = require('../economics/economics.model')
const serversDb = require('../servers/servers.db')
const db = require('./titles.db')
const lists = require('./lists')
@@ -118,12 +119,21 @@ async function heldFor(serverId, { wipeId, now = Date.now() } = {}) {
ruleBy.set(key, r)
}
// The richest ranks above the server's starting balance (D336), and only
// where the server reports Economics: a balance last known before it was
// removed is not anybody's standing now.
let floor = null
if (depth.has('richest')) {
const econ = await economics.forServer(serverId)
floor = econ.loaded ? econ.startingBalance : null
}
await Promise.all(
[...depth.entries()].map(async ([key, limit]) => {
const r = ruleBy.get(key)
const rows = r.stat === 'profilekills'
? await npcs.standing(serverId, r.profile, wipeId, limit)
: await db.standings({ serverId, wipeId, source: titles.STATS[r.stat].source, lists, limit })
: await db.standings({ serverId, wipeId, source: titles.STATS[r.stat].source, lists, limit, floor })
// SUM() and MAX() come back as strings; the rules compare numbers.
standings[key] = rows.map((row) => ({ steamId: row.steamId, value: Number(row.value) || 0 }))
}),

View File

@@ -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 }

View File

@@ -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

View File

@@ -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 }

View File

@@ -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']

View File

@@ -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 })

View File

@@ -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,

View File

@@ -460,6 +460,20 @@ const kits = (server) => request(server, '/kits')
const kitsUses = (server, kit, steamIds) =>
request(server, '/kits/uses', { method: 'POST', body: { kit, steamIds } })
/**
* Economics (protocol 14, PLAN_REDESIGNS.md §11.2.2). `economics.paid` lists each
* player's balance before and after; a key already paid answers `repeat: true`
* and pays nobody, so a retry is safe. `economics.error` has a reason.
*/
const economicsPay = (server, body) => request(server, '/economics/pay', { method: 'POST', body })
/** Each player's balance, and the server's starting balance when its config says. */
const economicsBalances = (server, steamIds) =>
request(server, '/economics/balances', { method: 'POST', body: { steamIds } })
/** Staff: `{ key, steamId, op, amount }`, op deposit · withdraw · set. `economics.adjusted` has before and after. */
const economicsAdjust = (server, body) => request(server, '/economics/adjust', { method: 'POST', body })
/**
* Say one line in the server's chat. `chat.ok` carries `said: false` when the
* key was said in the last ten minutes — a retry, answered and not repeated.
@@ -549,6 +563,9 @@ module.exports = {
tallyClose,
kits,
kitsUses,
economicsPay,
economicsBalances,
economicsAdjust,
chat,
titles,
mapInfo,

View File

@@ -1286,6 +1286,51 @@ module.exports = {
kills: { type: 'array', items: { type: 'object' }, example: [{ serverId: 'main', profile: 'warden', label: 'Warden', kills: 3 }] },
},
},
RustEconomicsBalances: {
type: 'object',
description: 'Coin balances per server and linked Steam account (PLAN_REDESIGNS §11.2.2). `source` is `live`, `last-known` or null.',
properties: {
servers: {
type: 'array',
items: {
type: 'object',
properties: {
serverId: { type: 'string', example: 'main' },
server: { type: 'string', example: 'Main' },
economics: { type: 'boolean', example: true },
reason: { type: 'string', example: 'Economics is not installed on this server' },
startingBalance: { type: 'number', example: 1000 },
startingKnown: { type: 'boolean', example: true },
live: { type: 'boolean', example: true },
accounts: {
type: 'array',
items: { type: 'object' },
example: [{ steamId: '76561198000000001', name: 'Wanderer', balance: 1500, source: 'live' }],
},
},
},
},
},
},
RustEconomicsAdjust: {
type: 'object',
required: ['serverId', 'steamId', 'op', 'amount'],
properties: {
serverId: { type: 'string', example: 'main' },
steamId: { type: 'string', example: '76561198000000001' },
op: { type: 'string', enum: ['deposit', 'withdraw', 'set'], example: 'deposit' },
amount: { type: 'number', example: 500, description: '0.01 to 1,000,000; 0 is allowed for set.' },
key: { type: 'string', example: '3f2a9c1e-7b4d-4e8a-9f10-2c6d5e8b7a41', description: 'One per submission, so a resend moves nothing.' },
},
},
RustEconomicsAdjusted: {
type: 'object',
properties: {
before: { type: 'number', example: 1000 },
after: { type: 'number', example: 1500 },
repeat: { type: 'boolean', example: false },
},
},
RustVisibilityUpdate: {
type: 'object',
description: 'A change to who may see who is online. Either part may be omitted; a server set to null follows the fleet default again.',

View File

@@ -0,0 +1,309 @@
// ── Economics (PLAN_REDESIGNS §11.2.2, protocol 14, D335, D336) ───────────
//
// Each test is one way the feature could look right and be wrong:
//
// a balance is a VALUE, written over — summed, it would grow every minute
// an offline player's balance moves nobody's last-seen
// `ecowipe` clears the wipe's balances; a new save's wipe keeps the last one's
// the richest ranks only above the starting balance, and only one wipe (D336)
// a server without Economics is refused with the reason, in the dry run too (D199)
// one payment per website user, with the key, so a retry never pays twice
// a withdrawal above the balance is a sentence with the balance in it
const test = require('node:test')
const assert = require('node:assert')
const { fakeCtx } = require('./_fakes')
/** Installs a core whose `db.query` records every statement and answers `rows`. */
function withRecorder(rows = []) {
const statements = []
const ctx = fakeCtx({
db: {
query: (sql, params = []) => {
statements.push({ sql, params })
return Promise.resolve(typeof rows === 'function' ? rows(sql, params) : rows)
},
pool: {},
},
})
require('../core')._reset()
require('../core').init(ctx)
return { statements, touching: (table) => statements.filter((s) => s.sql.includes(table)) }
}
withRecorder()
const client = require('../sidecarClient')
const serversDb = require('../model/servers/servers.db')
const servers = require('../model/servers/servers.model')
const linksDb = require('../model/links/links.db')
const economicsDb = require('../model/economics/economics.db')
const economics = require('../model/economics/economics.model')
const eventsDb = require('../model/events/events.db')
const eventsModel = require('../model/events/events.model')
const titlesDb = require('../model/titles/titles.db')
const rewards = require('../eventRewards')
const ok = (data) => ({ ok: true, status: 'ok', data })
const frame = (over = {}) => ({ type: 'event', t: 1, serverId: 'main', wipeId: 'w1', ...over })
const item = (kind, over = {}) => ({ id: 1, t: 1, kind, frame: frame({ kind, ...over }) })
const A = '76561198000000001'
const B = '76561198000000002'
const C = '76561198000000003'
const D = '76561198000000004'
// ── What a server reports ──────────────────────────────────────────────────
test('a server whose Economics config cannot be read is assumed at 1000, and says so', () => {
assert.deepStrictEqual(economics.shape(null), { loaded: false })
assert.deepStrictEqual(economics.shape({ loaded: false }), { loaded: false })
const unread = economics.shape(JSON.stringify({ loaded: true, version: '3.9.2' }))
assert.strictEqual(unread.startingBalance, 1000)
assert.strictEqual(unread.startingKnown, false)
const read = economics.shape({ loaded: true, version: '3.9.2', startingBalance: 0, wipesWithMap: true })
assert.strictEqual(read.startingBalance, 0)
assert.strictEqual(read.startingKnown, true)
assert.strictEqual(read.wipesWithMap, true)
})
// ── Ingest ─────────────────────────────────────────────────────────────────
test('a tally’s balance is written over, and an offline one moves nobody’s last-seen', async () => {
const rec = withRecorder()
const { apply } = require('../ingest')
// No name: the plugin names only a connected player.
await apply('main', item('player.tally', { steamId: A, balance: 1500.456 }))
const stats = rec.touching('rust_player_wipe_stats')
assert.strictEqual(stats.length, 1)
assert.match(stats[0].sql, /balance = VALUES\(balance\)/)
assert.doesNotMatch(stats[0].sql, /balance \+/)
assert.doesNotMatch(stats[0].sql, /last_seen/)
assert.deepStrictEqual(stats[0].params, ['main', 'w1', A, 1500.46])
assert.strictEqual(rec.touching('rust_players ').length, 0, 'no player row is touched')
// A connected player's tally carries the balance AND its play.
const live = withRecorder()
await apply('main', item('player.tally', { steamId: A, name: 'Wanderer', balance: 900, npcKills: 2 }))
assert.ok(live.touching('rust_player_wipe_stats').some((s) => /balance = VALUES\(balance\)/.test(s.sql)))
assert.ok(live.touching('rust_player_wipe_stats').some((s) => /npc_kills = npc_kills \+/.test(s.sql)))
})
test('ecowipe clears the wipe’s balances; a new save keeps the last wipe’s as its record', async () => {
const { apply } = require('../ingest')
const rec = withRecorder()
await apply('main', item('economics.wiped', { newSave: false }))
const cleared = rec.touching('rust_player_wipe_stats')
assert.strictEqual(cleared.length, 1)
assert.match(cleared[0].sql, /SET balance = NULL WHERE server_id = \? AND wipe_id = \?/)
assert.deepStrictEqual(cleared[0].params, ['main', 'w1'])
const kept = withRecorder()
await apply('main', item('economics.wiped', { newSave: true }))
assert.strictEqual(kept.touching('rust_player_wipe_stats').length, 0)
})
// ── The richest (D336) ─────────────────────────────────────────────────────
test('the richest column ranks one wipe, above the starting balance', async () => {
const rec = withRecorder()
await eventsDb.leaderboard({ serverId: 'main', wipeId: 'w1', sort: 'balance', limit: 25, balanceFloor: 1000 })
const [q] = rec.statements
assert.match(q.sql, /HAVING MAX\(s\.balance\) > \?/)
assert.match(q.sql, /ORDER BY MAX\(s\.balance\) DESC/)
assert.deepStrictEqual(q.params, ['main', 'w1', 1000, 25])
// All time has no balance: the sort falls back, and no row carries one.
const all = withRecorder([{ steamId: A, kills: 3, balance: 5000 }])
const rows = await eventsModel.leaderboard({ serverId: 'main', wipeId: null, sort: 'balance', economics: { startingBalance: 1000 } })
assert.doesNotMatch(all.statements[0].sql, /HAVING/)
assert.match(all.statements[0].sql, /ORDER BY SUM\(s\.kills\) DESC/)
assert.ok(!('balance' in rows[0]))
// One wipe on a server with Economics: every row carries its balance, null when none.
withRecorder([{ steamId: A, balance: '2500.00' }, { steamId: B, balance: null }])
const wipe = await eventsModel.leaderboard({ serverId: 'main', wipeId: 'w1', sort: 'kills', economics: { startingBalance: 1000 } })
assert.strictEqual(wipe[0].balance, 2500)
assert.strictEqual(wipe[1].balance, null)
})
test('Magnate ranks nobody without a floor, and only balances above it with one', async () => {
const none = withRecorder()
assert.deepStrictEqual(await titlesDb.standings({ serverId: 'main', wipeId: 'w1', source: { balance: 'balance' }, limit: 3 }), [])
assert.strictEqual(none.statements.length, 0)
const rec = withRecorder()
await titlesDb.standings({ serverId: 'main', wipeId: 'w1', source: { balance: 'balance' }, limit: 3, floor: 1000 })
assert.match(rec.statements[0].sql, /s\.balance > \?/)
assert.deepStrictEqual(rec.statements[0].params, ['main', 'w1', 1000, 3])
})
// ── The step ───────────────────────────────────────────────────────────────
const ROW = { id: 'main', name: 'Main', sidecarBaseUrl: 'http://main:1', sidecarTokenEnc: null, enabled: 1 }
const pay = rewards.ACTIONS.find((a) => a.id === 'rust.coins.pay')
function stub(t, { loaded = true, people = [], links = {}, payAnswer } = {}) {
const calls = { pay: [] }
const saved = {
getServer: serversDb.getServer,
reportFor: economicsDb.reportFor,
tallySnapshot: client.tallySnapshot,
economicsPay: client.economicsPay,
userIdsForSteamIds: linksDb.userIdsForSteamIds,
}
serversDb.getServer = async (id) => (id === 'main' ? ROW : null)
economicsDb.reportFor = async () => ({ serverId: 'main', wipeId: 'w1', economics: loaded ? JSON.stringify({ loaded: true, startingBalance: 1000 }) : null })
client.tallySnapshot = async (server, runId) => ok({ kind: 'tally.snapshot', runId, people, maxRecipients: 100 })
client.economicsPay = async (server, body) => {
calls.pay.push(body)
return payAnswer ? payAnswer(body) : ok({ kind: 'economics.paid', key: body.key, repeat: false, paid: [], failed: [] })
}
linksDb.userIdsForSteamIds = async (ids) => ids.filter((id) => links[id]).map((id) => ({ steamId: id, userId: links[id] }))
t.after(() => {
serversDb.getServer = saved.getServer
economicsDb.reportFor = saved.reportFor
client.tallySnapshot = saved.tallySnapshot
client.economicsPay = saved.economicsPay
linksDb.userIdsForSteamIds = saved.userIdsForSteamIds
})
return calls
}
const PARAMS = { server: 'main', amount: 500, recipients: 'everyone', message: 'You earned {amount} coins', delivery: 'popup' }
test('pay coins is irreversible and kept: no ledger, no revert (D335)', () => {
assert.strictEqual(pay.risk, 'irreversible')
assert.strictEqual(pay.reversible, 'none')
assert.strictEqual(pay.revert, undefined)
})
test('a server without Economics is refused in the dry run, with the reason (D199)', async (t) => {
stub(t, { loaded: false })
const result = await pay.perform({ runId: 7, stepId: 4, idempotencyKey: 'k-1', params: PARAMS, verify: true })
assert.strictEqual(result.ok, false)
assert.strictEqual(result.retry, false)
assert.match(result.error, /Economics is not installed on this server/)
})
test('coins go once to each website user, with the key, the amount and the message', async (t) => {
const person = (steamId, score) => ({ steamId, name: `p${steamId.slice(-1)}`, seconds: 60, kills: 0, score, joinedAt: 1 })
const calls = stub(t, {
// A and B are one person (user 1); C has linked nothing; D is user 2.
people: [person(A, 9), person(B, 5), person(C, 3), person(D, 1)],
links: { [A]: 1, [B]: 1, [D]: 2 },
payAnswer: (body) =>
ok({
kind: 'economics.paid',
key: body.key,
repeat: false,
paid: [{ steamId: A, before: 1000, after: 1500 }],
failed: [{ steamId: D, reason: 'at-limit' }],
}),
})
const result = await pay.perform({ runId: 7, stepId: 4, idempotencyKey: 'k-1', params: PARAMS, verify: false })
assert.strictEqual(result.ok, true)
assert.deepStrictEqual(calls.pay, [
{ key: 'k-1', amount: 500, steamIds: [A, D], message: 'You earned {amount} coins', delivery: 'popup' },
])
assert.strictEqual(result.detail.paid, 1)
assert.strictEqual(result.detail.total, 500)
assert.deepStrictEqual(result.detail.received, [{ name: 'p1', received: 500 }])
assert.deepStrictEqual(result.detail.failed, [{ name: 'p4', reason: 'at-limit' }])
assert.deepStrictEqual(result.detail.missed, ['p3'])
assert.strictEqual(result.resources, undefined, 'nothing is ledgered')
})
test('a repeated key is answered from the first payment, and a lost answer is retried', async (t) => {
const person = { steamId: A, name: 'pa', seconds: 60, kills: 0, score: 1, joinedAt: 1 }
stub(t, { people: [person], links: { [A]: 1 }, payAnswer: () => ok({ kind: 'economics.paid', repeat: true, paid: [{ steamId: A, before: 1000, after: 1500 }], failed: [] }) })
const repeat = await pay.perform({ runId: 7, stepId: 4, idempotencyKey: 'k-1', params: PARAMS, verify: false })
assert.strictEqual(repeat.ok, true)
assert.strictEqual(repeat.detail.repeat, true)
client.economicsPay = async () => ({ ok: false, status: 'timeout' })
const lost = await pay.perform({ runId: 7, stepId: 4, idempotencyKey: 'k-1', params: PARAMS, verify: false })
assert.strictEqual(lost.ok, false)
assert.notStrictEqual(lost.retry, false, 'a timeout is retried, and the key keeps it to one payment')
client.economicsPay = async () => ok({ kind: 'economics.error', reason: 'economics-missing', message: 'Economics is not installed on this server' })
const missing = await pay.perform({ runId: 7, stepId: 4, idempotencyKey: 'k-1', params: PARAMS, verify: false })
assert.strictEqual(missing.retry, false)
})
test('a payment is priced at the most it could pay, and its message is measured with the amount in it', () => {
assert.deepStrictEqual(pay.cost({ amount: 500, recipients: 'top', count: 3 }), { 'rust.coins': 1500 })
assert.deepStrictEqual(pay.cost({ amount: 2.5, recipients: 'everyone' }), { 'rust.coins': 250 })
assert.strictEqual(rewards.coinsNotice({ message: '' }, 500).text, null)
assert.strictEqual(rewards.coinsNotice({ message: `${'x'.repeat(250)}{amount}` }, 1000000).ok, false)
assert.strictEqual(rewards.coinsNotice({ message: 'Got {amount}', delivery: 'carrier pigeon' }, 5).ok, false)
const key = rewards.paymentKey('k'.repeat(300), 7, 4)
assert.ok(key.startsWith('coins:') && key.length <= 120)
assert.strictEqual(rewards.paymentKey('k-1', 7, 4), 'k-1')
})
test('an amount outside 0.01 to 1,000,000 is refused before anything is asked', async (t) => {
stub(t)
for (const amount of [0, -5, 1000001, 'lots']) {
const result = await pay.perform({ runId: 7, stepId: 4, idempotencyKey: 'k', params: { ...PARAMS, amount }, verify: true })
assert.strictEqual(result.ok, false, String(amount))
assert.strictEqual(result.retry, false)
}
})
// ── Staff ──────────────────────────────────────────────────────────────────
test('an adjustment checks its op and amount; only a set may be 0', () => {
assert.strictEqual(economics.checkAdjustment('steal', 5).ok, false)
assert.strictEqual(economics.checkAdjustment('deposit', 0).ok, false)
assert.strictEqual(economics.checkAdjustment('set', 0).ok, true)
assert.strictEqual(economics.checkAdjustment('withdraw', 1000001).ok, false)
assert.strictEqual(economics.checkAdjustment('deposit', 12.345).value, 12.35)
})
test('a withdrawal above the balance is refused with the balance', async (t) => {
const saved = { getForCalling: servers.getForCalling, reportFor: economicsDb.reportFor, economicsAdjust: client.economicsAdjust }
t.after(() => Object.assign(servers, { getForCalling: saved.getForCalling }) && Object.assign(economicsDb, { reportFor: saved.reportFor }) && Object.assign(client, { economicsAdjust: saved.economicsAdjust }))
const sent = []
servers.getForCalling = async () => ({ id: 'main', name: 'Main', baseUrl: 'http://main:1', token: 't' })
economicsDb.reportFor = async () => ({ serverId: 'main', wipeId: 'w1', economics: { loaded: true } })
client.economicsAdjust = async (server, body) => {
sent.push(body)
return ok({ kind: 'economics.error', reason: 'insufficient', balance: 340 })
}
const result = await economics.adjust({ serverId: 'main', steamId: A, op: 'withdraw', amount: 500, key: 'adj-1' })
assert.strictEqual(result.ok, false)
assert.strictEqual(result.status, 409)
assert.match(result.message, /only 340 coins on Main/)
assert.deepStrictEqual(sent, [{ key: 'adj-1', steamId: A, op: 'withdraw', amount: 500 }])
economicsDb.reportFor = async () => null
const missing = await economics.adjust({ serverId: 'main', steamId: A, op: 'deposit', amount: 5, key: 'adj-2' })
assert.strictEqual(missing.status, 409)
assert.strictEqual(missing.message, economics.MISSING)
})
test('the activity row says what moved', () => {
assert.strictEqual(
economics.adjustmentSummary({ op: 'deposit', amount: 500, name: 'Wanderer', server: 'Main', before: 1000, after: 1500 }),
'Deposited 500 coins to Wanderer on Main: 1,000 → 1,500',
)
assert.strictEqual(
economics.adjustmentSummary({ op: 'set', amount: 0, name: 'Wanderer', server: 'Main', before: 1000, after: 0 }),
'Set the balance of Wanderer on Main to 0: 1,000 → 0',
)
})

View File

@@ -156,6 +156,7 @@ test('the event verbs are registered, and every budget has a verb that spends it
const budgets = api.record.eventBudgets
assert.deepStrictEqual(actions.map((a) => a.id).sort(), [
'rust.announce',
'rust.coins.pay',
'rust.crate.place',
'rust.kit.entitle',
'rust.npc.place',
@@ -165,6 +166,7 @@ test('the event verbs are registered, and every budget has a verb that spends it
])
assert.deepStrictEqual(budgets.map((b) => b.id).sort(), [
'rust.announcements',
'rust.coins',
'rust.grants',
'rust.npcs',
'rust.prefabs',
@@ -189,9 +191,9 @@ test('the event verbs are registered, and every budget has a verb that spends it
}
assert.deepStrictEqual([...spent].sort(), budgets.map((b) => b.id).sort())
// What a teardown can give back is declared honestly: a line said and a
// collect filed are not undone.
const once = new Set(['rust.announce', 'rust.participation.collect'])
// What a teardown can give back is declared honestly: a line said, a
// collect filed and coins paid (D335) are not undone.
const once = new Set(['rust.announce', 'rust.participation.collect', 'rust.coins.pay'])
for (const a of actions) {
assert.strictEqual(a.reversible, once.has(a.id) ? 'none' : 'ledger', a.id)
if (once.has(a.id)) continue

View File

@@ -45,6 +45,8 @@ test('the player tier serves the identity routes, the entitlement read, and noth
routes.map((r) => `${r.method} ${r.path}`).sort(),
[
'DELETE /links/:steamId',
// Protocol 14 (§11.2.2): the caller's own coin balances. Read-only.
'GET /economics',
'GET /links',
// Phase 8: what the site has given the caller in game. Read-only on this
// tier by construction — the authoring routes are all admin.
@@ -88,8 +90,11 @@ test('the admin.users.detail router merges the parent’s params and keeps its o
'DELETE /rust/links/:steamId',
// Phase 7 filled the same panel with what this person may do in game.
'DELETE /rust/permissions/grants/:grantId',
// Protocol 14 (§11.2.2): their coins, and staff's hand on them.
'GET /rust/economics',
'GET /rust/links',
'GET /rust/permissions',
'POST /rust/economics',
'POST /rust/permissions/grants',
])

View File

@@ -47,8 +47,9 @@ const item = (kind, over = {}) => ({ id: 1, t: 1, kind, frame: frame({ kind, ...
// ── The categories ────────────────────────────────────────────────────────
test('twenty-three categories carry a default title, and playtime carries none (D172)', () => {
assert.strictEqual(titles.CATEGORY_IDS.length, 23)
test('twenty-four categories carry a default title, and playtime carries none (D172, §11.2.2)', () => {
assert.strictEqual(titles.CATEGORY_IDS.length, 24)
assert.strictEqual(titles.STATS.richest.title, 'Magnate')
assert.ok(!titles.CATEGORY_IDS.includes('playtime'))
assert.strictEqual(titles.STATS.playtime.title, null)
assert.strictEqual(titles.STATS.animalkills.title, 'Beastbane')

View File

@@ -2205,6 +2205,140 @@
}
}
},
"/api/v1/admin/users/{id}/rust/economics": {
"get": {
"tags": [
"Admin · Users"
],
"summary": "A user’s coin balances (admin only)",
"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.",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "integer"
},
"description": "User id."
}
],
"responses": {
"200": {
"description": "The balances",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RustEconomicsBalances"
}
}
}
},
"500": {
"description": "Internal Server Error"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
]
},
"post": {
"tags": [
"Admin · Users"
],
"summary": "Deposit, withdraw or set a user’s coins on one server (admin only)",
"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.",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "integer"
},
"description": "User id."
}
],
"responses": {
"200": {
"description": "Adjusted",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RustEconomicsAdjusted"
}
}
}
},
"400": {
"description": "A bad op or amount",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"404": {
"description": "No such server, or that Steam account is not linked to this user",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"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"
}
}
}
},
"500": {
"description": "Internal Server Error"
},
"503": {
"description": "The server could not be reached",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RustEconomicsAdjust"
}
}
}
}
}
},
"/api/v1/admin/users/{id}/rust/links": {
"get": {
"tags": [
@@ -2477,6 +2611,38 @@
]
}
},
"/api/v1/player/rust/economics": {
"get": {
"tags": [
"Player · Rust"
],
"summary": "Your coin balances",
"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.",
"responses": {
"200": {
"description": "The balances",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RustEconomicsBalances"
}
}
}
},
"500": {
"description": "Internal Server Error"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
]
}
},
"/api/v1/player/rust/link": {
"post": {
"tags": [
@@ -2919,7 +3085,7 @@
"Public · Rust"
],
"summary": "The leaderboard for one Rust server",
"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.",
"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).",
"parameters": [
{
"name": "id",
@@ -2943,7 +3109,7 @@
"name": "sort",
"in": "query",
"required": false,
"description": "kills, deaths, npcKills or playtime",
"description": "kills, deaths, npcKills, playtime, or balance (one wipe on a server with Economics)",
"schema": {
"type": "string"
}
@@ -4521,6 +4687,7 @@
"rockets",
"explosives",
"missions",
"richest",
"playtime",
"profilekills"
],
@@ -12842,6 +13009,343 @@
}
}
},
"RustEconomicsBalances": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"description": {
"type": "string",
"example": "Coin balances per server and linked Steam account (PLAN_REDESIGNS §11.2.2). `source` is `live`, `last-known` or null."
},
"properties": {
"type": "object",
"properties": {
"servers": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "array"
},
"items": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"properties": {
"type": "object",
"properties": {
"serverId": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "main"
}
}
},
"server": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "Main"
}
}
},
"economics": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "boolean"
},
"example": {
"type": "boolean",
"example": true
}
}
},
"reason": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "Economics is not installed on this server"
}
}
},
"startingBalance": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "number"
},
"example": {
"type": "number",
"example": 1000
}
}
},
"startingKnown": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "boolean"
},
"example": {
"type": "boolean",
"example": true
}
}
},
"live": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "boolean"
},
"example": {
"type": "boolean",
"example": true
}
}
},
"accounts": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "array"
},
"items": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
}
}
},
"example": {
"type": "array",
"items": {
"type": "object",
"properties": {
"steamId": {
"type": "string",
"example": "76561198000000001"
},
"name": {
"type": "string",
"example": "Wanderer"
},
"balance": {
"type": "number",
"example": 1500
},
"source": {
"type": "string",
"example": "live"
}
}
}
}
}
}
}
}
}
}
}
}
}
}
}
},
"RustEconomicsAdjust": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"required": {
"type": "array",
"example": [
"serverId",
"steamId",
"op",
"amount"
],
"items": {
"type": "string"
}
},
"properties": {
"type": "object",
"properties": {
"serverId": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "main"
}
}
},
"steamId": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "76561198000000001"
}
}
},
"op": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"enum": {
"type": "array",
"example": [
"deposit",
"withdraw",
"set"
],
"items": {
"type": "string"
}
},
"example": {
"type": "string",
"example": "deposit"
}
}
},
"amount": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "number"
},
"example": {
"type": "number",
"example": 500
},
"description": {
"type": "string",
"example": "0.01 to 1,000,000; 0 is allowed for set."
}
}
},
"key": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "3f2a9c1e-7b4d-4e8a-9f10-2c6d5e8b7a41"
},
"description": {
"type": "string",
"example": "One per submission, so a resend moves nothing."
}
}
}
}
}
}
},
"RustEconomicsAdjusted": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"properties": {
"type": "object",
"properties": {
"before": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "number"
},
"example": {
"type": "number",
"example": 1000
}
}
},
"after": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "number"
},
"example": {
"type": "number",
"example": 1500
}
}
},
"repeat": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "boolean"
},
"example": {
"type": "boolean",
"example": false
}
}
}
}
}
}
},
"RustVisibilityUpdate": {
"type": "object",
"properties": {