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:
@@ -42,6 +42,7 @@
|
|||||||
"mapImages.js",
|
"mapImages.js",
|
||||||
"mapLive.js",
|
"mapLive.js",
|
||||||
"model",
|
"model",
|
||||||
|
"npcSync.js",
|
||||||
"package.json",
|
"package.json",
|
||||||
"permSync.js",
|
"permSync.js",
|
||||||
"router",
|
"router",
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ const eventsDb = require('./model/events/events.db')
|
|||||||
const eventWorld = require('./eventWorld')
|
const eventWorld = require('./eventWorld')
|
||||||
const ingest = require('./ingest')
|
const ingest = require('./ingest')
|
||||||
const mapImages = require('./mapImages')
|
const mapImages = require('./mapImages')
|
||||||
|
const npcSync = require('./npcSync')
|
||||||
const permSync = require('./permSync')
|
const permSync = require('./permSync')
|
||||||
const permissionsDb = require('./model/permissions/permissions.db')
|
const permissionsDb = require('./model/permissions/permissions.db')
|
||||||
const titleSync = require('./titleSync')
|
const titleSync = require('./titleSync')
|
||||||
@@ -275,6 +276,9 @@ async function onBoot() {
|
|||||||
permSync.start()
|
permSync.start()
|
||||||
// The chat titles have a loop of their own for the same reason (phase 17).
|
// The chat titles have a loop of their own for the same reason (phase 17).
|
||||||
titleSync.start()
|
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)
|
refreshTimer = setInterval(refresh, REFRESH_MS)
|
||||||
ingestTimer = setInterval(ingestAll, INGEST_MS)
|
ingestTimer = setInterval(ingestAll, INGEST_MS)
|
||||||
pruneTimer = setInterval(prune, PRUNE_MS)
|
pruneTimer = setInterval(prune, PRUNE_MS)
|
||||||
@@ -300,6 +304,7 @@ async function onBoot() {
|
|||||||
async function onShutdown() {
|
async function onShutdown() {
|
||||||
permSync.stop()
|
permSync.stop()
|
||||||
titleSync.stop()
|
titleSync.stop()
|
||||||
|
npcSync.stop()
|
||||||
|
|
||||||
for (const timer of [refreshTimer, ingestTimer, pruneTimer, sweepTimer]) {
|
for (const timer of [refreshTimer, ingestTimer, pruneTimer, sweepTimer]) {
|
||||||
if (timer) clearInterval(timer)
|
if (timer) clearInterval(timer)
|
||||||
|
|||||||
@@ -105,6 +105,13 @@ const STAFF_KINDS = Object.freeze([
|
|||||||
'clan.member.added',
|
'clan.member.added',
|
||||||
'clan.member.left',
|
'clan.member.left',
|
||||||
'clan.member.kicked',
|
'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',
|
||||||
])
|
])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -19,6 +19,12 @@
|
|||||||
-- it knows this module registered, because it is the side that knows which
|
-- it knows this module registered, because it is the side that knows which
|
||||||
-- registrant owned what.
|
-- 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.
|
-- 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_preset_servers;
|
||||||
DROP TABLE IF EXISTS rust_zone_presets;
|
DROP TABLE IF EXISTS rust_zone_presets;
|
||||||
|
|||||||
@@ -1259,3 +1259,81 @@ CREATE TABLE IF NOT EXISTS rust_zone_preset_servers (
|
|||||||
CONSTRAINT fk_rust_zone_preset_servers_server
|
CONSTRAINT fk_rust_zone_preset_servers_server
|
||||||
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
|
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) 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;
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ const db = require('./model/events/events.db')
|
|||||||
const engagement = require('./engagement/emit')
|
const engagement = require('./engagement/emit')
|
||||||
const eventWorld = require('./eventWorld')
|
const eventWorld = require('./eventWorld')
|
||||||
const links = require('./model/links/links.model')
|
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 permissionsDb = require('./model/permissions/permissions.db')
|
||||||
const sidecar = require('./sidecarClient')
|
const sidecar = require('./sidecarClient')
|
||||||
|
|
||||||
@@ -158,6 +160,15 @@ async function apply(serverId, item, server = null) {
|
|||||||
for (const [weapon, kills] of Object.entries(weapons)) {
|
for (const [weapon, kills] of Object.entries(weapons)) {
|
||||||
await db.addWeaponKills(at, weapon, Math.floor(Number(kills) || 0))
|
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
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
176
server/model/npcs/npcProfile.js
Normal file
176
server/model/npcs/npcProfile.js
Normal 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,
|
||||||
|
}
|
||||||
308
server/model/npcs/npcs.db.js
Normal file
308
server/model/npcs/npcs.db.js
Normal 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,
|
||||||
|
}
|
||||||
450
server/model/npcs/npcs.model.js
Normal file
450
server/model/npcs/npcs.model.js
Normal 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
135
server/npcSync.js
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
// ── Pushing the site's NPC profiles to each server (runicnpc stage 4) ──────
|
||||||
|
//
|
||||||
|
// The site's profiles replace each server's RunicNPC profile set whole, as the
|
||||||
|
// permission sync replaces a server's permissions, and the server is then
|
||||||
|
// managed by this site: RunicNPC refuses in-game profile edits (D221).
|
||||||
|
//
|
||||||
|
// **Before the FIRST push to a server, its own profiles are adopted** (D244):
|
||||||
|
// read, imported as profiles for that server alone, and only then pushed back,
|
||||||
|
// so nothing on the server changes and its placements keep spawning. Where a
|
||||||
|
// site profile of the same name already covers it, the site's wins and the
|
||||||
|
// server's own is kept aside as replaced (D251).
|
||||||
|
//
|
||||||
|
// A tick asks each server whether it needs a push — its first, a change to its
|
||||||
|
// set, a restart, a failure worth retrying, or the quarter-hourly audit — and
|
||||||
|
// pushes only then. A server without RunicNPC (or with one older than API 3) is
|
||||||
|
// recorded `absent` and left alone; its events keep Rust's own scientists
|
||||||
|
// (D243).
|
||||||
|
|
||||||
|
const core = require('./core')
|
||||||
|
|
||||||
|
const db = require('./model/npcs/npcs.db')
|
||||||
|
const model = require('./model/npcs/npcs.model')
|
||||||
|
const servers = require('./model/servers/servers.model')
|
||||||
|
const sidecar = require('./sidecarClient')
|
||||||
|
|
||||||
|
const log = core.logger('npcs')
|
||||||
|
|
||||||
|
const TICK_MS = 30 * 1000
|
||||||
|
const AUDIT_MS = 15 * 60 * 1000
|
||||||
|
const FAIL_BACKOFF_MS = 2 * 60 * 1000
|
||||||
|
|
||||||
|
let timer = null
|
||||||
|
let lock = Promise.resolve()
|
||||||
|
|
||||||
|
/** One push at a time: an adoption racing a push would push before it imported. */
|
||||||
|
function withLock(fn) {
|
||||||
|
const run = lock.then(fn, fn)
|
||||||
|
lock = run.catch(() => {})
|
||||||
|
return run
|
||||||
|
}
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
if (timer) return
|
||||||
|
timer = setInterval(() => {
|
||||||
|
tick().catch((err) => log.error('NPC profile push tick failed', { error: err.message }))
|
||||||
|
}, TICK_MS)
|
||||||
|
if (timer.unref) timer.unref()
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop() {
|
||||||
|
if (!timer) return
|
||||||
|
clearInterval(timer)
|
||||||
|
timer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function age(value) {
|
||||||
|
const at = value ? new Date(value).getTime() : NaN
|
||||||
|
return Number.isFinite(at) ? Date.now() - at : Number.MAX_SAFE_INTEGER
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Why this server needs a push now, or null. */
|
||||||
|
function reasonToPush({ server, sync, hash, force }) {
|
||||||
|
if (force) return 'requested'
|
||||||
|
if (server.worldReady === false) return null
|
||||||
|
if (server.online === false) return null
|
||||||
|
if (!sync || !sync.adoptedAt) return 'first'
|
||||||
|
if (sync.state === 'failed' && age(sync.lastAttemptAt) < FAIL_BACKOFF_MS && sync.syncedHash) return null
|
||||||
|
if (sync.state !== 'ok') return 'retry'
|
||||||
|
if (hash !== sync.syncedHash) return 'changed'
|
||||||
|
if (server.bootId && server.bootId !== sync.bootId) return 'restart'
|
||||||
|
if (age(sync.lastAttemptAt) >= AUDIT_MS) return 'audit'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tick({ force = null } = {}) {
|
||||||
|
const [polling, npcServers, syncRows] = await Promise.all([servers.listForPolling(), db.listNpcServers(), db.listSync()])
|
||||||
|
const syncBy = new Map(syncRows.map((s) => [s.serverId, s]))
|
||||||
|
const npcBy = new Map(npcServers.map((s) => [s.id, s]))
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
polling
|
||||||
|
.filter((server) => force === null || force === server.id)
|
||||||
|
.map((server) => withLock(() => pushOne(server, { npc: npcBy.get(server.id) || null, sync: syncBy.get(server.id) || null, force: force !== null }))),
|
||||||
|
)
|
||||||
|
return results.map((r) => (r.status === 'fulfilled' ? r.value : { error: r.reason && r.reason.message }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One server: adopt its own profiles if the site never has (D244), then push
|
||||||
|
* its set. Returns what happened, for the admin's "push now" and the tests.
|
||||||
|
*/
|
||||||
|
async function pushOne(server, { npc, sync, force = false }) {
|
||||||
|
if (!model.npcReady(npc)) {
|
||||||
|
if (!sync || sync.state !== 'absent') await db.putSync(server.id, { state: 'absent', error: model.npcAbsence(npc) })
|
||||||
|
return { server: server.id, outcome: 'absent', reason: model.npcAbsence(npc) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const profiles = await db.listProfiles()
|
||||||
|
const first = model.desiredFor(server.id, profiles)
|
||||||
|
const reason = reasonToPush({ server: npc, sync, hash: first.hash, force })
|
||||||
|
if (!reason) return { server: server.id, outcome: 'current' }
|
||||||
|
|
||||||
|
let adopted = null
|
||||||
|
if (!sync || !sync.adoptedAt) {
|
||||||
|
const read = await sidecar.npcProfiles(server)
|
||||||
|
const data = read.ok ? read.data || {} : null
|
||||||
|
if (!data || data.kind !== 'npc.profiles') {
|
||||||
|
const error = data ? data.message || data.reason || `answered ${data.kind}` : `could not read its profiles (${read.status})`
|
||||||
|
await db.putSync(server.id, { state: 'failed', error })
|
||||||
|
log.warn('could not read a server\'s own NPC profiles', { server: server.id, error })
|
||||||
|
return { server: server.id, outcome: 'failed', error }
|
||||||
|
}
|
||||||
|
// A server a site already manages has nothing of its own left to adopt: its
|
||||||
|
// file is some site's last push. Only a standalone server's are its own.
|
||||||
|
adopted = data.managed ? [] : await model.adopt(server.id, data.profiles || {})
|
||||||
|
if (data.managed) await db.markAdopted(server.id)
|
||||||
|
if (adopted.length) log.info('adopted a server\'s own NPC profiles', { server: server.id, adopted })
|
||||||
|
}
|
||||||
|
|
||||||
|
const desired = adopted && adopted.length ? model.desiredFor(server.id, await db.listProfiles()) : first
|
||||||
|
const pushed = await sidecar.npcProfilesSet(server, desired.profiles)
|
||||||
|
const data = pushed.ok ? pushed.data || {} : null
|
||||||
|
if (!data || data.kind !== 'npc.ok') {
|
||||||
|
const error = data ? data.message || data.reason || `answered ${data.kind}` : `the push did not arrive (${pushed.status})`
|
||||||
|
await db.putSync(server.id, { state: 'failed', error })
|
||||||
|
log.warn('NPC profile push failed', { server: server.id, reason, error })
|
||||||
|
return { server: server.id, outcome: 'failed', error, ...(adopted ? { adopted } : {}) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const refused = data.refused && typeof data.refused === 'object' ? data.refused : {}
|
||||||
|
await db.putSync(server.id, { state: 'ok', syncedHash: desired.hash, bootId: npc.bootId, pushed: desired.map, refused })
|
||||||
|
log.info('pushed NPC profiles', { server: server.id, reason, profiles: Object.keys(desired.profiles).length, refused: Object.keys(refused).length })
|
||||||
|
return { server: server.id, outcome: 'pushed', reason, profiles: Object.keys(desired.profiles), refused, ...(adopted ? { adopted } : {}) }
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { TICK_MS, AUDIT_MS, start, stop, tick, pushOne, reasonToPush }
|
||||||
@@ -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). */
|
/** Everything that moves on one server's map, every layer, unfiltered. The caller filters (§8.5). */
|
||||||
const mapLive = (server) => request(server, '/map/live')
|
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 = {
|
module.exports = {
|
||||||
TIMEOUT_MS,
|
TIMEOUT_MS,
|
||||||
LEASE_TIMEOUT_MS,
|
LEASE_TIMEOUT_MS,
|
||||||
@@ -528,5 +541,9 @@ module.exports = {
|
|||||||
mapChunk,
|
mapChunk,
|
||||||
mapRender,
|
mapRender,
|
||||||
mapLive,
|
mapLive,
|
||||||
|
npcProfiles,
|
||||||
|
npcProfilesSet,
|
||||||
|
npcPlacements,
|
||||||
|
npcPlacement,
|
||||||
joinUrl,
|
joinUrl,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,6 +137,12 @@ test('the classification covers exactly the event kinds the protocol defines, th
|
|||||||
'config.outcome',
|
'config.outcome',
|
||||||
// Protocol 13 (§19, F8, D184). Which plugins a server runs and what each
|
// Protocol 13 (§19, F8, D184). Which plugins a server runs and what each
|
||||||
// registers: an operator's inventory.
|
// 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.loaded',
|
||||||
'plugin.unloaded',
|
'plugin.unloaded',
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user