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

@@ -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 }}>