Files
Module-Rust/server/eventRaidableBases.js
wtclaude 3aa142bf06
All checks were successful
PR Checks / client-build (pull_request) Successful in 24s
PR Checks / server-tests (pull_request) Successful in 32s
PR Checks / frozen-manifest (pull_request) Successful in 44s
fix(rust): the last fixes' small ones (W3 W6 W7/W11 W8 W10 W13-W18)
Stage 2 of PLAN_LAST_FIXES (docs#366).

- W7/W11: an "is it loaded" refusal is confirmed live before it is given.
  New liveStatus.js asks the sidecar once (boot.refreshOne, as a poll tick
  would), at most once per server per 5 s and waiting at most 3 s, then
  reads the stored status again. Used by Economics (step and staff page),
  Backpacks, Dangerous Treasures, Guarded Crate, RaidableBases, ZoneDomes,
  TruePVE and RunicNPC.
- W3: a world call answered `already-out` (the bridge half comes in stage
  3) succeeds as called 0, saying a Travelling Vendor is already out.
- W6: "A locked crate dropped by a Chinook is being hacked."
- W8: a RaidableBases profile's audit row names RaidableBases, not "@data".
- W10: the backpack sizes picker lists each size once, naming its servers.
- W13: a RaidableBases name both servers describe differently shows each
  server's own description.
- W14: each RaidableBases note after the first starts its own sentence.
- W15: a staff deposit refused at Economics' limit answers 409.
- W16: a payment's failedNote explains only the reasons that happened.
- W17: the sync state counts player grants that did not land
  (waitingToLand), and the Permissions page says "in sync · 1 waits for
  their player to join" instead of plain "in sync".
- W18: Guarded Crate's kept note drops the "30 minutes" it could not know.

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

472 lines
23 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── Spawning a RaidableBases base (protocol 14, PLAN_REDESIGNS §11.4) ───────
//
// `rust.raidbase.spawn`, "Spawn a raid base": one of RaidableBases' bases for a
// run, at a difficulty, a profile or a named base, where RaidableBases picks
// (D203) or at a spot the admin gave (D207), with settings for this one spawn
// (D205) and the lock to the first attacker set for it (D206).
//
// ── Two routes, by what the server has (D348, D387) ─────────────────────────
//
// * **With the raids helper** (`RunicGatewayRaids.cs`, D204) the base is the
// RUN's: the plugin answers with an id, the base is a ledger resource like a
// zone (`reversible: 'ledger'`), teardown despawns that one base and no
// other, and if RaidableBases ends it first (its own timer, or raided) the
// row is marked `expired` (D170). A spot, a profile, a base and the settings
// all need it.
// * **Without it** the plugin can still be asked for a base at a difficulty
// (the paid edition's own call), or for any base (the free edition's console
// command, which says nothing back). That base is NOT the run's: it records
// no resource, despawns on RaidableBases' own timer, and the run log says so.
//
// ── Names are the plugin's (D386) ──────────────────────────────────────────
//
// Difficulty, profile and base pickers list what each server's installed copy
// reported at hello. Nothing here knows "Easy" or "Nightmare": an operator's
// own difficulty is offered as written, and a server that has said nothing
// offers nothing.
//
// ── The spot check (D208, D388, D389, D391) ─────────────────────────────────
//
// At run time the plugin refuses a spot with a player, a building, a deployable
// or a raid base on it, or water under it unless the profile is built for
// water; rocks and cliffs are warnings, carried into the run log. The dry run
// asks the same check and returns its findings as WARNINGS (MODULE_API 1.16.0):
// advice, because the world can change before the event runs.
//
// **RaidableBases' grid.** For up to ten minutes after it loads (666 s on the
// rigs' 6000 map, §11.4.2) the plugin refuses every spawn; the plugin calls that
// `rb-not-ready`, and the step asks core to try again in ten minutes
// (`retryAfterMs`, D371), as a Dangerous Treasures start does (D365).
const client = require('./sidecarClient')
const { serverFor, transportError, pluginError } = require('./eventLeases')
const { BUDGET_MS, location, revert, reconcile } = require('./eventWorld')
const { gridOf } = require('./worldEvents')
const { reportOf, helperPatched } = require('./raidableBases')
const live = require('./liveStatus')
const serversDb = require('./model/servers/servers.db')
const mapDb = require('./model/map/map.db')
/** The resource kind a run's base is ledgered under: the world registry's, so `eventWorld`'s revert and reconcile serve it. */
const OWNED_KIND = 'world'
/** How long core waits before trying a spawn RaidableBases was not ready for (its grid, D365's pattern). */
const NOT_READY_RETRY_MS = 10 * 60 * 1000
/** What the step says while the grid builds. */
const NOT_READY = 'RaidableBases is not ready yet: it is still building its spawn grid'
/** What the run log says of a base that is not the run's. */
const OWN_TIMER = 'not the run’s: RaidableBases despawns it on its own timer, and teardown leaves it'
/** The plugin's refusals a second attempt would repeat. `rb-busy` and `rb-not-ready` are not: they pass. */
const PERMANENT = new Set([
'events-disabled',
'malformed',
'rb-missing',
'copypaste-missing',
'helper-needed',
'helper-unsupported',
'bad-difficulty',
'bad-profile',
'bad-base',
'console-unavailable',
'no-monument',
'out-of-range',
'off-map',
'in-water',
// The spot is taken. A player may walk off, a deployable stays: a refusal on a
// spot is reported, and the run pauses on its own `on_failure` rather than
// spawning the base somewhere the admin did not choose (D208).
'spot-blocked',
'refused',
])
/** The on/off switches a step may set for one spawn; blank keeps the profile's own (or, for the lock, the config's). */
const SWITCHES = Object.freeze([
{ value: 'on', label: 'On' },
{ value: 'off', label: 'Off' },
])
const BUDGETS = [
{
id: 'rust.raids',
label: 'RaidableBases bases',
unit: 'bases',
description: 'RaidableBases bases an event spawns, each one counted. With the raids helper a base is the run’s and despawns at teardown; without it RaidableBases despawns it on its own timer.',
},
]
/** Every enabled server's report, with its name. */
async function reports() {
const rows = await serversDb.rbReports().catch(() => [])
return rows.map((row) => ({ serverId: row.serverId, name: row.name || row.serverId, ...reportOf(row) }))
}
/** Each value once, with the servers that have it, as `{ value, label }`. */
function byValue(entries) {
const out = new Map()
for (const { value, label, server } of entries) {
const key = value.toLowerCase()
if (!out.has(key)) out.set(key, { value, labels: new Map() })
// Labels compare ignoring case, as the names do.
const labels = out.get(key).labels
const said = label.toLowerCase()
if (!labels.has(said)) labels.set(said, { label, servers: [] })
if (!labels.get(said).servers.includes(server)) labels.get(said).servers.push(server)
}
// One name, one row. Where servers describe it differently (a profile Hard on
// one and Expert on another), each server's own words, not the first's (W13).
return [...out.values()].map((e) => ({
value: e.value,
label: [...e.labels.values()].map(({ label, servers }) => `${label} — ${servers.join(', ')}`).join('; '),
}))
}
const OPTION_SOURCES = [
{
id: 'rust.options.raid_difficulties',
label: 'RaidableBases difficulties',
description: "The difficulties each server's RaidableBases lists, by its own names and levels. Nothing is offered for a server that has not said.",
async resolve() {
const entries = []
for (const r of await reports()) {
const rb = r.raidableBases
if (!rb || !rb.loaded) continue
for (const d of rb.difficulties) entries.push({ value: d.name, label: `${d.name} (level ${d.level})`, server: r.name })
}
return byValue(entries)
},
},
{
id: 'rust.options.raid_profiles',
label: 'RaidableBases profiles',
description: "The profiles each server's RaidableBases loaded, with their difficulty. Needs the raids helper, which is what reads them.",
async resolve() {
const entries = []
for (const r of await reports()) {
const profiles = r.raidableBases && r.raidableBases.loaded ? r.raidableBases.profiles : null
for (const p of profiles || []) {
entries.push({ value: p.name, label: `${p.name}${p.difficulty ? ` · ${p.difficulty}` : ''}${p.enabled ? '' : ' (disabled)'}`, server: r.name })
}
}
return byValue(entries)
},
},
{
id: 'rust.options.raid_bases',
label: 'RaidableBases bases',
searchable: true,
description: "The bases each server's RaidableBases can paste (those with a CopyPaste file), by profile. Needs the raids helper.",
async resolve({ q } = {}) {
const term = String(q || '').trim().toLowerCase()
const entries = []
for (const r of await reports()) {
const profiles = r.raidableBases && r.raidableBases.loaded ? r.raidableBases.profiles : null
for (const p of profiles || []) {
for (const b of p.bases) {
if (term && !b.toLowerCase().includes(term) && !p.name.toLowerCase().includes(term)) continue
entries.push({ value: b, label: `${b} · ${p.name}${p.difficulty ? ` (${p.difficulty})` : ''}`, server: r.name })
}
}
}
return byValue(entries)
},
},
{
id: 'rust.options.raid_switch',
label: 'On or off',
description: 'On or off for this one base. Left blank, the profile’s own setting stands (the lock: RaidableBases’ config).',
async resolve() {
return SWITCHES.map((s) => ({ ...s }))
},
},
]
/** A switch param: true, false, or undefined for blank. Null for anything else. */
function switchOf(raw) {
if (raw === undefined || raw === null || raw === '') return undefined
const text = String(raw).trim().toLowerCase()
if (text === 'on' || text === 'true') return true
if (text === 'off' || text === 'false') return false
return null
}
/** A whole-number param in a range, undefined for blank, null when it is not one. */
function intOf(raw, min, max) {
if (raw === undefined || raw === null || raw === '') return undefined
const n = Number(raw)
return Number.isInteger(n) && n >= min && n <= max ? n : null
}
/** A number param, undefined for blank, null when it is not one. */
function floatOf(raw) {
if (raw === undefined || raw === null || raw === '') return undefined
const n = Number(raw)
return Number.isFinite(n) ? n : null
}
/**
* The step's params as the wire's, or `{ error }`. Validates shapes only; the
* names are checked against the server's report by `missingOn`.
*/
function wireOf(params) {
const wire = {}
const text = (v) => String(v === undefined || v === null ? '' : v).trim()
for (const field of ['difficulty', 'profile', 'base']) {
if (text(params[field])) wire[field] = text(params[field])
}
const spot = Boolean(text(params.monument)) || params.x !== undefined && params.x !== '' || params.z !== undefined && params.z !== ''
let serverId = text(params.server)
if (spot) {
const where = location(params)
if (!where.ok) return { error: where.error }
const { y, ...at } = where.wire
Object.assign(wire, at)
serverId = where.serverId
const adjust = floatOf(params.heightAdjust)
const height = floatOf(params.height)
if (adjust === null || height === null) return { error: 'a height is a number of metres' }
if (adjust !== undefined && height !== undefined) return { error: 'a spot takes a height adjustment or an exact height, not both' }
if (adjust !== undefined) wire.heightAdjust = adjust
if (height !== undefined) wire.height = height
} else if (floatOf(params.heightAdjust) !== undefined || floatOf(params.height) !== undefined) {
return { error: 'a height needs a spot: a monument, or x and z' }
}
if (!serverId) return { error: 'the step does not say which server — pick one' }
for (const flag of ['pvp', 'npcs', 'lock']) {
const value = switchOf(params[flag])
if (value === null) return { error: `${flag} is on, off or blank, not "${params[flag]}"` }
if (value !== undefined) wire[flag] = value
}
const npcCount = intOf(params.npcCount, 0, 20)
if (npcCount === null) return { error: `the NPC count is a whole number from 0 to 20, not "${params.npcCount}"` }
if (npcCount !== undefined) wire.npcCount = npcCount
const despawn = intOf(params.despawnMinutes, 1, 24 * 60)
if (despawn === null) return { error: `despawn minutes are a whole number from 1 to 1440, not "${params.despawnMinutes}"` }
if (despawn !== undefined) wire.despawnMinutes = despawn
return { serverId, wire, spot }
}
/** Whether the wire asks for anything only the raids helper can do. */
function needsHelper(wire, spot) {
return spot || ['profile', 'base', 'pvp', 'npcs', 'npcCount', 'despawnMinutes', 'lock'].some((k) => wire[k] !== undefined)
}
const eq = (a, b) => String(a).toLowerCase() === String(b).toLowerCase()
/**
* Why a server cannot take the step, from what it last said, or null. Only a
* report that SAYS no refuses; one that said nothing is the plugin's to answer.
*/
function missingOn(report, name, wire, spot) {
const rb = report.raidableBases
if (!rb) return null
if (!rb.loaded) return `RaidableBases is not installed on ${name}`
if (rb.copyPaste && !rb.copyPaste.loaded) return `RaidableBases is loaded on ${name} but CopyPaste is missing: it pastes every base`
const patched = helperPatched(report)
if (needsHelper(wire, spot) && !patched) {
const state = rb.helper.state
const why = state === 'missing'
? `RunicGatewayRaids.cs is not installed on ${name}`
: `RunicGatewayRaids.cs could not patch ${name}'s RaidableBases (${rb.helper.reason || state})`
return `${why}, so a spot, a profile, a base and settings for one spawn cannot be used there; without them RaidableBases picks the spot and despawns the base on its own timer`
}
if (!patched && !rb.spawnCall && report.consolePlugins === false) {
return `${name}'s modding framework cannot run RaidableBases' console command from a plugin, and this edition has no spawn call another plugin can use; install RunicGatewayRaids.cs to spawn a base there (Rust-Plugins#34)`
}
if (wire.difficulty && rb.difficulties.length > 0 && !rb.difficulties.some((d) => eq(d.name, wire.difficulty))) {
return `"${wire.difficulty}" is not one of RaidableBases' difficulties on ${name} (${rb.difficulties.map((d) => d.name).join(', ')})`
}
if (rb.profiles) {
const profile = wire.profile ? rb.profiles.find((p) => eq(p.name, wire.profile)) : null
if (wire.profile && !profile) return `RaidableBases on ${name} has no profile "${wire.profile}"`
if (profile && wire.difficulty && profile.difficulty && !eq(profile.difficulty, wire.difficulty)) {
return `profile "${profile.name}" is ${profile.difficulty}, not ${wire.difficulty}`
}
if (wire.base) {
const owner = rb.profiles.find((p) => p.bases.some((b) => eq(b, wire.base)))
if (!owner) return `RaidableBases on ${name} has no base "${wire.base}" with a CopyPaste file`
if (profile && owner !== profile) return `base "${wire.base}" belongs to profile "${owner.name}", not "${profile.name}"`
if (wire.difficulty && owner.difficulty && !eq(owner.difficulty, wire.difficulty)) {
return `base "${wire.base}" is ${owner.difficulty}, not ${wire.difficulty}`
}
}
}
return null
}
/** The grid at a point, or null without the map's geometry. */
async function gridAt(serverId, x, z) {
const meta = await mapDb.getMeta(serverId).catch(() => null)
if (!meta) return null
return gridOf({ worldSize: Number(meta.worldSize), gridCells: Number(meta.gridCells), gridCellSize: Number(meta.gridCellSize) }, x, z)
}
/** The dry run's advice on a spot (D208, D391): warnings, never a refusal. */
async function spotAdvice(server, name, wire) {
const { pvp, npcs, npcCount, despawnMinutes, lock, heightAdjust, height, ...ask } = wire
const result = await client.raidCheck(server, ask)
if (!result.ok) return [`the spot could not be checked now (${transportError(server, result, 'spot check')})`]
const data = result.data || {}
if (data.kind !== 'raid.checked') return [`the spot could not be checked now: ${pluginError(data, `${name} did not check the spot`)}`]
const advice = (Array.isArray(data.warnings) ? data.warnings : []).map((w) => `at the spot now: ${w}`)
if (!data.ok) {
advice.unshift(`the spot is refused now (${data.reason || 'refused'}): ${data.message || 'no reason given'}. It is checked again when the event runs`)
}
return advice
}
const raidSpawn = {
id: 'rust.raidbase.spawn',
label: 'Spawn a raid base',
description:
'Spawns one RaidableBases base: where RaidableBases picks, or at a spot you give. Needs RaidableBases and CopyPaste on the server. With RunicGatewayRaids.cs (installed with the plugin) the base is the event’s — it despawns when the event ends, and may have its own PvP, NPCs, despawn time and lock — and a spot, a profile or a named base may be chosen; without it RaidableBases picks everything and despawns the base on its own timer. For up to ten minutes after RaidableBases loads it is not ready, and the step tries again ten minutes later.',
risk: 'change',
reversible: 'ledger',
version: 1,
budgetMs: BUDGET_MS,
params: [
{ name: 'server', type: 'string', required: false, example: 'main', source: 'rust.options.servers',
description: 'Which server. A monument already says; otherwise required.' },
{ name: 'difficulty', type: 'string', required: false, example: 'Hard', source: 'rust.options.raid_difficulties',
description: 'One of the server’s own RaidableBases difficulties. Blank: any (or the profile’s or base’s own).' },
{ name: 'profile', type: 'string', required: false, example: 'Hard Bases', source: 'rust.options.raid_profiles',
description: 'A RaidableBases profile; a base is picked from it at random. Needs RunicGatewayRaids.cs.' },
{ name: 'base', type: 'string', required: false, example: 'raidhard2', source: 'rust.options.raid_bases',
description: 'One base by name (its CopyPaste file). Needs RunicGatewayRaids.cs.' },
{ name: 'monument', type: 'string', required: false, example: 'main/powerplant_1', source: 'rust.options.monuments',
description: 'A spot by monument (with the offsets), instead of where RaidableBases picks. Needs RunicGatewayRaids.cs.' },
{ name: 'offsetX', type: 'float', required: false, example: 0, description: "Metres east of the monument's centre (negative is west)." },
{ name: 'offsetZ', type: 'float', required: false, example: 0, description: "Metres north of the monument's centre (negative is south)." },
{ name: 'x', type: 'float', required: false, example: 650, description: 'A spot by world x and z, instead of where RaidableBases picks. Needs RunicGatewayRaids.cs.' },
{ name: 'z', type: 'float', required: false, example: 2037, description: 'World z, with x.' },
{ name: 'heightAdjust', type: 'float', required: false, example: 5,
description: 'At a spot: metres added to the profile’s own paste height. Give this or an exact height, not both.' },
{ name: 'height', type: 'float', required: false, example: 30,
description: 'At a spot: the exact height the base is pasted at (the profile’s forced height).' },
{ name: 'pvp', type: 'string', required: false, example: 'off', source: 'rust.options.raid_switch',
description: 'PvP at this base: on or off. Blank keeps the profile’s (and RaidableBases’ own convert switches).' },
{ name: 'npcs', type: 'string', required: false, example: 'on', source: 'rust.options.raid_switch',
description: 'NPCs at this base: on or off. Blank keeps the profile’s.' },
{ name: 'npcCount', type: 'int', required: false, example: 6,
description: 'How many NPCs, 0 to 20, in the profile’s own mix of scientists and murderers. 0 is none.' },
{ name: 'despawnMinutes', type: 'int', required: false, example: 90,
description: 'Minutes until RaidableBases despawns the base on its own, 1 to 1440. Teardown despawns it sooner.' },
{ name: 'lock', type: 'string', required: false, example: 'on', source: 'rust.options.raid_switch',
description: 'Lock the base to the first player or team to attack it: on or off. Blank follows RaidableBases’ config.' },
],
cost: () => ({ 'rust.raids': 1 }),
async perform({ runId, idempotencyKey, params, verify }) {
const parsed = wireOf(params || {})
if (parsed.error) return { ok: false, retry: false, error: parsed.error }
const found = await serverFor(parsed.serverId)
if (!found.ok) return found
const name = found.server.name || found.server.id
// Confirmed live before it refuses: the stored status lags a restart (W7).
const { report, missing } = await live.confirmed(
found.server,
async () => {
const read = reportOf(await serversDb.rbReportFor(found.server.id).catch(() => null))
return { report: read, missing: missingOn(read, name, parsed.wire, parsed.spot) }
},
(r) => Boolean(r.missing),
)
if (missing) return { ok: false, retry: false, error: missing }
if (verify) {
// D391: the spot's check is advice in a dry run. A server that has not
// said it has the helper is not asked.
if (parsed.spot && helperPatched(report)) {
const warnings = await spotAdvice(found.server, name, parsed.wire)
if (warnings.length) return { ok: true, detail: { warnings } }
}
return { ok: true }
}
const result = await client.raidSpawn(found.server, { key: idempotencyKey, runId: String(runId), ...parsed.wire })
if (!result.ok) {
const permanent = result.status === 'not-configured' || result.status === 'no-token'
return { ok: false, ...(permanent ? { retry: false } : {}), error: transportError(found.server, result, 'raid base spawn') }
}
const data = result.data || {}
if (data.kind !== 'raid.queued') {
if (data.reason === 'rb-not-ready') {
return { ok: false, retry: true, retryAfterMs: NOT_READY_RETRY_MS, error: `${NOT_READY} on ${name}` }
}
const warned = Array.isArray(data.warnings) && data.warnings.length ? ` (also: ${data.warnings.join('; ')})` : ''
return {
ok: false,
...(PERMANENT.has(data.reason) ? { retry: false } : {}),
error: `${pluginError(data, `${name} did not spawn the raid base`)}${warned}`,
}
}
const grid = data.x !== undefined && data.z !== undefined ? await gridAt(found.server.id, data.x, data.z) : null
const warnings = Array.isArray(data.warnings) ? data.warnings.map(String) : []
const detail = {
route: String(data.route || ''),
...(data.difficulty ? { difficulty: String(data.difficulty) } : {}),
...(data.profile ? { profile: String(data.profile) } : {}),
...(data.base ? { base: String(data.base) } : {}),
...(grid ? { grid } : {}),
...(warnings.length ? { warnings } : {}),
...(data.repeat ? { repeat: true } : {}),
}
const notes = []
if (data.repeat) notes.push('answered from the first attempt; nothing new was spawned')
if (data.x === undefined) notes.push('RaidableBases picks the spot; the map shows it once the base opens')
if (data.despawns !== 'teardown') notes.push(OWN_TIMER)
// Each note after the first starts a sentence (W14).
if (notes.length) detail.note = notes.map((n, i) => (i ? n[0].toUpperCase() + n.slice(1) : n)).join('. ')
// Only a base the helper made the run's is a resource teardown can take back.
if (!data.id) return { ok: true, detail }
return {
ok: true,
detail,
resources: [
{
kind: OWNED_KIND,
ref: `${found.server.id}:${data.id}`,
payload: {
serverId: found.server.id,
what: 'raidbase',
...(data.base ? { name: String(data.base) } : {}),
...(data.difficulty ? { difficulty: String(data.difficulty) } : {}),
},
},
],
}
},
revert,
reconcile,
}
const ACTIONS = [raidSpawn]
module.exports = { BUDGETS, ACTIONS, OPTION_SOURCES, PERMANENT, NOT_READY, NOT_READY_RETRY_MS, OWN_TIMER, wireOf, missingOn, switchOf, needsHelper }