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:
@@ -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) ───────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user