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:
2026-09-30 04:21:46 -05:00
parent b0143801a2
commit 564a234890
12 changed files with 1200 additions and 0 deletions

View File

@@ -48,6 +48,7 @@ const eventsDb = require('./model/events/events.db')
const eventWorld = require('./eventWorld')
const ingest = require('./ingest')
const mapImages = require('./mapImages')
const npcSync = require('./npcSync')
const permSync = require('./permSync')
const permissionsDb = require('./model/permissions/permissions.db')
const titleSync = require('./titleSync')
@@ -275,6 +276,9 @@ async function onBoot() {
permSync.start()
// The chat titles have a loop of their own for the same reason (phase 17).
titleSync.start()
// So do the NPC profiles, adopted and pushed to each server's RunicNPC
// (runicnpc stage 4, D244): a first push reads a server before it writes it.
npcSync.start()
refreshTimer = setInterval(refresh, REFRESH_MS)
ingestTimer = setInterval(ingestAll, INGEST_MS)
pruneTimer = setInterval(prune, PRUNE_MS)
@@ -300,6 +304,7 @@ async function onBoot() {
async function onShutdown() {
permSync.stop()
titleSync.stop()
npcSync.stop()
for (const timer of [refreshTimer, ingestTimer, pruneTimer, sweepTimer]) {
if (timer) clearInterval(timer)

View File

@@ -105,6 +105,13 @@ const STAFF_KINDS = Object.freeze([
'clan.member.added',
'clan.member.left',
'clan.member.kicked',
// RunicNPC (runicnpc stage 4). `npc.died` names the player who killed it and
// everyone who hurt it, so it is a roll call like `player.tally`: staff until
// an operator says otherwise. What the public sees of a kill is the tally's
// per-profile count on the leaderboard (D250).
'npc.died',
'npc.health',
'npc.placement.changed',
])
/**

View File

@@ -19,6 +19,12 @@
-- it knows this module registered, because it is the side that knows which
-- registrant owned what.
-- RunicNPC (runicnpc PLAN.md stage 4). Children before `rust_npc_profiles`.
DROP TABLE IF EXISTS rust_npc_kills;
DROP TABLE IF EXISTS rust_npc_sync;
DROP TABLE IF EXISTS rust_npc_profile_servers;
DROP TABLE IF EXISTS rust_npc_profiles;
-- Zone presets (PLAN_REDESIGNS §3.1). The server list before its preset.
DROP TABLE IF EXISTS rust_zone_preset_servers;
DROP TABLE IF EXISTS rust_zone_presets;

View File

@@ -1259,3 +1259,81 @@ CREATE TABLE IF NOT EXISTS rust_zone_preset_servers (
CONSTRAINT fk_rust_zone_preset_servers_server
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ── RunicNPC (docs/runicnpc/PLAN.md stage 4, D243–D252) ────────────────────
--
-- NPC profiles are authored here and pushed to each server's RunicNPC through
-- the bridge, which then marks that server managed (D221). The same shape as a
-- zone preset: one server, several, or every server (`all_servers`), and two
-- profiles of one name may not share a server. `body` is the profile as RunicNPC
-- reads it (D238), everything but the name, as JSON in a TEXT column.
--
-- `adopted_from` names the server a profile was read from on the site's first
-- push there (D244), and `replaced` marks one whose name a site profile already
-- had on that server (D251): kept for an admin to restore, and pushed nowhere.
--
-- `kills_scope` is D247's setting, how the profile's kills are counted: `server`
-- (the default, kills of that name on the server being looked at), `name`
-- (every server's kills of that name) or `profile` (this site profile's own,
-- wherever it was pushed).
CREATE TABLE IF NOT EXISTS rust_npc_profiles (
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(40) NOT NULL,
body TEXT NOT NULL,
all_servers TINYINT(1) NOT NULL DEFAULT 0,
kills_scope VARCHAR(16) NOT NULL DEFAULT 'server',
adopted_from VARCHAR(64) NULL,
replaced TINYINT(1) NOT NULL DEFAULT 0,
created_by INT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_rust_npc_profiles_name (name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS rust_npc_profile_servers (
profile_id INT UNSIGNED NOT NULL,
server_id VARCHAR(64) NOT NULL,
PRIMARY KEY (profile_id, server_id),
KEY idx_rust_npc_profile_servers_server (server_id),
CONSTRAINT fk_rust_npc_profile_servers_profile
FOREIGN KEY (profile_id) REFERENCES rust_npc_profiles (id) ON DELETE CASCADE,
CONSTRAINT fk_rust_npc_profile_servers_server
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- What the push last did on each server. `adopted_at` is set once, when the
-- server's own profiles were read and imported (D244); until then nothing is
-- pushed there. `pushed` is the name → site profile id map of the last push,
-- which is how a kill is credited to "this profile only" (D247). `state` is
-- `ok`, `failed`, or `absent` (no RunicNPC, or one older than API 3).
CREATE TABLE IF NOT EXISTS rust_npc_sync (
server_id VARCHAR(64) NOT NULL PRIMARY KEY,
adopted_at DATETIME NULL,
state VARCHAR(16) NOT NULL DEFAULT 'pending',
synced_hash CHAR(64) NULL,
boot_id VARCHAR(64) NULL,
pushed TEXT NULL,
refused TEXT NULL,
error VARCHAR(191) NULL,
last_attempt_at DATETIME NULL,
synced_at DATETIME NULL,
CONSTRAINT fk_rust_npc_sync_server
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Kills of RunicNPC's NPCs, per player, profile name and wipe (D247), from the
-- tally's `npcProfileKills`. `site_profile_id` is the site profile that name was
-- pushed as on that server when the kill arrived, 0 for one the site did not
-- push. The ranking reads it by the profile's `kills_scope`. No foreign keys, as
-- the other stats: a deleted profile keeps its history.
CREATE TABLE IF NOT EXISTS rust_npc_kills (
server_id VARCHAR(64) NOT NULL,
wipe_id VARCHAR(48) NOT NULL,
steam_id VARCHAR(32) NOT NULL,
profile VARCHAR(40) NOT NULL,
site_profile_id INT UNSIGNED NOT NULL DEFAULT 0,
kills INT UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (server_id, wipe_id, steam_id, profile, site_profile_id),
KEY idx_rust_npc_kills_profile (profile, server_id, wipe_id),
KEY idx_rust_npc_kills_site (site_profile_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View File

@@ -40,6 +40,8 @@ const db = require('./model/events/events.db')
const engagement = require('./engagement/emit')
const eventWorld = require('./eventWorld')
const links = require('./model/links/links.model')
const npcs = require('./model/npcs/npcs.model')
const npcsDb = require('./model/npcs/npcs.db')
const permissionsDb = require('./model/permissions/permissions.db')
const sidecar = require('./sidecarClient')
@@ -158,6 +160,15 @@ async function apply(serverId, item, server = null) {
for (const [weapon, kills] of Object.entries(weapons)) {
await db.addWeaponKills(at, weapon, Math.floor(Number(kills) || 0))
}
// RunicNPC's NPCs, by profile name (D247). Credited to the site profile
// that name was last pushed as on this server, so "this profile only"
// can be counted; 0 for a name the site never pushed.
const profiles = frame.npcProfileKills && typeof frame.npcProfileKills === 'object' ? frame.npcProfileKills : {}
for (const [profile, kills] of Object.entries(profiles)) {
const n = Math.floor(Number(kills) || 0)
if (n > 0) await npcsDb.addKills(at, profile, await npcs.siteProfileFor(serverId, profile), n)
}
break
}

View File

@@ -0,0 +1,176 @@
// ── A RunicNPC profile, checked here as RunicNPC checks it (D238) ───────────
//
// The site authors profiles and pushes them to each server's RunicNPC, which
// checks them again and refuses what it cannot use (docs/runicnpc/API.md). The
// site checks first so an admin reads the reason on the form, not in a push
// report minutes later. The rules below are RunicNPC's `ValidateProfile`, in
// the same order and with the same sentences, less the one only the server can
// answer: whether it has each kit. That one is checked against the kit list
// each server last reported (`npcs.model.js`).
//
// Pure functions: no database, no sidecar.
/** RunicNPC's rule for a profile, placement or route name. */
const NAME_RULE = /^[a-z0-9_-]{1,40}$/
/** A plain scientist prefab, as RunicNPC resolves one (`scientistnpc_*`). */
const PREFAB_RULE = /^scientistnpc_[a-z0-9_]{1,40}$/
/** The prefabs the form offers; RunicNPC accepts any plain `scientistnpc_*`. */
const PREFABS = ['scientistnpc_roam', 'scientistnpc_heavy', 'scientistnpc_patrol', 'scientistnpc_roamtethered', 'scientistnpc_full_any']
const ROLES = ['roamer', 'sentry']
/** D247: how a profile's kills are counted. `server` is the default. */
const KILLS_SCOPES = ['server', 'name', 'profile']
const NAMES_MAX = 20
const DISPLAY_NAME_MAX = 32
const KITS_MAX = 20
const THRESHOLDS_MAX = 10
/** RunicNPC's defaults (API.md, D238), so a form may leave a value out. */
function defaults() {
return {
names: [],
kits: [],
prefab: 'scientistnpc_roam',
role: 'roamer',
movement: { mode: 'wander', radius: 20 },
health: 150,
damageDealt: 1,
damageTaken: { head: 1, body: 1, legs: 1 },
aimCone: 2,
ranges: { sense: 30, loseTarget: 40, chase: 40, attack: 30 },
visionCone: -0.8,
sleepDistance: 160,
healthThresholds: [],
}
}
function num(value) {
if (value === null || value === undefined || value === '') return NaN
const n = Number(value)
return Number.isFinite(n) ? n : NaN
}
/** `wander`, `monument` or `route:<name>` (D233). */
function parseMode(mode) {
const text = String(mode === undefined || mode === null ? '' : mode).trim()
if (text === 'wander' || text === 'monument') return { kind: text }
const m = /^route:([a-z0-9_-]{1,40})$/.exec(text)
return m ? { kind: 'route', route: m[1] } : null
}
/** A movement, checked: `{ ok, value }` or `{ ok: false, error }`. */
function checkMovement(input) {
const m = input || {}
const mode = String(m.mode === undefined || m.mode === null ? '' : m.mode).trim()
const parsed = parseMode(mode)
if (!parsed) return { ok: false, error: `movement.mode: '${mode}' is not wander, monument or route:<name>` }
const radius = m.radius === undefined || m.radius === null || m.radius === '' ? (parsed.kind === 'wander' ? 20 : 0) : num(m.radius)
if (Number.isNaN(radius) || radius < 0) return { ok: false, error: 'movement.radius: a number of metres' }
if (parsed.kind === 'wander' && !(radius > 0)) return { ok: false, error: "movement.radius: a wanderer's radius must be above 0" }
return { ok: true, value: { mode, radius } }
}
/**
* Checks a profile's body as the admin form sends it, merged over RunicNPC's
* defaults. Resolves `{ ok: true, value }` with the body RunicNPC will read, or
* `{ ok: false, error }` with RunicNPC's own sentence for the first problem.
*/
function checkBody(input) {
const b = { ...defaults(), ...(input || {}) }
const names = Array.isArray(b.names) ? b.names.map((n) => String(n === null || n === undefined ? '' : n).trim()) : null
if (!names || names.length === 0 || names.some((n) => !n)) return { ok: false, error: 'names: give at least one, and no blank ones' }
if (names.length > NAMES_MAX) return { ok: false, error: `names: at most ${NAMES_MAX}` }
if (names.some((n) => n.length > DISPLAY_NAME_MAX)) return { ok: false, error: `names: each at most ${DISPLAY_NAME_MAX} characters` }
const kits = Array.isArray(b.kits) ? [...new Set(b.kits.map((k) => String(k === null || k === undefined ? '' : k).trim()).filter(Boolean))] : null
if (!kits || kits.length === 0) return { ok: false, error: 'kits: give at least one; Kits is how an NPC is equipped (D217)' }
if (kits.length > KITS_MAX) return { ok: false, error: `kits: at most ${KITS_MAX}` }
const prefab = String(b.prefab || '').trim()
if (!PREFAB_RULE.test(prefab)) return { ok: false, error: `prefab: '${prefab}' is not one of Rust's scientist prefabs (scientistnpc_*)` }
if (!ROLES.includes(b.role)) return { ok: false, error: `role: '${b.role}' is not roamer or sentry` }
const movement = checkMovement(b.movement)
if (!movement.ok) return movement
const health = num(b.health)
if (!(health > 0)) return { ok: false, error: 'health: must be above 0' }
const damageDealt = num(b.damageDealt)
if (Number.isNaN(damageDealt) || damageDealt < 0) return { ok: false, error: 'damageDealt: must not be negative' }
const taken = b.damageTaken || {}
const damageTaken = { head: num(taken.head), body: num(taken.body), legs: num(taken.legs) }
if (Object.values(damageTaken).some((v) => Number.isNaN(v) || v < 0)) return { ok: false, error: 'damageTaken: head, body and legs must not be negative' }
const aimCone = num(b.aimCone)
if (Number.isNaN(aimCone) || aimCone < 0) return { ok: false, error: 'aimCone: must not be negative' }
const r = b.ranges || {}
const ranges = { sense: num(r.sense), loseTarget: num(r.loseTarget), chase: num(r.chase), attack: num(r.attack) }
if (!(ranges.sense > 0) || !(ranges.attack > 0) || !(ranges.loseTarget >= ranges.sense) || !(ranges.chase >= 0)) {
return { ok: false, error: 'ranges: sense and attack above 0, loseTarget at least sense, chase not negative' }
}
const visionCone = num(b.visionCone)
if (!(visionCone >= -1 && visionCone <= 1)) return { ok: false, error: 'visionCone: between -1 and 1' }
const sleepDistance = num(b.sleepDistance)
if (!(sleepDistance >= 0)) return { ok: false, error: 'sleepDistance: 0 (never sleeps) or more' }
const thresholds = Array.isArray(b.healthThresholds) ? b.healthThresholds.map(num) : null
if (!thresholds || thresholds.some((t) => !(t > 0 && t < 1))) return { ok: false, error: 'healthThresholds: fractions between 0 and 1' }
if (thresholds.length > THRESHOLDS_MAX) return { ok: false, error: `healthThresholds: at most ${THRESHOLDS_MAX}` }
return {
ok: true,
value: {
names,
kits,
prefab,
role: b.role,
movement: movement.value,
health,
damageDealt,
damageTaken,
aimCone,
ranges,
visionCone,
sleepDistance,
healthThresholds: [...new Set(thresholds)].sort((x, y) => y - x),
},
}
}
/**
* A profile read from a server's own RunicNPC (D244), made into a body this
* module will save. Whatever RunicNPC holds is kept as it is; only a shape this
* module could not push back is refused, with the reason.
*/
function fromServer(name, body) {
if (!NAME_RULE.test(String(name || ''))) return { ok: false, error: `'${name}' is not a profile name RunicNPC could hold` }
return checkBody(body || {})
}
/** A player-facing label for a profile: its first NPC name, else its own name. */
function labelOf(profile) {
const names = profile && profile.body && Array.isArray(profile.body.names) ? profile.body.names : []
return names[0] || (profile && profile.name) || ''
}
module.exports = {
NAME_RULE,
PREFAB_RULE,
PREFABS,
ROLES,
KILLS_SCOPES,
defaults,
parseMode,
checkMovement,
checkBody,
fromServer,
labelOf,
}

View File

@@ -0,0 +1,308 @@
// ── SQL for RunicNPC profiles, their push, and per-profile kills ───────────
//
// Profiles are this module's own (runicnpc PLAN.md stage 4). Placements are
// NOT stored here: they live on each server (D222), and the site reads and
// edits them through the bridge. What each server said about RunicNPC is read
// out of the state row's stored hello (`raw`), so the pages work while a server
// is off.
const core = require('../../core')
const PROFILES = 'rust_npc_profiles'
const PROFILE_SERVERS = 'rust_npc_profile_servers'
const SYNC = 'rust_npc_sync'
const KILLS = 'rust_npc_kills'
const SERVERS = 'rust_servers'
const STATE = 'rust_server_state'
const PLAYERS = 'rust_players'
/** The driver hands JSON_EXTRACT and TEXT back as strings. A bad value is no value. */
function json(value, fallback) {
if (value === null || value === undefined) return fallback
if (typeof value !== 'string') return value
try {
return JSON.parse(value)
} catch {
return fallback
}
}
function shape(row, serverIds) {
return {
id: Number(row.id),
name: row.name,
body: json(row.body, {}),
allServers: Boolean(row.allServers),
servers: serverIds,
killsScope: row.killsScope || 'server',
adoptedFrom: row.adoptedFrom || null,
replaced: Boolean(row.replaced),
updatedAt: row.updatedAt ? new Date(row.updatedAt).toISOString() : null,
}
}
const COLUMNS = `id, name, body, all_servers AS allServers, kills_scope AS killsScope,
adopted_from AS adoptedFrom, replaced, updated_at AS updatedAt`
/** Every profile, with the servers each names, by name. */
async function listProfiles() {
const [rows, links] = await Promise.all([
core.query(`SELECT ${COLUMNS} FROM ${PROFILES} ORDER BY name ASC, id ASC`),
core.query(`SELECT profile_id AS profileId, server_id AS serverId FROM ${PROFILE_SERVERS} ORDER BY server_id ASC`),
])
const by = new Map()
for (const l of links) {
const id = Number(l.profileId)
if (!by.has(id)) by.set(id, [])
by.get(id).push(l.serverId)
}
return rows.map((r) => shape(r, by.get(Number(r.id)) || []))
}
async function getProfile(id) {
const rows = await core.query(`SELECT ${COLUMNS} FROM ${PROFILES} WHERE id = ?`, [id])
if (!rows[0]) return null
const links = await core.query(`SELECT server_id AS serverId FROM ${PROFILE_SERVERS} WHERE profile_id = ? ORDER BY server_id ASC`, [id])
return shape(rows[0], links.map((l) => l.serverId))
}
/**
* Writes one profile and replaces its server list. The model has checked the
* servers and the name first, so nothing below has anything left to refuse.
*/
async function saveProfile({ id = null, name, body, allServers, servers, killsScope, adoptedFrom = null, replaced = false }, userId = null) {
let profileId = id
const args = [name, JSON.stringify(body), allServers ? 1 : 0, killsScope, replaced ? 1 : 0]
if (profileId) {
await core.query(
`UPDATE ${PROFILES} SET name = ?, body = ?, all_servers = ?, kills_scope = ?, replaced = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
[...args, profileId],
)
await core.query(`DELETE FROM ${PROFILE_SERVERS} WHERE profile_id = ?`, [profileId])
} else {
const res = await core.query(
`INSERT INTO ${PROFILES} (name, body, all_servers, kills_scope, replaced, adopted_from, created_by) VALUES (?, ?, ?, ?, ?, ?, ?)`,
[...args, adoptedFrom, userId],
)
profileId = Number(res.insertId)
}
if (!allServers && servers.length > 0) {
await core.query(
`INSERT INTO ${PROFILE_SERVERS} (profile_id, server_id) VALUES ${servers.map(() => '(?, ?)').join(', ')}`,
servers.flatMap((serverId) => [profileId, serverId]),
)
}
return profileId
}
async function deleteProfile(id) {
await core.query(`DELETE FROM ${PROFILES} WHERE id = ?`, [id])
}
/**
* Each configured server, in the operator's order, with what its last status
* said about RunicNPC (`{ loaded, version, api }`, null for a server that never
* said) and about its connection.
*/
async function listNpcServers() {
const rows = await core.query(
`SELECT s.id, s.name, s.enabled, st.online, st.boot_id AS bootId,
JSON_EXTRACT(st.raw, '$.integrations.runicNpc') AS runicNpc,
JSON_EXTRACT(st.raw, '$.worldReady') AS worldReady
FROM ${SERVERS} s
LEFT JOIN ${STATE} st ON st.server_id = s.id
ORDER BY s.sort_order ASC, s.id ASC`,
)
return rows.map((r) => {
const npc = json(r.runicNpc, null)
const ready = json(r.worldReady, null)
return {
id: r.id,
name: r.name,
enabled: Boolean(r.enabled),
online: r.online === null || r.online === undefined ? null : Boolean(Number(r.online)),
bootId: r.bootId || null,
worldReady: ready === null ? null : Boolean(ready),
runicNpc: npc && typeof npc === 'object' ? { loaded: Boolean(npc.loaded), version: npc.version || null, api: Number(npc.api) || 0 } : null,
}
})
}
// ── The push's record ───────────────────────────────────────────────────────
function syncShape(r) {
return {
serverId: r.serverId,
adoptedAt: r.adoptedAt ? new Date(r.adoptedAt).toISOString() : null,
state: r.state,
syncedHash: r.syncedHash || null,
bootId: r.bootId || null,
pushed: json(r.pushed, {}),
refused: json(r.refused, {}),
error: r.error || null,
lastAttemptAt: r.lastAttemptAt ? new Date(r.lastAttemptAt).toISOString() : null,
syncedAt: r.syncedAt ? new Date(r.syncedAt).toISOString() : null,
}
}
const SYNC_COLUMNS = `server_id AS serverId, adopted_at AS adoptedAt, state, synced_hash AS syncedHash, boot_id AS bootId,
pushed, refused, error, last_attempt_at AS lastAttemptAt, synced_at AS syncedAt`
async function listSync() {
return (await core.query(`SELECT ${SYNC_COLUMNS} FROM ${SYNC}`)).map(syncShape)
}
async function getSync(serverId) {
const rows = await core.query(`SELECT ${SYNC_COLUMNS} FROM ${SYNC} WHERE server_id = ?`, [serverId])
return rows[0] ? syncShape(rows[0]) : null
}
/** Records that a server's own profiles were read and imported (D244). Once. */
async function markAdopted(serverId) {
await core.query(
`INSERT INTO ${SYNC} (server_id, adopted_at) VALUES (?, CURRENT_TIMESTAMP)
ON DUPLICATE KEY UPDATE adopted_at = COALESCE(adopted_at, CURRENT_TIMESTAMP)`,
[serverId],
)
}
/** One push's outcome. A failure keeps the last good hash and map, so a retry is still a change. */
async function putSync(serverId, { state, syncedHash = null, bootId = null, pushed = null, refused = null, error = null }) {
const ok = state === 'ok'
await core.query(
`INSERT INTO ${SYNC} (server_id, state, synced_hash, boot_id, pushed, refused, error, last_attempt_at, synced_at)
VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, ${ok ? 'CURRENT_TIMESTAMP' : 'NULL'})
ON DUPLICATE KEY UPDATE state = VALUES(state),
synced_hash = ${ok ? 'VALUES(synced_hash)' : 'synced_hash'},
boot_id = ${ok ? 'VALUES(boot_id)' : 'boot_id'},
pushed = ${ok ? 'VALUES(pushed)' : 'pushed'},
refused = ${ok ? 'VALUES(refused)' : 'refused'},
error = VALUES(error),
last_attempt_at = CURRENT_TIMESTAMP,
synced_at = ${ok ? 'CURRENT_TIMESTAMP' : 'synced_at'}`,
[serverId, state, syncedHash, bootId, pushed ? JSON.stringify(pushed) : null, refused ? JSON.stringify(refused) : null, error ? String(error).slice(0, 191) : null],
)
}
/** Asks for a push on the next tick, after an admin's edit. */
async function markDirty(serverIds = null) {
if (Array.isArray(serverIds) && serverIds.length === 0) return
if (serverIds) {
await core.query(`UPDATE ${SYNC} SET synced_hash = NULL WHERE server_id IN (${serverIds.map(() => '?').join(', ')})`, serverIds)
} else {
await core.query(`UPDATE ${SYNC} SET synced_hash = NULL`)
}
}
// ── Kills (D247) ────────────────────────────────────────────────────────────
async function addKills({ serverId, wipeId, steamId }, profile, siteProfileId, kills) {
if (!serverId || !steamId || !profile || !(kills > 0)) return
await core.query(
`INSERT INTO ${KILLS} (server_id, wipe_id, steam_id, profile, site_profile_id, kills)
VALUES (?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE kills = kills + VALUES(kills)`,
[serverId, wipeId || '', steamId, String(profile).slice(0, 40), Number(siteProfileId) || 0, kills],
)
}
/**
* The WHERE clause for one profile's kills, by its scope (D247), and the page's
* wipe:
*
* `server` that name, on the server being looked at
* `name` that name, on every server
* `profile` that site profile, on every server it was pushed to
*
* `wipe`: a wipe id limits the server being looked at to that wipe; for a scope
* that reaches other servers, their CURRENT wipe is counted when the page shows
* this server's current wipe, and only this server's rows otherwise (another
* server's past wipes are not this page's). `null` is all time.
*/
function scopeWhere({ scope, name, siteProfileId, serverId, wipeId = null, currentWipe = false }) {
const where = []
const args = []
if (scope === 'profile') {
where.push('k.site_profile_id = ?')
args.push(siteProfileId)
} else {
where.push('k.profile = ?')
args.push(name)
}
if (scope === 'server') {
where.push('k.server_id = ?')
args.push(serverId)
if (wipeId !== null) {
where.push('k.wipe_id = ?')
args.push(wipeId)
}
} else if (wipeId !== null) {
if (currentWipe) {
where.push(`k.wipe_id = (SELECT COALESCE(st.wipe_id, '') FROM ${STATE} st WHERE st.server_id = k.server_id)`)
} else {
where.push('k.server_id = ? AND k.wipe_id = ?')
args.push(serverId, wipeId)
}
}
return { sql: where.join(' AND '), args }
}
/** One profile's ranking, most kills first, ties by Steam id as every other board. */
async function ranking(scope, limit = 50) {
const w = scopeWhere(scope)
return (await core.query(
`SELECT k.steam_id AS steamId, p.name, SUM(k.kills) AS kills
FROM ${KILLS} k
LEFT JOIN ${PLAYERS} p ON p.steam_id = k.steam_id
WHERE ${w.sql}
GROUP BY k.steam_id, p.name
HAVING kills > 0
ORDER BY kills DESC, k.steam_id ASC
LIMIT ?`,
[...w.args, Math.max(1, Math.min(200, Number(limit) || 50))],
)).map((r) => ({ steamId: String(r.steamId), name: r.name || null, value: Number(r.kills) || 0 }))
}
/** One player's kills by profile name on one server, for the wipe (or all time). */
async function playerKills({ serverId, steamId, wipeId = null }) {
return (await core.query(
`SELECT profile, SUM(kills) AS kills
FROM ${KILLS}
WHERE server_id = ? AND steam_id = ? ${wipeId !== null ? 'AND wipe_id = ?' : ''}
GROUP BY profile
ORDER BY kills DESC, profile ASC`,
wipeId !== null ? [serverId, steamId, wipeId] : [serverId, steamId],
)).map((r) => ({ profile: r.profile, kills: Number(r.kills) || 0 }))
}
/** A player's kills by server and profile name, current wipes, for their own account page. */
async function ownKills(steamIds) {
if (!steamIds || steamIds.length === 0) return []
return (await core.query(
`SELECT k.server_id AS serverId, k.profile, SUM(k.kills) AS kills
FROM ${KILLS} k
JOIN ${STATE} st ON st.server_id = k.server_id AND k.wipe_id = COALESCE(st.wipe_id, '')
WHERE k.steam_id IN (${steamIds.map(() => '?').join(', ')})
GROUP BY k.server_id, k.profile
ORDER BY k.server_id ASC, kills DESC`,
steamIds,
)).map((r) => ({ serverId: r.serverId, profile: r.profile, kills: Number(r.kills) || 0 }))
}
module.exports = {
listProfiles,
getProfile,
saveProfile,
deleteProfile,
listNpcServers,
listSync,
getSync,
markAdopted,
putSync,
markDirty,
addKills,
scopeWhere,
ranking,
playerKills,
ownKills,
}

View File

@@ -0,0 +1,450 @@
// ── RunicNPC profiles and placements (docs/runicnpc/PLAN.md stage 4) ───────
//
// Profiles are the site's: authored here, for one server, several, or the
// fleet (the zone-presets shape, D210), and pushed to each server's RunicNPC,
// which is then managed by this site (D221). Before the first push to a server
// its own profiles are read and adopted (D244); one whose name a site profile
// already has there is kept as "replaced" (D251).
//
// Placements are the SERVER's (D222). This model reads and edits them through
// the bridge and keeps no copy: a server that is off has no placements to show,
// and says so.
const crypto = require('node:crypto')
const client = require('../../sidecarClient')
const servers = require('../servers/servers.model')
const serversDb = require('../servers/servers.db')
const db = require('./npcs.db')
const shape = require('./npcProfile')
/** The RunicNPC API the bridge's `npc.*` commands need (D249). */
const API_NEEDED = 3
class NpcError extends Error {
constructor(message, status = 400) {
super(message)
this.status = status
}
}
function covers(profile, serverId) {
return profile.allServers || profile.servers.includes(serverId)
}
/** Profiles that are pushed: every one not kept aside as replaced (D251). */
function active(profiles) {
return profiles.filter((p) => !p.replaced)
}
/** Whether a server can take `npc.*` commands, from what it last said. */
function npcReady(server) {
return Boolean(server && server.runicNpc && server.runicNpc.loaded && server.runicNpc.api >= API_NEEDED)
}
/** Why a server cannot, in words, or null. */
function npcAbsence(server) {
if (!server) return 'no such server'
if (!server.runicNpc) return 'it has not said whether it has RunicNPC (a bridge older than protocol 13, or never reached)'
if (!server.runicNpc.loaded) return 'RunicNPC is not loaded on it'
if (server.runicNpc.api < API_NEEDED) return `its RunicNPC ${server.runicNpc.version || ''} answers API ${server.runicNpc.api}, and the site needs ${API_NEEDED}`.replace(' ', ' ')
return null
}
// ── What a server is sent ───────────────────────────────────────────────────
/**
* The profiles one server is pushed, as RunicNPC reads them, and the name → site
* profile id map a kill is credited through (D247). Sorted, so an unchanged set
* hashes the same on every tick.
*/
function desiredFor(serverId, profiles) {
const set = {}
const map = {}
for (const p of active(profiles).filter((x) => covers(x, serverId)).sort((a, b) => a.name.localeCompare(b.name) || a.id - b.id)) {
if (set[p.name]) continue
set[p.name] = p.body
map[p.name] = p.id
}
const hash = crypto.createHash('sha256').update(JSON.stringify(Object.keys(set).sort().map((n) => [n, set[n]]))).digest('hex')
return { profiles: set, map, hash }
}
// ── Adoption (D244, D251) ───────────────────────────────────────────────────
/**
* Imports a server's own profiles as profiles for that server alone, before the
* site first pushes there, so nothing on it changes. Where a site profile of the
* same name already covers it, the site's wins (D251): the server's own is kept,
* marked replaced, for an admin to restore. Returns what it did, per name.
*/
async function adopt(serverId, theirs, userId = null) {
const existing = await db.listProfiles()
const done = []
for (const [name, body] of Object.entries(theirs || {}).sort(([a], [b]) => a.localeCompare(b))) {
if (!shape.NAME_RULE.test(name)) {
done.push({ name, outcome: 'skipped', reason: 'not a name RunicNPC could hold' })
continue
}
const checked = shape.checkBody(body || {})
// Kept whole even when this module would refuse it on its form: adoption
// changes nothing on the server, and RunicNPC already said whether it uses it.
const kept = checked.ok ? checked.value : { ...shape.defaults(), ...(body || {}) }
const clash = active(existing).find((p) => p.name === name && covers(p, serverId))
await db.saveProfile(
{ name, body: kept, allServers: false, servers: [serverId], killsScope: 'server', adoptedFrom: serverId, replaced: Boolean(clash) },
userId,
)
done.push({ name, outcome: clash ? 'replaced' : 'adopted', ...(clash ? { by: clash.id } : {}) })
}
await db.markAdopted(serverId)
return done
}
// ── The admin page ──────────────────────────────────────────────────────────
async function describe() {
const [list, profiles, sync] = await Promise.all([db.listNpcServers(), db.listProfiles(), db.listSync()])
const syncBy = new Map(sync.map((s) => [s.serverId, s]))
return {
servers: list.map((s) => {
const row = syncBy.get(s.id) || null
return {
...s,
ready: npcReady(s),
absence: npcAbsence(s),
sync: row && { state: row.state, adoptedAt: row.adoptedAt, syncedAt: row.syncedAt, refused: row.refused, error: row.error },
}
}),
profiles: profiles.map((p) => ({ ...p, label: shape.labelOf(p) })),
prefabs: shape.PREFABS,
killsScopes: shape.KILLS_SCOPES,
defaults: shape.defaults(),
}
}
/**
* The kits each covered server that answers has; a server that does not answer
* is left to RunicNPC, which refuses a missing kit when the profile is pushed.
*/
async function checkKits(kits, covered) {
for (const s of covered) {
if (!s.enabled) continue
const row = await serversDb.getServer(s.id)
if (!row) continue
const result = await client.kits(servers.withToken(row))
const data = result.ok ? result.data || {} : null
if (!data || data.kind !== 'kits.list') continue
const have = new Set((data.kits || []).map((k) => String(k && k.name).toLowerCase()))
const missing = kits.find((k) => !have.has(k.toLowerCase()))
if (missing) throw new NpcError(`kits: ${s.name || s.id} has no kit '${missing}'`)
}
}
async function validate(input, id = null) {
const name = String((input && input.name) || '').trim()
if (!shape.NAME_RULE.test(name)) throw new NpcError('name: 1 to 40 of a-z, 0-9, _ and -, as RunicNPC names a profile')
const allServers = input.allServers === true
const requested = Array.isArray(input.servers) ? [...new Set(input.servers.map(String))] : []
if (!allServers && requested.length === 0) throw new NpcError('a profile is for at least one server, or for every server')
const killsScope = input.killsScope === undefined || input.killsScope === null || input.killsScope === '' ? 'server' : String(input.killsScope)
if (!shape.KILLS_SCOPES.includes(killsScope)) throw new NpcError(`killsScope: ${shape.KILLS_SCOPES.join(', ')}`)
const list = await db.listNpcServers()
const byId = new Map(list.map((s) => [s.id, s]))
for (const s of requested) {
if (!byId.has(s)) throw new NpcError(`no server "${s}"`, 404)
}
const checked = shape.checkBody(input.body)
if (!checked.ok) throw new NpcError(checked.error)
const mine = { allServers, servers: requested }
const others = active(await db.listProfiles()).filter((p) => p.id !== id && p.name === name)
for (const other of others) {
if (allServers && other.allServers) throw new NpcError(`a profile called "${name}" is already on every server`, 409)
const shared = list.find((s) => covers(mine, s.id) && covers(other, s.id))
if (shared) throw new NpcError(`a profile called "${name}" is already on ${shared.name || shared.id}`, 409)
}
const covered = allServers ? list : requested.map((s) => byId.get(s))
await checkKits(checked.value.kits, covered)
return { name, body: checked.value, allServers, servers: allServers ? [] : requested, killsScope }
}
/** The servers whose pushed set a change to this profile moves. */
function reach(profile) {
return profile.allServers ? null : profile.servers
}
async function create(input, userId = null) {
const clean = await validate(input)
const id = await db.saveProfile(clean, userId)
await db.markDirty(clean.allServers ? null : clean.servers)
return db.getProfile(id)
}
async function update(id, input) {
const existing = await db.getProfile(id)
if (!existing) throw new NpcError('no such profile', 404)
if (existing.replaced) throw new NpcError('this profile is kept aside as replaced (D251): restore it before editing it', 409)
const clean = await validate(input, existing.id)
await db.saveProfile({ ...clean, id: existing.id })
const before = reach(existing)
const after = clean.allServers ? null : clean.servers
await db.markDirty(before === null || after === null ? null : [...new Set([...before, ...after])])
return db.getProfile(existing.id)
}
/**
* Deletes a profile. Its placements on each server wait, and spawn again if a
* profile of that name returns (D237).
*/
async function remove(id) {
const existing = await db.getProfile(id)
if (!existing) throw new NpcError('no such profile', 404)
await db.deleteProfile(existing.id)
if (!existing.replaced) await db.markDirty(reach(existing))
return true
}
/**
* Brings a replaced profile back into use on its server (D251), when no site
* profile of its name covers that server any more.
*/
async function restore(id) {
const existing = await db.getProfile(id)
if (!existing) throw new NpcError('no such profile', 404)
if (!existing.replaced) throw new NpcError('this profile is in use already')
const clash = active(await db.listProfiles()).find((p) => p.name === existing.name && existing.servers.some((s) => covers(p, s)))
if (clash) {
throw new NpcError(`the site's profile "${clash.name}" is on ${existing.servers.join(', ')}: change its servers or delete it first`, 409)
}
await db.saveProfile({ ...existing, replaced: false })
await db.markDirty(existing.servers)
return db.getProfile(existing.id)
}
// ── Placements, through the bridge (D245, D246) ─────────────────────────────
/** A refusal from the bridge, as an HTTP status and its own sentence. */
const REFUSALS = {
'runicnpc-missing': 409,
'runicnpc-old': 409,
'not-found': 404,
malformed: 400,
refused: 400,
}
async function reachable(serverId) {
const row = await serversDb.getServer(serverId)
if (!row) throw new NpcError(`no server "${serverId}"`, 404)
if (!row.enabled) throw new NpcError(`the Rust server "${row.name || serverId}" is switched off`, 409)
return servers.withToken(row)
}
function transport(server, result) {
const name = server.name || server.id
if (result.status === 'http-503') return new NpcError(`${name} has no game connected, so its placements cannot be read or changed now`, 503)
if (result.status === 'timeout' || result.status === 'http-504') return new NpcError(`${name} did not answer in time`, 504)
if (result.status === 'protocol-mismatch') return new NpcError(`${name}'s sidecar speaks a different protocol: update the module or the sidecar`, 502)
return new NpcError(`${name} could not be reached (${result.status})`, 502)
}
function answer(server, result, kind) {
if (!result.ok) throw transport(server, result)
const data = result.data || {}
if (data.kind === 'npc.error') throw new NpcError(data.message || data.reason || 'refused', REFUSALS[data.reason] || 400)
if (data.kind !== kind) throw new NpcError(`${server.name || server.id} answered something else (${data.kind || 'nothing'})`, 502)
return data
}
/**
* One server's placements, with its routes (for the movement picker) and the
* cost warning (D227). Each placement: its id, values, how many of its NPCs
* are alive, what it waits for (D237) and its note (D239).
*/
async function listPlacements(serverId) {
const server = await reachable(serverId)
const data = answer(server, await client.npcPlacements(server), 'npc.placements')
return {
placements: (data.placements || []).map((p) => ({
id: p.id,
placement: p.placement || {},
alive: Number(p.alive) || 0,
waiting: p.waiting || null,
note: p.note || null,
lastError: p.lastError || null,
})),
routes: Array.isArray(data.routes) ? data.routes : [],
cost: data.cost || null,
}
}
/** A placement's values from the form (D246): `/rnpc place`'s options, checked as RunicNPC does. */
function placementBody(input, { position }) {
const p = input || {}
const profile = String(p.profile || '').trim()
if (!shape.NAME_RULE.test(profile)) throw new NpcError('profile: a profile name')
const count = p.count === undefined || p.count === null || p.count === '' ? 1 : Number(p.count)
if (!Number.isInteger(count) || count < 1 || count > 50) throw new NpcError('count: a whole number, 1 to 50')
const respawn = p.respawn === undefined || p.respawn === null || p.respawn === '' ? 300 : Number(p.respawn)
if (!Number.isFinite(respawn) || respawn < 1 || respawn > 86400) throw new NpcError('respawn: seconds, 1 to 86400')
const respawnMode = p.respawnMode === undefined || p.respawnMode === null || p.respawnMode === '' ? 'each' : String(p.respawnMode)
if (respawnMode !== 'each' && respawnMode !== 'group') throw new NpcError('respawnMode: each or group')
const yaw = p.yaw === undefined || p.yaw === null || p.yaw === '' ? 0 : Number(p.yaw)
if (!Number.isFinite(yaw)) throw new NpcError('yaw: degrees')
const body = { profile, position, yaw, count, respawn, respawnMode }
if (p.movement && p.movement.mode) {
const m = shape.checkMovement(p.movement)
if (!m.ok) throw new NpcError(m.error)
body.movement = m.value
}
return body
}
function point(raw, { withY }) {
const r = raw || {}
const x = Number(r.x)
const z = Number(r.z)
if (!Number.isFinite(x) || !Number.isFinite(z)) throw new NpcError('position: x and z')
if (!withY) return { x, z }
const y = Number(r.y)
if (!Number.isFinite(y)) throw new NpcError('position: x, y and z')
return { x, y, z }
}
/**
* A new placement from a point on the live map (D245): x and z only. The server
* puts it on the ground there, checks it against the navmesh, and names it as in
* game (D246); the answer carries the name, where it landed and the cost warning.
*/
async function addPlacement(serverId, input) {
const server = await reachable(serverId)
const body = placementBody(input, { position: point(input && input.position, { withY: false }) })
const data = answer(server, await client.npcPlacement(server, { op: 'add', placement: body }), 'npc.ok')
return { id: data.id, position: data.position || null, built: Boolean(data.built), cost: data.cost || null }
}
/** New values for a placement. Its spot is kept unless the form sends one whole. */
async function setPlacement(serverId, id, input) {
const server = await reachable(serverId)
const current = (await listPlacements(serverId)).placements.find((p) => p.id === id)
if (!current) throw new NpcError(`there is no placement '${id}' on ${server.name || server.id}`, 404)
const position = input && input.position ? point(input.position, { withY: true }) : current.placement.position
const body = placementBody({ yaw: current.placement.yaw, ...input }, { position })
const data = answer(server, await client.npcPlacement(server, { op: 'set', id, placement: body }), 'npc.ok')
return { id: data.id || id, cost: data.cost || null }
}
async function changePlacement(serverId, op, body) {
const server = await reachable(serverId)
const data = answer(server, await client.npcPlacement(server, { op, ...body }), 'npc.ok')
return data
}
const removePlacement = (serverId, id) => changePlacement(serverId, 'remove', { id })
const renamePlacement = (serverId, id, to) => {
if (!shape.NAME_RULE.test(String(to || ''))) throw new NpcError('to: 1 to 40 of a-z, 0-9, _ and -')
return changePlacement(serverId, 'rename', { id, to })
}
const respawnPlacement = (serverId, id) => changePlacement(serverId, 'respawn', { id })
// ── Events: the "Place NPCs" picker (D243) ─────────────────────────────────
/** A profile's value in the picker: `profile:<name>`, beside Rust's own `npc.*`. */
const PROFILE_PREFIX = 'profile:'
/**
* The site's profiles, first, grouped, for any server that has RunicNPC. A site
* with no such server offers none (D243: Rust's own only until stage 9).
*/
async function optionRows() {
const [profiles, list] = await Promise.all([db.listProfiles(), db.listNpcServers()])
const ready = list.filter(npcReady)
if (ready.length === 0) return []
const seen = new Set()
const rows = []
for (const p of active(profiles)) {
if (!ready.some((s) => covers(p, s.id)) || seen.has(p.name)) continue
seen.add(p.name)
rows.push({ value: `${PROFILE_PREFIX}${p.name}`, label: `${shape.labelOf(p)} (${p.name})`, group: 'NPC profiles' })
}
return rows
}
// ── Kills (D247, D250, D252) ────────────────────────────────────────────────
/** The site profile a name was pushed as on a server, from the last push; 0 for none. */
async function siteProfileFor(serverId, name) {
const sync = await db.getSync(serverId)
return sync && sync.pushed && sync.pushed[name] ? Number(sync.pushed[name]) : 0
}
/** The profiles a server's leaderboard may rank by: those pushed to it, with their labels. */
async function boardProfiles(serverId) {
return active(await db.listProfiles())
.filter((p) => covers(p, serverId))
.map((p) => ({ id: p.id, name: p.name, label: shape.labelOf(p), killsScope: p.killsScope }))
}
/**
* One profile's ranking as seen from one server's page, counted as the profile
* says (D247). `wipeId` null is all time; `currentWipe` says whether it is the
* server's current wipe, which is what reaches other servers' current wipes.
*/
async function ranking({ serverId, profileId, wipeId = null, currentWipe = false, limit = 50 }) {
const profile = await db.getProfile(profileId)
if (!profile || profile.replaced) throw new NpcError('no such profile', 404)
if (!covers(profile, serverId)) throw new NpcError('that profile is not on this server', 404)
const rows = await db.ranking({ scope: profile.killsScope, name: profile.name, siteProfileId: profile.id, serverId, wipeId, currentWipe }, limit)
return { profile: { id: profile.id, name: profile.name, label: shape.labelOf(profile), killsScope: profile.killsScope }, rows }
}
/**
* The titles' standing for a `profilekills` rule (D250): the ranking of the
* profile on the server the rule is for, current wipe, as the profile counts.
*/
async function standing(serverId, profileId, wipeId, limit) {
const profile = await db.getProfile(profileId)
if (!profile || profile.replaced || !covers(profile, serverId)) return []
return db.ranking({ scope: profile.killsScope, name: profile.name, siteProfileId: profile.id, serverId, wipeId: wipeId || '', currentWipe: true }, limit)
}
/** One player's kills by profile on one server's page (D252), labelled where the site knows the profile. */
async function playerKills({ serverId, steamId, wipeId = null }) {
const [rows, profiles] = await Promise.all([db.playerKills({ serverId, steamId, wipeId }), boardProfiles(serverId)])
const byName = new Map(profiles.map((p) => [p.name, p]))
return rows.map((r) => ({ profile: r.profile, label: byName.has(r.profile) ? byName.get(r.profile).label : r.profile, kills: r.kills }))
}
module.exports = {
API_NEEDED,
PROFILE_PREFIX,
NpcError,
covers,
npcReady,
npcAbsence,
desiredFor,
adopt,
describe,
create,
update,
remove,
restore,
listPlacements,
addPlacement,
setPlacement,
removePlacement,
renamePlacement,
respawnPlacement,
optionRows,
siteProfileFor,
boardProfiles,
ranking,
standing,
playerKills,
}

135
server/npcSync.js Normal file
View 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 }

View File

@@ -490,6 +490,19 @@ const mapRender = (server, body) => request(server, '/map/render', { method: 'PO
/** Everything that moves on one server's map, every layer, unfiltered. The caller filters (§8.5). */
const mapLive = (server) => request(server, '/map/live')
/**
* RunicNPC (runicnpc PLAN.md stage 4, protocol 13). The server's own profiles as
* RunicNPC holds them (`npc.profiles`: `managed`, `profiles`, `refused`), read
* before the site's first push so it can adopt them (D244); a push, which
* replaces them whole and marks the server managed (`npc.ok` lists what
* RunicNPC refused); every placement; and one change to one placement, by `op`.
* Each refusal is `data.kind` `npc.error` with a `reason` and RunicNPC's own sentence.
*/
const npcProfiles = (server) => request(server, '/npc/profiles')
const npcProfilesSet = (server, profiles) => request(server, '/npc/profiles', { method: 'POST', body: { profiles } })
const npcPlacements = (server) => request(server, '/npc/placements')
const npcPlacement = (server, body) => request(server, '/npc/placement', { method: 'POST', body })
module.exports = {
TIMEOUT_MS,
LEASE_TIMEOUT_MS,
@@ -528,5 +541,9 @@ module.exports = {
mapChunk,
mapRender,
mapLive,
npcProfiles,
npcProfilesSet,
npcPlacements,
npcPlacement,
joinUrl,
}

View File

@@ -137,6 +137,12 @@ test('the classification covers exactly the event kinds the protocol defines, th
'config.outcome',
// Protocol 13 (§19, F8, D184). Which plugins a server runs and what each
// registers: an operator's inventory.
// Protocol 13, RunicNPC (runicnpc stage 4): an NPC's death names its killer
// and everyone who hurt it; its health and a placement's change are an
// operator's. All staff.
'npc.died',
'npc.health',
'npc.placement.changed',
'plugin.loaded',
'plugin.unloaded',
]