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