feat(rust): Economics — pay coins, balances, staff adjust, the richest (PLAN_REDESIGNS §11.2)
The site half of §11.2.2, on protocol 14:
- `rust.coins.pay` ("Pay coins"): irreversible, reversible none (D335). The
recipient modes of rust.kit.entitle including `linked`, one payment per
website user (D103's rule), an optional message with {amount}, a new
`rust.coins` budget. Refused with "Economics is not installed on this
server" in the dry run too (D199). Core's key goes to the plugin, so a
retried step answers `repeat` and pays nobody twice.
- `rust_player_wipe_stats.balance`: the tally's balance, written as a value.
A nameless balance frame (an offline player) moves nobody's last-seen.
`economics.wiped` clears the wipe's balances, except at a new save.
- The richest: a Coins leaderboard column on one wipe of a server that
reports Economics, `sort=balance`, and a `richest` title category
(Magnate); both rank only above the starting balance (D336).
- GET /player/rust/economics (live, falling back to the last known), and
GET/POST /admin/users/:id/rust/economics for staff — scoped to the user's
own links, keyed per submission, an activity row with before and after.
- Client: the account page's Coins, the admin user page's Coins panel with
its form, the leaderboard column, and Economics in the server's mod line
(saying when its config could not be read).
Server 535/535 (14 new in test/economics.test.js), client 74/74,
routes.manifest regenerated against the pinned core (87 routes), swagger
fragment regenerated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
@@ -1363,3 +1363,11 @@ CREATE TABLE IF NOT EXISTS rust_npc_factions (
|
||||
-- stage 4, D250). Null for every other stat. No foreign key: a deleted profile
|
||||
-- leaves a rule that ranks nobody, which the form shows and an admin removes.
|
||||
ALTER TABLE rust_title_rules ADD COLUMN IF NOT EXISTS profile_id INT UNSIGNED NULL;
|
||||
|
||||
-- Economics (PLAN_REDESIGNS §11.2.2, D336): a player's last known coin balance
|
||||
-- on the server, for the wipe it was reported in. A VALUE, written over, never
|
||||
-- summed — the tally carries the balance itself, not a change. NULL until the
|
||||
-- server has reported one, and again after `ecowipe` clears the wipe's. The
|
||||
-- richest column and the Magnate title rank only balances above the server's
|
||||
-- starting balance, so the untouched accounts of a fresh wipe rank nobody.
|
||||
ALTER TABLE rust_player_wipe_stats ADD COLUMN IF NOT EXISTS balance DECIMAL(14,2) NULL;
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
// rust.kit.entitle the right to redeem a kit, and one more use of
|
||||
// it (R16, D103), for the people a mode picks
|
||||
// rust.announce one line in a server's chat, or every server's
|
||||
// rust.coins.pay Economics coins for the people a mode picks,
|
||||
// kept once paid (protocol 14, D335)
|
||||
//
|
||||
// …and the announce leg, `rust.chat`, which says a published news post in the
|
||||
// chat of every server whose switch is on (D104).
|
||||
@@ -40,6 +42,7 @@ const servers = require('./model/servers/servers.model')
|
||||
const permDb = require('./model/permissions/permissions.db')
|
||||
const linksDb = require('./model/links/links.db')
|
||||
const emit = require('./engagement/emit')
|
||||
const economics = require('./model/economics/economics.model')
|
||||
const voice = require('./model/permissions/voice')
|
||||
const { serverFor, transportError, pluginError, perServer, bounded } = require('./eventLeases')
|
||||
const { BUDGET_MS } = require('./eventWorld')
|
||||
@@ -81,6 +84,11 @@ const PERMANENT = new Set([
|
||||
// Protocol 12: a popup on a server without PopupNotifications. Waiting does not
|
||||
// install it.
|
||||
'popup-unavailable',
|
||||
// Protocol 14, Economics (§11.2.2). Nor does it install Economics, and a bad
|
||||
// amount or too long a list is the step's, not the moment's.
|
||||
'economics-missing',
|
||||
'bad-amount',
|
||||
'too-large',
|
||||
])
|
||||
|
||||
const BUDGETS = [
|
||||
@@ -97,6 +105,13 @@ const BUDGETS = [
|
||||
unit: 'lines',
|
||||
description: 'Lines an event says in a server\'s chat: one per server reached.',
|
||||
},
|
||||
{
|
||||
id: 'rust.coins',
|
||||
label: 'Coins paid',
|
||||
unit: 'coins',
|
||||
description:
|
||||
'Economics coins an event pays: the amount times the people paid. A mode that is not a count is priced at the most it could pay. Coins paid are kept — nothing takes them back.',
|
||||
},
|
||||
]
|
||||
|
||||
/** A transport failure, classified. Only a missing configuration is one waiting cannot fix. */
|
||||
@@ -685,6 +700,210 @@ const kitEntitle = {
|
||||
},
|
||||
}
|
||||
|
||||
// ── Coins (protocol 14, PLAN_REDESIGNS.md §11.2.2) ──────────────────────────
|
||||
|
||||
/** The plugin's bounds on one payment's amount (PROTOCOL.md §20.3). */
|
||||
const COINS_MIN = economics.AMOUNT_MIN
|
||||
const COINS_MAX = economics.AMOUNT_MAX
|
||||
|
||||
/** The plugin keeps a payment's key in a file; core's key is used as it is unless it is long. */
|
||||
const PAYMENT_KEY_MAX = 120
|
||||
|
||||
/**
|
||||
* A payment's message, checked like a kit reward's (D333): `{ ok, text,
|
||||
* delivery }`, text null for a step that says nothing. Measured with the amount
|
||||
* in place of `{amount}`, against the plugin's line bound. The plugin fills in
|
||||
* what each player RECEIVED, which is never more than the amount asked.
|
||||
*/
|
||||
function coinsNotice(params, amount) {
|
||||
const text = params.message === undefined || params.message === null ? '' : String(params.message).trim()
|
||||
if (!text) return { ok: true, text: null, delivery: null }
|
||||
|
||||
const rawDelivery = params.delivery === undefined || params.delivery === null || params.delivery === '' ? 'chat' : params.delivery
|
||||
const delivery = oneOf(rawDelivery, DELIVERIES)
|
||||
if (!delivery) return { ok: false, error: `a payment's message goes to chat or to a popup, not to "${rawDelivery}"` }
|
||||
|
||||
const said = text.replace(/\{amount\}/g, economics.coins(amount))
|
||||
if (said.length > MAX_CHAT) {
|
||||
return { ok: false, error: `a payment's message is at most ${MAX_CHAT} characters with the amount in it, not ${said.length}` }
|
||||
}
|
||||
return { ok: true, text, delivery }
|
||||
}
|
||||
|
||||
/** The key the plugin remembers a payment by: core's, or a digest of it past the plugin's bound. */
|
||||
function paymentKey(idempotencyKey, runId, stepId) {
|
||||
const key = String(idempotencyKey || `coins:${runId}:${stepId}`)
|
||||
return key.length <= PAYMENT_KEY_MAX ? key : `coins:${crypto.createHash('sha256').update(key).digest('hex').slice(0, 40)}`
|
||||
}
|
||||
|
||||
const coinsPay = {
|
||||
id: 'rust.coins.pay',
|
||||
label: 'Pay coins',
|
||||
description:
|
||||
'Pays Economics coins to the people a mode picks from this run\'s tally — or to every linked player of the server — and can tell each of them in game. One payment per website account. Coins paid are kept: a run cancelled or aborted afterwards does not take them back.',
|
||||
// Irreversible, like Module-uo's `uo.item.grant` (D335): coins in a player's
|
||||
// balance may be spent the moment they land, and the platform reverts a
|
||||
// ledgered step at the end of EVERY run, a normal finish included — so a
|
||||
// ledger here would take a prize back. Staff take coins back by hand.
|
||||
risk: 'irreversible',
|
||||
reversible: 'none',
|
||||
version: 1,
|
||||
budgetMs: BUDGET_MS,
|
||||
params: [
|
||||
{ name: 'server', type: 'string', required: true, example: 'main', source: 'rust.options.servers',
|
||||
description: 'The server whose Economics pays. It needs Economics installed.' },
|
||||
{ name: 'amount', type: 'float', required: true, example: 500,
|
||||
description: `How many coins each person receives, ${economics.coins(COINS_MIN)} to ${economics.coins(COINS_MAX)}.` },
|
||||
{ name: 'recipients', type: 'string', required: true, example: 'top', source: 'rust.options.recipientmodes',
|
||||
description: 'Who is paid: everyone who scored, the top N, a score of at least X, N drawn at random, the top X per cent — or every linked player of the server, with no tally at all.' },
|
||||
{ name: 'count', type: 'float', required: false, example: 3,
|
||||
description: 'N for top and random, X for a minimum score, the percentage for top per cent. Not used for everyone or linked.' },
|
||||
{ name: 'message', type: 'string', required: false, example: 'You earned {amount} coins',
|
||||
description: 'Said once to each player paid, in game: at once if they are up, otherwise when they next wake on the server. {amount} is what they received. Leave it empty to say nothing.' },
|
||||
{ name: 'delivery', type: 'string', required: false, example: 'chat', source: 'rust.options.delivery',
|
||||
description: 'Chat, or a popup where the server has PopupNotifications (chat where it has not). Chat if left empty.' },
|
||||
],
|
||||
|
||||
// Priced before the tally is read, so at the most it could pay: the amount
|
||||
// times the count for a count, times the recipient bound for every other mode.
|
||||
cost: (p) => {
|
||||
const mode = oneOf(p.recipients, MODES)
|
||||
const n = Math.round(Number(p.count) || 0)
|
||||
const people = mode === 'top' || mode === 'random' ? Math.max(0, n) : MAX_RECIPIENTS
|
||||
return { 'rust.coins': Math.ceil(Math.max(0, Number(p.amount) || 0) * people) }
|
||||
},
|
||||
|
||||
async perform({ runId, stepId, idempotencyKey, params, verify }) {
|
||||
const amount = Math.round(Number(params.amount) * 100) / 100
|
||||
if (!Number.isFinite(amount) || amount < COINS_MIN || amount > COINS_MAX) {
|
||||
return { ok: false, retry: false, error: `a payment is ${economics.coins(COINS_MIN)} to ${economics.coins(COINS_MAX)} coins, not "${params.amount}"` }
|
||||
}
|
||||
|
||||
const mode = oneOf(params.recipients, MODES)
|
||||
if (!mode) return { ok: false, retry: false, error: `recipients is one of ${MODES.join(', ')}, not "${params.recipients}"` }
|
||||
|
||||
const count = checkCount(mode, params.count)
|
||||
if (!count.ok) return { ok: false, retry: false, error: count.error }
|
||||
|
||||
const notice = coinsNotice(params, amount)
|
||||
if (!notice.ok) return { ok: false, retry: false, error: notice.error }
|
||||
|
||||
const found = await serverFor(String(params.server || '').trim())
|
||||
if (!found.ok) return found
|
||||
const server = found.server
|
||||
const name = server.name || server.id
|
||||
|
||||
// D199: refused with the reason where Economics is absent, in the dry run
|
||||
// too — the author learns it on the form, not at the run's reward.
|
||||
const econ = await economics.forServer(server.id)
|
||||
if (!econ.loaded) return { ok: false, retry: false, error: `${economics.MISSING} (${name})` }
|
||||
|
||||
if ((mode === 'top' || mode === 'random') && count.value > MAX_RECIPIENTS) {
|
||||
return { ok: false, retry: false, error: `${name} pays at most ${MAX_RECIPIENTS} people in one step, not ${count.value}` }
|
||||
}
|
||||
|
||||
if (verify) return { ok: true }
|
||||
|
||||
let picked
|
||||
let bound = MAX_RECIPIENTS
|
||||
let users
|
||||
|
||||
if (mode === 'linked') {
|
||||
// Every linked player of the server, as a kit weekend's (§2.5). One past
|
||||
// the bound, so "too many" is a sentence and not a silent cut.
|
||||
const linked = await linksDb.linkedOnServer(server.id, bound + 1)
|
||||
picked = linked.map((r) => ({ steamId: String(r.steamId), name: r.name ? String(r.name) : String(r.steamId) }))
|
||||
users = new Map(linked.map((r) => [String(r.steamId), Number(r.userId)]))
|
||||
if (picked.length > bound) {
|
||||
return {
|
||||
ok: false,
|
||||
retry: false,
|
||||
error: `${name} has more than ${bound} linked players, and pays at most ${bound} in one step — pay from a tally instead`,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const tally = await readTally(server, runId)
|
||||
if (!tally.ok) return tally
|
||||
|
||||
picked = pickRecipients(peopleOf(tally.data), mode, count.value, idempotencyKey)
|
||||
bound = Number(tally.data.maxRecipients) || bound
|
||||
if (picked.length > bound) {
|
||||
return {
|
||||
ok: false,
|
||||
retry: false,
|
||||
error: `${picked.length} people qualify, and ${name} pays at most ${bound} in one step — pick a count-based mode or a higher bar`,
|
||||
}
|
||||
}
|
||||
|
||||
users = await usersFor(picked.map((p) => p.steamId))
|
||||
}
|
||||
|
||||
// One payment per website user (D103's rule): of two linked accounts that
|
||||
// both took part, the earlier in the list — the higher score — is paid.
|
||||
const payees = []
|
||||
const byUser = new Set()
|
||||
const missed = []
|
||||
|
||||
for (const p of picked) {
|
||||
const userId = users.get(p.steamId)
|
||||
if (!userId) {
|
||||
missed.push(p.name)
|
||||
continue
|
||||
}
|
||||
if (byUser.has(userId)) continue
|
||||
byUser.add(userId)
|
||||
payees.push(p)
|
||||
}
|
||||
|
||||
const missedDetail = missed.length
|
||||
? { missed: missed.slice(0, 50), missedCount: missed.length, note: 'missed took part but have linked no website account' }
|
||||
: {}
|
||||
|
||||
if (!payees.length) {
|
||||
return { ok: true, detail: { server: name, mode, amount, paid: 0, ...missedDetail } }
|
||||
}
|
||||
|
||||
const result = await client.economicsPay(server, {
|
||||
key: paymentKey(idempotencyKey, runId, stepId),
|
||||
amount,
|
||||
steamIds: payees.map((p) => p.steamId),
|
||||
...(notice.text ? { message: notice.text, delivery: notice.delivery } : {}),
|
||||
})
|
||||
// A lost answer is retried with the same key, and the plugin answers it from
|
||||
// its first reply: nobody is paid twice.
|
||||
if (!result.ok) return transportFailure(server, result, 'Economics')
|
||||
const data = result.data || {}
|
||||
if (data.kind !== 'economics.paid') return refusal(server, data, 'payment')
|
||||
|
||||
const named = new Map(payees.map((p) => [p.steamId, p.name]))
|
||||
const paid = (data.paid || []).map((row) => ({
|
||||
name: named.get(String(row.steamId)) || String(row.steamId),
|
||||
received: Math.round((Number(row.after) - Number(row.before)) * 100) / 100,
|
||||
}))
|
||||
const failed = (data.failed || []).map((row) => ({ name: named.get(String(row.steamId)) || String(row.steamId), reason: row.reason }))
|
||||
const total = Math.round(paid.reduce((sum, row) => sum + row.received, 0) * 100) / 100
|
||||
|
||||
log.info('coins paid', { server: server.id, run: runId, step: stepId, amount, paid: paid.length, failed: failed.length, repeat: Boolean(data.repeat) })
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
detail: {
|
||||
server: name,
|
||||
mode,
|
||||
...(count.value === null ? {} : { count: count.value }),
|
||||
amount,
|
||||
paid: paid.length,
|
||||
total,
|
||||
received: paid.slice(0, 50),
|
||||
...(failed.length ? { failed: failed.slice(0, 50), failedNote: 'at-limit: the balance is at Economics\' limit; refused or unmeasured: Economics did not take or report the deposit' } : {}),
|
||||
...(notice.text && paid.length ? { told: `${paid.length} will be told in game, by ${notice.delivery}` } : {}),
|
||||
...(data.repeat ? { repeat: true, repeatNote: 'answered from the first attempt; nobody was paid twice' } : {}),
|
||||
...missedDetail,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* The body of a chat line: its delivery, and the voice's format when the line
|
||||
* goes to chat and a voice is chosen (D140). A popup is not a chat line and
|
||||
@@ -933,7 +1152,7 @@ const OPTION_SOURCES = [
|
||||
},
|
||||
]
|
||||
|
||||
const ACTIONS = [participationOpen, participationCollect, kitEntitle, announce]
|
||||
const ACTIONS = [participationOpen, participationCollect, kitEntitle, coinsPay, announce]
|
||||
|
||||
module.exports = {
|
||||
MAX_RECIPIENTS,
|
||||
@@ -950,6 +1169,8 @@ module.exports = {
|
||||
kitReward,
|
||||
readKit,
|
||||
rewardNotice,
|
||||
coinsNotice,
|
||||
paymentKey,
|
||||
chatLine,
|
||||
chatKey,
|
||||
lineBody,
|
||||
|
||||
@@ -138,6 +138,17 @@ async function apply(serverId, item, server = null) {
|
||||
}
|
||||
|
||||
case 'player.tally': {
|
||||
// Protocol 14 (§11.2.2): the player's Economics balance, a VALUE written
|
||||
// over rather than added — unlike everything below it.
|
||||
const hasBalance = frame.balance !== undefined && frame.balance !== null
|
||||
if (hasBalance) await db.setBalance(at, frame.balance)
|
||||
|
||||
// A balance with no name is an offline player's: a payment or a staff
|
||||
// adjustment reaches somebody who is not there, and the plugin names only
|
||||
// a connected player. It says nothing about play, so it must not move
|
||||
// anybody's last-seen — which every writer below does.
|
||||
if (hasBalance && !frame.name) break
|
||||
|
||||
await db.touchPlayer(frame.steamId, frame.name || null)
|
||||
await db.addStats(at, {
|
||||
npcKills: Number(frame.npcKills) || 0,
|
||||
@@ -257,6 +268,16 @@ async function apply(serverId, item, server = null) {
|
||||
break
|
||||
}
|
||||
|
||||
// ── Protocol 14: Economics wiped every balance (§11.2.2) ────────────────
|
||||
//
|
||||
// `ecowipe` puts everyone back at the start in the wipe in play, so none of
|
||||
// its balances is known any more. A new save's wipe (`newSave`) opens a new
|
||||
// map whose balances arrive as players connect; the last wipe's stay as the
|
||||
// record of how it ended.
|
||||
case 'economics.wiped':
|
||||
if (!frame.newSave) await db.clearBalances(serverId, wipeId)
|
||||
break
|
||||
|
||||
// ── Protocol 6: first-party clans ──────────────────────────────────────
|
||||
//
|
||||
// Each one is told to core as it happens (`ctx.teams.publish`) and written
|
||||
|
||||
36
server/model/economics/economics.db.js
Normal file
36
server/model/economics/economics.db.js
Normal file
@@ -0,0 +1,36 @@
|
||||
// ── SQL for Economics (PLAN_REDESIGNS §11.2.2) ────────────────────────────
|
||||
//
|
||||
// What each server's last hello said about Economics. The balances themselves
|
||||
// are rows of `rust_player_wipe_stats` and are written by ingest through
|
||||
// `events.db.js`; this file only reads the report the plugin makes about the
|
||||
// plugin.
|
||||
|
||||
const core = require('../../core')
|
||||
|
||||
const STATE = 'rust_server_state'
|
||||
|
||||
/**
|
||||
* Every server's report: `{ serverId, wipeId, economics }`, with `economics`
|
||||
* as the driver hands JSON_EXTRACT back — text, or null for a server whose
|
||||
* plugin never said (no state yet, or a plugin before protocol 14).
|
||||
*/
|
||||
async function reports() {
|
||||
return core.query(
|
||||
`SELECT server_id AS serverId, wipe_id AS wipeId,
|
||||
JSON_EXTRACT(raw, '$.integrations.economics') AS economics
|
||||
FROM ${STATE}`,
|
||||
)
|
||||
}
|
||||
|
||||
async function reportFor(serverId) {
|
||||
const rows = await core.query(
|
||||
`SELECT server_id AS serverId, wipe_id AS wipeId,
|
||||
JSON_EXTRACT(raw, '$.integrations.economics') AS economics
|
||||
FROM ${STATE}
|
||||
WHERE server_id = ?`,
|
||||
[serverId],
|
||||
)
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
module.exports = { reports, reportFor }
|
||||
218
server/model/economics/economics.model.js
Normal file
218
server/model/economics/economics.model.js
Normal file
@@ -0,0 +1,218 @@
|
||||
// ── Economics, as the site sees it (PLAN_REDESIGNS §11.2.2, D199, D335, D336) ─
|
||||
//
|
||||
// Wulf's Economics is optional (D199): a server reports it in hello's
|
||||
// `integrations.economics`, and every surface that needs it refuses with a
|
||||
// reason where it is absent, never quietly. Three readers share this file:
|
||||
//
|
||||
// • the step (`rust.coins.pay`, in eventRewards.js), which asks whether the
|
||||
// server has Economics before it pays anybody;
|
||||
// • the player's own page and the app, which read each linked account's
|
||||
// balance live and fall back to the last one the tally carried;
|
||||
// • staff, who deposit, withdraw or set one player's balance.
|
||||
//
|
||||
// The starting balance matters to D336: the richest column and the Magnate
|
||||
// title rank only balances above it, because Economics answers it for an
|
||||
// account it has never seen (§11.2.1). The plugin reads it from Economics'
|
||||
// config; when that file cannot be read the site assumes Economics' default,
|
||||
// 1000, and says so (`startingKnown: false`).
|
||||
|
||||
const core = require('../../core')
|
||||
|
||||
const client = require('../../sidecarClient')
|
||||
const db = require('./economics.db')
|
||||
const eventsDb = require('../events/events.db')
|
||||
const servers = require('../servers/servers.model')
|
||||
const { pluginError, transportError } = require('../../eventLeases')
|
||||
|
||||
const log = core.logger('economics')
|
||||
|
||||
/** Economics' own default starting balance, assumed when its config cannot be read. */
|
||||
const DEFAULT_STARTING_BALANCE = 1000
|
||||
|
||||
/** The sentence every surface says where a server has no Economics (D199). */
|
||||
const MISSING = 'Economics is not installed on this server'
|
||||
|
||||
const OPS = ['deposit', 'withdraw', 'set']
|
||||
|
||||
/** The plugin's bounds (PROTOCOL.md §20.3), refused here first so a form says why. */
|
||||
const AMOUNT_MIN = 0.01
|
||||
const AMOUNT_MAX = 1000000
|
||||
|
||||
/** JSON_EXTRACT comes back as text from the driver; an object passes through. */
|
||||
function parse(value) {
|
||||
if (value === null || value === undefined) return null
|
||||
if (typeof value === 'object') return value
|
||||
try {
|
||||
return JSON.parse(value)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A server's Economics as its last hello said: `{ loaded: false }`, or loaded
|
||||
* with its version, its starting balance (assumed where unread) and whether
|
||||
* balances wipe with the map.
|
||||
*/
|
||||
function shape(raw) {
|
||||
const e = parse(raw)
|
||||
if (!e || e.loaded !== true) return { loaded: false }
|
||||
|
||||
const start = Number(e.startingBalance)
|
||||
const known = e.startingBalance !== undefined && e.startingBalance !== null && Number.isFinite(start)
|
||||
|
||||
return {
|
||||
loaded: true,
|
||||
version: typeof e.version === 'string' ? e.version : null,
|
||||
startingBalance: known ? start : DEFAULT_STARTING_BALANCE,
|
||||
startingKnown: known,
|
||||
wipesWithMap: typeof e.wipesWithMap === 'boolean' ? e.wipesWithMap : null,
|
||||
}
|
||||
}
|
||||
|
||||
/** One server's report, with the wipe it is on. `{ loaded: false }` for a server never heard from. */
|
||||
async function forServer(serverId) {
|
||||
const row = await db.reportFor(serverId)
|
||||
if (!row) return { loaded: false, wipeId: null }
|
||||
return { ...shape(row.economics), wipeId: row.wipeId || null }
|
||||
}
|
||||
|
||||
/** Every server's report, keyed by id. */
|
||||
async function byServer() {
|
||||
const rows = await db.reports()
|
||||
return new Map(rows.map((r) => [r.serverId, { ...shape(r.economics), wipeId: r.wipeId || null }]))
|
||||
}
|
||||
|
||||
/** An amount as a sentence says it: thousands separated, at most two places. */
|
||||
function coins(n) {
|
||||
return Number(n).toLocaleString('en-US', { maximumFractionDigits: 2 })
|
||||
}
|
||||
|
||||
/**
|
||||
* The balances of some Steam accounts on every enabled server: live where the
|
||||
* server answers, the last balance the tally carried where it does not, and
|
||||
* null where neither knows. A server without Economics is listed with the reason,
|
||||
* so a page can say why there is nothing rather than show nothing.
|
||||
*
|
||||
* @param {Array<{steamId, name}>} accounts the accounts to read, a user's links
|
||||
*/
|
||||
async function balancesOf(accounts) {
|
||||
const ids = [...new Set(accounts.map((a) => String(a.steamId)).filter(Boolean))]
|
||||
const [list, reports] = await Promise.all([servers.listForPolling(), byServer()])
|
||||
|
||||
return Promise.all(
|
||||
list.map(async (server) => {
|
||||
const econ = reports.get(server.id) || { loaded: false }
|
||||
const base = { serverId: server.id, server: server.name || server.id }
|
||||
|
||||
if (!econ.loaded) return { ...base, economics: false, reason: MISSING, accounts: [] }
|
||||
|
||||
const known = await eventsDb.balancesFor(server.id, econ.wipeId, ids)
|
||||
const live = new Map()
|
||||
let answered = false
|
||||
|
||||
if (ids.length) {
|
||||
const result = await client.economicsBalances(server, ids)
|
||||
const data = (result.ok && result.data) || {}
|
||||
if (data.kind === 'economics.balances') {
|
||||
answered = true
|
||||
for (const row of data.balances || []) {
|
||||
if (row && row.steamId && Number.isFinite(Number(row.balance))) live.set(String(row.steamId), Number(row.balance))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
economics: true,
|
||||
startingBalance: econ.startingBalance,
|
||||
startingKnown: econ.startingKnown,
|
||||
live: answered,
|
||||
accounts: accounts.map((a) => {
|
||||
const id = String(a.steamId)
|
||||
const source = live.has(id) ? 'live' : known.has(id) ? 'last-known' : null
|
||||
return {
|
||||
steamId: id,
|
||||
name: a.name || null,
|
||||
balance: source === 'live' ? live.get(id) : source ? known.get(id) : null,
|
||||
source,
|
||||
}
|
||||
}),
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/** Checks a staff adjustment's op and amount. `{ ok }` or `{ ok: false, message }`. */
|
||||
function checkAdjustment(op, amount) {
|
||||
if (!OPS.includes(op)) return { ok: false, message: `op is one of ${OPS.join(', ')}` }
|
||||
const value = Number(amount)
|
||||
const min = op === 'set' ? 0 : AMOUNT_MIN
|
||||
if (!Number.isFinite(value) || value < min || value > AMOUNT_MAX) {
|
||||
return { ok: false, message: `an amount is ${op === 'set' ? '0' : coins(AMOUNT_MIN)} to ${coins(AMOUNT_MAX)} coins` }
|
||||
}
|
||||
return { ok: true, value: Math.round(value * 100) / 100 }
|
||||
}
|
||||
|
||||
/**
|
||||
* Staff deposit, withdraw or set one player's balance on one server.
|
||||
*
|
||||
* Resolves `{ ok: true, before, after, repeat, server }` or `{ ok: false,
|
||||
* status, message, reason? }`. The key makes a resent form harmless: the plugin
|
||||
* answers a key it has seen from the first reply, and moves nothing.
|
||||
*/
|
||||
async function adjust({ serverId, steamId, op, amount, key }) {
|
||||
const checked = checkAdjustment(op, amount)
|
||||
if (!checked.ok) return { ok: false, status: 400, message: checked.message }
|
||||
|
||||
const server = await servers.getForCalling(serverId)
|
||||
if (!server) return { ok: false, status: 404, message: 'No such server' }
|
||||
|
||||
const econ = await forServer(server.id)
|
||||
if (!econ.loaded) return { ok: false, status: 409, reason: 'economics-missing', message: MISSING }
|
||||
|
||||
const result = await client.economicsAdjust(server, { key, steamId, op, amount: checked.value })
|
||||
if (!result.ok) return { ok: false, status: 503, message: transportError(server, result, 'Economics') }
|
||||
|
||||
const data = result.data || {}
|
||||
const name = server.name || server.id
|
||||
|
||||
if (data.kind === 'economics.adjusted') {
|
||||
return { ok: true, before: Number(data.before), after: Number(data.after), repeat: Boolean(data.repeat), server: name, amount: checked.value }
|
||||
}
|
||||
|
||||
if (data.reason === 'insufficient') {
|
||||
// Economics takes all or nothing (§11.2.1), so the sentence is the balance.
|
||||
return { ok: false, status: 409, reason: 'insufficient', message: `They have only ${coins(data.balance)} coins on ${name}` }
|
||||
}
|
||||
|
||||
log.warn('Economics refused an adjustment', { server: server.id, op, reason: data.reason })
|
||||
return {
|
||||
ok: false,
|
||||
status: data.reason === 'economics-missing' ? 409 : 400,
|
||||
reason: data.reason || null,
|
||||
message: pluginError(data, `${name} refused the adjustment`),
|
||||
}
|
||||
}
|
||||
|
||||
/** The activity row's sentence: "Deposited 500 coins to Wanderer on Main: 1,000 → 1,500". */
|
||||
function adjustmentSummary({ op, amount, name, server, before, after }) {
|
||||
const verb = { deposit: `Deposited ${coins(amount)} coins to`, withdraw: `Withdrew ${coins(amount)} coins from`, set: `Set the balance of` }[op]
|
||||
return `${verb} ${name} on ${server}${op === 'set' ? ` to ${coins(amount)}` : ''}: ${coins(before)} → ${coins(after)}`
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_STARTING_BALANCE,
|
||||
MISSING,
|
||||
OPS,
|
||||
AMOUNT_MIN,
|
||||
AMOUNT_MAX,
|
||||
shape,
|
||||
forServer,
|
||||
byServer,
|
||||
coins,
|
||||
balancesOf,
|
||||
checkAdjustment,
|
||||
adjust,
|
||||
adjustmentSummary,
|
||||
}
|
||||
@@ -193,6 +193,45 @@ async function addTitleStats({ serverId, wipeId, steamId }, frame = {}) {
|
||||
)
|
||||
}
|
||||
|
||||
/** The largest balance a row holds: DECIMAL(14,2). Economics' own limit is far below it. */
|
||||
const BALANCE_MAX = 999999999999.99
|
||||
|
||||
/**
|
||||
* One player's Economics balance for the wipe (protocol 14, §11.2.2): a VALUE,
|
||||
* written over and never added. `last_seen` is left alone on purpose — a payment
|
||||
* or a staff adjustment reaches an offline player, and a balance is not play.
|
||||
*/
|
||||
async function setBalance({ serverId, wipeId, steamId }, balance) {
|
||||
const value = Number(balance)
|
||||
if (!serverId || !steamId || !Number.isFinite(value)) return
|
||||
|
||||
await core.query(
|
||||
`INSERT INTO ${STATS} (server_id, wipe_id, steam_id, balance)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE balance = VALUES(balance)`,
|
||||
[serverId, wipeId || '', steamId, Math.max(-BALANCE_MAX, Math.min(Math.round(value * 100) / 100, BALANCE_MAX))],
|
||||
)
|
||||
}
|
||||
|
||||
/** `ecowipe`: every balance of the wipe is back at the start, so none is known. */
|
||||
async function clearBalances(serverId, wipeId) {
|
||||
if (!serverId) return
|
||||
await core.query(`UPDATE ${STATS} SET balance = NULL WHERE server_id = ? AND wipe_id = ?`, [serverId, wipeId || ''])
|
||||
}
|
||||
|
||||
/** The last known balances of some players in one wipe, as Steam id → number. */
|
||||
async function balancesFor(serverId, wipeId, steamIds) {
|
||||
if (!serverId || !steamIds.length) return new Map()
|
||||
const rows = await core.query(
|
||||
`SELECT steam_id AS steamId, balance
|
||||
FROM ${STATS}
|
||||
WHERE server_id = ? AND wipe_id = ? AND balance IS NOT NULL
|
||||
AND steam_id IN (${steamIds.map(() => '?').join(', ')})`,
|
||||
[serverId, wipeId || '', ...steamIds],
|
||||
)
|
||||
return new Map(rows.map((r) => [String(r.steamId), Number(r.balance)]))
|
||||
}
|
||||
|
||||
/** One weapon's credited kills (§5.3), added as a delta like a gathered resource. */
|
||||
async function addWeaponKills({ serverId, wipeId, steamId }, weapon, kills) {
|
||||
if (!serverId || !steamId || !weapon || !(kills > 0)) return
|
||||
@@ -291,8 +330,15 @@ async function recentEvents({ serverId, kinds, wipeId = null, limit = 50 }) {
|
||||
* All-time is a SUM over the per-wipe rows rather than a separate set of
|
||||
* counters, which is what makes it impossible for the two to disagree — there
|
||||
* is only ever one number, added up differently.
|
||||
*
|
||||
* The balance is the exception (§11.2.2): a value per wipe, so it is read only
|
||||
* for one wipe, as that wipe's MAX — there is one row per player, so the max is
|
||||
* the value. `balanceFloor` is the server's starting balance, and only set when
|
||||
* the caller ranks by balance: then a row at or under it is not on the board
|
||||
* (D336), because "1,000" cannot tell a new account from one spent back to it.
|
||||
*/
|
||||
async function leaderboard({ serverId, wipeId = null, sort = 'kills', limit = 25 }) {
|
||||
async function leaderboard({ serverId, wipeId = null, sort = 'kills', limit = 25, balanceFloor = null }) {
|
||||
const byBalance = sort === 'balance' && wipeId && Number.isFinite(balanceFloor)
|
||||
const column = { kills: 'kills', deaths: 'deaths', npcKills: 'npc_kills', playtime: 'playtime_sec' }[sort] || 'kills'
|
||||
|
||||
const params = [serverId]
|
||||
@@ -303,6 +349,10 @@ async function leaderboard({ serverId, wipeId = null, sort = 'kills', limit = 25
|
||||
params.push(wipeId)
|
||||
}
|
||||
|
||||
const having = byBalance ? 'HAVING MAX(s.balance) > ?' : ''
|
||||
if (byBalance) params.push(balanceFloor)
|
||||
const order = byBalance ? 'MAX(s.balance)' : `SUM(s.${column})`
|
||||
|
||||
params.push(limit)
|
||||
|
||||
return core.query(
|
||||
@@ -313,12 +363,14 @@ async function leaderboard({ serverId, wipeId = null, sort = 'kills', limit = 25
|
||||
SUM(s.npc_kills) AS npcKills,
|
||||
SUM(s.structures) AS structures,
|
||||
SUM(s.playtime_sec) AS playtimeSec,
|
||||
MAX(s.balance) AS balance,
|
||||
MAX(s.last_seen) AS lastSeen
|
||||
FROM ${STATS} s
|
||||
LEFT JOIN ${PLAYERS} p ON p.steam_id = s.steam_id
|
||||
WHERE ${where}
|
||||
GROUP BY s.steam_id, p.name
|
||||
ORDER BY SUM(s.${column}) DESC, MAX(s.last_seen) DESC
|
||||
${having}
|
||||
ORDER BY ${order} DESC, MAX(s.last_seen) DESC
|
||||
LIMIT ?`,
|
||||
params,
|
||||
)
|
||||
@@ -387,6 +439,9 @@ module.exports = {
|
||||
addGathered,
|
||||
addTitleStats,
|
||||
addWeaponKills,
|
||||
setBalance,
|
||||
clearBalances,
|
||||
balancesFor,
|
||||
replacePresence,
|
||||
pruneEvents,
|
||||
recentEvents,
|
||||
|
||||
@@ -102,15 +102,20 @@ function shape(row) {
|
||||
* counters, so the two can never disagree — which is the whole reason R12's
|
||||
* "per-wipe detail plus all-time rollups" is one table and not two.
|
||||
*/
|
||||
async function leaderboard({ serverId, wipeId = null, sort = 'kills', limit, presence = false }) {
|
||||
async function leaderboard({ serverId, wipeId = null, sort = 'kills', limit, presence = false, economics = null }) {
|
||||
// The richest column (§11.2.2): only for one wipe — a balance is a value, and
|
||||
// there is no all-time one — and only where the server reports Economics.
|
||||
const withBalance = Boolean(economics && wipeId)
|
||||
const rows = await db.leaderboard({
|
||||
serverId,
|
||||
wipeId,
|
||||
sort,
|
||||
sort: sort === 'balance' && !withBalance ? 'kills' : sort,
|
||||
limit: boundedLimit(limit, 25),
|
||||
balanceFloor: withBalance && sort === 'balance' ? economics.startingBalance : null,
|
||||
})
|
||||
|
||||
return rows.map((r) => ({
|
||||
...(withBalance ? { balance: r.balance === null || r.balance === undefined ? null : Number(r.balance) } : {}),
|
||||
steamId: r.steamId,
|
||||
name: r.name || null,
|
||||
kills: Number(r.kills) || 0,
|
||||
|
||||
@@ -76,9 +76,27 @@ const COLUMN = /^[a-z_]+$/
|
||||
* @param {object} source a category's `source` from `titles.js`
|
||||
* @param {object} lists list name → the short names it holds (`lists.js`)
|
||||
*/
|
||||
async function standings({ serverId, wipeId, source, lists = {}, limit }) {
|
||||
async function standings({ serverId, wipeId, source, lists = {}, limit, floor = null }) {
|
||||
const params = [serverId, wipeId]
|
||||
|
||||
// The richest (§11.2.2): the balance is a value, one per row, ranked only
|
||||
// above `floor`, the server's starting balance (D336). No floor, no ranking —
|
||||
// the caller passes one only for a server that reports Economics.
|
||||
if (source.balance) {
|
||||
if (!COLUMN.test(source.balance)) throw new Error('not a stats column')
|
||||
if (!Number.isFinite(floor)) return []
|
||||
|
||||
return core.query(
|
||||
`SELECT s.steam_id AS steamId, MAX(s.${source.balance}) AS value
|
||||
FROM ${STATS} s
|
||||
WHERE s.server_id = ? AND s.wipe_id = ? AND s.${source.balance} > ?
|
||||
GROUP BY s.steam_id
|
||||
ORDER BY value DESC, MAX(s.last_seen) DESC
|
||||
LIMIT ?`,
|
||||
[...params, floor, limit],
|
||||
)
|
||||
}
|
||||
|
||||
if (source.sum || source.diff || source.best) {
|
||||
let value
|
||||
if (source.sum) value = `SUM(s.${source.sum})`
|
||||
|
||||
@@ -20,8 +20,9 @@ const crypto = require('node:crypto')
|
||||
|
||||
/**
|
||||
* The categories a rule may rank (PLAN_REDESIGNS §5, D172): the twenty-three
|
||||
* conditions and their shipped titles, plus `playtime`, which predates them and
|
||||
* has no default title, so a playtime rule always carries its own text.
|
||||
* conditions and their shipped titles, the richest (§11.2.2, Magnate), plus
|
||||
* `playtime`, which predates them and has no default title, so a playtime rule
|
||||
* always carries its own text.
|
||||
*
|
||||
* `source` says where the standing is read from (`titles.db.js`):
|
||||
* `sum` a column of `rust_player_wipe_stats`, summed
|
||||
@@ -30,6 +31,9 @@ const crypto = require('node:crypto')
|
||||
* `best` a column's MAX: the longest single kill (D174)
|
||||
* `weapons` a named list's kills in `rust_weapon_kills` (§5.3)
|
||||
* `gathered` a named list's amount in `rust_gather_totals` (D159)
|
||||
* `balance` the Economics balance, a value per wipe (PLAN_REDESIGNS §11.2.2):
|
||||
* ranked only above the server's starting balance (D336), and
|
||||
* only on a server that reports Economics
|
||||
* `npcProfile` one site NPC profile's kills, counted as the profile says
|
||||
* (runicnpc stage 4, D247, D250). A rule on it names the profile,
|
||||
* and carries its own text, like playtime.
|
||||
@@ -61,6 +65,7 @@ const STATS = {
|
||||
rockets: { label: 'Rockets fired', title: 'Artillerist', source: { sum: 'rockets' } },
|
||||
explosives: { label: 'Explosives thrown', title: 'Demolitionist', source: { sum: 'explosives' } },
|
||||
missions: { label: 'Missions completed', title: 'Wayfarer', source: { sum: 'missions' } },
|
||||
richest: { label: 'Richest (Economics)', title: 'Magnate', source: { balance: 'balance' } },
|
||||
playtime: { label: 'Playtime', title: null, source: { sum: 'playtime_sec' } },
|
||||
profilekills: { label: 'Kills of an NPC profile', title: null, source: { npcProfile: true } },
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// the app's — so it is remembered briefly: the leaderboard is polled by every
|
||||
// open page, and each rule is a leaderboard query of its own.
|
||||
|
||||
const economics = require('../economics/economics.model')
|
||||
const serversDb = require('../servers/servers.db')
|
||||
const db = require('./titles.db')
|
||||
const lists = require('./lists')
|
||||
@@ -118,12 +119,21 @@ async function heldFor(serverId, { wipeId, now = Date.now() } = {}) {
|
||||
ruleBy.set(key, r)
|
||||
}
|
||||
|
||||
// The richest ranks above the server's starting balance (D336), and only
|
||||
// where the server reports Economics: a balance last known before it was
|
||||
// removed is not anybody's standing now.
|
||||
let floor = null
|
||||
if (depth.has('richest')) {
|
||||
const econ = await economics.forServer(serverId)
|
||||
floor = econ.loaded ? econ.startingBalance : null
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
[...depth.entries()].map(async ([key, limit]) => {
|
||||
const r = ruleBy.get(key)
|
||||
const rows = r.stat === 'profilekills'
|
||||
? await npcs.standing(serverId, r.profile, wipeId, limit)
|
||||
: await db.standings({ serverId, wipeId, source: titles.STATS[r.stat].source, lists, limit })
|
||||
: await db.standings({ serverId, wipeId, source: titles.STATS[r.stat].source, lists, limit, floor })
|
||||
// SUM() and MAX() come back as strings; the rules compare numbers.
|
||||
standings[key] = rows.map((row) => ({ steamId: row.steamId, value: Number(row.value) || 0 }))
|
||||
}),
|
||||
|
||||
@@ -5,8 +5,11 @@
|
||||
// every statement here is scoped by it, so a panel opened on one user cannot
|
||||
// read or write another's rows by editing a path segment.
|
||||
|
||||
const crypto = require('node:crypto')
|
||||
|
||||
const core = require('../../core')
|
||||
|
||||
const economics = require('../../model/economics/economics.model')
|
||||
const links = require('../../model/links/links.model')
|
||||
const permissionsDb = require('../../model/permissions/permissions.db')
|
||||
const permissions = require('../../model/permissions/permissions.model')
|
||||
@@ -208,4 +211,68 @@ async function removeGrant(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { listLinks, removeLink, listPermissions, addGrant, removeGrant }
|
||||
/** GET /admin/users/:id/rust/economics — this user's balances (§11.2.2), as the player sees them. */
|
||||
async function listEconomics(req, res) {
|
||||
try {
|
||||
const held = await links.listForUser(Number(req.params.id))
|
||||
res.json({ servers: await economics.balancesOf(held.map((l) => ({ steamId: l.steamId, name: l.name }))) })
|
||||
} catch (err) {
|
||||
log.error('failed to read a user’s balances', { error: err.message })
|
||||
res.status(500).json({ message: 'Failed to read this user’s balances' })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /admin/users/:id/rust/economics — staff deposit, withdraw or set coins.
|
||||
*
|
||||
* **Scoped by the user**, like every write in this panel: the Steam id must be
|
||||
* one this user has linked, so a wrong id on the form moves nobody's coins
|
||||
* rather than a stranger's. The key is the form's, one per submission; a form
|
||||
* that sent none gets one, and is then only as safe as a single click.
|
||||
*/
|
||||
async function adjustEconomics(req, res) {
|
||||
const userId = Number(req.params.id)
|
||||
const { serverId, steamId, op, amount } = req.body
|
||||
const key = req.body.key ? `adj-${req.body.key}` : `adj-${crypto.randomUUID()}`
|
||||
|
||||
try {
|
||||
const held = await links.listForUser(userId)
|
||||
const link = held.find((l) => String(l.steamId) === String(steamId))
|
||||
if (!link) return res.status(404).json({ message: 'That Steam account is not linked to this user' })
|
||||
|
||||
const result = await economics.adjust({ serverId, steamId: String(steamId), op, amount, key })
|
||||
if (!result.ok) return res.status(result.status).json({ message: result.message, ...(result.reason ? { reason: result.reason } : {}) })
|
||||
|
||||
// A repeat moved nothing, so it is not a second row in the log.
|
||||
if (!result.repeat) {
|
||||
await core.activity.log({
|
||||
req,
|
||||
action: 'rust.economics.adjust',
|
||||
detail: {
|
||||
userId,
|
||||
serverId,
|
||||
steamId: String(steamId),
|
||||
op,
|
||||
amount: result.amount,
|
||||
before: result.before,
|
||||
after: result.after,
|
||||
summary: economics.adjustmentSummary({
|
||||
op,
|
||||
amount: result.amount,
|
||||
name: link.name || String(steamId),
|
||||
server: result.server,
|
||||
before: result.before,
|
||||
after: result.after,
|
||||
}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return res.json({ before: result.before, after: result.after, repeat: result.repeat })
|
||||
} catch (err) {
|
||||
log.error('failed to adjust a balance', { userId, serverId, op, error: err.message })
|
||||
return res.status(500).json({ message: 'Failed to adjust that balance' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { listLinks, removeLink, listPermissions, addGrant, removeGrant, listEconomics, adjustEconomics }
|
||||
|
||||
@@ -128,4 +128,47 @@ usersRustRouter.delete(
|
||||
usersRust.removeGrant,
|
||||
)
|
||||
|
||||
// ── Economics (PLAN_REDESIGNS §11.2.2): a person's coins, and staff's hand ──
|
||||
//
|
||||
// Beside their links, because "how many coins does this player have" is asked
|
||||
// about a person. The write is the only way coins come back after an event
|
||||
// paid them (D335): staff withdraw them by hand, and each adjustment is an
|
||||
// activity row with the balance before and after.
|
||||
|
||||
usersRustRouter.get(
|
||||
'/rust/economics',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
// #swagger.summary = 'A user’s coin balances (admin only)'
|
||||
// #swagger.description = 'This user’s Economics balance on every server, per linked Steam account — live where the server answers, else the last balance it reported. A server without Economics is listed with the reason. The same shape as GET /player/rust/economics.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||
/* #swagger.responses[200] = { description: 'The balances', content: { "application/json": { schema: { $ref: "#/components/schemas/RustEconomicsBalances" } } } } */
|
||||
param('id').isInt(),
|
||||
validate,
|
||||
usersRust.listEconomics,
|
||||
)
|
||||
|
||||
usersRustRouter.post(
|
||||
'/rust/economics',
|
||||
// #swagger.tags = ['Admin · Users']
|
||||
// #swagger.summary = 'Deposit, withdraw or set a user’s coins on one server (admin only)'
|
||||
// #swagger.description = 'Through the server’s Economics, for one Steam account this user has linked. A withdrawal above the balance is refused with the balance, because Economics takes all or nothing. `key` makes a resent form harmless: the game answers a key it has seen from the first reply and moves nothing. Recorded in the activity log with the balance before and after.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/RustEconomicsAdjust" } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Adjusted', content: { "application/json": { schema: { $ref: "#/components/schemas/RustEconomicsAdjusted" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'A bad op or amount', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such server, or that Steam account is not linked to this user', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[409] = { description: 'Economics is not installed there, or the balance is too low to withdraw that much', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[503] = { description: 'The server could not be reached', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('id').isInt(),
|
||||
body('serverId').isString().isLength({ min: 1, max: 64 }),
|
||||
body('steamId').isString().matches(STEAM_ID_RE),
|
||||
body('op').isIn(['deposit', 'withdraw', 'set']),
|
||||
body('amount').isFloat({ min: 0, max: 1000000 }).toFloat(),
|
||||
body('key').optional().isString().matches(/^[A-Za-z0-9-]{8,64}$/),
|
||||
validate,
|
||||
usersRust.adjustEconomics,
|
||||
)
|
||||
|
||||
module.exports = usersRustRouter
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
|
||||
const core = require('../../core')
|
||||
|
||||
const economics = require('../../model/economics/economics.model')
|
||||
const links = require('../../model/links/links.model')
|
||||
const npcs = require('../../model/npcs/npcs.model')
|
||||
const npcsDb = require('../../model/npcs/npcs.db')
|
||||
@@ -55,6 +56,20 @@ async function ownNpcKills(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /player/rust/economics — the caller's coin balances (§11.2.2): every
|
||||
* server, each linked account, live where the server answers.
|
||||
*/
|
||||
async function ownEconomics(req, res) {
|
||||
try {
|
||||
const held = await links.listForUser(req.user.id)
|
||||
res.json({ servers: await economics.balancesOf(held.map((l) => ({ steamId: l.steamId, name: l.name }))) })
|
||||
} catch (err) {
|
||||
log.error('failed to read a player’s balances', { error: err.message })
|
||||
res.status(500).json({ message: 'Failed to read your balances' })
|
||||
}
|
||||
}
|
||||
|
||||
/** GET /player/rust/links — the Steam accounts the caller holds. */
|
||||
async function listLinks(req, res) {
|
||||
try {
|
||||
@@ -187,4 +202,4 @@ async function listPermissions(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { listServers, listLinks, confirmLink, removeLink, listPermissions, ownNpcKills }
|
||||
module.exports = { listServers, listLinks, confirmLink, removeLink, listPermissions, ownNpcKills, ownEconomics }
|
||||
|
||||
@@ -67,6 +67,16 @@ playerRustRouter.get(
|
||||
rust.ownNpcKills,
|
||||
)
|
||||
|
||||
playerRustRouter.get(
|
||||
'/economics',
|
||||
// #swagger.tags = ['Player · Rust']
|
||||
// #swagger.summary = 'Your coin balances'
|
||||
// #swagger.description = 'The caller’s Economics balance on every server, per linked Steam account (PLAN_REDESIGNS §11.2.2). Read live from each server; where a server does not answer, the last balance it reported (`source: last-known`), and null where neither knows. A server without Economics is listed with `economics: false` and the reason. `startingKnown: false` means the server’s Economics config could not be read and the starting balance shown is Economics’ default. Empty `accounts` with no linked account. Requires a session.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The balances', content: { "application/json": { schema: { $ref: "#/components/schemas/RustEconomicsBalances" } } } } */
|
||||
rust.ownEconomics,
|
||||
)
|
||||
|
||||
playerRustRouter.get(
|
||||
'/servers',
|
||||
// #swagger.tags = ['Player · Rust']
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
const core = require('../../core')
|
||||
|
||||
const clans = require('../../model/clans/clans.model')
|
||||
const economics = require('../../model/economics/economics.model')
|
||||
const events = require('../../model/events/events.model')
|
||||
const map = require('../../model/map/map.model')
|
||||
const mapDb = require('../../model/map/map.db')
|
||||
@@ -108,13 +109,22 @@ async function listEvents(req, res) {
|
||||
async function listLeaderboard(req, res) {
|
||||
try {
|
||||
const presence = await visibility.canSeePresence(req, req.params.id)
|
||||
const wipeId = req.query.wipe || null
|
||||
|
||||
// The richest column (§11.2.2): one wipe at a time, on a server that
|
||||
// reports Economics. `economics` is null otherwise, and the rows carry no
|
||||
// `balance`, so a page shows the column exactly when there is one.
|
||||
const econ = await economics.forServer(req.params.id)
|
||||
const withBalance = econ.loaded && wipeId ? { startingBalance: econ.startingBalance } : null
|
||||
|
||||
const [rows, held] = await Promise.all([
|
||||
events.leaderboard({
|
||||
serverId: req.params.id,
|
||||
wipeId: req.query.wipe || null,
|
||||
wipeId,
|
||||
sort: req.query.sort,
|
||||
limit: req.query.limit,
|
||||
presence: presence.visible,
|
||||
economics: withBalance,
|
||||
}),
|
||||
titles.currentFor(req.params.id),
|
||||
])
|
||||
@@ -125,6 +135,7 @@ async function listLeaderboard(req, res) {
|
||||
// this page is showing, because a title is what a player is, not what a
|
||||
// past wipe was.
|
||||
leaderboard: rows.map((row) => ({ ...row, titles: held.get(row.steamId) || [] })),
|
||||
economics: withBalance,
|
||||
})
|
||||
} catch (err) {
|
||||
log.error('failed to read the leaderboard', { server: req.params.id, error: err.message })
|
||||
|
||||
@@ -82,10 +82,10 @@ rustRouter.get(
|
||||
'/servers/:id/leaderboard',
|
||||
// #swagger.tags = ['Public · Rust']
|
||||
// #swagger.summary = 'The leaderboard for one Rust server'
|
||||
// #swagger.description = 'Per-wipe when `wipe` is given, all-time otherwise. All-time is the per-wipe rows summed rather than a second set of counters, so a wipe splits a player’s history without ending it. `lastSeen` is withheld below the operator’s presence audience: a gather tally refreshes it every minute a player is on, so it would name who is online. Each row carries `titles`: the chat titles that player holds now, as `[{ text, color }]` in the order the game shows them, and an empty list for a player with none. Titles rank the CURRENT wipe whichever wipe the page asks for, and are set by the operator’s rules.'
|
||||
// #swagger.description = 'Per-wipe when `wipe` is given, all-time otherwise. All-time is the per-wipe rows summed rather than a second set of counters, so a wipe splits a player’s history without ending it. `lastSeen` is withheld below the operator’s presence audience: a gather tally refreshes it every minute a player is on, so it would name who is online. Each row carries `titles`: the chat titles that player holds now, as `[{ text, color }]` in the order the game shows them, and an empty list for a player with none. Titles rank the CURRENT wipe whichever wipe the page asks for, and are set by the operator’s rules. On a server that reports Economics, and only for one wipe, each row also carries `balance` (its last known coin balance, null when none was reported) and the answer carries `economics: { startingBalance }` (PLAN_REDESIGNS §11.2.2); `economics` is null otherwise. `sort=balance` ranks the richest, and only balances above the starting balance are on it (D336).'
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } }
|
||||
// #swagger.parameters['wipe'] = { in: 'query', required: false, description: 'Restrict to one wipe id', schema: { type: 'string' } }
|
||||
// #swagger.parameters['sort'] = { in: 'query', required: false, description: 'kills, deaths, npcKills or playtime', schema: { type: 'string' } }
|
||||
// #swagger.parameters['sort'] = { in: 'query', required: false, description: 'kills, deaths, npcKills, playtime, or balance (one wipe on a server with Economics)', schema: { type: 'string' } }
|
||||
// #swagger.parameters['limit'] = { in: 'query', required: false, description: 'Rows to return, capped at 200', schema: { type: 'integer' } }
|
||||
/* #swagger.responses[200] = { description: 'The leaderboard' } */
|
||||
siteMode,
|
||||
|
||||
@@ -460,6 +460,20 @@ const kits = (server) => request(server, '/kits')
|
||||
const kitsUses = (server, kit, steamIds) =>
|
||||
request(server, '/kits/uses', { method: 'POST', body: { kit, steamIds } })
|
||||
|
||||
/**
|
||||
* Economics (protocol 14, PLAN_REDESIGNS.md §11.2.2). `economics.paid` lists each
|
||||
* player's balance before and after; a key already paid answers `repeat: true`
|
||||
* and pays nobody, so a retry is safe. `economics.error` has a reason.
|
||||
*/
|
||||
const economicsPay = (server, body) => request(server, '/economics/pay', { method: 'POST', body })
|
||||
|
||||
/** Each player's balance, and the server's starting balance when its config says. */
|
||||
const economicsBalances = (server, steamIds) =>
|
||||
request(server, '/economics/balances', { method: 'POST', body: { steamIds } })
|
||||
|
||||
/** Staff: `{ key, steamId, op, amount }`, op deposit · withdraw · set. `economics.adjusted` has before and after. */
|
||||
const economicsAdjust = (server, body) => request(server, '/economics/adjust', { method: 'POST', body })
|
||||
|
||||
/**
|
||||
* Say one line in the server's chat. `chat.ok` carries `said: false` when the
|
||||
* key was said in the last ten minutes — a retry, answered and not repeated.
|
||||
@@ -549,6 +563,9 @@ module.exports = {
|
||||
tallyClose,
|
||||
kits,
|
||||
kitsUses,
|
||||
economicsPay,
|
||||
economicsBalances,
|
||||
economicsAdjust,
|
||||
chat,
|
||||
titles,
|
||||
mapInfo,
|
||||
|
||||
@@ -1286,6 +1286,51 @@ module.exports = {
|
||||
kills: { type: 'array', items: { type: 'object' }, example: [{ serverId: 'main', profile: 'warden', label: 'Warden', kills: 3 }] },
|
||||
},
|
||||
},
|
||||
RustEconomicsBalances: {
|
||||
type: 'object',
|
||||
description: 'Coin balances per server and linked Steam account (PLAN_REDESIGNS §11.2.2). `source` is `live`, `last-known` or null.',
|
||||
properties: {
|
||||
servers: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
serverId: { type: 'string', example: 'main' },
|
||||
server: { type: 'string', example: 'Main' },
|
||||
economics: { type: 'boolean', example: true },
|
||||
reason: { type: 'string', example: 'Economics is not installed on this server' },
|
||||
startingBalance: { type: 'number', example: 1000 },
|
||||
startingKnown: { type: 'boolean', example: true },
|
||||
live: { type: 'boolean', example: true },
|
||||
accounts: {
|
||||
type: 'array',
|
||||
items: { type: 'object' },
|
||||
example: [{ steamId: '76561198000000001', name: 'Wanderer', balance: 1500, source: 'live' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
RustEconomicsAdjust: {
|
||||
type: 'object',
|
||||
required: ['serverId', 'steamId', 'op', 'amount'],
|
||||
properties: {
|
||||
serverId: { type: 'string', example: 'main' },
|
||||
steamId: { type: 'string', example: '76561198000000001' },
|
||||
op: { type: 'string', enum: ['deposit', 'withdraw', 'set'], example: 'deposit' },
|
||||
amount: { type: 'number', example: 500, description: '0.01 to 1,000,000; 0 is allowed for set.' },
|
||||
key: { type: 'string', example: '3f2a9c1e-7b4d-4e8a-9f10-2c6d5e8b7a41', description: 'One per submission, so a resend moves nothing.' },
|
||||
},
|
||||
},
|
||||
RustEconomicsAdjusted: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
before: { type: 'number', example: 1000 },
|
||||
after: { type: 'number', example: 1500 },
|
||||
repeat: { type: 'boolean', example: false },
|
||||
},
|
||||
},
|
||||
RustVisibilityUpdate: {
|
||||
type: 'object',
|
||||
description: 'A change to who may see who is online. Either part may be omitted; a server set to null follows the fleet default again.',
|
||||
|
||||
309
server/test/economics.test.js
Normal file
309
server/test/economics.test.js
Normal file
@@ -0,0 +1,309 @@
|
||||
// ── Economics (PLAN_REDESIGNS §11.2.2, protocol 14, D335, D336) ───────────
|
||||
//
|
||||
// Each test is one way the feature could look right and be wrong:
|
||||
//
|
||||
// a balance is a VALUE, written over — summed, it would grow every minute
|
||||
// an offline player's balance moves nobody's last-seen
|
||||
// `ecowipe` clears the wipe's balances; a new save's wipe keeps the last one's
|
||||
// the richest ranks only above the starting balance, and only one wipe (D336)
|
||||
// a server without Economics is refused with the reason, in the dry run too (D199)
|
||||
// one payment per website user, with the key, so a retry never pays twice
|
||||
// a withdrawal above the balance is a sentence with the balance in it
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert')
|
||||
|
||||
const { fakeCtx } = require('./_fakes')
|
||||
|
||||
/** Installs a core whose `db.query` records every statement and answers `rows`. */
|
||||
function withRecorder(rows = []) {
|
||||
const statements = []
|
||||
const ctx = fakeCtx({
|
||||
db: {
|
||||
query: (sql, params = []) => {
|
||||
statements.push({ sql, params })
|
||||
return Promise.resolve(typeof rows === 'function' ? rows(sql, params) : rows)
|
||||
},
|
||||
pool: {},
|
||||
},
|
||||
})
|
||||
require('../core')._reset()
|
||||
require('../core').init(ctx)
|
||||
return { statements, touching: (table) => statements.filter((s) => s.sql.includes(table)) }
|
||||
}
|
||||
|
||||
withRecorder()
|
||||
|
||||
const client = require('../sidecarClient')
|
||||
const serversDb = require('../model/servers/servers.db')
|
||||
const servers = require('../model/servers/servers.model')
|
||||
const linksDb = require('../model/links/links.db')
|
||||
const economicsDb = require('../model/economics/economics.db')
|
||||
const economics = require('../model/economics/economics.model')
|
||||
const eventsDb = require('../model/events/events.db')
|
||||
const eventsModel = require('../model/events/events.model')
|
||||
const titlesDb = require('../model/titles/titles.db')
|
||||
const rewards = require('../eventRewards')
|
||||
|
||||
const ok = (data) => ({ ok: true, status: 'ok', data })
|
||||
const frame = (over = {}) => ({ type: 'event', t: 1, serverId: 'main', wipeId: 'w1', ...over })
|
||||
const item = (kind, over = {}) => ({ id: 1, t: 1, kind, frame: frame({ kind, ...over }) })
|
||||
|
||||
const A = '76561198000000001'
|
||||
const B = '76561198000000002'
|
||||
const C = '76561198000000003'
|
||||
const D = '76561198000000004'
|
||||
|
||||
// ── What a server reports ──────────────────────────────────────────────────
|
||||
|
||||
test('a server whose Economics config cannot be read is assumed at 1000, and says so', () => {
|
||||
assert.deepStrictEqual(economics.shape(null), { loaded: false })
|
||||
assert.deepStrictEqual(economics.shape({ loaded: false }), { loaded: false })
|
||||
|
||||
const unread = economics.shape(JSON.stringify({ loaded: true, version: '3.9.2' }))
|
||||
assert.strictEqual(unread.startingBalance, 1000)
|
||||
assert.strictEqual(unread.startingKnown, false)
|
||||
|
||||
const read = economics.shape({ loaded: true, version: '3.9.2', startingBalance: 0, wipesWithMap: true })
|
||||
assert.strictEqual(read.startingBalance, 0)
|
||||
assert.strictEqual(read.startingKnown, true)
|
||||
assert.strictEqual(read.wipesWithMap, true)
|
||||
})
|
||||
|
||||
// ── Ingest ─────────────────────────────────────────────────────────────────
|
||||
|
||||
test('a tally’s balance is written over, and an offline one moves nobody’s last-seen', async () => {
|
||||
const rec = withRecorder()
|
||||
const { apply } = require('../ingest')
|
||||
|
||||
// No name: the plugin names only a connected player.
|
||||
await apply('main', item('player.tally', { steamId: A, balance: 1500.456 }))
|
||||
|
||||
const stats = rec.touching('rust_player_wipe_stats')
|
||||
assert.strictEqual(stats.length, 1)
|
||||
assert.match(stats[0].sql, /balance = VALUES\(balance\)/)
|
||||
assert.doesNotMatch(stats[0].sql, /balance \+/)
|
||||
assert.doesNotMatch(stats[0].sql, /last_seen/)
|
||||
assert.deepStrictEqual(stats[0].params, ['main', 'w1', A, 1500.46])
|
||||
assert.strictEqual(rec.touching('rust_players ').length, 0, 'no player row is touched')
|
||||
|
||||
// A connected player's tally carries the balance AND its play.
|
||||
const live = withRecorder()
|
||||
await apply('main', item('player.tally', { steamId: A, name: 'Wanderer', balance: 900, npcKills: 2 }))
|
||||
assert.ok(live.touching('rust_player_wipe_stats').some((s) => /balance = VALUES\(balance\)/.test(s.sql)))
|
||||
assert.ok(live.touching('rust_player_wipe_stats').some((s) => /npc_kills = npc_kills \+/.test(s.sql)))
|
||||
})
|
||||
|
||||
test('ecowipe clears the wipe’s balances; a new save keeps the last wipe’s as its record', async () => {
|
||||
const { apply } = require('../ingest')
|
||||
|
||||
const rec = withRecorder()
|
||||
await apply('main', item('economics.wiped', { newSave: false }))
|
||||
const cleared = rec.touching('rust_player_wipe_stats')
|
||||
assert.strictEqual(cleared.length, 1)
|
||||
assert.match(cleared[0].sql, /SET balance = NULL WHERE server_id = \? AND wipe_id = \?/)
|
||||
assert.deepStrictEqual(cleared[0].params, ['main', 'w1'])
|
||||
|
||||
const kept = withRecorder()
|
||||
await apply('main', item('economics.wiped', { newSave: true }))
|
||||
assert.strictEqual(kept.touching('rust_player_wipe_stats').length, 0)
|
||||
})
|
||||
|
||||
// ── The richest (D336) ─────────────────────────────────────────────────────
|
||||
|
||||
test('the richest column ranks one wipe, above the starting balance', async () => {
|
||||
const rec = withRecorder()
|
||||
await eventsDb.leaderboard({ serverId: 'main', wipeId: 'w1', sort: 'balance', limit: 25, balanceFloor: 1000 })
|
||||
const [q] = rec.statements
|
||||
assert.match(q.sql, /HAVING MAX\(s\.balance\) > \?/)
|
||||
assert.match(q.sql, /ORDER BY MAX\(s\.balance\) DESC/)
|
||||
assert.deepStrictEqual(q.params, ['main', 'w1', 1000, 25])
|
||||
|
||||
// All time has no balance: the sort falls back, and no row carries one.
|
||||
const all = withRecorder([{ steamId: A, kills: 3, balance: 5000 }])
|
||||
const rows = await eventsModel.leaderboard({ serverId: 'main', wipeId: null, sort: 'balance', economics: { startingBalance: 1000 } })
|
||||
assert.doesNotMatch(all.statements[0].sql, /HAVING/)
|
||||
assert.match(all.statements[0].sql, /ORDER BY SUM\(s\.kills\) DESC/)
|
||||
assert.ok(!('balance' in rows[0]))
|
||||
|
||||
// One wipe on a server with Economics: every row carries its balance, null when none.
|
||||
withRecorder([{ steamId: A, balance: '2500.00' }, { steamId: B, balance: null }])
|
||||
const wipe = await eventsModel.leaderboard({ serverId: 'main', wipeId: 'w1', sort: 'kills', economics: { startingBalance: 1000 } })
|
||||
assert.strictEqual(wipe[0].balance, 2500)
|
||||
assert.strictEqual(wipe[1].balance, null)
|
||||
})
|
||||
|
||||
test('Magnate ranks nobody without a floor, and only balances above it with one', async () => {
|
||||
const none = withRecorder()
|
||||
assert.deepStrictEqual(await titlesDb.standings({ serverId: 'main', wipeId: 'w1', source: { balance: 'balance' }, limit: 3 }), [])
|
||||
assert.strictEqual(none.statements.length, 0)
|
||||
|
||||
const rec = withRecorder()
|
||||
await titlesDb.standings({ serverId: 'main', wipeId: 'w1', source: { balance: 'balance' }, limit: 3, floor: 1000 })
|
||||
assert.match(rec.statements[0].sql, /s\.balance > \?/)
|
||||
assert.deepStrictEqual(rec.statements[0].params, ['main', 'w1', 1000, 3])
|
||||
})
|
||||
|
||||
// ── The step ───────────────────────────────────────────────────────────────
|
||||
|
||||
const ROW = { id: 'main', name: 'Main', sidecarBaseUrl: 'http://main:1', sidecarTokenEnc: null, enabled: 1 }
|
||||
const pay = rewards.ACTIONS.find((a) => a.id === 'rust.coins.pay')
|
||||
|
||||
function stub(t, { loaded = true, people = [], links = {}, payAnswer } = {}) {
|
||||
const calls = { pay: [] }
|
||||
const saved = {
|
||||
getServer: serversDb.getServer,
|
||||
reportFor: economicsDb.reportFor,
|
||||
tallySnapshot: client.tallySnapshot,
|
||||
economicsPay: client.economicsPay,
|
||||
userIdsForSteamIds: linksDb.userIdsForSteamIds,
|
||||
}
|
||||
|
||||
serversDb.getServer = async (id) => (id === 'main' ? ROW : null)
|
||||
economicsDb.reportFor = async () => ({ serverId: 'main', wipeId: 'w1', economics: loaded ? JSON.stringify({ loaded: true, startingBalance: 1000 }) : null })
|
||||
client.tallySnapshot = async (server, runId) => ok({ kind: 'tally.snapshot', runId, people, maxRecipients: 100 })
|
||||
client.economicsPay = async (server, body) => {
|
||||
calls.pay.push(body)
|
||||
return payAnswer ? payAnswer(body) : ok({ kind: 'economics.paid', key: body.key, repeat: false, paid: [], failed: [] })
|
||||
}
|
||||
linksDb.userIdsForSteamIds = async (ids) => ids.filter((id) => links[id]).map((id) => ({ steamId: id, userId: links[id] }))
|
||||
|
||||
t.after(() => {
|
||||
serversDb.getServer = saved.getServer
|
||||
economicsDb.reportFor = saved.reportFor
|
||||
client.tallySnapshot = saved.tallySnapshot
|
||||
client.economicsPay = saved.economicsPay
|
||||
linksDb.userIdsForSteamIds = saved.userIdsForSteamIds
|
||||
})
|
||||
return calls
|
||||
}
|
||||
|
||||
const PARAMS = { server: 'main', amount: 500, recipients: 'everyone', message: 'You earned {amount} coins', delivery: 'popup' }
|
||||
|
||||
test('pay coins is irreversible and kept: no ledger, no revert (D335)', () => {
|
||||
assert.strictEqual(pay.risk, 'irreversible')
|
||||
assert.strictEqual(pay.reversible, 'none')
|
||||
assert.strictEqual(pay.revert, undefined)
|
||||
})
|
||||
|
||||
test('a server without Economics is refused in the dry run, with the reason (D199)', async (t) => {
|
||||
stub(t, { loaded: false })
|
||||
const result = await pay.perform({ runId: 7, stepId: 4, idempotencyKey: 'k-1', params: PARAMS, verify: true })
|
||||
assert.strictEqual(result.ok, false)
|
||||
assert.strictEqual(result.retry, false)
|
||||
assert.match(result.error, /Economics is not installed on this server/)
|
||||
})
|
||||
|
||||
test('coins go once to each website user, with the key, the amount and the message', async (t) => {
|
||||
const person = (steamId, score) => ({ steamId, name: `p${steamId.slice(-1)}`, seconds: 60, kills: 0, score, joinedAt: 1 })
|
||||
const calls = stub(t, {
|
||||
// A and B are one person (user 1); C has linked nothing; D is user 2.
|
||||
people: [person(A, 9), person(B, 5), person(C, 3), person(D, 1)],
|
||||
links: { [A]: 1, [B]: 1, [D]: 2 },
|
||||
payAnswer: (body) =>
|
||||
ok({
|
||||
kind: 'economics.paid',
|
||||
key: body.key,
|
||||
repeat: false,
|
||||
paid: [{ steamId: A, before: 1000, after: 1500 }],
|
||||
failed: [{ steamId: D, reason: 'at-limit' }],
|
||||
}),
|
||||
})
|
||||
|
||||
const result = await pay.perform({ runId: 7, stepId: 4, idempotencyKey: 'k-1', params: PARAMS, verify: false })
|
||||
|
||||
assert.strictEqual(result.ok, true)
|
||||
assert.deepStrictEqual(calls.pay, [
|
||||
{ key: 'k-1', amount: 500, steamIds: [A, D], message: 'You earned {amount} coins', delivery: 'popup' },
|
||||
])
|
||||
assert.strictEqual(result.detail.paid, 1)
|
||||
assert.strictEqual(result.detail.total, 500)
|
||||
assert.deepStrictEqual(result.detail.received, [{ name: 'p1', received: 500 }])
|
||||
assert.deepStrictEqual(result.detail.failed, [{ name: 'p4', reason: 'at-limit' }])
|
||||
assert.deepStrictEqual(result.detail.missed, ['p3'])
|
||||
assert.strictEqual(result.resources, undefined, 'nothing is ledgered')
|
||||
})
|
||||
|
||||
test('a repeated key is answered from the first payment, and a lost answer is retried', async (t) => {
|
||||
const person = { steamId: A, name: 'pa', seconds: 60, kills: 0, score: 1, joinedAt: 1 }
|
||||
stub(t, { people: [person], links: { [A]: 1 }, payAnswer: () => ok({ kind: 'economics.paid', repeat: true, paid: [{ steamId: A, before: 1000, after: 1500 }], failed: [] }) })
|
||||
const repeat = await pay.perform({ runId: 7, stepId: 4, idempotencyKey: 'k-1', params: PARAMS, verify: false })
|
||||
assert.strictEqual(repeat.ok, true)
|
||||
assert.strictEqual(repeat.detail.repeat, true)
|
||||
|
||||
client.economicsPay = async () => ({ ok: false, status: 'timeout' })
|
||||
const lost = await pay.perform({ runId: 7, stepId: 4, idempotencyKey: 'k-1', params: PARAMS, verify: false })
|
||||
assert.strictEqual(lost.ok, false)
|
||||
assert.notStrictEqual(lost.retry, false, 'a timeout is retried, and the key keeps it to one payment')
|
||||
|
||||
client.economicsPay = async () => ok({ kind: 'economics.error', reason: 'economics-missing', message: 'Economics is not installed on this server' })
|
||||
const missing = await pay.perform({ runId: 7, stepId: 4, idempotencyKey: 'k-1', params: PARAMS, verify: false })
|
||||
assert.strictEqual(missing.retry, false)
|
||||
})
|
||||
|
||||
test('a payment is priced at the most it could pay, and its message is measured with the amount in it', () => {
|
||||
assert.deepStrictEqual(pay.cost({ amount: 500, recipients: 'top', count: 3 }), { 'rust.coins': 1500 })
|
||||
assert.deepStrictEqual(pay.cost({ amount: 2.5, recipients: 'everyone' }), { 'rust.coins': 250 })
|
||||
|
||||
assert.strictEqual(rewards.coinsNotice({ message: '' }, 500).text, null)
|
||||
assert.strictEqual(rewards.coinsNotice({ message: `${'x'.repeat(250)}{amount}` }, 1000000).ok, false)
|
||||
assert.strictEqual(rewards.coinsNotice({ message: 'Got {amount}', delivery: 'carrier pigeon' }, 5).ok, false)
|
||||
|
||||
const key = rewards.paymentKey('k'.repeat(300), 7, 4)
|
||||
assert.ok(key.startsWith('coins:') && key.length <= 120)
|
||||
assert.strictEqual(rewards.paymentKey('k-1', 7, 4), 'k-1')
|
||||
})
|
||||
|
||||
test('an amount outside 0.01 to 1,000,000 is refused before anything is asked', async (t) => {
|
||||
stub(t)
|
||||
for (const amount of [0, -5, 1000001, 'lots']) {
|
||||
const result = await pay.perform({ runId: 7, stepId: 4, idempotencyKey: 'k', params: { ...PARAMS, amount }, verify: true })
|
||||
assert.strictEqual(result.ok, false, String(amount))
|
||||
assert.strictEqual(result.retry, false)
|
||||
}
|
||||
})
|
||||
|
||||
// ── Staff ──────────────────────────────────────────────────────────────────
|
||||
|
||||
test('an adjustment checks its op and amount; only a set may be 0', () => {
|
||||
assert.strictEqual(economics.checkAdjustment('steal', 5).ok, false)
|
||||
assert.strictEqual(economics.checkAdjustment('deposit', 0).ok, false)
|
||||
assert.strictEqual(economics.checkAdjustment('set', 0).ok, true)
|
||||
assert.strictEqual(economics.checkAdjustment('withdraw', 1000001).ok, false)
|
||||
assert.strictEqual(economics.checkAdjustment('deposit', 12.345).value, 12.35)
|
||||
})
|
||||
|
||||
test('a withdrawal above the balance is refused with the balance', async (t) => {
|
||||
const saved = { getForCalling: servers.getForCalling, reportFor: economicsDb.reportFor, economicsAdjust: client.economicsAdjust }
|
||||
t.after(() => Object.assign(servers, { getForCalling: saved.getForCalling }) && Object.assign(economicsDb, { reportFor: saved.reportFor }) && Object.assign(client, { economicsAdjust: saved.economicsAdjust }))
|
||||
|
||||
const sent = []
|
||||
servers.getForCalling = async () => ({ id: 'main', name: 'Main', baseUrl: 'http://main:1', token: 't' })
|
||||
economicsDb.reportFor = async () => ({ serverId: 'main', wipeId: 'w1', economics: { loaded: true } })
|
||||
client.economicsAdjust = async (server, body) => {
|
||||
sent.push(body)
|
||||
return ok({ kind: 'economics.error', reason: 'insufficient', balance: 340 })
|
||||
}
|
||||
|
||||
const result = await economics.adjust({ serverId: 'main', steamId: A, op: 'withdraw', amount: 500, key: 'adj-1' })
|
||||
assert.strictEqual(result.ok, false)
|
||||
assert.strictEqual(result.status, 409)
|
||||
assert.match(result.message, /only 340 coins on Main/)
|
||||
assert.deepStrictEqual(sent, [{ key: 'adj-1', steamId: A, op: 'withdraw', amount: 500 }])
|
||||
|
||||
economicsDb.reportFor = async () => null
|
||||
const missing = await economics.adjust({ serverId: 'main', steamId: A, op: 'deposit', amount: 5, key: 'adj-2' })
|
||||
assert.strictEqual(missing.status, 409)
|
||||
assert.strictEqual(missing.message, economics.MISSING)
|
||||
})
|
||||
|
||||
test('the activity row says what moved', () => {
|
||||
assert.strictEqual(
|
||||
economics.adjustmentSummary({ op: 'deposit', amount: 500, name: 'Wanderer', server: 'Main', before: 1000, after: 1500 }),
|
||||
'Deposited 500 coins to Wanderer on Main: 1,000 → 1,500',
|
||||
)
|
||||
assert.strictEqual(
|
||||
economics.adjustmentSummary({ op: 'set', amount: 0, name: 'Wanderer', server: 'Main', before: 1000, after: 0 }),
|
||||
'Set the balance of Wanderer on Main to 0: 1,000 → 0',
|
||||
)
|
||||
})
|
||||
@@ -156,6 +156,7 @@ test('the event verbs are registered, and every budget has a verb that spends it
|
||||
const budgets = api.record.eventBudgets
|
||||
assert.deepStrictEqual(actions.map((a) => a.id).sort(), [
|
||||
'rust.announce',
|
||||
'rust.coins.pay',
|
||||
'rust.crate.place',
|
||||
'rust.kit.entitle',
|
||||
'rust.npc.place',
|
||||
@@ -165,6 +166,7 @@ test('the event verbs are registered, and every budget has a verb that spends it
|
||||
])
|
||||
assert.deepStrictEqual(budgets.map((b) => b.id).sort(), [
|
||||
'rust.announcements',
|
||||
'rust.coins',
|
||||
'rust.grants',
|
||||
'rust.npcs',
|
||||
'rust.prefabs',
|
||||
@@ -189,9 +191,9 @@ test('the event verbs are registered, and every budget has a verb that spends it
|
||||
}
|
||||
assert.deepStrictEqual([...spent].sort(), budgets.map((b) => b.id).sort())
|
||||
|
||||
// What a teardown can give back is declared honestly: a line said and a
|
||||
// collect filed are not undone.
|
||||
const once = new Set(['rust.announce', 'rust.participation.collect'])
|
||||
// What a teardown can give back is declared honestly: a line said, a
|
||||
// collect filed and coins paid (D335) are not undone.
|
||||
const once = new Set(['rust.announce', 'rust.participation.collect', 'rust.coins.pay'])
|
||||
for (const a of actions) {
|
||||
assert.strictEqual(a.reversible, once.has(a.id) ? 'none' : 'ledger', a.id)
|
||||
if (once.has(a.id)) continue
|
||||
|
||||
@@ -45,6 +45,8 @@ test('the player tier serves the identity routes, the entitlement read, and noth
|
||||
routes.map((r) => `${r.method} ${r.path}`).sort(),
|
||||
[
|
||||
'DELETE /links/:steamId',
|
||||
// Protocol 14 (§11.2.2): the caller's own coin balances. Read-only.
|
||||
'GET /economics',
|
||||
'GET /links',
|
||||
// Phase 8: what the site has given the caller in game. Read-only on this
|
||||
// tier by construction — the authoring routes are all admin.
|
||||
@@ -88,8 +90,11 @@ test('the admin.users.detail router merges the parent’s params and keeps its o
|
||||
'DELETE /rust/links/:steamId',
|
||||
// Phase 7 filled the same panel with what this person may do in game.
|
||||
'DELETE /rust/permissions/grants/:grantId',
|
||||
// Protocol 14 (§11.2.2): their coins, and staff's hand on them.
|
||||
'GET /rust/economics',
|
||||
'GET /rust/links',
|
||||
'GET /rust/permissions',
|
||||
'POST /rust/economics',
|
||||
'POST /rust/permissions/grants',
|
||||
])
|
||||
|
||||
|
||||
@@ -47,8 +47,9 @@ const item = (kind, over = {}) => ({ id: 1, t: 1, kind, frame: frame({ kind, ...
|
||||
|
||||
// ── The categories ────────────────────────────────────────────────────────
|
||||
|
||||
test('twenty-three categories carry a default title, and playtime carries none (D172)', () => {
|
||||
assert.strictEqual(titles.CATEGORY_IDS.length, 23)
|
||||
test('twenty-four categories carry a default title, and playtime carries none (D172, §11.2.2)', () => {
|
||||
assert.strictEqual(titles.CATEGORY_IDS.length, 24)
|
||||
assert.strictEqual(titles.STATS.richest.title, 'Magnate')
|
||||
assert.ok(!titles.CATEGORY_IDS.includes('playtime'))
|
||||
assert.strictEqual(titles.STATS.playtime.title, null)
|
||||
assert.strictEqual(titles.STATS.animalkills.title, 'Beastbane')
|
||||
|
||||
Reference in New Issue
Block a user