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
510 lines
22 KiB
JavaScript
510 lines
22 KiB
JavaScript
// ── 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: 3 for placements (D249), 4 for the faction table and an event's orders (stage 5). */
|
|
const API_NEEDED = 4
|
|
|
|
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. The faction table (stage 5, D254) is the
|
|
* site's one table, sent to every server with its profiles and hashed with them,
|
|
* so an edit to it is pushed like an edit to a profile.
|
|
*/
|
|
function desiredFor(serverId, profiles, factions = []) {
|
|
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 rows = (factions || []).map((f) => ({ a: f.a, b: f.b, relation: f.relation }))
|
|
const hash = crypto
|
|
.createHash('sha256')
|
|
.update(JSON.stringify([Object.keys(set).sort().map((n) => [n, set[n]]), rows]))
|
|
.digest('hex')
|
|
return { profiles: set, factions: rows, 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
|
|
}
|
|
|
|
/**
|
|
* A standalone server's own faction table, adopted with its profiles (D244):
|
|
* each pair the site's table does not already set is added, and where the site
|
|
* sets a pair the site wins (D251). Returns the pairs added.
|
|
*/
|
|
async function adoptFactions(theirs) {
|
|
const checked = shape.checkFactions(Array.isArray(theirs) ? theirs : [])
|
|
if (!checked.ok || checked.value.length === 0) return []
|
|
const mine = await db.listFactions()
|
|
const have = new Set(mine.map((f) => `${f.a}|${f.b}`))
|
|
const added = checked.value.filter((f) => !have.has(`${f.a}|${f.b}`))
|
|
if (added.length) await db.replaceFactions(shape.checkFactions([...mine, ...added]).value)
|
|
return added
|
|
}
|
|
|
|
// ── The faction table (stage 5, D254, D268) ─────────────────────────────────
|
|
|
|
async function getFactions() {
|
|
return db.listFactions()
|
|
}
|
|
|
|
/** Replaces the site's faction table, checked as RunicNPC checks it, and pushes it to every server. */
|
|
async function setFactions(input) {
|
|
const checked = shape.checkFactions(input)
|
|
if (!checked.ok) throw new NpcError(checked.error)
|
|
await db.replaceFactions(checked.value)
|
|
await db.markDirty(null)
|
|
return db.listFactions()
|
|
}
|
|
|
|
// ── The admin page ──────────────────────────────────────────────────────────
|
|
|
|
async function describe() {
|
|
const [list, profiles, sync, factions] = await Promise.all([db.listNpcServers(), db.listProfiles(), db.listSync(), db.listFactions()])
|
|
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(),
|
|
// Stage 5: the faction table, the factions the profiles name, and what the form offers.
|
|
factions,
|
|
factionNames: [...new Set(profiles.map((p) => p.body && p.body.faction).filter(Boolean))].sort(),
|
|
builtInFactions: shape.BUILT_IN_FACTIONS,
|
|
roles: shape.ROLES,
|
|
turrets: shape.TURRETS,
|
|
kitUses: shape.KIT_USES,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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) {
|
|
const names = new Map((await db.listNpcServers()).map((srv) => [srv.id, srv.name || srv.id]))
|
|
const where = existing.servers.map((id) => names.get(id) || id).join(', ')
|
|
throw new NpcError(`the site's profile "${clash.name}" is on ${where}: 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
|
|
}
|
|
// D272: a ZoneManager zone its NPCs never leave. The server checks that the
|
|
// zone exists and holds the spot, and refuses the placement otherwise.
|
|
const tether = p.tether === undefined || p.tether === null ? '' : String(p.tether).trim()
|
|
if (tether) {
|
|
if (!TETHER_RULE.test(tether)) throw new NpcError('tether: a ZoneManager zone id')
|
|
body.tether = tether
|
|
}
|
|
return body
|
|
}
|
|
|
|
/** RunicNPC's rule for a zone id: one plain word. */
|
|
const TETHER_RULE = /^[A-Za-z0-9_.:-]{1,64}$/
|
|
|
|
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 = {
|
|
adoptFactions,
|
|
getFactions,
|
|
setFactions,
|
|
TETHER_RULE,
|
|
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,
|
|
}
|