From 564a2348906152ea273b7ae8453e6cc3f44a4373 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 30 Sep 2026 04:21:46 -0500 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- ci/bundle.json | 1 + server/boot.js | 5 + server/catalogue.js | 7 + server/db/purge.sql | 6 + server/db/schema.sql | 78 ++++++ server/ingest.js | 11 + server/model/npcs/npcProfile.js | 176 +++++++++++++ server/model/npcs/npcs.db.js | 308 ++++++++++++++++++++++ server/model/npcs/npcs.model.js | 450 ++++++++++++++++++++++++++++++++ server/npcSync.js | 135 ++++++++++ server/sidecarClient.js | 17 ++ server/test/catalogue.test.js | 6 + 12 files changed, 1200 insertions(+) create mode 100644 server/model/npcs/npcProfile.js create mode 100644 server/model/npcs/npcs.db.js create mode 100644 server/model/npcs/npcs.model.js create mode 100644 server/npcSync.js diff --git a/ci/bundle.json b/ci/bundle.json index 9697e51..2680b9e 100644 --- a/ci/bundle.json +++ b/ci/bundle.json @@ -42,6 +42,7 @@ "mapImages.js", "mapLive.js", "model", + "npcSync.js", "package.json", "permSync.js", "router", diff --git a/server/boot.js b/server/boot.js index d654a1f..b7cb77b 100644 --- a/server/boot.js +++ b/server/boot.js @@ -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) diff --git a/server/catalogue.js b/server/catalogue.js index 10d0888..c9aaf23 100644 --- a/server/catalogue.js +++ b/server/catalogue.js @@ -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', ]) /** diff --git a/server/db/purge.sql b/server/db/purge.sql index 97c2703..251b7f7 100644 --- a/server/db/purge.sql +++ b/server/db/purge.sql @@ -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; diff --git a/server/db/schema.sql b/server/db/schema.sql index 92d4066..89ed141 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -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; diff --git a/server/ingest.js b/server/ingest.js index 48b9b6e..0dd6708 100644 --- a/server/ingest.js +++ b/server/ingest.js @@ -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 } diff --git a/server/model/npcs/npcProfile.js b/server/model/npcs/npcProfile.js new file mode 100644 index 0000000..357e91e --- /dev/null +++ b/server/model/npcs/npcProfile.js @@ -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:` (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:` } + 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, +} diff --git a/server/model/npcs/npcs.db.js b/server/model/npcs/npcs.db.js new file mode 100644 index 0000000..7434d40 --- /dev/null +++ b/server/model/npcs/npcs.db.js @@ -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, +} diff --git a/server/model/npcs/npcs.model.js b/server/model/npcs/npcs.model.js new file mode 100644 index 0000000..4bf4398 --- /dev/null +++ b/server/model/npcs/npcs.model.js @@ -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:`, 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, +} diff --git a/server/npcSync.js b/server/npcSync.js new file mode 100644 index 0000000..cf813c5 --- /dev/null +++ b/server/npcSync.js @@ -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 } diff --git a/server/sidecarClient.js b/server/sidecarClient.js index cd50e80..f897ab9 100644 --- a/server/sidecarClient.js +++ b/server/sidecarClient.js @@ -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, } diff --git a/server/test/catalogue.test.js b/server/test/catalogue.test.js index bf7ed0a..1d4750e 100644 --- a/server/test/catalogue.test.js +++ b/server/test/catalogue.test.js @@ -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', ]