// ── 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, }