feat(rust): slash commands, the next wipe, and the Admin → Rust servers page (phase 16) #18

Merged
whitlocktech merged 2 commits from feat/phase-16-commands into edge 2026-09-25 19:18:33 +00:00
8 changed files with 450 additions and 11 deletions
Showing only changes of commit cddba957d3 - Show all commits

View File

@@ -43,11 +43,13 @@ rows here; the website core never learns there is more than one.
| Public | `GET …/servers/:id/clans` — the server's clans, best score first (public: names nobody) | | Public | `GET …/servers/:id/clans` — the server's clans, best score first (public: names nobody) |
| Public | `GET /api/v1/public/rust/clans/:externalId` — one clan, and its roster inside the roster audience | | Public | `GET /api/v1/public/rust/clans/:externalId` — one clan, and its roster inside the roster audience |
| Player | `GET /api/v1/player/rust/servers` — the server list, on the authenticated tier | | Player | `GET /api/v1/player/rust/servers` — the server list, on the authenticated tier |
| Admin | `GET/PUT/DELETE /api/v1/admin/rust/servers` and `POST …/:id/test` | | Admin | `GET/PUT/DELETE /api/v1/admin/rust/servers` and `POST …/:id/test` — the `PUT` carries the wipe schedule |
| Admin | `GET/PUT /api/v1/admin/rust/visibility` — who may see who is online, fleet-wide and per server | | Admin | `GET/PUT /api/v1/admin/rust/visibility` — who may see who is online, fleet-wide and per server |
| Pages | `/rust` — the server list, and the module's landing page | | Pages | `/rust` — the server list, and the module's landing page |
| Pages | `/rust/servers/:id` — one server: feed, leaderboard, who is on, wipes, clans | | Pages | `/rust/servers/:id` — one server: feed, leaderboard, who is on, wipes, clans |
| Pages | `/rust/clans/:externalId` — one clan, with core's Team notify, activity and forum in three module slots | | Pages | `/rust/clans/:externalId` — one clan, with core's Team notify, activity and forum in three module slots |
| Pages | `/admin/rust/servers` — add, edit, test and remove servers, and set each one's wipe schedule |
| Discord | `/status`, `/wipe`, `/top`, `/online`, `/clan` — read-only, answered from this module's tables |
| Teams | The deployment's Team provider: a first-party Rust clan is a Team | | Teams | The deployment's Team provider: a first-party Rust clan is a Team |
| Slot | `site.footer.status` — a live server/player count in core's footer | | Slot | `site.footer.status` — a live server/player count in core's footer |
@@ -73,8 +75,18 @@ at most 100 clans per server, and a server at that ceiling answers core partiall
removes a Team on its word. Core holds one Team provider per site, which is one reason **a site runs removes a Team on its word. Core holds one Team provider per site, which is one reason **a site runs
one module**: core's installer refuses a second. one module**: core's installer refuses a second.
The rest of the module — notifications, **The next wipe is the operator's to state** (Admin → Rust servers): a rule — the monthly forced
events, the live map, Discord commands — arrives phase by phase. **Nothing is registered before it wipe only, weekly or every other week, each in the server's own time zone and always including the
forced wipe (first Thursday, 19:00 UK time) — plus an optional one-off date that replaces the next
computed wipe. It is computed on every read and never stored, so it cannot go stale after a wipe.
The server list, the server page, the Android app and `/wipe` all show it.
**The Discord commands answer from the tables, never from a game server**, inside core's three-second
budget. Every refusal is private. **An answer narrower than public goes to the caller alone:** a
moderator's `/online` in a public channel shows the names to the moderator, never to the channel, and
the same for a clan roster. Everything else is posted where it was asked.
The rest of the module arrives phase by phase. **Nothing is registered before it
has something behind it:** a declared trigger nothing emits and a declared slot nothing fills are has something behind it:** a declared trigger nothing emits and a declared slot nothing fills are
both surfaces an operator can configure and then wait on, which is worse than an absent one. both surfaces an operator can configure and then wait on, which is worse than an absent one.
@@ -181,8 +193,8 @@ directory; a symlink answers no and the module is skipped in complete silence.
Either way, the module appears when the process restarts: the volume is read at require time. Either way, the module appears when the process restarts: the volume is read at require time.
Then, in Admin → Rust, add a server: its name, the sidecar's base URL, and the token the sidecar Then, in Admin → Rust servers, add a server: its name, the sidecar's base URL, and the token the
printed on first start (`rust-link-sidecar --print-config`). **The token is write-only** — it is sidecar printed on first start (`rust-link-sidecar --print-config`). **The token is write-only** — it is
stored encrypted through core's own secret box and never returned to any client; the panel reports stored encrypted through core's own secret box and never returned to any client; the panel reports
only whether one is set. only whether one is set.

