feat(rust): bosses and passive NPCs on the profile form, and public boss triggers (runicnpc stage 6)
All checks were successful
PR Checks / client-build (pull_request) Successful in 20s
PR Checks / server-tests (pull_request) Successful in 24s
PR Checks / frozen-manifest (pull_request) Successful in 46s

The profile form's boss box (D273-D277): the bar's distance, where its
lines are said, the spawn and death lines, and phases at health
percentages, each with optional damage, aim, speed, ranges, a kit swap,
adds of another profile and a line. The passive role (D278, D279,
D288): press E answers with a chat line or a window, it moves as its
profile says (now including `stand`), and it starts unhurtable.

The site checks both as RunicNPC does, in its words; a phase's kit is
checked on each server like the profile's own, and its adds must name a
site profile that is not a boss.

Public triggers rust.boss.spawned, rust.boss.phase and rust.boss.killed
(D277) from the bridge's new frames. In the feed, a boss appearing is
public; its death names the killer, so it sits behind the presence
setting; its phases are staff's, as the game tells them only to players
near it (D286). engagement-triggers.json 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-10-05 10:54:28 -05:00
parent 620ea63b54
commit f2ca314d10
11 changed files with 855 additions and 15 deletions

View File

@@ -19,7 +19,21 @@ const PREFAB_RULE = /^scientistnpc_[a-z0-9_]{1,40}$/
/** The prefabs the form offers; RunicNPC accepts any plain `scientistnpc_*`. */
const PREFABS = ['scientistnpc_roam', 'scientistnpc_heavy', 'scientistnpc_patrol', 'scientistnpc_roamtethered', 'scientistnpc_full_any']
const ROLES = ['roamer', 'sentry', 'guard']
/** Stage 6 adds passive (D278, D279): it never fights, and answers E. */
const ROLES = ['roamer', 'sentry', 'guard', 'passive']
/** D277: where a boss's lines are said. */
const ANNOUNCES = ['chat', 'popup']
/** D278: what pressing E on a passive NPC does. */
const USE_MODES = ['chat', 'window']
const LINE_MAX = 256
const USE_LINES_MAX = 20
const WINDOW_TITLE_MAX = 80
const WINDOW_TEXT_MAX = 2000
const PHASES_MAX = 10
const ADDS_MAX = 20
/** Rust's own two factions (D254). Neither can be a profile's faction. */
const BUILT_IN_FACTIONS = ['scientists', 'animals']
@@ -67,6 +81,9 @@ function defaults() {
hurtByPlayers: true,
hurtsPlayers: true,
kitUse: { heal: false, grenades: false, melee: false, rockets: false, flamethrower: false },
// Stage 6 (D273, D278): neither, until the form turns one on.
boss: null,
use: null,
}
}
@@ -119,10 +136,10 @@ function num(value) {
return Number.isFinite(n) ? n : NaN
}
/** `wander`, `monument` or `route:<name>` (D233). */
/** `wander`, `monument`, `route:<name>` (D233), or `stand` for a passive NPC (D288). */
function parseMode(mode) {
const text = String(mode === undefined || mode === null ? '' : mode).trim()
if (text === 'wander' || text === 'monument') return { kind: text }
if (text === 'wander' || text === 'monument' || text === 'stand') return { kind: text }
const m = /^route:([a-z0-9_-]{1,40})$/.exec(text)
return m ? { kind: 'route', route: m[1] } : null
}
@@ -132,7 +149,7 @@ function checkMovement(input) {
const m = input || {}
const mode = String(m.mode === undefined || m.mode === null ? '' : m.mode).trim()
const parsed = parseMode(mode)
if (!parsed) return { ok: false, error: `movement.mode: '${mode}' is not wander, monument or route:<name>` }
if (!parsed) return { ok: false, error: `movement.mode: '${mode}' is not wander, monument, route:<name> or (passive only) stand` }
const radius = m.radius === undefined || m.radius === null || m.radius === '' ? (parsed.kind === 'wander' ? 20 : 0) : num(m.radius)
if (Number.isNaN(radius) || radius < 0) return { ok: false, error: 'movement.radius: a number of metres' }
if (parsed.kind === 'wander' && !(radius > 0)) return { ok: false, error: "movement.radius: a wanderer's radius must be above 0" }
@@ -146,6 +163,8 @@ function checkMovement(input) {
*/
function checkBody(input) {
const b = { ...defaults(), ...(input || {}) }
// D279: a passive NPC cannot be hurt unless its profile says it can, as RunicNPC reads it.
if (b.role === 'passive' && (!input || input.hurtByPlayers === undefined || input.hurtByPlayers === null)) b.hurtByPlayers = false
const names = Array.isArray(b.names) ? b.names.map((n) => String(n === null || n === undefined ? '' : n).trim()) : null
if (!names || names.length === 0 || names.some((n) => !n)) return { ok: false, error: 'names: give at least one, and no blank ones' }
if (names.length > NAMES_MAX) return { ok: false, error: `names: at most ${NAMES_MAX}` }
@@ -158,10 +177,13 @@ function checkBody(input) {
const prefab = String(b.prefab || '').trim()
if (!PREFAB_RULE.test(prefab)) return { ok: false, error: `prefab: '${prefab}' is not one of Rust's scientist prefabs (scientistnpc_*)` }
if (!ROLES.includes(b.role)) return { ok: false, error: `role: '${b.role}' is not roamer, sentry or guard` }
if (!ROLES.includes(b.role)) return { ok: false, error: `role: '${b.role}' is not roamer, sentry, guard or passive` }
const movement = checkMovement(b.movement)
if (!movement.ok) return movement
if (movement.value.mode === 'stand' && b.role !== 'passive') {
return { ok: false, error: "movement.mode: stand is a passive NPC's; a fighting NPC that stands is a sentry" }
}
const health = num(b.health)
if (!(health > 0)) return { ok: false, error: 'health: must be above 0' }
@@ -213,6 +235,26 @@ function checkBody(input) {
const kitUse = {}
for (const k of KIT_USES) kitUse[k] = kit[k] === true
// ---- stage 6 (D273–D279), in RunicNPC's order and words ----
const passive = b.role === 'passive'
const hasBoss = b.boss !== undefined && b.boss !== null
const hasUse = b.use !== undefined && b.use !== null
if (passive && hasBoss) return { ok: false, error: 'boss: a passive NPC never fights, so it cannot be a boss' }
if (passive && !hasUse) return { ok: false, error: 'use: a passive NPC says what pressing E on it does (D278)' }
if (hasUse && !passive) return { ok: false, error: 'use: only a passive NPC answers E (D278)' }
let use = null
if (hasUse) {
const checked = checkUse(b.use)
if (!checked.ok) return checked
use = checked.value
}
let boss = null
if (hasBoss) {
const checked = checkBoss(b.boss)
if (!checked.ok) return checked
boss = checked.value
}
return {
ok: true,
value: {
@@ -236,10 +278,113 @@ function checkBody(input) {
hurtByPlayers: b.hurtByPlayers,
hurtsPlayers: b.hurtsPlayers,
kitUse,
boss,
use,
},
}
}
function text(value) {
return value === undefined || value === null ? '' : String(value)
}
/** D278: `{ mode: chat|window, lines, title, text }`, checked as RunicNPC's `ValidateUse`. */
function checkUse(input) {
if (typeof input !== 'object' || Array.isArray(input)) return { ok: false, error: 'use: an object' }
const mode = input.mode === undefined || input.mode === null || input.mode === '' ? 'chat' : String(input.mode)
if (!USE_MODES.includes(mode)) return { ok: false, error: `use.mode: '${mode}' is not chat or window` }
const lines = Array.isArray(input.lines) ? input.lines.map((l) => text(l).trim()) : []
if (mode === 'chat' && (lines.length === 0 || lines.some((l) => !l))) {
return { ok: false, error: 'use.lines: a chat answer gives at least one line, and no blank ones' }
}
if (lines.length > USE_LINES_MAX) return { ok: false, error: `use.lines: at most ${USE_LINES_MAX}` }
if (lines.some((l) => l.length > LINE_MAX)) return { ok: false, error: `use.lines: each line is at most ${LINE_MAX} characters` }
const title = text(input.title).trim()
const body = text(input.text)
if (mode === 'window' && !title && !body.trim()) return { ok: false, error: 'use: a window has a title or some text' }
if (title.length > WINDOW_TITLE_MAX) return { ok: false, error: `use.title: at most ${WINDOW_TITLE_MAX} characters` }
if (body.length > WINDOW_TEXT_MAX) return { ok: false, error: `use.text: at most ${WINDOW_TEXT_MAX} characters` }
return { ok: true, value: { mode, lines: lines.filter(Boolean), title, text: body } }
}
/** Optional ranges in a phase: absent, or a whole set checked as a profile's. */
function phaseRanges(r) {
if (r === undefined || r === null) return { ok: true, value: null }
const ranges = { sense: num(r.sense), loseTarget: num(r.loseTarget), chase: num(r.chase), attack: num(r.attack) }
if (!(ranges.sense > 0) || !(ranges.attack > 0) || !(ranges.loseTarget >= ranges.sense) || !(ranges.chase >= 0)) return { ok: false }
return { ok: true, value: ranges }
}
/** An optional number in a phase: absent (null) or a value. */
function optional(value) {
return value === undefined || value === null || value === '' ? null : num(value)
}
/**
* D273–D277: `{ barDistance, announce, spawnLine, deathLine, phases }`, checked as
* RunicNPC's `ValidateBoss`. Whether a phase's kit exists, and whether its adds'
* profile is a boss, are the model's (they need the servers and the other profiles).
*/
function checkBoss(input) {
if (typeof input !== 'object' || Array.isArray(input)) return { ok: false, error: 'boss: an object' }
const barDistance = input.barDistance === undefined || input.barDistance === null || input.barDistance === '' ? 100 : num(input.barDistance)
if (!(barDistance > 0 && barDistance <= 1000)) return { ok: false, error: 'boss.barDistance: above 0 and at most 1000 metres' }
const announce = input.announce === undefined || input.announce === null || input.announce === '' ? 'chat' : String(input.announce)
if (!ANNOUNCES.includes(announce)) return { ok: false, error: `boss.announce: '${announce}' is not chat or popup` }
const spawnLine = text(input.spawnLine).trim()
const deathLine = text(input.deathLine).trim()
if (spawnLine.length > LINE_MAX || deathLine.length > LINE_MAX) return { ok: false, error: `boss: a line is at most ${LINE_MAX} characters` }
const raw = input.phases === undefined || input.phases === null ? [] : input.phases
if (!Array.isArray(raw)) return { ok: false, error: 'boss.phases: a list, even an empty one' }
if (raw.length > PHASES_MAX) return { ok: false, error: `boss.phases: at most ${PHASES_MAX}` }
const seen = new Set()
const phases = []
for (let i = 0; i < raw.length; i++) {
const p = raw[i]
const at = `boss.phases[${i}]`
if (!p || typeof p !== 'object') return { ok: false, error: `${at}: it is empty` }
const fraction = num(p.at)
if (!(fraction > 0 && fraction < 1)) return { ok: false, error: `${at}.at: a fraction of its health between 0 and 1` }
if (seen.has(fraction)) return { ok: false, error: `${at}.at: two phases at ${fraction}` }
seen.add(fraction)
const phase = { at: fraction }
const damageDealt = optional(p.damageDealt)
if (damageDealt !== null && (Number.isNaN(damageDealt) || damageDealt < 0)) return { ok: false, error: `${at}.damageDealt: must not be negative` }
if (damageDealt !== null) phase.damageDealt = damageDealt
const aimCone = optional(p.aimCone)
if (aimCone !== null && (Number.isNaN(aimCone) || aimCone < 0)) return { ok: false, error: `${at}.aimCone: must not be negative` }
if (aimCone !== null) phase.aimCone = aimCone
const speed = optional(p.speed)
if (speed !== null && !(speed > 0 && speed <= 5)) return { ok: false, error: `${at}.speed: above 0 and at most 5 (times Rust's own)` }
if (speed !== null) phase.speed = speed
const ranges = phaseRanges(p.ranges)
if (!ranges.ok) return { ok: false, error: `${at}.ranges: sense and attack above 0, loseTarget at least sense, chase not negative` }
if (ranges.value) phase.ranges = ranges.value
const kit = text(p.kit).trim()
if (kit) phase.kit = kit
if (p.adds !== undefined && p.adds !== null) {
const profile = text(p.adds.profile).trim()
if (!NAME_RULE.test(profile)) return { ok: false, error: `${at}.adds.profile: a profile name` }
const count = num(p.adds.count)
if (!(Number.isInteger(count) && count >= 1 && count <= ADDS_MAX)) return { ok: false, error: `${at}.adds.count: 1 to ${ADDS_MAX}` }
phase.adds = { profile, count }
}
const line = text(p.line).trim()
if (line.length > LINE_MAX) return { ok: false, error: `${at}.line: at most ${LINE_MAX} characters` }
if (line) phase.line = line
phases.push(phase)
}
phases.sort((x, y) => y.at - x.at)
return { ok: true, value: { barDistance, announce, spawnLine, deathLine, phases } }
}
/** Every kit a profile names: its own, and each boss phase's. */
function kitsOf(body) {
const kits = new Set(body && Array.isArray(body.kits) ? body.kits : [])
for (const p of (body && body.boss && body.boss.phases) || []) if (p.kit) kits.add(p.kit)
return [...kits]
}
/**
* A profile read from a server's own RunicNPC (D244), made into a body this
* module will save. Whatever RunicNPC holds is kept as it is; only a shape this
@@ -268,6 +413,11 @@ module.exports = {
isFactionKey,
checkFactions,
KILLS_SCOPES,
ANNOUNCES,
USE_MODES,
checkUse,
checkBoss,
kitsOf,
defaults,
parseMode,
checkMovement,