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

129 lines
4.5 KiB
JavaScript

// ── Keeping each game's chat titles equal to the standings (phase 17) ─────
//
// The plugin holds minute tallies, not standings (§33.1): who is top of the
// wipe is known here, in `rust_player_wipe_stats`, and nowhere in the game. So
// a title is worked out on the site and PUSHED, as one whole set the plugin
// swaps in, and BetterChat's callback reads what was pushed.
//
// The same shape as `permSync.js`, smaller: every tick asks whether the set this
// server should hold still digests to what it last took, and does nothing when
// it does. A push happens when:
//
// • the standings moved somebody into or out of a title
// • an operator changed a rule, the mode or N
// • the game restarted (a new boot id — the plugin holds titles in memory only)
// • the wipe changed (a new wipe id — the titles rank the current wipe)
// • the last attempt failed
//
// What was sent is remembered in memory, not in a table. Forgetting it on a
// restart of this module costs one push per server, which the plugin answers by
// swapping in the same set.
//
// A server whose plugin is older than protocol 12 is skipped rather than asked:
// its sidecar has no `/titles`, and its plugin would not answer.
const core = require('./core')
const client = require('./sidecarClient')
const servers = require('./model/servers/servers.model')
const serversDb = require('./model/servers/servers.db')
const titles = require('./model/titles/titles')
const model = require('./model/titles/titles.model')
const log = core.logger('titles')
/** How often the loop asks whether anything needs pushing. */
const TICK_MS = 30 * 1000
/** The first protocol whose plugin holds titles. */
const TITLES_PROTOCOL = 12
/** Per server: `{ digest, bootId, wipeId, betterChat, count, at }` of the last set a game took. */
const sent = new Map()
let timer = null
function start() {
if (timer) return
timer = setInterval(() => {
tick().catch((err) => log.error('title sync tick failed', { error: err.message }))
}, TICK_MS)
if (timer.unref) timer.unref()
}
function stop() {
if (!timer) return
clearInterval(timer)
timer = null
}
async function tick() {
const [rows, states] = await Promise.all([servers.listForPolling(), serversDb.listState()])
const stateById = new Map(states.map((s) => [s.serverId, s]))
await Promise.allSettled(rows.map((server) => syncOne(server, stateById.get(server.id) || null)))
}
/** Whether this server needs the set again, and why — worth a log line either way. */
function reasonToPush({ digest, state, last }) {
if (!last) return 'first'
if (digest !== last.digest) return 'changed'
if (state.bootId && state.bootId !== last.bootId) return 'restart'
if (state.wipeId && state.wipeId !== last.wipeId) return 'wipe'
return null
}
async function syncOne(server, state) {
if (!state || !state.online || Number(state.protocol) < TITLES_PROTOCOL) return null
const held = await model.heldFor(server.id, { wipeId: state.wipeId || null })
const set = titles.wireSet(held)
const digest = titles.digest(set)
const reason = reasonToPush({ digest, state, last: sent.get(server.id) })
if (!reason) return null
const result = await client.titles(server, { setId: digest, titles: set })
const data = (result && result.data) || {}
if (!result.ok || data.kind !== 'titles.ok') {
// Forgotten, so the next tick tries again whatever the digest says.
sent.delete(server.id)
log.warn('title push failed', {
server: server.id,
reason,
status: result.status,
...(data.kind === 'titles.error' ? { refused: data.reason, message: data.message } : {}),
})
return 'failed'
}
sent.set(server.id, {
digest,
bootId: state.bootId || null,
wipeId: state.wipeId || null,
betterChat: data.betterChat === true,
count: Number(data.count) || 0,
at: new Date().toISOString(),
})
log.info('titles pushed', { server: server.id, reason, count: data.count, betterChat: data.betterChat === true })
return 'ok'
}
/** What the last push to one server found, for the admin page, or null. */
function lastPush(serverId) {
const last = sent.get(serverId)
return last ? { count: last.count, betterChat: last.betterChat, at: last.at } : null
}
/** Forget one server's last push, so the next tick sends its set whatever the digest says. */
function invalidate(serverId) {
sent.delete(serverId)
}
module.exports = { TICK_MS, TITLES_PROTOCOL, start, stop, tick, syncOne, reasonToPush, lastPush, invalidate }