diff --git a/README.md b/README.md index a12cfe5..e3789c5 100644 --- a/README.md +++ b/README.md @@ -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 /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 | -| 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 | | 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/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 | | 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 one module**: core's installer refuses a second. -The rest of the module — notifications, -events, the live map, Discord commands — arrives phase by phase. **Nothing is registered before it +**The next wipe is the operator's to state** (Admin → Rust servers): a rule — the monthly forced +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 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. -Then, in Admin → Rust, add a server: its name, the sidecar's base URL, and the token the sidecar -printed on first start (`rust-link-sidecar --print-config`). **The token is write-only** — it is +Then, in Admin → Rust servers, add a server: its name, the sidecar's base URL, and the token the +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 only whether one is set. diff --git a/ci/bundle.json b/ci/bundle.json index 5b46c58..39aa1a2 100644 --- a/ci/bundle.json +++ b/ci/bundle.json @@ -29,6 +29,7 @@ "server": [ "boot.js", "catalogue.js", + "commands", "configEdit.js", "core.js", "db", diff --git a/client/src/entry.jsx b/client/src/entry.jsx index e2dc353..fd5b373 100644 --- a/client/src/entry.jsx +++ b/client/src/entry.jsx @@ -25,9 +25,10 @@ import Account from './routes/player/Account.jsx' 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 UserRustSections from './routes/admin/UserRustSections.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 // 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 // it on purpose, fleet-wide or per server. { path: 'visibility', element: }, + // 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: }, ], }) @@ -153,6 +158,7 @@ registry.registerNav(ID, { { label: 'Rust permissions', to: '/admin/rust', icon: IconKey }, { 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 }, ], }) diff --git a/client/src/icons.jsx b/client/src/icons.jsx index f8e90ae..e5ef5b0 100644 --- a/client/src/icons.jsx +++ b/client/src/icons.jsx @@ -95,4 +95,19 @@ export const IconEye = () => ( ) -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 = () => ( + + + + + + +) + +export default { IconLink, IconKey, IconSliders, IconEye, IconServer } diff --git a/client/src/lib/format.js b/client/src/lib/format.js index 920da42..9140226 100644 --- a/client/src/lib/format.js +++ b/client/src/lib/format.js @@ -125,6 +125,29 @@ export function shortId(steamId) { 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) { if (value === null || value === undefined || value === '') return null if (typeof value === 'number') return Number.isFinite(value) ? value : null @@ -133,4 +156,4 @@ function toMillis(value) { 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 } diff --git a/client/src/routes/admin/ServerSettings.jsx b/client/src/routes/admin/ServerSettings.jsx new file mode 100644 index 0000000..8c9e823 --- /dev/null +++ b/client/src/routes/admin/ServerSettings.jsx @@ -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 + if (!data) return + 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 ( +
+

+ 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. +

+ +
+ {servers.length === 0 && ( +

No servers are configured yet.

+ )} + {servers.map((s) => ( +
+
+ {s.name} + {s.id} + {!s.enabled && disabled} + + {s.online ? `online · ${s.players}${s.maxPlayers ? `/${s.maxPlayers}` : ''}` : s.reachable ? 'sidecar up, game offline' : 'unreachable'} + +
+
+ {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)}` : ''} +
+
+ {s.nextWipe + ? `Next wipe ${nextWipe(s.nextWipe)}, from ${SOURCE[s.nextWipe.source] || s.nextWipe.source}.` + : 'No wipe schedule set.'} +
+
+ + + {confirming === s.id ? ( + <> + + + + ) : ( + + )} +
+ {notes[s.id] &&

{notes[s.id]}

} +
+ ))} + {!form && ( +
+ +
+ )} +
+ + {form && ( + setForm(null)} /> + )} +
+ ) +} + +function Field({ label, hint, children }) { + return ( + + ) +} + +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.isNew ? 'Add a server' : `Edit ${form.name}`} +

+ + {form.isNew && ( + + + + )} + + + + + + + + + +
+ + + + +
+ +
+ Wipe schedule + + + + {weekly && ( +
+ + + + + + + + + +
+ )} + {form.wipeRule === 'biweekly' && ( + + + + )} + + + + {form.wipeOnce && } + + + {oncePast && ( +

+ That date has passed, so it no longer changes anything. Saving will clear it. +

+ )} +
+ {ZONES.map((z) => + +
+ + + {error && {error}} +
+
+ ) +} + +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', +} diff --git a/client/src/routes/public/ServerDetail.jsx b/client/src/routes/public/ServerDetail.jsx index 3653b00..469dff6 100644 --- a/client/src/routes/public/ServerDetail.jsx +++ b/client/src/routes/public/ServerDetail.jsx @@ -31,7 +31,7 @@ import Online from '../../components/Online.jsx' import Tabs from '../../components/Tabs.jsx' import WipeSelect, { ALL_TIME } from '../../components/WipeSelect.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' const TABS = [ @@ -188,6 +188,7 @@ function describeWorld(server) { server.worldSize ? `size ${count(server.worldSize)}` : null, server.seed ? `seed ${server.seed}` : null, server.wipedAt ? `wiped ${day(server.wipedAt)}` : null, + nextWipe(server.nextWipe) ? `next wipe ${nextWipe(server.nextWipe)}` : null, ].filter(Boolean) return parts.length > 0 ? parts.join(' · ') : 'This server has not described itself yet.' diff --git a/client/src/routes/public/Servers.jsx b/client/src/routes/public/Servers.jsx index bac1f41..377a100 100644 --- a/client/src/routes/public/Servers.jsx +++ b/client/src/routes/public/Servers.jsx @@ -24,7 +24,7 @@ import { Link } from 'react-router-dom' import { ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js' 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' /** The "last reported" line, which has three cases and not one. */ @@ -93,6 +93,9 @@ export default function Servers() { server.level || null, server.worldSize ? `size ${count(server.worldSize)}` : 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) .join(' · ')} diff --git a/client/test/format.test.js b/client/test/format.test.js index 052c6c4..aaf4417 100644 --- a/client/test/format.test.js +++ b/client/test/format.test.js @@ -11,7 +11,7 @@ import test from 'node:test' 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') @@ -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') 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) +}) diff --git a/server/commands/clan.command.js b/server/commands/clan.command.js new file mode 100644 index 0000000..d16ca7d --- /dev/null +++ b/server/commands/clan.command.js @@ -0,0 +1,111 @@ +// ── `/clan name [server]` — one clan, and its roster where the caller may see it +// +// D50 deferred it here. A clan's name, colour, score and member count are public +// (D58); its ROSTER — who is in it and which of them is on — sits behind the +// roster audience (D48), which defaults to the clan's own members plus staff. +// +// The roster decision is `clans.getForViewer`'s, the same function the web page +// and core's `projectRoster` use, so the three cannot disagree about who may +// look. What this file adds is D127: **a roster shown to anything short of a +// `public` audience goes to the caller alone**, never to the channel. + +const c = require('./common') +const clans = require('../model/clans/clans.model') + +/** The candidates an ambiguous name lists. */ +const CANDIDATES = 10 + +/** + * Every clan on the servers searched, with the server each came from, and the + * servers whose clan board cannot be trusted right now. + */ +async function gather(servers) { + const boards = await Promise.all(servers.map(async (s) => ({ server: s, ...(await clans.listForServer(s.id)) }))) + return { + rows: boards.flatMap((b) => b.clans.map((clan) => ({ clan, server: b.server }))), + unreadable: boards.filter((b) => !b.board.supported || !b.board.fresh).map((b) => b.server), + } +} + +/** An exact name (case-insensitive), then a unique prefix. */ +function find(rows, wanted) { + const needle = wanted.trim().toLowerCase() + const exact = rows.filter((r) => r.clan.name.toLowerCase() === needle) + if (exact.length === 1) return { hit: exact[0] } + if (exact.length > 1) return { ambiguous: exact } + const prefix = rows.filter((r) => r.clan.name.toLowerCase().startsWith(needle)) + if (prefix.length === 1) return { hit: prefix[0] } + if (prefix.length > 1) return { ambiguous: prefix } + return {} +} + +function memberLine(m) { + const tags = [m.leader ? 'leader' : null, m.online ? 'online' : null].filter(Boolean) + return `${m.name || 'Unknown player'}${tags.length ? ` (${tags.join(', ')})` : ''}` +} + +async function handler({ options, actor }) { + const wanted = options && typeof options.name === 'string' ? options.name.trim() : '' + if (!wanted) return c.refuse('Name a clan.') + + const picked = c.pickServer(await c.listServers(), options.server) + const refusal = c.pickRefusal(picked) + if (refusal) return refusal + const searched = picked.server ? [picked.server] : picked.all + + const { rows, unreadable } = await gather(searched) + const { hit, ambiguous } = find(rows, wanted) + + if (ambiguous) { + const list = ambiguous.slice(0, CANDIDATES).map((r) => (searched.length > 1 ? `${r.clan.name} on ${r.server.name}` : r.clan.name)) + return c.refuse(`Several clans match “${wanted}”: ${list.join(', ')}${ambiguous.length > CANDIDATES ? ', …' : ''}`) + } + if (!hit) { + // A board that is missing, stale or unsupported proves nothing about a clan + // it does not list. Saying "no such clan" from it would be a guess presented + // as a fact. + if (unreadable.length) { + return c.refuse( + `No clan called “${wanted}” is on the boards that can be read right now. ` + + `The clan list for ${unreadable.map((s) => s.name).join(', ')} is not available, so it may be there.`, + ) + } + return c.refuse(`No clan called “${wanted}”.`) + } + + const answer = await clans.getForViewer(hit.clan.externalId, c.viewerOf(actor)) + if (!answer) return c.refuse(`No clan called “${wanted}”.`) + const { clan, roster } = answer + + const fields = [ + { name: 'Score', value: String(clan.score), inline: true }, + { name: 'Members', value: clan.maxMembers ? `${clan.memberCount}/${clan.maxMembers}` : String(clan.memberCount), inline: true }, + { name: 'Server', value: clan.serverName || hit.server.name, inline: true }, + ] + if (clan.color) fields.push({ name: 'Colour', value: clan.color, inline: true }) + + const base = { title: clan.name, url: c.pageUrl(`/rust/clans/${encodeURIComponent(clan.externalId)}`), fields } + if (!roster.visible || !roster.members.length) return base + + // Leaders first, then whoever is on, then everyone else — the order a person + // looking for "who can I talk to" wants. + const members = [...roster.members].sort( + (a, b) => Number(b.leader) - Number(a.leader) || Number(b.online) - Number(a.online) || String(a.name).localeCompare(String(b.name)), + ) + fields.push({ name: 'Roster', value: c.fitLines(members.map(memberLine)), inline: false }) + + // D127 and D48 together: a roster the whole site may see may be posted; any + // narrower one is for the caller. + return { ...base, ephemeral: roster.audience !== 'public' } +} + +module.exports = { + name: 'clan', + description: 'A clan on a Rust server — score and members, and its roster where you may see it', + options: [ + { name: 'name', type: 'string', description: 'Clan name, or the start of it', required: true }, + { name: 'server', type: 'string', description: 'Server name or id (every server when left out)', required: false }, + ], + access: 'everyone', + handler, +} diff --git a/server/commands/common.js b/server/commands/common.js new file mode 100644 index 0000000..5c6e2fd --- /dev/null +++ b/server/commands/common.js @@ -0,0 +1,187 @@ +// ── What the five commands share (phase 16, R11) ─────────────────────────── +// +// Registered with `api.registerSlashCommands` (MODULE_API 1.6.0). The handlers +// run in the WEBSITE process: core pulls the definitions over its internal API to +// the bot and dispatches each call back here. Nothing in these files knows what +// Discord is — a handler is handed an `actor` and returns an envelope. +// +// ── The two privacy rules (D127) ────────────────────────────────────────── +// +// 1. **A refusal is private.** Core already delivers an `ephemeral` answer as a +// private follow-up; `refuse()` below is the one way these files say no, and +// it always sets the flag. +// 2. **An answer narrower than `public` is private too.** Core has no reverse +// case: an answer WITHOUT the flag is posted to the channel the command was +// run in, where everybody reads it. So a moderator running `/online` in a +// public channel must get the names privately, or the names they may see are +// published to everyone who may not. That is the leak module-uo's `/guild` +// has (D132, Module-uo#46), and every answer here decides it explicitly. +// +// ── Three seconds (`HANDLER_TIMEOUT_MS`) ────────────────────────────────── +// +// Every answer reads this module's own tables. **No command asks a sidecar**: a +// game that is slow to answer would cost the reply, and the tables already hold +// the last thing each server said. + +const core = require('../core') +const servers = require('../model/servers/servers.model') +const visibility = require('../model/visibility/visibility.model') + +/** + * Where the caller sits on the presence ladder. + * + * An UNLINKED caller is `public`, answered directly. Handing `visibility.viewer` + * a synthetic request with no user would make it fall through to + * `getUserFromRequest`, which expects real cookies (module-uo's phase 3 bug). A + * linked caller is handed over as `{ user }`, which `viewer` reads first and then + * re-reads from the account row — so a banned or demoted account is judged by + * what it is now, not by what core resolved. + */ +async function levelFor(actor) { + if (!actor || actor.userId == null) return 'public' + return visibility.viewerLevel({ user: { id: actor.userId, role: actor.role } }) +} + +/** The viewer shape `model/clans` takes: `{ userId, role }` or null. */ +const viewerOf = (actor) => (actor && actor.userId != null ? { userId: actor.userId, role: actor.role || null } : null) + +/** Every answer that says no. Private, always (D127 rule 1). */ +const refuse = (text) => ({ text, ephemeral: true }) + +/** An absolute link to a page on the site. */ +const pageUrl = (path) => `${core.baseUrl}${path}` + +const serverUrl = (server) => pageUrl(`/rust/servers/${encodeURIComponent(server.id)}`) + +/** + * Which server a `server` option names, among `list`. + * + * An exact id, then an exact name (case-insensitive), then a unique prefix of + * either. Servers are added at runtime and `choices` are fixed at load, so this + * is free text matched here. A wrong-server answer is worse than "say which one", + * so two prefix matches are ambiguous rather than a guess. + * + * Answers `{ server }`, `{ all }` (no option and more than one server), + * `{ none }` (no servers at all), `{ ambiguous }` or `{ missing }`. + */ +function pickServer(list, option) { + if (!list.length) return { none: true } + const wanted = typeof option === 'string' ? option.trim().toLowerCase() : '' + if (!wanted) return list.length === 1 ? { server: list[0] } : { all: list } + + const byId = list.find((s) => s.id.toLowerCase() === wanted) + if (byId) return { server: byId } + const byName = list.filter((s) => s.name.toLowerCase() === wanted) + if (byName.length === 1) return { server: byName[0] } + + const prefix = list.filter((s) => s.id.toLowerCase().startsWith(wanted) || s.name.toLowerCase().startsWith(wanted)) + if (prefix.length === 1) return { server: prefix[0] } + if (prefix.length > 1) return { ambiguous: prefix } + return { missing: option.trim() } +} + +/** The refusal for each way `pickServer` can fail to name exactly one server. */ +function pickRefusal(picked, { needOne = false } = {}) { + const names = (list) => list.map((s) => `${s.name} (\`${s.id}\`)`).join(', ') + if (picked.none) return refuse('No Rust servers are set up on this site yet.') + if (picked.missing) return refuse(`No server matches “${picked.missing}”.`) + if (picked.ambiguous) return refuse(`Several servers match: ${names(picked.ambiguous)}. Name one.`) + if (needOne && picked.all) return refuse(`Which server? This site follows ${names(picked.all)}.`) + return null +} + +/** Every enabled server, as the public shape. */ +const listServers = (now = Date.now()) => servers.listPublic(now) + +// ── Time, in words ──────────────────────────────────────────────────────── +// +// Plain text, not a platform's timestamp markup: the envelope is platform- +// agnostic (§7.1), and a second platform would print `` literally. So an +// instant is written in UTC, with how far away it is beside it — the relative +// part is the one every reader can use without converting. + +const UTC_FORMAT = new Intl.DateTimeFormat('en-GB', { + timeZone: 'UTC', + weekday: 'short', + day: 'numeric', + month: 'short', + hour: '2-digit', + minute: '2-digit', + hourCycle: 'h23', +}) + +function relative(ms, now = Date.now()) { + const diff = ms - now + const abs = Math.abs(diff) + const unit = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}` + let span + if (abs < 60_000) span = 'less than a minute' + else if (abs < 3_600_000) span = unit(Math.round(abs / 60_000), 'minute') + else if (abs < 172_800_000) span = unit(Math.round(abs / 3_600_000), 'hour') + else span = unit(Math.round(abs / 86_400_000), 'day') + return diff >= 0 ? `in ${span}` : `${span} ago` +} + +/** `Thu 1 Oct, 18:00 UTC (in 6 days)`, or null for no instant. */ +function when(value, now = Date.now()) { + if (!value) return null + const ms = value instanceof Date ? value.getTime() : Date.parse(value) + if (Number.isNaN(ms)) return null + return `${UTC_FORMAT.format(ms).replace(/,? (\d\d:\d\d)$/, ', $1')} UTC (${relative(ms, now)})` +} + +/** Just the distance: `3 days ago`. */ +function ago(value, now = Date.now()) { + if (!value) return null + const ms = value instanceof Date ? value.getTime() : Date.parse(value) + return Number.isNaN(ms) ? null : relative(ms, now) +} + +const SOURCES = { + forced: 'the monthly forced wipe', + rule: 'the server’s own schedule', + once: 'rescheduled by the operator', +} + +/** The next wipe in words, with what decided it — or `null` for no schedule. */ +function nextWipeText(server, now = Date.now()) { + const next = server.nextWipe + if (!next) return null + return `${when(next.at, now)} — ${SOURCES[next.source] || next.source}` +} + +/** Players, as a number out of the maximum, for a server that is up. */ +const playerCount = (server) => (server.maxPlayers ? `${server.players}/${server.maxPlayers}` : String(server.players)) + +/** + * Cut a list of lines to fit one embed field (1 024 characters) and a count, + * ending in "and N more" when it had to cut. + */ +function fitLines(lines, { max = lines.length, limit = 1000 } = {}) { + const kept = [] + let length = 0 + for (const line of lines.slice(0, max)) { + if (length + line.length + 1 > limit - 20) break + kept.push(line) + length += line.length + 1 + } + const rest = lines.length - kept.length + return rest > 0 ? `${kept.join('\n')}\nand ${rest} more` : kept.join('\n') +} + +module.exports = { + levelFor, + viewerOf, + refuse, + pageUrl, + serverUrl, + pickServer, + pickRefusal, + listServers, + relative, + when, + ago, + nextWipeText, + playerCount, + fitLines, +} diff --git a/server/commands/index.js b/server/commands/index.js new file mode 100644 index 0000000..55b85c6 --- /dev/null +++ b/server/commands/index.js @@ -0,0 +1,16 @@ +// ── The slash commands (phase 16, R11, D126) ────────────────────────────── +// +// Five read-only questions, answered from this module's own tables. No write +// verbs, and no `/link`: the account link stays on R1's two surfaces. +// +// Registered as ONE batch in `index.js`. Names are bare words (§32.4 reading 1): +// Discord scopes commands to the bot that owns them, and one module per site +// (website#204) means no other module competes for them. None collides with the +// bot's own built-ins, which core cannot see and the bot resolves against us. +module.exports = [ + require('./status.command'), + require('./wipe.command'), + require('./top.command'), + require('./online.command'), + require('./clan.command'), +] diff --git a/server/commands/online.command.js b/server/commands/online.command.js new file mode 100644 index 0000000..8468886 --- /dev/null +++ b/server/commands/online.command.js @@ -0,0 +1,93 @@ +// ── `/online [server]` — how many, and who, where the caller may see it ───── +// +// The command with the gate. The org lead's rule (2026-09-22) is that nothing +// names who is online by default: the COUNT is public and the NAMES reach the +// server's presence audience (D42, per-server override D45), staff unless an +// operator widened it. +// +// And D127 on top of it: **a caller inside a narrower-than-public audience gets +// the names PRIVATELY.** The answer is posted where the command was run, and a +// moderator's `/online` in a public channel would otherwise hand the roll call to +// everyone reading the channel. Only a server whose audience is `public` posts +// its names in the open. + +const c = require('./common') +const events = require('../model/events/events.model') +const visibility = require('../model/visibility/visibility.model') + +/** Fifty names, then "and N more" (§32.4 reading 2). */ +const LIMIT = 50 + +// The link nudge, and only when it is TRUE — module-uo's rule. Linking a Discord +// account to a site account earns `signed_in` and nothing above it; `staff` is a +// role an operator grants. So a server that shows names to staff is not a reason +// to tell anybody to link. +function linkNudge(actor, required) { + if (actor && actor.isLinked) return null + if (required !== 'signed_in') return null + return 'Link your Discord account on the site to see who is on — this server shows names to signed-in members.' +} + +async function forServer(server, actor, level) { + const required = await visibility.presenceFor(server.id) + const visible = visibility.meets(level, required) + const count = server.online ? server.players : 0 + + // Offline, or nobody on: there are no names to decide about, and saying who + // was on when the server went down would be the presence board's last word + // presented as now. + if (!server.online) return { text: `${server.name} is offline.`, url: c.serverUrl(server) } + if (!visible || count === 0) { + return { + title: `${server.name} — ${count} online`, + url: c.serverUrl(server), + ...(visible ? {} : { notice: linkNudge(actor, required) }), + } + } + + const players = await events.online(server.id) + const names = players.map((p) => (p.sleeping ? `${p.name || 'Unknown player'} (sleeping)` : p.name || 'Unknown player')) + return { + title: `${server.name} — ${players.length} online`, + url: c.serverUrl(server), + text: c.fitLines(names, { max: LIMIT, limit: 1900 }), + // D127. Anything short of `public` is for the caller alone. + ephemeral: required !== 'public', + } +} + +async function handler({ options, actor }) { + const now = Date.now() + const picked = c.pickServer(await c.listServers(now), options && options.server) + const refusal = c.pickRefusal(picked) + if (refusal) return refusal + + const level = await c.levelFor(actor) + if (picked.server) return forServer(picked.server, actor, level) + + // The fleet: counts only, which are public. Names are one server's question, + // and the closing line offers it only when naming a server would show some. + const visibleSomewhere = ( + await Promise.all(picked.all.map(async (s) => visibility.meets(level, await visibility.presenceFor(s.id)))) + ).some(Boolean) + return { + title: 'Online now', + url: c.pageUrl('/rust'), + fields: picked.all.slice(0, 25).map((s) => ({ + name: s.name, + value: s.online ? `${s.players} online` : 'Offline', + inline: true, + })), + ...(visibleSomewhere ? { text: 'Name a server to see who is on.' } : {}), + } +} + +module.exports = { + name: 'online', + description: 'How many are on a Rust server, and who, where this site shows names', + options: [{ name: 'server', type: 'string', description: 'Server name or id (counts for all when left out)', required: false }], + // Everyone, deliberately: `linked` would hide the command from the unlinked + // members the nudge exists to invite. The gate is inside the handler. + access: 'everyone', + handler, +} diff --git a/server/commands/status.command.js b/server/commands/status.command.js new file mode 100644 index 0000000..266acc4 --- /dev/null +++ b/server/commands/status.command.js @@ -0,0 +1,60 @@ +// ── `/status [server]` — is it up, how full, when did it wipe ─────────────── +// +// Public at every setting: everything here is on the public server list already. +// The count is a number and names nobody; the names are `/online`'s business. + +const c = require('./common') + +/** One line per server, for the fleet view. */ +function summary(server, now) { + const parts = [server.online ? `Online · ${c.playerCount(server)}` : 'Offline'] + if (server.wipedAt) parts.push(`wiped ${c.ago(server.wipedAt, now)}`) + return parts.join(' · ') +} + +function detail(server, now) { + const fields = [ + { name: 'Status', value: server.online ? 'Online' : 'Offline', inline: true }, + { name: 'Players', value: server.online ? c.playerCount(server) : '—', inline: true }, + ] + if (server.worldSize) { + fields.push({ name: 'Map', value: server.seed != null ? `${server.worldSize} · seed ${server.seed}` : String(server.worldSize), inline: true }) + } + if (server.wipedAt) fields.push({ name: 'Last wipe', value: c.when(server.wipedAt, now), inline: false }) + const next = c.nextWipeText(server, now) + if (next) fields.push({ name: 'Next wipe', value: next, inline: false }) + + // An offline server says when it was last heard from, which is the difference + // between "down for a restart" and "gone for a week". A server nothing has ever + // heard from says so rather than printing the epoch. + if (!server.online) { + fields.push({ name: 'Last seen', value: server.lastSeenAt ? c.when(server.lastSeenAt, now) : 'never', inline: false }) + } + return { title: server.name, url: c.serverUrl(server), fields } +} + +async function handler({ options }) { + const now = Date.now() + const picked = c.pickServer(await c.listServers(now), options && options.server) + const refusal = c.pickRefusal(picked) + if (refusal) return refusal + if (picked.server) return detail(picked.server, now) + + // An embed takes 25 fields. A fleet larger than that is a site that has a + // server list page, and the title links to it. + return { + title: 'Rust servers', + url: c.pageUrl('/rust'), + fields: picked.all.slice(0, 25).map((s) => ({ name: s.name, value: summary(s, now), inline: false })), + } +} + +module.exports = { + name: 'status', + description: 'Is a Rust server up, how many are on, and when it last wiped', + options: [{ name: 'server', type: 'string', description: 'Server name or id (all servers when left out)', required: false }], + // Everyone: nothing here is narrower than public, and the gates that DO matter + // in other commands are resolved inside their handlers. + access: 'everyone', + handler, +} diff --git a/server/commands/top.command.js b/server/commands/top.command.js new file mode 100644 index 0000000..ef396b4 --- /dev/null +++ b/server/commands/top.command.js @@ -0,0 +1,85 @@ +// ── `/top [stat] [server] [alltime]` — the leaderboard's top ten (D126) ───── +// +// Public at every setting, as on the web: the leaderboard's NAMES are public, and +// only `lastSeen` sits behind presence (a tally refreshes it every minute a player +// is on, so it is the Online tab by another name). This answer never carries a +// `lastSeen`, and never a Steam id — `events.leaderboard` is asked with +// `presence: false`, so the field is not there to leak. + +const c = require('./common') +const events = require('../model/events/events.model') + +/** Ten rows: a summary a person reads, not a table they scroll (§32.4 reading 2). */ +const LIMIT = 10 + +// `stat`'s choices are fixed, so they are a `choices` list rather than free +// text. The value is the leaderboard's own sort key. +const STATS = { + kills: { sort: 'kills', label: 'kills', value: (r) => r.kills }, + deaths: { sort: 'deaths', label: 'deaths', value: (r) => r.deaths }, + npckills: { sort: 'npcKills', label: 'NPC kills', value: (r) => r.npcKills }, + playtime: { sort: 'playtime', label: 'playtime', value: (r) => r.playtimeSec, format: hours }, +} + +function hours(sec) { + const h = Math.floor(sec / 3600) + const m = Math.floor((sec % 3600) / 60) + return h ? `${h}h ${m}m` : `${m}m` +} + +async function handler({ options }) { + const now = Date.now() + const stat = STATS[(options && options.stat) || 'kills'] || STATS.kills + + // A leaderboard is one server's. With several and none named the command asks + // rather than choosing one, privately — the question is for the caller. + const picked = c.pickServer(await c.listServers(now), options && options.server) + const refusal = c.pickRefusal(picked, { needOne: true }) + if (refusal) return refusal + const { server } = picked + + // The current wipe unless asked for all-time. A server that has not reported + // a wipe yet has nothing to scope to, and all-time is the honest answer. + const allTime = Boolean(options && options.alltime) || !server.wipeId + const rows = await events.leaderboard({ + serverId: server.id, + wipeId: allTime ? null : server.wipeId, + sort: stat.sort, + limit: LIMIT, + presence: false, + }) + + const scope = allTime ? 'all time' : 'this wipe' + const title = `${server.name} — top ${stat.label}, ${scope}` + if (!rows.length) return { title, url: c.serverUrl(server), text: `Nobody is on the board for ${scope} yet.` } + + const format = stat.format || String + return { + title, + url: c.serverUrl(server), + text: rows.map((r, i) => `${i + 1}. ${r.name || 'Unknown player'} — ${format(stat.value(r))}`).join('\n'), + } +} + +module.exports = { + name: 'top', + description: 'The top ten players on a Rust server by kills, deaths, NPC kills or playtime', + options: [ + { + name: 'stat', + type: 'string', + description: 'What to rank by (kills when left out)', + required: false, + choices: [ + { name: 'Kills', value: 'kills' }, + { name: 'Deaths', value: 'deaths' }, + { name: 'NPC kills', value: 'npckills' }, + { name: 'Playtime', value: 'playtime' }, + ], + }, + { name: 'server', type: 'string', description: 'Server name or id (needed when there are several)', required: false }, + { name: 'alltime', type: 'boolean', description: 'Every wipe rather than the current one', required: false }, + ], + access: 'everyone', + handler, +} diff --git a/server/commands/wipe.command.js b/server/commands/wipe.command.js new file mode 100644 index 0000000..ac914b2 --- /dev/null +++ b/server/commands/wipe.command.js @@ -0,0 +1,38 @@ +// ── `/wipe [server]` — when it wipes next, and when it last did (D128) ────── +// +// Public: a wipe date is announced to bring players back. The next wipe is the +// operator's schedule, computed on this read (`model/servers/nextWipe.js`); a +// server with no schedule says so and shows only the last wipe it reported. + +const c = require('./common') + +function lines(server, now) { + return [ + `Next: ${c.nextWipeText(server, now) || 'no schedule set'}`, + `Last: ${server.wipedAt ? c.when(server.wipedAt, now) : 'not reported yet'}`, + ].join('\n') +} + +async function handler({ options }) { + const now = Date.now() + const picked = c.pickServer(await c.listServers(now), options && options.server) + const refusal = c.pickRefusal(picked) + if (refusal) return refusal + + if (picked.server) { + return { title: `${picked.server.name} — wipes`, url: c.serverUrl(picked.server), text: lines(picked.server, now) } + } + return { + title: 'Wipes', + url: c.pageUrl('/rust'), + fields: picked.all.slice(0, 25).map((s) => ({ name: s.name, value: lines(s, now), inline: false })), + } +} + +module.exports = { + name: 'wipe', + description: 'When a Rust server wipes next, and when it last wiped', + options: [{ name: 'server', type: 'string', description: 'Server name or id (all servers when left out)', required: false }], + access: 'everyone', + handler, +} diff --git a/server/core.js b/server/core.js index e68f0a9..4d932c6 100644 --- a/server/core.js +++ b/server/core.js @@ -172,4 +172,10 @@ module.exports = { // core's and the module's location is the loader's business. get moduleRoot() { return need().paths.moduleRoot }, get moduleId() { return need().moduleId }, + + // The site's public address, with no trailing slash (§2.3, 1.1.0). Phase 16's + // slash commands link an answer's title to its page; a chat message has no + // origin to be relative to. A getter over core's getter, so an operator who + // changes `APP_BASE_URL` is read at the call, never captured at load. + get baseUrl() { return need().site.baseUrl }, } diff --git a/server/db/schema.sql b/server/db/schema.sql index fc790f2..b45c346 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -900,3 +900,30 @@ CREATE TABLE IF NOT EXISTS rust_map_overrides ( CONSTRAINT fk_rust_map_overrides_user FOREIGN KEY (updated_by) REFERENCES users (id) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ── The next wipe (phase 16, D128 · D130) ───────────────────────────────── +-- +-- What an operator says about a server's wipe calendar: a rule, and an optional +-- one-off date. The next wipe itself is NOT stored — it is computed from these on +-- every read (`model/servers/nextWipe.js`), so nothing rolls it forward after a +-- wipe and nothing can go stale. +-- +-- wipe_rule `none` · `forced` · `weekly` · `biweekly`. `none` forecasts +-- nothing; every other rule includes Facepunch's forced wipe. +-- wipe_day 0 (Sunday) … 6, for weekly and biweekly +-- wipe_time `HH:MM`, 24-hour, on the wall clock of… +-- wipe_tz an IANA zone. The server's own, because a wipe announced as +-- "Thursday 14:00" means 14:00 where the operator is, summer or +-- winter; a UTC time would drift by an hour twice a year. +-- wipe_anchor one wipe on a biweekly rule, which says WHICH weeks +-- wipe_once_at a one-off wipe, UTC. While it is in the future it IS the next +-- wipe; once passed it is ignored rather than cleared. +-- +-- A word this build does not recognise reads as `none`: a forecast nobody made +-- must not appear. +ALTER TABLE rust_servers ADD COLUMN IF NOT EXISTS wipe_rule VARCHAR(16) NOT NULL DEFAULT 'none'; +ALTER TABLE rust_servers ADD COLUMN IF NOT EXISTS wipe_day TINYINT NULL; +ALTER TABLE rust_servers ADD COLUMN IF NOT EXISTS wipe_time CHAR(5) NULL; +ALTER TABLE rust_servers ADD COLUMN IF NOT EXISTS wipe_tz VARCHAR(64) NULL; +ALTER TABLE rust_servers ADD COLUMN IF NOT EXISTS wipe_anchor DATE NULL; +ALTER TABLE rust_servers ADD COLUMN IF NOT EXISTS wipe_once_at DATETIME NULL; diff --git a/server/index.js b/server/index.js index 238e882..b451369 100644 --- a/server/index.js +++ b/server/index.js @@ -60,6 +60,7 @@ module.exports = function register(ctx, api) { const eventWorld = require('./eventWorld') const eventRewards = require('./eventRewards') const boot = require('./boot') + const commands = require('./commands') /* eslint-enable global-require */ const log = core.logger() @@ -183,11 +184,12 @@ module.exports = function register(ctx, api) { ...eventRewards.OPTION_SOURCES, ]) - // Everything else this module will register — the slash commands — is - // deliberately absent, and arrives with the phase that has something real to - // put in it. A registration with nothing behind it is worse than a missing - // one: a declared trigger nothing emits and a declared slot nothing fills are - // both surfaces an operator can configure and then wait on. + // The slash commands (phase 16, R11). The handlers run HERE, in the website + // process; the bot pulls the definitions and dispatches each call back. Every + // command is `access: 'everyone'` and resolves its own audience gate inside the + // handler, and every answer narrower than public is sent to the caller alone + // (D127) — see `commands/common.js`. + api.registerSlashCommands(commands) log.info('registered', { version: require('../module.json').version, @@ -201,6 +203,7 @@ module.exports = function register(ctx, api) { actions: eventWorld.ACTIONS.length + eventRewards.ACTIONS.length, budgets: eventWorld.BUDGETS.length + eventRewards.BUDGETS.length, announceLeg: eventRewards.LEG.leg, + commands: commands.map((cmd) => cmd.name).join(' '), optionSources: eventLeases.OPTION_SOURCES.length + eventWorld.OPTION_SOURCES.length + eventRewards.OPTION_SOURCES.length, }) } diff --git a/server/model/servers/nextWipe.js b/server/model/servers/nextWipe.js new file mode 100644 index 0000000..7d21c19 --- /dev/null +++ b/server/model/servers/nextWipe.js @@ -0,0 +1,281 @@ +// ── When a server wipes next (phase 16, D128 · D130) ──────────────────────── +// +// The module knows every PAST wipe — each one is a fact a frame carried — and +// nothing about the next. D128 made the next one something an operator states, +// and D130 made the statement a RULE plus an optional one-off date, so it never +// goes stale: a rule computes the next wipe from the clock, and once a wipe has +// happened the rule simply names the one after it. +// +// **Computed on every read, never stored** (PLAN.md §32.4 reading 7). Nothing has +// to roll it forward after a wipe, and nothing can disagree with it. +// +// ── The rules ───────────────────────────────────────────────────────────── +// +// none no forecast. The operator has not said the server follows any +// calendar, so the game's forced wipe is NOT assumed either. +// forced Facepunch's forced wipe and nothing else. +// weekly every `wipe_day` at `wipe_time` in `wipe_tz`, AND the forced wipe. +// biweekly every other `wipe_day`, on the weeks `wipe_anchor` falls in, AND +// the forced wipe. +// +// Every rule includes the forced wipe because Facepunch forces it on every server +// whatever its own schedule (reading 3): a weekly server's next wipe is the +// earlier of its own next day and the first Thursday of the month. +// +// A one-off date, while it is in the future, IS the next wipe, and any computed +// wipe before it is skipped (reading 5). That one reading covers both of D130's +// cases — a date after the computed wipe delays it, a date before it adds one — +// and it applies under `none` too: an operator who states a date has stated a +// forecast. Once the date has passed it is ignored rather than cleared. +// +// ── The zone arithmetic ─────────────────────────────────────────────────── +// +// Core offers modules none (`events/recurrence.js` is core's own, and §2.7 forbids +// importing it), so it is done here through `Intl`, which Node ships with full +// ICU. Calendar dates are counted as whole days since the epoch — a local date is +// a date, not an instant — and turned into an instant only at the end, in the +// server's own zone. +// +// A wall-clock time that does not exist (the hour skipped in spring) moves +// FORWARD by the gap, and one that happens twice (the hour repeated in autumn) +// takes the FIRST occurrence (reading 6). That is Temporal's `compatible` +// disambiguation, and the tests pin both edges in both zones the walk uses. + +/** Facepunch's forced wipe: the first Thursday of the month, 19:00 UK time (reading 4). */ +const FORCED = Object.freeze({ weekday: 4, time: '19:00', tz: 'Europe/London' }) + +const RULES = Object.freeze(['none', 'forced', 'weekly', 'biweekly']) + +const DAY_MS = 86_400_000 + +const formatters = new Map() + +/** One cached formatter per zone; building one costs far more than using it. */ +function formatterFor(tz) { + let fmt = formatters.get(tz) + if (!fmt) { + fmt = new Intl.DateTimeFormat('en-US', { + timeZone: tz, + hourCycle: 'h23', + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + second: 'numeric', + }) + formatters.set(tz, fmt) + } + return fmt +} + +/** Is `tz` a zone this process can compute in? `Intl` throws a RangeError on one it cannot. */ +function isZone(tz) { + if (typeof tz !== 'string' || !tz) return false + try { + formatterFor(tz) + return true + } catch { + return false + } +} + +/** The wall clock in `tz` at instant `ms`, as `{ y, m, d, hh, mm, ss }`. */ +function wallClock(ms, tz) { + const parts = {} + for (const p of formatterFor(tz).formatToParts(new Date(ms))) parts[p.type] = p.value + return { + y: Number(parts.year), + m: Number(parts.month), + d: Number(parts.day), + hh: Number(parts.hour), + mm: Number(parts.minute), + ss: Number(parts.second), + } +} + +/** How far `tz` is ahead of UTC at instant `ms`, in milliseconds. */ +function offsetAt(ms, tz) { + const w = wallClock(ms, tz) + const asUtc = Date.UTC(w.y, w.m - 1, w.d, w.hh, w.mm, w.ss) + return asUtc - Math.floor(ms / 1000) * 1000 +} + +/** + * The instant at which `tz`'s wall clock reads `day` (days since the epoch) at + * `hh:mm`, disambiguated as the header says. + * + * Offsets change at most once a day, so the offsets a day either side are the + * only two a wall time can have. Each gives a candidate; a candidate is real if + * the zone's offset AT it is the one that produced it. + */ +function instantOf(day, hh, mm, tz) { + const naive = day * DAY_MS + (hh * 60 + mm) * 60_000 + const before = offsetAt(naive - DAY_MS, tz) + const after = offsetAt(naive + DAY_MS, tz) + const candidates = [naive - before, naive - after].filter((t, i) => offsetAt(t, tz) === (i === 0 ? before : after)) + + // A skipped hour: neither candidate reads back as that wall time. Using the + // offset from BEFORE the gap lands the same distance past it — 01:30 in a + // spring-forward from 01:00 to 02:00 becomes 02:30. + if (!candidates.length) return naive - before + return Math.min(...candidates) +} + +/** Today's date in `tz`, as days since the epoch. */ +function localDay(ms, tz) { + const w = wallClock(ms, tz) + return Math.floor(Date.UTC(w.y, w.m - 1, w.d) / DAY_MS) +} + +/** 0 = Sunday … 6 = Saturday, for a day count. 1970-01-01 was a Thursday. */ +const weekdayOf = (day) => (((day + 4) % 7) + 7) % 7 + +/** `HH:MM` → `[hh, mm]`, or null. */ +function parseTime(value) { + const match = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(String(value || '')) + return match ? [Number(match[1]), Number(match[2])] : null +} + +/** `YYYY-MM-DD` (or a Date) → days since the epoch, or null. */ +function parseDay(value) { + if (value instanceof Date) { + if (Number.isNaN(value.getTime())) return null + return Math.floor(Date.UTC(value.getFullYear(), value.getMonth(), value.getDate()) / DAY_MS) + } + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(value || '')) + if (!match) return null + const ms = Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])) + const back = new Date(ms) + // Reject a date that rolled over (2026-02-30 is not 2026-03-02). + if (back.getUTCDate() !== Number(match[3])) return null + return Math.floor(ms / DAY_MS) +} + +/** The first forced wipe strictly after `now`. */ +function nextForced(now) { + const [hh, mm] = parseTime(FORCED.time) + const today = wallClock(now, FORCED.tz) + + // This month's first Thursday, then next month's. Two are always enough: a + // month's forced wipe that has passed is followed by next month's. + for (let step = 0; step < 2; step += 1) { + const first = Math.floor(Date.UTC(today.y, today.m - 1 + step, 1) / DAY_MS) + const thursday = first + ((FORCED.weekday - weekdayOf(first) + 7) % 7) + const at = instantOf(thursday, hh, mm, FORCED.tz) + if (at > now) return at + } + return null +} + +/** The first wipe a weekly or biweekly rule names strictly after `now`, or null. */ +function nextByRule(row, now) { + const time = parseTime(row.wipeTime) + const day = Number(row.wipeDay) + if (!time || !Number.isInteger(day) || day < 0 || day > 6 || !isZone(row.wipeTz)) return null + + const anchor = row.wipeRule === 'biweekly' ? parseDay(row.wipeAnchor) : null + if (row.wipeRule === 'biweekly' && anchor == null) return null + + // Start a day early: "today" is judged in the server's zone and `now` may be a + // few hours either side of it in another. Three weeks covers a biweekly rule + // whose on-week has just passed. + const start = localDay(now, row.wipeTz) - 1 + for (let d = start; d < start + 22; d += 1) { + if (weekdayOf(d) !== day) continue + // eslint-disable-next-line no-continue + if (anchor != null && (((d - anchor) % 14) + 14) % 14 >= 7) continue + const at = instantOf(d, time[0], time[1], row.wipeTz) + if (at > now) return at + } + return null +} + +/** A one-off date as an instant, or null. */ +function onceOf(value) { + if (value == null || value === '') return null + const ms = value instanceof Date ? value.getTime() : Date.parse(value) + return Number.isNaN(ms) ? null : ms +} + +/** + * When `row` wipes next, and what decided it. + * + * `row` carries the six schedule fields in their camelCase names (`wipeRule`, + * `wipeDay`, `wipeTime`, `wipeTz`, `wipeAnchor`, `wipeOnceAt`). Answers + * `{ at, source }` — `at` an ISO instant, `source` one of `once`, `forced` or + * `rule` — or `null` when no forecast can honestly be made. + * + * Throws nothing: a row with a zone this process does not know, or a time that + * does not parse, answers what the rest of it can (the forced wipe still stands) + * rather than failing the page that asked. + */ +function nextWipe(row, now = Date.now()) { + if (!row) return null + + const once = onceOf(row.wipeOnceAt) + if (once != null && once > now) return { at: new Date(once).toISOString(), source: 'once' } + + const rule = RULES.includes(row.wipeRule) ? row.wipeRule : 'none' + if (rule === 'none') return null + + const forced = nextForced(now) + const own = rule === 'forced' ? null : nextByRule(row, now) + + // On a tie the forced wipe is the reason: it happens whatever the rule says. + if (own != null && (forced == null || own < forced)) return { at: new Date(own).toISOString(), source: 'rule' } + if (forced != null) return { at: new Date(forced).toISOString(), source: 'forced' } + return null +} + +/** + * Check an operator's schedule before it is saved. Answers a list of sentences, + * empty when the schedule is sound — the admin form shows them as they are. + * + * The rule's own fields are required only by the rules that read them, and the + * one-off date must be in the future on save: a date in the past says nothing + * about the next wipe, and accepting one would be storing a mistake. + */ +function validateSchedule(schedule, now = Date.now()) { + const errors = [] + const rule = schedule.wipeRule == null ? 'none' : schedule.wipeRule + if (!RULES.includes(rule)) errors.push(`The wipe rule is one of ${RULES.join(', ')}, not "${rule}".`) + + if (rule === 'weekly' || rule === 'biweekly') { + const day = Number(schedule.wipeDay) + if (schedule.wipeDay == null || schedule.wipeDay === '' || !Number.isInteger(day) || day < 0 || day > 6) { + errors.push('A weekly or biweekly rule needs a day of the week.') + } + if (!parseTime(schedule.wipeTime)) errors.push('The wipe time is HH:MM, on a 24-hour clock.') + if (!isZone(schedule.wipeTz)) errors.push(`"${schedule.wipeTz || ''}" is not a time zone this site knows (use an IANA name such as Europe/London).`) + } + + if (rule === 'biweekly') { + const anchor = parseDay(schedule.wipeAnchor) + if (anchor == null) { + errors.push('A biweekly rule needs the date of one wipe on it, as YYYY-MM-DD.') + } else if (Number.isInteger(Number(schedule.wipeDay)) && weekdayOf(anchor) !== Number(schedule.wipeDay)) { + errors.push('The biweekly rule’s date must fall on its day of the week.') + } + } + + if (schedule.wipeOnceAt != null && schedule.wipeOnceAt !== '') { + const once = onceOf(schedule.wipeOnceAt) + if (once == null) errors.push('The one-off wipe is not a date and time.') + else if (once <= now) errors.push('The one-off wipe must be in the future.') + } + + return errors +} + +module.exports = { + FORCED, + RULES, + nextWipe, + validateSchedule, + isZone, + // Exposed for the tests, which pin the arithmetic directly. + instantOf, + parseDay, + weekdayOf, +} diff --git a/server/model/servers/servers.db.js b/server/model/servers/servers.db.js index ca61734..439acd3 100644 --- a/server/model/servers/servers.db.js +++ b/server/model/servers/servers.db.js @@ -24,6 +24,8 @@ async function listServers({ enabledOnly = false } = {}) { return core.query( `SELECT id, name, sidecar_base_url AS sidecarBaseUrl, sidecar_token_enc AS sidecarTokenEnc, protocol, enabled, sort_order AS sortOrder, announce_news AS announceNews, + wipe_rule AS wipeRule, wipe_day AS wipeDay, wipe_time AS wipeTime, wipe_tz AS wipeTz, + DATE_FORMAT(wipe_anchor, '%Y-%m-%d') AS wipeAnchor, wipe_once_at AS wipeOnceAt, created_at AS createdAt, updated_at AS updatedAt FROM ${SERVERS} ${enabledOnly ? 'WHERE enabled = 1' : ''} @@ -34,7 +36,10 @@ async function listServers({ enabledOnly = false } = {}) { async function getServer(id) { const rows = await core.query( `SELECT id, name, sidecar_base_url AS sidecarBaseUrl, sidecar_token_enc AS sidecarTokenEnc, - protocol, enabled, sort_order AS sortOrder, created_at AS createdAt, updated_at AS updatedAt + protocol, enabled, sort_order AS sortOrder, + wipe_rule AS wipeRule, wipe_day AS wipeDay, wipe_time AS wipeTime, wipe_tz AS wipeTz, + DATE_FORMAT(wipe_anchor, '%Y-%m-%d') AS wipeAnchor, wipe_once_at AS wipeOnceAt, + created_at AS createdAt, updated_at AS updatedAt FROM ${SERVERS} WHERE id = ?`, [id], @@ -71,6 +76,26 @@ async function upsertServer({ id, name, sidecarBaseUrl, sidecarTokenEnc, protoco ) } +/** + * Write one server's wipe schedule (phase 16, D130), all six columns at once. + * + * Its own statement rather than six more columns on `upsertServer`, because the + * schedule is written only when a save CARRIES one: a client that predates the + * schedule and posts the rest of the row must not reset it to `none`. + * + * `wipeOnceAt` is a Date or null. The pool negotiates the session's zone + * (`timezone: 'auto'` in core), so a Date written here reads back as the same + * instant. + */ +async function setSchedule(id, { wipeRule, wipeDay, wipeTime, wipeTz, wipeAnchor, wipeOnceAt }) { + await core.query( + `UPDATE ${SERVERS} + SET wipe_rule = ?, wipe_day = ?, wipe_time = ?, wipe_tz = ?, wipe_anchor = ?, wipe_once_at = ? + WHERE id = ?`, + [wipeRule, wipeDay, wipeTime, wipeTz, wipeAnchor, wipeOnceAt, id], + ) +} + async function deleteServer(id) { await core.query(`DELETE FROM ${SERVERS} WHERE id = ?`, [id]) } @@ -188,6 +213,7 @@ module.exports = { listServers, getServer, upsertServer, + setSchedule, deleteServer, listState, getState, diff --git a/server/model/servers/servers.model.js b/server/model/servers/servers.model.js index 5129c27..a17408e 100644 --- a/server/model/servers/servers.model.js +++ b/server/model/servers/servers.model.js @@ -16,6 +16,7 @@ const core = require('../../core') const db = require('./servers.db') +const { nextWipe } = require('./nextWipe') const log = core.logger('servers') @@ -136,6 +137,27 @@ function shapePublic(row, state, now) { lastSeenAt: lastSeenAt ? lastSeenAt.toISOString() : null, updatedAt: updatedAt ? updatedAt.toISOString() : null, stale, + // Phase 16 (D130): `{ at, source }` or null, computed from the operator's + // schedule on every read — never stored, so it cannot go stale after a wipe. + // Public: a wipe date is announced to bring players back, not kept secret. + nextWipe: nextWipe(row, now), + } +} + +/** + * The schedule as stored, for the admin form: what the operator typed, not what + * it computes to. A one-off date that has passed is still returned, and the form + * shows it as past (§32.4 reading 5) rather than silently dropping it. + */ +function scheduleOf(row) { + const once = row.wipeOnceAt ? new Date(row.wipeOnceAt) : null + return { + rule: row.wipeRule || 'none', + day: row.wipeDay == null ? null : Number(row.wipeDay), + time: row.wipeTime || null, + tz: row.wipeTz || null, + anchor: row.wipeAnchor || null, + onceAt: once && !Number.isNaN(once.getTime()) ? once.toISOString() : null, } } @@ -184,6 +206,7 @@ async function listForAdmin(now = Date.now()) { reachable: Boolean(state && state.reachable), bootId: (state && state.bootId) || null, sidecarProtocol: state && state.protocol != null ? Number(state.protocol) : null, + schedule: scheduleOf(row), } }) } @@ -204,5 +227,6 @@ module.exports = { getPublic, listForAdmin, shapePublic, + scheduleOf, encryptToken, } diff --git a/server/router/admin/rust.controller.js b/server/router/admin/rust.controller.js index a518cdd..ab37e8d 100644 --- a/server/router/admin/rust.controller.js +++ b/server/router/admin/rust.controller.js @@ -14,6 +14,7 @@ const core = require('../../core') const db = require('../../model/servers/servers.db') const mapImages = require('../../mapImages') +const nextWipe = require('../../model/servers/nextWipe') const servers = require('../../model/servers/servers.model') const sidecar = require('../../sidecarClient') @@ -28,6 +29,29 @@ async function listServers(req, res) { } } +/** + * The wipe schedule a save carries (phase 16, D130), normalised for storage — or + * `null` when the body carries none, which leaves the stored one alone. + * + * Only the fields the rule reads are kept: a weekly rule's day left behind after + * the operator switched to `forced` would be a value nothing reads and the form + * would show back to them as if it meant something. + */ +function scheduleFrom(body) { + if (body.wipeRule === undefined) return null + const rule = body.wipeRule + const weekly = rule === 'weekly' || rule === 'biweekly' + const blank = (v) => v === undefined || v === null || v === '' + return { + wipeRule: rule, + wipeDay: weekly ? Number(body.wipeDay) : null, + wipeTime: weekly ? body.wipeTime : null, + wipeTz: weekly ? body.wipeTz : null, + wipeAnchor: rule === 'biweekly' ? body.wipeAnchor : null, + wipeOnceAt: blank(body.wipeOnceAt) ? null : new Date(body.wipeOnceAt), + } +} + async function putServer(req, res) { const { id } = req.params const { name, sidecarBaseUrl, sidecarToken, protocol, enabled, sortOrder } = req.body @@ -43,6 +67,13 @@ async function putServer(req, res) { return res.status(400).json({ message: 'A new server needs its sidecar token' }) } + // Checked whole before anything is written, so a bad schedule saves nothing + // rather than half a row. The sentences go back as they are: the form shows + // them beside the fields. + const schedule = scheduleFrom(req.body) + const problems = schedule ? nextWipe.validateSchedule(req.body) : [] + if (problems.length) return res.status(400).json({ message: problems.join(' '), errors: problems }) + await db.upsertServer({ id, name, @@ -56,6 +87,7 @@ async function putServer(req, res) { enabled: enabled === undefined ? true : enabled, sortOrder: sortOrder === undefined ? 0 : sortOrder, }) + if (schedule) await db.setSchedule(id, schedule) await core.activity.log({ req, @@ -66,6 +98,9 @@ async function putServer(req, res) { sidecarBaseUrl, // Whether the credential was rotated, never the credential. tokenChanged: Boolean(sidecarToken), + // The schedule as written, when the save carried one. Nothing in it is a + // secret, and "who moved the wipe" is a question players will ask. + ...(schedule ? { schedule: { ...schedule, wipeOnceAt: schedule.wipeOnceAt ? schedule.wipeOnceAt.toISOString() : null } } : {}), }, }) @@ -183,4 +218,4 @@ async function renderMap(req, res) { } } -module.exports = { listServers, putServer, deleteServer, testServer, fetchMap, renderMap } +module.exports = { scheduleFrom, listServers, putServer, deleteServer, testServer, fetchMap, renderMap } diff --git a/server/router/admin/rust.router.js b/server/router/admin/rust.router.js index 959111e..20440f8 100644 --- a/server/router/admin/rust.router.js +++ b/server/router/admin/rust.router.js @@ -55,7 +55,8 @@ adminRustRouter.put( '/servers/:id', // #swagger.tags = ['Admin · Rust'] // #swagger.summary = 'Create or update a Rust server' - // #swagger.description = 'Writes one server row. `sidecarToken` is write-only — send it to set or rotate the credential, and omit it or send an empty string to leave the stored one untouched. The id is the slug every URL under the module carries.' + // #swagger.description = 'Writes one server row. `sidecarToken` is write-only — send it to set or rotate the credential, and omit it or send an empty string to leave the stored one untouched. The id is the slug every URL under the module carries. The wipe schedule (`wipeRule`, `wipeDay`, `wipeTime`, `wipeTz`, `wipeAnchor`, `wipeOnceAt`) is written only when `wipeRule` is present, so a body without it leaves the stored schedule alone. `wipeRule` is `none`, `forced`, `weekly` or `biweekly`; a weekly or biweekly rule needs `wipeDay` (0 = Sunday), `wipeTime` (`HH:MM`) and an IANA `wipeTz`, and a biweekly rule a `wipeAnchor` date on that day. `wipeOnceAt` is a one-off wipe in the future. A 400 carries one sentence per problem in `errors`.' + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/RustServerSave" } } } } */ /* #swagger.responses[204] = { description: 'Saved' } */ /* #swagger.responses[400] = { description: 'Invalid body' } */ requireRole('admin'), @@ -72,6 +73,16 @@ adminRustRouter.put( body('protocol').optional().isInt({ min: 1, max: 1000 }).toInt(), body('enabled').optional().isBoolean().toBoolean(), body('sortOrder').optional().isInt({ min: -1000, max: 1000 }).toInt(), + // The wipe schedule (phase 16, D130). Shape only here; the rules that span + // fields — a day for a weekly rule, an anchor on that day, a known zone, a + // one-off date in the future — are `nextWipe.validateSchedule`'s, in the + // controller, so the form gets one sentence per problem. + body('wipeRule').optional().isIn(['none', 'forced', 'weekly', 'biweekly']), + body('wipeDay').optional({ values: 'null' }).isInt({ min: 0, max: 6 }), + body('wipeTime').optional({ values: 'null' }).isString().isLength({ max: 5 }), + body('wipeTz').optional({ values: 'null' }).isString().isLength({ max: 64 }), + body('wipeAnchor').optional({ values: 'falsy' }).isISO8601({ strict: true }).isLength({ max: 10 }), + body('wipeOnceAt').optional({ values: 'falsy' }).isISO8601({ strict: true }), validate, admin.putServer, ) diff --git a/server/swagger/doc.js b/server/swagger/doc.js index bbfb41c..3d4c469 100644 --- a/server/swagger/doc.js +++ b/server/swagger/doc.js @@ -65,6 +65,46 @@ module.exports = { description: 'Has nothing reported in longer than the freshness window? A stale row is reported offline.', example: false, }, + nextWipe: { $ref: '#/components/schemas/RustNextWipe' }, + }, + }, + RustNextWipe: { + type: 'object', + nullable: true, + description: 'When the server wipes next, computed from the operator’s schedule on every read, or null when no schedule is set. `source` is `forced` (Facepunch’s monthly forced wipe, first Thursday 19:00 UK time), `rule` (the server’s own weekly or biweekly day) or `once` (a one-off date the operator set, which replaces any computed wipe before it). Absent from a module older than phase 16.', + properties: { + at: { type: 'string', format: 'date-time', example: '2026-10-01T18:00:00.000Z' }, + source: { type: 'string', enum: ['forced', 'rule', 'once'], example: 'forced' }, + }, + }, + RustWipeSchedule: { + type: 'object', + description: 'A server’s wipe schedule as the operator stored it — what they typed, not what it computes to. A past `onceAt` is still returned so the form can show it as past.', + properties: { + rule: { type: 'string', enum: ['none', 'forced', 'weekly', 'biweekly'], example: 'weekly' }, + day: { type: 'integer', nullable: true, description: '0 = Sunday … 6 = Saturday', example: 4 }, + time: { type: 'string', nullable: true, example: '14:00' }, + tz: { type: 'string', nullable: true, example: 'America/Chicago' }, + anchor: { type: 'string', format: 'date', nullable: true, example: null }, + onceAt: { type: 'string', format: 'date-time', nullable: true, example: null }, + }, + }, + RustServerSave: { + type: 'object', + description: 'The body of PUT /admin/rust/servers/{id}. The wipe fields are written only when `wipeRule` is present.', + properties: { + name: { type: 'string', example: 'Main · Vanilla' }, + sidecarBaseUrl: { type: 'string', example: 'http://10.0.0.5:8090' }, + sidecarToken: { type: 'string', description: 'Write-only. Omit or send empty to keep the stored one.', example: '' }, + protocol: { type: 'integer', example: 11 }, + enabled: { type: 'boolean', example: true }, + sortOrder: { type: 'integer', example: 0 }, + wipeRule: { type: 'string', enum: ['none', 'forced', 'weekly', 'biweekly'], example: 'weekly' }, + wipeDay: { type: 'integer', nullable: true, example: 4 }, + wipeTime: { type: 'string', nullable: true, example: '14:00' }, + wipeTz: { type: 'string', nullable: true, example: 'America/Chicago' }, + wipeAnchor: { type: 'string', format: 'date', nullable: true, example: null }, + wipeOnceAt: { type: 'string', format: 'date-time', nullable: true, example: null }, }, }, RustAdminServerList: { @@ -98,6 +138,8 @@ module.exports = { online: { type: 'boolean', example: true }, players: { type: 'integer', example: 42 }, stale: { type: 'boolean', example: false }, + nextWipe: { $ref: '#/components/schemas/RustNextWipe' }, + schedule: { $ref: '#/components/schemas/RustWipeSchedule' }, }, }, RustLink: { diff --git a/server/test/_fakes.js b/server/test/_fakes.js index b0549ea..41567bf 100644 --- a/server/test/_fakes.js +++ b/server/test/_fakes.js @@ -129,6 +129,7 @@ function fakeApi() { routes: null, extensions: [], streams: null, legs: [], hooks: {}, teamProvider: null, triggers: null, audiences: null, engagementSeeds: null, eventBudgets: null, eventOptionSources: null, eventLeases: null, eventActions: null, + slashCommands: null, } const called = new Set() const once = (name) => { @@ -157,6 +158,8 @@ function fakeApi() { registerEventOptionSources(sources) { once('registerEventOptionSources'); record.eventOptionSources = sources }, registerEventLeases(leases) { once('registerEventLeases'); record.eventLeases = leases }, registerEventActions(actions) { once('registerEventActions'); record.eventActions = actions }, + // Core's `once()` holds here too: a module's commands are one batch. + registerSlashCommands(commands) { once('registerSlashCommands'); record.slashCommands = commands }, onBoot(fn) { once('onBoot'); record.hooks.onBoot = fn }, onShutdown(fn) { once('onShutdown'); record.hooks.onShutdown = fn }, } diff --git a/server/test/commands.test.js b/server/test/commands.test.js new file mode 100644 index 0000000..b0c070d --- /dev/null +++ b/server/test/commands.test.js @@ -0,0 +1,370 @@ +// ── The slash commands (phase 16) ───────────────────────────────────────── +// +// The handlers against stubbed models. What matters most is D127: **an answer +// is public only when everything in it is public.** Every refusal is private, +// and a permitted answer narrower than `public` goes to the caller alone — the +// case core cannot catch, because an answer without the flag is posted to the +// channel. + +const test = require('node:test') +const assert = require('node:assert') + +const { fakeCtx, fakeApi } = require('./_fakes') + +// Accounts as `core.users.getById` answers them. The role on the ACTOR is never +// trusted — the row is re-read — so a test gives the actor one role and the row +// another to prove it. +const USERS = { + 1: { id: 1, role: 'user', status: 'active' }, + 2: { id: 2, role: 'moderator', status: 'active' }, + 3: { id: 3, role: 'moderator', status: 'banned' }, +} + +function withCore() { + const ctx = fakeCtx({ users: { getById: async (id) => USERS[id] || null } }) + require('../core')._reset() + require('../core').init(ctx) + return ctx +} + +const actor = (userId, role = 'user') => ({ + platform: 'discord', + platformUserId: `d${userId || 0}`, + guildId: 'g1', + userId: userId || null, + role: userId ? role : null, + isLinked: Boolean(userId), + isStaff: role === 'admin' || role === 'moderator', +}) +const STRANGER = actor(null) +const PLAYER = actor(1) +const MOD = actor(2, 'moderator') + +const server = (over = {}) => ({ + id: 'main', + name: 'Main', + online: true, + players: 3, + maxPlayers: 100, + worldSize: 4000, + seed: 1234, + wipeId: 'w-2026-09', + wipedAt: '2026-09-03T18:00:00Z', + lastSeenAt: '2026-09-25T11:59:00Z', + stale: false, + nextWipe: { at: '2026-10-01T18:00:00.000Z', source: 'forced' }, + ...over, +}) + +/** Swap model functions for one test, and put them back whatever happens. */ +async function stubbed(stubs, body) { + const saved = [] + for (const [mod, name, fn] of stubs) { + saved.push([mod, name, mod[name]]) + mod[name] = fn + } + try { + return await body() + } finally { + for (const [mod, name, fn] of saved) mod[name] = fn + } +} + +function models() { + return { + servers: require('../model/servers/servers.model'), + events: require('../model/events/events.model'), + visibility: require('../model/visibility/visibility.model'), + clans: require('../model/clans/clans.model'), + visibilityDb: require('../model/visibility/visibility.db'), + } +} + +const command = (name) => require('../commands').find((cmd) => cmd.name === name) + +/** Everything an answer would put in front of a reader, as one string. */ +const said = (env) => JSON.stringify([env.title, env.text, env.fields, env.notice]) + +// ── Registration ────────────────────────────────────────────────────────── + +test('the entry point registers the five commands as one batch', () => { + withCore() + const api = fakeApi() + require('../index')(fakeCtx(), api) + assert.deepStrictEqual(api.record.slashCommands.map((cmd) => cmd.name), ['status', 'wipe', 'top', 'online', 'clan']) +}) + +test('every definition is one core will accept, and one Discord will', () => { + // Core's `checkSlashCommandShape` rules, restated: a definition it refuses + // fails the module at register(), and one Discord refuses takes every command + // in the batch down with it, the bot's own included. + const BUILT_INS = ['announce', 'autorole', 'ban', 'filter', 'filterallow', 'invite', 'kick', 'modlog', 'mute', + 'news', 'ping', 'role', 'rolemenu', 'roles', 'schedule', 'warn', 'warnings', 'wiki'] + for (const cmd of require('../commands')) { + assert.match(cmd.name, /^[a-z0-9_-]{1,32}$/) + assert.ok(!BUILT_INS.includes(cmd.name), `${cmd.name} collides with a bot built-in`) + assert.ok(cmd.description.length >= 1 && cmd.description.length <= 100, `${cmd.name}: description length`) + assert.strictEqual(cmd.access, 'everyone') + assert.strictEqual(typeof cmd.handler, 'function') + let optionalSeen = false + for (const o of cmd.options) { + assert.match(o.name, /^[a-z0-9_-]{1,32}$/) + assert.ok(['string', 'integer', 'boolean', 'user'].includes(o.type)) + assert.ok(o.description.length >= 1 && o.description.length <= 100, `${cmd.name}.${o.name}: description length`) + if (o.choices) assert.ok(['string', 'integer'].includes(o.type)) + if (!o.required) optionalSeen = true + else assert.ok(!optionalSeen, `${cmd.name}: a required option after an optional one`) + } + } +}) + +// ── Picking a server ────────────────────────────────────────────────────── + +test('a server is named by id, then name, then a unique prefix — never guessed', () => { + withCore() + const { pickServer } = require('../commands/common') + const list = [server(), server({ id: 'eu-2x', name: 'EU 2x' }), server({ id: 'eu-5x', name: 'EU 5x' })] + + assert.strictEqual(pickServer(list, 'MAIN').server.id, 'main') + assert.strictEqual(pickServer(list, 'eu 2x').server.id, 'eu-2x') + assert.strictEqual(pickServer(list, 'eu-5').server.id, 'eu-5x') + assert.strictEqual(pickServer(list, 'eu').ambiguous.length, 2) + assert.strictEqual(pickServer(list, 'us').missing, 'us') + assert.strictEqual(pickServer(list, undefined).all.length, 3) + assert.strictEqual(pickServer([server()], undefined).server.id, 'main', 'a fleet of one is that server') + assert.ok(pickServer([], 'main').none) +}) + +// ── Every refusal is private ────────────────────────────────────────────── + +test('every refusal is ephemeral: unknown, ambiguous, no servers, /top on a fleet', async () => { + withCore() + const { servers } = models() + const two = [server(), server({ id: 'main-2', name: 'Main 2' })] + + await stubbed([[servers, 'listPublic', async () => two]], async () => { + for (const [name, options] of [ + ['status', { server: 'nope' }], + ['wipe', { server: 'mai' }], + ['online', { server: 'nope' }], + ['top', {}], + ['top', { server: 'm' }], + ['clan', { name: '' }], + ]) { + // eslint-disable-next-line no-await-in-loop + const env = await command(name).handler({ command: name, options, actor: STRANGER }) + assert.strictEqual(env.ephemeral, true, `/${name} ${JSON.stringify(options)} refused in public: ${env.text}`) + } + }) + + await stubbed([[servers, 'listPublic', async () => []]], async () => { + const env = await command('status').handler({ options: {}, actor: STRANGER }) + assert.strictEqual(env.ephemeral, true) + assert.match(env.text, /No Rust servers/) + }) +}) + +// ── /status and /wipe ───────────────────────────────────────────────────── + +test('/status answers in public, with the next wipe, and says when an offline server was last seen', async () => { + withCore() + const { servers } = models() + await stubbed([[servers, 'listPublic', async () => [server(), server({ id: 'b', name: 'B', online: false, players: 0 })]]], async () => { + const one = await command('status').handler({ options: { server: 'main' }, actor: STRANGER }) + assert.ok(!one.ephemeral) + assert.strictEqual(one.url, 'http://localhost:5173/rust/servers/main') + assert.match(said(one), /3\/100/) + assert.match(said(one), /Next wipe.*Thu 1 Oct, 18:00 UTC.*monthly forced wipe/) + + const down = await command('status').handler({ options: { server: 'b' }, actor: STRANGER }) + assert.match(said(down), /Offline/) + assert.match(said(down), /Last seen/) + + const fleet = await command('status').handler({ options: {}, actor: STRANGER }) + assert.ok(!fleet.ephemeral) + assert.strictEqual(fleet.fields.length, 2) + }) +}) + +test('/wipe says "no schedule set" rather than inventing a forecast', async () => { + withCore() + const { servers } = models() + await stubbed([[servers, 'listPublic', async () => [server({ nextWipe: null })]]], async () => { + const env = await command('wipe').handler({ options: {}, actor: STRANGER }) + assert.ok(!env.ephemeral) + assert.match(env.text, /Next: no schedule set/) + assert.match(env.text, /Last: Thu 3 Sept?, 18:00 UTC/) + }) + await stubbed([[servers, 'listPublic', async () => [server({ nextWipe: { at: '2026-10-03T17:00:00.000Z', source: 'once' } })]]], async () => { + const env = await command('wipe').handler({ options: {}, actor: STRANGER }) + assert.match(env.text, /rescheduled by the operator/) + }) +}) + +// ── /top ────────────────────────────────────────────────────────────────── + +test('/top reads the current wipe, never asks for presence, and prints no Steam id', async () => { + withCore() + const { servers, events } = models() + const asked = [] + const rows = [{ steamId: '76561198000000001', name: 'Alice', kills: 9, deaths: 1, npcKills: 0, playtimeSec: 7260, lastSeen: 'x' }] + await stubbed( + [ + [servers, 'listPublic', async () => [server()]], + [events, 'leaderboard', async (q) => { asked.push(q); return rows }], + ], + async () => { + const env = await command('top').handler({ options: {}, actor: MOD }) + assert.ok(!env.ephemeral, 'the leaderboard names are public at every setting') + assert.deepStrictEqual(asked[0], { serverId: 'main', wipeId: 'w-2026-09', sort: 'kills', limit: 10, presence: false }) + assert.match(env.text, /1\. Alice — 9/) + assert.ok(!said(env).includes('76561198000000001')) + + const all = await command('top').handler({ options: { stat: 'playtime', alltime: true }, actor: STRANGER }) + assert.strictEqual(asked[1].wipeId, null) + assert.strictEqual(asked[1].sort, 'playtime') + assert.match(all.text, /2h 1m/) + assert.match(all.title, /all time/) + }, + ) +}) + +// ── /online — D127 ──────────────────────────────────────────────────────── + +async function online(audience, who, over = {}) { + const { servers, events, visibility } = models() + return stubbed( + [ + [servers, 'listPublic', async () => [server(over)]], + [visibility, 'presenceFor', async () => audience], + [events, 'online', async () => [{ name: 'Alice', sleeping: false }, { name: 'Bob', sleeping: true }]], + ], + () => command('online').handler({ options: {}, actor: who }), + ) +} + +test('/online below the audience: the count, in public, and no names', async () => { + withCore() + const env = await online('staff', STRANGER) + assert.ok(!env.ephemeral) + assert.match(env.title, /3 online/) + assert.ok(!said(env).includes('Alice')) + // Staff is not something linking earns, so there is no nudge to link. + assert.ok(!env.notice) +}) + +test('/online inside a narrower audience: the names, to the caller ALONE', async () => { + withCore() + const env = await online('staff', MOD) + assert.strictEqual(env.ephemeral, true, 'a moderator’s roll call was posted to the channel') + assert.match(env.text, /Alice/) + assert.match(env.text, /Bob \(sleeping\)/) + + const signedIn = await online('signed_in', PLAYER) + assert.strictEqual(signedIn.ephemeral, true) +}) + +test('/online with a public audience posts the names in the open', async () => { + withCore() + const env = await online('public', STRANGER) + assert.ok(!env.ephemeral) + assert.match(env.text, /Alice/) +}) + +test('/online nudges an unlinked caller to link only when linking would reach the names', async () => { + withCore() + const env = await online('signed_in', STRANGER) + assert.ok(!env.ephemeral) + assert.match(env.notice, /Link your Discord account/) +}) + +test('/online judges the ACCOUNT, not the actor: a banned moderator is nobody', async () => { + withCore() + const env = await online('staff', actor(3, 'moderator')) + assert.ok(!said(env).includes('Alice')) +}) + +test('/online for an offline server names nobody, whatever the audience', async () => { + withCore() + const env = await online('public', MOD, { online: false, players: 0 }) + assert.match(env.text, /offline/) + assert.ok(!said(env).includes('Alice')) +}) + +// ── /clan — D127 with D48 ───────────────────────────────────────────────── + +const CLAN = { externalId: 'main:7:1', name: 'Wolves', color: '#aa3300', score: 50, memberCount: 2, maxMembers: 8 } + +async function clan(rosterAudience, who, options = { name: 'wolves' }, board = { supported: true, fresh: true }) { + const { servers, clans, visibilityDb, visibility } = models() + return stubbed( + [ + [servers, 'listPublic', async () => [server()]], + [clans, 'listForServer', async () => ({ clans: [CLAN, { ...CLAN, externalId: 'main:8:1', name: 'Wolverines' }], board })], + [visibilityDb, 'getSetting', async (key) => (key === visibility.CLAN_ROSTER_KEY ? rosterAudience : null)], + [require('../model/clans/clans.db'), 'findClan', async () => ({ ...CLAN, serverId: 'main', serverName: 'Main', createdMs: 1 })], + [require('../model/clans/clans.db'), 'userIsMember', async (_ext, userId) => userId === 1], + [require('../model/clans/clans.db'), 'listMembers', async () => [ + { name: 'Alice', rank: 1, online: 1 }, + { name: 'Bob', rank: 2, online: 0 }, + ]], + ], + () => command('clan').handler({ options, actor: who }), + ) +} + +test('/clan outside the roster audience: the public facts, in public, no names', async () => { + withCore() + const env = await clan('members', STRANGER) + assert.ok(!env.ephemeral) + assert.strictEqual(env.title, 'Wolves') + assert.match(said(env), /50/) + assert.ok(!said(env).includes('Alice')) +}) + +test('/clan inside a narrower roster audience: the roster, to the caller ALONE', async () => { + withCore() + const member = await clan('members', PLAYER) + assert.strictEqual(member.ephemeral, true, 'a clan roster was posted to the channel') + assert.match(said(member), /Alice \(leader, online\)/) + + const mod = await clan('members', MOD) + assert.strictEqual(mod.ephemeral, true) +}) + +test('/clan with a public roster audience posts the roster in the open', async () => { + withCore() + const env = await clan('public', STRANGER) + assert.ok(!env.ephemeral) + assert.match(said(env), /Alice/) +}) + +test('/clan: an exact name wins over a prefix, and a shared prefix is ambiguous and private', async () => { + withCore() + assert.strictEqual((await clan('members', STRANGER, { name: 'WOLVES' })).title, 'Wolves') + const env = await clan('members', STRANGER, { name: 'wol' }) + assert.strictEqual(env.ephemeral, true) + assert.match(env.text, /Wolves, Wolverines/) +}) + +test('/clan does not say "no such clan" from a board it cannot trust', async () => { + withCore() + const env = await clan('members', STRANGER, { name: 'bears' }, { supported: true, fresh: false }) + assert.strictEqual(env.ephemeral, true) + assert.match(env.text, /not available, so it may be there/) + + const sure = await clan('members', STRANGER, { name: 'bears' }) + assert.match(sure.text, /^No clan called “bears”\.$/) +}) + +// ── Words ───────────────────────────────────────────────────────────────── + +test('times are written in UTC with the distance beside them', () => { + withCore() + const { when, relative } = require('../commands/common') + const now = Date.parse('2026-09-25T12:00:00Z') + assert.strictEqual(when('2026-10-01T18:00:00Z', now), 'Thu 1 Oct, 18:00 UTC (in 6 days)') + assert.strictEqual(relative(now - 3 * 3_600_000, now), '3 hours ago') + assert.strictEqual(relative(now + 90_000, now), 'in 2 minutes') + assert.strictEqual(when(null, now), null) +}) diff --git a/server/test/nextWipe.test.js b/server/test/nextWipe.test.js new file mode 100644 index 0000000..36369a9 --- /dev/null +++ b/server/test/nextWipe.test.js @@ -0,0 +1,136 @@ +// ── The next wipe (phase 16, D130) ──────────────────────────────────────── +// +// Pure arithmetic, so no core and no database. The DST edges are here rather +// than in the walk (§32.3 step 5): waiting for a clock change is not a test. + +const test = require('node:test') +const assert = require('node:assert') + +const { nextWipe, validateSchedule, instantOf, parseDay, weekdayOf, isZone } = require('../model/servers/nextWipe') + +const at = (iso) => Date.parse(iso) +const day = (iso) => parseDay(iso) + +test('the forced wipe is the first Thursday, 19:00 UK time — 18:00 UTC in summer', () => { + // The verified fact the plan rests on: Thursday 1 October 2026, 18:00 UTC. + const next = nextWipe({ wipeRule: 'forced' }, at('2026-09-25T12:00:00Z')) + assert.deepStrictEqual(next, { at: '2026-10-01T18:00:00.000Z', source: 'forced' }) + + // In winter the UK clock is UTC, and 19:00 is 19:00Z. + const winter = nextWipe({ wipeRule: 'forced' }, at('2026-11-10T00:00:00Z')) + assert.deepStrictEqual(winter, { at: '2026-12-03T19:00:00.000Z', source: 'forced' }) +}) + +test('a forced wipe that has just happened names next month’s', () => { + const next = nextWipe({ wipeRule: 'forced' }, at('2026-10-01T18:00:00Z')) + assert.strictEqual(next.at, '2026-11-05T19:00:00.000Z') +}) + +test('`none` forecasts nothing, not even the forced wipe', () => { + assert.strictEqual(nextWipe({ wipeRule: 'none' }, at('2026-09-25T12:00:00Z')), null) + assert.strictEqual(nextWipe({}, at('2026-09-25T12:00:00Z')), null) + // An unknown rule word narrows to none rather than guessing. + assert.strictEqual(nextWipe({ wipeRule: 'daily' }, at('2026-09-25T12:00:00Z')), null) +}) + +test('a weekly rule is the earlier of its own day and the forced wipe', () => { + const weekly = { wipeRule: 'weekly', wipeDay: 4, wipeTime: '14:00', wipeTz: 'America/Chicago' } + + // Friday 25 Sep: the next Thursday is 1 Oct, 14:00 CDT = 19:00Z — AFTER the + // forced wipe at 18:00Z the same day, so the forced one is next. + assert.deepStrictEqual(nextWipe(weekly, at('2026-09-25T12:00:00Z')), { at: '2026-10-01T18:00:00.000Z', source: 'forced' }) + + // Once that has passed, the rule's own Thursday comes first. + assert.deepStrictEqual(nextWipe(weekly, at('2026-10-01T18:30:00Z')), { at: '2026-10-01T19:00:00.000Z', source: 'rule' }) + assert.deepStrictEqual(nextWipe(weekly, at('2026-10-01T19:00:00Z')), { at: '2026-10-08T19:00:00.000Z', source: 'rule' }) +}) + +test('a biweekly rule wipes on its anchor’s weeks only', () => { + const biweekly = { wipeRule: 'biweekly', wipeDay: 1, wipeTime: '20:00', wipeTz: 'Europe/London', wipeAnchor: '2026-09-14' } + + // Mon 14 Sep is on, 21 Sep off, 28 Sep on (20:00 BST = 19:00Z). + assert.deepStrictEqual(nextWipe(biweekly, at('2026-09-15T00:00:00Z')), { at: '2026-09-28T19:00:00.000Z', source: 'rule' }) + // An anchor in the future still defines the parity. + assert.deepStrictEqual( + nextWipe({ ...biweekly, wipeAnchor: '2026-12-07' }, at('2026-09-15T00:00:00Z')), + { at: '2026-09-28T19:00:00.000Z', source: 'rule' }, + ) +}) + +test('a one-off date in the future is the next wipe, before or after the computed one', () => { + const forced = { wipeRule: 'forced' } + const now = at('2026-09-25T12:00:00Z') + + // Before the computed wipe: an extra wipe. + assert.deepStrictEqual(nextWipe({ ...forced, wipeOnceAt: '2026-09-27T17:00:00Z' }, now), { at: '2026-09-27T17:00:00.000Z', source: 'once' }) + // After it: a delay — the computed 1 Oct wipe is skipped. + assert.deepStrictEqual(nextWipe({ ...forced, wipeOnceAt: new Date('2026-10-03T17:00:00Z') }, now), { at: '2026-10-03T17:00:00.000Z', source: 'once' }) + // Past: ignored, and the rule answers again. + assert.deepStrictEqual(nextWipe({ ...forced, wipeOnceAt: '2026-09-20T17:00:00Z' }, now), { at: '2026-10-01T18:00:00.000Z', source: 'forced' }) + // Under `none` a stated date is still a forecast. + assert.deepStrictEqual(nextWipe({ wipeRule: 'none', wipeOnceAt: '2026-09-27T17:00:00Z' }, now), { at: '2026-09-27T17:00:00.000Z', source: 'once' }) +}) + +test('a broken rule still answers the forced wipe, and never throws', () => { + const now = at('2026-09-25T12:00:00Z') + const broken = { wipeRule: 'weekly', wipeDay: 4, wipeTime: '25:00', wipeTz: 'Mars/Olympus' } + assert.deepStrictEqual(nextWipe(broken, now), { at: '2026-10-01T18:00:00.000Z', source: 'forced' }) + assert.strictEqual(nextWipe(null, now), null) +}) + +// ── The DST edges, both zones, both directions (reading 6) ──────────────── + +test('Europe/London: a skipped hour moves forward, a repeated hour takes the first', () => { + // Spring 2027: 29 Mar 01:00 GMT → 02:00 BST. 01:30 does not exist. + assert.strictEqual(new Date(instantOf(day('2027-03-28'), 1, 30, 'Europe/London')).toISOString(), '2027-03-28T01:30:00.000Z') + // Autumn 2026: 25 Oct 02:00 BST → 01:00 GMT. 01:30 happens twice; the first is BST. + assert.strictEqual(new Date(instantOf(day('2026-10-25'), 1, 30, 'Europe/London')).toISOString(), '2026-10-25T00:30:00.000Z') + // Either side of the change, ordinary. + assert.strictEqual(new Date(instantOf(day('2026-10-24'), 19, 0, 'Europe/London')).toISOString(), '2026-10-24T18:00:00.000Z') + assert.strictEqual(new Date(instantOf(day('2026-10-26'), 19, 0, 'Europe/London')).toISOString(), '2026-10-26T19:00:00.000Z') +}) + +test('America/Chicago: a skipped hour moves forward, a repeated hour takes the first', () => { + // Spring 2027: 14 Mar 02:00 CST → 03:00 CDT. 02:30 does not exist → 03:30 CDT = 08:30Z. + assert.strictEqual(new Date(instantOf(day('2027-03-14'), 2, 30, 'America/Chicago')).toISOString(), '2027-03-14T08:30:00.000Z') + // Autumn 2026: 1 Nov 02:00 CDT → 01:00 CST. 01:30 twice; the first is CDT = 06:30Z. + assert.strictEqual(new Date(instantOf(day('2026-11-01'), 1, 30, 'America/Chicago')).toISOString(), '2026-11-01T06:30:00.000Z') +}) + +test('a weekly rule across the autumn change keeps its wall-clock time', () => { + const weekly = { wipeRule: 'weekly', wipeDay: 0, wipeTime: '14:00', wipeTz: 'America/Chicago' } + // Sun 25 Oct 14:00 CDT = 19:00Z; Sun 1 Nov 14:00 CST = 20:00Z. + assert.strictEqual(nextWipe(weekly, at('2026-10-24T00:00:00Z')).at, '2026-10-25T19:00:00.000Z') + assert.strictEqual(nextWipe(weekly, at('2026-10-26T00:00:00Z')).at, '2026-11-01T20:00:00.000Z') +}) + +test('the calendar helpers', () => { + assert.strictEqual(weekdayOf(day('2026-10-01')), 4) + assert.strictEqual(weekdayOf(day('1969-12-31')), 3) + assert.strictEqual(parseDay('2026-02-30'), null) + assert.strictEqual(parseDay('26-02-01'), null) + assert.strictEqual(isZone('Europe/London'), true) + assert.strictEqual(isZone('Europe/Londn'), false) + assert.strictEqual(isZone(''), false) +}) + +test('validation names every problem, and a one-off date must be in the future', () => { + const now = at('2026-09-25T12:00:00Z') + assert.deepStrictEqual(validateSchedule({ wipeRule: 'none' }, now), []) + assert.deepStrictEqual(validateSchedule({ wipeRule: 'forced' }, now), []) + assert.deepStrictEqual(validateSchedule({ wipeRule: 'weekly', wipeDay: 4, wipeTime: '19:00', wipeTz: 'Europe/London' }, now), []) + + assert.strictEqual(validateSchedule({ wipeRule: 'monthly' }, now).length, 1) + assert.strictEqual(validateSchedule({ wipeRule: 'weekly' }, now).length, 3) + assert.match(validateSchedule({ wipeRule: 'weekly', wipeDay: 4, wipeTime: '19:00', wipeTz: 'UK' }, now)[0], /time zone/) + + // The anchor must be a wipe on the rule: a Tuesday anchor for a Monday rule is refused. + const bi = { wipeRule: 'biweekly', wipeDay: 1, wipeTime: '20:00', wipeTz: 'Europe/London' } + assert.match(validateSchedule(bi, now)[0], /date of one wipe/) + assert.match(validateSchedule({ ...bi, wipeAnchor: '2026-09-15' }, now)[0], /day of the week/) + assert.deepStrictEqual(validateSchedule({ ...bi, wipeAnchor: '2026-09-14' }, now), []) + + assert.match(validateSchedule({ wipeRule: 'forced', wipeOnceAt: '2026-09-24T00:00:00Z' }, now)[0], /future/) + assert.match(validateSchedule({ wipeRule: 'forced', wipeOnceAt: 'soon' }, now)[0], /not a date/) + assert.deepStrictEqual(validateSchedule({ wipeRule: 'forced', wipeOnceAt: null }, now), []) +}) diff --git a/server/test/servers.test.js b/server/test/servers.test.js index e4abfdd..b5e05b9 100644 --- a/server/test/servers.test.js +++ b/server/test/servers.test.js @@ -97,7 +97,7 @@ test('the public shape carries nothing about the sidecar', () => { // someone who did not read this file, and an allowlist is the only assertion // that catches one. assert.deepStrictEqual(Object.keys(shaped).sort(), [ - 'hostname', 'id', 'lastSeenAt', 'level', 'maxPlayers', 'name', 'online', 'players', 'seed', 'stale', + 'hostname', 'id', 'lastSeenAt', 'level', 'maxPlayers', 'name', 'nextWipe', 'online', 'players', 'seed', 'stale', 'updatedAt', 'wipeId', 'wipedAt', 'worldSize', ]) }) diff --git a/swagger-fragment.json b/swagger-fragment.json index d14ddfa..3aae9a5 100644 --- a/swagger-fragment.json +++ b/swagger-fragment.json @@ -565,7 +565,7 @@ "Admin · Rust" ], "summary": "Create or update a Rust server", - "description": "Writes one server row. `sidecarToken` is write-only — send it to set or rotate the credential, and omit it or send an empty string to leave the stored one untouched. The id is the slug every URL under the module carries.", + "description": "Writes one server row. `sidecarToken` is write-only — send it to set or rotate the credential, and omit it or send an empty string to leave the stored one untouched. The id is the slug every URL under the module carries. The wipe schedule (`wipeRule`, `wipeDay`, `wipeTime`, `wipeTz`, `wipeAnchor`, `wipeOnceAt`) is written only when `wipeRule` is present, so a body without it leaves the stored schedule alone. `wipeRule` is `none`, `forced`, `weekly` or `biweekly`; a weekly or biweekly rule needs `wipeDay` (0 = Sunday), `wipeTime` (`HH:MM`) and an IANA `wipeTz`, and a biweekly rule a `wipeAnchor` date on that day. `wipeOnceAt` is a one-off wipe in the future. A 400 carries one sentence per problem in `errors`.", "parameters": [ { "name": "id", @@ -588,30 +588,11 @@ } }, "requestBody": { + "required": true, "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "name": { - "example": "any" - }, - "sidecarBaseUrl": { - "example": "any" - }, - "sidecarToken": { - "example": "any" - }, - "protocol": { - "example": "any" - }, - "enabled": { - "example": "any" - }, - "sortOrder": { - "example": "any" - } - } + "$ref": "#/components/schemas/RustServerSave" } } } @@ -1972,6 +1953,418 @@ "example": false } } + }, + "nextWipe": { + "$ref": "#/components/schemas/RustNextWipe" + } + } + } + } + }, + "RustNextWipe": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "When the server wipes next, computed from the operator’s schedule on every read, or null when no schedule is set. `source` is `forced` (Facepunch’s monthly forced wipe, first Thursday 19:00 UK time), `rule` (the server’s own weekly or biweekly day) or `once` (a one-off date the operator set, which replaces any computed wipe before it). Absent from a module older than phase 16." + }, + "properties": { + "type": "object", + "properties": { + "at": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + }, + "example": { + "type": "string", + "example": "2026-10-01T18:00:00.000Z" + } + } + }, + "source": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "enum": { + "type": "array", + "example": [ + "forced", + "rule", + "once" + ], + "items": { + "type": "string" + } + }, + "example": { + "type": "string", + "example": "forced" + } + } + } + } + } + } + }, + "RustWipeSchedule": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "A server’s wipe schedule as the operator stored it — what they typed, not what it computes to. A past `onceAt` is still returned so the form can show it as past." + }, + "properties": { + "type": "object", + "properties": { + "rule": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "enum": { + "type": "array", + "example": [ + "none", + "forced", + "weekly", + "biweekly" + ], + "items": { + "type": "string" + } + }, + "example": { + "type": "string", + "example": "weekly" + } + } + }, + "day": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "0 = Sunday … 6 = Saturday" + }, + "example": { + "type": "number", + "example": 4 + } + } + }, + "time": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "string", + "example": "14:00" + } + } + }, + "tz": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "string", + "example": "America/Chicago" + } + } + }, + "anchor": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": {} + } + }, + "onceAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": {} + } + } + } + } + } + }, + "RustServerSave": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "The body of PUT /admin/rust/servers/{id}. The wipe fields are written only when `wipeRule` is present." + }, + "properties": { + "type": "object", + "properties": { + "name": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "Main · Vanilla" + } + } + }, + "sidecarBaseUrl": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "http://10.0.0.5:8090" + } + } + }, + "sidecarToken": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "description": { + "type": "string", + "example": "Write-only. Omit or send empty to keep the stored one." + }, + "example": { + "type": "string", + "example": "" + } + } + }, + "protocol": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 11 + } + } + }, + "enabled": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": true + } + } + }, + "sortOrder": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 0 + } + } + }, + "wipeRule": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "enum": { + "type": "array", + "example": [ + "none", + "forced", + "weekly", + "biweekly" + ], + "items": { + "type": "string" + } + }, + "example": { + "type": "string", + "example": "weekly" + } + } + }, + "wipeDay": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "number", + "example": 4 + } + } + }, + "wipeTime": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "string", + "example": "14:00" + } + } + }, + "wipeTz": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "string", + "example": "America/Chicago" + } + } + }, + "wipeAnchor": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": {} + } + }, + "wipeOnceAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": {} + } } } } @@ -2201,6 +2594,12 @@ "example": false } } + }, + "nextWipe": { + "$ref": "#/components/schemas/RustNextWipe" + }, + "schedule": { + "$ref": "#/components/schemas/RustWipeSchedule" } } }