Merge edge into feat/title-conditions: §3's zone presets
Two conflicts, both additive: schema.sql and purge.sql keep both phases' tables. Swagger fragment (61 paths) and routes.manifest (67 routes) regenerated against the pinned core; 455 server and 58 client tests pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
196
server/model/zones/zoneOptions.js
Normal file
196
server/model/zones/zoneOptions.js
Normal file
@@ -0,0 +1,196 @@
|
||||
// ── 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 }
|
||||
142
server/model/zones/zones.db.js
Normal file
142
server/model/zones/zones.db.js
Normal file
@@ -0,0 +1,142 @@
|
||||
// ── SQL for zone presets, and what each server said about its zone mods ──────
|
||||
//
|
||||
// The presets are the module's own (PLAN_REDESIGNS §3.1, D195). What a server
|
||||
// has — its ZoneManager's flag list (D211), ZoneDomes and the domes helper (D194)
|
||||
// — is read out of the hello each server's state row keeps whole (`raw`), so the
|
||||
// preset form works while a server is off.
|
||||
|
||||
const core = require('../../core')
|
||||
|
||||
const PRESETS = 'rust_zone_presets'
|
||||
const PRESET_SERVERS = 'rust_zone_preset_servers'
|
||||
const SERVERS = 'rust_servers'
|
||||
const STATE = 'rust_server_state'
|
||||
|
||||
/** The driver hands JSON_EXTRACT and TEXT back as strings. A bad value is no value. */
|
||||
function json(value, fallback) {
|
||||
if (value === null || value === undefined) return fallback
|
||||
if (typeof value !== 'string') return value
|
||||
try {
|
||||
return JSON.parse(value)
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
function shape(row, serverIds) {
|
||||
return {
|
||||
id: Number(row.id),
|
||||
name: row.name,
|
||||
flags: json(row.flags, []),
|
||||
settings: json(row.settings, {}),
|
||||
allServers: Boolean(row.allServers),
|
||||
servers: serverIds,
|
||||
updatedAt: row.updatedAt ? new Date(row.updatedAt).toISOString() : null,
|
||||
}
|
||||
}
|
||||
|
||||
/** Every preset, with the servers each names, oldest first. */
|
||||
async function listPresets() {
|
||||
const [rows, links] = await Promise.all([
|
||||
core.query(
|
||||
`SELECT id, name, flags, settings, all_servers AS allServers, updated_at AS updatedAt
|
||||
FROM ${PRESETS} ORDER BY name ASC, id ASC`,
|
||||
),
|
||||
core.query(`SELECT preset_id AS presetId, server_id AS serverId FROM ${PRESET_SERVERS} ORDER BY server_id ASC`),
|
||||
])
|
||||
const by = new Map()
|
||||
for (const l of links) {
|
||||
const id = Number(l.presetId)
|
||||
if (!by.has(id)) by.set(id, [])
|
||||
by.get(id).push(l.serverId)
|
||||
}
|
||||
return rows.map((r) => shape(r, by.get(Number(r.id)) || []))
|
||||
}
|
||||
|
||||
async function getPreset(id) {
|
||||
const rows = await core.query(
|
||||
`SELECT id, name, flags, settings, all_servers AS allServers, updated_at AS updatedAt
|
||||
FROM ${PRESETS} WHERE id = ?`,
|
||||
[id],
|
||||
)
|
||||
if (!rows[0]) return null
|
||||
const links = await core.query(`SELECT server_id AS serverId FROM ${PRESET_SERVERS} WHERE preset_id = ? ORDER BY server_id ASC`, [id])
|
||||
return shape(rows[0], links.map((l) => l.serverId))
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes one preset and replaces its server list. The model has checked every
|
||||
* server exists first, so the inserts below have nothing left to refuse. The
|
||||
* list is written in one statement, so a preset is never left half-listed.
|
||||
*/
|
||||
async function savePreset({ id = null, name, flags, settings, allServers, servers }, userId = null) {
|
||||
let presetId = id
|
||||
if (presetId) {
|
||||
await core.query(
|
||||
`UPDATE ${PRESETS} SET name = ?, flags = ?, settings = ?, all_servers = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||
[name, JSON.stringify(flags), JSON.stringify(settings), allServers ? 1 : 0, presetId],
|
||||
)
|
||||
await core.query(`DELETE FROM ${PRESET_SERVERS} WHERE preset_id = ?`, [presetId])
|
||||
} else {
|
||||
const res = await core.query(
|
||||
`INSERT INTO ${PRESETS} (name, flags, settings, all_servers, created_by) VALUES (?, ?, ?, ?, ?)`,
|
||||
[name, JSON.stringify(flags), JSON.stringify(settings), allServers ? 1 : 0, userId],
|
||||
)
|
||||
presetId = Number(res.insertId)
|
||||
}
|
||||
if (!allServers && servers.length > 0) {
|
||||
await core.query(
|
||||
`INSERT INTO ${PRESET_SERVERS} (preset_id, server_id) VALUES ${servers.map(() => '(?, ?)').join(', ')}`,
|
||||
servers.flatMap((serverId) => [presetId, serverId]),
|
||||
)
|
||||
}
|
||||
return presetId
|
||||
}
|
||||
|
||||
async function deletePreset(id) {
|
||||
await core.query(`DELETE FROM ${PRESETS} WHERE id = ?`, [id])
|
||||
}
|
||||
|
||||
/**
|
||||
* Each configured server, in the operator's order, with what its last hello said
|
||||
* about ZoneManager (`{ version, flags }`) and ZoneDomes (`{ loaded, version,
|
||||
* helper, ready }`). Either is null for a server that never said: no ZoneManager,
|
||||
* a plugin before protocol 13, or a server never reached.
|
||||
*/
|
||||
async function listZoneServers() {
|
||||
const rows = await core.query(
|
||||
`SELECT s.id, s.name, s.enabled,
|
||||
JSON_EXTRACT(st.raw, '$.zoneManager') AS zoneManager,
|
||||
JSON_EXTRACT(st.raw, '$.integrations.zoneDomes') AS zoneDomes
|
||||
FROM ${SERVERS} s
|
||||
LEFT JOIN ${STATE} st ON st.server_id = s.id
|
||||
ORDER BY s.sort_order ASC, s.id ASC`,
|
||||
)
|
||||
return rows.map((r) => {
|
||||
const zm = json(r.zoneManager, null)
|
||||
const zd = json(r.zoneDomes, null)
|
||||
return {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
enabled: Boolean(r.enabled),
|
||||
zoneManager:
|
||||
zm && typeof zm === 'object'
|
||||
? {
|
||||
version: typeof zm.version === 'string' ? zm.version : null,
|
||||
flags: Array.isArray(zm.flags) ? zm.flags.filter((f) => typeof f === 'string') : null,
|
||||
}
|
||||
: null,
|
||||
zoneDomes:
|
||||
zd && typeof zd === 'object'
|
||||
? {
|
||||
loaded: zd.loaded === true,
|
||||
version: typeof zd.version === 'string' ? zd.version : null,
|
||||
helper: zd.helper && typeof zd.helper.state === 'string' ? zd.helper : null,
|
||||
ready: zd.ready === true,
|
||||
}
|
||||
: null,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = { PRESETS, PRESET_SERVERS, listPresets, getPreset, savePreset, deletePreset, listZoneServers }
|
||||
137
server/model/zones/zones.model.js
Normal file
137
server/model/zones/zones.model.js
Normal file
@@ -0,0 +1,137 @@
|
||||
// ── Zone presets (PLAN_REDESIGNS §3.1, D195, D210) ──────────────────────────
|
||||
//
|
||||
// An admin's own named set of ZoneManager flags and settings, for one server,
|
||||
// several, or the fleet. A zone step offers the presets as the options field's
|
||||
// dropdown; picking one COPIES its line into the step (D210), so a preset edited
|
||||
// later changes no published event. That is also why a preset needs no history
|
||||
// and deleting one is harmless.
|
||||
|
||||
const db = require('./zones.db')
|
||||
const options = require('./zoneOptions')
|
||||
|
||||
const NAME_MAX = 64
|
||||
|
||||
class ZonePresetError extends Error {
|
||||
constructor(message, status = 400) {
|
||||
super(message)
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The page's whole state: each server with what it said about its zone mods,
|
||||
* the flags the form draws (every server's, grouped), the settings, and the
|
||||
* presets.
|
||||
*/
|
||||
async function describe() {
|
||||
const [servers, presets] = await Promise.all([db.listZoneServers(), db.listPresets()])
|
||||
const union = new Map()
|
||||
for (const s of servers) {
|
||||
for (const f of (s.zoneManager && s.zoneManager.flags) || []) {
|
||||
if (!union.has(f.toLowerCase())) union.set(f.toLowerCase(), f)
|
||||
}
|
||||
}
|
||||
return {
|
||||
servers: servers.map((s) => ({
|
||||
...s,
|
||||
zoneManager: s.zoneManager ? { version: s.zoneManager.version, flagCount: (s.zoneManager.flags || []).length, flags: s.zoneManager.flags } : null,
|
||||
})),
|
||||
groups: options.grouped([...union.values()]),
|
||||
settings: Object.entries(options.SETTINGS).map(([key, spec]) => ({ key, ...spec })),
|
||||
presets: presets.map((p) => ({ ...p, line: options.format(p) })),
|
||||
}
|
||||
}
|
||||
|
||||
/** Does a preset cover this server? */
|
||||
function covers(preset, serverId) {
|
||||
return preset.allServers || preset.servers.includes(serverId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks a preset against every server it covers. A flag must be on each covered
|
||||
* server that has said which flags it has; a server that has not said (never
|
||||
* reached, no ZoneManager yet) is skipped, and the plugin checks the flag when a
|
||||
* step runs there.
|
||||
*/
|
||||
async function validate(input, id = null) {
|
||||
const name = String((input && input.name) || '').trim()
|
||||
if (!name || name.length > NAME_MAX) throw new ZonePresetError(`a preset's name is 1 to ${NAME_MAX} characters`)
|
||||
|
||||
const allServers = input.allServers === true
|
||||
const requested = Array.isArray(input.servers) ? [...new Set(input.servers.map(String))] : []
|
||||
if (!allServers && requested.length === 0) throw new ZonePresetError('a preset is for at least one server, or for every server')
|
||||
|
||||
const servers = await db.listZoneServers()
|
||||
const byId = new Map(servers.map((s) => [s.id, s]))
|
||||
for (const s of requested) {
|
||||
if (!byId.has(s)) throw new ZonePresetError(`no server "${s}"`, 404)
|
||||
}
|
||||
|
||||
const checked = options.check({ flags: input.flags || [], settings: input.settings || {} })
|
||||
if (!checked.ok) throw new ZonePresetError(checked.error)
|
||||
|
||||
const covered = allServers ? servers : requested.map((s) => byId.get(s))
|
||||
let flags = checked.flags
|
||||
for (const s of covered) {
|
||||
const known = s.zoneManager && s.zoneManager.flags
|
||||
if (!known || known.length === 0) continue
|
||||
const onServer = options.check({ flags }, known)
|
||||
if (!onServer.ok) throw new ZonePresetError(`${onServer.error.replace("this server's", `${s.name || s.id}'s`)}`)
|
||||
flags = onServer.flags
|
||||
}
|
||||
|
||||
// Two presets of one name on one server would be two rows in the step's
|
||||
// dropdown nobody could tell apart. Across servers it is allowed (D189).
|
||||
const mine = { allServers, servers: requested }
|
||||
const others = (await db.listPresets()).filter((p) => p.id !== id && p.name.toLowerCase() === name.toLowerCase())
|
||||
for (const other of others) {
|
||||
if (allServers && other.allServers) throw new ZonePresetError(`a preset called "${name}" is already on every server`, 409)
|
||||
const shared = servers.find((s) => covers(mine, s.id) && covers(other, s.id))
|
||||
if (shared) throw new ZonePresetError(`a preset called "${name}" is already on ${shared.name || shared.id}`, 409)
|
||||
}
|
||||
|
||||
return { name, flags, settings: checked.settings, allServers, servers: allServers ? [] : requested }
|
||||
}
|
||||
|
||||
async function create(input, userId = null) {
|
||||
const clean = await validate(input)
|
||||
const id = await db.savePreset(clean, userId)
|
||||
return db.getPreset(id)
|
||||
}
|
||||
|
||||
async function update(id, input) {
|
||||
const existing = await db.getPreset(id)
|
||||
if (!existing) throw new ZonePresetError('no such preset', 404)
|
||||
const clean = await validate(input, existing.id)
|
||||
await db.savePreset({ ...clean, id: existing.id })
|
||||
return db.getPreset(existing.id)
|
||||
}
|
||||
|
||||
async function remove(id) {
|
||||
const existing = await db.getPreset(id)
|
||||
if (!existing) throw new ZonePresetError('no such preset', 404)
|
||||
await db.deletePreset(existing.id)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* The zone step's dropdown (D210): one row per preset, whose VALUE is the line
|
||||
* of text the step keeps. Grouped by the servers each is for, so an admin
|
||||
* authoring a step for one server can see which presets were made for it.
|
||||
*/
|
||||
async function optionRows() {
|
||||
const [presets, servers] = await Promise.all([db.listPresets(), db.listZoneServers()])
|
||||
const nameOf = new Map(servers.map((s) => [s.id, s.name || s.id]))
|
||||
return presets.map((p) => ({
|
||||
value: options.format(p),
|
||||
label: p.name,
|
||||
group: p.allServers ? 'Every server' : p.servers.map((s) => nameOf.get(s) || s).join(', '),
|
||||
}))
|
||||
}
|
||||
|
||||
/** What one server's last hello said about ZoneManager and ZoneDomes, or null. */
|
||||
async function serverZoneMods(serverId) {
|
||||
return (await db.listZoneServers()).find((s) => s.id === serverId) || null
|
||||
}
|
||||
|
||||
module.exports = { ZonePresetError, describe, create, update, remove, optionRows, serverZoneMods, covers }
|
||||
Reference in New Issue
Block a user