The module's half of PLAN_FIXES §6 step 2 (decisions D181-D185, docs#288). - F13/F14 (D170, D183): `world.expired`, recognisable from protocol 13 by its `what`, is handed to core as the resource the zone step ledgered (`world`, `<serverId>:<id>`) through ctx.events.expired, which records it `expired`. coreApi moves to ^1.11.0 (website#209). - F8 (D184): `plugin.loaded` / `plugin.unloaded` mark the permission sync dirty when the plugin added or removed permissions, so an unresolved grant lands on the next tick instead of the fifteen-minute audit. - Catalogue: plugin.loaded/unloaded, world.expired and lease.expired are staff kinds. The last two were never classified (default deny kept them off public pages); the test now covers every event kind through protocol 13. - F7: permission and title pushes hold while the stored hello says `worldReady: false` (a human's "sync now" does not); a failed or refused permission sync now logs at warn. - F2 (D185): the killfeed names an NPC attacker — a family (Scientist, Bandit guard, Bradley APC…) or the prefab without its variant digits (wolf2 → Wolf). - F5/F6: a link code is asked of the servers that minted one in the last six minutes first, then of the rest, each group in parallel; "unsure" only when one of the minting servers is unreachable. - D182: the admin server list carries the ZoneManager helper's state from the hello, and the servers page says what a missing or failed helper costs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
132 lines
4.7 KiB
JavaScript
132 lines
4.7 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
|
|
// Not while the world is loading — permSync's reason (PLAN_FIXES F7). The first
|
|
// walk's titles push went with the restart sync, before the save had loaded.
|
|
if (state.worldReady === false) 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 }
|