// ── What an event MAKES on a Rust server (PLAN.md §28, protocol 9) ──────── // // 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 // 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')) 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 } } 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 } // **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. 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, } /** * 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, 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', ) }, }, placeVerb({ id: 'rust.crate.place', kind: 'crate', budget: 'rust.prefabs', max: MAX_CRATES, label: 'Place crates', description: '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 = [ { // 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 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.filter((p) => p.kind === kind).map((p) => ({ value: p.key, label: p.label })) }, })), ] // ── 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 — 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, 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 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, }