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:
176
server/model/npcs/npcProfile.js
Normal file
176
server/model/npcs/npcProfile.js
Normal file
@@ -0,0 +1,176 @@
|
||||
// ── A RunicNPC profile, checked here as RunicNPC checks it (D238) ───────────
|
||||
//
|
||||
// The site authors profiles and pushes them to each server's RunicNPC, which
|
||||
// checks them again and refuses what it cannot use (docs/runicnpc/API.md). The
|
||||
// site checks first so an admin reads the reason on the form, not in a push
|
||||
// report minutes later. The rules below are RunicNPC's `ValidateProfile`, in
|
||||
// the same order and with the same sentences, less the one only the server can
|
||||
// answer: whether it has each kit. That one is checked against the kit list
|
||||
// each server last reported (`npcs.model.js`).
|
||||
//
|
||||
// Pure functions: no database, no sidecar.
|
||||
|
||||
/** RunicNPC's rule for a profile, placement or route name. */
|
||||
const NAME_RULE = /^[a-z0-9_-]{1,40}$/
|
||||
|
||||
/** A plain scientist prefab, as RunicNPC resolves one (`scientistnpc_*`). */
|
||||
const PREFAB_RULE = /^scientistnpc_[a-z0-9_]{1,40}$/
|
||||
|
||||
/** The prefabs the form offers; RunicNPC accepts any plain `scientistnpc_*`. */
|
||||
const PREFABS = ['scientistnpc_roam', 'scientistnpc_heavy', 'scientistnpc_patrol', 'scientistnpc_roamtethered', 'scientistnpc_full_any']
|
||||
|
||||
const ROLES = ['roamer', 'sentry']
|
||||
|
||||
/** D247: how a profile's kills are counted. `server` is the default. */
|
||||
const KILLS_SCOPES = ['server', 'name', 'profile']
|
||||
|
||||
const NAMES_MAX = 20
|
||||
const DISPLAY_NAME_MAX = 32
|
||||
const KITS_MAX = 20
|
||||
const THRESHOLDS_MAX = 10
|
||||
|
||||
/** RunicNPC's defaults (API.md, D238), so a form may leave a value out. */
|
||||
function defaults() {
|
||||
return {
|
||||
names: [],
|
||||
kits: [],
|
||||
prefab: 'scientistnpc_roam',
|
||||
role: 'roamer',
|
||||
movement: { mode: 'wander', radius: 20 },
|
||||
health: 150,
|
||||
damageDealt: 1,
|
||||
damageTaken: { head: 1, body: 1, legs: 1 },
|
||||
aimCone: 2,
|
||||
ranges: { sense: 30, loseTarget: 40, chase: 40, attack: 30 },
|
||||
visionCone: -0.8,
|
||||
sleepDistance: 160,
|
||||
healthThresholds: [],
|
||||
}
|
||||
}
|
||||
|
||||
function num(value) {
|
||||
if (value === null || value === undefined || value === '') return NaN
|
||||
const n = Number(value)
|
||||
return Number.isFinite(n) ? n : NaN
|
||||
}
|
||||
|
||||
/** `wander`, `monument` or `route:<name>` (D233). */
|
||||
function parseMode(mode) {
|
||||
const text = String(mode === undefined || mode === null ? '' : mode).trim()
|
||||
if (text === 'wander' || text === 'monument') return { kind: text }
|
||||
const m = /^route:([a-z0-9_-]{1,40})$/.exec(text)
|
||||
return m ? { kind: 'route', route: m[1] } : null
|
||||
}
|
||||
|
||||
/** A movement, checked: `{ ok, value }` or `{ ok: false, error }`. */
|
||||
function checkMovement(input) {
|
||||
const m = input || {}
|
||||
const mode = String(m.mode === undefined || m.mode === null ? '' : m.mode).trim()
|
||||
const parsed = parseMode(mode)
|
||||
if (!parsed) return { ok: false, error: `movement.mode: '${mode}' is not wander, monument or route:<name>` }
|
||||
const radius = m.radius === undefined || m.radius === null || m.radius === '' ? (parsed.kind === 'wander' ? 20 : 0) : num(m.radius)
|
||||
if (Number.isNaN(radius) || radius < 0) return { ok: false, error: 'movement.radius: a number of metres' }
|
||||
if (parsed.kind === 'wander' && !(radius > 0)) return { ok: false, error: "movement.radius: a wanderer's radius must be above 0" }
|
||||
return { ok: true, value: { mode, radius } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks a profile's body as the admin form sends it, merged over RunicNPC's
|
||||
* defaults. Resolves `{ ok: true, value }` with the body RunicNPC will read, or
|
||||
* `{ ok: false, error }` with RunicNPC's own sentence for the first problem.
|
||||
*/
|
||||
function checkBody(input) {
|
||||
const b = { ...defaults(), ...(input || {}) }
|
||||
const names = Array.isArray(b.names) ? b.names.map((n) => String(n === null || n === undefined ? '' : n).trim()) : null
|
||||
if (!names || names.length === 0 || names.some((n) => !n)) return { ok: false, error: 'names: give at least one, and no blank ones' }
|
||||
if (names.length > NAMES_MAX) return { ok: false, error: `names: at most ${NAMES_MAX}` }
|
||||
if (names.some((n) => n.length > DISPLAY_NAME_MAX)) return { ok: false, error: `names: each at most ${DISPLAY_NAME_MAX} characters` }
|
||||
|
||||
const kits = Array.isArray(b.kits) ? [...new Set(b.kits.map((k) => String(k === null || k === undefined ? '' : k).trim()).filter(Boolean))] : null
|
||||
if (!kits || kits.length === 0) return { ok: false, error: 'kits: give at least one; Kits is how an NPC is equipped (D217)' }
|
||||
if (kits.length > KITS_MAX) return { ok: false, error: `kits: at most ${KITS_MAX}` }
|
||||
|
||||
const prefab = String(b.prefab || '').trim()
|
||||
if (!PREFAB_RULE.test(prefab)) return { ok: false, error: `prefab: '${prefab}' is not one of Rust's scientist prefabs (scientistnpc_*)` }
|
||||
|
||||
if (!ROLES.includes(b.role)) return { ok: false, error: `role: '${b.role}' is not roamer or sentry` }
|
||||
|
||||
const movement = checkMovement(b.movement)
|
||||
if (!movement.ok) return movement
|
||||
|
||||
const health = num(b.health)
|
||||
if (!(health > 0)) return { ok: false, error: 'health: must be above 0' }
|
||||
const damageDealt = num(b.damageDealt)
|
||||
if (Number.isNaN(damageDealt) || damageDealt < 0) return { ok: false, error: 'damageDealt: must not be negative' }
|
||||
|
||||
const taken = b.damageTaken || {}
|
||||
const damageTaken = { head: num(taken.head), body: num(taken.body), legs: num(taken.legs) }
|
||||
if (Object.values(damageTaken).some((v) => Number.isNaN(v) || v < 0)) return { ok: false, error: 'damageTaken: head, body and legs must not be negative' }
|
||||
|
||||
const aimCone = num(b.aimCone)
|
||||
if (Number.isNaN(aimCone) || aimCone < 0) return { ok: false, error: 'aimCone: must not be negative' }
|
||||
|
||||
const r = b.ranges || {}
|
||||
const ranges = { sense: num(r.sense), loseTarget: num(r.loseTarget), chase: num(r.chase), attack: num(r.attack) }
|
||||
if (!(ranges.sense > 0) || !(ranges.attack > 0) || !(ranges.loseTarget >= ranges.sense) || !(ranges.chase >= 0)) {
|
||||
return { ok: false, error: 'ranges: sense and attack above 0, loseTarget at least sense, chase not negative' }
|
||||
}
|
||||
|
||||
const visionCone = num(b.visionCone)
|
||||
if (!(visionCone >= -1 && visionCone <= 1)) return { ok: false, error: 'visionCone: between -1 and 1' }
|
||||
const sleepDistance = num(b.sleepDistance)
|
||||
if (!(sleepDistance >= 0)) return { ok: false, error: 'sleepDistance: 0 (never sleeps) or more' }
|
||||
|
||||
const thresholds = Array.isArray(b.healthThresholds) ? b.healthThresholds.map(num) : null
|
||||
if (!thresholds || thresholds.some((t) => !(t > 0 && t < 1))) return { ok: false, error: 'healthThresholds: fractions between 0 and 1' }
|
||||
if (thresholds.length > THRESHOLDS_MAX) return { ok: false, error: `healthThresholds: at most ${THRESHOLDS_MAX}` }
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
names,
|
||||
kits,
|
||||
prefab,
|
||||
role: b.role,
|
||||
movement: movement.value,
|
||||
health,
|
||||
damageDealt,
|
||||
damageTaken,
|
||||
aimCone,
|
||||
ranges,
|
||||
visionCone,
|
||||
sleepDistance,
|
||||
healthThresholds: [...new Set(thresholds)].sort((x, y) => y - x),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A profile read from a server's own RunicNPC (D244), made into a body this
|
||||
* module will save. Whatever RunicNPC holds is kept as it is; only a shape this
|
||||
* module could not push back is refused, with the reason.
|
||||
*/
|
||||
function fromServer(name, body) {
|
||||
if (!NAME_RULE.test(String(name || ''))) return { ok: false, error: `'${name}' is not a profile name RunicNPC could hold` }
|
||||
return checkBody(body || {})
|
||||
}
|
||||
|
||||
/** A player-facing label for a profile: its first NPC name, else its own name. */
|
||||
function labelOf(profile) {
|
||||
const names = profile && profile.body && Array.isArray(profile.body.names) ? profile.body.names : []
|
||||
return names[0] || (profile && profile.name) || ''
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
NAME_RULE,
|
||||
PREFAB_RULE,
|
||||
PREFABS,
|
||||
ROLES,
|
||||
KILLS_SCOPES,
|
||||
defaults,
|
||||
parseMode,
|
||||
checkMovement,
|
||||
checkBody,
|
||||
fromServer,
|
||||
labelOf,
|
||||
}
|
||||
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,
|
||||
}
|
||||
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