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
335 lines
13 KiB
JavaScript
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,
|
|
}
|