- 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
250 lines
11 KiB
JavaScript
250 lines
11 KiB
JavaScript
// ── Chat titles, as rules and as a set (phase 17, D135–D137) ───────────────
|
||
//
|
||
// An operator writes rules per server — "#1 kills", "top 3 playtime" — and the
|
||
// site works out who holds each title from the CURRENT wipe's standings. The
|
||
// same answer goes to three places: BetterChat in the game (`titles.set`), the
|
||
// web leaderboard and the app's. So it is worked out here, once, as pure
|
||
// functions of the rules and the standings, and every consumer reads it.
|
||
//
|
||
// Four readings shape it (§33.4):
|
||
//
|
||
// 1. a rule counts only a stat above zero — a fresh wipe gives no titles,
|
||
// rather than #1 kills to somebody with none
|
||
// 2. ties follow the leaderboard's own order, so a rule's top N is the first
|
||
// N rows the web shows for that stat
|
||
// 3. a title's text is at most 24 characters, with markup characters taken
|
||
// out, and its colour is `#rrggbb`
|
||
// 4. the rule order is precedence: `first` shows the first rule a player meets
|
||
|
||
const crypto = require('node:crypto')
|
||
|
||
/**
|
||
* The categories a rule may rank (PLAN_REDESIGNS §5, D172): the twenty-three
|
||
* conditions and their shipped titles, plus `playtime`, which predates them and
|
||
* has no default title, so a playtime rule always carries its own text.
|
||
*
|
||
* `source` says where the standing is read from (`titles.db.js`):
|
||
* `sum` a column of `rust_player_wipe_stats`, summed
|
||
* `diff` one column less another — human NPCs are every NPC kill less the
|
||
* animals (§5.2, D209), so the public NPC column keeps its meaning
|
||
* `best` a column's MAX: the longest single kill (D174)
|
||
* `weapons` a named list's kills in `rust_weapon_kills` (§5.3)
|
||
* `gathered` a named list's amount in `rust_gather_totals` (D159)
|
||
*
|
||
* An admin may rename any category that has a default (D175); the ids are what
|
||
* a rule stores, so they never change.
|
||
*/
|
||
const STATS = {
|
||
kills: { label: 'Players killed', title: 'Reaper', source: { sum: 'kills' } },
|
||
npckills: { label: 'NPC kills', title: 'Exterminator', source: { diff: ['npc_kills', 'animal_kills'] } },
|
||
animalkills: { label: 'Animal kills', title: 'Beastbane', source: { sum: 'animal_kills' } },
|
||
bowkills: { label: 'Bow kills', title: 'Fletcher', source: { weapons: 'bow' } },
|
||
meleekills: { label: 'Melee kills', title: 'Brawler', source: { weapons: 'melee' } },
|
||
bladekills: { label: 'Blade kills', title: 'Duelist', source: { weapons: 'blade' } },
|
||
revolverkills: { label: 'Revolver kills', title: 'Outlaw', source: { weapons: 'revolver' } },
|
||
headshots: { label: 'Headshots', title: 'Crownbreaker', source: { sum: 'headshots' } },
|
||
pvpdistance: { label: 'Longest player kill', title: 'Longshot', source: { best: 'best_pvp_m' } },
|
||
pvedistance: { label: 'Longest NPC or animal kill', title: 'Marksman', source: { best: 'best_pve_m' } },
|
||
apckills: { label: 'APC kills', title: 'Tankbreaker', source: { sum: 'apc_kills' } },
|
||
helikills: { label: 'Helicopter kills', title: 'Rotorbane', source: { sum: 'heli_kills' } },
|
||
wood: { label: 'Wood gathered', title: 'Timberwright', source: { gathered: 'wood' } },
|
||
ore: { label: 'Ore gathered', title: 'Prospector', source: { gathered: 'ore' } },
|
||
plants: { label: 'Plants gathered', title: 'Greenhand', source: { gathered: 'plants' } },
|
||
attire: { label: 'Clothes crafted', title: 'Seamster', source: { sum: 'crafted_attire' } },
|
||
weapons: { label: 'Weapons crafted', title: 'Armorer', source: { sum: 'crafted_weapons' } },
|
||
built: { label: 'Structures built', title: 'Mason', source: { sum: 'built' } },
|
||
repaired: { label: 'Structures repaired', title: 'Tinkerer', source: { sum: 'repaired' } },
|
||
healed: { label: 'Players healed', title: 'Mender', source: { sum: 'healed' } },
|
||
rockets: { label: 'Rockets fired', title: 'Artillerist', source: { sum: 'rockets' } },
|
||
explosives: { label: 'Explosives thrown', title: 'Demolitionist', source: { sum: 'explosives' } },
|
||
missions: { label: 'Missions completed', title: 'Wayfarer', source: { sum: 'missions' } },
|
||
playtime: { label: 'Playtime', title: null, source: { sum: 'playtime_sec' } },
|
||
}
|
||
|
||
/** The categories an admin can rename: every one with a shipped title (D175). */
|
||
const CATEGORY_IDS = Object.keys(STATS).filter((id) => STATS[id].title)
|
||
|
||
const MODES = ['first', 'all', 'upto']
|
||
|
||
const MAX_RULES = 10
|
||
const MAX_TOP_N = 10
|
||
const TEXT_MAX = 24
|
||
const MAX_UPTO = 5
|
||
|
||
/**
|
||
* An operator's title text, made safe to put in a chat line: the characters
|
||
* BetterChat's markup and its placeholders are built from are taken out, then
|
||
* the whitespace is collapsed. `{` and `}` go too, beyond reading 3's four: a
|
||
* title is substituted into `{Title}` before `{Message}` is, so a title reading
|
||
* `{Message}` would print the player's words twice.
|
||
*/
|
||
function cleanText(raw) {
|
||
return String(raw === undefined || raw === null ? '' : raw)
|
||
.replace(/[[\]<>{}]/g, '')
|
||
.replace(/\s+/g, ' ')
|
||
.trim()
|
||
}
|
||
|
||
/** A mode word, or `first` — the fewest — for one this build does not know. */
|
||
function normaliseMode(mode) {
|
||
return MODES.includes(mode) ? mode : 'first'
|
||
}
|
||
|
||
/**
|
||
* A server's title settings as the admin form sends them, checked whole.
|
||
*
|
||
* Resolves `{ ok: true, value: { mode, max, rules } }` with every rule cleaned,
|
||
* or `{ ok: false, errors }`, one sentence per problem. A text that is empty
|
||
* AFTER cleaning is refused rather than saved blank, so a title made entirely of
|
||
* brackets is a sentence on the form and not an empty chip in the game.
|
||
*/
|
||
function validateSettings(body) {
|
||
const errors = []
|
||
const input = body || {}
|
||
const mode = input.mode === undefined ? 'first' : input.mode
|
||
|
||
if (!MODES.includes(mode)) errors.push(`mode must be one of ${MODES.join(', ')}`)
|
||
|
||
const max = input.max === undefined || input.max === null || input.max === '' ? 2 : Number(input.max)
|
||
if (!Number.isInteger(max) || max < 1 || max > MAX_UPTO) errors.push(`max must be a whole number from 1 to ${MAX_UPTO}`)
|
||
|
||
const rules = Array.isArray(input.rules) ? input.rules : null
|
||
if (!rules) errors.push('rules must be a list')
|
||
else if (rules.length > MAX_RULES) errors.push(`a server has at most ${MAX_RULES} title rules`)
|
||
|
||
const clean = []
|
||
|
||
for (const [i, rule] of (rules || []).entries()) {
|
||
const n = i + 1
|
||
const r = rule || {}
|
||
if (!STATS[r.stat]) errors.push(`rule ${n}: stat must be one of ${Object.keys(STATS).join(', ')}`)
|
||
|
||
const topN = Number(r.topN)
|
||
if (!Number.isInteger(topN) || topN < 1 || topN > MAX_TOP_N) errors.push(`rule ${n}: top must be from 1 to ${MAX_TOP_N}`)
|
||
|
||
// Empty means "the category's title" (D175) — for a category that has one.
|
||
// A text that was typed and cleaned down to nothing is still refused: a
|
||
// title made entirely of brackets is a mistake, not a request for the default.
|
||
const typed = String(r.text === undefined || r.text === null ? '' : r.text).trim()
|
||
const text = cleanText(r.text)
|
||
const stat = STATS[r.stat]
|
||
if (!text && (typed || !stat || !stat.title)) errors.push(`rule ${n}: the title needs some text`)
|
||
else if (text.length > TEXT_MAX) errors.push(`rule ${n}: a title is at most ${TEXT_MAX} characters`)
|
||
|
||
const color = String(r.color || '').trim().toLowerCase()
|
||
if (!/^#[0-9a-f]{6}$/.test(color)) errors.push(`rule ${n}: colour must look like #ffaa55`)
|
||
|
||
clean.push({ stat: r.stat, topN, text, color })
|
||
}
|
||
|
||
return errors.length ? { ok: false, errors } : { ok: true, value: { mode, max, rules: clean } }
|
||
}
|
||
|
||
/**
|
||
* The title a category shows (D175): the admin's, if they wrote one, else the
|
||
* shipped default. `overrides` is stat → the admin's text.
|
||
*/
|
||
function categoryTitle(stat, overrides = {}) {
|
||
const own = overrides[stat]
|
||
if (own) return own
|
||
return STATS[stat] ? STATS[stat].title : null
|
||
}
|
||
|
||
/**
|
||
* Checks an admin's title for a category. Resolves `{ ok, text }` or
|
||
* `{ ok: false, error }`. An empty text is not an error here — it is the Reset,
|
||
* and the caller deletes the row.
|
||
*/
|
||
function validateCategory(stat, raw) {
|
||
if (!CATEGORY_IDS.includes(stat)) return { ok: false, error: `no category ${stat}` }
|
||
const typed = String(raw === undefined || raw === null ? '' : raw).trim()
|
||
const text = cleanText(raw)
|
||
if (typed && !text) return { ok: false, error: 'the title needs some text' }
|
||
if (text.length > TEXT_MAX) return { ok: false, error: `a title is at most ${TEXT_MAX} characters` }
|
||
return { ok: true, text }
|
||
}
|
||
|
||
/**
|
||
* Rules with their text settled, first that is set winning (§4.6): the rule's
|
||
* own, the admin's for the category, the module's default. A rule that ends up
|
||
* with none — only possible for a rule saved before its category lost a title —
|
||
* is dropped rather than shown as an empty chip.
|
||
*/
|
||
function resolveRules(rules, overrides = {}) {
|
||
return (rules || [])
|
||
.map((r) => ({ ...r, text: r.text || categoryTitle(r.stat, overrides) || '' }))
|
||
.filter((r) => r.text)
|
||
}
|
||
|
||
/**
|
||
* Who holds which title.
|
||
*
|
||
* @param {Array} rules in precedence order: `{ stat, topN, text, color }`,
|
||
* with the text already settled (`resolveRules`)
|
||
* @param {object} standings stat → `{ steamId, value }` rows for the current
|
||
* wipe, in the leaderboard's order, at least `topN` long
|
||
* @param {object} options
|
||
* @param {string} options.mode `first` · `all` · `upto`
|
||
* @param {number} options.max how many `upto` shows
|
||
* @returns {Map<string, Array<{text, color}>>} Steam id → titles, in rule order
|
||
*/
|
||
function evaluate(rules, standings, { mode = 'first', max = 2 } = {}) {
|
||
const held = new Map()
|
||
|
||
for (const rule of rules || []) {
|
||
if (!STATS[rule.stat]) continue
|
||
|
||
const rows = (standings[rule.stat] || []).filter((row) => Number(row.value) > 0).slice(0, rule.topN)
|
||
|
||
for (const row of rows) {
|
||
if (!held.has(row.steamId)) held.set(row.steamId, [])
|
||
held.get(row.steamId).push({ text: rule.text, color: rule.color })
|
||
}
|
||
}
|
||
|
||
const keep = { first: 1, upto: max, all: Infinity }[normaliseMode(mode)]
|
||
|
||
for (const [steamId, list] of held) held.set(steamId, list.slice(0, keep))
|
||
|
||
return held
|
||
}
|
||
|
||
/** One player's titles as BetterChat markup: `[#hex]text[/#]`, space-separated. */
|
||
function markup(list) {
|
||
return list.map((t) => `[#${t.color.replace(/^#/, '')}]${t.text}[/#]`).join(' ')
|
||
}
|
||
|
||
/** The wire set, sorted so an unchanged set digests the same on every tick. */
|
||
function wireSet(held) {
|
||
return [...held.entries()]
|
||
.map(([steamId, list]) => ({ steamId, text: markup(list) }))
|
||
.sort((a, b) => a.steamId.localeCompare(b.steamId))
|
||
}
|
||
|
||
function digest(set) {
|
||
return crypto
|
||
.createHash('sha256')
|
||
.update(set.map((t) => `${t.steamId} ${t.text}`).join('\n'))
|
||
.digest('hex')
|
||
}
|
||
|
||
module.exports = {
|
||
STATS,
|
||
CATEGORY_IDS,
|
||
categoryTitle,
|
||
validateCategory,
|
||
resolveRules,
|
||
MODES,
|
||
MAX_RULES,
|
||
MAX_TOP_N,
|
||
MAX_UPTO,
|
||
TEXT_MAX,
|
||
cleanText,
|
||
normaliseMode,
|
||
validateSettings,
|
||
evaluate,
|
||
markup,
|
||
wireSet,
|
||
digest,
|
||
}
|