feat(rust): the world verbs, their budgets and the reconcile watch (phase 13a, protocol 9)

- registerEventActions: rust.zone.open and rust.prefab.place, both
  reversible 'ledger' with revert() and reconcile(), budgetMs 15000 above the
  client's 12 s. A location is a monument (kind + instance, carrying its
  server) or raw coordinates, exactly one (D87, D93); bounds mirrored from the
  plugin so a bad step is refused on the form (D95); zone minutes required and
  held by the game (D96).
- registerEventBudgets: rust.prefabs, rust.npcs and rust.zone.minutes, each
  beside the verb that spends it (D79, D89).
- Option sources rust.options.monuments (live, searchable) and
  rust.options.prefabs (mirrored, answers with every server off), registered in
  the one batch core accepts alongside the lease sources.
- Refs are <serverId>:<id>, since revert and reconcile get no params. The undo
  sends no idempotency key; a lost answer is reverted by key on every server.
  reconcile asks the plugin, and a server that cannot be asked keeps its rows.
- The refresh's bootId/wipeId watch calls ctx.events.reconcile() on a restart
  or a wipe, never on a first sighting or a reconnect (§11.1).
- The permission mirror keeps the plugin's new notLanded grants out of what it
  records as pushed, and the admin page says so (D85).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-09-24 01:26:50 -05:00
parent 36a5cb975a
commit a3bcec9cde
11 changed files with 1101 additions and 13 deletions

View File

@@ -34,6 +34,7 @@
"db",
"engagement",
"eventLeases.js",
"eventWorld.js",
"index.js",
"ingest.js",
"model",

View File

@@ -97,6 +97,7 @@ function ServerState({ row, onSync, busy }) {
const report = row.report || {}
const unresolved = report.unresolved || []
const pending = report.pending || []
const notLanded = report.notLanded || []
return (
<div style={{ padding: '10px 0', borderTop: '1px solid var(--line-soft)' }}>
@@ -141,6 +142,15 @@ function ServerState({ row, onSync, busy }) {
in a group yet.
</Warn>
)}
{notLanded.length > 0 && (
<Warn>
{notLanded.length} {notLanded.length === 1 ? 'grant was' : 'grants were'} sent and not found
in the game's permission store afterwards ({notLanded.slice(0, 5).join(', ')}
{notLanded.length > 5 ? ', …' : ''}). They are not counted as pushed, and the next sync
tries again.
</Warn>
)}
</div>
)
}

View File

