Files
Module-Rust/server/model/servers/nextWipe.js
wtclaude 0670341198 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
2026-09-25 13:29:59 -05:00

282 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── 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,
}