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) +})