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