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
This commit is contained in:
@@ -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: '' }
|
||||
}
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user