Files
Module-Rust/server/model/titles/titles.db.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

181 lines
6.4 KiB
JavaScript

// ── SQL for chat titles (phase 17) ────────────────────────────────────────
//
// The rules an operator writes, per server, and the two columns on
// `rust_servers` that say how many titles a player shows. The titles themselves
// are never stored — see `schema.sql` and `titles.js`.
const core = require('../../core')
const RULES = 'rust_title_rules'
const SERVERS = 'rust_servers'
/** One server's rules, in precedence order. */
async function listRules(serverId) {
return core.query(
`SELECT id, stat, top_n AS topN, text, color, profile_id AS profile
FROM ${RULES}
WHERE server_id = ?
ORDER BY position ASC, id ASC`,
[serverId],
)
}
/** Every server's rules, for the admin list — one read rather than one per server. */
async function listAllRules() {
return core.query(
`SELECT server_id AS serverId, stat, top_n AS topN, text, color, profile_id AS profile
FROM ${RULES}
ORDER BY server_id ASC, position ASC, id ASC`,
)
}
async function getMode(serverId) {
const rows = await core.query(
`SELECT title_mode AS mode, title_max AS max FROM ${SERVERS} WHERE id = ?`,
[serverId],
)
return rows[0] || null
}
async function listModes() {
return core.query(`SELECT id AS serverId, title_mode AS mode, title_max AS max FROM ${SERVERS}`)
}
/**
* Replace one server's rules and mode whole. The form edits a list, so the
* write is a list — and a rule's position is its place in that list.
*/
async function saveSettings(serverId, { mode, max, rules }) {
await core.query(`UPDATE ${SERVERS} SET title_mode = ?, title_max = ? WHERE id = ?`, [mode, max, serverId])
await core.query(`DELETE FROM ${RULES} WHERE server_id = ?`, [serverId])
if (!rules.length) return
await core.query(
`INSERT INTO ${RULES} (server_id, position, stat, top_n, text, color, profile_id)
VALUES ${rules.map(() => '(?, ?, ?, ?, ?, ?, ?)').join(',')}`,
rules.flatMap((r, i) => [serverId, i, r.stat, r.topN, r.text, r.color, r.profile || null]),
)
}
// ── The standings a category ranks (PLAN_REDESIGNS §5) ────────────────────
const STATS = 'rust_player_wipe_stats'
const WEAPONS = 'rust_weapon_kills'
const GATHER = 'rust_gather_totals'
const CATEGORIES = 'rust_title_categories'
/** Only these ever reach a column position in SQL: the names `titles.js` declares. */
const COLUMN = /^[a-z_]+$/
/**
* One category's standings for one wipe: `{ steamId, value }` rows, highest
* first, ties broken by who was seen last — the leaderboard's own order, so a
* rule's top N is what the web would show (§33.4 reading 2).
*
* @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, 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})`
else if (source.best) value = `MAX(s.${source.best})`
// Signed: `npc_kills` is unsigned, and a row counted before `animal_kills`
// existed must not wrap round to four billion.
else value = `SUM(CAST(s.${source.diff[0]} AS SIGNED) - CAST(s.${source.diff[1]} AS SIGNED))`
const cols = [source.sum, source.best, ...(source.diff || [])].filter(Boolean)
if (!cols.every((c) => COLUMN.test(c))) throw new Error('not a stats column')
return core.query(
`SELECT s.steam_id AS steamId, ${value} AS value
FROM ${STATS} s
WHERE s.server_id = ? AND s.wipe_id = ?
GROUP BY s.steam_id
ORDER BY value DESC, MAX(s.last_seen) DESC
LIMIT ?`,
[...params, limit],
)
}
const table = source.weapons ? WEAPONS : GATHER
const key = source.weapons ? 'weapon' : 'resource'
const amount = source.weapons ? 'kills' : 'amount'
const names = lists[source.weapons || source.gathered] || []
if (!names.length) return []
// The stats row is joined only for the tie-break; its key is the table's,
// so one row joins at most one and the sum is not multiplied.
return core.query(
`SELECT t.steam_id AS steamId, SUM(t.${amount}) AS value
FROM ${table} t
LEFT JOIN ${STATS} s
ON s.server_id = t.server_id AND s.wipe_id = t.wipe_id AND s.steam_id = t.steam_id
WHERE t.server_id = ? AND t.wipe_id = ? AND t.${key} IN (${names.map(() => '?').join(', ')})
GROUP BY t.steam_id
ORDER BY value DESC, MAX(s.last_seen) DESC
LIMIT ?`,
[...params, ...names, limit],
)
}
/** The admin's category titles (D175), as stat → text. */
async function categoryTitles() {
const rows = await core.query(`SELECT stat, text FROM ${CATEGORIES}`)
return Object.fromEntries(rows.map((r) => [r.stat, r.text]))
}
/** The same, with who changed each and when, for the admin screen. */
async function listCategories() {
return core.query(`SELECT stat, text, updated_by AS updatedBy, updated_at AS updatedAt FROM ${CATEGORIES}`)
}
async function setCategory(stat, text, userId = null) {
await core.query(
`INSERT INTO ${CATEGORIES} (stat, text, updated_by, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP)
ON DUPLICATE KEY UPDATE text = VALUES(text), updated_by = VALUES(updated_by), updated_at = CURRENT_TIMESTAMP`,
[stat, text, userId],
)
}
/** Reset: the row goes, and the module's default is back. */
async function clearCategory(stat) {
await core.query(`DELETE FROM ${CATEGORIES} WHERE stat = ?`, [stat])
}
module.exports = {
RULES,
listRules,
listAllRules,
getMode,
listModes,
saveSettings,
standings,
categoryTitles,
listCategories,
setCategory,
clearCategory,
}