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
197 lines
8.8 KiB
JavaScript
197 lines
8.8 KiB
JavaScript
// ── 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 }
|