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", "db",
"engagement", "engagement",
"eventLeases.js", "eventLeases.js",
"eventWorld.js",
"index.js", "index.js",
"ingest.js", "ingest.js",
"model", "model",

View File

@@ -97,6 +97,7 @@ function ServerState({ row, onSync, busy }) {
const report = row.report || {} const report = row.report || {}
const unresolved = report.unresolved || [] const unresolved = report.unresolved || []
const pending = report.pending || [] const pending = report.pending || []
const notLanded = report.notLanded || []
return ( return (
<div style={{ padding: '10px 0', borderTop: '1px solid var(--line-soft)' }}> <div style={{ padding: '10px 0', borderTop: '1px solid var(--line-soft)' }}>
@@ -141,6 +142,15 @@ function ServerState({ row, onSync, busy }) {
in a group yet. in a group yet.
</Warn> </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> </div>
) )
} }

View File

@@ -45,6 +45,7 @@ const core = require('./core')
const db = require('./model/servers/servers.db') const db = require('./model/servers/servers.db')
const engagement = require('./engagement/emit') const engagement = require('./engagement/emit')
const eventsDb = require('./model/events/events.db') const eventsDb = require('./model/events/events.db')
const eventWorld = require('./eventWorld')
const ingest = require('./ingest') const ingest = require('./ingest')
const permSync = require('./permSync') const permSync = require('./permSync')
const servers = require('./model/servers/servers.model') 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. // After the write, so a transition announced is one a page already shows.
engagement.serverObserved(server, connected) 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) { } catch (err) {
// A failure here is one server's, and it must not reach `Promise.allSettled` // 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. // 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 = { module.exports = {
MAX_LEASE_MS, MAX_LEASE_MS,
MAX_OPTIONS,
LEASES, LEASES,
OPTION_SOURCES, OPTION_SOURCES,
splitTarget, 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 { AUDIENCES } = require('./engagement/audiences')
const seeds = require('./engagement/seeds') const seeds = require('./engagement/seeds')
const eventLeases = require('./eventLeases') const eventLeases = require('./eventLeases')
const eventWorld = require('./eventWorld')
const boot = require('./boot') const boot = require('./boot')
/* eslint-enable global-require */ /* 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 // target names the server (D73), which is how one value on one server gets
// exactly one holder without core learning what a server is. // exactly one holder without core learning what a server is.
// //
// The option sources are the three targets' own (D78). **No budgets** (D79): a // The option sources are the three targets' own (D78).
// 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.
api.registerEventLeases(eventLeases.LEASES) api.registerEventLeases(eventLeases.LEASES)
api.registerEventOptionSources(eventLeases.OPTION_SOURCES)
// Everything else this module will register — the event actions and budgets, // The world verbs (PLAN.md §28, protocol 9): what an event MAKES and gives
// the announce leg and the slash commands — is deliberately absent. Each arrives // 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 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 // 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 // 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, streams: STREAMS.length,
audiences: AUDIENCES.length, audiences: AUDIENCES.length,
leases: eventLeases.LEASES.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 }) { async function applyReport(server, { desired, retire, report, bootId, wipeId }) {
const unresolved = new Set((report.unresolved || []).map(model.normaliseName)) const unresolved = new Set((report.unresolved || []).map(model.normaliseName))
const pending = new Set(report.pending || []) 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 — // A grant naming a permission this server has not registered did NOT land —
// `GrantUserPermission` no-ops silently for an unregistered name, which is // `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 // 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. // on their first connection, and it is not in the game yet.
const landed = desired.rows.filter((row) => { 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}`) if (row.kind === 'member') return !pending.has(`${row.subject}:${row.object}`)
return true return true
}) })
@@ -325,6 +332,7 @@ async function applyReport(server, { desired, retire, report, bootId, wipeId })
unresolved: (report.unresolved || []).length, unresolved: (report.unresolved || []).length,
foreign: (report.foreign || []).length, foreign: (report.foreign || []).length,
pending: (report.pending || []).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 * 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`, * 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 * `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 * 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 * `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 * 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 * deployment into a `409` naming both numbers instead of a parse failure three
* layers further in. * 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. */ /** What a caller gets back. Shaped once so every call site reads the same. */
function reply(ok, status, data = null) { function reply(ok, status, data = null) {
@@ -331,6 +334,34 @@ const leaseApply = (server, body) =>
const leaseRelease = (server, body) => const leaseRelease = (server, body) =>
request(server, '/lease/release', { method: 'POST', body, timeoutMs: LEASE_TIMEOUT_MS }) 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 = { module.exports = {
TIMEOUT_MS, TIMEOUT_MS,
LEASE_TIMEOUT_MS, LEASE_TIMEOUT_MS,
@@ -352,5 +383,10 @@ module.exports = {
leaseList, leaseList,
leaseApply, leaseApply,
leaseRelease, leaseRelease,
worldMonuments,
worldOwned,
worldZone,
worldPlace,
worldRevert,
joinUrl, 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 // 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 // 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. // 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.deepStrictEqual(api.record.legs, [])
assert.strictEqual(api.record.hooks.post, undefined) 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)', () => { 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'], ['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)) 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()) assert.deepStrictEqual([...read].sort(), sources.map((s) => s.id).sort())
for (const l of leases) { 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') 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', () => { test('a restart, a wipe and a hand edit each provoke a sync; a quiet server does not', () => {
withCore() withCore()
const permSync = require('../permSync') 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)
})