Files
Module-Rust/server/test/commands.test.js
wtclaude 0670341198 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
2026-09-25 13:29:59 -05:00

371 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── 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)
})