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