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:
@@ -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,
|
||||
|
||||
@@ -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])))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user