Stage 2 of PLAN_LAST_FIXES (docs#366). - W7/W11: an "is it loaded" refusal is confirmed live before it is given. New liveStatus.js asks the sidecar once (boot.refreshOne, as a poll tick would), at most once per server per 5 s and waiting at most 3 s, then reads the stored status again. Used by Economics (step and staff page), Backpacks, Dangerous Treasures, Guarded Crate, RaidableBases, ZoneDomes, TruePVE and RunicNPC. - W3: a world call answered `already-out` (the bridge half comes in stage 3) succeeds as called 0, saying a Travelling Vendor is already out. - W6: "A locked crate dropped by a Chinook is being hacked." - W8: a RaidableBases profile's audit row names RaidableBases, not "@data". - W10: the backpack sizes picker lists each size once, naming its servers. - W13: a RaidableBases name both servers describe differently shows each server's own description. - W14: each RaidableBases note after the first starts its own sentence. - W15: a staff deposit refused at Economics' limit answers 409. - W16: a payment's failedNote explains only the reasons that happened. - W17: the sync state counts player grants that did not land (waitingToLand), and the Permissions page says "in sync · 1 waits for their player to join" instead of plain "in sync". - W18: Guarded Crate's kept note drops the "30 minutes" it could not know. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
223 lines
8.6 KiB
JavaScript
223 lines
8.6 KiB
JavaScript
// ── 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 live = require('../../liveStatus')
|
|
|
|
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' }
|
|
|
|
// Confirmed live before it refuses: the stored status lags a restart (W7).
|
|
const econ = await live.confirmed(server, () => forServer(server.id), (e) => !e.loaded)
|
|
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 })
|
|
// The game saying no is a 409, as `insufficient` is above: the request was
|
|
// well formed, and the balance it met refused it (W15).
|
|
return {
|
|
ok: false,
|
|
status: data.reason === 'economics-missing' || data.reason === 'at-limit' ? 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,
|
|
}
|