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
This commit is contained in:
@@ -8,7 +8,7 @@
|
||||
// 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 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
|
||||
@@ -37,7 +37,7 @@ const npcSync = require('../npcSync')
|
||||
const world = require('../eventWorld')
|
||||
const titles = require('../model/titles/titles')
|
||||
|
||||
const READY = { loaded: true, version: '0.3.0', api: 4 }
|
||||
const READY = { loaded: true, version: '0.7.0', api: 6 }
|
||||
|
||||
function body(extra = {}) {
|
||||
return { ...shape.defaults(), names: ['Warden'], kits: ['warden_rifle'], ...extra }
|
||||
@@ -293,12 +293,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 < 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 })] })
|
||||
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 3, and the site needs 4/)
|
||||
assert.match(out[1].reason, /API 5, and the site needs 6/)
|
||||
assert.strictEqual(syncRows.get('main').state, 'absent')
|
||||
assert.strictEqual(calls.length, 0)
|
||||
})
|
||||
@@ -367,8 +367,35 @@ test('the Place NPCs step sends a profile, and refuses one a server cannot place
|
||||
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')
|
||||
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 ──────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user