Files
Module-Rust/server/titleSync.js
wtclaude b10f11b057
Some checks failed
PR Checks / client-build (pull_request) Successful in 18s
PR Checks / frozen-manifest (pull_request) Failing after 56s
PR Checks / server-tests (pull_request) Successful in 7m45s
fix(rust): protocol 13 step 2 — expiry, plugin loads, the loading hold, NPC names, the link fleet (F2 F5 F6 F7 F8 F13 F14)
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
2026-09-26 21:35:46 -05:00

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 }