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

@@ -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'] })