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
500 lines
32 KiB
JavaScript
500 lines
32 KiB
JavaScript
// ── 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/)
|
||
})
|