feat(rust): Economics — pay coins, balances, staff adjust, the richest (PLAN_REDESIGNS §11.2) #40
10
README.md
10
README.md
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -259,7 +259,23 @@ export function integrationsLine(r) {
|
||||
: 'This server’s plugin is older than protocol 12, so it cannot say which optional mods it has.'
|
||||
}
|
||||
const one = (name, x, without) => (x && x.loaded ? `${name} ${x.version || ''} is loaded`.trim() : `${name} is not loaded — ${without}`)
|
||||
return `${one('BetterChat', r.integrations.betterChat, 'titles and group styles wait for it')}. ${one('PopupNotifications', r.integrations.popupNotifications, 'a popup is refused, and chat still works')}.`
|
||||
const line = `${one('BetterChat', r.integrations.betterChat, 'titles and group styles wait for it')}. ${one('PopupNotifications', r.integrations.popupNotifications, 'a popup is refused, and chat still works')}.`
|
||||
return r.integrations.economics ? `${line} ${economicsLine(r.integrations.economics)}` : line
|
||||
}
|
||||
|
||||
/**
|
||||
* Economics (PLAN_REDESIGNS §11.2.2), from protocol 14. Its starting balance
|
||||
* decides who the richest column ranks (D336), so a config the plugin could not
|
||||
* read is said: the site then assumes Economics' default of 1000.
|
||||
*/
|
||||
export function economicsLine(e) {
|
||||
if (!e.loaded) return 'Economics is not loaded — coin rewards, balances and the richest column wait for it.'
|
||||
const name = `Economics ${e.version || ''}`.trim()
|
||||
if (e.startingBalance === undefined || e.startingBalance === null) {
|
||||
return `${name} is loaded, but its config could not be read — the site assumes a starting balance of 1,000.`
|
||||
}
|
||||
const wipes = e.wipesWithMap === true ? 'balances wipe with the map' : e.wipesWithMap === false ? 'balances carry over a wipe' : null
|
||||
return `${name} is loaded — starting balance ${Number(e.startingBalance).toLocaleString()}${wipes ? `, ${wipes}` : ''}.`
|
||||
}
|
||||
|
||||
export function VoiceCard() {
|
||||
|
||||
@@ -14,8 +14,8 @@
|
||||
// majority of them. Silence is the honest answer to "what does the Rust module
|
||||
// know about this person" when it is nothing.
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
import { ago, count, duration } from '../../lib/format.js'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { ago, coins, count, duration } from '../../lib/format.js'
|
||||
import { useAsync } from '../../core.js'
|
||||
import api from '../../api.js'
|
||||
|
||||
@@ -229,6 +229,130 @@ function PermissionsPanel({ userId, data, onChanged }) {
|
||||
)
|
||||
}
|
||||
|
||||
/** One submission's key, so a resent form moves nothing (§11.2.2). */
|
||||
function submissionKey() {
|
||||
const c = globalThis.crypto
|
||||
return c && typeof c.randomUUID === 'function' ? c.randomUUID() : `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`
|
||||
}
|
||||
|
||||
/** One server's coins for this person, and the form that changes them. */
|
||||
function EconomicsServer({ userId, server, onChanged }) {
|
||||
const [steamId, setSteamId] = useState(server.accounts.length ? server.accounts[0].steamId : '')
|
||||
const [op, setOp] = useState('deposit')
|
||||
const [amount, setAmount] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [done, setDone] = useState('')
|
||||
|
||||
// D199: said, not hidden — staff asking why the form is missing are owed it.
|
||||
if (!server.economics) {
|
||||
return (
|
||||
<div className="sans" style={{ fontSize: '0.84rem', padding: '4px 0' }}>
|
||||
<span style={{ color: 'var(--head)' }}>{server.server}</span>{' '}
|
||||
<span className="dim" style={{ fontSize: '0.76rem' }}>{server.reason}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
async function submit(event) {
|
||||
event.preventDefault()
|
||||
if (busy || !steamId || amount === '') return
|
||||
setBusy(true)
|
||||
setError('')
|
||||
setDone('')
|
||||
try {
|
||||
const result = await api.adminUserEconomics.adjust(userId, { serverId: server.serverId, steamId, op, amount: Number(amount), key: submissionKey() })
|
||||
setDone(`${coins(result.before)} → ${coins(result.after)}`)
|
||||
setAmount('')
|
||||
await onChanged()
|
||||
} catch (err) {
|
||||
// "They have only 340 coins on Main" is the server's sentence, and the useful one.
|
||||
setError(err.message || 'That did not work.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '6px 0', borderTop: '1px solid var(--line-soft)' }}>
|
||||
<div className="sans" style={{ fontSize: '0.84rem' }}>
|
||||
<span style={{ color: 'var(--head)' }}>{server.server}</span>{' '}
|
||||
{server.accounts.map((a, i) => (
|
||||
<span key={a.steamId}>
|
||||
{i > 0 && <span className="dim"> · </span>}
|
||||
{server.accounts.length > 1 && <span className="dim">{a.name || a.steamId} </span>}
|
||||
<strong>{coins(a.balance)}</strong>
|
||||
{a.source === 'last-known' && <span className="dim"> (last known)</span>}
|
||||
</span>
|
||||
))}
|
||||
{!server.startingKnown && (
|
||||
<span className="dim" style={{ fontSize: '0.74rem' }}>
|
||||
{' '}· its Economics config could not be read, so a starting balance of {coins(server.startingBalance)} is assumed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form onSubmit={submit} style={{ display: 'flex', gap: 8, marginTop: 6, flexWrap: 'wrap' }}>
|
||||
{server.accounts.length > 1 && (
|
||||
<select className="input" value={steamId} onChange={(e) => setSteamId(e.target.value)} aria-label="Steam account">
|
||||
{server.accounts.map((a) => <option key={a.steamId} value={a.steamId}>{a.name || a.steamId}</option>)}
|
||||
</select>
|
||||
)}
|
||||
<select className="input" value={op} onChange={(e) => setOp(e.target.value)} aria-label="What to do">
|
||||
<option value="deposit">Deposit</option>
|
||||
<option value="withdraw">Withdraw</option>
|
||||
<option value="set">Set to</option>
|
||||
</select>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
min={op === 'set' ? 0 : 0.01}
|
||||
max={1000000}
|
||||
step="0.01"
|
||||
placeholder="500"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
aria-label="Coins"
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
<button type="submit" className="btn" disabled={busy || amount === ''}>
|
||||
{busy ? 'Working…' : 'Apply'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{done && <p className="sans dim" style={{ fontSize: '0.78rem', margin: '6px 0 0' }}>Done: {done}. Recorded in the activity log.</p>}
|
||||
{error && <p className="sans" style={{ color: '#e05a5a', fontSize: '0.8rem', margin: '6px 0 0' }}>{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Economics (PLAN_REDESIGNS §11.2.2): this person's coins on every server, and
|
||||
* staff's hand on them — the only way coins come back after an event paid them
|
||||
* (D335). Its own reload, and it keeps showing the last answer while it
|
||||
* re-reads, so a deposit's "1,000 → 1,500" is not blanked by the read it caused.
|
||||
*/
|
||||
function EconomicsPanel({ userId }) {
|
||||
const [reloads, setReloads] = useState(0)
|
||||
const { data } = useAsync(() => api.adminUserEconomics.list(userId), [userId, reloads])
|
||||
const last = useRef(null)
|
||||
if (data) last.current = data
|
||||
const shown = data || last.current
|
||||
const onChanged = useCallback(() => setReloads((n) => n + 1), [])
|
||||
|
||||
const list = (shown && shown.servers) || []
|
||||
if (!list.length) return null
|
||||
|
||||
return (
|
||||
<div className="panel" style={{ padding: '14px 16px' }}>
|
||||
<div className="field-label" style={{ marginBottom: 8 }}>Coins</div>
|
||||
{list.map((server) => (
|
||||
<EconomicsServer key={server.serverId} userId={userId} server={server} onChanged={onChanged} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function UserRustSections({ userId }) {
|
||||
// Core's `useAsync` has no refresh, so a counter in the deps is how this
|
||||
// re-reads after its own write (the same shape the player page uses).
|
||||
@@ -278,6 +402,9 @@ export default function UserRustSections({ userId }) {
|
||||
above belongs to the links, so it sits with them rather than under
|
||||
the panel it would otherwise appear to describe. */}
|
||||
<PermissionsPanel userId={userId} data={permissions} onChanged={reload} />
|
||||
|
||||
{/* Coins belong to a Steam account, so only with one linked. */}
|
||||
{data.links.length > 0 && <EconomicsPanel userId={userId} />}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
36
server/model/economics/economics.db.js
Normal file
36
server/model/economics/economics.db.js
Normal 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 }
|
||||
218
server/model/economics/economics.model.js
Normal file
218
server/model/economics/economics.model.js
Normal 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,
|
||||
}
|
||||
@@ -193,6 +193,45 @@ async function addTitleStats({ serverId, wipeId, steamId }, frame = {}) {
|
||||
)
|
||||
}
|
||||
|
||||
/** The largest balance a row holds: DECIMAL(14,2). Economics' own limit is far below it. */
|
||||
const BALANCE_MAX = 999999999999.99
|
||||
|
||||
/**
|
||||
* One player's Economics balance for the wipe (protocol 14, §11.2.2): a VALUE,
|
||||
* written over and never added. `last_seen` is left alone on purpose — a payment
|
||||
* or a staff adjustment reaches an offline player, and a balance is not play.
|
||||
*/
|
||||
async function setBalance({ serverId, wipeId, steamId }, balance) {
|
||||
const value = Number(balance)
|
||||
if (!serverId || !steamId || !Number.isFinite(value)) return
|
||||
|
||||
await core.query(
|
||||
`INSERT INTO ${STATS} (server_id, wipe_id, steam_id, balance)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE balance = VALUES(balance)`,
|
||||
[serverId, wipeId || '', steamId, Math.max(-BALANCE_MAX, Math.min(Math.round(value * 100) / 100, BALANCE_MAX))],
|
||||
)
|
||||
}
|
||||
|
||||
/** `ecowipe`: every balance of the wipe is back at the start, so none is known. */
|
||||
async function clearBalances(serverId, wipeId) {
|
||||
if (!serverId) return
|
||||
await core.query(`UPDATE ${STATS} SET balance = NULL WHERE server_id = ? AND wipe_id = ?`, [serverId, wipeId || ''])
|
||||
}
|
||||
|
||||
/** The last known balances of some players in one wipe, as Steam id → number. */
|
||||
async function balancesFor(serverId, wipeId, steamIds) {
|
||||
if (!serverId || !steamIds.length) return new Map()
|
||||
const rows = await core.query(
|
||||
`SELECT steam_id AS steamId, balance
|
||||
FROM ${STATS}
|
||||
WHERE server_id = ? AND wipe_id = ? AND balance IS NOT NULL
|
||||
AND steam_id IN (${steamIds.map(() => '?').join(', ')})`,
|
||||
[serverId, wipeId || '', ...steamIds],
|
||||
)
|
||||
return new Map(rows.map((r) => [String(r.steamId), Number(r.balance)]))
|
||||
}
|
||||
|
||||
/** One weapon's credited kills (§5.3), added as a delta like a gathered resource. */
|
||||
async function addWeaponKills({ serverId, wipeId, steamId }, weapon, kills) {
|
||||
if (!serverId || !steamId || !weapon || !(kills > 0)) return
|
||||
@@ -291,8 +330,15 @@ async function recentEvents({ serverId, kinds, wipeId = null, limit = 50 }) {
|
||||
* All-time is a SUM over the per-wipe rows rather than a separate set of
|
||||
* counters, which is what makes it impossible for the two to disagree — there
|
||||
* is only ever one number, added up differently.
|
||||
*
|
||||
* The balance is the exception (§11.2.2): a value per wipe, so it is read only
|
||||
* for one wipe, as that wipe's MAX — there is one row per player, so the max is
|
||||
* the value. `balanceFloor` is the server's starting balance, and only set when
|
||||
* the caller ranks by balance: then a row at or under it is not on the board
|
||||
* (D336), because "1,000" cannot tell a new account from one spent back to it.
|
||||
*/
|
||||
async function leaderboard({ serverId, wipeId = null, sort = 'kills', limit = 25 }) {
|
||||
async function leaderboard({ serverId, wipeId = null, sort = 'kills', limit = 25, balanceFloor = null }) {
|
||||
const byBalance = sort === 'balance' && wipeId && Number.isFinite(balanceFloor)
|
||||
const column = { kills: 'kills', deaths: 'deaths', npcKills: 'npc_kills', playtime: 'playtime_sec' }[sort] || 'kills'
|
||||
|
||||
const params = [serverId]
|
||||
@@ -303,6 +349,10 @@ async function leaderboard({ serverId, wipeId = null, sort = 'kills', limit = 25
|
||||
params.push(wipeId)
|
||||
}
|
||||
|
||||
const having = byBalance ? 'HAVING MAX(s.balance) > ?' : ''
|
||||
if (byBalance) params.push(balanceFloor)
|
||||
const order = byBalance ? 'MAX(s.balance)' : `SUM(s.${column})`
|
||||
|
||||
params.push(limit)
|
||||
|
||||
return core.query(
|
||||
@@ -313,12 +363,14 @@ async function leaderboard({ serverId, wipeId = null, sort = 'kills', limit = 25
|
||||
SUM(s.npc_kills) AS npcKills,
|
||||
SUM(s.structures) AS structures,
|
||||
SUM(s.playtime_sec) AS playtimeSec,
|
||||
MAX(s.balance) AS balance,
|
||||
MAX(s.last_seen) AS lastSeen
|
||||
FROM ${STATS} s
|
||||
LEFT JOIN ${PLAYERS} p ON p.steam_id = s.steam_id
|
||||
WHERE ${where}
|
||||
GROUP BY s.steam_id, p.name
|
||||
ORDER BY SUM(s.${column}) DESC, MAX(s.last_seen) DESC
|
||||
${having}
|
||||
ORDER BY ${order} DESC, MAX(s.last_seen) DESC
|
||||
LIMIT ?`,
|
||||
params,
|
||||
)
|
||||
@@ -387,6 +439,9 @@ module.exports = {
|
||||
addGathered,
|
||||
addTitleStats,
|
||||
addWeaponKills,
|
||||
setBalance,
|
||||
clearBalances,
|
||||
balancesFor,
|
||||
replacePresence,
|
||||
pruneEvents,
|
||||
recentEvents,
|
||||
|
||||
@@ -102,15 +102,20 @@ function shape(row) {
|
||||
* counters, so the two can never disagree — which is the whole reason R12's
|
||||
* "per-wipe detail plus all-time rollups" is one table and not two.
|
||||
*/
|
||||
async function leaderboard({ serverId, wipeId = null, sort = 'kills', limit, presence = false }) {
|
||||
async function leaderboard({ serverId, wipeId = null, sort = 'kills', limit, presence = false, economics = null }) {
|
||||
// The richest column (§11.2.2): only for one wipe — a balance is a value, and
|
||||
// there is no all-time one — and only where the server reports Economics.
|
||||
const withBalance = Boolean(economics && wipeId)
|
||||
const rows = await db.leaderboard({
|
||||
serverId,
|
||||
wipeId,
|
||||
sort,
|
||||
sort: sort === 'balance' && !withBalance ? 'kills' : sort,
|
||||
limit: boundedLimit(limit, 25),
|
||||
balanceFloor: withBalance && sort === 'balance' ? economics.startingBalance : null,
|
||||
})
|
||||
|
||||
return rows.map((r) => ({
|
||||
...(withBalance ? { balance: r.balance === null || r.balance === undefined ? null : Number(r.balance) } : {}),
|
||||
steamId: r.steamId,
|
||||
name: r.name || null,
|
||||
kills: Number(r.kills) || 0,
|
||||
|
||||
@@ -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})`
|
||||
|
||||
@@ -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 } },
|
||||
}
|
||||
|
||||
@@ -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 }))
|
||||
}),
|
||||
|
||||
@@ -5,8 +5,11 @@
|
||||
// every statement here is scoped by it, so a panel opened on one user cannot
|
||||
// read or write another's rows by editing a path segment.
|
||||
|
||||
const crypto = require('node:crypto')
|
||||
|
||||
const core = require('../../core')
|
||||
|
||||
const economics = require('../../model/economics/economics.model')
|
||||
const links = require('../../model/links/links.model')
|
||||
const permissionsDb = require('../../model/permissions/permissions.db')
|
||||
const permissions = require('../../model/permissions/permissions.model')
|
||||
@@ -208,4 +211,68 @@ async function removeGrant(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { listLinks, removeLink, listPermissions, addGrant, removeGrant }
|
||||
/** GET /admin/users/:id/rust/economics — this user's balances (§11.2.2), as the player sees them. */
|
||||
async function listEconomics(req, res) {
|
||||
try {
|
||||
const held = await links.listForUser(Number(req.params.id))
|
||||
res.json({ servers: await economics.balancesOf(held.map((l) => ({ steamId: l.steamId, name: l.name }))) })
|
||||
} catch (err) {
|
||||
log.error('failed to read a user’s balances', { error: err.message })
|
||||
res.status(500).json({ message: 'Failed to read this user’s balances' })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /admin/users/:id/rust/economics — staff deposit, withdraw or set coins.
|
||||
*
|
||||
* **Scoped by the user**, like every write in this panel: the Steam id must be
|
||||
* one this user has linked, so a wrong id on the form moves nobody's coins
|
||||
* rather than a stranger's. The key is the form's, one per submission; a form
|
||||
* that sent none gets one, and is then only as safe as a single click.
|
||||
*/
|
||||
async function adjustEconomics(req, res) {
|
||||
const userId = Number(req.params.id)
|
||||
const { serverId, steamId, op, amount } = req.body
|
||||
const key = req.body.key ? `adj-${req.body.key}` : `adj-${crypto.randomUUID()}`
|
||||
|
||||
try {
|
||||
const held = await links.listForUser(userId)
|
||||
const link = held.find((l) => String(l.steamId) === String(steamId))
|
||||
if (!link) return res.status(404).json({ message: 'That Steam account is not linked to this user' })
|
||||
|
||||
const result = await economics.adjust({ serverId, steamId: String(steamId), op, amount, key })
|
||||
if (!result.ok) return res.status(result.status).json({ message: result.message, ...(result.reason ? { reason: result.reason } : {}) })
|
||||
|
||||
// A repeat moved nothing, so it is not a second row in the log.
|
||||
if (!result.repeat) {
|
||||
await core.activity.log({
|
||||
req,
|
||||
action: 'rust.economics.adjust',
|
||||
detail: {
|
||||
userId,
|
||||
serverId,
|
||||
steamId: String(steamId),
|
||||
op,
|
||||
amount: result.amount,
|
||||
before: result.before,
|
||||
after: result.after,
|
||||
summary: economics.adjustmentSummary({
|
||||
op,
|
||||
amount: result.amount,
|
||||
name: link.name || String(steamId),
|
||||
server: result.server,
|
||||
before: result.before,
|
||||
after: result.after,
|
||||
}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return res.json({ before: result.before, after: result.after, repeat: result.repeat })
|
||||
} catch (err) {
|
||||
log.error('failed to adjust a balance', { userId, serverId, op, error: err.message })
|
||||
return res.status(500).json({ message: 'Failed to adjust that balance' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { listLinks, removeLink, listPermissions, addGrant, removeGrant, listEconomics, adjustEconomics }
|
||||
|
||||
@@ -128,4 +128,47 @@ usersRustRouter.delete(
|
||||
usersRust.removeGrant,
|
||||
)
|
||||
|
||||
// ── Economics (PLAN_REDESIGNS §11.2.2): a person's coins, and staff's hand ──
|
||||
//
|
||||
// Beside their links, because "how many coins does this player have" is asked
|
||||
// about a person. The write is the only way coins come back after an event
|
||||
// paid them (D335): staff withdraw them by hand, and each adjustment is an
|
||||
// activity row with the balance before and after.
|
||||
|
||||
usersRustRouter.get(
|
||||
'/rust/economics',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
// #swagger.summary = 'A user’s coin balances (admin only)'
|
||||
// #swagger.description = 'This user’s Economics balance on every server, per linked Steam account — live where the server answers, else the last balance it reported. A server without Economics is listed with the reason. The same shape as GET /player/rust/economics.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||
/* #swagger.responses[200] = { description: 'The balances', content: { "application/json": { schema: { $ref: "#/components/schemas/RustEconomicsBalances" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
usersRust.listEconomics,
|
||||
)
|
||||
|
||||
usersRustRouter.post(
|
||||
'/rust/economics',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
// #swagger.summary = 'Deposit, withdraw or set a user’s coins on one server (admin only)'
|
||||
// #swagger.description = 'Through the server’s Economics, for one Steam account this user has linked. A withdrawal above the balance is refused with the balance, because Economics takes all or nothing. `key` makes a resent form harmless: the game answers a key it has seen from the first reply and moves nothing. Recorded in the activity log with the balance before and after.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/RustEconomicsAdjust" } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Adjusted', content: { "application/json": { schema: { $ref: "#/components/schemas/RustEconomicsAdjusted" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'A bad op or amount', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such server, or that Steam account is not linked to this user', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[409] = { description: 'Economics is not installed there, or the balance is too low to withdraw that much', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[503] = { description: 'The server could not be reached', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
body('serverId').isString().isLength({ min: 1, max: 64 }),
|
||||
body('steamId').isString().matches(STEAM_ID_RE),
|
||||
body('op').isIn(['deposit', 'withdraw', 'set']),
|
||||
body('amount').isFloat({ min: 0, max: 1000000 }).toFloat(),
|
||||
body('key').optional().isString().matches(/^[A-Za-z0-9-]{8,64}$/),
|
||||
validate,
|
||||
usersRust.adjustEconomics,
|
||||
)
|
||||
|
||||
module.exports = usersRustRouter
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
|
||||
const core = require('../../core')
|
||||
|
||||
const economics = require('../../model/economics/economics.model')
|
||||
const links = require('../../model/links/links.model')
|
||||
const npcs = require('../../model/npcs/npcs.model')
|
||||
const npcsDb = require('../../model/npcs/npcs.db')
|
||||
@@ -55,6 +56,20 @@ async function ownNpcKills(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /player/rust/economics — the caller's coin balances (§11.2.2): every
|
||||
* server, each linked account, live where the server answers.
|
||||
*/
|
||||
async function ownEconomics(req, res) {
|
||||
try {
|
||||
const held = await links.listForUser(req.user.id)
|
||||
res.json({ servers: await economics.balancesOf(held.map((l) => ({ steamId: l.steamId, name: l.name }))) })
|
||||
} catch (err) {
|
||||
log.error('failed to read a player’s balances', { error: err.message })
|
||||
res.status(500).json({ message: 'Failed to read your balances' })
|
||||
}
|
||||
}
|
||||
|
||||
/** GET /player/rust/links — the Steam accounts the caller holds. */
|
||||
async function listLinks(req, res) {
|
||||
try {
|
||||
@@ -187,4 +202,4 @@ async function listPermissions(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { listServers, listLinks, confirmLink, removeLink, listPermissions, ownNpcKills }
|
||||
module.exports = { listServers, listLinks, confirmLink, removeLink, listPermissions, ownNpcKills, ownEconomics }
|
||||
|
||||
@@ -67,6 +67,16 @@ playerRustRouter.get(
|
||||
rust.ownNpcKills,
|
||||
)
|
||||
|
||||
playerRustRouter.get(
|
||||
'/economics',
|
||||
// #swagger.tags = ['Player · Rust']
|
||||
// #swagger.summary = 'Your coin balances'
|
||||
// #swagger.description = 'The caller’s Economics balance on every server, per linked Steam account (PLAN_REDESIGNS §11.2.2). Read live from each server; where a server does not answer, the last balance it reported (`source: last-known`), and null where neither knows. A server without Economics is listed with `economics: false` and the reason. `startingKnown: false` means the server’s Economics config could not be read and the starting balance shown is Economics’ default. Empty `accounts` with no linked account. Requires a session.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The balances', content: { "application/json": { schema: { $ref: "#/components/schemas/RustEconomicsBalances" } } } } */
|
||||
rust.ownEconomics,
|
||||
)
|
||||
|
||||
playerRustRouter.get(
|
||||
'/servers',
|
||||
// #swagger.tags = ['Player · Rust']
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
const core = require('../../core')
|
||||
|
||||
const clans = require('../../model/clans/clans.model')
|
||||
const economics = require('../../model/economics/economics.model')
|
||||
const events = require('../../model/events/events.model')
|
||||
const map = require('../../model/map/map.model')
|
||||
const mapDb = require('../../model/map/map.db')
|
||||
@@ -108,13 +109,22 @@ async function listEvents(req, res) {
|
||||
async function listLeaderboard(req, res) {
|
||||
try {
|
||||
const presence = await visibility.canSeePresence(req, req.params.id)
|
||||
const wipeId = req.query.wipe || null
|
||||
|
||||
// The richest column (§11.2.2): one wipe at a time, on a server that
|
||||
// reports Economics. `economics` is null otherwise, and the rows carry no
|
||||
// `balance`, so a page shows the column exactly when there is one.
|
||||
const econ = await economics.forServer(req.params.id)
|
||||
const withBalance = econ.loaded && wipeId ? { startingBalance: econ.startingBalance } : null
|
||||
|
||||
const [rows, held] = await Promise.all([
|
||||
events.leaderboard({
|
||||
serverId: req.params.id,
|
||||
wipeId: req.query.wipe || null,
|
||||
wipeId,
|
||||
sort: req.query.sort,
|
||||
limit: req.query.limit,
|
||||
presence: presence.visible,
|
||||
economics: withBalance,
|
||||
}),
|
||||
titles.currentFor(req.params.id),
|
||||
])
|
||||
@@ -125,6 +135,7 @@ async function listLeaderboard(req, res) {
|
||||
// this page is showing, because a title is what a player is, not what a
|
||||
// past wipe was.
|
||||
leaderboard: rows.map((row) => ({ ...row, titles: held.get(row.steamId) || [] })),
|
||||
economics: withBalance,
|
||||
})
|
||||
} catch (err) {
|
||||
log.error('failed to read the leaderboard', { server: req.params.id, error: err.message })
|
||||
|
||||
@@ -82,10 +82,10 @@ rustRouter.get(
|
||||
'/servers/:id/leaderboard',
|
||||
// #swagger.tags = ['Public · Rust']
|
||||
// #swagger.summary = 'The leaderboard for one Rust server'
|
||||
// #swagger.description = 'Per-wipe when `wipe` is given, all-time otherwise. All-time is the per-wipe rows summed rather than a second set of counters, so a wipe splits a player’s history without ending it. `lastSeen` is withheld below the operator’s presence audience: a gather tally refreshes it every minute a player is on, so it would name who is online. Each row carries `titles`: the chat titles that player holds now, as `[{ text, color }]` in the order the game shows them, and an empty list for a player with none. Titles rank the CURRENT wipe whichever wipe the page asks for, and are set by the operator’s rules.'
|
||||
// #swagger.description = 'Per-wipe when `wipe` is given, all-time otherwise. All-time is the per-wipe rows summed rather than a second set of counters, so a wipe splits a player’s history without ending it. `lastSeen` is withheld below the operator’s presence audience: a gather tally refreshes it every minute a player is on, so it would name who is online. Each row carries `titles`: the chat titles that player holds now, as `[{ text, color }]` in the order the game shows them, and an empty list for a player with none. Titles rank the CURRENT wipe whichever wipe the page asks for, and are set by the operator’s rules. On a server that reports Economics, and only for one wipe, each row also carries `balance` (its last known coin balance, null when none was reported) and the answer carries `economics: { startingBalance }` (PLAN_REDESIGNS §11.2.2); `economics` is null otherwise. `sort=balance` ranks the richest, and only balances above the starting balance are on it (D336).'
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } }
|
||||
// #swagger.parameters['wipe'] = { in: 'query', required: false, description: 'Restrict to one wipe id', schema: { type: 'string' } }
|
||||
// #swagger.parameters['sort'] = { in: 'query', required: false, description: 'kills, deaths, npcKills or playtime', schema: { type: 'string' } }
|
||||
// #swagger.parameters['sort'] = { in: 'query', required: false, description: 'kills, deaths, npcKills, playtime, or balance (one wipe on a server with Economics)', schema: { type: 'string' } }
|
||||
// #swagger.parameters['limit'] = { in: 'query', required: false, description: 'Rows to return, capped at 200', schema: { type: 'integer' } }
|
||||
/* #swagger.responses[200] = { description: 'The leaderboard' } */
|
||||
siteMode,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.',
|
||||
|
||||
309
server/test/economics.test.js
Normal file
309
server/test/economics.test.js
Normal 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',
|
||||
)
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
])
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user