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

@@ -30,6 +30,9 @@ const crypto = require('node:crypto')
* `best` a column's MAX: the longest single kill (D174)
* `weapons` a named list's kills in `rust_weapon_kills` (§5.3)
* `gathered` a named list's amount in `rust_gather_totals` (D159)
* `npcProfile` one site NPC profile's kills, counted as the profile says
* (runicnpc stage 4, D247, D250). A rule on it names the profile,
* and carries its own text, like playtime.
*
* An admin may rename any category that has a default (D175); the ids are what
* a rule stores, so they never change.
@@ -59,6 +62,15 @@ const STATS = {
explosives: { label: 'Explosives thrown', title: 'Demolitionist', source: { sum: 'explosives' } },
missions: { label: 'Missions completed', title: 'Wayfarer', source: { sum: 'missions' } },
playtime: { label: 'Playtime', title: null, source: { sum: 'playtime_sec' } },
profilekills: { label: 'Kills of an NPC profile', title: null, source: { npcProfile: true } },
}
/**
* The standing a rule ranks by. One per stat, except a profile's kills, which
* are one per profile: two rules on two profiles rank two different boards.
*/
function standingKey(rule) {
return rule && rule.stat === 'profilekills' ? `profilekills:${rule.profile}` : rule && rule.stat
}
/** The categories an admin can rename: every one with a shipped title (D175). */
@@ -134,7 +146,11 @@ function validateSettings(body) {
const color = String(r.color || '').trim().toLowerCase()
if (!/^#[0-9a-f]{6}$/.test(color)) errors.push(`rule ${n}: colour must look like #ffaa55`)
clean.push({ stat: r.stat, topN, text, color })
// D250: a profile's kills name the site profile ranked.
const profile = r.stat === 'profilekills' ? Number(r.profile) : null
if (r.stat === 'profilekills' && !(Number.isInteger(profile) && profile > 0)) errors.push(`rule ${n}: pick the NPC profile whose kills it ranks`)
clean.push({ stat: r.stat, topN, text, color, ...(r.stat === 'profilekills' ? { profile } : {}) })
}
return errors.length ? { ok: false, errors } : { ok: true, value: { mode, max, rules: clean } }
@@ -194,7 +210,7 @@ function evaluate(rules, standings, { mode = 'first', max = 2 } = {}) {
for (const rule of rules || []) {
if (!STATS[rule.stat]) continue
const rows = (standings[rule.stat] || []).filter((row) => Number(row.value) > 0).slice(0, rule.topN)
const rows = (standings[standingKey(rule)] || []).filter((row) => Number(row.value) > 0).slice(0, rule.topN)
for (const row of rows) {
if (!held.has(row.steamId)) held.set(row.steamId, [])
@@ -230,6 +246,7 @@ function digest(set) {
module.exports = {
STATS,
standingKey,
CATEGORY_IDS,
categoryTitle,
validateCategory,