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 servers = require('./model/servers/servers.model')
const zones = require('./model/zones/zones.model')
const zoneOptions = require('./model/zones/zoneOptions')
const voice = require('./model/permissions/voice')
const npcs = require('./model/npcs/npcs.model')
const npcsDb = require('./model/npcs/npcs.db')
const npcProfile = require('./model/npcs/npcProfile')
const { serverFor, transportError, pluginError, perServer, bounded } = require('./eventLeases')
const log = core.logger('world')
@@ -110,6 +113,9 @@ const PERMANENT = new Set([
// and a dome asked of a server without ZoneDomes or the domes helper.
'bad-option',
'dome-unavailable',
// runicnpc stage 4 (D243): a profile the server does not have, or no RunicNPC.
'unknown-profile',
'runicnpc-missing',
])
const BUDGETS = [
@@ -472,7 +478,10 @@ function placeVerb({ id, kind, budget, max, label, description, source, example
required: true,
example,
source,
description: `Which of the server's own ${noun} to place.`,
description:
kind === 'npc'
? "Which NPCs: one of this site's NPC profiles (Admin → Rust NPC profiles, on a server with RunicNPC), or one of the server's own scientists."
: `Which of the server's own ${noun} to place.`,
},
{
name: 'count',
@@ -492,8 +501,12 @@ function placeVerb({ id, kind, budget, max, label, description, source, example
],
async perform({ runId, idempotencyKey, params, verify }) {
const known = PLACEABLE.find((x) => x.key === String(params.prefab || '').trim())
if (!known || known.kind !== kind) {
const picked = String(params.prefab || '').trim()
// D243: one of the site's RunicNPC profiles, beside Rust's own.
const profile = kind === 'npc' && picked.startsWith(npcs.PROFILE_PREFIX) ? picked.slice(npcs.PROFILE_PREFIX.length) : null
const known = profile === null ? PLACEABLE.find((x) => x.key === picked) : null
if (profile !== null ? !npcProfile.NAME_RULE.test(profile) : !known || known.kind !== kind) {
return { ok: false, retry: false, error: `"${params.prefab}" is not one of the ${noun} a Rust server places for events` }
}
@@ -513,6 +526,11 @@ function placeVerb({ id, kind, budget, max, label, description, source, example
const found = await serverFor(where.serverId)
if (!found.ok) return found
if (profile !== null) {
const missing = await profileMissing(found.server, profile)
if (missing) return { ok: false, retry: false, error: missing }
}
if (verify) return { ok: true }
return place(
@@ -521,17 +539,34 @@ function placeVerb({ id, kind, budget, max, label, description, source, example
{
runId: String(runId),
key: idempotencyKey,
prefab: known.key,
...(profile !== null ? { profile } : { prefab: known.key }),
count,
...(spread === undefined ? {} : { spread }),
...where.wire,
},
known.label.toLowerCase(),
profile !== null ? `NPCs of the profile "${profile}"` : known.label.toLowerCase(),
)
},
}
}
/**
* Why a profile cannot be placed on this server, from what the site knows, or
* null (D243). A server without RunicNPC offers only Rust's own until stage 9;
* a profile the site does not push there is not on it. What the server itself
* holds is the plugin's to answer (`unknown-profile`).
*/
async function profileMissing(server, profile) {
const [list, profiles] = await Promise.all([npcsDb.listNpcServers(), npcsDb.listProfiles()])
const here = list.find((s) => s.id === server.id)
const name = server.name || server.id
if (!npcs.npcReady(here)) return `${name} cannot place the profile "${profile}": ${npcs.npcAbsence(here)}. Pick one of the server's own scientists.`
if (!profiles.some((p) => !p.replaced && p.name === profile && npcs.covers(p, server.id))) {
return `the site has no NPC profile "${profile}" for ${name} (Admin → Rust NPC profiles)`
}
return null
}
const ACTIONS = [
{
...WORLD_COMMON,
@@ -688,7 +723,7 @@ const ACTIONS = [
max: MAX_NPCS,
label: 'Place NPCs',
description:
'Scientists or guards at a monument or a point. Taken away at teardown. The game does not save NPCs, so a restart ends them; the ledger then says so.',
"NPCs of one of the site's profiles (RunicNPC), or the server's own scientists or guards, at a monument or a point. Taken away at teardown. NPCs are never saved, so a restart ends them; the ledger then says so.",
source: 'rust.options.npcs',
example: 'npc.scientist',
}),
@@ -720,12 +755,27 @@ const OPTION_SOURCES = [
},
// From the mirror, so both answer with every server off (the field they fill
// must never be taken away by an outage, MODULE_API §2.4). One per verb (D97).
//
// D243: the NPC list puts the site's RunicNPC profiles first, then Rust's own,
// each group named. A site without a server that has RunicNPC has no profile
// rows, and its list reads as it always did.
...['crate', 'npc'].map((kind) => ({
id: kind === 'npc' ? 'rust.options.npcs' : 'rust.options.crates',
label: kind === 'npc' ? 'NPCs' : 'Crates',
description: `The ${kind === 'npc' ? 'NPCs' : 'crates'} a Rust server places for events.`,
description:
kind === 'npc'
? "The site's NPC profiles, on servers with RunicNPC, then the NPCs a Rust server places of its own."
: 'The crates a Rust server places for events.',
async resolve() {
return PLACEABLE.filter((p) => p.kind === kind).map((p) => ({ value: p.key, label: p.label }))
const own = PLACEABLE.filter((p) => p.kind === kind).map((p) => ({ value: p.key, label: p.label }))
if (kind !== 'npc') return own
let profiles = []
try {
profiles = await npcs.optionRows()
} catch (err) {
log.warn('could not list the NPC profiles for the picker', { error: err.message })
}
return profiles.length ? [...profiles, ...own.map((row) => ({ ...row, group: "Rust's own" }))] : own
},
})),
{