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