View File

@@ -25,9 +25,10 @@ import Account from './routes/player/Account.jsx'
import Permissions from './routes/admin/Permissions.jsx' import Permissions from './routes/admin/Permissions.jsx'
import ModConfig from './routes/admin/ModConfig.jsx' import ModConfig from './routes/admin/ModConfig.jsx'
import Visibility from './routes/admin/Visibility.jsx' import Visibility from './routes/admin/Visibility.jsx'
import ServerSettings from './routes/admin/ServerSettings.jsx'
import UserRustSections from './routes/admin/UserRustSections.jsx' import UserRustSections from './routes/admin/UserRustSections.jsx'
import FooterStatus from './components/FooterStatus.jsx' import FooterStatus from './components/FooterStatus.jsx'
import { IconEye, IconKey, IconLink, IconSliders } from './icons.jsx' import { IconEye, IconKey, IconLink, IconServer, IconSliders } from './icons.jsx'
// The module id, exactly as `module.json` spells it. Core keys the registry by it // The module id, exactly as `module.json` spells it. Core keys the registry by it
// and prefixes every route path with it. // and prefixes every route path with it.
@@ -104,6 +105,10 @@ registry.registerRoutes(ID, {
// nothing names who is online by default; this is where an operator widens // nothing names who is online by default; this is where an operator widens
// it on purpose, fleet-wide or per server. // it on purpose, fleet-wide or per server.
{ path: 'visibility', element: <Visibility /> }, { path: 'visibility', element: <Visibility /> },
// The servers themselves (phase 16, D133): the page that was missing. Until
// it, a server row was written only through the API, and D130's wipe
// schedule needed somewhere to be typed.
{ path: 'servers', element: <ServerSettings /> },
], ],
}) })
@@ -153,6 +158,7 @@ registry.registerNav(ID, {
{ label: 'Rust permissions', to: '/admin/rust', icon: IconKey }, { label: 'Rust permissions', to: '/admin/rust', icon: IconKey },
{ label: 'Rust mod config', to: '/admin/rust/config', icon: IconSliders }, { label: 'Rust mod config', to: '/admin/rust/config', icon: IconSliders },
{ label: 'Rust visibility', to: '/admin/rust/visibility', icon: IconEye }, { label: 'Rust visibility', to: '/admin/rust/visibility', icon: IconEye },
{ label: 'Rust servers', to: '/admin/rust/servers', icon: IconServer },
], ],
}) })

View File

@@ -95,4 +95,19 @@ export const IconEye = () => (
</Icon> </Icon>
) )
export default { IconLink, IconKey, IconSliders, IconEye } /**
* Two stacked units — the admin sidebar's row for the servers themselves.
*
* The one row that is about the machines rather than what happens on them: the
* sidecar each one answers through, and when each one wipes.
*/
export const IconServer = () => (
<Icon>
<rect x="3" y="4" width="18" height="7" rx="1.5" />
<rect x="3" y="13" width="18" height="7" rx="1.5" />
<path d="M7 7.5h.01" />
<path d="M7 16.5h.01" />
</Icon>
)
export default { IconLink, IconKey, IconSliders, IconEye, IconServer }

View File

