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
137 lines
8.0 KiB
JavaScript
137 lines
8.0 KiB
JavaScript
// ── The next wipe (phase 16, D130) ────────────────────────────────────────
|
||
//
|
||
// Pure arithmetic, so no core and no database. The DST edges are here rather
|
||
// than in the walk (§32.3 step 5): waiting for a clock change is not a test.
|
||
|
||
const test = require('node:test')
|
||
const assert = require('node:assert')
|
||
|
||
const { nextWipe, validateSchedule, instantOf, parseDay, weekdayOf, isZone } = require('../model/servers/nextWipe')
|
||
|
||
const at = (iso) => Date.parse(iso)
|
||
const day = (iso) => parseDay(iso)
|
||
|
||
test('the forced wipe is the first Thursday, 19:00 UK time — 18:00 UTC in summer', () => {
|
||
// The verified fact the plan rests on: Thursday 1 October 2026, 18:00 UTC.
|
||
const next = nextWipe({ wipeRule: 'forced' }, at('2026-09-25T12:00:00Z'))
|
||
assert.deepStrictEqual(next, { at: '2026-10-01T18:00:00.000Z', source: 'forced' })
|
||
|
||
// In winter the UK clock is UTC, and 19:00 is 19:00Z.
|
||
const winter = nextWipe({ wipeRule: 'forced' }, at('2026-11-10T00:00:00Z'))
|
||
assert.deepStrictEqual(winter, { at: '2026-12-03T19:00:00.000Z', source: 'forced' })
|
||
})
|
||
|
||
test('a forced wipe that has just happened names next month’s', () => {
|
||
const next = nextWipe({ wipeRule: 'forced' }, at('2026-10-01T18:00:00Z'))
|
||
assert.strictEqual(next.at, '2026-11-05T19:00:00.000Z')
|
||
})
|
||
|
||
test('`none` forecasts nothing, not even the forced wipe', () => {
|
||
assert.strictEqual(nextWipe({ wipeRule: 'none' }, at('2026-09-25T12:00:00Z')), null)
|
||
assert.strictEqual(nextWipe({}, at('2026-09-25T12:00:00Z')), null)
|
||
// An unknown rule word narrows to none rather than guessing.
|
||
assert.strictEqual(nextWipe({ wipeRule: 'daily' }, at('2026-09-25T12:00:00Z')), null)
|
||
})
|
||
|
||
test('a weekly rule is the earlier of its own day and the forced wipe', () => {
|
||
const weekly = { wipeRule: 'weekly', wipeDay: 4, wipeTime: '14:00', wipeTz: 'America/Chicago' }
|
||
|
||
// Friday 25 Sep: the next Thursday is 1 Oct, 14:00 CDT = 19:00Z — AFTER the
|
||
// forced wipe at 18:00Z the same day, so the forced one is next.
|
||
assert.deepStrictEqual(nextWipe(weekly, at('2026-09-25T12:00:00Z')), { at: '2026-10-01T18:00:00.000Z', source: 'forced' })
|
||
|
||
// Once that has passed, the rule's own Thursday comes first.
|
||
assert.deepStrictEqual(nextWipe(weekly, at('2026-10-01T18:30:00Z')), { at: '2026-10-01T19:00:00.000Z', source: 'rule' })
|
||
assert.deepStrictEqual(nextWipe(weekly, at('2026-10-01T19:00:00Z')), { at: '2026-10-08T19:00:00.000Z', source: 'rule' })
|
||
})
|
||
|
||
test('a biweekly rule wipes on its anchor’s weeks only', () => {
|
||
const biweekly = { wipeRule: 'biweekly', wipeDay: 1, wipeTime: '20:00', wipeTz: 'Europe/London', wipeAnchor: '2026-09-14' }
|
||
|
||
// Mon 14 Sep is on, 21 Sep off, 28 Sep on (20:00 BST = 19:00Z).
|
||
assert.deepStrictEqual(nextWipe(biweekly, at('2026-09-15T00:00:00Z')), { at: '2026-09-28T19:00:00.000Z', source: 'rule' })
|
||
// An anchor in the future still defines the parity.
|
||
assert.deepStrictEqual(
|
||
nextWipe({ ...biweekly, wipeAnchor: '2026-12-07' }, at('2026-09-15T00:00:00Z')),
|
||
{ at: '2026-09-28T19:00:00.000Z', source: 'rule' },
|
||
)
|
||
})
|
||
|
||
test('a one-off date in the future is the next wipe, before or after the computed one', () => {
|
||
const forced = { wipeRule: 'forced' }
|
||
const now = at('2026-09-25T12:00:00Z')
|
||
|
||
// Before the computed wipe: an extra wipe.
|
||
assert.deepStrictEqual(nextWipe({ ...forced, wipeOnceAt: '2026-09-27T17:00:00Z' }, now), { at: '2026-09-27T17:00:00.000Z', source: 'once' })
|
||
// After it: a delay — the computed 1 Oct wipe is skipped.
|
||
assert.deepStrictEqual(nextWipe({ ...forced, wipeOnceAt: new Date('2026-10-03T17:00:00Z') }, now), { at: '2026-10-03T17:00:00.000Z', source: 'once' })
|
||
// Past: ignored, and the rule answers again.
|
||
assert.deepStrictEqual(nextWipe({ ...forced, wipeOnceAt: '2026-09-20T17:00:00Z' }, now), { at: '2026-10-01T18:00:00.000Z', source: 'forced' })
|
||
// Under `none` a stated date is still a forecast.
|
||
assert.deepStrictEqual(nextWipe({ wipeRule: 'none', wipeOnceAt: '2026-09-27T17:00:00Z' }, now), { at: '2026-09-27T17:00:00.000Z', source: 'once' })
|
||
})
|
||
|
||
test('a broken rule still answers the forced wipe, and never throws', () => {
|
||
const now = at('2026-09-25T12:00:00Z')
|
||
const broken = { wipeRule: 'weekly', wipeDay: 4, wipeTime: '25:00', wipeTz: 'Mars/Olympus' }
|
||
assert.deepStrictEqual(nextWipe(broken, now), { at: '2026-10-01T18:00:00.000Z', source: 'forced' })
|
||
assert.strictEqual(nextWipe(null, now), null)
|
||
})
|
||
|
||
// ── The DST edges, both zones, both directions (reading 6) ────────────────
|
||
|
||
test('Europe/London: a skipped hour moves forward, a repeated hour takes the first', () => {
|
||
// Spring 2027: 29 Mar 01:00 GMT → 02:00 BST. 01:30 does not exist.
|
||
assert.strictEqual(new Date(instantOf(day('2027-03-28'), 1, 30, 'Europe/London')).toISOString(), '2027-03-28T01:30:00.000Z')
|
||
// Autumn 2026: 25 Oct 02:00 BST → 01:00 GMT. 01:30 happens twice; the first is BST.
|
||
assert.strictEqual(new Date(instantOf(day('2026-10-25'), 1, 30, 'Europe/London')).toISOString(), '2026-10-25T00:30:00.000Z')
|
||
// Either side of the change, ordinary.
|
||
assert.strictEqual(new Date(instantOf(day('2026-10-24'), 19, 0, 'Europe/London')).toISOString(), '2026-10-24T18:00:00.000Z')
|
||
assert.strictEqual(new Date(instantOf(day('2026-10-26'), 19, 0, 'Europe/London')).toISOString(), '2026-10-26T19:00:00.000Z')
|
||
})
|
||
|
||
test('America/Chicago: a skipped hour moves forward, a repeated hour takes the first', () => {
|
||
// Spring 2027: 14 Mar 02:00 CST → 03:00 CDT. 02:30 does not exist → 03:30 CDT = 08:30Z.
|
||
assert.strictEqual(new Date(instantOf(day('2027-03-14'), 2, 30, 'America/Chicago')).toISOString(), '2027-03-14T08:30:00.000Z')
|
||
// Autumn 2026: 1 Nov 02:00 CDT → 01:00 CST. 01:30 twice; the first is CDT = 06:30Z.
|
||
assert.strictEqual(new Date(instantOf(day('2026-11-01'), 1, 30, 'America/Chicago')).toISOString(), '2026-11-01T06:30:00.000Z')
|
||
})
|
||
|
||
test('a weekly rule across the autumn change keeps its wall-clock time', () => {
|
||
const weekly = { wipeRule: 'weekly', wipeDay: 0, wipeTime: '14:00', wipeTz: 'America/Chicago' }
|
||
// Sun 25 Oct 14:00 CDT = 19:00Z; Sun 1 Nov 14:00 CST = 20:00Z.
|
||
assert.strictEqual(nextWipe(weekly, at('2026-10-24T00:00:00Z')).at, '2026-10-25T19:00:00.000Z')
|
||
assert.strictEqual(nextWipe(weekly, at('2026-10-26T00:00:00Z')).at, '2026-11-01T20:00:00.000Z')
|
||
})
|
||
|
||
test('the calendar helpers', () => {
|
||
assert.strictEqual(weekdayOf(day('2026-10-01')), 4)
|
||
assert.strictEqual(weekdayOf(day('1969-12-31')), 3)
|
||
assert.strictEqual(parseDay('2026-02-30'), null)
|
||
assert.strictEqual(parseDay('26-02-01'), null)
|
||
assert.strictEqual(isZone('Europe/London'), true)
|
||
assert.strictEqual(isZone('Europe/Londn'), false)
|
||
assert.strictEqual(isZone(''), false)
|
||
})
|
||
|
||
test('validation names every problem, and a one-off date must be in the future', () => {
|
||
const now = at('2026-09-25T12:00:00Z')
|
||
assert.deepStrictEqual(validateSchedule({ wipeRule: 'none' }, now), [])
|
||
assert.deepStrictEqual(validateSchedule({ wipeRule: 'forced' }, now), [])
|
||
assert.deepStrictEqual(validateSchedule({ wipeRule: 'weekly', wipeDay: 4, wipeTime: '19:00', wipeTz: 'Europe/London' }, now), [])
|
||
|
||
assert.strictEqual(validateSchedule({ wipeRule: 'monthly' }, now).length, 1)
|
||
assert.strictEqual(validateSchedule({ wipeRule: 'weekly' }, now).length, 3)
|
||
assert.match(validateSchedule({ wipeRule: 'weekly', wipeDay: 4, wipeTime: '19:00', wipeTz: 'UK' }, now)[0], /time zone/)
|
||
|
||
// The anchor must be a wipe on the rule: a Tuesday anchor for a Monday rule is refused.
|
||
const bi = { wipeRule: 'biweekly', wipeDay: 1, wipeTime: '20:00', wipeTz: 'Europe/London' }
|
||
assert.match(validateSchedule(bi, now)[0], /date of one wipe/)
|
||
assert.match(validateSchedule({ ...bi, wipeAnchor: '2026-09-15' }, now)[0], /day of the week/)
|
||
assert.deepStrictEqual(validateSchedule({ ...bi, wipeAnchor: '2026-09-14' }, now), [])
|
||
|
||
assert.match(validateSchedule({ wipeRule: 'forced', wipeOnceAt: '2026-09-24T00:00:00Z' }, now)[0], /future/)
|
||
assert.match(validateSchedule({ wipeRule: 'forced', wipeOnceAt: 'soon' }, now)[0], /not a date/)
|
||
assert.deepStrictEqual(validateSchedule({ wipeRule: 'forced', wipeOnceAt: null }, now), [])
|
||
})
|