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
129 lines
4.5 KiB
JavaScript
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 }
|