Files
Module-Rust/server/eventRaidableBases.js
wtclaude 481837e937
All checks were successful
PR Checks / server-tests (pull_request) Successful in 37s
PR Checks / client-build (pull_request) Successful in 24s
PR Checks / frozen-manifest (pull_request) Successful in 49s
feat(rust): RaidableBases — feed, raids map layer, Warlord, Spawn a raid base, profiles (PLAN_REDESIGNS §11.4)
PLAN_REDESIGNS §11.4 (D202-D208, D386-D391), protocol 14. Spec: docs
rust-link/PROTOCOL.md §20.12.

- raid.started, raid.completed, raid.purchased and raid.locked public,
  raid.ended staff's; raidableBases.js reads each row per viewer (raiders'
  and a buyer's names only for the presence audience, D376/D390; Steam ids,
  position and a run's key only for staff). Feed lines in the World filter.
- The step rust.raidbase.spawn, "Spawn a raid base" (ledgered, budget
  rust.raids): difficulty, profile and base from option sources listing each
  server's own names (D386), a spot as a world call's location with a height
  adjustment or an exact height, and PvP/NPCs/lock as on/off/blank. A base
  the raids helper made is a world resource, so eventWorld's revert and
  reconcile serve it and world.expired marks it expired; one it did not make
  records nothing and the run log says it despawns on its own timer. Refused
  at verify for a server that said it cannot; a dry run with a spot returns
  the spot check as detail.warnings (MODULE_API 1.16.0, D391). rb-not-ready
  retries in ten minutes.
- Warlord: raids_completed, title category raidscompleted, the leaderboard's
  Raid bases column (sort=raidsCompleted).
- The map's raids layer (D114), public by default with its own audience; the
  run's key is dropped before any viewer gets it.
- Server settings' line for RaidableBases; its profiles in Mod configuration
  under a virtual path (D205).
- Swagger fragment, bundle and README.

Tests: server 684/684 (raidableBases.test.js new, every name in it invented);
client 99/99, build and externals.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-16 15:18:52 -05:00

454 lines
22 KiB
JavaScript
Raw 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 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, label, servers: [] })
if (!out.get(key).servers.includes(server)) out.get(key).servers.push(server)
}
return [...out.values()].map((e) => ({ value: e.value, label: `${e.label} — ${e.servers.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
const report = reportOf(await serversDb.rbReportFor(found.server.id).catch(() => null))
const missing = missingOn(report, name, parsed.wire, parsed.spot)
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)
if (notes.length) detail.note = notes.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 }