feat(rust): zone presets, and the zone step's options, messages and dome (PLAN_REDESIGNS §3, D210)

Admin → Rust zone presets: named sets of ZoneManager flags and settings for
one server, several, or every server (D195), ticked in groups read from each
server's own ZoneManager list in its last hello (D211). A flag not on a
covered server is refused on save with the server named; two presets of one
name may not share a server.

rust.zone.open gains options (one line, NoBuild, radiation=10), enterMessage,
leaveMessage, delivery, dome and domeStack. The options field's dropdown is
rust.options.zone_presets, whose row VALUE is the preset's line, so picking
one copies it into the step (D210) and no published event changes when a
preset does. The line and the dome are checked against the server's hello
on save and in a dry run; bad-option and dome-unavailable are permanent.

Schema: rust_zone_presets, rust_zone_preset_servers. Swagger fragment and
routes.manifest regenerated against the pinned core f0e7d2a.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-09-29 05:25:44 -05:00
parent afb16112b0
commit 8361b19c24
17 changed files with 2159 additions and 1 deletions

View File

@@ -27,6 +27,9 @@
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 { serverFor, transportError, pluginError, perServer, bounded } = require('./eventLeases')
const log = core.logger('world')
@@ -50,6 +53,21 @@ 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). The colours
* show only where the sphere meets terrain or a structure; ZoneDomes says so
* itself.
*/
const DOMES = [
{ value: 'standard', type: 0, label: 'Standard (shaded)' },
{ value: 'red', type: 1, label: 'Red' },
{ value: 'blue', type: 2, label: 'Blue' },
{ value: 'green', type: 3, label: 'Green' },
{ value: 'purple', type: 4, label: 'Purple' },
]
/** The ledger kind both verbs file under. */
const OWNED_KIND = 'world'
@@ -86,6 +104,10 @@ const PERMANENT = new Set([
'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',
])
const BUDGETS = [
@@ -109,6 +131,21 @@ const BUDGETS = [
},
]
/**
* 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
@@ -518,6 +555,34 @@ const ACTIONS = [
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, by colour. 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 }) {
@@ -537,11 +602,50 @@ const ACTIONS = [
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,
@@ -552,6 +656,12 @@ const ACTIONS = [
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',
)
@@ -615,6 +725,26 @@ const OPTION_SOURCES = [
return PLACEABLE.filter((p) => p.kind === kind).map((p) => ({ value: p.key, label: p.label }))
},
})),
{
// 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) ───────────────────────────────────────────────────────