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

@@ -40,6 +40,10 @@ export const FEED_KINDS = Object.freeze([
'server.wipe',
'server.initialized',
'server.shutdown',
// RunicNPC bosses (runicnpc stage 6): the game says both to the whole server.
// The death names its killer, so the server shows it only where presence is on.
'npc.boss.spawned',
'npc.boss.died',
])
/** The filters the feed offers, and the kinds each one asks the API for. */
@@ -53,6 +57,7 @@ export const FILTERS = Object.freeze([
kinds: ['player.connected', 'player.disconnected', 'player.respawned'],
},
{ id: 'server', label: 'Server', kinds: ['server.wipe', 'server.initialized', 'server.shutdown'] },
{ id: 'bosses', label: 'Bosses', kinds: ['npc.boss.spawned', 'npc.boss.died'] },
])
export function kindsFor(filterId) {
@@ -128,6 +133,17 @@ export function describe(row) {
case 'server.shutdown':
return { tone: 'server', actor: null, verb: 'The server went down', subject: null, detail: '' }
// A boss's name is the profile's, as typed (`name`), never a prefab's.
case 'npc.boss.spawned':
return { tone: 'server', actor: name, verb: 'has appeared', subject: null, detail: 'a boss' }
case 'npc.boss.died': {
const fought = Number(frame.damagers) > 1 ? `${frame.damagers} players fought it` : ''
return frame.killerName
? { tone: 'kill', actor: frame.killerName, verb: 'killed', subject: name, detail: fought }
: { tone: 'death', actor: name, verb: 'was killed', subject: null, detail: fought }
}
default:
return { tone: 'other', actor: name, verb: String((row && row.kind) || 'unknown'), subject: null, detail: '' }
}

View File

@@ -63,7 +63,43 @@ function relationsFrom(text) {
}
const list = (text) => String(text || '').split(/[\n,]+/).map((s) => s.trim()).filter(Boolean)
const lines = (text) => String(text || '').split('\n').map((s) => s.trim()).filter(Boolean)
const str = (v) => (v === undefined || v === null ? '' : String(v))
/** An optional number as typed: blank is "not set". */
const opt = (v) => (String(v).trim() === '' ? undefined : v)
/** A boss phase as the form edits it: `at` in per cent, every other part optional (D275). */
function phaseForm(p = {}) {
const r = p.ranges || {}
return {
at: p.at === undefined ? '' : str(Math.round(p.at * 100)),
damageDealt: str(p.damageDealt),
aimCone: str(p.aimCone),
speed: str(p.speed),
kit: str(p.kit),
addsProfile: str(p.adds && p.adds.profile),
addsCount: str(p.adds && p.adds.count),
line: str(p.line),
sense: str(r.sense),
loseTarget: str(r.loseTarget),
chase: str(r.chase),
attack: str(r.attack),
}
}
function phaseBody(p) {
const out = { at: Number(p.at) / 100 }
if (opt(p.damageDealt) !== undefined) out.damageDealt = p.damageDealt
if (opt(p.aimCone) !== undefined) out.aimCone = p.aimCone
if (opt(p.speed) !== undefined) out.speed = p.speed
if (p.kit.trim()) out.kit = p.kit.trim()
if (p.addsProfile.trim()) out.adds = { profile: p.addsProfile.trim(), count: p.addsCount }
if (p.line.trim()) out.line = p.line.trim()
if ([p.sense, p.loseTarget, p.chase, p.attack].some((v) => String(v).trim() !== '')) {
out.ranges = { sense: p.sense, loseTarget: p.loseTarget, chase: p.chase, attack: p.attack }
}
return out
}
function formFrom(p, defaults) {
const b = { ...defaults, ...(p ? p.body : {}) }
@@ -99,6 +135,17 @@ function formFrom(p, defaults) {
hurtByPlayers: b.hurtByPlayers !== false,
hurtsPlayers: b.hurtsPlayers !== false,
kitUse: { ...(defaults.kitUse || {}), ...(b.kitUse || {}) },
// Stage 6 (D273–D278).
bossOn: Boolean(b.boss),
barDistance: str(b.boss ? b.boss.barDistance : 100),
announce: (b.boss && b.boss.announce) || 'chat',
spawnLine: str(b.boss && b.boss.spawnLine),
deathLine: str(b.boss && b.boss.deathLine),
phases: ((b.boss && b.boss.phases) || []).map(phaseForm),
useMode: (b.use && b.use.mode) || 'chat',
useLines: ((b.use && b.use.lines) || []).join('\n'),
useTitle: str(b.use && b.use.title),
useText: str(b.use && b.use.text),
}
}
@@ -130,10 +177,22 @@ function bodyFrom(f) {
hurtByPlayers: f.hurtByPlayers,
hurtsPlayers: f.hurtsPlayers,
kitUse: f.kitUse,
boss:
f.bossOn && f.role !== 'passive'
? { barDistance: f.barDistance, announce: f.announce, spawnLine: f.spawnLine, deathLine: f.deathLine, phases: f.phases.map(phaseBody) }
: null,
use: f.role === 'passive' ? { mode: f.useMode, lines: lines(f.useLines), title: f.useTitle, text: f.useText } : null,
},
}
}
/** A role change: a passive NPC starts unhurtable and standing (D279, D288); leaving passive, it walks again. */
function withRole(f, role) {
if (role === 'passive' && f.role !== 'passive') return { ...f, role, hurtByPlayers: false, moveMode: 'stand' }
if (role !== 'passive' && f.role === 'passive') return { ...f, role, hurtByPlayers: true, moveMode: f.moveMode === 'stand' ? 'wander' : f.moveMode }
return { ...f, role }
}
/** One line on a server's RunicNPC and the last push to it. */
function serverLine(s) {
if (!s.ready) return s.absence || 'no RunicNPC'
@@ -259,7 +318,7 @@ export default function NpcProfiles() {
</button>
</div>
<span className="dim" style={{ fontSize: '0.76rem' }}>
{p.body.role} · {p.body.health} health · kits {(p.body.kits || []).join(', ')} · {p.body.role === 'sentry' ? 'stands still' : p.body.movement && p.body.movement.mode}
{p.body.role}{p.body.boss ? ' · boss' : ''} · {p.body.health} health · kits {(p.body.kits || []).join(', ')} · {p.body.role === 'sentry' ? 'stands still' : p.body.movement && p.body.movement.mode}
{' · '}kills counted: {p.killsScope === 'server' ? 'per server' : p.killsScope === 'name' ? 'every server with this name' : 'this profile only'}
</span>
</div>
@@ -320,6 +379,62 @@ function Num({ value, onChange, width = 110 }) {
return <input value={value} onChange={(e) => onChange(e.target.value)} inputMode="decimal" style={{ ...inputStyle, maxWidth: width }} />
}
/**
* A boss's phases (D275): each at a health percentage, each part optional. The
* adds' profile is offered from the site's profiles that are not bosses.
*/
function PhaseList({ form, setForm, profiles }) {
const setPhase = (i, key, value) =>
setForm((f) => ({ ...f, phases: f.phases.map((p, j) => (j === i ? { ...p, [key]: value } : p)) }))
const remove = (i) => setForm((f) => ({ ...f, phases: f.phases.filter((_, j) => j !== i) }))
const add = () => setForm((f) => ({ ...f, phases: [...f.phases, phaseForm()] }))
const addable = [...new Set(profiles.filter((p) => !p.replaced && !(p.body && p.body.boss) && p.name !== form.name).map((p) => p.name))].sort()
const row = { display: 'flex', gap: 12, flexWrap: 'wrap' }
return (
<div style={{ display: 'grid', gap: 10 }}>
<span className="sans" style={{ color: 'var(--head)', fontSize: '0.84rem' }}>Phases</span>
<p className="sans dim" style={{ fontSize: '0.76rem', margin: 0 }}>
When its health falls to a phase’s percentage, the phase’s changes apply from then on. Leave a part blank to keep it as it was. A
phase’s line is said only to the players near it and those who have hurt it.
</p>
{form.phases.map((p, i) => (
<div key={i} style={{ border: '1px solid var(--line-soft)', borderRadius: 6, padding: '8px 10px', display: 'grid', gap: 8 }}>
<div style={row}>
<Field label="At health (%)"><Num value={p.at} onChange={(v) => setPhase(i, 'at', v)} width={80} /></Field>
<Field label="Damage dealt ×"><Num value={p.damageDealt} onChange={(v) => setPhase(i, 'damageDealt', v)} width={80} /></Field>
<Field label="Aim spread"><Num value={p.aimCone} onChange={(v) => setPhase(i, 'aimCone', v)} width={80} /></Field>
<Field label="Speed ×" hint="1.3: a third faster."><Num value={p.speed} onChange={(v) => setPhase(i, 'speed', v)} width={80} /></Field>
<Field label="Swaps to kit"><input value={p.kit} onChange={(e) => setPhase(i, 'kit', e.target.value)} style={{ ...inputStyle, maxWidth: 160 }} /></Field>
</div>
<div style={row}>
<Field label="Summons (profile)">
<select value={p.addsProfile} onChange={(e) => setPhase(i, 'addsProfile', e.target.value)} style={inputStyle}>
<option value="">nobody</option>
{[...new Set([p.addsProfile, ...addable].filter(Boolean))].map((n) => <option key={n} value={n}>{n}</option>)}
</select>
</Field>
{p.addsProfile && <Field label="How many" hint="1 to 20."><Num value={p.addsCount} onChange={(v) => setPhase(i, 'addsCount', v)} width={60} /></Field>}
<Field label="Notices at (m)"><Num value={p.sense} onChange={(v) => setPhase(i, 'sense', v)} width={70} /></Field>
<Field label="Forgets at"><Num value={p.loseTarget} onChange={(v) => setPhase(i, 'loseTarget', v)} width={70} /></Field>
<Field label="Chases to"><Num value={p.chase} onChange={(v) => setPhase(i, 'chase', v)} width={70} /></Field>
<Field label="Shoots from"><Num value={p.attack} onChange={(v) => setPhase(i, 'attack', v)} width={70} /></Field>
</div>
<div style={{ ...row, alignItems: 'end' }}>
<Field label="Line">
<input value={p.line} onChange={(e) => setPhase(i, 'line', e.target.value)} maxLength={256} style={{ ...inputStyle, width: 420, maxWidth: '100%' }} />
</Field>
<button type="button" className="btn" onClick={() => remove(i)}>Remove phase</button>
</div>
</div>
))}
<div>
<button type="button" className="btn" onClick={add}>Add a phase</button>
</div>
</div>
)
}
function ProfileForm({ form, setForm, data, busy, error, onSave, onCancel, onDelete }) {
const servers = data.servers || []
const set = (key) => (value) => setForm((f) => ({ ...f, [key]: value }))
@@ -367,10 +482,11 @@ function ProfileForm({ form, setForm, data, busy, error, onSave, onCancel, onDel
</select>
</Field>
<Field label="Role">
<select value={form.role} onChange={(e) => set('role')(e.target.value)} style={inputStyle}>
<select value={form.role} onChange={(e) => setForm((f) => withRole(f, e.target.value))} style={inputStyle}>
<option value="roamer">Roamer: moves, chases and fights</option>
<option value="sentry">Sentry: stands still and shoots</option>
<option value="guard">Guard: holds its spot, chases within its leash, walks back</option>
<option value="passive">Passive: never fights; players press E to talk to it</option>
</select>
</Field>
</div>
@@ -380,11 +496,18 @@ function ProfileForm({ form, setForm, data, busy, error, onSave, onCancel, onDel
<fieldset style={fieldset}>
<legend className="sans" style={legend}>Moving</legend>
<div style={row}>
{form.role === 'roamer' && (
{(form.role === 'roamer' || form.role === 'passive') && (
<>
<Field label="How it moves" hint="wander, monument (Rust’s own paths there), or route:<name> for a route recorded in game. A placement may change it.">
<Field
label="How it moves"
hint={`wander, monument (Rust’s own paths there), or route:<name> for a route recorded in game${form.role === 'passive' ? ', or stand: it stays where it is placed and turns to whoever talks to it' : ''}. A placement may change it.`}
>
<input value={form.moveMode} onChange={(e) => set('moveMode')(e.target.value)} list="rnpc-modes" style={{ ...inputStyle, maxWidth: 220 }} />
<datalist id="rnpc-modes"><option value="wander" /><option value="monument" /></datalist>
<datalist id="rnpc-modes">
{form.role === 'passive' && <option value="stand" />}
<option value="wander" />
<option value="monument" />
</datalist>
</Field>
<Field label="Wander radius (m)"><Num value={form.moveRadius} onChange={set('moveRadius')} /></Field>
</>
@@ -418,6 +541,67 @@ function ProfileForm({ form, setForm, data, busy, error, onSave, onCancel, onDel
</Field>
</fieldset>
{form.role === 'passive' && (
<fieldset style={fieldset}>
<legend className="sans" style={legend}>Press E</legend>
<p className="sans dim" style={{ fontSize: '0.76rem', margin: 0 }}>
What a player gets for pressing E while looking at it, from up to 3 m. It never fights, whatever happens to it.{' '}
<code>{'{player}'}</code> is the player’s name.
</p>
<Field label="Answer">
<select value={form.useMode} onChange={(e) => set('useMode')(e.target.value)} style={{ ...inputStyle, maxWidth: 420 }}>
<option value="chat">A line in that player’s chat, picked at random from the lines below</option>
<option value="window">A window with a title, the text and a Close button</option>
</select>
</Field>
{form.useMode === 'chat' ? (
<Field label="Lines" hint="One per line, up to 256 characters each.">
<textarea value={form.useLines} onChange={(e) => set('useLines')(e.target.value)} rows={4} style={{ ...inputStyle, maxWidth: 560 }} />
</Field>
) : (
<>
<Field label="Title" hint="Blank: the NPC’s name.">
<input value={form.useTitle} onChange={(e) => set('useTitle')(e.target.value)} maxLength={80} style={{ ...inputStyle, maxWidth: 420 }} />
</Field>
<Field label="Text" hint="Up to 2,000 characters.">
<textarea value={form.useText} onChange={(e) => set('useText')(e.target.value)} rows={6} maxLength={2000} style={{ ...inputStyle, maxWidth: 560 }} />
</Field>
</>
)}
</fieldset>
)}
{form.role !== 'passive' && (
<fieldset style={fieldset}>
<legend className="sans" style={legend}>Boss</legend>
<label className="sans" style={{ display: 'flex', gap: 8, fontSize: '0.84rem' }}>
<input type="checkbox" checked={form.bossOn} onChange={(e) => set('bossOn')(e.target.checked)} />
It is a boss <span className="dim">(a health bar on screen, phases, and lines said in game; it still moves and fights as its role does)</span>
</label>
{form.bossOn && (
<>
<div style={row}>
<Field label="Bar shown within (m)" hint="And to everyone who has hurt it, wherever they are.">
<Num value={form.barDistance} onChange={set('barDistance')} />
</Field>
<Field label="Lines said in" hint="A popup needs PopupNotifications on the server; chat is used where it is missing.">
<select value={form.announce} onChange={(e) => set('announce')(e.target.value)} style={inputStyle}>
{(data.announces || ['chat', 'popup']).map((a) => <option key={a} value={a}>{a}</option>)}
</select>
</Field>
</div>
<Field label="When it spawns, to everyone" hint="Blank says nothing. {name} is its name.">
<input value={form.spawnLine} onChange={(e) => set('spawnLine')(e.target.value)} maxLength={256} style={{ ...inputStyle, maxWidth: 560 }} />
</Field>
<Field label="When it dies, to everyone" hint="{killer} is the player who landed the killing blow.">
<input value={form.deathLine} onChange={(e) => set('deathLine')(e.target.value)} maxLength={256} style={{ ...inputStyle, maxWidth: 560 }} />
</Field>
<PhaseList form={form} setForm={setForm} profiles={data.profiles || []} />
</>
)}
</fieldset>
)}
<fieldset style={fieldset}>
<legend className="sans" style={legend}>Sides</legend>
<p className="sans dim" style={{ fontSize: '0.76rem', margin: 0 }}>

View File

@@ -136,6 +136,21 @@ test('every kind the feed asks for is one the public route will serve', () => {
}
})
test('a boss appearing and falling read as sentences, with its name as typed (runicnpc stage 6)', () => {
const up = describe(row('npc.boss.spawned', { profile: 'juggernaut', name: 'The Juggernaut' }))
assert.deepEqual({ actor: up.actor, verb: up.verb, detail: up.detail }, { actor: 'The Juggernaut', verb: 'has appeared', detail: 'a boss' })
const killed = describe(row('npc.boss.died', { profile: 'juggernaut', name: 'The Juggernaut', killerName: 'Marisol', damagers: 5 }))
assert.deepEqual({ tone: killed.tone, actor: killed.actor, verb: killed.verb, subject: killed.subject, detail: killed.detail },
{ tone: 'kill', actor: 'Marisol', verb: 'killed', subject: 'The Juggernaut', detail: '5 players fought it' })
// No player landed the blow (a turret, or another NPC): it still fell.
const fell = describe(row('npc.boss.died', { profile: 'juggernaut', name: 'The Juggernaut', damagers: 1 }))
assert.deepEqual({ actor: fell.actor, verb: fell.verb, detail: fell.detail }, { actor: 'The Juggernaut', verb: 'was killed', detail: '' })
assert.deepEqual(kindsFor('bosses'), ['npc.boss.spawned', 'npc.boss.died'])
})
test('an unknown filter falls back to everything rather than to nothing', () => {
assert.deepEqual(kindsFor('nonsense'), FEED_KINDS)
assert.deepEqual(kindsFor(undefined), FEED_KINDS)

View File

@@ -84,6 +84,245 @@
}
]
},
{
"id": "rust.boss.killed",
"label": "A boss was killed",
"description": "Players killed a RunicNPC boss on a server.",
"kind": "event",
"subjectKey": "profile",
"audience": "subscribers",
"ceiling": "everyone",
"version": 1,
"variables": [
{
"name": "serverId",
"type": "string",
"required": true,
"example": "main",
"description": "The server the event happened on, as configured in Admin -> Rust. Also the cooldown subject for broadcasts."
},
{
"name": "server",
"type": "string",
"required": true,
"example": "Runic Gateway | Main",
"description": "The server's display name."
},
{
"name": "serverUrl",
"type": "url",
"required": false,
"example": "/rust/servers/main",
"description": "Site-relative path to the server's page."
},
{
"name": "title",
"type": "string",
"required": false,
"example": "Main is back online",
"description": "A one-line headline naming what happened and where. Core generic bodies use it as the title."
},
{
"name": "intro",
"type": "string",
"required": false,
"example": "Main is back up and taking players.",
"description": "One sentence of detail. Core generic bodies use it as the body."
},
{
"name": "profile",
"type": "string",
"required": true,
"example": "juggernaut",
"description": "The RunicNPC profile the boss was made from."
},
{
"name": "boss",
"type": "string",
"required": true,
"example": "Juggernaut",
"description": "The boss's own name, as players see it."
},
{
"name": "byEvent",
"type": "boolean",
"required": true,
"example": true,
"description": "Whether an event placed it."
},
{
"name": "killer",
"type": "string",
"required": false,
"example": "Marisol",
"description": "The player who landed the killing blow, by in-game name. Absent when no player did."
},
{
"name": "damagers",
"type": "int",
"required": true,
"example": 5,
"description": "How many players hurt it, the killer included."
}
]
},
{
"id": "rust.boss.phase",
"label": "A boss changed phase",
"description": "A RunicNPC boss fell to one of its phases' health thresholds and changed: stronger, faster, or calling for help.",
"kind": "event",
"subjectKey": "phaseKey",
"audience": "subscribers",
"ceiling": "everyone",
"version": 1,
"variables": [
{
"name": "serverId",
"type": "string",
"required": true,
"example": "main",
"description": "The server the event happened on, as configured in Admin -> Rust. Also the cooldown subject for broadcasts."
},
{
"name": "server",
"type": "string",
"required": true,
"example": "Runic Gateway | Main",
"description": "The server's display name."
},
{
"name": "serverUrl",
"type": "url",
"required": false,
"example": "/rust/servers/main",
"description": "Site-relative path to the server's page."
},
{
"name": "title",
"type": "string",
"required": false,
"example": "Main is back online",
"description": "A one-line headline naming what happened and where. Core generic bodies use it as the title."
},
{
"name": "intro",
"type": "string",
"required": false,
"example": "Main is back up and taking players.",
"description": "One sentence of detail. Core generic bodies use it as the body."
},
{
"name": "profile",
"type": "string",
"required": true,
"example": "juggernaut",
"description": "The RunicNPC profile the boss was made from."
},
{
"name": "boss",
"type": "string",
"required": true,
"example": "Juggernaut",
"description": "The boss's own name, as players see it."
},
{
"name": "byEvent",
"type": "boolean",
"required": true,
"example": true,
"description": "Whether an event placed it."
},
{
"name": "phaseKey",
"type": "string",
"required": true,
"example": "juggernaut:2",
"description": "The profile and the phase. The cooldown subject; not meant for display."
},
{
"name": "phase",
"type": "int",
"required": true,
"example": 2,
"description": "Which phase it entered, counting from 1."
},
{
"name": "percent",
"type": "int",
"required": true,
"example": 25,
"description": "The health it fell to, as a percentage."
}
]
},
{
"id": "rust.boss.spawned",
"label": "A boss appeared",
"description": "A RunicNPC boss spawned on a server: an admin's placement, an event, or another plugin.",
"kind": "event",
"subjectKey": "profile",
"audience": "subscribers",
"ceiling": "everyone",
"version": 1,
"variables": [
{
"name": "serverId",
"type": "string",
"required": true,
"example": "main",
"description": "The server the event happened on, as configured in Admin -> Rust. Also the cooldown subject for broadcasts."
},
{
"name": "server",
"type": "string",
"required": true,
"example": "Runic Gateway | Main",
"description": "The server's display name."
},
{
"name": "serverUrl",
"type": "url",
"required": false,
"example": "/rust/servers/main",
"description": "Site-relative path to the server's page."
},
{
"name": "title",
"type": "string",
"required": false,
"example": "Main is back online",
"description": "A one-line headline naming what happened and where. Core generic bodies use it as the title."
},
{
"name": "intro",
"type": "string",
"required": false,
"example": "Main is back up and taking players.",
"description": "One sentence of detail. Core generic bodies use it as the body."
},
{
"name": "profile",
"type": "string",
"required": true,
"example": "juggernaut",
"description": "The RunicNPC profile the boss was made from."
},
{
"name": "boss",
"type": "string",
"required": true,
"example": "Juggernaut",
"description": "The boss's own name, as players see it."
},
{
"name": "byEvent",
"type": "boolean",
"required": true,
"example": true,
"description": "Whether an event placed it."
}
]
},
{
"id": "rust.clan.disbanded",
"label": "Your clan was disbanded",

View File

@@ -52,6 +52,13 @@ const PUBLIC_KINDS = Object.freeze([
'server.wipe',
'server.initialized',
'server.shutdown',
// RunicNPC bosses (runicnpc stage 6, D277, D286). The game says a boss's spawn
// and death to everyone on the server, so the feed may too: the death names the
// killer by in-game name and counts the players who fought it, and names no
// Steam id but the killer's, as the killfeed does. Its phases are told only to
// the players near it (D286), so `npc.boss.phase` is staff's, below.
'npc.boss.spawned',
'npc.boss.died',
])
/**
@@ -112,6 +119,8 @@ const STAFF_KINDS = Object.freeze([
'npc.died',
'npc.health',
'npc.placement.changed',
// Runicnpc stage 6: a boss's phase is said in game only to the players near it (D286).
'npc.boss.phase',
])
/**
@@ -139,6 +148,8 @@ const PRESENCE_KINDS = Object.freeze([
'player.death',
'player.chat',
'player.tally',
// Runicnpc stage 6: a boss's death names its killer, which says that player was on.
'npc.boss.died',
])
/** Every event kind the protocol defines, through protocol 13. */

View File

@@ -167,6 +167,19 @@ const HEADLINES = Object.freeze({
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.boss.spawned': (d) => ({
title: `${d.boss} has appeared on ${d.server}`,
intro: `${d.boss} is up on ${d.server}.`,
}),
'rust.boss.phase': (d) => ({
title: `${d.boss} is down to ${d.percent}% on ${d.server}`,
intro: `${d.boss} fell to ${d.percent}% of its health on ${d.server} and entered phase ${d.phase}.`,
}),
'rust.boss.killed': (d) => ({
title: `${d.boss} was killed on ${d.server}`,
intro: `${d.killer ? `${d.killer} landed the killing blow` : `${d.boss} fell`}`
+ `${d.damagers > 1 ? `, with ${d.damagers} players fighting it` : ''}.`,
}),
'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.`,
@@ -417,7 +430,40 @@ async function onNpc(server, item, frame) {
}) ? 1 : 0
}
/**
* A RunicNPC boss (runicnpc stage 6, D277): spawned, a phase, killed. Public
* news, so a replay older than a broadcast's window tells nobody (D63). The
* dedupe key is the boss's net id, with the phase for a phase.
*/
async function onBoss(server, item, frame) {
const t = frameTime(item, frame)
if (!stillNews(t, BROADCAST_MAX_AGE_MS) || !str(frame.profile)) return 0
const base = { ...serverVars(server), profile: str(frame.profile), boss: str(frame.name) || str(frame.profile), byEvent: Boolean(frame.runId) }
if (frame.kind === 'npc.boss.spawned') {
return fire(T['rust.boss.spawned'], { data: base, dedupeKey: dedupeKey('boss.spawned', server.id, frame.netId, t), occurredAt: t }) ? 1 : 0
}
if (frame.kind === 'npc.boss.phase') {
const phase = Number(frame.phase) || 0
return fire(T['rust.boss.phase'], {
data: { ...base, phaseKey: `${base.profile}:${phase}`, phase, percent: Math.round((Number(frame.at) || 0) * 100) },
dedupeKey: dedupeKey('boss.phase', server.id, frame.netId, phase),
occurredAt: t,
}) ? 1 : 0
}
return fire(T['rust.boss.killed'], {
data: { ...base, killer: str(frame.killerName), damagers: Number(frame.damagers) || 0 },
dedupeKey: dedupeKey('boss.killed', server.id, frame.netId, t),
occurredAt: t,
}) ? 1 : 0
}
const HANDLERS = Object.freeze({
'npc.boss.spawned': onBoss,
'npc.boss.phase': onBoss,
'npc.boss.died': onBoss,
'npc.died': onNpc,
'npc.health': onNpc,
'entity.destroyed': onRaid,

View File

@@ -472,7 +472,83 @@ const NPCS = [
},
]
const TRIGGERS = Object.freeze([RAID, ...BROADCASTS, ACCOUNT, REWARD, ...CLANS, ...MODERATION, ...NPCS])
// ── A RunicNPC boss (docs/runicnpc/PLAN.md stage 6, D277) ───────────────────
//
// Public, unlike the two above: a boss rising, changing phase and falling is
// server news, as a wipe is. `everyone` is the ceiling and `subscribers` the
// default, as for the broadcasts. The game also says these lines in chat; these
// are for the app, the in-app inbox and email. Discord hears a boss only through
// an event's own announce step (D277), so core does not change.
//
// No Steam id is declared, the killer's included: a public notice names the
// killer by their in-game name only.
const BOSS_VARS = [
{ name: 'profile', type: 'string', required: true, example: 'juggernaut',
description: 'The RunicNPC profile the boss was made from.' },
{ name: 'boss', type: 'string', required: true, example: 'Juggernaut',
description: 'The boss\'s own name, as players see it.' },
{ name: 'byEvent', type: 'boolean', required: true, example: true,
description: 'Whether an event placed it.' },
]
const BOSSES = [
{
id: 'rust.boss.spawned',
label: 'A boss appeared',
description: 'A RunicNPC boss spawned on a server: an admin\'s placement, an event, or another plugin.',
kind: 'event',
// One per kind of boss per cooldown, however often it respawns.
subjectKey: 'profile',
audience: 'subscribers',
ceiling: 'everyone',
version: V1,
variables: [...SERVER, ...HEADLINE, ...BOSS_VARS],
},
{
id: 'rust.boss.phase',
label: 'A boss changed phase',
description: 'A RunicNPC boss fell to one of its phases\' health thresholds and changed: stronger, faster, or calling for help.',
kind: 'event',
// A profile's phase: phase 2 is not swallowed by phase 1's cooldown.
subjectKey: 'phaseKey',
audience: 'subscribers',
ceiling: 'everyone',
version: V1,
variables: [
...SERVER,
...HEADLINE,
...BOSS_VARS,
{ name: 'phaseKey', type: 'string', required: true, example: 'juggernaut:2',
description: 'The profile and the phase. The cooldown subject; not meant for display.' },
{ name: 'phase', type: 'int', required: true, example: 2,
description: 'Which phase it entered, counting from 1.' },
{ name: 'percent', type: 'int', required: true, example: 25,
description: 'The health it fell to, as a percentage.' },
],
},
{
id: 'rust.boss.killed',
label: 'A boss was killed',
description: 'Players killed a RunicNPC boss on a server.',
kind: 'event',
subjectKey: 'profile',
audience: 'subscribers',
ceiling: 'everyone',
version: V1,
variables: [
...SERVER,
...HEADLINE,
...BOSS_VARS,
{ name: 'killer', type: 'string', required: false, example: 'Marisol',
description: 'The player who landed the killing blow, by in-game name. Absent when no player did.' },
{ name: 'damagers', type: 'int', required: true, example: 5,
description: 'How many players hurt it, the killer included.' },
],
},
]
const TRIGGERS = Object.freeze([RAID, ...BROADCASTS, ACCOUNT, REWARD, ...CLANS, ...MODERATION, ...NPCS, ...BOSSES])
const TRIGGER_IDS = Object.freeze(Object.fromEntries(TRIGGERS.map((t) => [t.id, t.id])))

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,

View File

@@ -163,6 +163,8 @@ async function describe() {
roles: shape.ROLES,
turrets: shape.TURRETS,
kitUses: shape.KIT_USES,
announces: shape.ANNOUNCES,
useModes: shape.USE_MODES,
}
}
@@ -212,8 +214,20 @@ async function validate(input, id = null) {
if (shared) throw new NpcError(`a profile called "${name}" is already on ${shared.name || shared.id}`, 409)
}
// Stage 6 (D275): a phase's adds are another of the site's profiles, and never a boss, as RunicNPC refuses.
const all = active(await db.listProfiles())
for (const [i, phase] of ((checked.value.boss && checked.value.boss.phases) || []).entries()) {
if (!phase.adds) continue
const named = all.filter((p) => p.name === phase.adds.profile && p.id !== id)
if (phase.adds.profile === name) throw new NpcError(`boss.phases[${i}].adds.profile: a boss does not summon itself`)
if (named.length === 0) throw new NpcError(`boss.phases[${i}].adds.profile: there is no profile '${phase.adds.profile}'`)
if (named.some((p) => p.body && p.body.boss)) {
throw new NpcError(`boss.phases[${i}].adds.profile: '${phase.adds.profile}' is a boss, and a boss does not summon bosses`)
}
}
const covered = allServers ? list : requested.map((s) => byId.get(s))
await checkKits(checked.value.kits, covered)
await checkKits(shape.kitsOf(checked.value), covered)
return { name, body: checked.value, allServers, servers: allServers ? [] : requested, killsScope }
}

View File

@@ -145,6 +145,11 @@ test('the classification covers exactly the event kinds the protocol defines, th
'npc.placement.changed',
'plugin.loaded',
'plugin.unloaded',
// Protocol 13, RunicNPC (runicnpc stage 6): a boss's spawn and death are said to
// the whole server and are public; its phases are told only to players near it.
'npc.boss.spawned',
'npc.boss.phase',
'npc.boss.died',
]
assert.deepEqual([...catalogue.ALL_KINDS].sort(), [...PROTOCOL_4].sort())
@@ -154,9 +159,12 @@ test('the classification covers exactly the event kinds the protocol defines, th
}
assert.equal(catalogue.isPublic('config.outcome'), false, 'a server’s log tail must not be public')
for (const kind of ['plugin.loaded', 'plugin.unloaded', 'lease.expired', 'world.expired']) {
for (const kind of ['plugin.loaded', 'plugin.unloaded', 'lease.expired', 'world.expired', 'npc.boss.phase']) {
assert.equal(catalogue.isPublic(kind), false, `${kind} is staff-only`)
}
for (const kind of ['npc.boss.spawned', 'npc.boss.died']) {
assert.equal(catalogue.isPublic(kind), true, `${kind} is said to the whole server, so it is public (D286)`)
}
})
test('every kind that names a player who was on is behind the presence setting', () => {
@@ -169,6 +177,7 @@ test('every kind that names a player who was on is behind the presence setting',
'player.death',
'player.chat',
'player.tally',
'npc.boss.died',
]) {
assert.ok(catalogue.isPresence(kind), `${kind} must be gated as presence`)
assert.ok(!catalogue.kindsFor({}).includes(kind), `${kind} must not reach a default public view`)
@@ -179,5 +188,5 @@ test('every kind that names a player who was on is behind the presence setting',
for (const kind of catalogue.PRESENCE_KINDS) assert.ok(catalogue.PUBLIC_KINDS.includes(kind))
// And what is left names nobody.
assert.deepEqual(catalogue.kindsFor({}).sort(), ['server.initialized', 'server.shutdown', 'server.wipe'])
assert.deepEqual(catalogue.kindsFor({}).sort(), ['npc.boss.spawned', 'server.initialized', 'server.shutdown', 'server.wipe'])
})

View File

@@ -129,7 +129,7 @@ test('a profile is refused for what RunicNPC would refuse, in its own words', ()
[{ names: ['ok', ' '] }, 'names: give at least one'],
[{ kits: [] }, 'kits: give at least one'],
[{ prefab: 'player' }, "prefab: 'player' is not one of Rust's scientist prefabs"],
[{ role: 'boss' }, "role: 'boss' is not roamer, sentry or guard"],
[{ role: 'boss' }, "role: 'boss' is not roamer, sentry, guard or passive"],
[{ movement: { mode: 'fly', radius: 5 } }, "movement.mode: 'fly' is not wander"],
[{ movement: { mode: 'wander', radius: 0 } }, "a wanderer's radius must be above 0"],
[{ health: 0 }, 'health: must be above 0'],
@@ -149,6 +149,86 @@ test('a profile is refused for what RunicNPC would refuse, in its own words', ()
assert.deepStrictEqual(ok.value.healthThresholds, [0.5, 0.25], 'deduplicated, highest first')
})
// ── stage 6: bosses and passive NPCs (D273–D279, D288) ─────────────────────
const PASSIVE = { role: 'passive', movement: { mode: 'stand' }, use: { mode: 'chat', lines: ['Hello'] } }
const BOSS = {
boss: {
spawnLine: 'It rises',
phases: [
{ at: 0.25, damageDealt: 1.5, speed: 1.3, line: 'Enraged' },
{ at: 0.5, adds: { profile: 'raider', count: 3 }, kit: 'warden_heavy' },
],
},
}
test('stage 6: a boss or passive profile is refused for what RunicNPC would refuse, in its own words', () => {
const cases = [
[{ ...PASSIVE, ...BOSS }, 'boss: a passive NPC never fights, so it cannot be a boss'],
[{ role: 'passive' }, 'use: a passive NPC says what pressing E on it does (D278)'],
[{ use: { mode: 'chat', lines: ['hi'] } }, 'use: only a passive NPC answers E (D278)'],
[{ movement: { mode: 'stand' } }, "movement.mode: stand is a passive NPC's"],
[{ ...PASSIVE, use: { mode: 'shout' } }, "use.mode: 'shout' is not chat or window"],
[{ ...PASSIVE, use: { mode: 'chat', lines: [] } }, 'use.lines: a chat answer gives at least one line'],
[{ ...PASSIVE, use: { mode: 'window' } }, 'use: a window has a title or some text'],
[{ boss: { barDistance: 0 } }, 'boss.barDistance: above 0 and at most 1000 metres'],
[{ boss: { announce: 'shout' } }, "boss.announce: 'shout' is not chat or popup"],
[{ boss: { phases: [{ at: 1.5 }] } }, 'boss.phases[0].at: a fraction of its health between 0 and 1'],
[{ boss: { phases: [{ at: 0.5 }, { at: 0.5 }] } }, 'boss.phases[1].at: two phases at 0.5'],
[{ boss: { phases: [{ at: 0.5, speed: 9 }] } }, "boss.phases[0].speed: above 0 and at most 5 (times Rust's own)"],
[{ boss: { phases: [{ at: 0.5, adds: { profile: 'raider', count: 0 } }] } }, 'boss.phases[0].adds.count: 1 to 20'],
[{ boss: { phases: [{ at: 0.5, ranges: { sense: 10, loseTarget: 5, chase: 0, attack: 10 } }] } }, 'boss.phases[0].ranges'],
]
for (const [extra, words] of cases) {
const out = shape.checkBody(body(extra))
assert.strictEqual(out.ok, false, JSON.stringify(extra))
assert.ok(out.error.includes(words), `${out.error} / ${words}`)
}
})
test('stage 6: a passive profile cannot be hurt unless it says so (D279), and a boss reads back clean', () => {
const { hurtByPlayers, ...unsaid } = body(PASSIVE)
assert.strictEqual(hurtByPlayers, true)
const passive = shape.checkBody(unsaid)
assert.strictEqual(passive.ok, true, passive.error)
assert.strictEqual(passive.value.hurtByPlayers, false, 'off by default for a passive profile')
assert.deepStrictEqual(passive.value.use, { mode: 'chat', lines: ['Hello'], title: '', text: '' })
assert.strictEqual(passive.value.boss, null)
assert.strictEqual(shape.checkBody(body({ ...PASSIVE, hurtByPlayers: true })).value.hurtByPlayers, true)
assert.strictEqual(shape.checkBody(body()).value.hurtByPlayers, true, 'still on by default for every other role')
const boss = shape.checkBody(body(BOSS))
assert.strictEqual(boss.ok, true, boss.error)
assert.deepStrictEqual(boss.value.boss, {
barDistance: 100,
announce: 'chat',
spawnLine: 'It rises',
deathLine: '',
phases: [
{ at: 0.5, kit: 'warden_heavy', adds: { profile: 'raider', count: 3 } },
{ at: 0.25, damageDealt: 1.5, speed: 1.3, line: 'Enraged' },
],
}, 'defaults filled, phases highest first, absent parts left out')
assert.deepStrictEqual(shape.kitsOf(boss.value).sort(), ['warden_heavy', 'warden_rifle'])
})
test('stage 6: a phase\'s kit is checked on each server, and its adds must be a profile that is not a boss', async (t) => {
const raider = { id: 7, name: 'raider', body: body({ names: ['Raider'] }), allServers: true }
const juggernaut = { id: 8, name: 'juggernaut', body: body(BOSS), allServers: true }
stubStore(t, { servers: [npcServer('main')], profiles: [raider, juggernaut] })
stubWire(t, { kits: ['warden_rifle'] })
await assert.rejects(
npcs.create({ name: 'boss', body: body(BOSS), servers: ['main'] }),
(err) => err.status === 400 && /MAIN has no kit 'warden_heavy'/.test(err.message),
)
const noAdds = { boss: { phases: [{ at: 0.5, adds: { profile: 'nobody', count: 1 } }] } }
await assert.rejects(npcs.create({ name: 'boss', body: body(noAdds), servers: ['main'] }), /there is no profile 'nobody'/)
const addsBoss = { boss: { phases: [{ at: 0.5, adds: { profile: 'juggernaut', count: 1 } }] } }
await assert.rejects(npcs.create({ name: 'boss', body: body(addsBoss), servers: ['main'] }), /'juggernaut' is a boss/)
const self = { boss: { phases: [{ at: 0.5, adds: { profile: 'boss', count: 1 } }] } }
await assert.rejects(npcs.create({ name: 'boss', body: body(self), servers: ['main'] }), /a boss does not summon itself/)
})
test('a kit a covered server lacks is refused on save, naming the server', async (t) => {
stubStore(t, { servers: [npcServer('main'), npcServer('arena')] })
stubWire(t, { kits: ['bandit_smg'] })