diff --git a/README.md b/README.md index 98f1c02..2f9dc79 100644 --- a/README.md +++ b/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 diff --git a/client/src/api.js b/client/src/api.js index ee68435..75c5387 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -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, } diff --git a/client/src/components/Leaderboard.jsx b/client/src/components/Leaderboard.jsx index f389b66..172964c 100644 --- a/client/src/components/Leaderboard.jsx +++ b/client/src/components/Leaderboard.jsx @@ -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 }) { ) @@ -110,7 +120,7 @@ function Overall({ serverId, wipeId, sort, onSort }) { Player - {COLUMNS.map((column) => ( + {columns.map((column) => ( {column.sort ? ( + + + {done &&

Done: {done}. Recorded in the activity log.

} + {error &&

{error}

} + + ) +} + +/** + * 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 ( +
+
Coins
+ {list.map((server) => ( + + ))} +
+ ) +} + 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. */} + + {/* Coins belong to a Steam account, so only with one linked. */} + {data.links.length > 0 && } ) diff --git a/client/src/routes/player/Account.jsx b/client/src/routes/player/Account.jsx index 1b7bed3..475a668 100644 --- a/client/src/routes/player/Account.jsx +++ b/client/src/routes/player/Account.jsx @@ -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 + if (error) return + const list = (data && data.servers) || [] + if (list.length === 0) return

No servers yet.

+ return ( +
    + {list.map((server) => ( +
  • + {server.server}: + {!server.economics ? ( + {server.reason} + ) : ( + server.accounts.map((a, i) => ( + + {i > 0 && · } + {server.accounts.length > 1 && {a.name || shortId(a.steamId)} } + {coins(a.balance)} + {a.source === 'last-known' && (last known — the server did not answer)} + + )) + )} +
  • + ))} +
+ ) +} + 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() {
NPCs you have killed
{data && links.length > 0 ? :

Link a Steam account to see them.

} + +
Coins
+ {data && links.length > 0 ? :

Link a Steam account to see your balance.

} ) } diff --git a/client/test/format.test.js b/client/test/format.test.js index a51bf56..1abdc86 100644 --- a/client/test/format.test.js +++ b/client/test/format.test.js @@ -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. diff --git a/routes.manifest.json b/routes.manifest.json index 78cccf4..b83aa60 100644 --- a/routes.manifest.json +++ b/routes.manifest.json @@ -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", diff --git a/server/db/schema.sql b/server/db/schema.sql index f142ef3..5d32ff1 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -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; diff --git a/server/eventRewards.js b/server/eventRewards.js index d08ed05..535757d 100644 --- a/server/eventRewards.js +++ b/server/eventRewards.js @@ -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, diff --git a/server/ingest.js b/server/ingest.js index 0dd6708..2cd6254 100644 --- a/server/ingest.js +++ b/server/ingest.js @@ -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 diff --git a/server/model/economics/economics.db.js b/server/model/economics/economics.db.js new file mode 100644 index 0000000..fe93f13 --- /dev/null +++ b/server/model/economics/economics.db.js @@ -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 } diff --git a/server/model/economics/economics.model.js b/server/model/economics/economics.model.js new file mode 100644 index 0000000..13cfcec --- /dev/null +++ b/server/model/economics/economics.model.js @@ -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, +} diff --git a/server/model/events/events.db.js b/server/model/events/events.db.js index 4befed6..96e0762 100644 --- a/server/model/events/events.db.js +++ b/server/model/events/events.db.js @@ -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, diff --git a/server/model/events/events.model.js b/server/model/events/events.model.js index 98f562f..53fd8a5 100644 --- a/server/model/events/events.model.js +++ b/server/model/events/events.model.js @@ -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, diff --git a/server/model/titles/titles.db.js b/server/model/titles/titles.db.js index 62a3950..240308f 100644 --- a/server/model/titles/titles.db.js +++ b/server/model/titles/titles.db.js @@ -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})` diff --git a/server/model/titles/titles.js b/server/model/titles/titles.js index e611c61..3939d45 100644 --- a/server/model/titles/titles.js +++ b/server/model/titles/titles.js @@ -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 } }, } diff --git a/server/model/titles/titles.model.js b/server/model/titles/titles.model.js index b27f479..4be73a7 100644 --- a/server/model/titles/titles.model.js +++ b/server/model/titles/titles.model.js @@ -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 })) }), diff --git a/server/router/admin/usersRust.controller.js b/server/router/admin/usersRust.controller.js index 414bd0d..8cb6da4 100644 --- a/server/router/admin/usersRust.controller.js +++ b/server/router/admin/usersRust.controller.js @@ -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 } diff --git a/server/router/admin/usersRust.router.js b/server/router/admin/usersRust.router.js index 24c830c..59abb8e 100644 --- a/server/router/admin/usersRust.router.js +++ b/server/router/admin/usersRust.router.js @@ -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 diff --git a/server/router/player/rust.controller.js b/server/router/player/rust.controller.js index 64b13b0..650dc19 100644 --- a/server/router/player/rust.controller.js +++ b/server/router/player/rust.controller.js @@ -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 } diff --git a/server/router/player/rust.router.js b/server/router/player/rust.router.js index 099f003..19d7423 100644 --- a/server/router/player/rust.router.js +++ b/server/router/player/rust.router.js @@ -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'] diff --git a/server/router/public/rust.controller.js b/server/router/public/rust.controller.js index 11d1f8e..8913c55 100644 --- a/server/router/public/rust.controller.js +++ b/server/router/public/rust.controller.js @@ -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 }) diff --git a/server/router/public/rust.router.js b/server/router/public/rust.router.js index 1e99277..08ee45d 100644 --- a/server/router/public/rust.router.js +++ b/server/router/public/rust.router.js @@ -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, diff --git a/server/sidecarClient.js b/server/sidecarClient.js index 4b36825..9172aa4 100644 --- a/server/sidecarClient.js +++ b/server/sidecarClient.js @@ -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, diff --git a/server/swagger/doc.js b/server/swagger/doc.js index 877ace8..7dd5c41 100644 --- a/server/swagger/doc.js +++ b/server/swagger/doc.js @@ -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.', diff --git a/server/test/economics.test.js b/server/test/economics.test.js new file mode 100644 index 0000000..29f9d23 --- /dev/null +++ b/server/test/economics.test.js @@ -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', + ) +}) diff --git a/server/test/entry.test.js b/server/test/entry.test.js index 0653a0e..80da7cb 100644 --- a/server/test/entry.test.js +++ b/server/test/entry.test.js @@ -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 diff --git a/server/test/identityRoutes.test.js b/server/test/identityRoutes.test.js index 9523a1f..e6a331f 100644 --- a/server/test/identityRoutes.test.js +++ b/server/test/identityRoutes.test.js @@ -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', ]) diff --git a/server/test/titleConditions.test.js b/server/test/titleConditions.test.js index bc4f628..42e1b03 100644 --- a/server/test/titleConditions.test.js +++ b/server/test/titleConditions.test.js @@ -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') diff --git a/swagger-fragment.json b/swagger-fragment.json index 2422ccd..c7872c4 100644 --- a/swagger-fragment.json +++ b/swagger-fragment.json @@ -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": {