Two defects the phase 13a walk found by restarting the rig mid-run: - The watch asked core to reconcile the moment a new boot id appeared, which is before the game has loaded its save — every crate looked gone and was orphaned. It now waits for the plugin's hello to say `worldReady`; an older plugin that never says is taken as ready. - revert() read any 200 as success. On this bridge a refusal is a 200 carrying world.error (`not-ready` while loading), so every row would have been marked reverted with the game still holding every crate. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
663 lines
25 KiB
JavaScript
663 lines
25 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'))
|
|
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,
|
|
}
|