Files
Module-Rust/server/eventWorld.js
wtclaude 11619a4dc6 feat(rust): NPC sides, the faction table, an event's escort, ally and tether (runicnpc stage 5)
RunicNPC stage 5 on the site (docs runicnpc/PLAN.md, D253-D272):

- profiles gain the guard role, faction, relations (its own exceptions),
  alertRadius, turrets, hurtByPlayers, hurtsPlayers and kitUse, checked in
  RunicNPC's order and words, and defaulting to "players only" (D255);
- the faction table: one site-wide table, one row per pair and both ways
  (D254, D268), stored in rust_npc_factions, edited on the NPC profiles page
  (PUT /admin/rust/npcs/factions), pushed to every server with its
  profiles and hashed with them, and a standalone server's own pairs
  adopted at its first push with the site's winning (D244, D251);
- the Place NPCs step takes escort (a Steam id or a {placeholder}), an ally
  (a clan from the new rust.options.clans source, or the team of a player)
  and tether (the zone this event made), for a RunicNPC profile only
  (D269, D270, D272);
- a placement may be held inside a zone (tether, D272);
- the bridge's RunicNPC API floor is 4.

Tests: 488 server, 66 client. routes.manifest.json regenerated against the
pinned core (one route added).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-01 07:58:13 -05:00

