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
This commit is contained in:
@@ -58,4 +58,105 @@ async function saveSettings(serverId, { mode, max, rules }) {
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = { RULES, listRules, listAllRules, getMode, listModes, saveSettings }
|
||||
// ── 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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user