Files
Module-Rust/server/model/titles/titles.model.js
wtclaude 845caef817
All checks were successful
PR Checks / server-tests (pull_request) Successful in 31s
PR Checks / client-build (pull_request) Successful in 29s
PR Checks / frozen-manifest (pull_request) Successful in -1m29s
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
2026-10-12 07:07:05 -05:00

164 lines
5.6 KiB
JavaScript

// ── Chat titles, read against the live standings (phase 17) ────────────────
//
// `titles.js` decides; this file fetches what it decides from. One answer per
// server serves three readers — the push to the game, the web leaderboard and
// 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')
const titles = require('./titles')
const npcs = require('../npcs/npcs.model')
/** How long one server's answer is reused. The same as the push loop's tick. */
const MEMO_MS = 30 * 1000
const memo = new Map()
/** A server's settings as the admin form reads them. */
async function settingsFor(serverId) {
const [mode, rules] = await Promise.all([db.getMode(serverId), db.listRules(serverId)])
return shapeSettings(mode, rules)
}
function shapeSettings(mode, rules) {
return {
mode: titles.normaliseMode(mode && mode.mode),
max: mode && Number(mode.max) ? Number(mode.max) : 2,
rules: rules.map(rule),
}
}
/** One stored rule as the form and the evaluator read it; a profile's kills name the profile (D250). */
function rule(r) {
return { stat: r.stat, topN: Number(r.topN), text: r.text, color: r.color, ...(r.stat === 'profilekills' ? { profile: Number(r.profile) || null } : {}) }
}
/** Every server's settings, keyed by id, for the admin server list. */
async function settingsByServer() {
const [modes, rules] = await Promise.all([db.listModes(), db.listAllRules()])
const out = new Map(modes.map((m) => [m.serverId, shapeSettings(m, [])]))
for (const r of rules) {
const s = out.get(r.serverId)
if (s) s.rules.push(rule(r))
}
return out
}
async function saveSettings(serverId, value) {
await db.saveSettings(serverId, value)
forget(serverId)
}
/** Forgets one server's answer, or every server's when a category was renamed. */
function forget(serverId = null) {
for (const key of memo.keys()) if (serverId === null || key.startsWith(`${serverId} `)) memo.delete(key)
}
/**
* The categories as the admin screen shows them (D175): each one's label, the
* shipped default, the admin's own title if any, and what rules show now.
*/
async function categories() {
const own = new Map((await db.listCategories()).map((r) => [r.stat, r]))
return titles.CATEGORY_IDS.map((stat) => {
const row = own.get(stat)
const def = titles.STATS[stat]
return {
stat,
label: def.label,
default: def.title,
text: row ? row.text : null,
current: row ? row.text : def.title,
updatedAt: row ? row.updatedAt : null,
}
})
}
/**
* Sets or resets one category's title. An empty text is the Reset. Every
* server's answer is forgotten, because every server's rules may use it, and
* the next push carries the new name (§4.6: "from the next push").
*/
async function setCategory(stat, text, userId = null) {
if (text) await db.setCategory(stat, text, userId)
else await db.clearCategory(stat)
forget()
}
/**
* Who holds which title on one server right now: a Map of Steam id to
* `[{ text, color }]`, after the server's mode. Empty with no rules, and empty
* with no current wipe — a title ranks the current wipe (D135), and a server that
* has never said which wipe it is on has none to rank.
*/
async function heldFor(serverId, { wipeId, now = Date.now() } = {}) {
if (!wipeId) return new Map()
const key = `${serverId} ${wipeId}`
const hit = memo.get(key)
if (hit && now - hit.at < MEMO_MS) return hit.held
const [settings, overrides] = await Promise.all([settingsFor(serverId), db.categoryTitles()])
const rules = titles.resolveRules(settings.rules, overrides)
const standings = {}
// One query per STAT, at the deepest top N any rule on it asks for, rather
// than one per rule: two rules on kills read the same rows.
// A profile's kills are one standing per profile (`titles.standingKey`).
const depth = new Map()
const ruleBy = new Map()
for (const r of rules) {
if (!titles.STATS[r.stat]) continue
const key = titles.standingKey(r)
depth.set(key, Math.max(depth.get(key) || 0, r.topN))
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, 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 }))
}),
)
const held = titles.evaluate(rules, standings, settings)
memo.set(key, { at: now, held })
return held
}
/** The same, for a server by id, reading its current wipe from its last report. */
async function currentFor(serverId) {
const state = await serversDb.getState(serverId)
return heldFor(serverId, { wipeId: state && state.wipeId ? state.wipeId : null })
}
module.exports = {
MEMO_MS,
settingsFor,
settingsByServer,
saveSettings,
heldFor,
currentFor,
forget,
categories,
setCategory,
}