Files
Module-Rust/server/model/zones/zoneOptions.js
wtclaude 8361b19c24 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
2026-09-29 05:25:44 -05:00

197 lines
8.8 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.

// ── A zone's ZoneManager options (PLAN_REDESIGNS §3.1, D166, D210, D211) ──────
//
// ZoneManager's flags are whatever the installed version declares, and the
// plugin reads the list from it and sends it in hello (D211). This file does not
// hold the list. It holds two things the list lacks:
//
// * **How the form groups them.** Seven groups a Rust admin recognises, and
// **Other** for a flag this file does not know (a newer ZoneManager), so a
// flag is never hidden because this file is out of date.
// * **The text a step carries them in.** Core's step editor has single-value
// fields only, so a zone step's flags and settings are one line of text:
// `NoBuild, NoPlayerLoot, radiation=10`. A preset's option row puts that line
// into the field when it is picked (D210), and the step keeps its own copy.
//
// The settings mirror the plugin's allowlist and bounds. The plugin's are the
// ones that hold; these make a bad step a refusal on the authoring form rather
// than at four in the morning.
const GROUPS = ['Vehicles', 'Combat', 'Building', 'Loot', 'NPCs', 'Communication', 'Survival', 'Other']
/** ZoneManager 3.1.14's flags, lower-cased, by group. A flag not here is Other. */
const GROUP_OF = Object.freeze(
Object.fromEntries(
Object.entries({
Vehicles: ['KeepVehiclesIn', 'KeepVehiclesOut', 'NoVehicleMounting', 'NoVehicleDismounting'],
Combat: [
'PvpGod', 'PveGod', 'SleepGod', 'NoPve', 'NoFallDamage', 'NoWounded', 'NoSuicide', 'NoCorpse',
'NoHeliTargeting', 'NoTurretTargeting', 'NoAPCTargeting', 'InfiniteTrapAmmo',
'Eject', 'EjectSleepers', 'Kill', 'KillSleepers',
],
Building: [
'NoBuild', 'NoDeploy', 'NoUpgrade', 'NoStability', 'NoDecay', 'NoRemove', 'UnDestr', 'NoBuildingDamage',
'NoSprays', 'NoSignUpdates', 'NoDoorAccess', 'NoCup', 'NoOvenToggle', 'PoweredSwitches', 'AutoLights', 'AlwaysLights',
],
Loot: [
'LootSelf', 'NoPlayerLoot', 'NoNPCLoot', 'NoBoxLoot', 'NoPickup', 'NoEntityPickup', 'NoCollect', 'NoGather',
'NoDrop', 'NoLootSpawns', 'NoStash', 'NoCraft', 'NoKits', 'NoTrade', 'NoShop', 'NoVending',
],
NPCs: ['NoNPCTargeting', 'NpcFreeze', 'NoNPCSpawns'],
Communication: ['NoChat', 'NoVoice'],
Survival: ['NoPoison', 'NoStarvation', 'NoThirst', 'NoRadiation', 'NoDrown', 'NoBleed'],
}).flatMap(([group, names]) => names.map((n) => [n.toLowerCase(), group])),
),
)
/** A line for the flags whose names do not say what they do. */
const NOTES = Object.freeze({
undestr: 'Nothing in the zone can be damaged.',
lootself: 'A player may loot only what is theirs.',
eject: 'Players without the zone permission are pushed out.',
kill: 'Players without the zone permission are killed on entry.',
poweredswitches: 'Switches in the zone are powered.',
keepvehiclesin: 'Vehicles cannot leave the zone.',
keepvehiclesout: 'Vehicles cannot enter the zone.',
pvegod: 'Players take no damage from NPCs and animals.',
pvpgod: 'Players take no damage from other players.',
nopve: 'Players cannot damage NPCs and animals.',
})
/**
* The settings a zone may carry, with the plugin's bounds. `permission` is the
* name ZoneManager registers as `zonemanager.<name>`: who may enter a zone that
* ejects or kills.
*/
const SETTINGS = Object.freeze({
radiation: { type: 'float', min: 0, max: 500, label: 'Radiation', description: 'Radiation inside the zone. 0 is none.' },
comfort: { type: 'float', min: 0, max: 1, label: 'Comfort', description: 'Comfort inside the zone, 0 to 1.' },
temperature: { type: 'float', min: -100, max: 100, label: 'Temperature', description: 'Temperature inside the zone, in °C. 0 leaves the world’s.' },
safezone: { type: 'boolean', label: 'Safe zone', description: 'A safe zone, as the game’s outposts are.' },
permission: { type: 'permission', label: 'Permission', description: 'Who may enter a zone that ejects or kills: zonemanager.<name>.' },
})
const PERMISSION_RE = /^[a-z0-9_]{1,32}$/
const MAX_FLAGS = 80
/** The group a flag is drawn under. */
function groupOf(flag) {
return GROUP_OF[String(flag || '').toLowerCase()] || 'Other'
}
/**
* A server's flag list as the form draws it: every group in order, each with
* its flags sorted, and empty groups left out.
*/
function grouped(flags) {
const by = new Map(GROUPS.map((g) => [g, []]))
for (const f of flags || []) {
if (typeof f !== 'string' || !f) continue
by.get(groupOf(f)).push({ name: f, ...(NOTES[f.toLowerCase()] ? { note: NOTES[f.toLowerCase()] } : {}) })
}
return GROUPS.map((group) => ({ group, flags: by.get(group).sort((a, b) => a.name.localeCompare(b.name)) })).filter(
(g) => g.flags.length > 0,
)
}
/**
* One setting's value, checked and written as the plugin reads it. Returns
* `{ ok, value }` or `{ ok: false, error }`.
*/
function settingValue(key, raw) {
const spec = SETTINGS[key]
if (!spec) return { ok: false, error: `"${key}" is not a zone setting (${Object.keys(SETTINGS).join(', ')})` }
if (spec.type === 'boolean') {
const text = String(raw).trim().toLowerCase()
if (raw === true || text === 'true') return { ok: true, value: true }
if (raw === false || text === 'false') return { ok: true, value: false }
return { ok: false, error: `${key} is true or false, not "${raw}"` }
}
if (spec.type === 'permission') {
const name = String(raw || '').trim().toLowerCase().replace(/^zonemanager\./, '')
if (!PERMISSION_RE.test(name)) return { ok: false, error: `a zone's permission is 1 to 32 letters, digits or _, not "${raw}"` }
return { ok: true, value: name }
}
const value = typeof raw === 'number' ? raw : Number(String(raw).trim())
if (String(raw).trim() === '' || !Number.isFinite(value) || value < spec.min || value > spec.max) {
return { ok: false, error: `${key} is ${spec.min} to ${spec.max}, not "${raw}"` }
}
return { ok: true, value }
}
/**
* Checks a set of flags and settings. `known` is the server's own flag list,
* when there is one: each flag is then spelled as that ZoneManager spells it,
* and an unknown one is refused. Without it (a server that has not said), the
* flags are kept as written and the plugin checks them.
*/
function check({ flags = [], settings = {} } = {}, known = null) {
if (!Array.isArray(flags)) return { ok: false, error: 'flags are a list of names' }
if (flags.length > MAX_FLAGS) return { ok: false, error: `a zone takes at most ${MAX_FLAGS} flags, not ${flags.length}` }
if (!settings || typeof settings !== 'object' || Array.isArray(settings)) {
return { ok: false, error: 'settings are an object of name and value' }
}
const spelled = known ? new Map(known.map((k) => [k.toLowerCase(), k])) : null
const outFlags = []
for (const raw of flags) {
const name = String(raw || '').trim()
if (!/^[A-Za-z0-9_]{1,40}$/.test(name)) return { ok: false, error: `"${raw}" is not a flag name` }
const flag = spelled ? spelled.get(name.toLowerCase()) : name
if (!flag) return { ok: false, error: `"${name}" is not a flag this server's ZoneManager has` }
if (!outFlags.some((f) => f.toLowerCase() === flag.toLowerCase())) outFlags.push(flag)
}
const outSettings = {}
for (const [rawKey, raw] of Object.entries(settings)) {
const key = rawKey.toLowerCase()
const one = settingValue(key, raw)
if (!one.ok) return one
outSettings[key] = one.value
}
return { ok: true, flags: outFlags, settings: outSettings }
}
/**
* The line of text a step carries: flags first, then settings, as
* `NoBuild, NoPlayerLoot, radiation=10`. The inverse of `parse`.
*/
function format({ flags = [], settings = {} } = {}) {
const parts = [...flags]
for (const key of Object.keys(SETTINGS)) {
if (settings[key] !== undefined && settings[key] !== null) parts.push(`${key}=${settings[key]}`)
}
return parts.join(', ')
}
/**
* Reads a step's options line. Items are separated by commas or spaces; one with
* `=` is a setting, anything else a flag. Blank is no options. Checked the same
* way `check` checks a preset.
*/
function parse(text, known = null) {
const line = String(text === undefined || text === null ? '' : text).trim()
if (!line) return { ok: true, flags: [], settings: {} }
if (line.length > 2000) return { ok: false, error: 'a zone’s options are at most 2000 characters' }
const flags = []
const settings = {}
for (const item of line.split(/[\s,]+/).filter(Boolean)) {
const at = item.indexOf('=')
if (at < 0) {
flags.push(item)
continue
}
const key = item.slice(0, at).trim().toLowerCase()
if (Object.prototype.hasOwnProperty.call(settings, key)) return { ok: false, error: `${key} is given twice` }
settings[key] = item.slice(at + 1).trim()
}
return check({ flags, settings }, known)
}
module.exports = { GROUPS, SETTINGS, NOTES, groupOf, grouped, check, parse, format, settingValue, MAX_FLAGS }