Files
Module-Rust/server/model/titles/titles.js
wtclaude 1b70cef5be feat(rust): chat titles, BetterChat group styles, the voice and popups (phase 17)
PLAN.md §33, D134-D143. Protocol 12.

- Chat titles (D135-D137): per-server rules (stat, top N, text, colour)
  that rank the current wipe, and a mode (first | all | up to N). Worked
  out once in model/titles and read three ways: pushed whole to the game by
  a new titleSync loop (on change, restart or wipe), and on every
  leaderboard row as `titles`. Admin: PUT /servers/:id/titles.
- Group styles (D138, D139): a site group may carry all twelve BetterChat
  fields (rust_perm_group_chat). They ride perm.sync with `expect` from the
  pushed ledger, which gains a value column; a field changed in game is a
  `chat-field` drift row with the game's value, adopted into the style or
  put back. A withdrawn style is one `chat-group` retirement, never for
  `default`, cleared from the ledger only once BetterChat removed it.
- The voice (D140): one fleet setting naming a styled group; news and
  rust.announce chat lines carry its format and the plugin says them with
  no sender. Admin: GET/PUT /voice.
- Popups (D141, D142): rust.announce gains `delivery` (still version 1,
  from rust.options.delivery); each server gains news_delivery beside the
  news switch; `popup-unavailable` is not retried.
- GET /servers/:id/integrations reads, live, which optional mods a server
  has loaded. README lists BetterChat and PopupNotifications as optional.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-25 17:48:22 -05:00

166 lines
6.0 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 stats a rule may rank, and the leaderboard sort and column each means. */
const STATS = {
kills: { sort: 'kills', value: (row) => row.kills, label: 'kills' },
npckills: { sort: 'npcKills', value: (row) => row.npcKills, label: 'NPC kills' },
playtime: { sort: 'playtime', value: (row) => row.playtimeSec, label: 'playtime' },
}
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}`)
const text = cleanText(r.text)
if (!text) 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 } }
}
/**
* Who holds which title.
*
* @param {Array} rules in precedence order: `{ stat, topN, text, color }`
* @param {object} standings stat → leaderboard 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 || []) {
const stat = STATS[rule.stat]
if (!stat) continue
const rows = (standings[rule.stat] || []).filter((row) => Number(stat.value(row)) > 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,
MODES,
MAX_RULES,
MAX_TOP_N,
MAX_UPTO,
TEXT_MAX,
cleanText,
normaliseMode,
validateSettings,
evaluate,
markup,
wireSet,
digest,
}