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