feat(rust): RunicNPC profiles, their push and adoption, per-profile kills (runicnpc stage 4, WIP)
Schema for site NPC profiles (per server, shared or fleet), the per-server push record, and kills by profile. The push adopts a server's own profiles before its first push (D244), keeping one whose name a site profile already has as replaced (D251). The tally's npcProfileKills are stored per profile and credited to the site profile pushed under that name (D247). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
135
server/npcSync.js
Normal file
135
server/npcSync.js
Normal file
@@ -0,0 +1,135 @@
|
||||
// ── 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 3) 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 = await db.listProfiles()
|
||||
const first = model.desiredFor(server.id, profiles)
|
||||
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 || {})
|
||||
if (data.managed) await db.markAdopted(server.id)
|
||||
if (adopted.length) log.info('adopted a server\'s own NPC profiles', { server: server.id, adopted })
|
||||
}
|
||||
|
||||
const desired = adopted && adopted.length ? model.desiredFor(server.id, await db.listProfiles()) : first
|
||||
const pushed = await sidecar.npcProfilesSet(server, desired.profiles)
|
||||
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 : {}
|
||||
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 }
|
||||
Reference in New Issue
Block a user