@@ -125,6 +125,29 @@ export function shortId(steamId) {
return id.length > 10 ? `…${id.slice(-6)}` : id return id.length > 10 ? `…${id.slice(-6)}` : id
} }
/**
* The next wipe (phase 16, D130), as `Thu, Oct 1, 7:00 PM (in 6 days)` in the
* VIEWER's locale and clock — or `null` when the server has no schedule.
*
* The server computes the instant from the operator's rule in the operator's
* zone; the page only says it the reader's way. A `once` source is an operator
* moving a wipe, and says so, because "the wipe is not when it usually is" is
* the thing a regular needs to notice.
*/
export function nextWipe(value, now = Date.now()) {
if (!value || !value.at) return null
const at = toMillis(value.at)
if (at === null) return null
const when = new Date(at).toLocaleString(undefined, {
weekday: 'short',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
})
return `${when} (${ago(at, now)})${value.source === 'once' ? ' — rescheduled' : ''}`
}
function toMillis(value) { function toMillis(value) {
if (value === null || value === undefined || value === '') return null if (value === null || value === undefined || value === '') return null
if (typeof value === 'number') return Number.isFinite(value) ? value : null if (typeof value === 'number') return Number.isFinite(value) ? value : null
@@ -133,4 +156,4 @@ function toMillis(value) {
return Number.isNaN(parsed) ? null : parsed return Number.isNaN(parsed) ? null : parsed
} }
export default { ago, clock, day, duration, count, prefab, shortId } export default { ago, clock, day, duration, count, prefab, shortId, nextWipe }

View File

@@ -0,0 +1,366 @@
// ── Admin · Rust · Servers (phase 16, D133) ───────────────────────────────
//
// The page the module did not have. Until phase 16 a server row was written only
// through `PUT /admin/rust/servers/:id` — D106 recorded it, and the README said
// "in Admin → Rust, add a server" about a page that did not exist. D130's wipe
// schedule needed somewhere to be typed, and the org lead chose to build the
// missing page rather than hang the schedule on the visibility page (D133).
//
// One form for adding and editing, because they are one `PUT`. Three rules it
// keeps from the API:
//
// 1. **The token is write-only.** The field is blank on every edit, a blank
// save leaves the stored one alone, and the row says whether one is stored.
// 2. **The protocol a row was configured against is kept.** The `PUT` defaults
// an omitted protocol to this build's, so an edit sends the stored value back
// rather than silently re-stamping the row.
// 3. **The wipe schedule is the operator's words, not the forecast.** The form
// shows what was stored; the row shows what it computes to, from the same
// answer the public pages read.
//
// Test and Delete act at once rather than on Save — they are questions put to a
// sidecar and a removal, not settings.
import { useState } from 'react'
import { ErrorState, Loading, useAsync } from '../../core.js'
import api from '../../api.js'
import { ago, nextWipe } from '../../lib/format.js'
const RULES = [
{ id: 'none', label: 'No schedule', hint: 'Nothing is forecast, not even the monthly forced wipe.' },
{ id: 'forced', label: 'Forced wipe only', hint: 'The first Thursday of each month, 19:00 UK time — Facepunch forces it on every server.' },
{ id: 'weekly', label: 'Weekly', hint: 'Every week on the day and time below, and the forced wipe.' },
{ id: 'biweekly', label: 'Every other week', hint: 'Every second week, on the weeks the date below falls in, and the forced wipe.' },
]
const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
const SOURCE = { forced: 'the forced wipe', rule: 'its own schedule', once: 'the one-off date' }
/** The browser's own zone — the likeliest answer for an operator typing a time. */
const localZone = () => {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
} catch {
return 'UTC'
}
}
/** Every zone the browser knows, for the datalist; an older browser gets none and a free field. */
const ZONES = (() => {
try {
return typeof Intl.supportedValuesOf === 'function' ? Intl.supportedValuesOf('timeZone') : []
} catch {
return []
}
})()
/** An ISO instant as the value a `datetime-local` input takes, in the browser's clock. */
function toLocalInput(iso) {
if (!iso) return ''
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return ''
const pad = (n) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
}
function blankForm() {
return {
isNew: true,
id: '',
name: '',
sidecarBaseUrl: '',
sidecarToken: '',
enabled: true,
sortOrder: 0,
protocol: undefined,
wipeRule: 'none',
wipeDay: 4,
wipeTime: '19:00',
wipeTz: localZone(),
wipeAnchor: '',
wipeOnce: '',
}
}
function formFrom(server) {
const s = server.schedule || { rule: 'none' }
return {
isNew: false,
id: server.id,
name: server.name,
sidecarBaseUrl: server.sidecarBaseUrl,
sidecarToken: '',
enabled: server.enabled,
sortOrder: server.sortOrder,
protocol: server.protocol,
wipeRule: s.rule || 'none',
wipeDay: s.day == null ? 4 : s.day,
wipeTime: s.time || '19:00',
wipeTz: s.tz || localZone(),
wipeAnchor: s.anchor || '',
wipeOnce: toLocalInput(s.onceAt),
storedOnceAt: s.onceAt || null,
}
}
/** The PUT body. The token only when one was typed; the schedule always. */
function bodyFrom(form) {
const weekly = form.wipeRule === 'weekly' || form.wipeRule === 'biweekly'
const onceUnchanged = form.storedOnceAt && form.wipeOnce === toLocalInput(form.storedOnceAt)
const body = {
name: form.name.trim(),
sidecarBaseUrl: form.sidecarBaseUrl.trim(),
enabled: form.enabled,
sortOrder: Number(form.sortOrder) || 0,
wipeRule: form.wipeRule,
wipeDay: weekly ? Number(form.wipeDay) : null,
wipeTime: weekly ? form.wipeTime : null,
wipeTz: weekly ? form.wipeTz.trim() : null,
wipeAnchor: form.wipeRule === 'biweekly' ? form.wipeAnchor : null,
// A past one-off date that was not touched is dropped rather than sent back:
// the server refuses a past date on save (it says nothing about the next
// wipe), and an operator editing the name must not be stopped by it.
wipeOnceAt: !form.wipeOnce || (onceUnchanged && Date.parse(form.storedOnceAt) <= Date.now())
? null
: new Date(form.wipeOnce).toISOString(),
}
if (form.sidecarToken) body.sidecarToken = form.sidecarToken
if (form.protocol !== undefined) body.protocol = form.protocol
return body
}
export default function ServerSettings() {
const [reloads, setReloads] = useState(0)
const { data, error: loadError } = useAsync(() => api.admin.listServers(), [reloads])
const [form, setForm] = useState(null)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [notes, setNotes] = useState({})
const [confirming, setConfirming] = useState(null)
if (loadError) return <ErrorState error={loadError} />
if (!data) return <Loading />
const servers = data.servers || []
const set = (key) => (e) => {
const value = e && e.target ? (e.target.type === 'checkbox' ? e.target.checked : e.target.value) : e
setForm((f) => ({ ...f, [key]: value }))
}
const save = async (e) => {
e.preventDefault()
setBusy(true)
setError('')
try {
await api.admin.saveServer(form.id.trim(), bodyFrom(form))
setForm(null)
setReloads((n) => n + 1)
} catch (err) {
setError(err.message || 'That did not save.')
} finally {
setBusy(false)
}
}
const test = async (server) => {
setNotes((n) => ({ ...n, [server.id]: 'Asking the sidecar…' }))
try {
const r = await api.admin.testServer(server.id)
const sidecar = r.sidecar || {}
const text = r.ok
? `The sidecar answered${sidecar.protocol ? ` (protocol ${sidecar.protocol})` : ''}${sidecar.plugin_connected === false ? ', but the game’s plugin is not connected to it' : ''}.`
: `The sidecar did not answer: ${r.status}.`
setNotes((n) => ({ ...n, [server.id]: text }))
} catch (err) {
setNotes((n) => ({ ...n, [server.id]: err.message || 'The test did not run.' }))
}
}
const remove = async (server) => {
setConfirming(null)
try {
await api.admin.deleteServer(server.id)
setReloads((n) => n + 1)
} catch (err) {
setNotes((n) => ({ ...n, [server.id]: err.message || 'That did not delete.' }))
}
}
return (
<div style={{ maxWidth: 900 }}>
<p className="sans dim" style={{ fontSize: '0.82rem', marginTop: 0 }}>
Each Rust server this site follows, the sidecar it answers through, and when it wipes. A server’s sidecar is
installed on its own game host; this page tells the site where to find it.
</p>
<section className="panel" style={{ padding: '16px 18px', marginBottom: 18 }}>
{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={{ borderTop: '1px solid var(--line-soft)', padding: '10px 0', fontSize: '0.84rem' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'baseline', gap: 10 }}>
<strong style={{ color: 'var(--head)' }}>{s.name}</strong>
<code className="dim" style={{ fontSize: '0.74rem' }}>{s.id}</code>
{!s.enabled && <span className="dim" style={{ fontSize: '0.74rem' }}>disabled</span>}
<span style={{ marginLeft: 'auto', color: s.online ? 'var(--mode-live, #5fb98a)' : 'var(--dim)' }}>
{s.online ? `online · ${s.players}${s.maxPlayers ? `/${s.maxPlayers}` : ''}` : s.reachable ? 'sidecar up, game offline' : 'unreachable'}
</span>
</div>
<div className="dim" style={{ fontSize: '0.76rem', marginTop: 4 }}>
{s.sidecarBaseUrl} · {s.hasToken ? 'token stored' : 'no token'} · protocol {s.protocol}
{s.sidecarProtocol != null && s.sidecarProtocol !== s.protocol ? ` (sidecar speaks ${s.sidecarProtocol})` : ''}
{s.lastSeenAt ? ` · last seen ${ago(s.lastSeenAt)}` : ''}
</div>
<div className="dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>
{s.nextWipe
? `Next wipe ${nextWipe(s.nextWipe)}, from ${SOURCE[s.nextWipe.source] || s.nextWipe.source}.`
: 'No wipe schedule set.'}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 8 }}>
<button type="button" className="btn" onClick={() => { setError(''); setForm(formFrom(s)) }}>Edit</button>
<button type="button" className="btn" onClick={() => test(s)}>Test the sidecar</button>
{confirming === s.id ? (
<>
<button type="button" className="btn" onClick={() => remove(s)}>Delete {s.name} and everything recorded about it</button>
<button type="button" className="btn" onClick={() => setConfirming(null)}>Keep it</button>
</>
) : (
<button type="button" className="btn" onClick={() => setConfirming(s.id)}>Delete</button>
)}
</div>
{notes[s.id] && <p className="dim" style={{ fontSize: '0.76rem', margin: '6px 0 0' }}>{notes[s.id]}</p>}
</div>
))}
{!form && (
<div style={{ marginTop: 12 }}>
<button type="button" className="btn" onClick={() => { setError(''); setForm(blankForm()) }}>Add a server</button>
</div>
)}
</section>
{form && (
<ServerForm form={form} set={set} busy={busy} error={error} onSave={save} onCancel={() => setForm(null)} />
)}
</div>
)
}
function Field({ label, hint, children }) {
return (
<label className="sans" style={{ display: 'grid', gap: 4, fontSize: '0.84rem' }}>
<span style={{ color: 'var(--head)' }}>{label}</span>
{children}
{hint && <span className="dim" style={{ fontSize: '0.74rem' }}>{hint}</span>}
</label>
)
}
function ServerForm({ form, set, busy, error, onSave, onCancel }) {
const weekly = form.wipeRule === 'weekly' || form.wipeRule === 'biweekly'
const rule = RULES.find((r) => r.id === form.wipeRule) || RULES[0]
const oncePast = form.storedOnceAt && form.wipeOnce === toLocalInput(form.storedOnceAt) && Date.parse(form.storedOnceAt) <= Date.now()
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.isNew ? 'Add a server' : `Edit ${form.name}`}
</h2>
{form.isNew && (
<Field label="Id" hint="Lowercase letters, digits and hyphens. It is in every link to this server and cannot be changed later.">
<input value={form.id} onChange={set('id')} required pattern="[a-z0-9][a-z0-9-]{0,63}" style={inputStyle} />
</Field>
)}
<Field label="Name">
<input value={form.name} onChange={set('name')} required maxLength={120} style={inputStyle} />
</Field>
<Field label="Sidecar address" hint="The base URL of the sidecar on the game host, e.g. http://10.0.0.5:8090.">
<input value={form.sidecarBaseUrl} onChange={set('sidecarBaseUrl')} required style={inputStyle} />
</Field>
<Field
label="Sidecar token"
hint={form.isNew ? 'From the sidecar’s own config. Needed to add a server.' : 'Leave blank to keep the stored token. It is never shown again once saved.'}
>
<input
type="password"
autoComplete="new-password"
value={form.sidecarToken}
onChange={set('sidecarToken')}
required={form.isNew}
maxLength={512}
style={inputStyle}
/>
</Field>
<div style={{ display: 'flex', gap: 18, flexWrap: 'wrap' }}>
<label className="sans" style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: '0.84rem' }}>
<input type="checkbox" checked={form.enabled} onChange={set('enabled')} /> Enabled
</label>
<Field label="Order">
<input type="number" value={form.sortOrder} onChange={set('sortOrder')} min={-1000} max={1000} style={{ ...inputStyle, width: 90 }} />
</Field>
</div>
<fieldset style={{ border: '1px solid var(--line-soft)', borderRadius: 8, padding: '12px 14px', display: 'grid', gap: 12 }}>
<legend className="sans" style={{ color: 'var(--head)', fontSize: '0.86rem', padding: '0 6px' }}>Wipe schedule</legend>
<Field label="Rule" hint={rule.hint}>
<select value={form.wipeRule} onChange={set('wipeRule')} style={inputStyle}>
{RULES.map((r) => <option key={r.id} value={r.id}>{r.label}</option>)}
</select>
</Field>
{weekly && (
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<Field label="Day">
<select value={form.wipeDay} onChange={set('wipeDay')} style={inputStyle}>
{DAYS.map((d, i) => <option key={d} value={i}>{d}</option>)}
</select>
</Field>
<Field label="Time">
<input type="time" value={form.wipeTime} onChange={set('wipeTime')} required style={inputStyle} />
</Field>
<Field label="Time zone" hint="The zone the time is in. It follows that zone’s summer time.">
<input value={form.wipeTz} onChange={set('wipeTz')} list="rust-zones" required style={inputStyle} />
</Field>
</div>
)}
{form.wipeRule === 'biweekly' && (
<Field label="One wipe on this schedule" hint={`A ${DAYS[form.wipeDay]} the server wiped or will wipe on. It says which weeks are wipe weeks.`}>
<input type="date" value={form.wipeAnchor} onChange={set('wipeAnchor')} required style={inputStyle} />
</Field>
)}
<Field
label="One-off wipe (optional)"
hint="A delayed or extra wipe, in your own clock. While it is in the future it is the next wipe, and anything scheduled before it is skipped."
>
<span style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input type="datetime-local" value={form.wipeOnce} onChange={set('wipeOnce')} style={inputStyle} />
{form.wipeOnce && <button type="button" className="btn" onClick={() => set('wipeOnce')('')}>Clear</button>}
</span>
</Field>
{oncePast && (
<p className="sans dim" style={{ fontSize: '0.76rem', margin: 0 }}>
That date has passed, so it no longer changes anything. Saving will clear it.
</p>
)}
</fieldset>
<datalist id="rust-zones">{ZONES.map((z) => <option key={z} value={z} />)}</datalist>
<div className="sans" style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<button type="submit" className="btn" disabled={busy}>{busy ? 'Saving…' : 'Save'}</button>
<button type="button" className="btn" onClick={onCancel} disabled={busy}>Cancel</button>
{error && <span style={{ color: '#d08a2a', fontSize: '0.8rem' }}>{error}</span>}
</div>
</form>
)
}
const inputStyle = {
background: 'var(--panel-flat, transparent)',
color: 'var(--text)',
border: '1px solid var(--line)',
borderRadius: 'var(--radius-input, 6px)',
padding: '5px 8px',
fontSize: '0.84rem',
}

