From a3bcec9cde95cbf0957782607960116f979eafd8 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Thu, 24 Sep 2026 01:26:50 -0500 Subject: [PATCH 1/3] feat(rust): the world verbs, their budgets and the reconcile watch (phase 13a, protocol 9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 :, 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 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- ci/bundle.json | 1 + client/src/routes/admin/Permissions.jsx | 10 + server/boot.js | 6 + server/eventLeases.js | 6 + server/eventWorld.js | 616 ++++++++++++++++++++++++ server/index.js | 24 +- server/permSync.js | 10 +- server/sidecarClient.js | 40 +- server/test/entry.test.js | 39 +- server/test/permissions.test.js | 37 ++ server/test/world.test.js | 325 +++++++++++++ 11 files changed, 1101 insertions(+), 13 deletions(-) create mode 100644 server/eventWorld.js create mode 100644 server/test/world.test.js diff --git a/ci/bundle.json b/ci/bundle.json index 5e8b335..c5d0c57 100644 --- a/ci/bundle.json +++ b/ci/bundle.json @@ -34,6 +34,7 @@ "db", "engagement", "eventLeases.js", + "eventWorld.js", "index.js", "ingest.js", "model", diff --git a/client/src/routes/admin/Permissions.jsx b/client/src/routes/admin/Permissions.jsx index 27661f0..d57d130 100644 --- a/client/src/routes/admin/Permissions.jsx +++ b/client/src/routes/admin/Permissions.jsx @@ -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 (
@@ -141,6 +142,15 @@ function ServerState({ row, onSync, busy }) { in a group yet. )} + + {notLanded.length > 0 && ( + + {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. + + )}
) } diff --git a/server/boot.js b/server/boot.js index e711dd5..af4770c 100644 --- a/server/boot.js +++ b/server/boot.js @@ -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. diff --git a/server/eventLeases.js b/server/eventLeases.js index 7d18d5f..09189f9 100644 --- a/server/eventLeases.js +++ b/server/eventLeases.js @@ -428,7 +428,13 @@ const OPTION_SOURCES = [ module.exports = { MAX_LEASE_MS, + MAX_OPTIONS, LEASES, OPTION_SOURCES, splitTarget, + serverFor, + transportError, + pluginError, + perServer, + bounded, } diff --git a/server/eventWorld.js b/server/eventWorld.js new file mode 100644 index 0000000..7f38226 --- /dev/null +++ b/server/eventWorld.js @@ -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 `:`. `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 }) } } +} + +/** `:` — 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, +} diff --git a/server/index.js b/server/index.js index 2c0868b..959459d 100644 --- a/server/index.js +++ b/server/index.js @@ -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, }) } diff --git a/server/permSync.js b/server/permSync.js index 00881e1..9f2bdb9 100644 --- a/server/permSync.js +++ b/server/permSync.js @@ -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, }) } diff --git a/server/sidecarClient.js b/server/sidecarClient.js index 56e763f..715d574 100644 --- a/server/sidecarClient.js +++ b/server/sidecarClient.js @@ -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, } diff --git a/server/test/entry.test.js b/server/test/entry.test.js index 0c2b576..537c5ec 100644 --- a/server/test/entry.test.js +++ b/server/test/entry.test.js @@ -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) { diff --git a/server/test/permissions.test.js b/server/test/permissions.test.js index bf24bb8..9a06078 100644 --- a/server/test/permissions.test.js +++ b/server/test/permissions.test.js @@ -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') diff --git a/server/test/world.test.js b/server/test/world.test.js new file mode 100644 index 0000000..6da7f04 --- /dev/null +++ b/server/test/world.test.js @@ -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) +}) -- 2.49.1 From fab31f23e864d83a77b492675bc7915cc9b5a4c6 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Thu, 24 Sep 2026 02:09:49 -0500 Subject: [PATCH 2/3] =?UTF-8?q?refactor(rust):=20one=20placing=20verb=20pe?= =?UTF-8?q?r=20kind=20=E2=80=94=20rust.crate.place=20and=20rust.npc.place?= =?UTF-8?q?=20(D97)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core learns which caps an action accepts by pricing its declared examples once, and drops a dimension priced at zero. A single rust.prefab.place whose cost moved between rust.prefabs and rust.npcs by its prefab param could only ever show the crates cap, so D89's separate dial for fights was unreachable. Two verbs, each pricing exactly one dimension, with the prefab source split to match (rust.options.crates / rust.options.npcs). The switchboard can now allow crates and leave NPCs off. The plugin's world.place is unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- server/eventWorld.js | 204 ++++++++++++++++++++++---------------- server/test/entry.test.js | 20 ++-- server/test/world.test.js | 30 +++--- 3 files changed, 144 insertions(+), 110 deletions(-) diff --git a/server/eventWorld.js b/server/eventWorld.js index 7f38226..16d97f1 100644 --- a/server/eventWorld.js +++ b/server/eventWorld.js @@ -1,7 +1,7 @@ // ── 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 +// A lease borrows a value that was already there. These three verbs make +// something that was not — a zone, crates, NPCs — 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 @@ -372,6 +372,91 @@ const WORLD_COMMON = { reconcile, } +/** + * One placing verb per KIND (D97), not one verb for both. + * + * Core learns which caps an action accepts by pricing that action's declared + * EXAMPLES once, and drops a dimension priced at zero. So a single verb whose + * cost moved between `rust.prefabs` and `rust.npcs` by its `prefab` param could + * only ever show the operator the crates cap, and D89's separate dial for fights + * would be unreachable. Two verbs, each pricing exactly one dimension, is also + * what lets the switchboard allow crates and leave NPCs off. + */ +function placeVerb({ id, kind, budget, max, label, description, source, example }) { + const noun = kind === 'npc' ? 'NPCs' : 'crates' + return { + ...WORLD_COMMON, + id, + label, + description, + cost: (p) => ({ [budget]: Math.max(0, Math.round(Number(p.count) || 0)) }), + params: [ + { + name: 'prefab', + type: 'string', + required: true, + example, + source, + description: `Which of the server's own ${noun} to place.`, + }, + { + name: 'count', + type: 'int', + required: true, + example: 3, + description: `How many — 1 to ${max} 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 || known.kind !== kind) { + return { ok: false, retry: false, error: `"${params.prefab}" is not one of the ${noun} a Rust server places for events` } + } + + const count = Number(params.count) + if (!Number.isInteger(count) || count < 1 || count > max) { + return { ok: false, retry: false, error: `place 1 to ${max} ${noun} 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 ACTIONS = [ { ...WORLD_COMMON, @@ -436,85 +521,28 @@ const ACTIONS = [ ) }, }, - { - ...WORLD_COMMON, - id: 'rust.prefab.place', - label: 'Place crates or NPCs', + placeVerb({ + id: 'rust.crate.place', + kind: 'crate', + budget: 'rust.prefabs', + max: MAX_CRATES, + label: 'Place crates', 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(), - ) - }, - }, + 'Crates, barrels or a supply drop at a monument or a point, scattered a little. Taken away at teardown; a crate somebody looted is simply gone.', + source: 'rust.options.crates', + example: 'crate.elite', + }), + placeVerb({ + id: 'rust.npc.place', + kind: 'npc', + budget: 'rust.npcs', + max: MAX_NPCS, + label: 'Place NPCs', + description: + 'Scientists or guards at a monument or a point. Taken away at teardown. The game does not save NPCs, so a restart ends them; the ledger then says so.', + source: 'rust.options.npcs', + example: 'npc.scientist', + }), ] const OPTION_SOURCES = [ @@ -541,16 +569,16 @@ const OPTION_SOURCES = [ 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.', + // From the mirror, so both answer with every server off (the field they fill + // must never be taken away by an outage, MODULE_API §2.4). One per verb (D97). + ...['crate', 'npc'].map((kind) => ({ + id: kind === 'npc' ? 'rust.options.npcs' : 'rust.options.crates', + label: kind === 'npc' ? 'NPCs' : 'Crates', + description: `The ${kind === 'npc' ? 'NPCs' : 'crates'} a Rust server places for events.`, async resolve() { - return PLACEABLE.map((p) => ({ value: p.key, label: p.label, group: p.kind === 'npc' ? 'NPCs' : 'Crates' })) + return PLACEABLE.filter((p) => p.kind === kind).map((p) => ({ value: p.key, label: p.label })) }, - }, + })), ] // ── The watch (§11.1) ─────────────────────────────────────────────────────── diff --git a/server/test/entry.test.js b/server/test/entry.test.js index 537c5ec..2f7f0fe 100644 --- a/server/test/entry.test.js +++ b/server/test/entry.test.js @@ -153,19 +153,19 @@ test('the world verbs are registered, and every budget has a verb that spends it 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(actions.map((a) => a.id).sort(), ['rust.crate.place', 'rust.npc.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. + // D79/D89/D97: every dimension has a verb that spends it, and each verb spends + // exactly ONE — priced from its own declared examples, which is how core + // decides which cap boxes the switchboard shows. A verb whose dimension moved + // with its params would hide the other dial from every operator. 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) + for (const a of actions) { + const example = Object.fromEntries(a.params.map((p) => [p.name, p.example])) + const dims = Object.keys(a.cost(example)).filter((id) => a.cost(example)[id] > 0) + assert.strictEqual(dims.length, 1, `${a.id} prices ${dims.join(', ')}`) + spent.add(dims[0]) } assert.deepStrictEqual([...spent].sort(), budgets.map((b) => b.id).sort()) diff --git a/server/test/world.test.js b/server/test/world.test.js index 6da7f04..9b16339 100644 --- a/server/test/world.test.js +++ b/server/test/world.test.js @@ -129,7 +129,7 @@ test('a dry run checks everything it can and sends nothing', async (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({ + const place = await action('rust.crate.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 }]) @@ -138,15 +138,18 @@ test('a dry run checks everything it can and sends nothing', async (t) => { 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 crates = action('rust.crate.place') + const npcs = action('rust.npc.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(crates, { monument: 'main/a', prefab: 'minicopter', count: 1 }), + await run(crates, { monument: 'main/a', prefab: 'npc.scientist', count: 1 }), // D97: the other verb's + await run(npcs, { monument: 'main/a', prefab: 'crate.elite', count: 1 }), + await run(crates, { monument: 'main/a', prefab: 'crate.elite', count: 26 }), + await run(npcs, { monument: 'main/a', prefab: 'npc.scientist', count: 21 }), + await run(crates, { 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 }), @@ -182,7 +185,7 @@ test('one resource per thing placed, and a repeated key is said to be one', asyn data: { kind: 'world.ok', repeat: true, placed: [{ id: '101', kind: 'npc', prefab: 'npc.scientist' }] }, }), }) - const result = await action('rust.prefab.place').perform({ + const result = await action('rust.npc.place').perform({ runId: 7, idempotencyKey: 'k', params: { x: -604, z: -342, server: 'main', prefab: 'npc.scientist', count: 1 }, }) assert.strictEqual(result.ok, true) @@ -194,7 +197,7 @@ test('the switch being off is a refusal with the switch named, for good (D94)', stub(t, { place: async () => ({ ok: true, data: { kind: 'world.error', reason: 'events-disabled', message: 'set EventsEnabled' } }), }) - const result = await action('rust.prefab.place').perform({ + const result = await action('rust.crate.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' }) @@ -202,7 +205,7 @@ test('the switch being off is a refusal with the switch named, for good (D94)', 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({ + const result = await action('rust.crate.place').perform({ runId: 7, idempotencyKey: 'k', params: { monument: 'main/a', prefab: 'crate.elite', count: 1 }, }) assert.strictEqual(result.ok, false) @@ -296,10 +299,13 @@ test('the monument source lists each server\'s map as whole values, numbered whe assert.deepStrictEqual(narrowed.map((r) => r.value), ['main/powerplant_1']) }) -test('the prefab source answers with every server off', async (t) => { +test('the crate and NPC sources split the allowlist, and answer with every server off (D97)', async (t) => { const calls = stub(t) - const rows = await source('rust.options.prefabs').resolve() - assert.strictEqual(rows.length, world.PLACEABLE.length) + const crates = await source('rust.options.crates').resolve() + const npcs = await source('rust.options.npcs').resolve() + assert.strictEqual(crates.length + npcs.length, world.PLACEABLE.length) + assert.ok(crates.every((r) => !r.value.startsWith('npc.'))) + assert.ok(npcs.every((r) => r.value.startsWith('npc.'))) assert.deepStrictEqual(calls.monuments, []) }) -- 2.49.1 From ba636c8939524728cfd050fcef10d63420475202 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Thu, 24 Sep 2026 02:16:03 -0500 Subject: [PATCH 3/3] fix(rust): hold the reconcile until the world is loaded, and never read a refusal as a revert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects the phase 13a walk found by restarting the rig mid-run: - The watch asked core to reconcile the moment a new boot id appeared, which is before the game has loaded its save — every crate looked gone and was orphaned. It now waits for the plugin's hello to say `worldReady`; an older plugin that never says is taken as ready. - revert() read any 200 as success. On this bridge a refusal is a 200 carrying world.error (`not-ready` while loading), so every row would have been marked reverted with the game still holding every crate. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- server/boot.js | 4 +++- server/eventWorld.js | 22 ++++++++++++++++++++-- server/test/world.test.js | 36 ++++++++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 3 deletions(-) diff --git a/server/boot.js b/server/boot.js index af4770c..193fc25 100644 --- a/server/boot.js +++ b/server/boot.js @@ -185,7 +185,9 @@ async function refreshOne(server) { // 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 }) + if (connected) { + eventWorld.observeServer(server.id, { bootId: frame.bootId, wipeId: frame.wipeId, worldReady: frame.worldReady }) + } } 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. diff --git a/server/eventWorld.js b/server/eventWorld.js index 16d97f1..e869074 100644 --- a/server/eventWorld.js +++ b/server/eventWorld.js @@ -256,6 +256,7 @@ async function revert({ runId, resources, idempotencyKey }) { 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')) + else if (!result.data || result.data.kind !== 'world.ok') errors.push(pluginError(result.data, `${server.name || server.id} refused the revert`)) } return errors.length ? { ok: false, error: errors.join('; ') } : { ok: true } } @@ -279,6 +280,16 @@ async function revert({ runId, resources, idempotencyKey }) { continue } + // **A 200 is not a success on this bridge** — a refusal comes back as one, + // carrying `world.error` (`not-ready` while the world is still loading). + // Read as success it would mark every row reverted while the game still + // held every crate. + if (!result.data || result.data.kind !== 'world.ok') { + failed.push(...group.map((r) => r.ref)) + errors.push(pluginError(result.data, `${found.server.name || found.server.id} refused the 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. @@ -593,12 +604,19 @@ 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 + * it moved — once its world is loaded. 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 } = {}) { +function observeServer(serverId, { bootId, wipeId, worldReady } = {}) { if (!serverId || (!bootId && !wipeId)) return false + // **Not until the world is loaded** (§28.6). The plugin connects before the + // save loads, so the new boot id arrives while every crate still looks gone; + // asked then, reconcile would orphan the lot. The plugin says when it is + // ready, and the change is noticed on that hello instead. An older plugin + // that never says is taken as ready, as it always was. + if (worldReady === false) return false + const previous = lastSeen.get(serverId) lastSeen.set(serverId, { bootId: bootId || null, wipeId: wipeId || null }) if (!previous) return false diff --git a/server/test/world.test.js b/server/test/world.test.js index 9b16339..a66c8dd 100644 --- a/server/test/world.test.js +++ b/server/test/world.test.js @@ -329,3 +329,39 @@ test('only a restart or a wipe asks core to reconcile — never a first sighting assert.strictEqual(world.observeServer('alt', { bootId: 'x', wipeId: 'y' }), false) // another server's baseline assert.strictEqual(asked, 2) }) + +test('a refusal that arrives as a 200 is not a revert (§28.6)', async (t) => { + // The plugin answers `world.error` with a 200 — `not-ready` while the world is + // still loading. Read as success, every row would be marked reverted while the + // game still held every crate. + stub(t, { + revert: async () => ({ ok: true, data: { kind: 'world.error', reason: 'not-ready', message: 'still loading' } }), + }) + const result = await world.revert({ runId: 8, resources: [{ kind: 'world', ref: 'main:101' }] }) + assert.deepStrictEqual(result, { ok: false, error: 'still loading' }) + + const lost = await world.revert({ runId: 8, resources: [], idempotencyKey: 'k' }) + assert.strictEqual(lost.ok, false) +}) + +test('a boot id seen before the world has loaded is not a restart yet (§28.6)', (t) => { + world.resetWatch() + let asked = 0 + const saved = core.reconcileEvents + core.reconcileEvents = () => { + asked += 1 + return Promise.resolve({}) + } + t.after(() => { + core.reconcileEvents = saved + world.resetWatch() + }) + + world.observeServer('main', { bootId: 'b1', wipeId: 'w1', worldReady: true }) + // The plugin reconnects on the new boot BEFORE the save loads. + assert.strictEqual(world.observeServer('main', { bootId: 'b2', wipeId: 'w1', worldReady: false }), false) + assert.strictEqual(asked, 0) + // Its next hello says the world is there, and that is when the change counts. + assert.strictEqual(world.observeServer('main', { bootId: 'b2', wipeId: 'w1', worldReady: true }), true) + assert.strictEqual(asked, 1) +}) -- 2.49.1