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

@@ -129,6 +129,7 @@ function fakeApi() {
routes: null, extensions: [], streams: null, legs: [], hooks: {}, teamProvider: null,
triggers: null, audiences: null, engagementSeeds: null,
eventBudgets: null, eventOptionSources: null, eventLeases: null, eventActions: null,
slashCommands: null,
}
const called = new Set()
const once = (name) => {
@@ -157,6 +158,8 @@ function fakeApi() {
registerEventOptionSources(sources) { once('registerEventOptionSources'); record.eventOptionSources = sources },
registerEventLeases(leases) { once('registerEventLeases'); record.eventLeases = leases },
registerEventActions(actions) { once('registerEventActions'); record.eventActions = actions },
// Core's `once()` holds here too: a module's commands are one batch.
registerSlashCommands(commands) { once('registerSlashCommands'); record.slashCommands = commands },
onBoot(fn) { once('onBoot'); record.hooks.onBoot = fn },
onShutdown(fn) { once('onShutdown'); record.hooks.onShutdown = fn },
}

View File

@@ -0,0 +1,370 @@
// ── The slash commands (phase 16) ─────────────────────────────────────────
//
// The handlers against stubbed models. What matters most is D127: **an answer
// is public only when everything in it is public.** Every refusal is private,
// and a permitted answer narrower than `public` goes to the caller alone — the
// case core cannot catch, because an answer without the flag is posted to the
// channel.
const test = require('node:test')
const assert = require('node:assert')
const { fakeCtx, fakeApi } = require('./_fakes')
// Accounts as `core.users.getById` answers them. The role on the ACTOR is never
// trusted — the row is re-read — so a test gives the actor one role and the row
// another to prove it.
const USERS = {
1: { id: 1, role: 'user', status: 'active' },
2: { id: 2, role: 'moderator', status: 'active' },
3: { id: 3, role: 'moderator', status: 'banned' },
}
function withCore() {
const ctx = fakeCtx({ users: { getById: async (id) => USERS[id] || null } })
require('../core')._reset()
require('../core').init(ctx)
return ctx
}
const actor = (userId, role = 'user') => ({
platform: 'discord',
platformUserId: `d${userId || 0}`,
guildId: 'g1',
userId: userId || null,
role: userId ? role : null,
isLinked: Boolean(userId),
isStaff: role === 'admin' || role === 'moderator',
})
const STRANGER = actor(null)
const PLAYER = actor(1)
const MOD = actor(2, 'moderator')
const server = (over = {}) => ({
id: 'main',
name: 'Main',
online: true,
players: 3,
maxPlayers: 100,
worldSize: 4000,
seed: 1234,
wipeId: 'w-2026-09',
wipedAt: '2026-09-03T18:00:00Z',
lastSeenAt: '2026-09-25T11:59:00Z',
stale: false,
nextWipe: { at: '2026-10-01T18:00:00.000Z', source: 'forced' },
...over,
})
/** Swap model functions for one test, and put them back whatever happens. */
async function stubbed(stubs, body) {
const saved = []
for (const [mod, name, fn] of stubs) {
saved.push([mod, name, mod[name]])
mod[name] = fn
}
try {
return await body()
} finally {
for (const [mod, name, fn] of saved) mod[name] = fn
}
}
function models() {
return {
servers: require('../model/servers/servers.model'),
events: require('../model/events/events.model'),
visibility: require('../model/visibility/visibility.model'),
clans: require('../model/clans/clans.model'),
visibilityDb: require('../model/visibility/visibility.db'),
}
}
const command = (name) => require('../commands').find((cmd) => cmd.name === name)
/** Everything an answer would put in front of a reader, as one string. */
const said = (env) => JSON.stringify([env.title, env.text, env.fields, env.notice])
// ── Registration ──────────────────────────────────────────────────────────
test('the entry point registers the five commands as one batch', () => {
withCore()
const api = fakeApi()
require('../index')(fakeCtx(), api)
assert.deepStrictEqual(api.record.slashCommands.map((cmd) => cmd.name), ['status', 'wipe', 'top', 'online', 'clan'])
})
test('every definition is one core will accept, and one Discord will', () => {
// Core's `checkSlashCommandShape` rules, restated: a definition it refuses
// fails the module at register(), and one Discord refuses takes every command
// in the batch down with it, the bot's own included.
const BUILT_INS = ['announce', 'autorole', 'ban', 'filter', 'filterallow', 'invite', 'kick', 'modlog', 'mute',
'news', 'ping', 'role', 'rolemenu', 'roles', 'schedule', 'warn', 'warnings', 'wiki']
for (const cmd of require('../commands')) {
assert.match(cmd.name, /^[a-z0-9_-]{1,32}$/)
assert.ok(!BUILT_INS.includes(cmd.name), `${cmd.name} collides with a bot built-in`)
assert.ok(cmd.description.length >= 1 && cmd.description.length <= 100, `${cmd.name}: description length`)
assert.strictEqual(cmd.access, 'everyone')
assert.strictEqual(typeof cmd.handler, 'function')
let optionalSeen = false
for (const o of cmd.options) {
assert.match(o.name, /^[a-z0-9_-]{1,32}$/)
assert.ok(['string', 'integer', 'boolean', 'user'].includes(o.type))
assert.ok(o.description.length >= 1 && o.description.length <= 100, `${cmd.name}.${o.name}: description length`)
if (o.choices) assert.ok(['string', 'integer'].includes(o.type))
if (!o.required) optionalSeen = true
else assert.ok(!optionalSeen, `${cmd.name}: a required option after an optional one`)
}
}
})
// ── Picking a server ──────────────────────────────────────────────────────
test('a server is named by id, then name, then a unique prefix — never guessed', () => {
withCore()
const { pickServer } = require('../commands/common')
const list = [server(), server({ id: 'eu-2x', name: 'EU 2x' }), server({ id: 'eu-5x', name: 'EU 5x' })]
assert.strictEqual(pickServer(list, 'MAIN').server.id, 'main')
assert.strictEqual(pickServer(list, 'eu 2x').server.id, 'eu-2x')
assert.strictEqual(pickServer(list, 'eu-5').server.id, 'eu-5x')
assert.strictEqual(pickServer(list, 'eu').ambiguous.length, 2)
assert.strictEqual(pickServer(list, 'us').missing, 'us')
assert.strictEqual(pickServer(list, undefined).all.length, 3)
assert.strictEqual(pickServer([server()], undefined).server.id, 'main', 'a fleet of one is that server')
assert.ok(pickServer([], 'main').none)
})
// ── Every refusal is private ──────────────────────────────────────────────
test('every refusal is ephemeral: unknown, ambiguous, no servers, /top on a fleet', async () => {
withCore()
const { servers } = models()
const two = [server(), server({ id: 'main-2', name: 'Main 2' })]
await stubbed([[servers, 'listPublic', async () => two]], async () => {
for (const [name, options] of [
['status', { server: 'nope' }],
['wipe', { server: 'mai' }],
['online', { server: 'nope' }],
['top', {}],
['top', { server: 'm' }],
['clan', { name: '' }],
]) {
// eslint-disable-next-line no-await-in-loop
const env = await command(name).handler({ command: name, options, actor: STRANGER })
assert.strictEqual(env.ephemeral, true, `/${name} ${JSON.stringify(options)} refused in public: ${env.text}`)
}
})
await stubbed([[servers, 'listPublic', async () => []]], async () => {
const env = await command('status').handler({ options: {}, actor: STRANGER })
assert.strictEqual(env.ephemeral, true)
assert.match(env.text, /No Rust servers/)
})
})
// ── /status and /wipe ─────────────────────────────────────────────────────
test('/status answers in public, with the next wipe, and says when an offline server was last seen', async () => {
withCore()
const { servers } = models()
await stubbed([[servers, 'listPublic', async () => [server(), server({ id: 'b', name: 'B', online: false, players: 0 })]]], async () => {
const one = await command('status').handler({ options: { server: 'main' }, actor: STRANGER })
assert.ok(!one.ephemeral)
assert.strictEqual(one.url, 'http://localhost:5173/rust/servers/main')
assert.match(said(one), /3\/100/)
assert.match(said(one), /Next wipe.*Thu 1 Oct, 18:00 UTC.*monthly forced wipe/)
const down = await command('status').handler({ options: { server: 'b' }, actor: STRANGER })
assert.match(said(down), /Offline/)
assert.match(said(down), /Last seen/)
const fleet = await command('status').handler({ options: {}, actor: STRANGER })
assert.ok(!fleet.ephemeral)
assert.strictEqual(fleet.fields.length, 2)
})
})
test('/wipe says "no schedule set" rather than inventing a forecast', async () => {
withCore()
const { servers } = models()
await stubbed([[servers, 'listPublic', async () => [server({ nextWipe: null })]]], async () => {
const env = await command('wipe').handler({ options: {}, actor: STRANGER })
assert.ok(!env.ephemeral)
assert.match(env.text, /Next: no schedule set/)
assert.match(env.text, /Last: Thu 3 Sept?, 18:00 UTC/)
})
await stubbed([[servers, 'listPublic', async () => [server({ nextWipe: { at: '2026-10-03T17:00:00.000Z', source: 'once' } })]]], async () => {
const env = await command('wipe').handler({ options: {}, actor: STRANGER })
assert.match(env.text, /rescheduled by the operator/)
})
})
// ── /top ──────────────────────────────────────────────────────────────────
test('/top reads the current wipe, never asks for presence, and prints no Steam id', async () => {
withCore()
const { servers, events } = models()
const asked = []
const rows = [{ steamId: '76561198000000001', name: 'Alice', kills: 9, deaths: 1, npcKills: 0, playtimeSec: 7260, lastSeen: 'x' }]
await stubbed(
[
[servers, 'listPublic', async () => [server()]],
[events, 'leaderboard', async (q) => { asked.push(q); return rows }],
],
async () => {
const env = await command('top').handler({ options: {}, actor: MOD })
assert.ok(!env.ephemeral, 'the leaderboard names are public at every setting')
assert.deepStrictEqual(asked[0], { serverId: 'main', wipeId: 'w-2026-09', sort: 'kills', limit: 10, presence: false })
assert.match(env.text, /1\. Alice — 9/)
assert.ok(!said(env).includes('76561198000000001'))
const all = await command('top').handler({ options: { stat: 'playtime', alltime: true }, actor: STRANGER })
assert.strictEqual(asked[1].wipeId, null)
assert.strictEqual(asked[1].sort, 'playtime')
assert.match(all.text, /2h 1m/)
assert.match(all.title, /all time/)
},
)
})
// ── /online — D127 ────────────────────────────────────────────────────────
async function online(audience, who, over = {}) {
const { servers, events, visibility } = models()
return stubbed(
[
[servers, 'listPublic', async () => [server(over)]],
[visibility, 'presenceFor', async () => audience],
[events, 'online', async () => [{ name: 'Alice', sleeping: false }, { name: 'Bob', sleeping: true }]],
],
() => command('online').handler({ options: {}, actor: who }),
)
}
test('/online below the audience: the count, in public, and no names', async () => {
withCore()
const env = await online('staff', STRANGER)
assert.ok(!env.ephemeral)
assert.match(env.title, /3 online/)
assert.ok(!said(env).includes('Alice'))
// Staff is not something linking earns, so there is no nudge to link.
assert.ok(!env.notice)
})
test('/online inside a narrower audience: the names, to the caller ALONE', async () => {
withCore()
const env = await online('staff', MOD)
assert.strictEqual(env.ephemeral, true, 'a moderator’s roll call was posted to the channel')
assert.match(env.text, /Alice/)
assert.match(env.text, /Bob \(sleeping\)/)
const signedIn = await online('signed_in', PLAYER)
assert.strictEqual(signedIn.ephemeral, true)
})
test('/online with a public audience posts the names in the open', async () => {
withCore()
const env = await online('public', STRANGER)
assert.ok(!env.ephemeral)
assert.match(env.text, /Alice/)
})
test('/online nudges an unlinked caller to link only when linking would reach the names', async () => {
withCore()
const env = await online('signed_in', STRANGER)
assert.ok(!env.ephemeral)
assert.match(env.notice, /Link your Discord account/)
})
test('/online judges the ACCOUNT, not the actor: a banned moderator is nobody', async () => {
withCore()
const env = await online('staff', actor(3, 'moderator'))
assert.ok(!said(env).includes('Alice'))
})
test('/online for an offline server names nobody, whatever the audience', async () => {
withCore()
const env = await online('public', MOD, { online: false, players: 0 })
assert.match(env.text, /offline/)
assert.ok(!said(env).includes('Alice'))
})
// ── /clan — D127 with D48 ─────────────────────────────────────────────────
const CLAN = { externalId: 'main:7:1', name: 'Wolves', color: '#aa3300', score: 50, memberCount: 2, maxMembers: 8 }
async function clan(rosterAudience, who, options = { name: 'wolves' }, board = { supported: true, fresh: true }) {
const { servers, clans, visibilityDb, visibility } = models()
return stubbed(
[
[servers, 'listPublic', async () => [server()]],
[clans, 'listForServer', async () => ({ clans: [CLAN, { ...CLAN, externalId: 'main:8:1', name: 'Wolverines' }], board })],
[visibilityDb, 'getSetting', async (key) => (key === visibility.CLAN_ROSTER_KEY ? rosterAudience : null)],
[require('../model/clans/clans.db'), 'findClan', async () => ({ ...CLAN, serverId: 'main', serverName: 'Main', createdMs: 1 })],
[require('../model/clans/clans.db'), 'userIsMember', async (_ext, userId) => userId === 1],
[require('../model/clans/clans.db'), 'listMembers', async () => [
{ name: 'Alice', rank: 1, online: 1 },
{ name: 'Bob', rank: 2, online: 0 },
]],
],
() => command('clan').handler({ options, actor: who }),
)
}
test('/clan outside the roster audience: the public facts, in public, no names', async () => {
withCore()
const env = await clan('members', STRANGER)
assert.ok(!env.ephemeral)
assert.strictEqual(env.title, 'Wolves')
assert.match(said(env), /50/)
assert.ok(!said(env).includes('Alice'))
})
test('/clan inside a narrower roster audience: the roster, to the caller ALONE', async () => {
withCore()
const member = await clan('members', PLAYER)
assert.strictEqual(member.ephemeral, true, 'a clan roster was posted to the channel')
assert.match(said(member), /Alice \(leader, online\)/)
const mod = await clan('members', MOD)
assert.strictEqual(mod.ephemeral, true)
})
test('/clan with a public roster audience posts the roster in the open', async () => {
withCore()
const env = await clan('public', STRANGER)
assert.ok(!env.ephemeral)
assert.match(said(env), /Alice/)
})
test('/clan: an exact name wins over a prefix, and a shared prefix is ambiguous and private', async () => {
withCore()
assert.strictEqual((await clan('members', STRANGER, { name: 'WOLVES' })).title, 'Wolves')
const env = await clan('members', STRANGER, { name: 'wol' })
assert.strictEqual(env.ephemeral, true)
assert.match(env.text, /Wolves, Wolverines/)
})
test('/clan does not say "no such clan" from a board it cannot trust', async () => {
withCore()
const env = await clan('members', STRANGER, { name: 'bears' }, { supported: true, fresh: false })
assert.strictEqual(env.ephemeral, true)
assert.match(env.text, /not available, so it may be there/)
const sure = await clan('members', STRANGER, { name: 'bears' })
assert.match(sure.text, /^No clan called “bears”\.$/)
})
// ── Words ─────────────────────────────────────────────────────────────────
test('times are written in UTC with the distance beside them', () => {
withCore()
const { when, relative } = require('../commands/common')
const now = Date.parse('2026-09-25T12:00:00Z')
assert.strictEqual(when('2026-10-01T18:00:00Z', now), 'Thu 1 Oct, 18:00 UTC (in 6 days)')
assert.strictEqual(relative(now - 3 * 3_600_000, now), '3 hours ago')
assert.strictEqual(relative(now + 90_000, now), 'in 2 minutes')
assert.strictEqual(when(null, now), null)
})

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), [])
})

View File

@@ -97,7 +97,7 @@ test('the public shape carries nothing about the sidecar', () => {
// someone who did not read this file, and an allowlist is the only assertion
// that catches one.
assert.deepStrictEqual(Object.keys(shaped).sort(), [
'hostname', 'id', 'lastSeenAt', 'level', 'maxPlayers', 'name', 'online', 'players', 'seed', 'stale',
'hostname', 'id', 'lastSeenAt', 'level', 'maxPlayers', 'name', 'nextWipe', 'online', 'players', 'seed', 'stale',
'updatedAt', 'wipeId', 'wipedAt', 'worldSize',
])
})