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,111 @@
// ── `/clan name [server]` — one clan, and its roster where the caller may see it
//
// D50 deferred it here. A clan's name, colour, score and member count are public
// (D58); its ROSTER — who is in it and which of them is on — sits behind the
// roster audience (D48), which defaults to the clan's own members plus staff.
//
// The roster decision is `clans.getForViewer`'s, the same function the web page
// and core's `projectRoster` use, so the three cannot disagree about who may
// look. What this file adds is D127: **a roster shown to anything short of a
// `public` audience goes to the caller alone**, never to the channel.
const c = require('./common')
const clans = require('../model/clans/clans.model')
/** The candidates an ambiguous name lists. */
const CANDIDATES = 10
/**
* Every clan on the servers searched, with the server each came from, and the
* servers whose clan board cannot be trusted right now.
*/
async function gather(servers) {
const boards = await Promise.all(servers.map(async (s) => ({ server: s, ...(await clans.listForServer(s.id)) })))
return {
rows: boards.flatMap((b) => b.clans.map((clan) => ({ clan, server: b.server }))),
unreadable: boards.filter((b) => !b.board.supported || !b.board.fresh).map((b) => b.server),
}
}
/** An exact name (case-insensitive), then a unique prefix. */
function find(rows, wanted) {
const needle = wanted.trim().toLowerCase()
const exact = rows.filter((r) => r.clan.name.toLowerCase() === needle)
if (exact.length === 1) return { hit: exact[0] }
if (exact.length > 1) return { ambiguous: exact }
const prefix = rows.filter((r) => r.clan.name.toLowerCase().startsWith(needle))
if (prefix.length === 1) return { hit: prefix[0] }
if (prefix.length > 1) return { ambiguous: prefix }
return {}
}
function memberLine(m) {
const tags = [m.leader ? 'leader' : null, m.online ? 'online' : null].filter(Boolean)
return `${m.name || 'Unknown player'}${tags.length ? ` (${tags.join(', ')})` : ''}`
}
async function handler({ options, actor }) {
const wanted = options && typeof options.name === 'string' ? options.name.trim() : ''
if (!wanted) return c.refuse('Name a clan.')
const picked = c.pickServer(await c.listServers(), options.server)
const refusal = c.pickRefusal(picked)
if (refusal) return refusal
const searched = picked.server ? [picked.server] : picked.all
const { rows, unreadable } = await gather(searched)
const { hit, ambiguous } = find(rows, wanted)
if (ambiguous) {
const list = ambiguous.slice(0, CANDIDATES).map((r) => (searched.length > 1 ? `${r.clan.name} on ${r.server.name}` : r.clan.name))
return c.refuse(`Several clans match “${wanted}”: ${list.join(', ')}${ambiguous.length > CANDIDATES ? ', …' : ''}`)
}
if (!hit) {
// A board that is missing, stale or unsupported proves nothing about a clan
// it does not list. Saying "no such clan" from it would be a guess presented
// as a fact.
if (unreadable.length) {
return c.refuse(
`No clan called “${wanted}” is on the boards that can be read right now. ` +
`The clan list for ${unreadable.map((s) => s.name).join(', ')} is not available, so it may be there.`,
)
}
return c.refuse(`No clan called “${wanted}”.`)
}
const answer = await clans.getForViewer(hit.clan.externalId, c.viewerOf(actor))
if (!answer) return c.refuse(`No clan called “${wanted}”.`)
const { clan, roster } = answer
const fields = [
{ name: 'Score', value: String(clan.score), inline: true },
{ name: 'Members', value: clan.maxMembers ? `${clan.memberCount}/${clan.maxMembers}` : String(clan.memberCount), inline: true },
{ name: 'Server', value: clan.serverName || hit.server.name, inline: true },
]
if (clan.color) fields.push({ name: 'Colour', value: clan.color, inline: true })
const base = { title: clan.name, url: c.pageUrl(`/rust/clans/${encodeURIComponent(clan.externalId)}`), fields }
if (!roster.visible || !roster.members.length) return base
// Leaders first, then whoever is on, then everyone else — the order a person
// looking for "who can I talk to" wants.
const members = [...roster.members].sort(
(a, b) => Number(b.leader) - Number(a.leader) || Number(b.online) - Number(a.online) || String(a.name).localeCompare(String(b.name)),
)
fields.push({ name: 'Roster', value: c.fitLines(members.map(memberLine)), inline: false })
// D127 and D48 together: a roster the whole site may see may be posted; any
// narrower one is for the caller.
return { ...base, ephemeral: roster.audience !== 'public' }
}
module.exports = {
name: 'clan',
description: 'A clan on a Rust server — score and members, and its roster where you may see it',
options: [
{ name: 'name', type: 'string', description: 'Clan name, or the start of it', required: true },
{ name: 'server', type: 'string', description: 'Server name or id (every server when left out)', required: false },
],
access: 'everyone',
handler,
}