- Schema: fourteen title columns on rust_player_wipe_stats (the two best_* distances move by GREATEST, the rest are sums), rust_weapon_kills (kills by weapon prefab name) and rust_title_categories (the admin's title per category). purge.sql drops the two tables. - Ingest: player.tally's new fields, in one statement per frame, none for an older plugin's frame. - Titles: 23 categories plus playtime, each naming where it is read from (a sum, a MAX, npc_kills - animal_kills in signed arithmetic, or a named list). The bow/melee/blade/revolver/wood/ore/plants lists are one file, applied when a title is read. "NPC kills" ranks human NPCs only (D209). - A rule's text may be empty, meaning the category's title: the rule's own, then the admin's, then the default. Typed-only-markup is still refused. A rename forgets every server's cached answer. - Admin API: GET /admin/rust/title-categories and PUT /admin/rust/title-categories/:stat (empty text resets); the server list returns them too. Swagger fragment and routes.manifest.json regenerated (63 routes, against the pinned core). - Screen: every category in the rule form, the category's title as a placeholder, and a Category titles section with Save and Reset. The weapon lists are unverified until the rig probe runs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
163 lines
5.6 KiB
JavaScript
163 lines
5.6 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
|
|
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,
|
|
}
|