// ── 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 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 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) VALUES ${rules.map(() => '(?, ?, ?, ?, ?, ?)').join(',')}`, rules.flatMap((r, i) => [serverId, i, r.stat, r.topN, r.text, r.color]), ) } // ── 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 }) { const params = [serverId, wipeId] 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, }