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

@@ -159,6 +159,14 @@ const HEADLINES = Object.freeze({
title: `${d.player || d.steamId} was unbanned on ${d.server}`,
intro: `The ban on ${d.player || d.steamId} (${d.steamId}) was lifted.`,
}),
'rust.npc.died': (d) => ({
title: `${d.npc || d.profile} was killed on ${d.server}`,
intro: `${d.killer ? `${d.killer} killed` : 'Something killed'} ${d.npc || 'an NPC'} (${d.profile}) on ${d.server}.`,
}),
'rust.npc.health': (d) => ({
title: `${d.npc || d.profile} is below ${d.percent}% on ${d.server}`,
intro: `${d.npc || 'An NPC'} (${d.profile}) fell to ${d.percent}% of its health on ${d.server}.`,
}),
'rust.login.denied': (d) => ({
title: `A login to ${d.server} was not approved`,
intro: `${d.player || 'Someone'} (${d.steamId}) tried to join ${d.server} and was not let in within a minute.`,
@@ -369,7 +377,49 @@ async function onBan(server, item, frame) {
}) ? 1 : 0
}
/**
* RunicNPC's NPCs (runicnpc stage 4): a death, and a health threshold. What a
* phase gate counts, so the window is short: a death an hour old is history, not
* a wave falling. The dedupe key is the NPC's net id, which the game never
* reuses within a boot.
*/
const NPC_MAX_AGE_MS = 15 * 60 * 1000
function npcVars(server, frame) {
return {
...serverVars(server),
profile: str(frame.profile),
npc: str(frame.name),
byEvent: Boolean(frame.runId),
runId: str(frame.runId),
placement: str(frame.placement),
}
}
async function onNpc(server, item, frame) {
const t = frameTime(item, frame)
if (!stillNews(t, NPC_MAX_AGE_MS) || !str(frame.profile)) return 0
if (frame.kind === 'npc.died') {
const contributors = Array.isArray(frame.contributors) ? frame.contributors : []
return fire(T['rust.npc.died'], {
data: { ...npcVars(server, frame), killer: str(frame.killerName), killerSteamId: str(frame.killerId), contributors: contributors.length },
dedupeKey: dedupeKey('npc.died', server.id, frame.netId, t),
occurredAt: t,
}) ? 1 : 0
}
const percent = Math.round((Number(frame.threshold) || 0) * 100)
return fire(T['rust.npc.health'], {
data: { ...npcVars(server, frame), percent },
dedupeKey: dedupeKey('npc.health', server.id, frame.netId, percent),
occurredAt: t,
}) ? 1 : 0
}
const HANDLERS = Object.freeze({
'npc.died': onNpc,
'npc.health': onNpc,
'entity.destroyed': onRaid,
'server.wipe': onWipe,
'clan.member.left': onClan,

View File

@@ -407,7 +407,72 @@ const MODERATION = [
},
]
const TRIGGERS = Object.freeze([RAID, ...BROADCASTS, ACCOUNT, REWARD, ...CLANS, ...MODERATION])
// ── RunicNPC (docs/runicnpc/PLAN.md stage 4) ───────────────────────────────
//
// What an event's phase waits on: "8 guards died" (waves) and "the boss fell
// below 50%". Core counts a gate's firings from phase entry and does not know
// which run an NPC belongs to, so each firing says its profile, whether an event
// placed it and which run did; a gate's `where` names what it waits for. A
// profile only events place is the plain way to count only an event's NPCs.
//
// `staff`, both halves: a death names who killed it. A boss announced to
// players is stage 6's, with its own trigger.
const NPC_VARS = [
{ name: 'profile', type: 'string', required: true, example: 'guard',
description: 'The RunicNPC profile the NPC was made from. The cooldown subject, and what a phase gate usually names.' },
{ name: 'npc', type: 'string', required: false, example: 'Gate Guard',
description: 'The NPC\'s own name, as the victim\'s death screen shows it.' },
{ name: 'byEvent', type: 'boolean', required: true, example: true,
description: 'Whether an event placed it (rather than an admin\'s placement or another plugin).' },
{ name: 'runId', type: 'string', required: false, example: '42',
description: 'The event run that placed it, when one did.' },
{ name: 'placement', type: 'string', required: false, example: 'guard-3',
description: 'The placement it came from, when it came from one.' },
]
const NPCS = [
{
id: 'rust.npc.died',
label: 'A RunicNPC NPC died',
description: 'One of RunicNPC\'s NPCs was killed. A phase can wait for a number of them: "8 guards died".',
kind: 'event',
subjectKey: 'profile',
audience: 'staff',
ceiling: 'staff',
version: V1,
variables: [
...SERVER,
...HEADLINE,
...NPC_VARS,
{ name: 'killer', type: 'string', required: false, example: 'Marisol',
description: 'The player who landed the killing blow. Absent when no player did.' },
{ name: 'killerSteamId', type: 'string', required: false, example: '76561198000000002',
description: 'Their Steam id.' },
{ name: 'contributors', type: 'int', required: true, example: 3,
description: 'How many players took health from it, the killer included.' },
],
},
{
id: 'rust.npc.health',
label: 'A RunicNPC NPC fell to a health threshold',
description: 'One of RunicNPC\'s NPCs fell to a fraction of its health its profile names. A phase can wait for "the boss below 50%".',
kind: 'event',
subjectKey: 'profile',
audience: 'staff',
ceiling: 'staff',
version: V1,
variables: [
...SERVER,
...HEADLINE,
...NPC_VARS,
{ name: 'percent', type: 'int', required: true, example: 50,
description: 'The threshold it fell to, as a percentage of its health: 50 for half.' },
],
},
]
const TRIGGERS = Object.freeze([RAID, ...BROADCASTS, ACCOUNT, REWARD, ...CLANS, ...MODERATION, ...NPCS])
const TRIGGER_IDS = Object.freeze(Object.fromEntries(TRIGGERS.map((t) => [t.id, t.id])))