Files
Module-Rust/server/model/titles/titles.js
wtclaude da1d74bdff
All checks were successful
PR Checks / client-build (pull_request) Successful in 30s
PR Checks / server-tests (pull_request) Successful in 34s
PR Checks / frozen-manifest (pull_request) Successful in -1m34s
feat(rust): Guarded Crate — feed, map, Warden, Place a guarded crate (PLAN_REDESIGNS §14.3.5)
Stage 6 of the §14.3 integration plan, on protocol 14. Spec: docs
rust-link/PROTOCOL.md §20.9.

- gc.started and gc.ended public, gc.failed staff's; guardedCrate.js reads
  each row per viewer (the clearer's name for the presence audience, Steam id,
  position and key for staff, `won` for everyone).
- Triggers rust.gc.started and rust.gc.ended (a crate a player cleared only).
- The step rust.gc.place: an event from rust.options.gc_events and a world
  call's location; refused at verify for Guarded Crate absent, NpcSpawn missing
  (D364), console unavailable, or an unknown event. Kept (D373); the run log's
  success line says so (D380). Budget rust.gc.crates.
- Warden (D375): guarded_won, title category guardedwon, Crates cleared column.
- Map marker, server settings line, swagger, engagement manifest, bundle, README.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-15 12:26:50 -05:00

282 lines
13 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, the richest (§11.2.2, Magnate), the
* two world-event counts (§14.3.1, Safecracker and Scavenger), Dangerous
* Treasures won (§14.3.4, Treasure Hunter), guarded crates cleared (§14.3.5,
* Warden), 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)
* `balance` the Economics balance, a value per wipe (PLAN_REDESIGNS §11.2.2):
* ranked only above the server's starting balance (D336), and
* only on a server that reports Economics
* `npcProfile` one site NPC profile's kills, counted as the profile says
* (runicnpc stage 4, D247, D250). A rule on it names the profile,
* and carries its own text, like playtime.
*
* 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' } },
// Protocol 14, Rust's own world events (D356): a hack counts for its starter, once per crate.
crateshacked: { label: 'Locked crates hacked', title: 'Safecracker', source: { sum: 'crates_hacked' } },
dropslooted: { label: 'Airdrops looted first', title: 'Scavenger', source: { sum: 'drops_looted' } },
// Protocol 14, Dangerous Treasures (D375): a chest counts for the player who emptied it.
treasureswon: { label: 'Dangerous Treasures won', title: 'Treasure Hunter', source: { sum: 'treasures_won' } },
// Protocol 14, Guarded Crate (D375): a crate counts for the player who killed its last guard.
guardedwon: { label: 'Guarded crates cleared', title: 'Warden', source: { sum: 'guarded_won' } },
richest: { label: 'Richest (Economics)', title: 'Magnate', source: { balance: 'balance' } },
playtime: { label: 'Playtime', title: null, source: { sum: 'playtime_sec' } },
profilekills: { label: 'Kills of an NPC profile', title: null, source: { npcProfile: true } },
}
/**
* The standing a rule ranks by. One per stat, except a profile's kills, which
* are one per profile: two rules on two profiles rank two different boards.
*/
function standingKey(rule) {
return rule && rule.stat === 'profilekills' ? `profilekills:${rule.profile}` : rule && rule.stat
}
/** 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`)
// D250: a profile's kills name the site profile ranked.
const profile = r.stat === 'profilekills' ? Number(r.profile) : null
if (r.stat === 'profilekills' && !(Number.isInteger(profile) && profile > 0)) errors.push(`rule ${n}: pick the NPC profile whose kills it ranks`)
clean.push({ stat: r.stat, topN, text, color, ...(r.stat === 'profilekills' ? { profile } : {}) })
}
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[standingKey(rule)] || []).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,
standingKey,
CATEGORY_IDS,
categoryTitle,
validateCategory,
resolveRules,
MODES,
MAX_RULES,
MAX_TOP_N,
MAX_UPTO,
TEXT_MAX,
cleanText,
normaliseMode,
validateSettings,
evaluate,
markup,
wireSet,
digest,
}