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
This commit is contained in:
@@ -16,6 +16,7 @@
|
||||
// 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')
|
||||
@@ -36,7 +37,7 @@ const npcSync = require('../npcSync')
|
||||
const world = require('../eventWorld')
|
||||
const titles = require('../model/titles/titles')
|
||||
|
||||
const READY = { loaded: true, version: '0.2.0', api: 3 }
|
||||
const READY = { loaded: true, version: '0.3.0', api: 4 }
|
||||
|
||||
function body(extra = {}) {
|
||||
return { ...shape.defaults(), names: ['Warden'], kits: ['warden_rifle'], ...extra }
|
||||
@@ -47,7 +48,7 @@ function npcServer(id, runicNpc = READY, extra = {}) {
|
||||
}
|
||||
|
||||
/** An in-memory stand-in for npcs.db, one test long. */
|
||||
function stubStore(t, { servers: list = [npcServer('main')], profiles = [], sync = [] } = {}) {
|
||||
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 }]))
|
||||
@@ -76,9 +77,12 @@ function stubStore(t, { servers: list = [npcServer('main')], profiles = [], sync
|
||||
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 }
|
||||
return { store, syncRows, kills, table }
|
||||
}
|
||||
|
||||
/** The sidecar and the server rows, recording what went up. */
|
||||
@@ -96,9 +100,9 @@ function stubWire(t, { kits = ['warden_rifle', 'bandit_smg'], own = { managed: f
|
||||
calls.push({ server: srv.id, cmd: 'npc.profiles' })
|
||||
return { ok: true, data: { kind: 'npc.profiles', ...own } }
|
||||
}
|
||||
client.npcProfilesSet = async (srv, profiles) => {
|
||||
calls.push({ server: srv.id, cmd: 'npc.profiles.set', profiles })
|
||||
return { ok: true, data: { kind: 'npc.ok', refused: answers.refused || {} } }
|
||||
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) => {
|
||||
@@ -125,7 +129,7 @@ test('a profile is refused for what RunicNPC would refuse, in its own words', ()
|
||||
[{ names: ['ok', ' '] }, 'names: give at least one'],
|
||||
[{ kits: [] }, 'kits: give at least one'],
|
||||
[{ prefab: 'player' }, "prefab: 'player' is not one of Rust's scientist prefabs"],
|
||||
[{ role: 'guard' }, "role: 'guard' is not roamer or sentry"],
|
||||
[{ 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'],
|
||||
@@ -209,12 +213,12 @@ test('a server a site already manages is not adopted from', async (t) => {
|
||||
assert.strictEqual(store.length, 0)
|
||||
})
|
||||
|
||||
test('a server without RunicNPC, or with API < 3, is recorded absent and never pushed', async (t) => {
|
||||
const { syncRows } = stubStore(t, { servers: [npcServer('main', null), npcServer('arena', { loaded: true, version: '0.1.0', api: 2 })] })
|
||||
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 2, and the site needs 3/)
|
||||
assert.match(out[1].reason, /API 3, and the site needs 4/)
|
||||
assert.strictEqual(syncRows.get('main').state, 'absent')
|
||||
assert.strictEqual(calls.length, 0)
|
||||
})
|
||||
@@ -386,3 +390,110 @@ test('an edit keeps the placement\'s spot unless a whole one is sent', async (t)
|
||||
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/)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user