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:
@@ -205,6 +205,18 @@ export const adminVisibility = {
|
||||
save: (body) => req('/admin/rust/visibility', { method: 'PUT', body }),
|
||||
}
|
||||
|
||||
// ── admin · zone presets (PLAN_REDESIGNS §3.1, D210) ───────────────────────
|
||||
//
|
||||
// An admin's named sets of ZoneManager flags and settings. `read` also carries
|
||||
// what each server's last hello said about ZoneManager and ZoneDomes, so the
|
||||
// page works while a server is off.
|
||||
export const adminZones = {
|
||||
read: () => req('/admin/rust/zones'),
|
||||
create: (body) => req('/admin/rust/zones/presets', { method: 'POST', body }),
|
||||
update: (id, body) => req(`/admin/rust/zones/presets/${encodeURIComponent(id)}`, { method: 'PUT', body }),
|
||||
remove: (id) => req(`/admin/rust/zones/presets/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
}
|
||||
|
||||
// ── admin · mod configuration (R18) ───────────────────────────────────────
|
||||
//
|
||||
// Every call here is a LIVE round trip to a game host, which makes this the only
|
||||
@@ -285,6 +297,7 @@ export default {
|
||||
adminPermissions,
|
||||
adminConfig,
|
||||
adminVisibility,
|
||||
adminZones,
|
||||
adminUserLinks,
|
||||
adminUserPermissions,
|
||||
BASE,
|
||||
|
||||
@@ -26,9 +26,10 @@ import Permissions from './routes/admin/Permissions.jsx'
|
||||
import ModConfig from './routes/admin/ModConfig.jsx'
|
||||
import Visibility from './routes/admin/Visibility.jsx'
|
||||
import ServerSettings from './routes/admin/ServerSettings.jsx'
|
||||
import ZonePresets from './routes/admin/ZonePresets.jsx'
|
||||
import UserRustSections from './routes/admin/UserRustSections.jsx'
|
||||
import FooterStatus from './components/FooterStatus.jsx'
|
||||
import { IconEye, IconKey, IconLink, IconServer, IconSliders } from './icons.jsx'
|
||||
import { IconEye, IconKey, IconLink, IconServer, IconSliders, IconZone } from './icons.jsx'
|
||||
|
||||
// The module id, exactly as `module.json` spells it. Core keys the registry by it
|
||||
// and prefixes every route path with it.
|
||||
@@ -109,6 +110,9 @@ registry.registerRoutes(ID, {
|
||||
// it, a server row was written only through the API, and D130's wipe
|
||||
// schedule needed somewhere to be typed.
|
||||
{ path: 'servers', element: <ServerSettings /> },
|
||||
// Zone presets (PLAN_REDESIGNS §3.1, D210). Core's step editor has single-
|
||||
// value fields, so a zone's flags are ticked here and a step copies the set.
|
||||
{ path: 'zones', element: <ZonePresets /> },
|
||||
],
|
||||
})
|
||||
|
||||
@@ -159,6 +163,7 @@ registry.registerNav(ID, {
|
||||
{ label: 'Rust mod config', to: '/admin/rust/config', icon: IconSliders },
|
||||
{ label: 'Rust visibility', to: '/admin/rust/visibility', icon: IconEye },
|
||||
{ label: 'Rust servers', to: '/admin/rust/servers', icon: IconServer },
|
||||
{ label: 'Rust zone presets', to: '/admin/rust/zones', icon: IconZone },
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@@ -111,3 +111,16 @@ export const IconServer = () => (
|
||||
)
|
||||
|
||||
export default { IconLink, IconKey, IconSliders, IconEye, IconServer }
|
||||
|
||||
/**
|
||||
* A circle inside a dashed ring — the admin sidebar's row for zone presets.
|
||||
*
|
||||
* A zone is an area with rules at its edge, so the glyph is a place and its
|
||||
* boundary rather than a map pin, which the live map already means.
|
||||
*/
|
||||
export const IconZone = () => (
|
||||
<Icon>
|
||||
<circle cx="12" cy="12" r="9" strokeDasharray="3 3" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</Icon>
|
||||
)
|
||||
|
||||
294
client/src/routes/admin/ZonePresets.jsx
Normal file
294
client/src/routes/admin/ZonePresets.jsx
Normal file
@@ -0,0 +1,294 @@
|
||||
// ── Admin · Rust · Zone presets (PLAN_REDESIGNS §3.1, D195, D210) ──────────
|
||||
//
|
||||
// Where a zone's ZoneManager flags and settings are chosen. Core's step editor
|
||||
// has single-value fields only, so the org lead put the checkboxes here (D210):
|
||||
// an admin saves a named set for one server, several, or every server, and a
|
||||
// zone step's Options field offers the presets. Picking one COPIES its line into
|
||||
// the step, so editing a preset here changes no published event.
|
||||
//
|
||||
// The flags are whatever each server's ZoneManager declared in its last hello
|
||||
// (D211), grouped by `server/model/zones/zoneOptions.js`. A flag that file does
|
||||
// not know is under Other, never hidden. A flag not on every server a preset
|
||||
// covers is refused on save, with the server named.
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import { ErrorState, Loading, useAsync } from '../../core.js'
|
||||
import api from '../../api.js'
|
||||
|
||||
const inputStyle = {
|
||||
background: 'var(--panel-flat, transparent)',
|
||||
color: 'var(--text)',
|
||||
border: '1px solid var(--line)',
|
||||
borderRadius: 6,
|
||||
padding: '6px 8px',
|
||||
font: 'inherit',
|
||||
}
|
||||
|
||||
function blankForm() {
|
||||
return { id: null, name: '', flags: [], settings: {}, allServers: false, servers: [] }
|
||||
}
|
||||
|
||||
function formFrom(p) {
|
||||
const settings = {}
|
||||
for (const [k, v] of Object.entries(p.settings || {})) settings[k] = String(v)
|
||||
return { id: p.id, name: p.name, flags: [...p.flags], settings, allServers: p.allServers, servers: [...p.servers] }
|
||||
}
|
||||
|
||||
/** What the page sends: blank settings left out, the rest as typed (the server checks them). */
|
||||
function bodyFrom(form) {
|
||||
const settings = {}
|
||||
for (const [k, v] of Object.entries(form.settings)) {
|
||||
if (v !== '' && v !== undefined && v !== null) settings[k] = v
|
||||
}
|
||||
return { name: form.name.trim(), flags: form.flags, settings, allServers: form.allServers, servers: form.allServers ? [] : form.servers }
|
||||
}
|
||||
|
||||
/** One line on what a server said about its zone mods. */
|
||||
export function zoneModsLine(s) {
|
||||
const zm = s.zoneManager
|
||||
const zd = s.zoneDomes
|
||||
const parts = [zm ? `ZoneManager ${zm.version || ''} · ${zm.flagCount} flags`.replace(' ', ' ') : 'no ZoneManager reported']
|
||||
if (!zd || !zd.loaded) parts.push('no ZoneDomes, so no domes')
|
||||
else if (!zd.helper || zd.helper.state === 'missing') parts.push('ZoneDomes without RunicGatewayDomes.cs, so no domes')
|
||||
else if (zd.helper.state !== 'patched') parts.push(`the domes helper could not patch ZoneDomes${zd.helper.reason ? ` (${zd.helper.reason})` : ''}`)
|
||||
else parts.push('domes available')
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
export default function ZonePresets() {
|
||||
const [reloads, setReloads] = useState(0)
|
||||
const { data, error: loadError } = useAsync(() => api.adminZones.read(), [reloads])
|
||||
const [form, setForm] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
if (loadError) return <ErrorState error={loadError} />
|
||||
if (!data) return <Loading />
|
||||
|
||||
const servers = data.servers || []
|
||||
const nameOf = new Map(servers.map((s) => [s.id, s.name || s.id]))
|
||||
|
||||
const save = async (e) => {
|
||||
e.preventDefault()
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
if (form.id) await api.adminZones.update(form.id, bodyFrom(form))
|
||||
else await api.adminZones.create(bodyFrom(form))
|
||||
setForm(null)
|
||||
setReloads((n) => n + 1)
|
||||
} catch (err) {
|
||||
setError(err.message || 'That did not save.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const remove = async (preset) => {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await api.adminZones.remove(preset.id)
|
||||
setForm(null)
|
||||
setReloads((n) => n + 1)
|
||||
} catch (err) {
|
||||
setError(err.message || 'That did not delete.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 900 }}>
|
||||
<p className="sans dim" style={{ fontSize: '0.82rem', marginTop: 0 }}>
|
||||
Named sets of ZoneManager flags and settings. An event’s <em>Open a zone</em> step offers these in its Options
|
||||
field; picking one copies it into the step, so changing a preset later does not change an event already
|
||||
published. Who is exempt from a flag is a <code>zonemanager.ignoreflag.*</code> permission, set on the{' '}
|
||||
<Link to="/admin/rust">permissions page</Link>.
|
||||
</p>
|
||||
|
||||
<section className="panel" style={{ padding: '14px 18px', marginBottom: 18 }}>
|
||||
<h2 className="display" style={{ fontSize: '1rem', margin: '0 0 8px', color: 'var(--head)' }}>Servers</h2>
|
||||
{servers.length === 0 && <p className="sans dim" style={{ fontSize: '0.82rem', margin: 0 }}>No servers are configured yet.</p>}
|
||||
{servers.map((s) => (
|
||||
<div key={s.id} className="sans" style={{ fontSize: '0.8rem', padding: '4px 0' }}>
|
||||
<strong style={{ color: 'var(--head)' }}>{s.name || s.id}</strong>{' '}
|
||||
<span className="dim">{zoneModsLine(s)}</span>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="panel" style={{ padding: '14px 18px', marginBottom: 18 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10 }}>
|
||||
<h2 className="display" style={{ fontSize: '1rem', margin: 0, color: 'var(--head)' }}>Presets</h2>
|
||||
<button type="button" className="btn" style={{ marginLeft: 'auto' }} onClick={() => { setError(''); setForm(blankForm()) }}>
|
||||
New preset
|
||||
</button>
|
||||
</div>
|
||||
{(data.presets || []).length === 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.82rem', margin: '8px 0 0' }}>
|
||||
None yet. None ship with the module: what an arena is differs by server.
|
||||
</p>
|
||||
)}
|
||||
{(data.presets || []).map((p) => (
|
||||
<div key={p.id} className="sans" style={{ borderTop: '1px solid var(--line-soft)', padding: '8px 0', fontSize: '0.84rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10, flexWrap: 'wrap' }}>
|
||||
<strong style={{ color: 'var(--head)' }}>{p.name}</strong>
|
||||
<span className="dim" style={{ fontSize: '0.76rem' }}>
|
||||
{p.allServers ? 'every server' : p.servers.map((id) => nameOf.get(id) || id).join(', ')}
|
||||
</span>
|
||||
<button type="button" className="btn" style={{ marginLeft: 'auto' }} onClick={() => { setError(''); setForm(formFrom(p)) }}>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
<code className="dim" style={{ fontSize: '0.74rem', wordBreak: 'break-word' }}>{p.line || 'no flags or settings'}</code>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
{form && (
|
||||
<PresetForm
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
data={data}
|
||||
busy={busy}
|
||||
error={error}
|
||||
onSave={save}
|
||||
onCancel={() => setForm(null)}
|
||||
onDelete={form.id ? () => remove(form) : null}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PresetForm({ form, setForm, data, busy, error, onSave, onCancel, onDelete }) {
|
||||
const servers = data.servers || []
|
||||
|
||||
// The flags on EVERY covered server that reported a list: a flag missing from
|
||||
// one of them is shown but marked, and the save refuses it with the server named.
|
||||
const covered = form.allServers ? servers : servers.filter((s) => form.servers.includes(s.id))
|
||||
const missingOn = useMemo(() => {
|
||||
const out = new Map()
|
||||
for (const s of covered) {
|
||||
const flags = s.zoneManager && s.zoneManager.flags
|
||||
if (!flags || flags.length === 0) continue
|
||||
const have = new Set(flags.map((f) => f.toLowerCase()))
|
||||
for (const g of data.groups || []) {
|
||||
for (const f of g.flags) {
|
||||
if (!have.has(f.name.toLowerCase())) out.set(f.name, [...(out.get(f.name) || []), s.name || s.id])
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}, [covered, data.groups])
|
||||
|
||||
const toggleFlag = (name) =>
|
||||
setForm((f) => ({ ...f, flags: f.flags.includes(name) ? f.flags.filter((x) => x !== name) : [...f.flags, name] }))
|
||||
const toggleServer = (id) =>
|
||||
setForm((f) => ({ ...f, servers: f.servers.includes(id) ? f.servers.filter((x) => x !== id) : [...f.servers, id] }))
|
||||
const setSetting = (key, value) => setForm((f) => ({ ...f, settings: { ...f.settings, [key]: value } }))
|
||||
|
||||
return (
|
||||
<form onSubmit={onSave} className="panel" style={{ padding: '16px 18px', display: 'grid', gap: 14 }}>
|
||||
<h2 className="display" style={{ fontSize: '1.05rem', margin: 0, color: 'var(--head)' }}>
|
||||
{form.id ? `Edit ${form.name}` : 'A new preset'}
|
||||
</h2>
|
||||
|
||||
<label className="sans" style={{ display: 'grid', gap: 4, fontSize: '0.84rem' }}>
|
||||
<span style={{ color: 'var(--head)' }}>Name</span>
|
||||
<input value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} required maxLength={64} style={inputStyle} />
|
||||
</label>
|
||||
|
||||
<fieldset style={{ border: '1px solid var(--line-soft)', borderRadius: 8, padding: '10px 14px', display: 'grid', gap: 6 }}>
|
||||
<legend className="sans" style={{ color: 'var(--head)', fontSize: '0.86rem', padding: '0 6px' }}>For</legend>
|
||||
<label className="sans" style={{ display: 'flex', gap: 8, fontSize: '0.84rem' }}>
|
||||
<input type="checkbox" checked={form.allServers} onChange={(e) => setForm((f) => ({ ...f, allServers: e.target.checked }))} />
|
||||
Every server, including servers added later
|
||||
</label>
|
||||
{!form.allServers &&
|
||||
servers.map((s) => (
|
||||
<label key={s.id} className="sans" style={{ display: 'flex', gap: 8, fontSize: '0.84rem' }}>
|
||||
<input type="checkbox" checked={form.servers.includes(s.id)} onChange={() => toggleServer(s.id)} />
|
||||
{s.name || s.id}
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
|
||||
<fieldset style={{ border: '1px solid var(--line-soft)', borderRadius: 8, padding: '10px 14px', display: 'grid', gap: 12 }}>
|
||||
<legend className="sans" style={{ color: 'var(--head)', fontSize: '0.86rem', padding: '0 6px' }}>Flags</legend>
|
||||
{(data.groups || []).length === 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.8rem', margin: 0 }}>
|
||||
No server has reported its ZoneManager flags yet. They arrive when a server with ZoneManager connects.
|
||||
</p>
|
||||
)}
|
||||
{(data.groups || []).map((g) => (
|
||||
<div key={g.group}>
|
||||
<div className="sans" style={{ fontSize: '0.8rem', color: 'var(--head)', marginBottom: 4 }}>{g.group}</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(190px, 1fr))', gap: '4px 12px' }}>
|
||||
{g.flags.map((f) => {
|
||||
const missing = missingOn.get(f.name)
|
||||
return (
|
||||
<label
|
||||
key={f.name}
|
||||
className="sans"
|
||||
title={[f.note, missing ? `Not on ${missing.join(', ')}` : null].filter(Boolean).join(' ')}
|
||||
style={{ display: 'flex', gap: 6, fontSize: '0.8rem', opacity: missing ? 0.55 : 1 }}
|
||||
>
|
||||
<input type="checkbox" checked={form.flags.includes(f.name)} onChange={() => toggleFlag(f.name)} />
|
||||
{f.name}
|
||||
{missing && <span className="dim">*</span>}
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{missingOn.size > 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.74rem', margin: 0 }}>
|
||||
* not on every server this preset is for. Hover for which.
|
||||
</p>
|
||||
)}
|
||||
</fieldset>
|
||||
|
||||
<fieldset style={{ border: '1px solid var(--line-soft)', borderRadius: 8, padding: '10px 14px', display: 'grid', gap: 10 }}>
|
||||
<legend className="sans" style={{ color: 'var(--head)', fontSize: '0.86rem', padding: '0 6px' }}>Settings</legend>
|
||||
{(data.settings || []).map((s) => (
|
||||
<label key={s.key} className="sans" style={{ display: 'grid', gap: 4, fontSize: '0.84rem' }}>
|
||||
<span style={{ color: 'var(--head)' }}>{s.label}</span>
|
||||
{s.type === 'boolean' ? (
|
||||
<select value={form.settings[s.key] ?? ''} onChange={(e) => setSetting(s.key, e.target.value)} style={inputStyle}>
|
||||
<option value="">Not set</option>
|
||||
<option value="true">Yes</option>
|
||||
<option value="false">No</option>
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
value={form.settings[s.key] ?? ''}
|
||||
onChange={(e) => setSetting(s.key, e.target.value)}
|
||||
inputMode={s.type === 'float' ? 'decimal' : undefined}
|
||||
placeholder={s.type === 'float' ? `${s.min} to ${s.max}` : ''}
|
||||
style={{ ...inputStyle, maxWidth: 260 }}
|
||||
/>
|
||||
)}
|
||||
<span className="dim" style={{ fontSize: '0.74rem' }}>{s.description}</span>
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
|
||||
{error && <p className="sans" style={{ color: 'var(--danger, #d98b84)', fontSize: '0.84rem', margin: 0 }}>{error}</p>}
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<button type="submit" className="btn" disabled={busy}>{busy ? 'Saving…' : 'Save'}</button>
|
||||
<button type="button" className="btn" onClick={onCancel} disabled={busy}>Cancel</button>
|
||||
{onDelete && (
|
||||
<button type="button" className="btn" style={{ marginLeft: 'auto' }} onClick={onDelete} disabled={busy}>
|
||||
Delete preset
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -16,6 +16,11 @@
|
||||
"path": "/api/v1/admin/rust/servers/:id",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/rust/zones/presets/:id",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/api/v1/admin/users/:id/rust/links/:steamId",
|
||||
@@ -91,6 +96,11 @@
|
||||
"path": "/api/v1/admin/rust/voice",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/rust/zones",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/users/:id/rust/links",
|
||||
@@ -261,6 +271,11 @@
|
||||
"path": "/api/v1/admin/rust/servers/:id/test",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/rust/zones/presets",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/admin/users/:id/rust/permissions/grants",
|
||||
@@ -305,6 +320,11 @@
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/rust/voice",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/rust/zones/presets/:id",
|
||||
"tier": "public"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -19,6 +19,10 @@
|
||||
-- it knows this module registered, because it is the side that knows which
|
||||
-- registrant owned what.
|
||||
|
||||
-- Zone presets (PLAN_REDESIGNS §3.1). The server list before its preset.
|
||||
DROP TABLE IF EXISTS rust_zone_preset_servers;
|
||||
DROP TABLE IF EXISTS rust_zone_presets;
|
||||
|
||||
-- The permission manager, rebuilt (PLAN_REDESIGNS §1). Children before
|
||||
-- `rust_permgroups`, which they reference.
|
||||
DROP TABLE IF EXISTS rust_perm_exceptions;
|
||||
|
||||
@@ -1163,3 +1163,41 @@ ALTER TABLE rust_servers ADD COLUMN IF NOT EXISTS perm_policy VARCHAR(16) NOT NU
|
||||
-- `split` (D190 gave a server its own copy of a shared group, and says so in
|
||||
-- case the change was meant for every server).
|
||||
ALTER TABLE rust_perm_drift ADD COLUMN IF NOT EXISTS direction VARCHAR(16) NOT NULL DEFAULT 'added';
|
||||
|
||||
-- ── Zone presets (PLAN_REDESIGNS §3.1, D195, D210) ─────────────────────────
|
||||
--
|
||||
-- A set of ZoneManager flags and settings an admin saved under a name, for one
|
||||
-- server, several, or the whole fleet. None ship with the module: what an
|
||||
-- "arena" is differs by server. A zone step copies a preset's options into its
|
||||
-- own field when the preset is picked (D210), so editing a preset later leaves
|
||||
-- every published event as it was.
|
||||
--
|
||||
-- `flags` is a JSON array of names as ZoneManager spells them, `settings` a JSON
|
||||
-- object of the plugin's allowlisted settings. Both are TEXT, not JSON: MariaDB's
|
||||
-- JSON is an alias the driver hands back as a string either way.
|
||||
CREATE TABLE IF NOT EXISTS rust_zone_presets (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(64) NOT NULL,
|
||||
flags TEXT NOT NULL,
|
||||
settings TEXT NOT NULL,
|
||||
-- 1: every server, including servers added later. 0: exactly the servers
|
||||
-- `rust_zone_preset_servers` names. The shape of a shared group (§1.4).
|
||||
all_servers TINYINT(1) NOT NULL DEFAULT 0,
|
||||
created_by INT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
-- Not unique: two servers may each have their own "Arena" (D189's rule for
|
||||
-- groups). The model refuses two of one name on one server.
|
||||
KEY idx_rust_zone_presets_name (name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rust_zone_preset_servers (
|
||||
preset_id INT UNSIGNED NOT NULL,
|
||||
server_id VARCHAR(64) NOT NULL,
|
||||
PRIMARY KEY (preset_id, server_id),
|
||||
KEY idx_rust_zone_preset_servers_server (server_id),
|
||||
CONSTRAINT fk_rust_zone_preset_servers_preset
|
||||
FOREIGN KEY (preset_id) REFERENCES rust_zone_presets (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_rust_zone_preset_servers_server
|
||||
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
@@ -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) ───────────────────────────────────────────────────────
|
||||
|
||||
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 }
|
||||
@@ -42,6 +42,10 @@ adminRustRouter.use('/config', require('./config.router'))
|
||||
// deliberately widens it, fleet-wide or for one server.
|
||||
adminRustRouter.use('/visibility', require('./visibility.router'))
|
||||
|
||||
// Zone presets (PLAN_REDESIGNS §3.1, D195, D210): the flags and settings a zone
|
||||
// step copies, saved by name for one server, several, or the fleet.
|
||||
adminRustRouter.use('/zones', require('./zones.router'))
|
||||
|
||||
adminRustRouter.get(
|
||||
'/servers',
|
||||
// #swagger.tags = ['Admin · Rust']
|
||||
|
||||
56
server/router/admin/zones.controller.js
Normal file
56
server/router/admin/zones.controller.js
Normal file
@@ -0,0 +1,56 @@
|
||||
// ── Admin · Rust · Zone presets — the handlers ────────────────────────────
|
||||
|
||||
const core = require('../../core')
|
||||
|
||||
const zones = require('../../model/zones/zones.model')
|
||||
|
||||
const log = core.logger('zones')
|
||||
|
||||
const by = (req) => (req.user ? req.user.id : null)
|
||||
|
||||
/** A model refusal answers with its own status; anything else is ours to log. */
|
||||
function fail(res, err, what) {
|
||||
if (err instanceof zones.ZonePresetError) return res.status(err.status).json({ message: err.message })
|
||||
log.error(`failed to ${what}`, { error: err.message })
|
||||
return res.status(500).json({ message: `Failed to ${what}` })
|
||||
}
|
||||
|
||||
async function describe(req, res) {
|
||||
try {
|
||||
res.json(await zones.describe())
|
||||
} catch (err) {
|
||||
fail(res, err, 'read the zone presets')
|
||||
}
|
||||
}
|
||||
|
||||
async function create(req, res) {
|
||||
try {
|
||||
const preset = await zones.create(req.body || {}, by(req))
|
||||
await core.activity.log({ req, action: 'rust.zones.preset.create', detail: { id: preset.id, name: preset.name } })
|
||||
res.status(201).json(preset)
|
||||
} catch (err) {
|
||||
fail(res, err, 'save the zone preset')
|
||||
}
|
||||
}
|
||||
|
||||
async function update(req, res) {
|
||||
try {
|
||||
const preset = await zones.update(Number(req.params.id), req.body || {})
|
||||
await core.activity.log({ req, action: 'rust.zones.preset.update', detail: { id: preset.id, name: preset.name } })
|
||||
res.json(preset)
|
||||
} catch (err) {
|
||||
fail(res, err, 'save the zone preset')
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(req, res) {
|
||||
try {
|
||||
await zones.remove(Number(req.params.id))
|
||||
await core.activity.log({ req, action: 'rust.zones.preset.delete', detail: { id: Number(req.params.id) } })
|
||||
res.json({ ok: true })
|
||||
} catch (err) {
|
||||
fail(res, err, 'delete the zone preset')
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { describe, create, update, remove }
|
||||
84
server/router/admin/zones.router.js
Normal file
84
server/router/admin/zones.router.js
Normal file
@@ -0,0 +1,84 @@
|
||||
// ── Admin · Rust · Zone presets ───────────────────────────────────────────
|
||||
//
|
||||
// Mounted under the admin tier's `/rust` prefix, so every path here is
|
||||
// `/api/v1/admin/rust/zones`. An admin's own named sets of ZoneManager flags and
|
||||
// settings (PLAN_REDESIGNS §3.1, D195). A zone step's options field offers them
|
||||
// and copies the one picked (D210).
|
||||
//
|
||||
// **Every route is `requireRole('admin')`,** like every other page under Admin →
|
||||
// Rust: a preset decides what a zone does to the players standing in it.
|
||||
|
||||
const core = require('../../core')
|
||||
|
||||
const express = core.express
|
||||
const zones = require('./zones.controller')
|
||||
const { requireRole, validate } = core.middleware
|
||||
const { body, param } = core.validator
|
||||
|
||||
const zonesRouter = express.Router()
|
||||
|
||||
const presetBody = [
|
||||
body('name').isString().trim().isLength({ min: 1, max: 64 }).withMessage('name is 1 to 64 characters'),
|
||||
body('flags').optional().isArray({ max: 80 }).withMessage('flags is a list of at most 80 names'),
|
||||
body('settings').optional().isObject().withMessage('settings is an object of name and value'),
|
||||
body('allServers').optional().isBoolean().withMessage('allServers is true or false'),
|
||||
body('servers').optional().isArray().withMessage('servers is a list of server ids'),
|
||||
]
|
||||
|
||||
zonesRouter.get(
|
||||
'/',
|
||||
// #swagger.tags = ['Admin · Rust']
|
||||
// #swagger.summary = 'Zone presets, and what each server said about its zone mods'
|
||||
// #swagger.description = 'Everything the zone presets page draws. Each server with what its last hello said about ZoneManager (its version and flag list, D211) and ZoneDomes (loaded, version, the domes helper’s state, and whether a dome may be added now, D194); every server’s flags, grouped as the form draws them (a flag the module does not know is under Other); the settings a zone may carry, with their bounds; and every preset, with `line` — the text a zone step copies when the preset is picked (D210). Read from the stored hello, so it answers while a server is off.'
|
||||
/* #swagger.responses[200] = { description: 'The page', content: { "application/json": { schema: { $ref: "#/components/schemas/RustZones" } } } } */
|
||||
requireRole('admin'),
|
||||
zones.describe,
|
||||
)
|
||||
|
||||
zonesRouter.post(
|
||||
'/presets',
|
||||
// #swagger.tags = ['Admin · Rust']
|
||||
// #swagger.summary = 'Save a new zone preset'
|
||||
// #swagger.description = 'A named set of ZoneManager flags and settings for the servers it lists, or for every server (`allServers`). Every flag must be on each listed server that has reported its flags, and is saved as that ZoneManager spells it. Two presets of one name may not share a server; the same name on different servers is allowed (D189). No preset ships with the module (D195).'
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/RustZonePresetInput" } } } } */
|
||||
/* #swagger.responses[201] = { description: 'Saved', content: { "application/json": { schema: { $ref: "#/components/schemas/RustZonePreset" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'A flag or setting the servers do not have, or out of range' } */
|
||||
/* #swagger.responses[404] = { description: 'A server that does not exist' } */
|
||||
/* #swagger.responses[409] = { description: 'A preset of that name is already on one of the servers' } */
|
||||
requireRole('admin'),
|
||||
...presetBody,
|
||||
validate,
|
||||
zones.create,
|
||||
)
|
||||
|
||||
zonesRouter.put(
|
||||
'/presets/:id',
|
||||
// #swagger.tags = ['Admin · Rust']
|
||||
// #swagger.summary = 'Change a zone preset'
|
||||
// #swagger.description = 'Replaces the preset whole, under the same rules as saving a new one. A zone step already made from it keeps its own copy of the options (D210), so no published event changes.'
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/RustZonePresetInput" } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Saved', content: { "application/json": { schema: { $ref: "#/components/schemas/RustZonePreset" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'A flag or setting the servers do not have, or out of range' } */
|
||||
/* #swagger.responses[404] = { description: 'No such preset, or a server that does not exist' } */
|
||||
/* #swagger.responses[409] = { description: 'A preset of that name is already on one of the servers' } */
|
||||
requireRole('admin'),
|
||||
param('id').isInt({ min: 1 }).withMessage('id is a preset id'),
|
||||
...presetBody,
|
||||
validate,
|
||||
zones.update,
|
||||
)
|
||||
|
||||
zonesRouter.delete(
|
||||
'/presets/:id',
|
||||
// #swagger.tags = ['Admin · Rust']
|
||||
// #swagger.summary = 'Delete a zone preset'
|
||||
// #swagger.description = 'Removes the preset from the zone step’s dropdown. Steps made from it keep their own copy of its options (D210).'
|
||||
/* #swagger.responses[200] = { description: 'Deleted' } */
|
||||
/* #swagger.responses[404] = { description: 'No such preset' } */
|
||||
requireRole('admin'),
|
||||
param('id').isInt({ min: 1 }).withMessage('id is a preset id'),
|
||||
validate,
|
||||
zones.remove,
|
||||
)
|
||||
|
||||
module.exports = zonesRouter
|
||||
@@ -1026,6 +1026,54 @@ module.exports = {
|
||||
},
|
||||
},
|
||||
},
|
||||
RustZonePreset: {
|
||||
type: 'object',
|
||||
description: 'A named set of ZoneManager flags and settings for one server, several, or every server (PLAN_REDESIGNS §3.1, D195). `line` is the text a zone step copies when the preset is picked (D210).',
|
||||
properties: {
|
||||
id: { type: 'integer', example: 3 },
|
||||
name: { type: 'string', example: 'Arena' },
|
||||
flags: { type: 'array', items: { type: 'string' }, example: ['NoBuild', 'NoPlayerLoot'] },
|
||||
settings: { type: 'object', additionalProperties: true, example: { radiation: 10, safezone: false } },
|
||||
allServers: { type: 'boolean', example: false },
|
||||
servers: { type: 'array', items: { type: 'string' }, example: ['main'] },
|
||||
line: { type: 'string', example: 'NoBuild, NoPlayerLoot, radiation=10, safezone=false' },
|
||||
updatedAt: { type: 'string', format: 'date-time', nullable: true },
|
||||
},
|
||||
},
|
||||
RustZonePresetInput: {
|
||||
type: 'object',
|
||||
required: ['name'],
|
||||
description: 'A preset to save. `servers` is required unless `allServers` is true. Every flag must be on each covered server that has reported its ZoneManager flags; settings are radiation (0–500), comfort (0–1), temperature (−100–100), safezone (boolean) and permission (1–32 of a–z, 0–9, _).',
|
||||
properties: {
|
||||
name: { type: 'string', example: 'Arena' },
|
||||
flags: { type: 'array', items: { type: 'string' }, example: ['NoBuild', 'NoPlayerLoot'] },
|
||||
settings: { type: 'object', additionalProperties: true, example: { radiation: 10 } },
|
||||
allServers: { type: 'boolean', example: false },
|
||||
servers: { type: 'array', items: { type: 'string' }, example: ['main'] },
|
||||
},
|
||||
},
|
||||
RustZones: {
|
||||
type: 'object',
|
||||
description: 'The zone presets page: each server with what its last hello said about ZoneManager (version, flags) and ZoneDomes (loaded, version, helper state, ready), every server’s flags grouped for the form, the settings a zone may carry, and the presets.',
|
||||
properties: {
|
||||
servers: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', example: 'main' },
|
||||
name: { type: 'string', example: 'Main · Vanilla' },
|
||||
enabled: { type: 'boolean', example: true },
|
||||
zoneManager: { type: 'object', nullable: true, example: { version: '3.1.14', flagCount: 69, flags: ['NoBuild'] } },
|
||||
zoneDomes: { type: 'object', nullable: true, example: { loaded: true, version: '2.0.2', helper: { state: 'patched', version: '0.1.0' }, ready: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
groups: { type: 'array', items: { type: 'object' }, example: [{ group: 'Building', flags: [{ name: 'NoBuild' }] }] },
|
||||
settings: { type: 'array', items: { type: 'object' }, example: [{ key: 'radiation', type: 'float', min: 0, max: 500, label: 'Radiation' }] },
|
||||
presets: { type: 'array', items: { $ref: '#/components/schemas/RustZonePreset' } },
|
||||
},
|
||||
},
|
||||
RustVisibilityUpdate: {
|
||||
type: 'object',
|
||||
description: 'A change to who may see who is online. Either part may be omitted; a server set to null follows the fleet default again.',
|
||||
|
||||
282
server/test/zones.test.js
Normal file
282
server/test/zones.test.js
Normal file
@@ -0,0 +1,282 @@
|
||||
// ── Zones: presets, options and the dome (PLAN_REDESIGNS §3, D210, D211) ───
|
||||
//
|
||||
// What each test guards:
|
||||
//
|
||||
// a step's options line reads back as the flags and settings it names, spelled
|
||||
// as the server's own ZoneManager spells them (D211)
|
||||
// a flag the server does not have is refused on the form, not at 4 a.m.
|
||||
// a flag this module does not know is drawn under Other, never hidden
|
||||
// a preset is checked against every server it covers, and one that has not
|
||||
// said is left to the plugin
|
||||
// two presets of one name may not share a server; across servers they may (D189)
|
||||
// the step's dropdown VALUE is the line itself, so picking copies it (D210)
|
||||
// the zone crosses with its flags, settings, messages and dome
|
||||
// a dome on a server without ZoneDomes or its helper is refused with the reason
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert')
|
||||
|
||||
const { fakeCtx } = require('./_fakes')
|
||||
|
||||
require('../core')._reset()
|
||||
require('../core').init(fakeCtx())
|
||||
|
||||
const client = require('../sidecarClient')
|
||||
const serversDb = require('../model/servers/servers.db')
|
||||
const zonesDb = require('../model/zones/zones.db')
|
||||
const zones = require('../model/zones/zones.model')
|
||||
const options = require('../model/zones/zoneOptions')
|
||||
const voice = require('../model/permissions/voice')
|
||||
const world = require('../eventWorld')
|
||||
|
||||
const action = (id) => world.ACTIONS.find((a) => a.id === id)
|
||||
const source = (id) => world.OPTION_SOURCES.find((s) => s.id === id)
|
||||
|
||||
const FLAGS = ['AutoLights', 'NoBuild', 'NoPlayerLoot', 'PvpGod', 'NoChat', 'SomethingNew']
|
||||
|
||||
function server(id, { flags = FLAGS, domes = null } = {}) {
|
||||
return {
|
||||
id,
|
||||
name: id.toUpperCase(),
|
||||
enabled: true,
|
||||
zoneManager: flags ? { version: '3.1.14', flags } : null,
|
||||
zoneDomes: domes,
|
||||
}
|
||||
}
|
||||
|
||||
/** Stubs the preset store and the servers' hellos for one test. */
|
||||
function stubZones(t, { servers = [server('main')], presets = [] } = {}) {
|
||||
const saved = { ...zonesDb }
|
||||
const store = presets.map((p) => ({ ...p }))
|
||||
const writes = []
|
||||
zonesDb.listZoneServers = async () => servers
|
||||
zonesDb.listPresets = async () => store.map((p) => ({ ...p }))
|
||||
zonesDb.getPreset = async (id) => store.find((p) => p.id === id) || null
|
||||
zonesDb.savePreset = async (row) => {
|
||||
writes.push(row)
|
||||
const id = row.id || store.length + 100
|
||||
const next = { id, ...row, servers: row.allServers ? [] : row.servers, updatedAt: null }
|
||||
const at = store.findIndex((p) => p.id === id)
|
||||
if (at >= 0) store[at] = next
|
||||
else store.push(next)
|
||||
return id
|
||||
}
|
||||
zonesDb.deletePreset = async (id) => {
|
||||
const at = store.findIndex((p) => p.id === id)
|
||||
if (at >= 0) store.splice(at, 1)
|
||||
}
|
||||
t.after(() => Object.assign(zonesDb, saved))
|
||||
return { store, writes }
|
||||
}
|
||||
|
||||
function stubWorld(t) {
|
||||
const calls = []
|
||||
const saved = { getServer: serversDb.getServer, worldZone: client.worldZone, currentFormat: voice.currentFormat }
|
||||
serversDb.getServer = async (id) => ({ id, name: id, sidecarBaseUrl: `http://${id}:1`, sidecarTokenEnc: null, enabled: 1 })
|
||||
client.worldZone = async (srv, body) => {
|
||||
calls.push({ server: srv.id, body })
|
||||
return { ok: true, status: 'ok', data: { kind: 'world.ok', placed: [{ id: 'rg-7-1-1', kind: 'zone', name: 'Z' }] } }
|
||||
}
|
||||
voice.currentFormat = async () => '<color=#ff0>[Event]</color> {message}'
|
||||
t.after(() => {
|
||||
serversDb.getServer = saved.getServer
|
||||
client.worldZone = saved.worldZone
|
||||
voice.currentFormat = saved.currentFormat
|
||||
})
|
||||
return calls
|
||||
}
|
||||
|
||||
// ── the options line ───────────────────────────────────────────────────────
|
||||
|
||||
test('an options line reads as flags and settings, spelled as the server spells them', () => {
|
||||
const read = options.parse('nobuild, NOPLAYERLOOT radiation=10 safezone=true permission=Arena_1', FLAGS)
|
||||
assert.deepStrictEqual(read, {
|
||||
ok: true,
|
||||
flags: ['NoBuild', 'NoPlayerLoot'],
|
||||
settings: { radiation: 10, safezone: true, permission: 'arena_1' },
|
||||
})
|
||||
assert.deepStrictEqual(options.parse('', FLAGS), { ok: true, flags: [], settings: {} })
|
||||
assert.deepStrictEqual(options.parse(undefined), { ok: true, flags: [], settings: {} })
|
||||
})
|
||||
|
||||
test('a flag the server lacks, or a setting out of range, is refused', () => {
|
||||
for (const line of [
|
||||
'NoFlying',
|
||||
'radiation=501',
|
||||
'comfort=2',
|
||||
'temperature=-101',
|
||||
'safezone=maybe',
|
||||
'permission=has.dots',
|
||||
'rotation=90',
|
||||
'radiation=1 radiation=2',
|
||||
'No-Build',
|
||||
]) {
|
||||
const read = options.parse(line, FLAGS)
|
||||
assert.strictEqual(read.ok, false, line)
|
||||
assert.ok(read.error, line)
|
||||
}
|
||||
})
|
||||
|
||||
test('without a flag list the flags are kept as written, and the plugin checks them', () => {
|
||||
assert.deepStrictEqual(options.parse('NoFlying', null), { ok: true, flags: ['NoFlying'], settings: {} })
|
||||
})
|
||||
|
||||
test('format and parse are inverses', () => {
|
||||
const preset = { flags: ['NoBuild', 'PvpGod'], settings: { safezone: false, radiation: 2.5 } }
|
||||
const line = options.format(preset)
|
||||
assert.strictEqual(line, 'NoBuild, PvpGod, radiation=2.5, safezone=false')
|
||||
const back = options.parse(line, FLAGS)
|
||||
assert.deepStrictEqual({ flags: back.flags, settings: back.settings }, preset)
|
||||
})
|
||||
|
||||
test('a flag this module does not know is drawn under Other, never hidden', () => {
|
||||
const groups = options.grouped(FLAGS)
|
||||
assert.deepStrictEqual(groups.map((g) => g.group), ['Combat', 'Building', 'Loot', 'Communication', 'Other'])
|
||||
assert.deepStrictEqual(groups.find((g) => g.group === 'Other').flags.map((f) => f.name), ['SomethingNew'])
|
||||
assert.strictEqual(groups.flatMap((g) => g.flags).length, FLAGS.length)
|
||||
})
|
||||
|
||||
// ── presets ────────────────────────────────────────────────────────────────
|
||||
|
||||
test('a preset is checked against every server it covers, and saved as the server spells it', async (t) => {
|
||||
const { writes } = stubZones(t, {
|
||||
servers: [server('main'), server('alt', { flags: ['NoBuild'] }), server('dark', { flags: null })],
|
||||
})
|
||||
|
||||
await assert.rejects(zones.create({ name: 'Arena', flags: ['NoPlayerLoot'], servers: ['main', 'alt'] }), (err) => {
|
||||
assert.strictEqual(err.status, 400)
|
||||
assert.match(err.message, /ALT/)
|
||||
return true
|
||||
})
|
||||
|
||||
// `dark` has not said which flags it has: skipped, and the plugin checks.
|
||||
const saved = await zones.create({ name: 'Arena', flags: ['noplayerloot'], settings: { radiation: '5' }, servers: ['main', 'dark'] }, 9)
|
||||
assert.deepStrictEqual(writes[0], {
|
||||
name: 'Arena', flags: ['NoPlayerLoot'], settings: { radiation: 5 }, allServers: false, servers: ['main', 'dark'],
|
||||
})
|
||||
assert.strictEqual(saved.name, 'Arena')
|
||||
})
|
||||
|
||||
test('a preset names a server, or every server, and only ones that exist', async (t) => {
|
||||
stubZones(t)
|
||||
await assert.rejects(zones.create({ name: 'A', flags: [] }), /at least one server/)
|
||||
await assert.rejects(zones.create({ name: 'A', servers: ['nowhere'] }), (err) => err.status === 404)
|
||||
await assert.rejects(zones.create({ name: '', servers: ['main'] }), /name/)
|
||||
})
|
||||
|
||||
test('two presets of one name may not share a server; on different servers they may (D189)', async (t) => {
|
||||
stubZones(t, {
|
||||
servers: [server('main'), server('alt')],
|
||||
presets: [{ id: 1, name: 'Arena', flags: [], settings: {}, allServers: false, servers: ['main'] }],
|
||||
})
|
||||
await assert.rejects(zones.create({ name: 'arena', servers: ['main', 'alt'] }), (err) => err.status === 409)
|
||||
await assert.rejects(zones.create({ name: 'Arena', allServers: true }), (err) => err.status === 409)
|
||||
const alt = await zones.create({ name: 'Arena', servers: ['alt'] })
|
||||
assert.deepStrictEqual(alt.servers, ['alt'])
|
||||
// Editing the first keeps its own name without clashing with itself.
|
||||
const same = await zones.update(1, { name: 'Arena', flags: ['PvpGod'], servers: ['main'] })
|
||||
assert.deepStrictEqual(same.flags, ['PvpGod'])
|
||||
})
|
||||
|
||||
test('the zone step’s dropdown VALUE is the options line, so picking a preset copies it (D210)', async (t) => {
|
||||
stubZones(t, {
|
||||
servers: [server('main')],
|
||||
presets: [
|
||||
{ id: 1, name: 'Arena', flags: ['NoBuild'], settings: { radiation: 10 }, allServers: false, servers: ['main'] },
|
||||
{ id: 2, name: 'Calm', flags: ['PvpGod'], settings: {}, allServers: true, servers: [] },
|
||||
],
|
||||
})
|
||||
assert.deepStrictEqual(await source('rust.options.zone_presets').resolve({}), [
|
||||
{ value: 'NoBuild, radiation=10', label: 'Arena', group: 'MAIN' },
|
||||
{ value: 'PvpGod', label: 'Calm', group: 'Every server' },
|
||||
])
|
||||
})
|
||||
|
||||
// ── the step ───────────────────────────────────────────────────────────────
|
||||
|
||||
const PATCHED = { loaded: true, version: '2.0.2', helper: { state: 'patched', version: '0.1.0' }, ready: true }
|
||||
|
||||
test('a zone crosses with its flags, settings, messages in the chat voice, and its dome', async (t) => {
|
||||
stubZones(t, { servers: [server('main', { domes: PATCHED })] })
|
||||
const calls = stubWorld(t)
|
||||
|
||||
const result = await action('rust.zone.open').perform({
|
||||
runId: 7,
|
||||
idempotencyKey: 'k1',
|
||||
params: {
|
||||
monument: 'main/airfield_1', radius: 40, minutes: 30,
|
||||
options: 'nobuild, pvpgod, radiation=10',
|
||||
enterMessage: ' In you go. ', leaveMessage: 'Out.',
|
||||
dome: 'Red', domeStack: 2,
|
||||
},
|
||||
})
|
||||
|
||||
assert.strictEqual(result.ok, true, result.error)
|
||||
assert.deepStrictEqual(calls[0].body, {
|
||||
runId: '7', key: 'k1', monument: 'airfield_1', offsetX: 0, offsetZ: 0, radius: 40, holdMs: 1800000,
|
||||
flags: ['NoBuild', 'PvpGod'],
|
||||
settings: { radiation: 10 },
|
||||
enterMessage: 'In you go.',
|
||||
leaveMessage: 'Out.',
|
||||
delivery: 'chat',
|
||||
format: '<color=#ff0>[Event]</color> {message}',
|
||||
dome: { type: 1, stack: 2 },
|
||||
})
|
||||
})
|
||||
|
||||
test('a zone with no options crosses exactly as it always did', async (t) => {
|
||||
stubZones(t)
|
||||
const calls = stubWorld(t)
|
||||
await action('rust.zone.open').perform({ runId: 7, idempotencyKey: 'k', params: { monument: 'main/a', radius: 40, minutes: 5 } })
|
||||
assert.deepStrictEqual(Object.keys(calls[0].body).sort(), ['holdMs', 'key', 'monument', 'offsetX', 'offsetZ', 'radius', 'runId'])
|
||||
})
|
||||
|
||||
test('a popup carries no chat voice', async (t) => {
|
||||
stubZones(t)
|
||||
const calls = stubWorld(t)
|
||||
await action('rust.zone.open').perform({
|
||||
runId: 7, idempotencyKey: 'k', params: { monument: 'main/a', radius: 40, minutes: 5, enterMessage: 'Hi', delivery: 'popup' },
|
||||
})
|
||||
assert.strictEqual(calls[0].body.delivery, 'popup')
|
||||
assert.strictEqual(calls[0].body.format, undefined)
|
||||
})
|
||||
|
||||
test('a bad option, message or dome is refused for good, before anything is sent', async (t) => {
|
||||
const run = (params) =>
|
||||
action('rust.zone.open').perform({ runId: 7, idempotencyKey: 'k', params: { monument: 'main/a', radius: 40, minutes: 5, ...params } })
|
||||
|
||||
for (const [domes, params, words] of [
|
||||
[PATCHED, { options: 'NoFlying' }, /not a flag/],
|
||||
[PATCHED, { enterMessage: 'x'.repeat(257) }, /256/],
|
||||
[PATCHED, { delivery: 'shout' }, /chat or to a popup/],
|
||||
[PATCHED, { dome: 'orange' }, /standard, red/],
|
||||
[PATCHED, { dome: 'red', domeStack: 11 }, /1 to 10/],
|
||||
[{ loaded: false }, { dome: 'red' }, /ZoneDomes is not loaded/],
|
||||
[{ loaded: true, helper: { state: 'missing' } }, { dome: 'red' }, /RunicGatewayDomes\.cs is not installed/],
|
||||
[{ loaded: true, helper: { state: 'unsupported' } }, { dome: 'red' }, /could not patch/],
|
||||
]) {
|
||||
await t.test(String(words), async (tt) => {
|
||||
stubZones(tt, { servers: [server('main', { domes })] })
|
||||
const calls = stubWorld(tt)
|
||||
const result = await run(params)
|
||||
assert.strictEqual(result.ok, false)
|
||||
assert.strictEqual(result.retry, false)
|
||||
assert.match(result.error, words)
|
||||
assert.strictEqual(calls.length, 0)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
test('a dry run checks the options and the dome and sends nothing', async (t) => {
|
||||
stubZones(t, { servers: [server('main', { domes: PATCHED })] })
|
||||
const calls = stubWorld(t)
|
||||
const result = await action('rust.zone.open').perform({
|
||||
runId: 7, idempotencyKey: 'k', verify: true, params: { monument: 'main/a', radius: 40, minutes: 5, options: 'NoBuild', dome: 'standard' },
|
||||
})
|
||||
assert.deepStrictEqual(result, { ok: true })
|
||||
assert.strictEqual(calls.length, 0)
|
||||
})
|
||||
|
||||
test('the dome dropdown offers ZoneDomes’ five colours', async () => {
|
||||
assert.deepStrictEqual((await source('rust.options.domes').resolve()).map((r) => r.value), ['standard', 'red', 'blue', 'green', 'purple'])
|
||||
})
|
||||
@@ -1468,6 +1468,142 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/rust/zones": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Admin · Rust"
|
||||
],
|
||||
"summary": "Zone presets, and what each server said about its zone mods",
|
||||
"description": "Everything the zone presets page draws. Each server with what its last hello said about ZoneManager (its version and flag list, D211) and ZoneDomes (loaded, version, the domes helper’s state, and whether a dome may be added now, D194); every server’s flags, grouped as the form draws them (a flag the module does not know is under Other); the settings a zone may carry, with their bounds; and every preset, with `line` — the text a zone step copies when the preset is picked (D210). Read from the stored hello, so it answers while a server is off.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The page",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RustZones"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/rust/zones/presets": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Admin · Rust"
|
||||
],
|
||||
"summary": "Save a new zone preset",
|
||||
"description": "A named set of ZoneManager flags and settings for the servers it lists, or for every server (`allServers`). Every flag must be on each listed server that has reported its flags, and is saved as that ZoneManager spells it. Two presets of one name may not share a server; the same name on different servers is allowed (D189). No preset ships with the module (D195).",
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Saved",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RustZonePreset"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "A flag or setting the servers do not have, or out of range"
|
||||
},
|
||||
"404": {
|
||||
"description": "A server that does not exist"
|
||||
},
|
||||
"409": {
|
||||
"description": "A preset of that name is already on one of the servers"
|
||||
}
|
||||
},
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RustZonePresetInput"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/rust/zones/presets/{id}": {
|
||||
"put": {
|
||||
"tags": [
|
||||
"Admin · Rust"
|
||||
],
|
||||
"summary": "Change a zone preset",
|
||||
"description": "Replaces the preset whole, under the same rules as saving a new one. A zone step already made from it keeps its own copy of the options (D210), so no published event changes.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Saved",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RustZonePreset"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "A flag or setting the servers do not have, or out of range"
|
||||
},
|
||||
"404": {
|
||||
"description": "No such preset, or a server that does not exist"
|
||||
},
|
||||
"409": {
|
||||
"description": "A preset of that name is already on one of the servers"
|
||||
}
|
||||
},
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RustZonePresetInput"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"tags": [
|
||||
"Admin · Rust"
|
||||
],
|
||||
"summary": "Delete a zone preset",
|
||||
"description": "Removes the preset from the zone step’s dropdown. Steps made from it keep their own copy of its options (D210).",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Deleted"
|
||||
},
|
||||
"404": {
|
||||
"description": "No such preset"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/users/{id}/rust/links": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -9101,6 +9237,562 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"RustZonePreset": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "object"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "A named set of ZoneManager flags and settings for one server, several, or every server (PLAN_REDESIGNS §3.1, D195). `line` is the text a zone step copies when the preset is picked (D210)."
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "integer"
|
||||
},
|
||||
"example": {
|
||||
"type": "number",
|
||||
"example": 3
|
||||
}
|
||||
}
|
||||
},
|
||||
"name": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "Arena"
|
||||
}
|
||||
}
|
||||
},
|
||||
"flags": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "array"
|
||||
},
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
"type": "array",
|
||||
"example": [
|
||||
"NoBuild",
|
||||
"NoPlayerLoot"
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "object"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
},
|
||||
"example": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"radiation": {
|
||||
"type": "number",
|
||||
"example": 10
|
||||
},
|
||||
"safezone": {
|
||||
"type": "boolean",
|
||||
"example": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"allServers": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "boolean"
|
||||
},
|
||||
"example": {
|
||||
"type": "boolean",
|
||||
"example": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"servers": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "array"
|
||||
},
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
"type": "array",
|
||||
"example": [
|
||||
"main"
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"line": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "NoBuild, NoPlayerLoot, radiation=10, safezone=false"
|
||||
}
|
||||
}
|
||||
},
|
||||
"updatedAt": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"format": {
|
||||
"type": "string",
|
||||
"example": "date-time"
|
||||
},
|
||||
"nullable": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"RustZonePresetInput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "object"
|
||||
},
|
||||
"required": {
|
||||
"type": "array",
|
||||
"example": [
|
||||
"name"
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "A preset to save. `servers` is required unless `allServers` is true. Every flag must be on each covered server that has reported its ZoneManager flags; settings are radiation (0–500), comfort (0–1), temperature (−100–100), safezone (boolean) and permission (1–32 of a–z, 0–9, _)."
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "Arena"
|
||||
}
|
||||
}
|
||||
},
|
||||
"flags": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "array"
|
||||
},
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
"type": "array",
|
||||
"example": [
|
||||
"NoBuild",
|
||||
"NoPlayerLoot"
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "object"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
},
|
||||
"example": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"radiation": {
|
||||
"type": "number",
|
||||
"example": 10
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"allServers": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "boolean"
|
||||
},
|
||||
"example": {
|
||||
"type": "boolean",
|
||||
"example": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"servers": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "array"
|
||||
},
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
"type": "array",
|
||||
"example": [
|
||||
"main"
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"RustZones": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "object"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "The zone presets page: each server with what its last hello said about ZoneManager (version, flags) and ZoneDomes (loaded, version, helper state, ready), every server’s flags grouped for the form, the settings a zone may carry, and the presets."
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"servers": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "array"
|
||||
},
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "object"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "main"
|
||||
}
|
||||
}
|
||||
},
|
||||
"name": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "Main · Vanilla"
|
||||
}
|
||||
}
|
||||
},
|
||||
"enabled": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "boolean"
|
||||
},
|
||||
"example": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"zoneManager": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "object"
|
||||
},
|
||||
"nullable": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
},
|
||||
"example": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"version": {
|
||||
"type": "string",
|
||||
"example": "3.1.14"
|
||||
},
|
||||
"flagCount": {
|
||||
"type": "number",
|
||||
"example": 69
|
||||
},
|
||||
"flags": {
|
||||
"type": "array",
|
||||
"example": [
|
||||
"NoBuild"
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"zoneDomes": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "object"
|
||||
},
|
||||
"nullable": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
},
|
||||
"example": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"loaded": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"example": "2.0.2"
|
||||
},
|
||||
"helper": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"state": {
|
||||
"type": "string",
|
||||
"example": "patched"
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"example": "0.1.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ready": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"groups": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "array"
|
||||
},
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"group": {
|
||||
"type": "string",
|
||||
"example": "Building"
|
||||
},
|
||||
"flags": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"example": "NoBuild"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "array"
|
||||
},
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"key": {
|
||||
"type": "string",
|
||||
"example": "radiation"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "float"
|
||||
},
|
||||
"min": {
|
||||
"type": "number",
|
||||
"example": 0
|
||||
},
|
||||
"max": {
|
||||
"type": "number",
|
||||
"example": 500
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"example": "Radiation"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"presets": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "array"
|
||||
},
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/RustZonePreset"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"RustVisibilityUpdate": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
Reference in New Issue
Block a user