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:
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,
|
||||
}
|
||||
Reference in New Issue
Block a user