Files
Module-Rust/server/test/npcs.test.js
wtclaude 11619a4dc6 feat(rust): NPC sides, the faction table, an event's escort, ally and tether (runicnpc stage 5)
RunicNPC stage 5 on the site (docs runicnpc/PLAN.md, D253-D272):

- profiles gain the guard role, faction, relations (its own exceptions),
  alertRadius, turrets, hurtByPlayers, hurtsPlayers and kitUse, checked in
  RunicNPC's order and words, and defaulting to "players only" (D255);
- the faction table: one site-wide table, one row per pair and both ways
  (D254, D268), stored in rust_npc_factions, edited on the NPC profiles page
  (PUT /admin/rust/npcs/factions), pushed to every server with its
  profiles and hashed with them, and a standalone server's own pairs
  adopted at its first push with the site's winning (D244, D251);
- the Place NPCs step takes escort (a Steam id or a {placeholder}), an ally
  (a clan from the new rust.options.clans source, or the team of a player)
  and tether (the zone this event made), for a RunicNPC profile only
  (D269, D270, D272);
- a placement may be held inside a zone (tether, D272);
- the bridge's RunicNPC API floor is 4.

Tests: 488 server, 66 client. routes.manifest.json regenerated against the
pinned core (one route added).

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

500 lines
32 KiB
JavaScript
Raw 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 with API < 3) 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.3.0', api: 4 }
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 or guard"],
[{ 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')
})
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 < 4, is recorded absent and never pushed', async (t) => {
const { syncRows } = stubStore(t, { servers: [npcServer('main', null), npcServer('arena', { loaded: true, version: '0.2.0', api: 3 })] })
const calls = stubWire(t)
const out = await npcSync.tick()
assert.deepStrictEqual(out.map((o) => o.outcome), ['absent', 'absent'])
assert.match(out[1].reason, /API 3, and the site needs 4/)
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('arena', 'npc.scientist'), verify: true })
assert.strictEqual(own.ok, true, 'Rust\'s own still place anywhere')
})
// ── 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')
})
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/)
})