Files
Module-Rust/server/eventWorld.js
wtclaude fab31f23e8 refactor(rust): one placing verb per kind — rust.crate.place and rust.npc.place (D97)
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-24 02:09:49 -05:00

645 lines
24 KiB
JavaScript

// ── 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 `<serverId>:<id>`. `revert` and `reconcile` are handed
// resources and not the step's params, and a run may reach six servers; the ref
// is the only place the server can travel with the thing.
const core = require('./core')
const client = require('./sidecarClient')
const servers = require('./model/servers/servers.model')
const { serverFor, transportError, pluginError, perServer, bounded } = require('./eventLeases')
const log = core.logger('world')
/**
* The budget every verb here declares. It must EXCEED the client's own timeout
* (`TIMEOUT_MS`, 12 s), which in turn exceeds the sidecar's ten-second reply
* timeout — otherwise core gives up first and a `retry: false` this module
* answered is unreachable (MODULE_API §2.4). `world.test.js` asserts the order.
*/
const BUDGET_MS = 15000
// Mirrors of the plugin's bounds (D95, D96). The plugin's are authoritative and
// an operator may set them lower, in which case its refusal is the one that
// lands; these exist so a bad step is a refusal on the AUTHORING FORM and in a
// dry run, rather than a step failing unattended at four in the morning.
const MAX_CRATES = 25
const MAX_NPCS = 20
const MAX_SPREAD = 50
const MAX_OFFSET = 150
const ZONE_MIN_RADIUS = 5
const ZONE_MAX_RADIUS = 150
const ZONE_MAX_MINUTES = 7 * 24 * 60
/** The ledger kind both verbs file under. */
const OWNED_KIND = 'world'
/**
* What the plugin will place, mirroring its allowlist (D88).
*
* **Two copies of a short list, deliberately** — `module-uo`'s `GRANTABLE`
* argument. This one prices a step (`cost()` is synchronous and cannot ask a
* game) and fills the dropdown with every server off; the plugin's is what is
* true when this one is wrong.
*/
const PLACEABLE = [
{ key: 'crate.basic', kind: 'crate', label: 'Basic crate' },
{ key: 'crate.normal', kind: 'crate', label: 'Military crate' },
{ key: 'crate.normal2', kind: 'crate', label: 'Crate' },
{ key: 'crate.elite', kind: 'crate', label: 'Elite crate' },
{ key: 'crate.tools', kind: 'crate', label: 'Tool box' },
{ key: 'crate.hackable', kind: 'crate', label: 'Locked crate (hackable)' },
{ key: 'supply.drop', kind: 'crate', label: 'Supply drop' },
{ key: 'barrel.loot', kind: 'crate', label: 'Loot barrel' },
{ key: 'npc.scientist', kind: 'npc', label: 'Scientist' },
{ key: 'npc.scientist.heavy', kind: 'npc', label: 'Heavy scientist' },
{ key: 'npc.scientist.tethered', kind: 'npc', label: 'Scientist (stays put)' },
{ key: 'npc.bandit.guard', kind: 'npc', label: 'Bandit guard' },
]
/** The plugin's refusals a second attempt would repeat. Anything else is left to core's default. */
const PERMANENT = new Set([
'events-disabled',
'malformed',
'unknown-prefab',
'out-of-range',
'no-monument',
'off-map',
'zonemanager-missing',
])
const BUDGETS = [
{
id: 'rust.prefabs',
label: 'Crates placed',
unit: 'crates',
description: 'Crates, barrels and supply drops an event puts in the world. Counted per server a run reaches.',
},
{
id: 'rust.npcs',
label: 'NPCs placed',
unit: 'NPCs',
description: 'Scientists and guards an event puts in the world — its own dial, so fights can be capped apart from loot (D89).',
},
{
id: 'rust.zone.minutes',
label: 'Zone time',
unit: 'minutes',
description: 'How long the zones an event opens stand, added up. Every zone declares its minutes, and the game erases it when they run out (D96).',
},
]
/** A number param, or undefined when left blank. */
function num(raw) {
if (raw === undefined || raw === null || raw === '') return undefined
const value = Number(raw)
return Number.isFinite(value) ? value : NaN
}
/**
* Where a step puts its thing — a monument plus an offset, or raw coordinates,
* and exactly one of the two (D87) — and which server that is on.
*
* A monument value carries its server (`srv-a/harbor_1#2`, D93), so a monument
* step needs no `server`; one that gives both must agree. Raw coordinates name
* nothing, so they need `server`. Every refusal is `retry: false`: the second
* attempt has the same params.
*/
function location(params) {
const monument = String(params.monument || '').trim()
const x = num(params.x)
const z = num(params.z)
const y = num(params.y)
const byCoords = x !== undefined || z !== undefined
let serverId = String(params.server || '').trim()
if (Boolean(monument) === byCoords) {
return { ok: false, error: 'a location is a monument or x and z, and exactly one of them' }
}
if (monument) {
const slash = monument.indexOf('/')
if (slash <= 0 || slash === monument.length - 1) {
return { ok: false, error: `"${monument}" is not a monument — pick one from the list, as server/monument` }
}
const onServer = monument.slice(0, slash)
if (serverId && serverId !== onServer) {
return { ok: false, error: `that monument is on ${onServer}, not ${serverId}` }
}
serverId = onServer
const offsetX = num(params.offsetX) ?? 0
const offsetZ = num(params.offsetZ) ?? 0
if (Number.isNaN(offsetX) || Number.isNaN(offsetZ)) return { ok: false, error: 'an offset is a number of metres' }
if (Math.hypot(offsetX, offsetZ) > MAX_OFFSET) {
return { ok: false, error: `an offset from a monument is at most ${MAX_OFFSET} m` }
}
return { ok: true, serverId, wire: { monument: monument.slice(slash + 1), offsetX, offsetZ } }
}
if (x === undefined || z === undefined || Number.isNaN(x) || Number.isNaN(z)) {
return { ok: false, error: 'coordinates need both x and z, as numbers' }
}
if (Number.isNaN(y)) return { ok: false, error: 'y is a number of metres, or left blank for the ground' }
if (!serverId) return { ok: false, error: 'coordinates do not say which server — pick one' }
return { ok: true, serverId, wire: { x, z, ...(y === undefined ? {} : { y }) } }
}
/** `<serverId>:<id>` — see the header. */
const refOf = (serverId, id) => `${serverId}:${id}`
/** A ref split back into its server and id, at the FIRST colon (a server id has none). */
function splitRef(ref) {
const text = String(ref || '')
const colon = text.indexOf(':')
return colon <= 0 ? { serverId: null, id: text } : { serverId: text.slice(0, colon), id: text.slice(colon + 1) }
}
/** Resources grouped by the server each one is on. */
function byServer(resources) {
const groups = new Map()
for (const resource of resources || []) {
const serverId = (resource.payload && resource.payload.serverId) || splitRef(resource.ref).serverId
if (!groups.has(serverId)) groups.set(serverId, [])
groups.get(serverId).push(resource)
}
return groups
}
/** A transport failure, classified. Only a missing configuration is one waiting cannot fix. */
function transportFailure(server, result, what) {
const permanent = result.status === 'not-configured' || result.status === 'no-token'
return { ok: false, ...(permanent ? { retry: false } : {}), error: transportError(server, result, what) }
}
/**
* Send one world write and file what came back.
*
* One resource per id — per crate, per NPC, per zone — like `module-uo`'s one
* per serial, so a group half of which players looted reconciles per crate
* rather than all or nothing.
*/
async function place(server, send, body, what) {
const result = await send(server, body)
if (!result.ok) return transportFailure(server, result, what)
const data = result.data || {}
if (data.kind !== 'world.ok') {
return {
ok: false,
...(PERMANENT.has(data.reason) ? { retry: false } : {}),
error: pluginError(data, `${server.name || server.id} refused the ${what}`),
}
}
const placed = Array.isArray(data.placed) ? data.placed : []
return {
ok: true,
resources: placed.map((row) => ({
kind: OWNED_KIND,
ref: refOf(server.id, row.id),
payload: {
serverId: server.id,
what: row.kind,
...(row.prefab ? { prefab: row.prefab } : {}),
...(row.name ? { name: row.name } : {}),
},
})),
...(data.repeat ? { detail: { repeat: true, note: 'answered from the first attempt; nothing new was placed' } } : {}),
}
}
/**
* Give back what a step made.
*
* **No idempotency key goes with it.** `module-uo` shipped exactly that
* mistake: its despawn carried the key the spawn went out under, the shard
* recognised a repeat of the DO and answered with the spawn's reply, and every
* teardown was a no-op that reported success (MODULE_API §2.4). A repeated
* revert is safe here without one — the second finds everything `gone`.
*
* The one case the key IS for is the lost answer: core knows a dispatch went
* out under it and never learned what it made, so `resources` is empty. The
* step's server is not known then either — it was a param, and params do not
* reach `revert` — so every enabled server is asked to give back whatever this
* run placed under that key. A server that cannot be asked leaves the row
* visible rather than guessing.
*/
async function revert({ runId, resources, idempotencyKey }) {
const failed = []
const errors = []
if (!resources || resources.length === 0) {
if (!idempotencyKey) return { ok: true }
for (const server of await servers.listForPolling()) {
const result = await client.worldRevert(server, { runId: String(runId), key: idempotencyKey })
if (!result.ok) errors.push(transportError(server, result, 'revert'))
}
return errors.length ? { ok: false, error: errors.join('; ') } : { ok: true }
}
for (const [serverId, group] of byServer(resources)) {
const found = await serverFor(serverId)
if (!found.ok) {
failed.push(...group.map((r) => r.ref))
errors.push(found.error)
continue
}
const result = await client.worldRevert(found.server, {
runId: String(runId),
ids: group.map((r) => splitRef(r.ref).id),
})
if (!result.ok) {
failed.push(...group.map((r) => r.ref))
errors.push(transportError(found.server, result, 'revert'))
continue
}
// `gone` is not reported: a crate a player looted is the point of having
// placed it. `refused` IS — the plugin found something there that this run
// did not make, and nothing will ever remove it through this path.
const refused = new Set(((result.data && result.data.refused) || []).map(String))
for (const r of group) if (refused.has(splitRef(r.ref).id)) failed.push(r.ref)
}
if (!failed.length) return { ok: true }
if (failed.length === resources.length && errors.length) return { ok: false, error: errors.join('; ') }
return { ok: true, failed }
}
/**
* Which of these does the world still hold?
*
* The plugin LOOKS for each one, by net id or zone id. A server that cannot be
* asked has said nothing, so its resources are all reported in force — "I do
* not know" is never "it is gone" (MODULE_API §1.1).
*/
async function reconcile({ runId, resources }) {
const inForce = []
for (const [serverId, group] of byServer(resources)) {
const found = await serverFor(serverId)
const result = found.ok ? await client.worldOwned(found.server, { runId: String(runId) }) : null
if (!result || !result.ok || !result.data || !Array.isArray(result.data.owned)) {
inForce.push(...group.map((r) => r.ref))
continue
}
const held = new Set(result.data.owned.map((row) => String(row.id)))
for (const r of group) if (held.has(splitRef(r.ref).id)) inForce.push(r.ref)
}
return { ok: true, inForce }
}
/** The location params both verbs share, so two declarations cannot drift apart. */
const LOCATION_PARAMS = [
{
name: 'monument',
type: 'string',
required: false,
example: 'main/powerplant_1',
source: 'rust.options.monuments',
description: 'Where, by monument. Give this OR x and z. Names the server too.',
},
{
name: 'offsetX',
type: 'float',
required: false,
example: 20,
description: `Metres east of the monument's centre (negative is west). Up to ${MAX_OFFSET} m from it in all.`,
},
{
name: 'offsetZ',
type: 'float',
required: false,
example: -15,
description: "Metres north of the monument's centre (negative is south).",
},
{
name: 'server',
type: 'string',
required: false,
example: 'main',
source: 'rust.options.servers',
description: 'Which server, when the location is coordinates. A monument already says.',
},
{ name: 'x', type: 'float', required: false, example: -604, description: 'World x, instead of a monument.' },
{ name: 'z', type: 'float', required: false, example: -342, description: 'World z, instead of a monument.' },
{
name: 'y',
type: 'float',
required: false,
example: 30,
description: 'Height. Left blank, the ground at x and z.',
},
]
const WORLD_COMMON = {
// Something appears where there was nothing. §K puts the default-off line
// between `inspect` and `change`, so an operator switches these on
// deliberately — the right consent for an unattended change to a live world.
risk: 'change',
reversible: 'ledger',
version: 1,
budgetMs: BUDGET_MS,
revert,
reconcile,
}
/**
* 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. 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,
}