View File

@@ -31,7 +31,7 @@ import Online from '../../components/Online.jsx'
import Tabs from '../../components/Tabs.jsx' import Tabs from '../../components/Tabs.jsx'
import WipeSelect, { ALL_TIME } from '../../components/WipeSelect.jsx' import WipeSelect, { ALL_TIME } from '../../components/WipeSelect.jsx'
import Wipes from '../../components/Wipes.jsx' import Wipes from '../../components/Wipes.jsx'
import { ago, count, day } from '../../lib/format.js' import { ago, count, day, nextWipe } from '../../lib/format.js'
import api from '../../api.js' import api from '../../api.js'
const TABS = [ const TABS = [
@@ -188,6 +188,7 @@ function describeWorld(server) {
server.worldSize ? `size ${count(server.worldSize)}` : null, server.worldSize ? `size ${count(server.worldSize)}` : null,
server.seed ? `seed ${server.seed}` : null, server.seed ? `seed ${server.seed}` : null,
server.wipedAt ? `wiped ${day(server.wipedAt)}` : null, server.wipedAt ? `wiped ${day(server.wipedAt)}` : null,
nextWipe(server.nextWipe) ? `next wipe ${nextWipe(server.nextWipe)}` : null,
].filter(Boolean) ].filter(Boolean)
return parts.length > 0 ? parts.join(' · ') : 'This server has not described itself yet.' return parts.length > 0 ? parts.join(' · ') : 'This server has not described itself yet.'

View File

@@ -24,7 +24,7 @@
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js' import { ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
import Empty from '../../components/Empty.jsx' import Empty from '../../components/Empty.jsx'
import { ago, count, day } from '../../lib/format.js' import { ago, count, day, nextWipe } from '../../lib/format.js'
import api from '../../api.js' import api from '../../api.js'
/** The "last reported" line, which has three cases and not one. */ /** The "last reported" line, which has three cases and not one. */
@@ -93,6 +93,9 @@ export default function Servers() {
server.level || null, server.level || null,
server.worldSize ? `size ${count(server.worldSize)}` : null, server.worldSize ? `size ${count(server.worldSize)}` : null,
server.wipedAt ? `wiped ${day(server.wipedAt)}` : null, server.wipedAt ? `wiped ${day(server.wipedAt)}` : null,
// Phase 16 (D131): absent from an older module, and null for a
// server with no schedule — either way the part is left out.
nextWipe(server.nextWipe) ? `next wipe ${nextWipe(server.nextWipe)}` : null,
] ]
.filter(Boolean) .filter(Boolean)
.join(' · ')} .join(' · ')}

