Files
Module-Rust/server/model/npcs/npcs.db.js
wtclaude 11619a4dc6 feat(rust): NPC sides, the faction table, an event's escort, ally and tether (runicnpc stage 5)
RunicNPC stage 5 on the site (docs runicnpc/PLAN.md, D253-D272):

- profiles gain the guard role, faction, relations (its own exceptions),
  alertRadius, turrets, hurtByPlayers, hurtsPlayers and kitUse, checked in
  RunicNPC's order and words, and defaulting to "players only" (D255);
- the faction table: one site-wide table, one row per pair and both ways
  (D254, D268), stored in rust_npc_factions, edited on the NPC profiles page
  (PUT /admin/rust/npcs/factions), pushed to every server with its
  profiles and hashed with them, and a standalone server's own pairs
  adopted at its first push with the site's winning (D244, D251);
- the Place NPCs step takes escort (a Steam id or a {placeholder}), an ally
  (a clan from the new rust.options.clans source, or the team of a player)
  and tether (the zone this event made), for a RunicNPC profile only
  (D269, D270, D272);
- a placement may be held inside a zone (tether, D272);
- the bridge's RunicNPC API floor is 4.

Tests: 488 server, 66 client. routes.manifest.json regenerated against the
pinned core (one route added).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-01 07:58:13 -05:00

335 lines
13 KiB
JavaScript

// ── 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 FACTIONS = 'rust_npc_factions'
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`)
}
}
// ── The faction table (stage 5, D254) ─────────────────────────────────────
/** Every pair, `a` before `b`, sorted. */
async function listFactions() {
return (await core.query(`SELECT a, b, relation FROM ${FACTIONS} ORDER BY a ASC, b ASC`)).map((r) => ({ a: r.a, b: r.b, relation: r.relation }))
}
/** Replaces the whole table, in one transaction; the caller has checked it (`checkFactions`). */
async function replaceFactions(rows) {
const conn = await core.pool.getConnection()
try {
await conn.beginTransaction()
await conn.query(`DELETE FROM ${FACTIONS}`)
for (const r of rows) await conn.query(`INSERT INTO ${FACTIONS} (a, b, relation) VALUES (?, ?, ?)`, [r.a, r.b, r.relation])
await conn.commit()
} catch (err) {
await conn.rollback().catch(() => {})
throw err
} finally {
conn.release()
}
}
// ── 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,
listFactions,
replaceFactions,
addKills,
scopeWhere,
ranking,
playerKills,
ownKills,
}