feat(rust): slash commands and the next wipe, server half (phase 16)

Five read-only commands registered with api.registerSlashCommands:
/status, /wipe, /top, /online and /clan (D126). Every refusal is private,
and any answer narrower than public (online names, a clan roster) goes
to the caller alone (D127). No command asks a sidecar.

The next wipe (D128, D130): six nullable columns on rust_servers, a pure
nextWipe(row, now) with the zone arithmetic through Intl, computed on
every read. The public server shape gains nextWipe; the admin shape
gains the stored schedule; PUT /admin/rust/servers/:id takes the six
fields and writes them only when wipeRule is present.

server/commands joins ci/bundle.json, which checkBundle caught.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-09-25 13:23:11 -05:00
parent cf4d183181
commit 0670341198
22 changed files with 1985 additions and 31 deletions

View File

@@ -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,
}