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:
2026-09-29 05:25:44 -05:00
parent afb16112b0
commit 8361b19c24
17 changed files with 2159 additions and 1 deletions

View 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>
)
}