Files
Module-Rust/server/model/titles/titles.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

250 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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