From d140d5c66d2467630780dd9f8bdd04f4badf9585 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 6 Oct 2026 16:58:30 -0500 Subject: [PATCH] feat(rust): RunicNPC is required for every Place NPCs step (runicnpc stage 9, D310) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Place NPCs step on a server without RunicNPC, or with one older than the bridge needs, is refused before it is sent: " needs RunicNPC to place NPCs, Rust's own scientists included: ". 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 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- client/src/routes/admin/ServerSettings.jsx | 12 +++++++ server/eventWorld.js | 25 +++++++------ server/model/npcs/npcs.model.js | 28 +++++++++++++-- server/router/admin/rust.controller.js | 5 ++- server/swagger/doc.js | 10 ++++++ server/test/npcs.test.js | 41 ++++++++++++++++++---- server/test/world.test.js | 6 ++++ 7 files changed, 107 insertions(+), 20 deletions(-) diff --git a/client/src/routes/admin/ServerSettings.jsx b/client/src/routes/admin/ServerSettings.jsx index adaf4aa..8ee4264 100644 --- a/client/src/routes/admin/ServerSettings.jsx +++ b/client/src/routes/admin/ServerSettings.jsx @@ -61,6 +61,15 @@ const ZONES = (() => { })() /** An ISO instant as the value a `datetime-local` input takes, in the browser's clock. */ +/** + * Why the server's RunicNPC will not do (runicnpc stage 9, D310), or null when it + * does. RunicNPC is required, so a server without it is shown as incomplete. + */ +export function runicNpcNote(npc) { + if (!npc || npc.ready) return null + return `Incomplete: ${npc.absence || 'RunicNPC is not loaded on it'}. RunicNPC is required — without it every Place NPCs step is refused, Rust’s own scientists included. Runic Gateway’s installer and egg put it in place.` +} + /** * What the missing ZoneManager helper costs this server (PLAN_FIXES F12, D182), or * null when there is nothing to say — the helper patched, or a plugin that @@ -252,6 +261,9 @@ export default function ServerSettings() { : 'No wipe schedule set.'}
{titlesSummary(s.titles, s.titlePush, categories)}
+ {runicNpcNote(s.runicNpc) && ( +
{runicNpcNote(s.runicNpc)}
+ )} {zoneHelperNote(s.zoneHelper) && (
{zoneHelperNote(s.zoneHelper)}
)} diff --git a/server/eventWorld.js b/server/eventWorld.js index a2439d4..276629e 100644 --- a/server/eventWorld.js +++ b/server/eventWorld.js @@ -116,7 +116,8 @@ const PERMANENT = new Set([ // and a dome asked of a server without ZoneDomes or the domes helper. 'bad-option', 'dome-unavailable', - // runicnpc stage 4 (D243): a profile the server does not have, or no RunicNPC. + // runicnpc stage 4 (D243): a profile the server does not have, or no RunicNPC + // (since stage 9, D310, for every NPC step, Rust's own scientists included). 'unknown-profile', 'runicnpc-missing', // runicnpc stage 5 (D272): no zone of the run holds the point; a RunicNPC @@ -538,8 +539,9 @@ function placeVerb({ id, kind, budget, max, label, description, source, example const found = await serverFor(where.serverId) if (!found.ok) return found - if (profile !== null) { - const missing = await profileMissing(found.server, profile) + // D310: every NPC needs RunicNPC on the server, Rust's own scientists included. + if (kind === 'npc') { + const missing = await npcMissing(found.server, profile) if (missing) return { ok: false, retry: false, error: missing } } @@ -651,16 +653,19 @@ function npcOrders(params, server) { } /** - * Why a profile cannot be placed on this server, from what the site knows, or - * null (D243). A server without RunicNPC offers only Rust's own until stage 9; - * a profile the site does not push there is not on it. What the server itself - * holds is the plugin's to answer (`unknown-profile`). + * Why NPCs cannot be placed on this server, from what the site knows, or null. + * Since runicnpc stage 9 (D310) that is every Place NPCs step on a server + * without RunicNPC, or with one older than the bridge needs, Rust's own + * scientists included: the bridge refuses them the same way. `profile` is null + * for one of Rust's own; a profile the site does not push there is not on it. + * What the server itself holds is the plugin's to answer (`unknown-profile`). */ -async function profileMissing(server, profile) { - const [list, profiles] = await Promise.all([npcsDb.listNpcServers(), npcsDb.listProfiles()]) +async function npcMissing(server, profile) { + const [list, profiles] = await Promise.all([npcsDb.listNpcServers(), profile === null ? [] : npcsDb.listProfiles()]) const here = list.find((s) => s.id === server.id) const name = server.name || server.id - if (!npcs.npcReady(here)) return `${name} cannot place the profile "${profile}": ${npcs.npcAbsence(here)}. Pick one of the server's own scientists.` + if (!npcs.npcReady(here)) return `${name} needs RunicNPC to place NPCs, Rust's own scientists included: ${npcs.npcAbsence(here)}` + if (profile === null) return null if (!profiles.some((p) => !p.replaced && p.name === profile && npcs.covers(p, server.id))) { return `the site has no NPC profile "${profile}" for ${name} (Admin → Rust NPC profiles)` } diff --git a/server/model/npcs/npcs.model.js b/server/model/npcs/npcs.model.js index 673c5b1..1e206e8 100644 --- a/server/model/npcs/npcs.model.js +++ b/server/model/npcs/npcs.model.js @@ -18,8 +18,13 @@ const serversDb = require('../servers/servers.db') const db = require('./npcs.db') const shape = require('./npcProfile') -/** The RunicNPC API the bridge's `npc.*` commands need: 3 for placements (D249), 4 for the faction table and an event's orders (stage 5). */ -const API_NEEDED = 4 +/** + * The RunicNPC API the bridge needs (its `RunicNpcApiNeeded`): 3 for placements + * (D249), 4 the faction table and an event's orders (stage 5), 5 bosses (stage 6), + * 6 loot (stage 7). Stage 9 (D310) makes the site's floor the bridge's, so a + * server the site calls ready is one the bridge will not refuse as `runicnpc-old`. + */ +const API_NEEDED = 6 class NpcError extends Error { constructor(message, status = 400) { @@ -51,6 +56,24 @@ function npcAbsence(server) { return null } +/** + * Each server's RunicNPC as Admin → Rust shows it (stage 9, D310): a server + * without one that will do is incomplete, because events cannot place NPCs there. + */ +async function serverStatus() { + const out = new Map() + for (const s of await db.listNpcServers()) { + const ready = npcReady(s) + out.set(s.id, { + ready, + ...(ready ? {} : { absence: npcAbsence(s) }), + version: (s.runicNpc && s.runicNpc.version) || null, + api: (s.runicNpc && s.runicNpc.api) || 0, + }) + } + return out +} + // ── What a server is sent ─────────────────────────────────────────────────── /** @@ -514,6 +537,7 @@ module.exports = { covers, npcReady, npcAbsence, + serverStatus, desiredFor, adopt, describe, diff --git a/server/router/admin/rust.controller.js b/server/router/admin/rust.controller.js index 88fd1c8..43882b6 100644 --- a/server/router/admin/rust.controller.js +++ b/server/router/admin/rust.controller.js @@ -27,10 +27,11 @@ const log = core.logger('admin') async function listServers(req, res) { try { - const [rows, settings, categories] = await Promise.all([ + const [rows, settings, categories, npcStatus] = await Promise.all([ servers.listForAdmin(), titlesModel.settingsByServer(), titlesModel.categories(), + npcs.serverStatus(), ]) // Phase 17: each server's chat titles, and what the last push of them found. @@ -41,6 +42,8 @@ async function listServers(req, res) { ...row, titles: settings.get(row.id) || { mode: 'first', max: 2, rules: [] }, titlePush: titleSync.lastPush(row.id), + // Runicnpc stage 9 (D310): RunicNPC is required, so a server without it is incomplete. + runicNpc: npcStatus.get(row.id) || { ready: false, absence: 'no such server', version: null, api: 0 }, })), titleCategories: categories, }) diff --git a/server/swagger/doc.js b/server/swagger/doc.js index 1f1ffc1..877ace8 100644 --- a/server/swagger/doc.js +++ b/server/swagger/doc.js @@ -149,6 +149,16 @@ module.exports = { reason: { type: 'string', example: 'this ZoneManager (3.2.0) has no Zone.InitializeZone' }, }, }, + runicNpc: { + type: 'object', + description: "The server's RunicNPC from its last hello (runicnpc stage 9, D310). RunicNPC is required: `ready: false` means every Place NPCs step on it is refused, Rust's own scientists included, and `absence` says why.", + properties: { + ready: { type: 'boolean', example: true }, + absence: { type: 'string', example: 'RunicNPC is not loaded on it' }, + version: { type: 'string', nullable: true, example: '1.0.0' }, + api: { type: 'integer', example: 6 }, + }, + }, online: { type: 'boolean', example: true }, players: { type: 'integer', example: 42 }, stale: { type: 'boolean', example: false }, diff --git a/server/test/npcs.test.js b/server/test/npcs.test.js index cd8012d..1f62b9f 100644 --- a/server/test/npcs.test.js +++ b/server/test/npcs.test.js @@ -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 ────────────────────────────────────────── diff --git a/server/test/world.test.js b/server/test/world.test.js index a66c8dd..5517033 100644 --- a/server/test/world.test.js +++ b/server/test/world.test.js @@ -25,6 +25,7 @@ const core = require('../core') const client = require('../sidecarClient') const serversDb = require('../model/servers/servers.db') const servers = require('../model/servers/servers.model') +const npcsDb = require('../model/npcs/npcs.db') const world = require('../eventWorld') const action = (id) => world.ACTIONS.find((a) => a.id === id) @@ -47,9 +48,13 @@ function stub(t, { zone, place, revert, owned, monuments, polling } = {}) { worldRevert: client.worldRevert, worldOwned: client.worldOwned, worldMonuments: client.worldMonuments, + listNpcServers: npcsDb.listNpcServers, } serversDb.getServer = async (id) => ROWS[id] || null + // Runicnpc stage 9 (D310): an NPC step asks for RunicNPC first; every server here has it. + npcsDb.listNpcServers = async () => + Object.keys(ROWS).map((id) => ({ id, name: ROWS[id].name, runicNpc: { loaded: true, version: '1.0.0', api: 6 } })) servers.listForPolling = async () => (polling || ['main']).map((id) => ({ id, name: ROWS[id] ? ROWS[id].name : id, baseUrl: `http://${id}:1`, token: 't' })) const ok = (data) => ({ ok: true, status: 'ok', data }) @@ -86,6 +91,7 @@ function stub(t, { zone, place, revert, owned, monuments, polling } = {}) { t.after(() => { serversDb.getServer = saved.getServer servers.listForPolling = saved.listForPolling + npcsDb.listNpcServers = saved.listNpcServers }) return calls