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:
281
server/model/servers/nextWipe.js
Normal file
281
server/model/servers/nextWipe.js
Normal 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,
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user