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:
2026-09-25 13:23:11 -05:00
parent cf4d183181
commit 0670341198
22 changed files with 1985 additions and 31 deletions

View File

@@ -0,0 +1,136 @@
// ── 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), [])
})