Files
Module-Rust/server/model/economics/economics.model.js
wtclaude 3aa142bf06
All checks were successful
PR Checks / client-build (pull_request) Successful in 24s
PR Checks / server-tests (pull_request) Successful in 32s
PR Checks / frozen-manifest (pull_request) Successful in 44s
fix(rust): the last fixes' small ones (W3 W6 W7/W11 W8 W10 W13-W18)
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
2026-10-21 07:42:24 -05:00

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