Files
Module-Rust/server/model/titles/titles.db.js
wtclaude aea94b5d7a feat(rust): chat titles rank twenty-three conditions, and admins name the categories (PLAN_REDESIGNS §5, D172-D175, D209)
- 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
2026-09-28 23:32:59 -05:00

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