// ── Pushing the site's NPC profiles to each server (runicnpc stage 4) ────── // // The site's profiles replace each server's RunicNPC profile set whole, as the // permission sync replaces a server's permissions, and the server is then // managed by this site: RunicNPC refuses in-game profile edits (D221). // // **Before the FIRST push to a server, its own profiles are adopted** (D244): // read, imported as profiles for that server alone, and only then pushed back, // so nothing on the server changes and its placements keep spawning. Where a // site profile of the same name already covers it, the site's wins and the // server's own is kept aside as replaced (D251). // // A tick asks each server whether it needs a push — its first, a change to its // set, a restart, a failure worth retrying, or the quarter-hourly audit — and // pushes only then. A server without RunicNPC (or with one older than API 4) is // recorded `absent` and left alone; its events keep Rust's own scientists // (D243). const core = require('./core') const db = require('./model/npcs/npcs.db') const model = require('./model/npcs/npcs.model') const servers = require('./model/servers/servers.model') const sidecar = require('./sidecarClient') const log = core.logger('npcs') const TICK_MS = 30 * 1000 const AUDIT_MS = 15 * 60 * 1000 const FAIL_BACKOFF_MS = 2 * 60 * 1000 let timer = null let lock = Promise.resolve() /** One push at a time: an adoption racing a push would push before it imported. */ function withLock(fn) { const run = lock.then(fn, fn) lock = run.catch(() => {}) return run } function start() { if (timer) return timer = setInterval(() => { tick().catch((err) => log.error('NPC profile push tick failed', { error: err.message })) }, TICK_MS) if (timer.unref) timer.unref() } function stop() { if (!timer) return clearInterval(timer) timer = null } function age(value) { const at = value ? new Date(value).getTime() : NaN return Number.isFinite(at) ? Date.now() - at : Number.MAX_SAFE_INTEGER } /** Why this server needs a push now, or null. */ function reasonToPush({ server, sync, hash, force }) { if (force) return 'requested' if (server.worldReady === false) return null if (server.online === false) return null if (!sync || !sync.adoptedAt) return 'first' if (sync.state === 'failed' && age(sync.lastAttemptAt) < FAIL_BACKOFF_MS && sync.syncedHash) return null if (sync.state !== 'ok') return 'retry' if (hash !== sync.syncedHash) return 'changed' if (server.bootId && server.bootId !== sync.bootId) return 'restart' if (age(sync.lastAttemptAt) >= AUDIT_MS) return 'audit' return null } async function tick({ force = null } = {}) { const [polling, npcServers, syncRows] = await Promise.all([servers.listForPolling(), db.listNpcServers(), db.listSync()]) const syncBy = new Map(syncRows.map((s) => [s.serverId, s])) const npcBy = new Map(npcServers.map((s) => [s.id, s])) const results = await Promise.allSettled( polling .filter((server) => force === null || force === server.id) .map((server) => withLock(() => pushOne(server, { npc: npcBy.get(server.id) || null, sync: syncBy.get(server.id) || null, force: force !== null }))), ) return results.map((r) => (r.status === 'fulfilled' ? r.value : { error: r.reason && r.reason.message })) } /** * One server: adopt its own profiles if the site never has (D244), then push * its set. Returns what happened, for the admin's "push now" and the tests. */ async function pushOne(server, { npc, sync, force = false }) { if (!model.npcReady(npc)) { if (!sync || sync.state !== 'absent') await db.putSync(server.id, { state: 'absent', error: model.npcAbsence(npc) }) return { server: server.id, outcome: 'absent', reason: model.npcAbsence(npc) } } const [profiles, factions] = await Promise.all([db.listProfiles(), db.listFactions()]) const first = model.desiredFor(server.id, profiles, factions) const reason = reasonToPush({ server: npc, sync, hash: first.hash, force }) if (!reason) return { server: server.id, outcome: 'current' } let adopted = null if (!sync || !sync.adoptedAt) { const read = await sidecar.npcProfiles(server) const data = read.ok ? read.data || {} : null if (!data || data.kind !== 'npc.profiles') { const error = data ? data.message || data.reason || `answered ${data.kind}` : `could not read its profiles (${read.status})` await db.putSync(server.id, { state: 'failed', error }) log.warn('could not read a server\'s own NPC profiles', { server: server.id, error }) return { server: server.id, outcome: 'failed', error } } // A server a site already manages has nothing of its own left to adopt: its // file is some site's last push. Only a standalone server's are its own. adopted = data.managed ? [] : await model.adopt(server.id, data.profiles || {}) // Stage 5 (D254): its own faction table too, pair by pair, the site's winning. const adoptedFactions = data.managed ? [] : await model.adoptFactions(data.factions) if (data.managed) await db.markAdopted(server.id) if (adopted.length) log.info('adopted a server\'s own NPC profiles', { server: server.id, adopted }) if (adoptedFactions.length) log.info('adopted a server\'s own faction pairs', { server: server.id, pairs: adoptedFactions.length }) if (adoptedFactions.length) adopted.push(...adoptedFactions.map((f) => ({ name: `${f.a}↔${f.b}`, outcome: 'faction' }))) } const desired = adopted && adopted.length ? model.desiredFor(server.id, await db.listProfiles(), await db.listFactions()) : first const pushed = await sidecar.npcProfilesSet(server, desired.profiles, desired.factions) const data = pushed.ok ? pushed.data || {} : null if (!data || data.kind !== 'npc.ok') { const error = data ? data.message || data.reason || `answered ${data.kind}` : `the push did not arrive (${pushed.status})` await db.putSync(server.id, { state: 'failed', error }) log.warn('NPC profile push failed', { server: server.id, reason, error }) return { server: server.id, outcome: 'failed', error, ...(adopted ? { adopted } : {}) } } const refused = data.refused && typeof data.refused === 'object' ? { ...data.refused } : {} // A faction table RunicNPC refused is kept aside there, and the old one kept; it says why. if (data.factionsRefused) refused['(factions)'] = String(data.factionsRefused) await db.putSync(server.id, { state: 'ok', syncedHash: desired.hash, bootId: npc.bootId, pushed: desired.map, refused }) log.info('pushed NPC profiles', { server: server.id, reason, profiles: Object.keys(desired.profiles).length, refused: Object.keys(refused).length }) return { server: server.id, outcome: 'pushed', reason, profiles: Object.keys(desired.profiles), refused, ...(adopted ? { adopted } : {}) } } module.exports = { TICK_MS, AUDIT_MS, start, stop, tick, pushOne, reasonToPush }