View File

@@ -11,7 +11,7 @@
import test from 'node:test' import test from 'node:test'
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { ago, clock, count, day, duration, prefab, shortId } from '../src/lib/format.js' import { ago, clock, count, day, duration, nextWipe, prefab, shortId } from '../src/lib/format.js'
const NOW = Date.parse('2026-09-16T12:00:00Z') const NOW = Date.parse('2026-09-16T12:00:00Z')
@@ -94,3 +94,16 @@ test("a feed row from another day carries its date, not just a time", () => {
const earlyToday = Date.parse('2026-09-16T00:20:00') const earlyToday = Date.parse('2026-09-16T00:20:00')
assert.ok(clock(lateLastNight, earlyToday).length > time(lateLastNight).length) assert.ok(clock(lateLastNight, earlyToday).length > time(lateLastNight).length)
}) })
test('the next wipe: the reader’s own clock, how far away, and whether it moved', () => {
const now = Date.parse('2026-09-25T12:00:00Z')
const forced = nextWipe({ at: '2026-10-01T18:00:00.000Z', source: 'forced' }, now)
assert.match(forced, /\(in 6 days\)$/)
assert.ok(!forced.includes('rescheduled'))
assert.match(nextWipe({ at: '2026-09-27T17:00:00.000Z', source: 'once' }, now), /\(in 2 days\) — rescheduled$/)
// No schedule is no line, not "unknown": the page leaves the part out.
assert.equal(nextWipe(null, now), null)
assert.equal(nextWipe({ at: 'soon', source: 'rule' }, now), null)
})