Files
Module-Rust/server/model/npcs/npcProfile.js
wtclaude f2ca314d10
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
feat(rust): bosses and passive NPCs on the profile form, and public boss triggers (runicnpc stage 6)
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
2026-10-05 10:54:28 -05:00

428 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── A RunicNPC profile, checked here as RunicNPC checks it (D238) ───────────
//
// The site authors profiles and pushes them to each server's RunicNPC, which
// checks them again and refuses what it cannot use (docs/runicnpc/API.md). The
// site checks first so an admin reads the reason on the form, not in a push
// report minutes later. The rules below are RunicNPC's `ValidateProfile`, in
// the same order and with the same sentences, less the one only the server can
// answer: whether it has each kit. That one is checked against the kit list
// each server last reported (`npcs.model.js`).
//
// Pure functions: no database, no sidecar.
/** RunicNPC's rule for a profile, placement or route name. */
const NAME_RULE = /^[a-z0-9_-]{1,40}$/
/** A plain scientist prefab, as RunicNPC resolves one (`scientistnpc_*`). */
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']
/** 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']
const RELATIONS = ['hostile', 'neutral', 'allied']
/** D259, D264, D267. */
const TURRETS = ['default', 'ignore', 'always']
/** D260: the kit's extras a profile may opt into, each used up (D265). */
const KIT_USES = ['heal', 'grenades', 'melee', 'rockets', 'flamethrower']
const RELATIONS_MAX = 40
const FACTION_PAIRS_MAX = 400
/** D247: how a profile's kills are counted. `server` is the default. */
const KILLS_SCOPES = ['server', 'name', 'profile']
const NAMES_MAX = 20
const DISPLAY_NAME_MAX = 32
const KITS_MAX = 20
const THRESHOLDS_MAX = 10
/** RunicNPC's defaults (API.md, D238), so a form may leave a value out. */
function defaults() {
return {
names: [],
kits: [],
prefab: 'scientistnpc_roam',
role: 'roamer',
movement: { mode: 'wander', radius: 20 },
health: 150,
damageDealt: 1,
damageTaken: { head: 1, body: 1, legs: 1 },
aimCone: 2,
ranges: { sense: 30, loseTarget: 40, chase: 40, attack: 30 },
visionCone: -0.8,
sleepDistance: 160,
healthThresholds: [],
// Stage 5 (D254–D261, D271). None of them makes a profile fight anything but players (D255).
faction: null,
relations: {},
alertRadius: 40,
turrets: 'default',
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,
}
}
/** A faction name, one of Rust's two, or (where a profile names it) `profile:<name>`. */
function isFactionKey(key, profileAllowed) {
const k = String(key === undefined || key === null ? '' : key)
if (BUILT_IN_FACTIONS.includes(k) || NAME_RULE.test(k)) return true
return profileAllowed && k.startsWith('profile:') && NAME_RULE.test(k.slice(8))
}
function pairKey(a, b) {
return a < b ? `${a}|${b}` : `${b}|${a}`
}
/**
* The faction table (D254), one row per pair, both ways (D268), checked as
* RunicNPC's `ValidateFactions` checks it, with its sentences. `{ ok, value }`
* with the rows sorted, or `{ ok: false, error }`.
*/
function checkFactions(input) {
if (!Array.isArray(input)) return { ok: false, error: 'factions: a list of { a, b, relation }' }
if (input.length > FACTION_PAIRS_MAX) return { ok: false, error: `factions: at most ${FACTION_PAIRS_MAX} pairs` }
const seen = new Set()
const rows = []
for (let i = 0; i < input.length; i++) {
const f = input[i] || {}
const a = String(f.a === undefined || f.a === null ? '' : f.a).trim()
const b = String(f.b === undefined || f.b === null ? '' : f.b).trim()
const relation = String(f.relation === undefined || f.relation === null ? 'neutral' : f.relation)
if (!isFactionKey(a, false) || !isFactionKey(b, false)) {
return { ok: false, error: `factions[${i}]: a and b are faction names (1–40 of a-z, 0-9, _ and -), scientists or animals` }
}
if (a === b) return { ok: false, error: `factions[${i}]: a faction is always allied to itself` }
if (BUILT_IN_FACTIONS.includes(a) && BUILT_IN_FACTIONS.includes(b)) {
return { ok: false, error: `factions[${i}]: both are Rust's own, and RunicNPC does not change how Rust's NPCs treat each other` }
}
if (!RELATIONS.includes(relation)) return { ok: false, error: `factions[${i}]: '${relation}' is not hostile, neutral or allied` }
const key = pairKey(a, b)
if (seen.has(key)) return { ok: false, error: `factions[${i}]: ${a} and ${b} are given twice (one row is both ways, D268)` }
seen.add(key)
rows.push(a < b ? { a, b, relation } : { a: b, b: a, relation })
}
rows.sort((x, y) => x.a.localeCompare(y.a) || x.b.localeCompare(y.b))
return { ok: true, value: rows }
}
function num(value) {
if (value === null || value === undefined || value === '') return NaN
const n = Number(value)
return Number.isFinite(n) ? n : NaN
}
/** `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' || text === 'stand') return { kind: text }
const m = /^route:([a-z0-9_-]{1,40})$/.exec(text)
return m ? { kind: 'route', route: m[1] } : null
}
/** A movement, checked: `{ ok, value }` or `{ ok: false, error }`. */
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, 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" }
return { ok: true, value: { mode, radius } }
}
/**
* Checks a profile's body as the admin form sends it, merged over RunicNPC's
* defaults. Resolves `{ ok: true, value }` with the body RunicNPC will read, or
* `{ ok: false, error }` with RunicNPC's own sentence for the first problem.
*/
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}` }
if (names.some((n) => n.length > DISPLAY_NAME_MAX)) return { ok: false, error: `names: each at most ${DISPLAY_NAME_MAX} characters` }
const kits = Array.isArray(b.kits) ? [...new Set(b.kits.map((k) => String(k === null || k === undefined ? '' : k).trim()).filter(Boolean))] : null
if (!kits || kits.length === 0) return { ok: false, error: 'kits: give at least one; Kits is how an NPC is equipped (D217)' }
if (kits.length > KITS_MAX) return { ok: false, error: `kits: at most ${KITS_MAX}` }
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, 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' }
const damageDealt = num(b.damageDealt)
if (Number.isNaN(damageDealt) || damageDealt < 0) return { ok: false, error: 'damageDealt: must not be negative' }
const taken = b.damageTaken || {}
const damageTaken = { head: num(taken.head), body: num(taken.body), legs: num(taken.legs) }
if (Object.values(damageTaken).some((v) => Number.isNaN(v) || v < 0)) return { ok: false, error: 'damageTaken: head, body and legs must not be negative' }
const aimCone = num(b.aimCone)
if (Number.isNaN(aimCone) || aimCone < 0) return { ok: false, error: 'aimCone: must not be negative' }
const r = b.ranges || {}
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, error: 'ranges: sense and attack above 0, loseTarget at least sense, chase not negative' }
}
const visionCone = num(b.visionCone)
if (!(visionCone >= -1 && visionCone <= 1)) return { ok: false, error: 'visionCone: between -1 and 1' }
const sleepDistance = num(b.sleepDistance)
if (!(sleepDistance >= 0)) return { ok: false, error: 'sleepDistance: 0 (never sleeps) or more' }
const thresholds = Array.isArray(b.healthThresholds) ? b.healthThresholds.map(num) : null
if (!thresholds || thresholds.some((t) => !(t > 0 && t < 1))) return { ok: false, error: 'healthThresholds: fractions between 0 and 1' }
if (thresholds.length > THRESHOLDS_MAX) return { ok: false, error: `healthThresholds: at most ${THRESHOLDS_MAX}` }
// ---- stage 5, in RunicNPC's order and words ----
const faction = b.faction === undefined || b.faction === null || String(b.faction).trim() === '' ? null : String(b.faction).trim()
if (faction !== null && (!NAME_RULE.test(faction) || BUILT_IN_FACTIONS.includes(faction))) {
return { ok: false, error: `faction: '${faction}' must be 1–40 of a-z, 0-9, _ and -, and not scientists or animals (Rust's own)` }
}
const rel = b.relations === undefined || b.relations === null ? {} : b.relations
if (typeof rel !== 'object' || Array.isArray(rel)) return { ok: false, error: 'relations: an object, even an empty one' }
const relations = {}
for (const [key, value] of Object.entries(rel)) {
if (!isFactionKey(key, true)) return { ok: false, error: `relations: '${key}' is not a faction, scientists, animals or profile:<name>` }
if (!RELATIONS.includes(value)) return { ok: false, error: `relations.${key}: '${value}' is not hostile, neutral or allied` }
relations[key] = value
}
if (Object.keys(relations).length > RELATIONS_MAX) return { ok: false, error: `relations: at most ${RELATIONS_MAX}` }
const alertRadius = num(b.alertRadius)
if (!(alertRadius >= 0)) return { ok: false, error: 'alertRadius: 0 (off) or more' }
if (!TURRETS.includes(b.turrets)) return { ok: false, error: `turrets: '${b.turrets}' is not default, ignore or always` }
if (typeof b.hurtByPlayers !== 'boolean' || typeof b.hurtsPlayers !== 'boolean') return { ok: false, error: 'hurtByPlayers and hurtsPlayers: true or false' }
const kit = b.kitUse === undefined || b.kitUse === null ? {} : b.kitUse
if (typeof kit !== 'object' || Array.isArray(kit)) return { ok: false, error: 'kitUse: an object, even an empty one' }
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: {
names,
kits,
prefab,
role: b.role,
movement: movement.value,
health,
damageDealt,
damageTaken,
aimCone,
ranges,
visionCone,
sleepDistance,
healthThresholds: [...new Set(thresholds)].sort((x, y) => y - x),
faction,
relations,
alertRadius,
turrets: b.turrets,
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
* module could not push back is refused, with the reason.
*/
function fromServer(name, body) {
if (!NAME_RULE.test(String(name || ''))) return { ok: false, error: `'${name}' is not a profile name RunicNPC could hold` }
return checkBody(body || {})
}
/** A player-facing label for a profile: its first NPC name, else its own name. */
function labelOf(profile) {
const names = profile && profile.body && Array.isArray(profile.body.names) ? profile.body.names : []
return names[0] || (profile && profile.name) || ''
}
module.exports = {
NAME_RULE,
PREFAB_RULE,
PREFABS,
ROLES,
BUILT_IN_FACTIONS,
RELATIONS,
TURRETS,
KIT_USES,
isFactionKey,
checkFactions,
KILLS_SCOPES,
ANNOUNCES,
USE_MODES,
checkUse,
checkBoss,
kitsOf,
defaults,
parseMode,
checkMovement,
checkBody,
fromServer,
labelOf,
}