@@ -45,6 +45,7 @@ const core = require('./core')
const db = require('./model/servers/servers.db')
const engagement = require('./engagement/emit')
const eventsDb = require('./model/events/events.db')
const eventWorld = require('./eventWorld')
const ingest = require('./ingest')
const permSync = require('./permSync')
const servers = require('./model/servers/servers.model')
@@ -180,6 +181,11 @@ async function refreshOne(server) {
// After the write, so a transition announced is one a page already shows.
engagement.serverObserved(server, connected)
// A restart or a wipe under a running event is the moment core must be told
// to ask what the world still holds (§11.1). Only a CONNECTED plugin's hello
// counts: a board the game left behind says nothing about now.
if (connected) eventWorld.observeServer(server.id, { bootId: frame.bootId, wipeId: frame.wipeId })
} catch (err) {
// A failure here is one server's, and it must not reach `Promise.allSettled`
// as a rejection that hides which one. Log with the id and carry on.

View File

@@ -428,7 +428,13 @@ const OPTION_SOURCES = [
module.exports = {
MAX_LEASE_MS,
MAX_OPTIONS,
LEASES,
OPTION_SOURCES,
splitTarget,
serverFor,
transportError,
pluginError,
perServer,
bounded,
}

616
server/eventWorld.js Normal file
View File

@@ -0,0 +1,616 @@
// ── What an event MAKES on a Rust server (PLAN.md §28, protocol 9) ────────
//
// A lease borrows a value that was already there. These two verbs make
// something that was not — a zone, and crates or NPCs placed in the world — and
// give it back at teardown. Everything that decides what is allowed lives on the
// plugin: the allowlist, the bounds, the monument vocabulary, the registry of
// what each run owns. What is here is the contract's half: declarations core can
// check an author's step against, and the three callables core calls.
//
// ── Four facts from the rig shape all of it (§28.1) ──────────────────────────
//
// * A restart is NOT proof a placed thing is gone. Crates are saved by the
// game and come back with the same net id; NPCs are not. So `reconcile` asks
// the plugin, which looks — `module-uo`'s `reconcileByBootId` trick would
// orphan every crate on every restart.
// * A wipe IS proof everything is gone, and the plugin drops its registry.
// * The bridge has no at-most-once store, so its registry is keyed by core's
// idempotency key: a retried step is answered with the first call's ids.
// * Monument names repeat, so a monument is named by kind and instance (D93).
//
// ── The ref names the server ─────────────────────────────────────────────────
//
// Every resource is `<serverId>:<id>`. `revert` and `reconcile` are handed
// resources and not the step's params, and a run may reach six servers; the ref
// is the only place the server can travel with the thing.
const core = require('./core')
const client = require('./sidecarClient')
const servers = require('./model/servers/servers.model')
const { serverFor, transportError, pluginError, perServer, bounded } = require('./eventLeases')
const log = core.logger('world')
/**
* The budget every verb here declares. It must EXCEED the client's own timeout
* (`TIMEOUT_MS`, 12 s), which in turn exceeds the sidecar's ten-second reply
* timeout — otherwise core gives up first and a `retry: false` this module
* answered is unreachable (MODULE_API §2.4). `world.test.js` asserts the order.
*/
const BUDGET_MS = 15000
// Mirrors of the plugin's bounds (D95, D96). The plugin's are authoritative and
// an operator may set them lower, in which case its refusal is the one that
// lands; these exist so a bad step is a refusal on the AUTHORING FORM and in a
// dry run, rather than a step failing unattended at four in the morning.
const MAX_CRATES = 25
const MAX_NPCS = 20
const MAX_SPREAD = 50
const MAX_OFFSET = 150
const ZONE_MIN_RADIUS = 5
const ZONE_MAX_RADIUS = 150
const ZONE_MAX_MINUTES = 7 * 24 * 60
/** The ledger kind both verbs file under. */
const OWNED_KIND = 'world'
/**
* What the plugin will place, mirroring its allowlist (D88).
*
* **Two copies of a short list, deliberately** — `module-uo`'s `GRANTABLE`
* argument. This one prices a step (`cost()` is synchronous and cannot ask a
* game) and fills the dropdown with every server off; the plugin's is what is
* true when this one is wrong.
*/
const PLACEABLE = [
{ key: 'crate.basic', kind: 'crate', label: 'Basic crate' },
{ key: 'crate.normal', kind: 'crate', label: 'Military crate' },
{ key: 'crate.normal2', kind: 'crate', label: 'Crate' },
{ key: 'crate.elite', kind: 'crate', label: 'Elite crate' },
{ key: 'crate.tools', kind: 'crate', label: 'Tool box' },
{ key: 'crate.hackable', kind: 'crate', label: 'Locked crate (hackable)' },
{ key: 'supply.drop', kind: 'crate', label: 'Supply drop' },
{ key: 'barrel.loot', kind: 'crate', label: 'Loot barrel' },
{ key: 'npc.scientist', kind: 'npc', label: 'Scientist' },
{ key: 'npc.scientist.heavy', kind: 'npc', label: 'Heavy scientist' },
{ key: 'npc.scientist.tethered', kind: 'npc', label: 'Scientist (stays put)' },
{ key: 'npc.bandit.guard', kind: 'npc', label: 'Bandit guard' },
]
/** The plugin's refusals a second attempt would repeat. Anything else is left to core's default. */
const PERMANENT = new Set([
'events-disabled',
'malformed',
'unknown-prefab',
'out-of-range',
'no-monument',
'off-map',
'zonemanager-missing',
])
const BUDGETS = [
{
id: 'rust.prefabs',
label: 'Crates placed',
unit: 'crates',
description: 'Crates, barrels and supply drops an event puts in the world. Counted per server a run reaches.',
},
{
id: 'rust.npcs',
label: 'NPCs placed',
unit: 'NPCs',
description: 'Scientists and guards an event puts in the world — its own dial, so fights can be capped apart from loot (D89).',
},
{
id: 'rust.zone.minutes',
label: 'Zone time',
unit: 'minutes',
description: 'How long the zones an event opens stand, added up. Every zone declares its minutes, and the game erases it when they run out (D96).',
},
]
/** A number param, or undefined when left blank. */
function num(raw) {
if (raw === undefined || raw === null || raw === '') return undefined
const value = Number(raw)
return Number.isFinite(value) ? value : NaN
}
/**
* Where a step puts its thing — a monument plus an offset, or raw coordinates,
* and exactly one of the two (D87) — and which server that is on.
*
* A monument value carries its server (`srv-a/harbor_1#2`, D93), so a monument
* step needs no `server`; one that gives both must agree. Raw coordinates name
* nothing, so they need `server`. Every refusal is `retry: false`: the second
* attempt has the same params.
*/
function location(params) {
const monument = String(params.monument || '').trim()
const x = num(params.x)
const z = num(params.z)
const y = num(params.y)
const byCoords = x !== undefined || z !== undefined
let serverId = String(params.server || '').trim()
if (Boolean(monument) === byCoords) {
return { ok: false, error: 'a location is a monument or x and z, and exactly one of them' }
}
if (monument) {
const slash = monument.indexOf('/')
if (slash <= 0 || slash === monument.length - 1) {
return { ok: false, error: `"${monument}" is not a monument — pick one from the list, as server/monument` }
}
const onServer = monument.slice(0, slash)
if (serverId && serverId !== onServer) {
return { ok: false, error: `that monument is on ${onServer}, not ${serverId}` }
}
serverId = onServer
const offsetX = num(params.offsetX) ?? 0
const offsetZ = num(params.offsetZ) ?? 0
if (Number.isNaN(offsetX) || Number.isNaN(offsetZ)) return { ok: false, error: 'an offset is a number of metres' }
if (Math.hypot(offsetX, offsetZ) > MAX_OFFSET) {
return { ok: false, error: `an offset from a monument is at most ${MAX_OFFSET} m` }
}
return { ok: true, serverId, wire: { monument: monument.slice(slash + 1), offsetX, offsetZ } }
}
if (x === undefined || z === undefined || Number.isNaN(x) || Number.isNaN(z)) {
return { ok: false, error: 'coordinates need both x and z, as numbers' }
}
if (Number.isNaN(y)) return { ok: false, error: 'y is a number of metres, or left blank for the ground' }
if (!serverId) return { ok: false, error: 'coordinates do not say which server — pick one' }
return { ok: true, serverId, wire: { x, z, ...(y === undefined ? {} : { y }) } }
}
/** `<serverId>:<id>` — see the header. */
const refOf = (serverId, id) => `${serverId}:${id}`
/** A ref split back into its server and id, at the FIRST colon (a server id has none). */
function splitRef(ref) {
const text = String(ref || '')
const colon = text.indexOf(':')
return colon <= 0 ? { serverId: null, id: text } : { serverId: text.slice(0, colon), id: text.slice(colon + 1) }
}
/** Resources grouped by the server each one is on. */
function byServer(resources) {
const groups = new Map()
for (const resource of resources || []) {
const serverId = (resource.payload && resource.payload.serverId) || splitRef(resource.ref).serverId
if (!groups.has(serverId)) groups.set(serverId, [])
groups.get(serverId).push(resource)
}
return groups
}
/** A transport failure, classified. Only a missing configuration is one waiting cannot fix. */
function transportFailure(server, result, what) {
const permanent = result.status === 'not-configured' || result.status === 'no-token'
return { ok: false, ...(permanent ? { retry: false } : {}), error: transportError(server, result, what) }
}
/**
* Send one world write and file what came back.
*
* One resource per id — per crate, per NPC, per zone — like `module-uo`'s one
* per serial, so a group half of which players looted reconciles per crate
* rather than all or nothing.
*/
async function place(server, send, body, what) {
const result = await send(server, body)
if (!result.ok) return transportFailure(server, result, what)
const data = result.data || {}
if (data.kind !== 'world.ok') {
return {
ok: false,
...(PERMANENT.has(data.reason) ? { retry: false } : {}),
error: pluginError(data, `${server.name || server.id} refused the ${what}`),
}
}
const placed = Array.isArray(data.placed) ? data.placed : []
return {
ok: true,
resources: placed.map((row) => ({
kind: OWNED_KIND,
ref: refOf(server.id, row.id),
payload: {
serverId: server.id,
what: row.kind,
...(row.prefab ? { prefab: row.prefab } : {}),
...(row.name ? { name: row.name } : {}),
},
})),
...(data.repeat ? { detail: { repeat: true, note: 'answered from the first attempt; nothing new was placed' } } : {}),
}
}
/**
* Give back what a step made.
*
* **No idempotency key goes with it.** `module-uo` shipped exactly that
* mistake: its despawn carried the key the spawn went out under, the shard
* recognised a repeat of the DO and answered with the spawn's reply, and every
* teardown was a no-op that reported success (MODULE_API §2.4). A repeated
* revert is safe here without one — the second finds everything `gone`.
*
* The one case the key IS for is the lost answer: core knows a dispatch went
* out under it and never learned what it made, so `resources` is empty. The
* step's server is not known then either — it was a param, and params do not
* reach `revert` — so every enabled server is asked to give back whatever this
* run placed under that key. A server that cannot be asked leaves the row
* visible rather than guessing.
*/
async function revert({ runId, resources, idempotencyKey }) {
const failed = []
const errors = []
if (!resources || resources.length === 0) {
if (!idempotencyKey) return { ok: true }
for (const server of await servers.listForPolling()) {
const result = await client.worldRevert(server, { runId: String(runId), key: idempotencyKey })
if (!result.ok) errors.push(transportError(server, result, 'revert'))
}
return errors.length ? { ok: false, error: errors.join('; ') } : { ok: true }
}
for (const [serverId, group] of byServer(resources)) {
const found = await serverFor(serverId)
if (!found.ok) {
failed.push(...group.map((r) => r.ref))
errors.push(found.error)
continue
}
const result = await client.worldRevert(found.server, {
runId: String(runId),
ids: group.map((r) => splitRef(r.ref).id),
})
if (!result.ok) {
failed.push(...group.map((r) => r.ref))
errors.push(transportError(found.server, result, 'revert'))
continue
}
// `gone` is not reported: a crate a player looted is the point of having
// placed it. `refused` IS — the plugin found something there that this run
// did not make, and nothing will ever remove it through this path.
const refused = new Set(((result.data && result.data.refused) || []).map(String))
for (const r of group) if (refused.has(splitRef(r.ref).id)) failed.push(r.ref)
}
if (!failed.length) return { ok: true }
if (failed.length === resources.length && errors.length) return { ok: false, error: errors.join('; ') }
return { ok: true, failed }
}
/**
* Which of these does the world still hold?
*
* The plugin LOOKS for each one, by net id or zone id. A server that cannot be
* asked has said nothing, so its resources are all reported in force — "I do
* not know" is never "it is gone" (MODULE_API §1.1).
*/
async function reconcile({ runId, resources }) {
const inForce = []
for (const [serverId, group] of byServer(resources)) {
const found = await serverFor(serverId)
const result = found.ok ? await client.worldOwned(found.server, { runId: String(runId) }) : null
if (!result || !result.ok || !result.data || !Array.isArray(result.data.owned)) {
inForce.push(...group.map((r) => r.ref))
continue
}
const held = new Set(result.data.owned.map((row) => String(row.id)))
for (const r of group) if (held.has(splitRef(r.ref).id)) inForce.push(r.ref)
}
return { ok: true, inForce }
}
/** The location params both verbs share, so two declarations cannot drift apart. */
const LOCATION_PARAMS = [
{
name: 'monument',
type: 'string',
required: false,
example: 'main/powerplant_1',
source: 'rust.options.monuments',
description: 'Where, by monument. Give this OR x and z. Names the server too.',
},
{
name: 'offsetX',
type: 'float',
required: false,
example: 20,
description: `Metres east of the monument's centre (negative is west). Up to ${MAX_OFFSET} m from it in all.`,
},
{
name: 'offsetZ',
type: 'float',
required: false,
example: -15,
description: "Metres north of the monument's centre (negative is south).",
},
{
name: 'server',
type: 'string',
required: false,
example: 'main',
source: 'rust.options.servers',
description: 'Which server, when the location is coordinates. A monument already says.',
},
{ name: 'x', type: 'float', required: false, example: -604, description: 'World x, instead of a monument.' },
{ name: 'z', type: 'float', required: false, example: -342, description: 'World z, instead of a monument.' },
{
name: 'y',
type: 'float',
required: false,
example: 30,
description: 'Height. Left blank, the ground at x and z.',
},
]
const WORLD_COMMON = {
// Something appears where there was nothing. §K puts the default-off line
// between `inspect` and `change`, so an operator switches these on
// deliberately — the right consent for an unattended change to a live world.
risk: 'change',
reversible: 'ledger',
version: 1,
budgetMs: BUDGET_MS,
revert,
reconcile,
}
const ACTIONS = [
{
...WORLD_COMMON,
id: 'rust.zone.open',
label: 'Open a zone',
description:
'A ZoneManager zone at a monument or a point, for a set number of minutes. The game erases it when they run out, even if this site is down; teardown erases it sooner.',
cost: (p) => ({ 'rust.zone.minutes': Math.max(0, Math.round(Number(p.minutes) || 0)) }),
params: [
...LOCATION_PARAMS,
{
name: 'radius',
type: 'float',
required: true,
example: 40,
description: `How far the zone reaches, ${ZONE_MIN_RADIUS} to ${ZONE_MAX_RADIUS} m.`,
},
{
name: 'minutes',
type: 'int',
required: true,
example: 120,
description: `How long it stands, up to ${ZONE_MAX_MINUTES} (seven days). Counted against zone time.`,
},
{ name: 'name', type: 'string', required: false, example: 'Airfield brawl', description: 'What the zone is called.' },
],
async perform({ runId, idempotencyKey, params, verify }) {
const where = location(params)
if (!where.ok) return { ok: false, retry: false, error: where.error }
const radius = Number(params.radius)
if (!Number.isFinite(radius) || radius < ZONE_MIN_RADIUS || radius > ZONE_MAX_RADIUS) {
return { ok: false, retry: false, error: `a zone's radius is ${ZONE_MIN_RADIUS} to ${ZONE_MAX_RADIUS} m, not "${params.radius}"` }
}
const minutes = Number(params.minutes)
if (!Number.isInteger(minutes) || minutes < 1 || minutes > ZONE_MAX_MINUTES) {
return { ok: false, retry: false, error: `a zone stands for 1 to ${ZONE_MAX_MINUTES} minutes, not "${params.minutes}"` }
}
const found = await serverFor(where.serverId)
if (!found.ok) return found
// The dry run stops here, and has checked everything it can without the
// game. It does not ask whether the monument exists: a step authored for
// next wipe's map would fail every dry run until the wipe.
if (verify) return { ok: true }
return place(
found.server,
client.worldZone,
{
runId: String(runId),
key: idempotencyKey,
...where.wire,
radius,
holdMs: minutes * 60000,
...(params.name ? { name: String(params.name).slice(0, 64) } : {}),
},
'zone',
)
},
},
{
...WORLD_COMMON,
id: 'rust.prefab.place',
label: 'Place crates or NPCs',
description:
'Crates, a supply drop or NPCs at a monument or a point, scattered a little. Taken away at teardown; a crate somebody looted is simply gone.',
cost: (p) => {
const known = PLACEABLE.find((x) => x.key === String(p.prefab || '').trim())
const count = Math.max(0, Math.round(Number(p.count) || 0))
return { [known && known.kind === 'npc' ? 'rust.npcs' : 'rust.prefabs']: count }
},
params: [
{
name: 'prefab',
type: 'string',
required: true,
example: 'crate.elite',
source: 'rust.options.prefabs',
description: "What to place. The list is the server's own allowlist: crates and NPCs, never vehicles.",
},
{
name: 'count',
type: 'int',
required: true,
example: 3,
description: `How many — up to ${MAX_CRATES} crates or ${MAX_NPCS} NPCs at a time.`,
},
{
name: 'spread',
type: 'float',
required: false,
example: 10,
description: `How widely to scatter a group, up to ${MAX_SPREAD} m. Left blank, 10.`,
},
...LOCATION_PARAMS,
],
async perform({ runId, idempotencyKey, params, verify }) {
const known = PLACEABLE.find((x) => x.key === String(params.prefab || '').trim())
if (!known) return { ok: false, retry: false, error: `"${params.prefab}" is not something a Rust server places for events` }
const max = known.kind === 'npc' ? MAX_NPCS : MAX_CRATES
const count = Number(params.count)
if (!Number.isInteger(count) || count < 1 || count > max) {
return {
ok: false,
retry: false,
error: `place 1 to ${max} ${known.kind === 'npc' ? 'NPCs' : 'crates'} at a time, and "${params.count}" is not that`,
}
}
const spread = num(params.spread)
if (Number.isNaN(spread) || (spread !== undefined && (spread < 0 || spread > MAX_SPREAD))) {
return { ok: false, retry: false, error: `a scatter is 0 to ${MAX_SPREAD} m, not "${params.spread}"` }
}
const where = location(params)
if (!where.ok) return { ok: false, retry: false, error: where.error }
const found = await serverFor(where.serverId)
if (!found.ok) return found
if (verify) return { ok: true }
return place(
found.server,
client.worldPlace,
{
runId: String(runId),
key: idempotencyKey,
prefab: known.key,
count,
...(spread === undefined ? {} : { spread }),
...where.wire,
},
known.label.toLowerCase(),
)
},
},
]
const OPTION_SOURCES = [
{
// Every server's map, live. A procedural map changes at every wipe, so a
// cached list would offer monuments that are not there any more.
id: 'rust.options.monuments',
label: 'Monuments',
description: "Each server's monuments on its current map. A kind that repeats is numbered, #1 first (D93).",
searchable: true,
async resolve({ q } = {}) {
const term = String(q || '').trim().toLowerCase()
const answers = await perServer((server) => client.worldMonuments(server))
const rows = []
for (const { server, result } of answers) {
for (const m of (result.data && result.data.monuments) || []) {
if (!m || !m.value) continue
const label = `${m.label}${m.of > 1 ? ` #${m.instance}` : ''}${m.grid ? ` · ${m.grid}` : ''}`
const value = `${server.id}/${m.value}`
if (term && !value.toLowerCase().includes(term) && !label.toLowerCase().includes(term)) continue
rows.push({ value, label, group: server.name || server.id })
}
}
return bounded(rows, 'rust.options.monuments')
},
},
{
// From the mirror, so it answers with every server off (the field it fills
// must never be taken away by an outage, MODULE_API §2.4).
id: 'rust.options.prefabs',
label: 'Things to place',
description: 'Crates and NPCs a Rust server places for events.',
async resolve() {
return PLACEABLE.map((p) => ({ value: p.key, label: p.label, group: p.kind === 'npc' ? 'NPCs' : 'Crates' }))
},
},
]
// ── The watch (§11.1) ───────────────────────────────────────────────────────
//
// Core asks the module what the world still holds once, at its own boot, and
// otherwise waits to be told. A game that restarted or wiped under a running
// event is the moment to tell it: the boot id changes on a restart, the wipe
// id on a wipe, and neither changes on a sidecar reconnect — which loses
// nothing and must not provoke a sweep.
const lastSeen = new Map()
/**
* Note a server's identity as the refresh saw it, and ask core to reconcile when
* it moved. The first sighting after this module boots is a baseline, not a
* change: core's own boot reconcile already covered it.
*/
function observeServer(serverId, { bootId, wipeId } = {}) {
if (!serverId || (!bootId && !wipeId)) return false
const previous = lastSeen.get(serverId)
lastSeen.set(serverId, { bootId: bootId || null, wipeId: wipeId || null })
if (!previous) return false
const restarted = Boolean(bootId && previous.bootId && bootId !== previous.bootId)
const wiped = Boolean(wipeId && previous.wipeId && wipeId !== previous.wipeId)
if (!restarted && !wiped) return false
log.info('game changed under the events ledger; asking core to reconcile', {
server: serverId,
...(restarted ? { restarted: { from: previous.bootId, to: bootId } } : {}),
...(wiped ? { wiped: { from: previous.wipeId, to: wipeId } } : {}),
})
try {
Promise.resolve(core.reconcileEvents()).catch((err) => log.warn('reconcile failed', { error: err.message }))
} catch (err) {
log.warn('reconcile failed', { error: err.message })
}
return true
}
/** For tests. */
function resetWatch() {
lastSeen.clear()
}
module.exports = {
BUDGET_MS,
MAX_CRATES,
MAX_NPCS,
ZONE_MAX_MINUTES,
PLACEABLE,
BUDGETS,
ACTIONS,
OPTION_SOURCES,
location,
splitRef,
revert,
reconcile,
observeServer,
resetWatch,
}

View File

@@ -57,6 +57,7 @@ module.exports = function register(ctx, api) {
const { AUDIENCES } = require('./engagement/audiences')
const seeds = require('./engagement/seeds')
const eventLeases = require('./eventLeases')
const eventWorld = require('./eventWorld')
const boot = require('./boot')
/* eslint-enable global-require */
@@ -153,14 +154,21 @@ module.exports = function register(ctx, api) {
// target names the server (D73), which is how one value on one server gets
// exactly one holder without core learning what a server is.
//
// The option sources are the three targets' own (D78). **No budgets** (D79): a
// lease spends none, and a dimension with nothing to spend it is a dial on the
// operator's cap screen that does nothing. They arrive with the actions.
// The option sources are the three targets' own (D78).
api.registerEventLeases(eventLeases.LEASES)
api.registerEventOptionSources(eventLeases.OPTION_SOURCES)
// Everything else this module will register — the event actions and budgets,
// the announce leg and the slash commands — is deliberately absent. Each arrives
// The world verbs (PLAN.md §28, protocol 9): what an event MAKES and gives
// back — a zone, crates, NPCs — and the budgets that price them, each declared
// beside the verb that spends it (D79, D89). A lease spends none of them.
api.registerEventBudgets(eventWorld.BUDGETS)
api.registerEventActions(eventWorld.ACTIONS)
// ONE call for every option source: core takes a batch once, as this module's
// complete statement, and refuses a second.
api.registerEventOptionSources([...eventLeases.OPTION_SOURCES, ...eventWorld.OPTION_SOURCES])
// Everything else this module will register — the rewards and the announce
// leg (13b), the slash commands — is deliberately absent. Each arrives
// with the phase that has something real to put in it. A registration
// with nothing behind it is worse than a missing one: a declared trigger
// nothing emits and a declared slot nothing fills are both surfaces an operator
@@ -175,6 +183,8 @@ module.exports = function register(ctx, api) {
streams: STREAMS.length,
audiences: AUDIENCES.length,
leases: eventLeases.LEASES.length,
optionSources: eventLeases.OPTION_SOURCES.length,
actions: eventWorld.ACTIONS.length,
budgets: eventWorld.BUDGETS.length,
optionSources: eventLeases.OPTION_SOURCES.length + eventWorld.OPTION_SOURCES.length,
})
}

View File

@@ -267,6 +267,11 @@ async function syncOne(server, { authored, sync, state, force }) {
async function applyReport(server, { desired, retire, report, bootId, wipeId }) {
const unresolved = new Set((report.unresolved || []).map(model.normaliseName))
const pending = new Set(report.pending || [])
// Grants the plugin made and then did not find in the store when it read it
// back (D85). Before protocol 9 there was no such read-back, and on Oxide every
// grant of another plugin's permission landed nowhere while this site recorded
// it as pushed (PLAN.md §27.6).
const notLanded = new Set((report.notLanded || []).map((entry) => String(entry).toLowerCase()))
// A grant naming a permission this server has not registered did NOT land —
// `GrantUserPermission` no-ops silently for an unregistered name, which is
@@ -276,7 +281,9 @@ async function applyReport(server, { desired, retire, report, bootId, wipeId })
// The same for a member the store could not place: the membership is waiting
// on their first connection, and it is not in the game yet.
const landed = desired.rows.filter((row) => {
if (row.kind === 'grant' || row.kind === 'group-permission') return !unresolved.has(row.object)
if (row.kind === 'grant' || row.kind === 'group-permission') {
return !unresolved.has(row.object) && !notLanded.has(`${row.subject}:${row.object}`.toLowerCase())
}
if (row.kind === 'member') return !pending.has(`${row.subject}:${row.object}`)
return true
})
@@ -325,6 +332,7 @@ async function applyReport(server, { desired, retire, report, bootId, wipeId })
unresolved: (report.unresolved || []).length,
foreign: (report.foreign || []).length,
pending: (report.pending || []).length,
notLanded: (report.notLanded || []).length,
})
}

View File

@@ -60,7 +60,10 @@ const TIMEOUT_MS = 12000
* walls and the cupboard and names who is authorised there, which is what the
* raid alert is sent to (PLAN.md §25); **8** adds the leases — `GET /lease`,
* `POST /lease` and `POST /lease/release` — which is what lets an event borrow
* a value on a server and give it back (PLAN.md §27). The bump lands here in the same change as the emitters,
* a value on a server and give it back (PLAN.md §27); **9** adds the world
* verbs — `/world/monuments`, `/world/owned`, `/world/zone`, `/world/place` and
* `/world/revert` — what an event places in the world and gives back (PLAN.md
* §28). The bump lands here in the same change as the emitters,
* because the sidecar refuses a client declaring a different version with a
* `409`: a module left on 2 would stop being able to read the server board it
* has been reading all along. A constant that lags the deployment is not a safe
@@ -70,7 +73,7 @@ const TIMEOUT_MS = 12000
* deployment into a `409` naming both numbers instead of a parse failure three
* layers further in.
*/
const PROTOCOL_VERSION = 8
const PROTOCOL_VERSION = 9
/** What a caller gets back. Shaped once so every call site reads the same. */
function reply(ok, status, data = null) {
@@ -331,6 +334,34 @@ const leaseApply = (server, body) =>
const leaseRelease = (server, body) =>
request(server, '/lease/release', { method: 'POST', body, timeoutMs: LEASE_TIMEOUT_MS })
/**
* This wipe's monuments, the plugin's placeable allowlist and its bounds
* (protocol 9). Live, because a map changes at every wipe.
*/
const worldMonuments = (server) => request(server, '/world/monuments')
/**
* What the world still holds of what events made, looked for by net id on the
* game (a restart is not proof a crate is gone, §28.1). One run, or all.
*/
const worldOwned = (server, { runId } = {}) =>
request(server, `/world/owned${runId ? `?runId=${encodeURIComponent(runId)}` : ''}`)
/**
* Open a zone, or place crates or NPCs, for a run. `data.kind` is `world.ok`
* (with `placed`) or `world.error` (with `reason`); a repeated idempotency key
* is answered with the first call's ids and `repeat: true`.
*/
const worldZone = (server, body) => request(server, '/world/zone', { method: 'POST', body })
const worldPlace = (server, body) => request(server, '/world/place', { method: 'POST', body })
/**
* Give back what a run owns: named ids, else everything under a key, else the
* whole run. `data` lists `removed`, `gone` (already not there — a success)
* and `refused` (there, and not this run's to erase).
*/
const worldRevert = (server, body) => request(server, '/world/revert', { method: 'POST', body })
module.exports = {
TIMEOUT_MS,
LEASE_TIMEOUT_MS,
@@ -352,5 +383,10 @@ module.exports = {
leaseList,
leaseApply,
leaseRelease,
worldMonuments,
worldOwned,
worldZone,
worldPlace,
worldRevert,
joinUrl,
}

View File

@@ -142,10 +142,41 @@ test('nothing is registered that has nothing behind it yet', () => {
// NOT register (D62) moved into the assertions below. Phase 12 deleted the
// leases and option sources, and kept budgets here on purpose (D79): a lease
// spends none, and a dimension nothing spends is a dial that does nothing.
// Phase 13a deleted the budgets and actions lines, and registered each budget
// beside the verb that spends it (below). The announce leg is 13b's.
assert.deepStrictEqual(api.record.legs, [])
assert.strictEqual(api.record.hooks.post, undefined)
assert.strictEqual(api.record.eventBudgets, null)
assert.strictEqual(api.record.eventActions, null)
})
test('the world verbs are registered, and every budget has a verb that spends it (phase 13a)', () => {
const { api } = register()
const actions = api.record.eventActions
const budgets = api.record.eventBudgets
assert.deepStrictEqual(actions.map((a) => a.id).sort(), ['rust.prefab.place', 'rust.zone.open'])
assert.deepStrictEqual(budgets.map((b) => b.id).sort(), ['rust.npcs', 'rust.prefabs', 'rust.zone.minutes'])
// D79/D89: no dimension without a verb that spends it. A crate step and an
// NPC step are priced on different dials, and a zone on its minutes.
const spent = new Set()
const place = actions.find((a) => a.id === 'rust.prefab.place')
for (const cost of [
place.cost({ prefab: 'crate.elite', count: 3 }),
place.cost({ prefab: 'npc.scientist', count: 2 }),
actions.find((a) => a.id === 'rust.zone.open').cost({ minutes: 90 }),
]) {
for (const id of Object.keys(cost)) spent.add(id)
}
assert.deepStrictEqual([...spent].sort(), budgets.map((b) => b.id).sort())
for (const a of actions) {
assert.strictEqual(a.reversible, 'ledger')
assert.strictEqual(typeof a.revert, 'function')
assert.strictEqual(typeof a.reconcile, 'function')
// Every param source is one this module registers.
const sources = new Set(api.record.eventOptionSources.map((s) => s.id))
for (const p of a.params) if (p.source) assert.ok(sources.has(p.source), `${a.id}.${p.name} names ${p.source}`)
}
})
test('the leases and their option sources are registered, every source a lease reads (phase 12)', () => {
@@ -158,8 +189,10 @@ test('the leases and their option sources are registered, every source a lease r
['rust.decay.scale', 'rust.group.permission', 'rust.population', 'rust.spawn.scalar'],
)
// D78: exactly the sources the leases' targets name — none without a reader.
// D78: exactly the sources the leases' targets name, plus those the world
// verbs' params name (phase 13a) — none without a reader.
const read = new Set(leases.map((l) => l.target.source))
for (const a of api.record.eventActions) for (const p of a.params) if (p.source) read.add(p.source)
assert.deepStrictEqual([...read].sort(), sources.map((s) => s.id).sort())
for (const l of leases) {

View File

@@ -205,6 +205,43 @@ test('a permission the server could not resolve is not recorded as pushed', asyn
assert.ok(!recorded.includes('7656003'), 'a pending membership is not in the game yet')
})
test('a grant the store did not hold after the plugin read it back is not recorded as pushed (D85)', async () => {
const queries = withCore()
const permSync = require('../permSync')
const desired = {
hash: 'h1',
rows: [
{ kind: 'grant', subject: '7656001', object: 'kits.gold' },
{ kind: 'grant', subject: '7656001', object: 'zonemanager.zone' },
{ kind: 'group-permission', subject: 'vip', object: 'kits.vip' },
],
}
// Protocol 9's read-back. The case it exists for is phase 7's owner bug: a
// grant the plugin made that never reached Oxide's store, which the site had
// been recording as pushed.
const report = {
kind: 'perm.report',
applied: { grants: 1 },
unresolved: [],
pending: [],
notLanded: ['7656001:ZoneManager.Zone', 'vip:kits.vip'],
foreign: [],
}
const sidecar = require('../sidecarClient')
sidecar.permCatalogue = async () => ({ ok: false, status: 'no-token', data: null })
await permSync.applyReport({ id: 'main' }, { desired, retire: [], report, bootId: null, wipeId: null })
const insert = queries.find((q) => q.sql.startsWith('INSERT IGNORE INTO rust_perm_pushed'))
const recorded = insert.params.join(' ')
assert.ok(recorded.includes('kits.gold'), 'a grant that landed is pushed')
assert.ok(!recorded.includes('zonemanager.zone'), 'a grant that did not land is not, whatever its case')
assert.ok(!recorded.includes('kits.vip'), 'nor a group permission that did not land')
})
test('a restart, a wipe and a hand edit each provoke a sync; a quiet server does not', () => {
withCore()
const permSync = require('../permSync')

325
server/test/world.test.js Normal file
View File

@@ -0,0 +1,325 @@
// ── The world verbs (PLAN.md §28, protocol 9) ─────────────────────────────
//
// What an event makes and gives back. Every test here is one of the ways the
// contract's half can look right and be wrong:
//
// the budget must outlive the client, or `retry: false` is unreachable
// a location is a monument OR coordinates, and a monument names its server
// a dry run checks everything it can and sends nothing
// the ref carries the server, because revert and reconcile get no params
// the undo carries NO idempotency key (module-uo's teardown-was-a-no-op bug)
// a lost answer is reverted by key on every server, since the server is unknown
// `gone` is a success and `refused` is not
// "cannot ask" is never "it is gone"
// only a restart or a wipe provokes a reconcile, never a reconnect
const test = require('node:test')
const assert = require('node:assert')
const { fakeCtx } = require('./_fakes')
require('../core')._reset()
require('../core').init(fakeCtx())
const core = require('../core')
const client = require('../sidecarClient')
const serversDb = require('../model/servers/servers.db')
const servers = require('../model/servers/servers.model')
const world = require('../eventWorld')
const action = (id) => world.ACTIONS.find((a) => a.id === id)
const source = (id) => world.OPTION_SOURCES.find((s) => s.id === id)
const ROWS = {
main: { id: 'main', name: 'Main', sidecarBaseUrl: 'http://main:1', sidecarTokenEnc: null, enabled: 1 },
alt: { id: 'alt', name: 'Alt', sidecarBaseUrl: 'http://alt:1', sidecarTokenEnc: null, enabled: 1 },
off: { id: 'off', name: 'Off', sidecarBaseUrl: 'http://off:1', sidecarTokenEnc: null, enabled: 0 },
}
/** Replace the module's collaborators for one test, and put them back after. */
function stub(t, { zone, place, revert, owned, monuments, polling } = {}) {
const calls = { zone: [], place: [], revert: [], owned: [], monuments: [] }
const saved = {
getServer: serversDb.getServer,
listForPolling: servers.listForPolling,
worldZone: client.worldZone,
worldPlace: client.worldPlace,
worldRevert: client.worldRevert,
worldOwned: client.worldOwned,
worldMonuments: client.worldMonuments,
}
serversDb.getServer = async (id) => ROWS[id] || null
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 })
client.worldZone = async (server, body) => {
calls.zone.push({ server: server.id, body })
return zone ? zone(server, body) : ok({ kind: 'world.ok', placed: [{ id: 'rg-7-1-1', kind: 'zone', name: 'Z' }] })
}
client.worldPlace = async (server, body) => {
calls.place.push({ server: server.id, body })
return place
? place(server, body)
: ok({ kind: 'world.ok', placed: [{ id: '101', kind: 'crate', prefab: body.prefab }, { id: '102', kind: 'crate', prefab: body.prefab }] })
}
client.worldRevert = async (server, body) => {
calls.revert.push({ server: server.id, body })
return revert ? revert(server, body) : ok({ kind: 'world.ok', removed: body.ids || [], gone: [], refused: [] })
}
client.worldOwned = async (server, q) => {
calls.owned.push({ server: server.id, ...q })
return owned ? owned(server, q) : { ok: false, status: 'http-503' }
}
client.worldMonuments = async (server) => {
calls.monuments.push(server.id)
return monuments ? monuments(server) : { ok: false, status: 'http-503' }
}
t.after(() => Object.assign(client, {
worldZone: saved.worldZone,
worldPlace: saved.worldPlace,
worldRevert: saved.worldRevert,
worldOwned: saved.worldOwned,
worldMonuments: saved.worldMonuments,
}))
t.after(() => {
serversDb.getServer = saved.getServer
servers.listForPolling = saved.listForPolling
})
return calls
}
test('every world verb outlives the client, which outlives the sidecar', () => {
// `sidecar RPC (10s) < TIMEOUT_MS < budgetMs` — or the dispatcher gives up
// first, classifies retry, and every `retry: false` below is dead code.
assert.ok(10000 < client.TIMEOUT_MS)
for (const a of world.ACTIONS) assert.ok(client.TIMEOUT_MS < a.budgetMs, `${a.id} budgetMs`)
})
test('the mirrored bounds are the plugin\'s own (D95, D96)', () => {
assert.strictEqual(world.MAX_CRATES, 25)
assert.strictEqual(world.MAX_NPCS, 20)
assert.strictEqual(world.ZONE_MAX_MINUTES, 7 * 24 * 60)
// Crates and NPCs only, never a vehicle (D88).
assert.deepStrictEqual([...new Set(world.PLACEABLE.map((p) => p.kind))].sort(), ['crate', 'npc'])
})
test('a location is a monument or coordinates, exactly one, and a monument names its server', () => {
assert.strictEqual(world.location({}).ok, false)
assert.strictEqual(world.location({ monument: 'main/airfield_1', x: 1, z: 2 }).ok, false)
const byMonument = world.location({ monument: 'main/harbor_1#2', offsetX: 10 })
assert.deepStrictEqual(byMonument, { ok: true, serverId: 'main', wire: { monument: 'harbor_1#2', offsetX: 10, offsetZ: 0 } })
// Two ways to name a server must agree.
assert.match(world.location({ monument: 'main/harbor_1', server: 'alt' }).error, /on main, not alt/)
// An offset past the bound is refused on the form, not mid-run.
assert.match(world.location({ monument: 'main/harbor_1', offsetX: 120, offsetZ: 120 }).error, /at most 150/)
// Coordinates name nothing, so they need the server.
assert.match(world.location({ x: 1, z: 2 }).error, /which server/)
assert.deepStrictEqual(world.location({ x: 1, z: 2, server: 'alt' }), { ok: true, serverId: 'alt', wire: { x: 1, z: 2 } })
assert.match(world.location({ x: 1, server: 'alt' }).error, /both x and z/)
})
test('a dry run checks everything it can and sends nothing', async (t) => {
const calls = stub(t)
const zone = await action('rust.zone.open').perform({
runId: 7, idempotencyKey: 'k1', verify: true, params: { monument: 'main/airfield_1', radius: 40, minutes: 60 },
})
const place = await action('rust.prefab.place').perform({
runId: 7, idempotencyKey: 'k2', verify: true, params: { monument: 'main/airfield_1', prefab: 'crate.elite', count: 3 },
})
assert.deepStrictEqual([zone, place], [{ ok: true }, { ok: true }])
assert.deepStrictEqual([calls.zone.length, calls.place.length], [0, 0])
})
test('every authoring mistake is refused for good, before anything is sent', async (t) => {
const calls = stub(t)
const place = action('rust.prefab.place')
const zone = action('rust.zone.open')
const run = (a, params) => a.perform({ runId: 7, idempotencyKey: 'k', params })
for (const result of [
await run(place, { monument: 'main/a', prefab: 'minicopter', count: 1 }),
await run(place, { monument: 'main/a', prefab: 'crate.elite', count: 26 }),
await run(place, { monument: 'main/a', prefab: 'npc.scientist', count: 21 }),
await run(place, { monument: 'main/a', prefab: 'crate.elite', count: 1, spread: 51 }),
await run(zone, { monument: 'main/a', radius: 4, minutes: 10 }),
await run(zone, { monument: 'main/a', radius: 40 }), // D96: minutes are required
await run(zone, { monument: 'main/a', radius: 40, minutes: 7 * 24 * 60 + 1 }),
await run(zone, { monument: 'nowhere/a', radius: 40, minutes: 10 }),
await run(zone, { monument: 'off/a', radius: 40, minutes: 10 }),
]) {
assert.strictEqual(result.ok, false)
assert.strictEqual(result.retry, false, result.error)
}
assert.deepStrictEqual([calls.zone.length, calls.place.length], [0, 0])
})
test('a zone crosses with its key, its duration, and the monument without the server', async (t) => {
const calls = stub(t)
const result = await action('rust.zone.open').perform({
runId: 7, idempotencyKey: 'k1', params: { monument: 'main/airfield_1', offsetZ: -20, radius: 40, minutes: 90, name: 'Brawl' },
})
assert.deepStrictEqual(calls.zone[0], {
server: 'main',
body: { runId: '7', key: 'k1', monument: 'airfield_1', offsetX: 0, offsetZ: -20, radius: 40, holdMs: 5400000, name: 'Brawl' },
})
// The ref carries the server: revert and reconcile are handed no params.
assert.deepStrictEqual(result.resources, [
{ kind: 'world', ref: 'main:rg-7-1-1', payload: { serverId: 'main', what: 'zone', name: 'Z' } },
])
})
test('one resource per thing placed, and a repeated key is said to be one', async (t) => {
stub(t, {
place: async () => ({
ok: true,
data: { kind: 'world.ok', repeat: true, placed: [{ id: '101', kind: 'npc', prefab: 'npc.scientist' }] },
}),
})
const result = await action('rust.prefab.place').perform({
runId: 7, idempotencyKey: 'k', params: { x: -604, z: -342, server: 'main', prefab: 'npc.scientist', count: 1 },
})
assert.strictEqual(result.ok, true)
assert.deepStrictEqual(result.resources.map((r) => r.ref), ['main:101'])
assert.strictEqual(result.detail.repeat, true)
})
test('the switch being off is a refusal with the switch named, for good (D94)', async (t) => {
stub(t, {
place: async () => ({ ok: true, data: { kind: 'world.error', reason: 'events-disabled', message: 'set EventsEnabled' } }),
})
const result = await action('rust.prefab.place').perform({
runId: 7, idempotencyKey: 'k', params: { monument: 'main/a', prefab: 'crate.elite', count: 1 },
})
assert.deepStrictEqual(result, { ok: false, retry: false, error: 'set EventsEnabled' })
})
test('a game that is down or slow is left to core to retry', async (t) => {
stub(t, { place: async () => ({ ok: false, status: 'http-503' }) })
const result = await action('rust.prefab.place').perform({
runId: 7, idempotencyKey: 'k', params: { monument: 'main/a', prefab: 'crate.elite', count: 1 },
})
assert.strictEqual(result.ok, false)
assert.strictEqual(result.retry, undefined)
assert.match(result.error, /Main has no game connected/)
})
test('revert sends ids per server and NO idempotency key', async (t) => {
const calls = stub(t)
const result = await world.revert({
runId: 7,
idempotencyKey: 'k-of-the-do',
resources: [
{ kind: 'world', ref: 'main:101', payload: { serverId: 'main' } },
{ kind: 'world', ref: 'alt:rg-7-1-1', payload: { serverId: 'alt' } },
{ kind: 'world', ref: 'main:102' },
],
})
assert.deepStrictEqual(result, { ok: true })
assert.deepStrictEqual(calls.revert, [
{ server: 'main', body: { runId: '7', ids: ['101', '102'] } },
{ server: 'alt', body: { runId: '7', ids: ['rg-7-1-1'] } },
])
})
test('gone is a success, and refused is a failure named by ref', async (t) => {
stub(t, { revert: async () => ({ ok: true, data: { kind: 'world.ok', removed: ['101'], gone: ['102'], refused: ['103'] } }) })
const result = await world.revert({
runId: 7,
resources: ['101', '102', '103'].map((id) => ({ kind: 'world', ref: `main:${id}` })),
})
assert.deepStrictEqual(result, { ok: true, failed: ['main:103'] })
})
test('a lost answer is reverted by its key on every server, and an unreachable one keeps the row', async (t) => {
const calls = stub(t, {
polling: ['main', 'alt'],
revert: async (server) => (server.id === 'alt' ? { ok: false, status: 'http-503' } : { ok: true, data: { kind: 'world.ok' } }),
})
const result = await world.revert({ runId: 7, resources: [], idempotencyKey: 'k-lost' })
assert.deepStrictEqual(calls.revert.map((c) => c.body), [
{ runId: '7', key: 'k-lost' },
{ runId: '7', key: 'k-lost' },
])
assert.strictEqual(result.ok, false)
assert.match(result.error, /Alt has no game connected/)
})
test('reconcile asks each server what it holds, and "cannot ask" is not "gone"', async (t) => {
stub(t, {
owned: async (server) =>
server.id === 'main'
? { ok: true, data: { kind: 'world.owned', owned: [{ id: '101' }] } }
: { ok: false, status: 'http-503' },
})
const result = await world.reconcile({
runId: 7,
resources: [
{ kind: 'world', ref: 'main:101' },
{ kind: 'world', ref: 'main:102' }, // looted, or an NPC a restart took
{ kind: 'world', ref: 'alt:rg-7-1-1', payload: { serverId: 'alt' } },
],
})
assert.deepStrictEqual(result, { ok: true, inForce: ['main:101', 'alt:rg-7-1-1'] })
})
test('the monument source lists each server\'s map as whole values, numbered where a kind repeats', async (t) => {
stub(t, {
polling: ['main', 'alt'],
monuments: async (server) =>
server.id === 'alt'
? { ok: false, status: 'http-503' }
: {
ok: true,
data: {
monuments: [
{ value: 'harbor_1#1', label: 'Harbor', instance: 1, of: 2, grid: 'M7' },
{ value: 'harbor_1#2', label: 'Harbor', instance: 2, of: 2, grid: 'C12' },
{ value: 'powerplant_1', label: 'Power Plant', instance: 1, of: 1, grid: 'F14' },
],
},
},
})
const rows = await source('rust.options.monuments').resolve()
assert.deepStrictEqual(rows, [
{ value: 'main/harbor_1#1', label: 'Harbor #1 · M7', group: 'Main' },
{ value: 'main/harbor_1#2', label: 'Harbor #2 · C12', group: 'Main' },
{ value: 'main/powerplant_1', label: 'Power Plant · F14', group: 'Main' },
])
const narrowed = await source('rust.options.monuments').resolve({ q: 'power' })
assert.deepStrictEqual(narrowed.map((r) => r.value), ['main/powerplant_1'])
})
test('the prefab source answers with every server off', async (t) => {
const calls = stub(t)
const rows = await source('rust.options.prefabs').resolve()
assert.strictEqual(rows.length, world.PLACEABLE.length)
assert.deepStrictEqual(calls.monuments, [])
})
test('only a restart or a wipe asks core to reconcile — never a first sighting or a reconnect', (t) => {
world.resetWatch()
let asked = 0
const saved = core.reconcileEvents
core.reconcileEvents = () => {
asked += 1
return Promise.resolve({})
}
t.after(() => {
core.reconcileEvents = saved
world.resetWatch()
})
assert.strictEqual(world.observeServer('main', { bootId: 'b1', wipeId: 'w1' }), false) // baseline
assert.strictEqual(world.observeServer('main', { bootId: 'b1', wipeId: 'w1' }), false) // a reconnect
assert.strictEqual(world.observeServer('main', { bootId: 'b2', wipeId: 'w1' }), true) // a restart
assert.strictEqual(world.observeServer('main', { bootId: 'b3', wipeId: 'w2' }), true) // a wipe
assert.strictEqual(world.observeServer('alt', { bootId: 'x', wipeId: 'y' }), false) // another server's baseline
assert.strictEqual(asked, 2)
})