Files
Module-Rust/server/test/npcs.test.js
wtclaude d140d5c66d feat(rust): RunicNPC is required for every Place NPCs step (runicnpc stage 9, D310)
A Place NPCs step on a server without RunicNPC, or with one older than the
bridge needs, is refused before it is sent: "<server> needs RunicNPC to place
NPCs, Rust's own scientists included: <why>". Rust's own scientists were the
fallback until now (D243); they need RunicNPC too. Crates are unaffected.

- npcs.model: API_NEEDED rises from 4 to 6, the bridge's RunicNpcApiNeeded, so a
  server the site calls ready is one the bridge will not refuse as runicnpc-old.
  The profile push follows the same floor.
- eventWorld: profileMissing becomes npcMissing, asked for every NPC step.
- Admin → Rust → Servers: each server carries `runicNpc` ({ready, absence,
  version, api}) and a server without one that will do reads "Incomplete" with
  the reason. Swagger documents the field.

The event picker is unchanged in this commit (an open question on the PR).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-06 16:58:30 -05:00

669 lines
42 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── RunicNPC: profiles, their push, placements, events and kills (stage 4) ──
//
// What each test guards (docs/runicnpc/PLAN.md stage 4):
//
// a profile is refused on the form for what RunicNPC would refuse, in its words
// a kit a covered server lacks is refused on save, naming the server
// two profiles of one name may not share a server (the zone-presets rule)
// the first push adopts a server's own profiles and changes nothing on it (D244)
// a site profile of the same name wins, and the server's is kept as replaced (D251)
// a replaced profile is restored only when nothing of its name covers its server
// a server without RunicNPC (or one older than the bridge needs) is recorded absent, never pushed
// a push happens on a change, a restart, a failure and the audit, and not otherwise
// the event picker lists the site's profiles first, then Rust's own (D243)
// the Place NPCs step sends a profile, and refuses one a server cannot place
// rust.npc.died and rust.npc.health fire with what a gate filters on
// a kill is credited to the site profile pushed under its name (D247)
// a title rule on a profile's kills names the profile (D250)
// a placement from the map goes up with x and z only (D245), and a refusal keeps its words
// stage 5: the sides fields, the faction table (pushed, hashed, adopted), an event's orders, a tether
const test = require('node:test')
const assert = require('node:assert')
const { fakeCtx } = require('./_fakes')
require('../core')._reset()
const ctx = fakeCtx({ freeze: false })
require('../core').init(ctx)
const client = require('../sidecarClient')
const servers = require('../model/servers/servers.model')
const serversDb = require('../model/servers/servers.db')
const npcsDb = require('../model/npcs/npcs.db')
const npcs = require('../model/npcs/npcs.model')
const shape = require('../model/npcs/npcProfile')
const npcSync = require('../npcSync')
const world = require('../eventWorld')
const titles = require('../model/titles/titles')
const READY = { loaded: true, version: '0.7.0', api: 6 }
function body(extra = {}) {
return { ...shape.defaults(), names: ['Warden'], kits: ['warden_rifle'], ...extra }
}
function npcServer(id, runicNpc = READY, extra = {}) {
return { id, name: id.toUpperCase(), enabled: true, online: true, bootId: 'boot-1', worldReady: true, runicNpc, ...extra }
}
/** An in-memory stand-in for npcs.db, one test long. */
function stubStore(t, { servers: list = [npcServer('main')], profiles = [], sync = [], factions = [] } = {}) {
const saved = { ...npcsDb }
const store = profiles.map((p, i) => ({ id: p.id || i + 1, allServers: false, servers: [], killsScope: 'server', adoptedFrom: null, replaced: false, ...p }))
const syncRows = new Map(sync.map((s) => [s.serverId, { ...s }]))
const kills = []
let next = 100
npcsDb.listNpcServers = async () => list
npcsDb.listProfiles = async () => store.map((p) => ({ ...p, servers: [...p.servers] }))
npcsDb.getProfile = async (id) => {
const p = store.find((x) => x.id === id)
return p ? { ...p, servers: [...p.servers] } : null
}
npcsDb.saveProfile = async (row) => {
const id = row.id || next++
const nextRow = { killsScope: 'server', adoptedFrom: null, replaced: false, ...row, id, servers: row.allServers ? [] : row.servers }
const at = store.findIndex((p) => p.id === id)
if (at >= 0) store[at] = { ...store[at], ...nextRow }
else store.push(nextRow)
return id
}
npcsDb.deleteProfile = async (id) => {
const at = store.findIndex((p) => p.id === id)
if (at >= 0) store.splice(at, 1)
}
npcsDb.listSync = async () => [...syncRows.values()]
npcsDb.getSync = async (id) => syncRows.get(id) || null
npcsDb.markAdopted = async (id) => syncRows.set(id, { serverId: id, state: 'pending', ...(syncRows.get(id) || {}), adoptedAt: '2026-09-30T00:00:00.000Z' })
npcsDb.putSync = async (id, row) => syncRows.set(id, { serverId: id, ...(syncRows.get(id) || {}), ...row, lastAttemptAt: new Date().toISOString() })
npcsDb.markDirty = async () => {}
const table = factions.map((f) => ({ ...f }))
npcsDb.listFactions = async () => table.map((f) => ({ ...f }))
npcsDb.replaceFactions = async (rows) => table.splice(0, table.length, ...rows.map((f) => ({ ...f })))
npcsDb.addKills = async (at, profile, siteProfileId, n) => kills.push({ ...at, profile, siteProfileId, n })
t.after(() => Object.assign(npcsDb, saved))
return { store, syncRows, kills, table }
}
/** The sidecar and the server rows, recording what went up. */
function stubWire(t, { kits = ['warden_rifle', 'bandit_smg'], own = { managed: false, profiles: {} }, answers = {} } = {}) {
const calls = []
const saved = {
kits: client.kits, npcProfiles: client.npcProfiles, npcProfilesSet: client.npcProfilesSet,
npcPlacements: client.npcPlacements, npcPlacement: client.npcPlacement, worldPlace: client.worldPlace,
getServer: serversDb.getServer, listForPolling: servers.listForPolling,
}
serversDb.getServer = async (id) => ({ id, name: id.toUpperCase(), sidecarBaseUrl: `http://${id}:1`, sidecarTokenEnc: null, enabled: 1 })
servers.listForPolling = async () => [{ id: 'main', name: 'MAIN' }, { id: 'arena', name: 'ARENA' }]
client.kits = async () => ({ ok: true, data: { kind: 'kits.list', kits: kits.map((name) => ({ name })) } })
client.npcProfiles = async (srv) => {
calls.push({ server: srv.id, cmd: 'npc.profiles' })
return { ok: true, data: { kind: 'npc.profiles', ...own } }
}
client.npcProfilesSet = async (srv, profiles, factions) => {
calls.push({ server: srv.id, cmd: 'npc.profiles.set', profiles, factions })
return { ok: true, data: { kind: 'npc.ok', refused: answers.refused || {}, ...(answers.factionsRefused ? { factionsRefused: answers.factionsRefused } : {}) } }
}
client.npcPlacements = async (srv) => ({ ok: true, data: { kind: 'npc.placements', placements: answers.placements || [], routes: ['gate'], cost: 'RunicNPC: 3 NPC(s)' } })
client.npcPlacement = async (srv, bodyOut) => {
calls.push({ server: srv.id, cmd: 'npc.placement', body: bodyOut })
return { ok: true, data: answers.placement || { kind: 'npc.ok', id: 'warden-1', position: { x: 1, y: 2, z: 3 }, built: false, cost: 'RunicNPC: 1 NPC(s)' } }
}
client.worldPlace = async (srv, bodyOut) => {
calls.push({ server: srv.id, cmd: 'world.place', body: bodyOut })
return { ok: true, data: { kind: 'world.ok', placed: [{ id: '77', kind: 'npc', prefab: `runicnpc:${bodyOut.profile}` }] } }
}
t.after(() => {
Object.assign(client, { kits: saved.kits, npcProfiles: saved.npcProfiles, npcProfilesSet: saved.npcProfilesSet, npcPlacements: saved.npcPlacements, npcPlacement: saved.npcPlacement, worldPlace: saved.worldPlace })
serversDb.getServer = saved.getServer
servers.listForPolling = saved.listForPolling
})
return calls
}
// ── the profile, as RunicNPC reads it ─────────────────────────────────────
test('a profile is refused for what RunicNPC would refuse, in its own words', () => {
const cases = [
[{ names: [] }, 'names: give at least one'],
[{ 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, 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'],
[{ damageTaken: { head: -1, body: 1, legs: 1 } }, 'damageTaken'],
[{ ranges: { sense: 30, loseTarget: 10, chase: 0, attack: 30 } }, 'loseTarget at least sense'],
[{ visionCone: 2 }, 'visionCone: between -1 and 1'],
[{ healthThresholds: [1.5] }, 'healthThresholds: fractions between 0 and 1'],
]
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}`)
}
const ok = shape.checkBody(body({ movement: { mode: 'route:gate' }, healthThresholds: [0.25, 0.5, 0.5] }))
assert.strictEqual(ok.ok, true)
assert.deepStrictEqual(ok.value.movement, { mode: 'route:gate', radius: 0 })
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'] })
await assert.rejects(
npcs.create({ name: 'warden', body: body(), servers: ['main'] }),
(err) => err.status === 400 && /MAIN has no kit 'warden_rifle'/.test(err.message),
)
})
test('two profiles of one name may not share a server; across servers they may', async (t) => {
stubStore(t, { servers: [npcServer('main'), npcServer('arena')], profiles: [{ id: 1, name: 'warden', body: body(), servers: ['main'] }] })
stubWire(t)
await assert.rejects(npcs.create({ name: 'warden', body: body(), allServers: true }), (err) => err.status === 409 && /already on MAIN/.test(err.message))
const made = await npcs.create({ name: 'warden', body: body(), servers: ['arena'] })
assert.deepStrictEqual(made.servers, ['arena'])
await assert.rejects(npcs.create({ name: 'Bad Name', body: body(), servers: ['main'] }), /name: 1 to 40/)
await assert.rejects(npcs.create({ name: 'x', body: body(), servers: ['main'], killsScope: 'fleet' }), /killsScope/)
})
// ── adoption and the push (D244, D251) ───────────────────────────────────
test('the first push adopts a server\'s own profiles, then pushes them back unchanged (D244)', async (t) => {
const { store, syncRows } = stubStore(t, { servers: [npcServer('main')] })
const own = { managed: false, profiles: { bandit: body({ names: ['Bandit'], kits: ['bandit_smg'] }), 'Bad Name': body() } }
const calls = stubWire(t, { own })
const [out] = await npcSync.tick({ force: 'main' })
assert.strictEqual(out.outcome, 'pushed')
assert.deepStrictEqual(out.adopted.map((a) => [a.name, a.outcome]), [['Bad Name', 'skipped'], ['bandit', 'adopted']])
assert.strictEqual(store.length, 1)
assert.deepStrictEqual({ name: store[0].name, servers: store[0].servers, adoptedFrom: store[0].adoptedFrom }, { name: 'bandit', servers: ['main'], adoptedFrom: 'main' })
const pushed = calls.find((c) => c.cmd === 'npc.profiles.set')
assert.deepStrictEqual(Object.keys(pushed.profiles), ['bandit'])
assert.deepStrictEqual(pushed.profiles.bandit.names, ['Bandit'])
assert.ok(syncRows.get('main').adoptedAt)
assert.deepStrictEqual(syncRows.get('main').pushed, { bandit: store[0].id })
})
test('a site profile of the same name wins, and the server\'s own is kept as replaced (D251)', async (t) => {
const site = { id: 1, name: 'bandit', body: body({ names: ['Site Bandit'] }), allServers: true }
const { store } = stubStore(t, { servers: [npcServer('main')], profiles: [site] })
const calls = stubWire(t, { own: { managed: false, profiles: { bandit: body({ names: ['Own Bandit'] }) } } })
const [out] = await npcSync.tick({ force: 'main' })
assert.deepStrictEqual(out.adopted, [{ name: 'bandit', outcome: 'replaced', by: 1 }])
const kept = store.find((p) => p.replaced)
assert.ok(kept && kept.adoptedFrom === 'main' && kept.body.names[0] === 'Own Bandit')
assert.deepStrictEqual(calls.find((c) => c.cmd === 'npc.profiles.set').profiles.bandit.names, ['Site Bandit'])
// Restore is refused while the site's still covers the server...
await assert.rejects(npcs.restore(kept.id), (err) => err.status === 409)
// ...and allowed once it no longer does.
await npcs.remove(1)
const back = await npcs.restore(kept.id)
assert.strictEqual(back.replaced, false)
})
test('a server a site already manages is not adopted from', async (t) => {
const { store } = stubStore(t)
stubWire(t, { own: { managed: true, profiles: { stale: body() } } })
const [out] = await npcSync.tick({ force: 'main' })
assert.strictEqual(out.outcome, 'pushed')
assert.deepStrictEqual(out.adopted, [])
assert.strictEqual(store.length, 0)
})
test('a server without RunicNPC, or with API < 6 (the bridge floor), is recorded absent and never pushed', async (t) => {
const { syncRows } = stubStore(t, { servers: [npcServer('main', null), npcServer('arena', { loaded: true, version: '0.6.0', api: 5 })] })
const calls = stubWire(t)
const out = await npcSync.tick()
assert.deepStrictEqual(out.map((o) => o.outcome), ['absent', 'absent'])
assert.match(out[1].reason, /API 5, and the site needs 6/)
assert.strictEqual(syncRows.get('main').state, 'absent')
assert.strictEqual(calls.length, 0)
})
test('a push happens on a change, a restart, a failure and the audit, and not otherwise', () => {
const server = { worldReady: true, online: true, bootId: 'b1' }
const now = new Date().toISOString()
const ok = { adoptedAt: now, state: 'ok', syncedHash: 'h', bootId: 'b1', lastAttemptAt: now }
assert.strictEqual(npcSync.reasonToPush({ server, sync: null, hash: 'h' }), 'first')
assert.strictEqual(npcSync.reasonToPush({ server, sync: ok, hash: 'h' }), null)
assert.strictEqual(npcSync.reasonToPush({ server, sync: ok, hash: 'other' }), 'changed')
assert.strictEqual(npcSync.reasonToPush({ server: { ...server, bootId: 'b2' }, sync: ok, hash: 'h' }), 'restart')
assert.strictEqual(npcSync.reasonToPush({ server, sync: { ...ok, lastAttemptAt: new Date(Date.now() - npcSync.AUDIT_MS - 1000).toISOString() }, hash: 'h' }), 'audit')
assert.strictEqual(npcSync.reasonToPush({ server, sync: { ...ok, state: 'failed', syncedHash: null }, hash: 'h' }), 'retry')
assert.strictEqual(npcSync.reasonToPush({ server: { ...server, worldReady: false }, sync: ok, hash: 'other' }), null, 'not before the world has loaded')
assert.strictEqual(npcSync.reasonToPush({ server: { ...server, online: false }, sync: null, hash: 'h' }), null)
})
test('what a server is pushed: its covering profiles, not the replaced, hashed stably', () => {
const profiles = [
{ id: 1, name: 'warden', body: body(), allServers: true, servers: [], replaced: false },
{ id: 2, name: 'bandit', body: body(), allServers: false, servers: ['arena'], replaced: false },
{ id: 3, name: 'old', body: body(), allServers: false, servers: ['main'], replaced: true },
]
const main = npcs.desiredFor('main', profiles)
assert.deepStrictEqual(Object.keys(main.profiles), ['warden'])
assert.deepStrictEqual(main.map, { warden: 1 })
assert.strictEqual(main.hash, npcs.desiredFor('main', [...profiles].reverse()).hash)
assert.notStrictEqual(main.hash, npcs.desiredFor('arena', profiles).hash)
})
// ── events (D243) ────────────────────────────────────────────────────────
test('the NPC picker lists the site\'s profiles first, then Rust\'s own, grouped (D243)', async (t) => {
stubStore(t, {
servers: [npcServer('main'), npcServer('arena', null)],
profiles: [{ id: 1, name: 'warden', body: body({ names: ['Warden'] }), servers: ['main'] }, { id: 2, name: 'ghost', body: body(), servers: ['arena'] }],
})
const rows = await world.OPTION_SOURCES.find((s) => s.id === 'rust.options.npcs').resolve()
assert.deepStrictEqual(rows[0], { value: 'profile:warden', label: 'Warden (warden)', group: 'NPC profiles' })
assert.ok(!rows.some((r) => r.value === 'profile:ghost'), 'a profile only on a server without RunicNPC is not offered')
assert.ok(rows.slice(1).every((r) => r.group === "Rust's own"))
})
test('with no server that has RunicNPC, the picker reads as it always did', async (t) => {
stubStore(t, { servers: [npcServer('main', null)], profiles: [{ id: 1, name: 'warden', body: body(), allServers: true }] })
const rows = await world.OPTION_SOURCES.find((s) => s.id === 'rust.options.npcs').resolve()
assert.deepStrictEqual(rows.map((r) => r.value), world.PLACEABLE.filter((p) => p.kind === 'npc').map((p) => p.key))
assert.ok(rows.every((r) => !r.group))
})
test('the Place NPCs step sends a profile, and refuses one a server cannot place', async (t) => {
stubStore(t, { servers: [npcServer('main'), npcServer('arena', { loaded: false })], profiles: [{ id: 1, name: 'warden', body: body(), servers: ['main'] }] })
const calls = stubWire(t)
const place = world.ACTIONS.find((a) => a.id === 'rust.npc.place')
const params = (server, prefab) => ({ prefab, count: 3, server, x: 10, z: 20 })
const done = await place.perform({ runId: 9, idempotencyKey: 'k1', params: params('main', 'profile:warden') })
assert.strictEqual(done.ok, true, done.error)
const sent = calls.find((c) => c.cmd === 'world.place').body
assert.strictEqual(sent.profile, 'warden')
assert.strictEqual(sent.prefab, undefined)
const noNpc = await place.perform({ runId: 9, idempotencyKey: 'k2', params: params('arena', 'profile:warden'), verify: true })
assert.strictEqual(noNpc.ok, false)
assert.match(noNpc.error, /RunicNPC is not loaded on it/)
const unknown = await place.perform({ runId: 9, idempotencyKey: 'k3', params: params('main', 'profile:nobody'), verify: true })
assert.match(unknown.error, /no NPC profile "nobody"/)
const own = await place.perform({ runId: 9, idempotencyKey: 'k4', params: params('main', 'npc.scientist'), verify: true })
assert.strictEqual(own.ok, true, 'with RunicNPC loaded, Rust\'s own still place')
})
test('without RunicNPC every Place NPCs step is refused, Rust\'s own included (D310)', async (t) => {
stubStore(t, {
servers: [npcServer('arena', { loaded: false }), npcServer('old', { loaded: true, version: '0.5.0', api: 5 }), npcServer('quiet', null)],
profiles: [],
})
const calls = stubWire(t)
const place = world.ACTIONS.find((a) => a.id === 'rust.npc.place')
const at = (server) => ({ prefab: 'npc.scientist', count: 2, server, x: 10, z: 20 })
const missing = await place.perform({ runId: 9, idempotencyKey: 'a', params: at('arena') })
assert.deepStrictEqual([missing.ok, missing.retry], [false, false])
assert.match(missing.error, /needs RunicNPC to place NPCs, Rust's own scientists included: RunicNPC is not loaded on it/)
const old = await place.perform({ runId: 9, idempotencyKey: 'b', params: at('old'), verify: true })
assert.match(old.error, /answers API 5, and the site needs 6/)
const quiet = await place.perform({ runId: 9, idempotencyKey: 'c', params: at('quiet'), verify: true })
assert.match(quiet.error, /has not said whether it has RunicNPC/)
assert.strictEqual(calls.filter((c) => c.cmd === 'world.place').length, 0, 'nothing is sent to a server that would refuse it')
// Crates are not NPCs: the other verb never asks.
const crates = world.ACTIONS.find((a) => a.id === 'rust.crate.place')
const crate = await crates.perform({ runId: 9, idempotencyKey: 'd', params: { ...at('arena'), prefab: 'crate.elite' }, verify: true })
assert.strictEqual(crate.ok, true, crate.error)
})
// ── triggers, ingest and titles ──────────────────────────────────────────
test('rust.npc.died and rust.npc.health fire with what a gate filters on', async () => {
const emit = require('../engagement/emit')
const calls = ctx.events.emit.calls
const before = calls.length
const server = { id: 'main', name: 'Main' }
const t = Date.now()
await emit.onEvent(server, { kind: 'npc.died', frame: { kind: 'npc.died', t, netId: '5', profile: 'guard', name: 'Gate Guard', runId: '42', killerName: 'Marisol', killerId: '7656', contributors: [{ steamId: '7656', damage: 250 }] } })
await emit.onEvent(server, { kind: 'npc.health', frame: { kind: 'npc.health', t, netId: '6', profile: 'boss', name: 'Big', owner: 'placement:boss-1', placement: 'boss-1', threshold: 0.5 } })
await emit.onEvent(server, { kind: 'npc.died', frame: { kind: 'npc.died', t: t - 60 * 60 * 1000, netId: '8', profile: 'guard' } })
const fired = calls.slice(before)
assert.strictEqual(fired.length, 2, 'a death an hour old is history, not a wave falling')
const [died, health] = fired
assert.strictEqual(died[0], 'rust.npc.died')
assert.deepStrictEqual(
{ profile: died[1].data.profile, byEvent: died[1].data.byEvent, runId: died[1].data.runId, killer: died[1].data.killer, contributors: died[1].data.contributors },
{ profile: 'guard', byEvent: true, runId: '42', killer: 'Marisol', contributors: 1 },
)
assert.strictEqual(health[0], 'rust.npc.health')
assert.deepStrictEqual({ percent: health[1].data.percent, byEvent: health[1].data.byEvent, placement: health[1].data.placement }, { percent: 50, byEvent: false, placement: 'boss-1' })
})
test('a kill is credited to the site profile pushed under its name (D247)', async (t) => {
const { kills } = stubStore(t, { sync: [{ serverId: 'main', state: 'ok', pushed: { warden: 4 } }] })
const eventsDb = require('../model/events/events.db')
const saved = { ...eventsDb }
for (const k of ['touchWipe', 'insertEvent', 'touchPlayer', 'addStats', 'addGathered', 'addTitleStats', 'addWeaponKills']) eventsDb[k] = async () => {}
t.after(() => Object.assign(eventsDb, saved))
const ingest = require('../ingest')
await ingest.apply('main', { kind: 'player.tally', frame: { kind: 'player.tally', wipeId: 'w1', steamId: '7656', npcKills: 3, npcProfileKills: { warden: 2, bandit: 1, zero: 0 } } })
assert.deepStrictEqual(
kills.map((k) => [k.profile, k.siteProfileId, k.n, k.wipeId]),
[['warden', 4, 2, 'w1'], ['bandit', 0, 1, 'w1']],
)
})
test('a title rule on a profile\'s kills names the profile, and ranks it on its own (D250)', () => {
const bad = titles.validateSettings({ rules: [{ stat: 'profilekills', topN: 1, text: 'Warden Slayer', color: '#ff0000' }] })
assert.strictEqual(bad.ok, false)
assert.match(bad.errors[0], /pick the NPC profile/)
const good = titles.validateSettings({ rules: [
{ stat: 'profilekills', profile: 4, topN: 1, text: 'Warden Slayer', color: '#ff0000' },
{ stat: 'profilekills', profile: 5, topN: 1, text: 'Bandit Bane', color: '#00ff00' },
] })
assert.strictEqual(good.ok, true)
assert.deepStrictEqual(good.value.rules.map((r) => r.profile), [4, 5])
const held = titles.evaluate(good.value.rules, {
'profilekills:4': [{ steamId: 'a', value: 3 }],
'profilekills:5': [{ steamId: 'b', value: 9 }],
})
assert.deepStrictEqual(held.get('a'), [{ text: 'Warden Slayer', color: '#ff0000' }])
assert.deepStrictEqual(held.get('b'), [{ text: 'Bandit Bane', color: '#00ff00' }])
})
test('a profile\'s kills are counted as the profile says (D247)', () => {
const base = { name: 'warden', siteProfileId: 4, serverId: 'main' }
const server = npcsDb.scopeWhere({ ...base, scope: 'server', wipeId: 'w1' })
assert.match(server.sql, /k\.profile = \? AND k\.server_id = \? AND k\.wipe_id = \?/)
assert.deepStrictEqual(server.args, ['warden', 'main', 'w1'])
const name = npcsDb.scopeWhere({ ...base, scope: 'name', wipeId: 'w1', currentWipe: true })
assert.match(name.sql, /k\.profile = \? AND k\.wipe_id = \(SELECT/)
assert.deepStrictEqual(name.args, ['warden'])
const profile = npcsDb.scopeWhere({ ...base, scope: 'profile', wipeId: null })
assert.strictEqual(profile.sql, 'k.site_profile_id = ?')
assert.deepStrictEqual(profile.args, [4])
})
// ── placements (D245, D246) ─────────────────────────────────────────────
test('a placement from the map goes up with x and z only, and comes back named (D245, D246)', async (t) => {
stubStore(t)
const calls = stubWire(t)
const out = await npcs.addPlacement('main', { profile: 'warden', position: { x: 12.5, y: 99, z: -40 }, count: 3, respawnMode: 'group' })
assert.deepStrictEqual(out, { id: 'warden-1', position: { x: 1, y: 2, z: 3 }, built: false, cost: 'RunicNPC: 1 NPC(s)' })
const sent = calls.find((c) => c.cmd === 'npc.placement').body
assert.strictEqual(sent.op, 'add')
assert.deepStrictEqual(sent.placement.position, { x: 12.5, z: -40 }, 'the server grounds it, so no y goes up')
assert.deepStrictEqual({ count: sent.placement.count, respawn: sent.placement.respawn, respawnMode: sent.placement.respawnMode }, { count: 3, respawn: 300, respawnMode: 'group' })
await assert.rejects(npcs.addPlacement('main', { profile: 'warden', position: { x: 1 } }), /position: x and z/)
await assert.rejects(npcs.addPlacement('main', { profile: 'warden', position: { x: 1, z: 1 }, respawnMode: 'sometimes' }), /each or group/)
})
test('a refusal from the server keeps RunicNPC\'s own words and a fitting status', async (t) => {
stubStore(t)
stubWire(t, { answers: { placement: { kind: 'npc.error', reason: 'refused', message: 'A roamer cannot stand there: it is under water.' } } })
await assert.rejects(npcs.addPlacement('main', { profile: 'warden', position: { x: 1, z: 1 } }), (err) => err.status === 400 && err.message === 'A roamer cannot stand there: it is under water.')
stubWire(t, { answers: { placement: { kind: 'npc.error', reason: 'not-found', message: "there is no placement 'x'" } } })
await assert.rejects(npcs.removePlacement('main', 'x'), (err) => err.status === 404)
})
test('an edit keeps the placement\'s spot unless a whole one is sent', async (t) => {
stubStore(t)
const calls = stubWire(t, { answers: { placements: [{ id: 'warden-1', placement: { profile: 'warden', position: { x: 5, y: 6, z: 7 }, yaw: 90, count: 1, respawn: 300, respawnMode: 'each' }, alive: 1 }] } })
await npcs.setPlacement('main', 'warden-1', { profile: 'warden', count: 2 })
const sent = calls.find((c) => c.cmd === 'npc.placement').body
assert.deepStrictEqual({ op: sent.op, id: sent.id, position: sent.placement.position, yaw: sent.placement.yaw, count: sent.placement.count }, { op: 'set', id: 'warden-1', position: { x: 5, y: 6, z: 7 }, yaw: 90, count: 2 })
await assert.rejects(npcs.setPlacement('main', 'nope', { profile: 'warden' }), (err) => err.status === 404)
})
// ── stage 5: sides, the faction table, an event's orders, a placement's tether ──
test('a profile\'s stage 5 fields default to "players only" and are refused as RunicNPC refuses them', () => {
const ok = shape.checkBody(body())
assert.strictEqual(ok.ok, true, ok.error)
assert.deepStrictEqual(
{ faction: ok.value.faction, relations: ok.value.relations, alertRadius: ok.value.alertRadius, turrets: ok.value.turrets, hurtByPlayers: ok.value.hurtByPlayers, hurtsPlayers: ok.value.hurtsPlayers },
{ faction: null, relations: {}, alertRadius: 40, turrets: 'default', hurtByPlayers: true, hurtsPlayers: true },
)
assert.deepStrictEqual(ok.value.kitUse, { heal: false, grenades: false, melee: false, rockets: false, flamethrower: false })
const kept = shape.checkBody(body({ role: 'guard', faction: 'bandits', relations: { scientists: 'hostile', 'profile:warden': 'allied' }, kitUse: { heal: true, nonsense: true } }))
assert.strictEqual(kept.ok, true, kept.error)
assert.strictEqual(kept.value.role, 'guard')
assert.deepStrictEqual(kept.value.relations, { scientists: 'hostile', 'profile:warden': 'allied' })
assert.deepStrictEqual(kept.value.kitUse, { heal: true, grenades: false, melee: false, rockets: false, flamethrower: false })
const cases = [
[{ faction: 'scientists' }, "faction: 'scientists' must be 1–40 of a-z, 0-9, _ and -, and not scientists or animals (Rust's own)"],
[{ faction: 'Bad Name' }, "faction: 'Bad Name' must be"],
[{ relations: { 'not a key': 'hostile' } }, "relations: 'not a key' is not a faction, scientists, animals or profile:<name>"],
[{ relations: { scientists: 'angry' } }, "relations.scientists: 'angry' is not hostile, neutral or allied"],
[{ alertRadius: -1 }, 'alertRadius: 0 (off) or more'],
[{ turrets: 'sometimes' }, "turrets: 'sometimes' is not default, ignore or always"],
[{ hurtByPlayers: 'no' }, 'hurtByPlayers and hurtsPlayers: true or false'],
]
for (const [extra, words] of cases) {
const r = shape.checkBody(body(extra))
assert.strictEqual(r.ok, false, JSON.stringify(extra))
assert.ok(r.error.startsWith(words), `${r.error} / ${words}`)
}
})
test('the faction table is one row per pair, both ways, refused in RunicNPC\'s words (D254, D268)', () => {
const ok = shape.checkFactions([{ a: 'red', b: 'blue', relation: 'hostile' }, { a: 'scientists', b: 'red', relation: 'hostile' }])
assert.strictEqual(ok.ok, true, ok.error)
assert.deepStrictEqual(ok.value, [{ a: 'blue', b: 'red', relation: 'hostile' }, { a: 'red', b: 'scientists', relation: 'hostile' }])
assert.match(shape.checkFactions([{ a: 'red', b: 'red', relation: 'hostile' }]).error, /always allied to itself/)
assert.match(shape.checkFactions([{ a: 'scientists', b: 'animals', relation: 'hostile' }]).error, /both are Rust's own/)
assert.match(shape.checkFactions([{ a: 'red', b: 'blue', relation: 'angry' }]).error, /'angry' is not hostile, neutral or allied/)
assert.match(shape.checkFactions([{ a: 'red', b: 'blue', relation: 'hostile' }, { a: 'blue', b: 'red', relation: 'allied' }]).error, /given twice \(one row is both ways, D268\)/)
assert.match(shape.checkFactions('red').error, /a list of/)
})
test('the faction table is pushed with the profiles, moves the hash, and a refusal is recorded', async (t) => {
const { syncRows } = stubStore(t, { profiles: [{ id: 1, name: 'warden', body: body(), allServers: true }], factions: [{ a: 'blue', b: 'red', relation: 'hostile' }] })
const calls = stubWire(t, { answers: { factionsRefused: 'factions[0]: nope' } })
const [out] = await npcSync.tick({ force: 'main' })
assert.strictEqual(out.outcome, 'pushed')
const pushed = calls.find((c) => c.cmd === 'npc.profiles.set')
assert.deepStrictEqual(pushed.factions, [{ a: 'blue', b: 'red', relation: 'hostile' }])
assert.strictEqual(syncRows.get('main').refused['(factions)'], 'factions[0]: nope')
const profiles = [{ id: 1, name: 'warden', body: body(), allServers: true, servers: [], replaced: false }]
const a = npcs.desiredFor('main', profiles, [])
const b = npcs.desiredFor('main', profiles, [{ a: 'blue', b: 'red', relation: 'hostile' }])
assert.notStrictEqual(a.hash, b.hash, 'an edit to the table is pushed like an edit to a profile')
})
test('a standalone server\'s own faction pairs are adopted, the site\'s winning (D244, D251)', async (t) => {
const { table } = stubStore(t, { factions: [{ a: 'blue', b: 'red', relation: 'neutral' }] })
stubWire(t, { own: { managed: false, profiles: {}, factions: [{ a: 'red', b: 'blue', relation: 'hostile' }, { a: 'green', b: 'red', relation: 'allied' }] } })
const [out] = await npcSync.tick({ force: 'main' })
assert.strictEqual(out.outcome, 'pushed')
assert.deepStrictEqual(table, [{ a: 'blue', b: 'red', relation: 'neutral' }, { a: 'green', b: 'red', relation: 'allied' }])
assert.deepStrictEqual(out.adopted, [{ name: 'green↔red', outcome: 'faction' }])
})
test('the site\'s faction table is replaced whole, checked, and pushed everywhere', async (t) => {
const { table } = stubStore(t)
const saved = await npcs.setFactions([{ a: 'red', b: 'blue', relation: 'hostile' }])
assert.deepStrictEqual(saved, [{ a: 'blue', b: 'red', relation: 'hostile' }])
assert.deepStrictEqual(table, saved)
await assert.rejects(npcs.setFactions([{ a: 'red', b: 'red', relation: 'hostile' }]), (err) => err.status === 400 && /allied to itself/.test(err.message))
})
test('the Place NPCs step sends escort, ally and tether for a profile, and refuses them for Rust\'s own', async (t) => {
stubStore(t, { profiles: [{ id: 1, name: 'warden', body: body(), servers: ['main'] }] })
const calls = stubWire(t)
const place = world.ACTIONS.find((a) => a.id === 'rust.npc.place')
const base = { prefab: 'profile:warden', count: 2, server: 'main', x: 10, z: 20 }
const steam = '76561198000000001'
const done = await place.perform({ runId: 9, idempotencyKey: 'o1', params: { ...base, escort: steam, allyClan: 'main/1234567', tether: true } })
assert.strictEqual(done.ok, true, done.error)
const sent = calls.find((c) => c.cmd === 'world.place').body
assert.deepStrictEqual({ escort: sent.escort, ally: sent.ally, tether: sent.tether }, { escort: steam, ally: { kind: 'clan', id: '1234567' }, tether: true })
const team = await place.perform({ runId: 9, idempotencyKey: 'o2', params: { ...base, allyTeamOf: steam }, verify: true })
assert.strictEqual(team.ok, true, team.error)
const bad = async (params) => (await place.perform({ runId: 9, idempotencyKey: 'ox', params: { ...base, ...params }, verify: true })).error
assert.match(await bad({ escort: 'bob' }), /Steam id, and "bob" is not one/)
assert.match(await bad({ allyClan: 'main/1', allyTeamOf: steam }), /a clan or a player and their team, not both/)
assert.match(await bad({ allyClan: 'arena/1' }), /that clan is on arena/)
assert.match(await bad({ prefab: 'npc.scientist', escort: steam }), /for one of the site's NPC profiles/)
const plain = await place.perform({ runId: 9, idempotencyKey: 'o3', params: { ...base, tether: false }, verify: true })
assert.strictEqual(plain.ok, true, 'an untouched order is no order')
})
// ── stage 7: loot (D290–D301) ─────────────────────────────────────────────
const LOOT = {
loot: {
start: 'kit',
always: [{ item: 'scrap', min: 100, max: 300, chance: 1 }, { item: 'rope', chance: 0.5 }],
pool: { pick: 1, nothing: 85, rows: [{ item: 'lmg.m249', weight: 10 }, { item: 'explosive.timed', weight: 5 }] },
crate: 'military',
corpseSeconds: 0,
},
}
test('stage 7: a loot table is refused for what RunicNPC would refuse, in its own words', () => {
const cases = [
[{ loot: { start: 'some' } }, "loot.start: 'some' is not rust, kit or none"],
[{ loot: { always: [{ item: 'Not An Item' }] } }, "loot.always[0].item: 'Not An Item' is not an item's short name"],
[{ loot: { always: [{ item: 'scrap', chance: 0 }] } }, 'loot.always[0].chance: above 0 and at most 1'],
[{ loot: { always: [{ item: 'scrap', min: 5, max: 2 }] } }, 'loot.always[0]: min at least 1, and max at least min'],
[{ loot: { pool: { pick: 2, rows: [{ item: 'scrap' }] } } }, 'loot.pool.pick: 0 to the number of pool rows'],
[{ loot: { pool: { rows: [{ item: 'scrap', weight: 0 }] } } }, 'loot.pool.rows[0].weight: above 0'],
[{ loot: { crate: 'barrel' } }, "loot.crate: 'barrel' is not wooden, military, elite or locked"],
[{ loot: { crate: 'wooden', always: ['a', 'b', 'c', 'd', 'e', 'f', 'g'].map((x) => ({ item: x })) } }, 'need 7 slots, and the wooden crate has 6'],
[{ loot: { hackSeconds: 0 } }, 'loot.hackSeconds: 1 to 86400'],
[{ loot: { corpseSeconds: -1 } }, "loot.corpseSeconds: null (Rust's own time), 0 (none) or more"],
]
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 7: a loot table reads back clean, with every default filled', () => {
const out = shape.checkBody(body(LOOT))
assert.strictEqual(out.ok, true, out.error)
assert.deepStrictEqual(out.value.loot, {
start: 'kit',
always: [{ item: 'scrap', min: 100, max: 300, chance: 1 }, { item: 'rope', min: 1, max: 1, chance: 0.5 }],
pool: { pick: 1, nothing: 85, rows: [{ item: 'lmg.m249', min: 1, max: 1, weight: 10 }, { item: 'explosive.timed', min: 1, max: 1, weight: 5 }] },
crate: 'military',
hackSeconds: 900,
corpseSeconds: 0,
dropTable: true,
})
assert.strictEqual(shape.checkBody(body()).value.loot, null, 'no loot block keeps Rust\'s scientist loot')
})
test('stage 7: the Place NPCs step sends "drop loot" off only when switched off (D296)', async (t) => {
stubStore(t, { profiles: [{ id: 1, name: 'warden', body: body(), servers: ['main'] }] })
const calls = stubWire(t)
const place = world.ACTIONS.find((a) => a.id === 'rust.npc.place')
const base = { prefab: 'profile:warden', count: 1, server: 'main', x: 10, z: 20 }
assert.ok(place.params.some((p) => p.name === 'dropLoot' && p.type === 'boolean'))
await place.perform({ runId: 9, idempotencyKey: 'l1', params: { ...base, dropLoot: false } })
assert.strictEqual(calls.filter((c) => c.cmd === 'world.place').pop().body.dropLoot, false)
await place.perform({ runId: 9, idempotencyKey: 'l2', params: { ...base } })
assert.strictEqual('dropLoot' in calls.filter((c) => c.cmd === 'world.place').pop().body, false, 'on is the default and is not sent')
const scientist = await place.perform({ runId: 9, idempotencyKey: 'l3', params: { ...base, prefab: 'npc.scientist', dropLoot: false }, verify: true })
assert.match(scientist.error, /drop loot are for one of the site's NPC profiles/)
})
test('a placement may be held inside a zone (D272), and a bad zone id is refused here', async (t) => {
stubStore(t)
const calls = stubWire(t)
await npcs.addPlacement('main', { profile: 'warden', position: { x: 1, z: 2 }, tether: 'arena_zone' })
assert.strictEqual(calls.find((c) => c.cmd === 'npc.placement').body.placement.tether, 'arena_zone')
await assert.rejects(npcs.addPlacement('main', { profile: 'warden', position: { x: 1, z: 2 }, tether: 'not a zone!' }), /tether: a ZoneManager zone id/)
})