feat(rust): NPC profile and placement routes, the event picker, triggers and titles (runicnpc stage 4)

Admin: /admin/rust/npcs for profiles (create, change, delete, restore a
replaced one, push now) and each server's placements (list, add from a map
point, change, remove, rename, respawn). Public: the profiles a leaderboard
ranks by, one profile's ranking counted as the profile says (D247, D250), and
one player's kills by profile (D252). Player: your own kills by profile.

The Place NPCs step offers the site's profiles first, then Rust's own
(D243). rust.npc.died and rust.npc.health are triggers a phase can wait on.
A title rule can rank a profile's kills. Swagger fragment, engagement and
route manifests regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-09-30 04:34:43 -05:00
parent 564a234890
commit fa16f0ad2e
20 changed files with 3224 additions and 24 deletions

View File

@@ -9,6 +9,7 @@ const serversDb = require('../servers/servers.db')
const db = require('./titles.db')
const lists = require('./lists')
const titles = require('./titles')
const npcs = require('../npcs/npcs.model')
/** How long one server's answer is reused. The same as the push loop's tick. */
const MEMO_MS = 30 * 1000
@@ -25,10 +26,15 @@ function shapeSettings(mode, rules) {
return {
mode: titles.normaliseMode(mode && mode.mode),
max: mode && Number(mode.max) ? Number(mode.max) : 2,
rules: rules.map((r) => ({ stat: r.stat, topN: Number(r.topN), text: r.text, color: r.color })),
rules: rules.map(rule),
}
}
/** One stored rule as the form and the evaluator read it; a profile's kills name the profile (D250). */
function rule(r) {
return { stat: r.stat, topN: Number(r.topN), text: r.text, color: r.color, ...(r.stat === 'profilekills' ? { profile: Number(r.profile) || null } : {}) }
}
/** Every server's settings, keyed by id, for the admin server list. */
async function settingsByServer() {
const [modes, rules] = await Promise.all([db.listModes(), db.listAllRules()])
@@ -36,7 +42,7 @@ async function settingsByServer() {
for (const r of rules) {
const s = out.get(r.serverId)
if (s) s.rules.push({ stat: r.stat, topN: Number(r.topN), text: r.text, color: r.color })
if (s) s.rules.push(rule(r))
}
return out
@@ -102,14 +108,24 @@ async function heldFor(serverId, { wipeId, now = Date.now() } = {}) {
// One query per STAT, at the deepest top N any rule on it asks for, rather
// than one per rule: two rules on kills read the same rows.
// A profile's kills are one standing per profile (`titles.standingKey`).
const depth = new Map()
for (const r of rules) if (titles.STATS[r.stat]) depth.set(r.stat, Math.max(depth.get(r.stat) || 0, r.topN))
const ruleBy = new Map()
for (const r of rules) {
if (!titles.STATS[r.stat]) continue
const key = titles.standingKey(r)
depth.set(key, Math.max(depth.get(key) || 0, r.topN))
ruleBy.set(key, r)
}
await Promise.all(
[...depth.entries()].map(async ([stat, limit]) => {
const rows = await db.standings({ serverId, wipeId, source: titles.STATS[stat].source, lists, limit })
[...depth.entries()].map(async ([key, limit]) => {
const r = ruleBy.get(key)
const rows = r.stat === 'profilekills'
? await npcs.standing(serverId, r.profile, wipeId, limit)
: await db.standings({ serverId, wipeId, source: titles.STATS[r.stat].source, lists, limit })
// SUM() and MAX() come back as strings; the rules compare numbers.
standings[stat] = rows.map((r) => ({ steamId: r.steamId, value: Number(r.value) || 0 }))
standings[key] = rows.map((row) => ({ steamId: row.steamId, value: Number(row.value) || 0 }))
}),
)