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