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