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
282 lines
11 KiB
JavaScript
282 lines
11 KiB
JavaScript
// ── 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,
|
||
}
|