// ── 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) { const names = new Map((await db.listNpcServers()).map((srv) => [srv.id, srv.name || srv.id])) const where = existing.servers.map((id) => names.get(id) || id).join(', ') throw new NpcError(`the site's profile "${clash.name}" is on ${where}: 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:`, 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, }