Merge pull request 'feat(rust): RunicNPC is required for every Place NPCs step (runicnpc stage 9, D310)' (#34) from feat/runicnpc-stage9 into edge
Reviewed-on: #34
This commit is contained in:
@@ -61,6 +61,15 @@ const ZONES = (() => {
|
|||||||
})()
|
})()
|
||||||
|
|
||||||
/** An ISO instant as the value a `datetime-local` input takes, in the browser's clock. */
|
/** 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
|
* 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
|
* 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.'}
|
: 'No wipe schedule set.'}
|
||||||
</div>
|
</div>
|
||||||
<div className="dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>{titlesSummary(s.titles, s.titlePush, categories)}</div>
|
<div className="dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>{titlesSummary(s.titles, s.titlePush, categories)}</div>
|
||||||
|
{runicNpcNote(s.runicNpc) && (
|
||||||
|
<div style={{ fontSize: '0.76rem', marginTop: 2, color: 'var(--warn, #d9c184)' }}>{runicNpcNote(s.runicNpc)}</div>
|
||||||
|
)}
|
||||||
{zoneHelperNote(s.zoneHelper) && (
|
{zoneHelperNote(s.zoneHelper) && (
|
||||||
<div style={{ fontSize: '0.76rem', marginTop: 2, color: 'var(--warn, #d9c184)' }}>{zoneHelperNote(s.zoneHelper)}</div>
|
<div style={{ fontSize: '0.76rem', marginTop: 2, color: 'var(--warn, #d9c184)' }}>{zoneHelperNote(s.zoneHelper)}</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -116,7 +116,8 @@ const PERMANENT = new Set([
|
|||||||
// and a dome asked of a server without ZoneDomes or the domes helper.
|
// and a dome asked of a server without ZoneDomes or the domes helper.
|
||||||
'bad-option',
|
'bad-option',
|
||||||
'dome-unavailable',
|
'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',
|
'unknown-profile',
|
||||||
'runicnpc-missing',
|
'runicnpc-missing',
|
||||||
// runicnpc stage 5 (D272): no zone of the run holds the point; a RunicNPC
|
// 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)
|
const found = await serverFor(where.serverId)
|
||||||
if (!found.ok) return found
|
if (!found.ok) return found
|
||||||
|
|
||||||
if (profile !== null) {
|
// D310: every NPC needs RunicNPC on the server, Rust's own scientists included.
|
||||||
const missing = await profileMissing(found.server, profile)
|
if (kind === 'npc') {
|
||||||
|
const missing = await npcMissing(found.server, profile)
|
||||||
if (missing) return { ok: false, retry: false, error: missing }
|
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
|
* Why NPCs cannot be placed on this server, from what the site knows, or null.
|
||||||
* null (D243). A server without RunicNPC offers only Rust's own until stage 9;
|
* Since runicnpc stage 9 (D310) that is every Place NPCs step on a server
|
||||||
* a profile the site does not push there is not on it. What the server itself
|
* without RunicNPC, or with one older than the bridge needs, Rust's own
|
||||||
* holds is the plugin's to answer (`unknown-profile`).
|
* 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) {
|
async function npcMissing(server, profile) {
|
||||||
const [list, profiles] = await Promise.all([npcsDb.listNpcServers(), npcsDb.listProfiles()])
|
const [list, profiles] = await Promise.all([npcsDb.listNpcServers(), profile === null ? [] : npcsDb.listProfiles()])
|
||||||
const here = list.find((s) => s.id === server.id)
|
const here = list.find((s) => s.id === server.id)
|
||||||
const name = server.name || 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))) {
|
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)`
|
return `the site has no NPC profile "${profile}" for ${name} (Admin → Rust NPC profiles)`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,13 @@ const serversDb = require('../servers/servers.db')
|
|||||||
const db = require('./npcs.db')
|
const db = require('./npcs.db')
|
||||||
const shape = require('./npcProfile')
|
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 {
|
class NpcError extends Error {
|
||||||
constructor(message, status = 400) {
|
constructor(message, status = 400) {
|
||||||
@@ -51,6 +56,24 @@ function npcAbsence(server) {
|
|||||||
return null
|
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 ───────────────────────────────────────────────────
|
// ── What a server is sent ───────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -431,7 +454,9 @@ const PROFILE_PREFIX = 'profile:'
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The site's profiles, first, grouped, for any server that has RunicNPC. A site
|
* The site's profiles, first, grouped, for any server that has RunicNPC. A site
|
||||||
* with no such server offers none (D243: Rust's own only until stage 9).
|
* with no such server offers none, and the picker lists Rust's own as before: since
|
||||||
|
* stage 9 those are refused on save there too, with the reason (D310; the org lead
|
||||||
|
* kept the list rather than change core's option sources, 2026-10-06).
|
||||||
*/
|
*/
|
||||||
async function optionRows() {
|
async function optionRows() {
|
||||||
const [profiles, list] = await Promise.all([db.listProfiles(), db.listNpcServers()])
|
const [profiles, list] = await Promise.all([db.listProfiles(), db.listNpcServers()])
|
||||||
@@ -514,6 +539,7 @@ module.exports = {
|
|||||||
covers,
|
covers,
|
||||||
npcReady,
|
npcReady,
|
||||||
npcAbsence,
|
npcAbsence,
|
||||||
|
serverStatus,
|
||||||
desiredFor,
|
desiredFor,
|
||||||
adopt,
|
adopt,
|
||||||
describe,
|
describe,
|
||||||
|
|||||||
@@ -27,10 +27,11 @@ const log = core.logger('admin')
|
|||||||
|
|
||||||
async function listServers(req, res) {
|
async function listServers(req, res) {
|
||||||
try {
|
try {
|
||||||
const [rows, settings, categories] = await Promise.all([
|
const [rows, settings, categories, npcStatus] = await Promise.all([
|
||||||
servers.listForAdmin(),
|
servers.listForAdmin(),
|
||||||
titlesModel.settingsByServer(),
|
titlesModel.settingsByServer(),
|
||||||
titlesModel.categories(),
|
titlesModel.categories(),
|
||||||
|
npcs.serverStatus(),
|
||||||
])
|
])
|
||||||
|
|
||||||
// Phase 17: each server's chat titles, and what the last push of them found.
|
// 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,
|
...row,
|
||||||
titles: settings.get(row.id) || { mode: 'first', max: 2, rules: [] },
|
titles: settings.get(row.id) || { mode: 'first', max: 2, rules: [] },
|
||||||
titlePush: titleSync.lastPush(row.id),
|
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,
|
titleCategories: categories,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -149,6 +149,16 @@ module.exports = {
|
|||||||
reason: { type: 'string', example: 'this ZoneManager (3.2.0) has no Zone.InitializeZone' },
|
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 },
|
online: { type: 'boolean', example: true },
|
||||||
players: { type: 'integer', example: 42 },
|
players: { type: 'integer', example: 42 },
|
||||||
stale: { type: 'boolean', example: false },
|
stale: { type: 'boolean', example: false },
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
// the first push adopts a server's own profiles and changes nothing on it (D244)
|
// 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 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 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
|
// 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 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
|
// 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 world = require('../eventWorld')
|
||||||
const titles = require('../model/titles/titles')
|
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 = {}) {
|
function body(extra = {}) {
|
||||||
return { ...shape.defaults(), names: ['Warden'], kits: ['warden_rifle'], ...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)
|
assert.strictEqual(store.length, 0)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('a server without RunicNPC, or with API < 4, is recorded absent and never pushed', async (t) => {
|
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.2.0', api: 3 })] })
|
const { syncRows } = stubStore(t, { servers: [npcServer('main', null), npcServer('arena', { loaded: true, version: '0.6.0', api: 5 })] })
|
||||||
const calls = stubWire(t)
|
const calls = stubWire(t)
|
||||||
const out = await npcSync.tick()
|
const out = await npcSync.tick()
|
||||||
assert.deepStrictEqual(out.map((o) => o.outcome), ['absent', 'absent'])
|
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(syncRows.get('main').state, 'absent')
|
||||||
assert.strictEqual(calls.length, 0)
|
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/)
|
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 })
|
const unknown = await place.perform({ runId: 9, idempotencyKey: 'k3', params: params('main', 'profile:nobody'), verify: true })
|
||||||
assert.match(unknown.error, /no NPC profile "nobody"/)
|
assert.match(unknown.error, /no NPC profile "nobody"/)
|
||||||
const own = await place.perform({ runId: 9, idempotencyKey: 'k4', params: params('arena', 'npc.scientist'), verify: true })
|
const own = await place.perform({ runId: 9, idempotencyKey: 'k4', params: params('main', 'npc.scientist'), verify: true })
|
||||||
assert.strictEqual(own.ok, true, 'Rust\'s own still place anywhere')
|
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 ──────────────────────────────────────────
|
// ── triggers, ingest and titles ──────────────────────────────────────────
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const core = require('../core')
|
|||||||
const client = require('../sidecarClient')
|
const client = require('../sidecarClient')
|
||||||
const serversDb = require('../model/servers/servers.db')
|
const serversDb = require('../model/servers/servers.db')
|
||||||
const servers = require('../model/servers/servers.model')
|
const servers = require('../model/servers/servers.model')
|
||||||
|
const npcsDb = require('../model/npcs/npcs.db')
|
||||||
const world = require('../eventWorld')
|
const world = require('../eventWorld')
|
||||||
|
|
||||||
const action = (id) => world.ACTIONS.find((a) => a.id === id)
|
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,
|
worldRevert: client.worldRevert,
|
||||||
worldOwned: client.worldOwned,
|
worldOwned: client.worldOwned,
|
||||||
worldMonuments: client.worldMonuments,
|
worldMonuments: client.worldMonuments,
|
||||||
|
listNpcServers: npcsDb.listNpcServers,
|
||||||
}
|
}
|
||||||
|
|
||||||
serversDb.getServer = async (id) => ROWS[id] || null
|
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 () =>
|
servers.listForPolling = async () =>
|
||||||
(polling || ['main']).map((id) => ({ id, name: ROWS[id] ? ROWS[id].name : id, baseUrl: `http://${id}:1`, token: 't' }))
|
(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 })
|
const ok = (data) => ({ ok: true, status: 'ok', data })
|
||||||
@@ -86,6 +91,7 @@ function stub(t, { zone, place, revert, owned, monuments, polling } = {}) {
|
|||||||
t.after(() => {
|
t.after(() => {
|
||||||
serversDb.getServer = saved.getServer
|
serversDb.getServer = saved.getServer
|
||||||
servers.listForPolling = saved.listForPolling
|
servers.listForPolling = saved.listForPolling
|
||||||
|
npcsDb.listNpcServers = saved.listNpcServers
|
||||||
})
|
})
|
||||||
|
|
||||||
return calls
|
return calls
|
||||||
|
|||||||
Reference in New Issue
Block a user