974 lines
40 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 zones = require('./model/zones/zones.model')
const zoneOptions = require('./model/zones/zoneOptions')
const voice = require('./model/permissions/voice')
const npcs = require('./model/npcs/npcs.model')
const npcsDb = require('./model/npcs/npcs.db')
const npcProfile = require('./model/npcs/npcProfile')
const clansDb = require('./model/clans/clans.db')
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
const ZONE_MESSAGE_MAX = 256
const DOME_STACK_MAX = 10
/**
* ZoneDomes' own sphere types, by the number its API takes (D194). Only Standard
* is a whole dome: Rust's shaded sphere, darker with each stacked copy. The four
* colours are the Twitch battle-royale spheres, which show only where they cut
* terrain or a structure (ZoneDomes says so itself), so they read as a ring at
* the zone's edge. Standard is the default (D212).
*/
const DOMES = [
{ value: 'standard', type: 0, label: 'Full dome (shaded) — the default' },
{ value: 'red', type: 1, label: 'Red — only where it meets the ground or a building' },
{ value: 'blue', type: 2, label: 'Blue — only where it meets the ground or a building' },
{ value: 'green', type: 3, label: 'Green — only where it meets the ground or a building' },
{ value: 'purple', type: 4, label: 'Purple — only where it meets the ground or a building' },
]
/** 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',
// PLAN_REDESIGNS §3: a flag or setting this server's ZoneManager does not have,
// and a dome asked of a server without ZoneDomes or the domes helper.
'bad-option',
'dome-unavailable',
// runicnpc stage 4 (D243): a profile the server does not have, or no RunicNPC.
'unknown-profile',
'runicnpc-missing',
// runicnpc stage 5 (D272): no zone of the run holds the point; a RunicNPC
// older than the API escort, ally and tether need. An escort who is not on
// the server (`escort-offline`) is left to retry: they may join.
'no-zone',
'runicnpc-old',
])
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).',
},
]
/**
* Why a dome cannot go on this server, from its last hello, or null when it can
* (or when the server has not said, which leaves the plugin to answer). The
* words name what is missing, because the fix is to install one of two files.
*/
function domeMissing(mods) {
const zd = mods && mods.zoneDomes
if (!zd) return null
if (!zd.loaded) return 'ZoneDomes is not loaded on this server, so a zone cannot have a dome'
const state = zd.helper && zd.helper.state
if (state === 'missing') return 'RunicGatewayDomes.cs is not installed on this server, and ZoneDomes cannot be called without it'
if (state && state !== 'patched') return `RunicGatewayDomes.cs could not patch this server's ZoneDomes (${state})`
return null
}
/** 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) }
}
/**
* A zone the plugin erased at its deadline (`world.expired`, PLAN_FIXES F13, F14).
*
* D96 said the website maps this frame to nothing and learns of it through
* `reconcile` and `revert`. The first player walk showed what that costs: three
* zones expired in the game on time and sat `confirmed` on the run console until
* the runs were cancelled, when teardown found them "already gone" and called that
* a success. D170 changed it, and D183 put the record in core: the resource row is
* marked `expired`.
*
* Until protocol 13 this frame could not be recognised at all — the plugin wrote
* the zone's kind over the frame's — so `what` is new with it, and a frame without
* an `id` names nothing to expire.
*/
function expired(serverId, frame) {
if (!serverId || !frame || frame.id === undefined || frame.id === null || frame.id === '') return false
try {
core.expireEvent({ kind: OWNED_KIND, ref: refOf(serverId, String(frame.id)) })
} catch (err) {
log.warn('could not tell core a zone expired', { server: serverId, id: frame.id, error: err.message })
return false
}
return true
}
/** 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:
kind === 'npc'
? "Which NPCs: one of this site's NPC profiles (Admin → Rust NPC profiles, on a server with RunicNPC), or one of the server's own scientists."
: `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,
...(kind === 'npc' ? NPC_ORDER_PARAMS : []),
],
async perform({ runId, idempotencyKey, params, verify }) {
const picked = String(params.prefab || '').trim()
// D243: one of the site's RunicNPC profiles, beside Rust's own.
const profile = kind === 'npc' && picked.startsWith(npcs.PROFILE_PREFIX) ? picked.slice(npcs.PROFILE_PREFIX.length) : null
const known = profile === null ? PLACEABLE.find((x) => x.key === picked) : null
if (profile !== null ? !npcProfile.NAME_RULE.test(profile) : !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 (profile !== null) {
const missing = await profileMissing(found.server, profile)
if (missing) return { ok: false, retry: false, error: missing }
}
// Stage 5: escort, ally and tether are RunicNPC's, so only a profile takes them.
const orders = kind === 'npc' ? npcOrders(params, found.server) : { ok: true, wire: {} }
if (!orders.ok) return { ok: false, retry: false, error: orders.error }
if (profile === null && Object.keys(orders.wire).length) {
return { ok: false, retry: false, error: "escort, ally and tether are for one of the site's NPC profiles (RunicNPC), not Rust's own scientists" }
}
if (verify) return { ok: true }
return place(
found.server,
client.worldPlace,
{
runId: String(runId),
key: idempotencyKey,
...(profile !== null ? { profile } : { prefab: known.key }),
count,
...(spread === undefined ? {} : { spread }),
...where.wire,
...orders.wire,
},
profile !== null ? `NPCs of the profile "${profile}"` : known.label.toLowerCase(),
)
},
}
}
/** A Steam id: seventeen digits, as Rust's are. */
const STEAM_ID = /^\d{17}$/
/**
* Stage 5 (D269, D270, D272): what a RunicNPC profile's NPCs are told besides
* their profile. Each is optional; the server refuses a step whose order it
* cannot honour (an escort who is not on, a clan it does not have, no zone of
* the run around the point), so nothing half-placed is left behind.
*/
const NPC_ORDER_PARAMS = [
{
name: 'escort',
type: 'string',
required: false,
example: '76561198000000001',
description:
"A player's Steam id, or a {placeholder} from the run's start params: the NPCs keep close to that player and fight whoever attacks them, and walk back to their spot if the player dies or leaves (D269). The player must be on the server. RunicNPC profiles only.",
},
{
name: 'allyClan',
type: 'string',
required: false,
source: 'rust.options.clans',
example: 'main/1234567',
description:
"A clan on the same server: the NPCs never target its members and defend them and what they own (D257). RunicNPC profiles only; give this or allyTeamOf, not both.",
},
{
name: 'allyTeamOf',
type: 'string',
required: false,
example: '76561198000000001',
description:
"A player's Steam id, or a {placeholder}: the NPCs are allied to that player and their team (D257, D270). RunicNPC profiles only.",
},
{
name: 'tether',
type: 'boolean',
required: false,
example: true,
description:
'Keep the NPCs inside the zone this event made around the point (D272): a Make a zone step must come first. RunicNPC profiles only.',
},
]
/** The orders, checked, as the bridge reads them: `{ ok, wire }` or `{ ok: false, error }`. */
function npcOrders(params, server) {
const wire = {}
const escort = String(params.escort === undefined || params.escort === null ? '' : params.escort).trim()
if (escort) {
if (!STEAM_ID.test(escort)) return { ok: false, error: `an escort is a player's Steam id, and "${escort}" is not one` }
wire.escort = escort
}
const clan = String(params.allyClan === undefined || params.allyClan === null ? '' : params.allyClan).trim()
const teamOf = String(params.allyTeamOf === undefined || params.allyTeamOf === null ? '' : params.allyTeamOf).trim()
if (clan && teamOf) return { ok: false, error: 'an ally is a clan or a player and their team, not both' }
if (clan) {
const m = /^([^/]+)\/(-?\d{1,20})$/.exec(clan)
if (!m) return { ok: false, error: `"${clan}" is not a clan from the list` }
if (m[1] !== server.id) return { ok: false, error: `that clan is on ${m[1]}, and these NPCs are placed on ${server.id}` }
wire.ally = { kind: 'clan', id: m[2] }
}
if (teamOf) {
if (!STEAM_ID.test(teamOf)) return { ok: false, error: `an ally's team is named by a player's Steam id, and "${teamOf}" is not one` }
wire.ally = { kind: 'player', id: teamOf }
}
if (params.tether === true || params.tether === 'true') wire.tether = true
return { ok: true, wire }
}
/**
* Why a profile cannot be placed on this server, from what the site knows, or
* null (D243). A server without RunicNPC offers only Rust's own until stage 9;
* a profile the site does not push there is not on it. What the server itself
* holds is the plugin's to answer (`unknown-profile`).
*/
async function profileMissing(server, profile) {
const [list, profiles] = await Promise.all([npcsDb.listNpcServers(), npcsDb.listProfiles()])
const here = list.find((s) => s.id === server.id)
const name = server.name || server.id
if (!npcs.npcReady(here)) return `${name} cannot place the profile "${profile}": ${npcs.npcAbsence(here)}. Pick one of the server's own scientists.`
if (!profiles.some((p) => !p.replaced && p.name === profile && npcs.covers(p, server.id))) {
return `the site has no NPC profile "${profile}" for ${name} (Admin → Rust NPC profiles)`
}
return null
}
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.' },
{
name: 'options',
type: 'string',
required: false,
example: 'NoBuild, NoPlayerLoot, radiation=10',
source: 'rust.options.zone_presets',
description:
'ZoneManager flags and settings, separated by commas. Pick a preset (Admin → Rust zone presets) to fill it; the step keeps its own copy. Settings: radiation, comfort, temperature, safezone, permission.',
},
{ name: 'enterMessage', type: 'string', required: false, example: 'You entered the arena.', description: `Said to a player who walks in, up to ${ZONE_MESSAGE_MAX} characters.` },
{ name: 'leaveMessage', type: 'string', required: false, example: 'You left the arena.', description: `Said to a player who walks out, up to ${ZONE_MESSAGE_MAX} characters.` },
{
name: 'delivery',
type: 'string',
required: false,
example: 'chat',
source: 'rust.options.delivery',
description: 'How the two messages are said: chat (the default) or popup. Without PopupNotifications on the server a popup is said in chat instead.',
},
{
name: 'dome',
type: 'string',
required: false,
example: 'standard',
source: 'rust.options.domes',
description:
'A ZoneDomes dome over the zone. "standard" is a full shaded dome; the colours show only where the sphere meets the ground or a building. Needs ZoneDomes and RunicGatewayDomes.cs on the server. Blank is no dome.',
},
{ name: 'domeStack', type: 'int', required: false, example: 1, description: `How many spheres the dome stacks, 1 to ${DOME_STACK_MAX}; more is darker. Left blank, 1.` },
],
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
// §3.1: the flags and settings, checked against the flag list this server's
// last hello carried (D211). A server that has not said is left to the
// plugin, which checks every one against its live ZoneManager.
const mods = await zones.serverZoneMods(found.server.id)
const known = mods && mods.zoneManager && mods.zoneManager.flags && mods.zoneManager.flags.length ? mods.zoneManager.flags : null
const opts = zoneOptions.parse(params.options, known)
if (!opts.ok) return { ok: false, retry: false, error: opts.error }
const messages = {}
for (const key of ['enterMessage', 'leaveMessage']) {
const text = params[key] === undefined || params[key] === null ? '' : String(params[key]).trim()
if (text.length > ZONE_MESSAGE_MAX) return { ok: false, retry: false, error: `a zone's message is at most ${ZONE_MESSAGE_MAX} characters` }
if (text) messages[key] = text
}
const delivery = params.delivery === undefined || params.delivery === null || params.delivery === '' ? 'chat' : String(params.delivery)
if (delivery !== 'chat' && delivery !== 'popup') {
return { ok: false, retry: false, error: `a zone's messages go to chat or to a popup, not to "${params.delivery}"` }
}
// §3.2: a dome, where the server said it has ZoneDomes and the helper.
let dome = null
const domeName = params.dome === undefined || params.dome === null ? '' : String(params.dome).trim().toLowerCase()
if (domeName) {
const kind = DOMES.find((d) => d.value === domeName)
if (!kind) return { ok: false, retry: false, error: `a dome is ${DOMES.map((d) => d.value).join(', ')} or blank, not "${params.dome}"` }
const stack = num(params.domeStack)
if (Number.isNaN(stack) || (stack !== undefined && (!Number.isInteger(stack) || stack < 1 || stack > DOME_STACK_MAX))) {
return { ok: false, retry: false, error: `a dome stacks 1 to ${DOME_STACK_MAX} spheres, not "${params.domeStack}"` }
}
const missing = domeMissing(mods)
if (missing) return { ok: false, retry: false, error: missing }
dome = { type: kind.type, stack: stack === undefined ? 1 : stack }
}
// 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 }
// The chat voice (D140), as a chat line has it; a popup ignores it.
const hasMessage = Boolean(messages.enterMessage || messages.leaveMessage)
const format = hasMessage && delivery === 'chat' ? await voice.currentFormat() : null
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) } : {}),
...(opts.flags.length ? { flags: opts.flags } : {}),
...(Object.keys(opts.settings).length ? { settings: opts.settings } : {}),
...messages,
...(hasMessage ? { delivery } : {}),
...(format ? { format } : {}),
...(dome ? { dome } : {}),
},
'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:
"NPCs of one of the site's profiles (RunicNPC), or the server's own scientists or guards, at a monument or a point. Taken away at teardown. NPCs are never saved, 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).
//
// D243: the NPC list puts the site's RunicNPC profiles first, then Rust's own,
// each group named. A site without a server that has RunicNPC has no profile
// rows, and its list reads as it always did.
...['crate', 'npc'].map((kind) => ({
id: kind === 'npc' ? 'rust.options.npcs' : 'rust.options.crates',
label: kind === 'npc' ? 'NPCs' : 'Crates',
description:
kind === 'npc'
? "The site's NPC profiles, on servers with RunicNPC, then the NPCs a Rust server places of its own."
: 'The crates a Rust server places for events.',
async resolve() {
const own = PLACEABLE.filter((p) => p.kind === kind).map((p) => ({ value: p.key, label: p.label }))
if (kind !== 'npc') return own
let profiles = []
try {
profiles = await npcs.optionRows()
} catch (err) {
log.warn('could not list the NPC profiles for the picker', { error: err.message })
}
return profiles.length ? [...profiles, ...own.map((row) => ({ ...row, group: "Rust's own" }))] : own
},
})),
{
// Stage 5 (D270): an ally for a Place NPCs step. From the site's own clan
// mirror, so it answers with every server off. A row's value carries its
// server, because a clan id means something only on its own server.
id: 'rust.options.clans',
label: 'Clans',
description: "Each server's clans, as the site last read them.",
searchable: true,
async resolve({ q } = {}) {
const term = String(q || '').trim().toLowerCase()
const rows = (await clansDb.listActiveClans())
.filter((c) => c.clanId !== null && c.clanId !== undefined)
.map((c) => ({ value: `${c.serverId}/${c.clanId}`, label: `${c.name} (${c.memberCount})`, group: c.serverName || c.serverId }))
.filter((r) => !term || r.label.toLowerCase().includes(term))
return bounded(rows, 'rust.options.clans')
},
},
{
// D210: the admins' own presets. A row's VALUE is the options line itself,
// so picking one writes that line into the step, and the step keeps its own
// copy: a preset edited later changes no published event. From the site's
// own tables, so it answers with every server off.
id: 'rust.options.zone_presets',
label: 'Zone presets',
description: 'The zone presets saved under Admin → Rust zone presets, by the servers each is for.',
async resolve() {
return zones.optionRows()
},
},
{
id: 'rust.options.domes',
label: 'Domes',
description: "ZoneDomes' dome colours (D194). A coloured dome shows only where it meets terrain or a structure.",
async resolve() {
return DOMES.map((d) => ({ value: d.value, label: d.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,
expired,
resetWatch,
}