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

@@ -16,7 +16,9 @@ const events = require('../../model/events/events.model')
const map = require('../../model/map/map.model')
const mapDb = require('../../model/map/map.db')
const mapLive = require('../../mapLive')
const npcs = require('../../model/npcs/npcs.model')
const servers = require('../../model/servers/servers.model')
const serversDb = require('../../model/servers/servers.db')
const titles = require('../../model/titles/titles.model')
const visibility = require('../../model/visibility/visibility.model')
@@ -130,6 +132,51 @@ async function listLeaderboard(req, res) {
}
}
/** GET /servers/:id/npc-profiles — what the leaderboard's profile picker offers (D250). */
async function listNpcProfiles(req, res) {
try {
res.json({ profiles: await npcs.boardProfiles(req.params.id) })
} catch (err) {
log.error('failed to read the NPC profiles', { server: req.params.id, error: err.message })
res.status(500).json({ message: 'Failed to read the NPC profiles' })
}
}
/** GET /servers/:id/npc-leaderboard?profile= — one profile's kills, counted as it says (D247, D250). */
async function npcLeaderboard(req, res) {
const profileId = Number(req.query.profile)
if (!Number.isInteger(profileId) || profileId < 1) return res.status(400).json({ message: 'profile is a profile id' })
try {
const state = await serversDb.getState(req.params.id)
const wipeId = req.query.wipe ? String(req.query.wipe) : null
const currentWipe = Boolean(wipeId && state && state.wipeId === wipeId)
const [ranked, held] = await Promise.all([
npcs.ranking({ serverId: req.params.id, profileId, wipeId, currentWipe, limit: req.query.limit }),
titles.currentFor(req.params.id),
])
res.json({ profile: ranked.profile, leaderboard: ranked.rows.map((row) => ({ ...row, kills: row.value, titles: held.get(row.steamId) || [] })) })
} catch (err) {
if (err instanceof npcs.NpcError) return res.status(err.status).json({ message: err.message })
log.error('failed to read an NPC leaderboard', { server: req.params.id, error: err.message })
return res.status(500).json({ message: 'Failed to read the leaderboard' })
}
}
/** GET /servers/:id/players/:steamId/npc-kills — one leaderboard row, opened (D252). */
async function playerNpcKills(req, res) {
const steamId = String(req.params.steamId || '')
if (!/^\d{1,20}$/.test(steamId)) return res.status(400).json({ message: 'steamId is a Steam id' })
try {
const wipeId = req.query.wipe ? String(req.query.wipe) : null
res.json({ steamId, kills: await npcs.playerKills({ serverId: req.params.id, steamId, wipeId }) })
} catch (err) {
log.error('failed to read a player’s NPC kills', { server: req.params.id, error: err.message })
res.status(500).json({ message: 'Failed to read the kills' })
}
}
async function listWipes(req, res) {
try {
res.json({ wipes: await events.wipes(req.params.id) })
@@ -353,6 +400,9 @@ module.exports = {
getServer,
listEvents,
listLeaderboard,
listNpcProfiles,
npcLeaderboard,
playerNpcKills,
listWipes,
listOnline,
listClans,

View File

@@ -92,6 +92,45 @@ rustRouter.get(
servers.listLeaderboard,
)
rustRouter.get(
'/servers/:id/npc-profiles',
// #swagger.tags = ['Public · Rust']
// #swagger.summary = 'The NPC profiles a server’s leaderboard can rank by'
// #swagger.description = 'The site’s RunicNPC profiles pushed to this server (docs/runicnpc/PLAN.md stage 4, D250): each one’s id, its name, a label (the first of its NPC names), and how its kills are counted (D247) — `server` (this server’s kills of that name), `name` (every server’s kills of that name) or `profile` (this site profile’s, wherever it was pushed). Empty on a site without RunicNPC.'
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } }
/* #swagger.responses[200] = { description: 'The profiles', content: { "application/json": { schema: { $ref: "#/components/schemas/RustNpcBoardProfiles" } } } } */
siteMode,
servers.listNpcProfiles,
)
rustRouter.get(
'/servers/:id/npc-leaderboard',
// #swagger.tags = ['Public · Rust']
// #swagger.summary = 'Who has killed the most NPCs of one profile'
// #swagger.description = 'One RunicNPC profile’s kills, most first, counted as the profile says (D247, D250). Per wipe when `wipe` is given, all time otherwise. A profile counted across servers reaches every server’s current wipe when `wipe` is this server’s current one, and only this server’s rows for an older wipe. Each row carries the chat `titles` the player holds now, as the main leaderboard’s do.'
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } }
// #swagger.parameters['profile'] = { in: 'query', required: true, description: 'The profile’s id, from npc-profiles', schema: { type: 'integer' } }
// #swagger.parameters['wipe'] = { in: 'query', required: false, description: 'Restrict to one wipe id', schema: { type: 'string' } }
// #swagger.parameters['limit'] = { in: 'query', required: false, description: 'Rows to return, capped at 200', schema: { type: 'integer' } }
/* #swagger.responses[200] = { description: 'The ranking', content: { "application/json": { schema: { $ref: "#/components/schemas/RustNpcLeaderboard" } } } } */
/* #swagger.responses[404] = { description: 'No such profile on this server' } */
siteMode,
servers.npcLeaderboard,
)
rustRouter.get(
'/servers/:id/players/:steamId/npc-kills',
// #swagger.tags = ['Public · Rust']
// #swagger.summary = 'One player’s kills of each NPC profile on a server'
// #swagger.description = 'What opening a leaderboard row shows (D252): the player’s kills of RunicNPC’s NPCs by profile on this server, for the wipe the page shows (`wipe`), or all time without it. Counted by profile name on this server, whatever a profile’s leaderboard counts; labelled with the site’s name for the profile where it has one. The same numbers the leaderboard already makes public, split by profile.'
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } }
// #swagger.parameters['steamId'] = { in: 'path', required: true, description: 'The player’s Steam id', schema: { type: 'string' } }
// #swagger.parameters['wipe'] = { in: 'query', required: false, description: 'Restrict to one wipe id', schema: { type: 'string' } }
/* #swagger.responses[200] = { description: 'The kills', content: { "application/json": { schema: { $ref: "#/components/schemas/RustNpcPlayerKills" } } } } */
siteMode,
servers.playerNpcKills,
)
rustRouter.get(
'/servers/:id/wipes',
// #swagger.tags = ['Public · Rust']