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

187
server/commands/common.js Normal file
View File

@@ -0,0 +1,187 @@
// ── What the five commands share (phase 16, R11) ───────────────────────────
//
// Registered with `api.registerSlashCommands` (MODULE_API 1.6.0). The handlers
// run in the WEBSITE process: core pulls the definitions over its internal API to
// the bot and dispatches each call back here. Nothing in these files knows what
// Discord is — a handler is handed an `actor` and returns an envelope.
//
// ── The two privacy rules (D127) ──────────────────────────────────────────
//
// 1. **A refusal is private.** Core already delivers an `ephemeral` answer as a
// private follow-up; `refuse()` below is the one way these files say no, and
// it always sets the flag.
// 2. **An answer narrower than `public` is private too.** Core has no reverse
// case: an answer WITHOUT the flag is posted to the channel the command was
// run in, where everybody reads it. So a moderator running `/online` in a
// public channel must get the names privately, or the names they may see are
// published to everyone who may not. That is the leak module-uo's `/guild`
// has (D132, Module-uo#46), and every answer here decides it explicitly.
//
// ── Three seconds (`HANDLER_TIMEOUT_MS`) ──────────────────────────────────
//
// Every answer reads this module's own tables. **No command asks a sidecar**: a
// game that is slow to answer would cost the reply, and the tables already hold
// the last thing each server said.
const core = require('../core')
const servers = require('../model/servers/servers.model')
const visibility = require('../model/visibility/visibility.model')
/**
* Where the caller sits on the presence ladder.
*
* An UNLINKED caller is `public`, answered directly. Handing `visibility.viewer`
* a synthetic request with no user would make it fall through to
* `getUserFromRequest`, which expects real cookies (module-uo's phase 3 bug). A
* linked caller is handed over as `{ user }`, which `viewer` reads first and then
* re-reads from the account row — so a banned or demoted account is judged by
* what it is now, not by what core resolved.
*/
async function levelFor(actor) {
if (!actor || actor.userId == null) return 'public'
return visibility.viewerLevel({ user: { id: actor.userId, role: actor.role } })
}
/** The viewer shape `model/clans` takes: `{ userId, role }` or null. */
const viewerOf = (actor) => (actor && actor.userId != null ? { userId: actor.userId, role: actor.role || null } : null)
/** Every answer that says no. Private, always (D127 rule 1). */
const refuse = (text) => ({ text, ephemeral: true })
/** An absolute link to a page on the site. */
const pageUrl = (path) => `${core.baseUrl}${path}`
const serverUrl = (server) => pageUrl(`/rust/servers/${encodeURIComponent(server.id)}`)
/**
* Which server a `server` option names, among `list`.
*
* An exact id, then an exact name (case-insensitive), then a unique prefix of
* either. Servers are added at runtime and `choices` are fixed at load, so this
* is free text matched here. A wrong-server answer is worse than "say which one",
* so two prefix matches are ambiguous rather than a guess.
*
* Answers `{ server }`, `{ all }` (no option and more than one server),
* `{ none }` (no servers at all), `{ ambiguous }` or `{ missing }`.
*/
function pickServer(list, option) {
if (!list.length) return { none: true }
const wanted = typeof option === 'string' ? option.trim().toLowerCase() : ''
if (!wanted) return list.length === 1 ? { server: list[0] } : { all: list }
const byId = list.find((s) => s.id.toLowerCase() === wanted)
if (byId) return { server: byId }
const byName = list.filter((s) => s.name.toLowerCase() === wanted)
if (byName.length === 1) return { server: byName[0] }
const prefix = list.filter((s) => s.id.toLowerCase().startsWith(wanted) || s.name.toLowerCase().startsWith(wanted))
if (prefix.length === 1) return { server: prefix[0] }
if (prefix.length > 1) return { ambiguous: prefix }
return { missing: option.trim() }
}
/** The refusal for each way `pickServer` can fail to name exactly one server. */
function pickRefusal(picked, { needOne = false } = {}) {
const names = (list) => list.map((s) => `${s.name} (\`${s.id}\`)`).join(', ')
if (picked.none) return refuse('No Rust servers are set up on this site yet.')
if (picked.missing) return refuse(`No server matches “${picked.missing}”.`)
if (picked.ambiguous) return refuse(`Several servers match: ${names(picked.ambiguous)}. Name one.`)
if (needOne && picked.all) return refuse(`Which server? This site follows ${names(picked.all)}.`)
return null
}
/** Every enabled server, as the public shape. */
const listServers = (now = Date.now()) => servers.listPublic(now)
// ── Time, in words ────────────────────────────────────────────────────────
//
// Plain text, not a platform's timestamp markup: the envelope is platform-
// agnostic (§7.1), and a second platform would print `<t:…>` literally. So an
// instant is written in UTC, with how far away it is beside it — the relative
// part is the one every reader can use without converting.
const UTC_FORMAT = new Intl.DateTimeFormat('en-GB', {
timeZone: 'UTC',
weekday: 'short',
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
})
function relative(ms, now = Date.now()) {
const diff = ms - now
const abs = Math.abs(diff)
const unit = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`
let span
if (abs < 60_000) span = 'less than a minute'
else if (abs < 3_600_000) span = unit(Math.round(abs / 60_000), 'minute')
else if (abs < 172_800_000) span = unit(Math.round(abs / 3_600_000), 'hour')
else span = unit(Math.round(abs / 86_400_000), 'day')
return diff >= 0 ? `in ${span}` : `${span} ago`
}
/** `Thu 1 Oct, 18:00 UTC (in 6 days)`, or null for no instant. */
function when(value, now = Date.now()) {
if (!value) return null
const ms = value instanceof Date ? value.getTime() : Date.parse(value)
if (Number.isNaN(ms)) return null
return `${UTC_FORMAT.format(ms).replace(/,? (\d\d:\d\d)$/, ', $1')} UTC (${relative(ms, now)})`
}
/** Just the distance: `3 days ago`. */
function ago(value, now = Date.now()) {
if (!value) return null
const ms = value instanceof Date ? value.getTime() : Date.parse(value)
return Number.isNaN(ms) ? null : relative(ms, now)
}
const SOURCES = {
forced: 'the monthly forced wipe',
rule: 'the server’s own schedule',
once: 'rescheduled by the operator',
}
/** The next wipe in words, with what decided it — or `null` for no schedule. */
function nextWipeText(server, now = Date.now()) {
const next = server.nextWipe
if (!next) return null
return `${when(next.at, now)} — ${SOURCES[next.source] || next.source}`
}
/** Players, as a number out of the maximum, for a server that is up. */
const playerCount = (server) => (server.maxPlayers ? `${server.players}/${server.maxPlayers}` : String(server.players))
/**
* Cut a list of lines to fit one embed field (1 024 characters) and a count,
* ending in "and N more" when it had to cut.
*/
function fitLines(lines, { max = lines.length, limit = 1000 } = {}) {
const kept = []
let length = 0
for (const line of lines.slice(0, max)) {
if (length + line.length + 1 > limit - 20) break
kept.push(line)
length += line.length + 1
}
const rest = lines.length - kept.length
return rest > 0 ? `${kept.join('\n')}\nand ${rest} more` : kept.join('\n')
}
module.exports = {
levelFor,
viewerOf,
refuse,
pageUrl,
serverUrl,
pickServer,
pickRefusal,
listServers,
relative,
when,
ago,
nextWipeText,
playerCount,
fitLines,
}

16
server/commands/index.js Normal file
View File

@@ -0,0 +1,16 @@
// ── The slash commands (phase 16, R11, D126) ──────────────────────────────
//
// Five read-only questions, answered from this module's own tables. No write
// verbs, and no `/link`: the account link stays on R1's two surfaces.
//
// Registered as ONE batch in `index.js`. Names are bare words (§32.4 reading 1):
// Discord scopes commands to the bot that owns them, and one module per site
// (website#204) means no other module competes for them. None collides with the
// bot's own built-ins, which core cannot see and the bot resolves against us.
module.exports = [
require('./status.command'),
require('./wipe.command'),
require('./top.command'),
require('./online.command'),
require('./clan.command'),
]

View File

@@ -0,0 +1,93 @@
// ── `/online [server]` — how many, and who, where the caller may see it ─────
//
// The command with the gate. The org lead's rule (2026-09-22) is that nothing
// names who is online by default: the COUNT is public and the NAMES reach the
// server's presence audience (D42, per-server override D45), staff unless an
// operator widened it.
//
// And D127 on top of it: **a caller inside a narrower-than-public audience gets
// the names PRIVATELY.** The answer is posted where the command was run, and a
// moderator's `/online` in a public channel would otherwise hand the roll call to
// everyone reading the channel. Only a server whose audience is `public` posts
// its names in the open.
const c = require('./common')
const events = require('../model/events/events.model')
const visibility = require('../model/visibility/visibility.model')
/** Fifty names, then "and N more" (§32.4 reading 2). */
const LIMIT = 50
// The link nudge, and only when it is TRUE — module-uo's rule. Linking a Discord
// account to a site account earns `signed_in` and nothing above it; `staff` is a
// role an operator grants. So a server that shows names to staff is not a reason
// to tell anybody to link.
function linkNudge(actor, required) {
if (actor && actor.isLinked) return null
if (required !== 'signed_in') return null
return 'Link your Discord account on the site to see who is on — this server shows names to signed-in members.'
}
async function forServer(server, actor, level) {
const required = await visibility.presenceFor(server.id)
const visible = visibility.meets(level, required)
const count = server.online ? server.players : 0
// Offline, or nobody on: there are no names to decide about, and saying who
// was on when the server went down would be the presence board's last word
// presented as now.
if (!server.online) return { text: `${server.name} is offline.`, url: c.serverUrl(server) }
if (!visible || count === 0) {
return {
title: `${server.name} — ${count} online`,
url: c.serverUrl(server),
...(visible ? {} : { notice: linkNudge(actor, required) }),
}
}
const players = await events.online(server.id)
const names = players.map((p) => (p.sleeping ? `${p.name || 'Unknown player'} (sleeping)` : p.name || 'Unknown player'))
return {
title: `${server.name} — ${players.length} online`,
url: c.serverUrl(server),
text: c.fitLines(names, { max: LIMIT, limit: 1900 }),
// D127. Anything short of `public` is for the caller alone.
ephemeral: required !== 'public',
}
}
async function handler({ options, actor }) {
const now = Date.now()
const picked = c.pickServer(await c.listServers(now), options && options.server)
const refusal = c.pickRefusal(picked)
if (refusal) return refusal
const level = await c.levelFor(actor)
if (picked.server) return forServer(picked.server, actor, level)
// The fleet: counts only, which are public. Names are one server's question,
// and the closing line offers it only when naming a server would show some.
const visibleSomewhere = (
await Promise.all(picked.all.map(async (s) => visibility.meets(level, await visibility.presenceFor(s.id))))
).some(Boolean)
return {
title: 'Online now',
url: c.pageUrl('/rust'),
fields: picked.all.slice(0, 25).map((s) => ({
name: s.name,
value: s.online ? `${s.players} online` : 'Offline',
inline: true,
})),
...(visibleSomewhere ? { text: 'Name a server to see who is on.' } : {}),
}
}
module.exports = {
name: 'online',
description: 'How many are on a Rust server, and who, where this site shows names',
options: [{ name: 'server', type: 'string', description: 'Server name or id (counts for all when left out)', required: false }],
// Everyone, deliberately: `linked` would hide the command from the unlinked
// members the nudge exists to invite. The gate is inside the handler.
access: 'everyone',
handler,
}

View File

@@ -0,0 +1,60 @@
// ── `/status [server]` — is it up, how full, when did it wipe ───────────────
//
// Public at every setting: everything here is on the public server list already.
// The count is a number and names nobody; the names are `/online`'s business.
const c = require('./common')
/** One line per server, for the fleet view. */
function summary(server, now) {
const parts = [server.online ? `Online · ${c.playerCount(server)}` : 'Offline']
if (server.wipedAt) parts.push(`wiped ${c.ago(server.wipedAt, now)}`)
return parts.join(' · ')
}
function detail(server, now) {
const fields = [
{ name: 'Status', value: server.online ? 'Online' : 'Offline', inline: true },
{ name: 'Players', value: server.online ? c.playerCount(server) : '—', inline: true },
]
if (server.worldSize) {
fields.push({ name: 'Map', value: server.seed != null ? `${server.worldSize} · seed ${server.seed}` : String(server.worldSize), inline: true })
}
if (server.wipedAt) fields.push({ name: 'Last wipe', value: c.when(server.wipedAt, now), inline: false })
const next = c.nextWipeText(server, now)
if (next) fields.push({ name: 'Next wipe', value: next, inline: false })
// An offline server says when it was last heard from, which is the difference
// between "down for a restart" and "gone for a week". A server nothing has ever
// heard from says so rather than printing the epoch.
if (!server.online) {
fields.push({ name: 'Last seen', value: server.lastSeenAt ? c.when(server.lastSeenAt, now) : 'never', inline: false })
}
return { title: server.name, url: c.serverUrl(server), fields }
}
async function handler({ options }) {
const now = Date.now()
const picked = c.pickServer(await c.listServers(now), options && options.server)
const refusal = c.pickRefusal(picked)
if (refusal) return refusal
if (picked.server) return detail(picked.server, now)
// An embed takes 25 fields. A fleet larger than that is a site that has a
// server list page, and the title links to it.
return {
title: 'Rust servers',
url: c.pageUrl('/rust'),
fields: picked.all.slice(0, 25).map((s) => ({ name: s.name, value: summary(s, now), inline: false })),
}
}
module.exports = {
name: 'status',
description: 'Is a Rust server up, how many are on, and when it last wiped',
options: [{ name: 'server', type: 'string', description: 'Server name or id (all servers when left out)', required: false }],
// Everyone: nothing here is narrower than public, and the gates that DO matter
// in other commands are resolved inside their handlers.
access: 'everyone',
handler,
}

View File

@@ -0,0 +1,85 @@
// ── `/top [stat] [server] [alltime]` — the leaderboard's top ten (D126) ─────
//
// Public at every setting, as on the web: the leaderboard's NAMES are public, and
// only `lastSeen` sits behind presence (a tally refreshes it every minute a player
// is on, so it is the Online tab by another name). This answer never carries a
// `lastSeen`, and never a Steam id — `events.leaderboard` is asked with
// `presence: false`, so the field is not there to leak.
const c = require('./common')
const events = require('../model/events/events.model')
/** Ten rows: a summary a person reads, not a table they scroll (§32.4 reading 2). */
const LIMIT = 10
// `stat`'s choices are fixed, so they are a `choices` list rather than free
// text. The value is the leaderboard's own sort key.
const STATS = {
kills: { sort: 'kills', label: 'kills', value: (r) => r.kills },
deaths: { sort: 'deaths', label: 'deaths', value: (r) => r.deaths },
npckills: { sort: 'npcKills', label: 'NPC kills', value: (r) => r.npcKills },
playtime: { sort: 'playtime', label: 'playtime', value: (r) => r.playtimeSec, format: hours },
}
function hours(sec) {
const h = Math.floor(sec / 3600)
const m = Math.floor((sec % 3600) / 60)
return h ? `${h}h ${m}m` : `${m}m`
}
async function handler({ options }) {
const now = Date.now()
const stat = STATS[(options && options.stat) || 'kills'] || STATS.kills
// A leaderboard is one server's. With several and none named the command asks
// rather than choosing one, privately — the question is for the caller.
const picked = c.pickServer(await c.listServers(now), options && options.server)
const refusal = c.pickRefusal(picked, { needOne: true })
if (refusal) return refusal
const { server } = picked
// The current wipe unless asked for all-time. A server that has not reported
// a wipe yet has nothing to scope to, and all-time is the honest answer.
const allTime = Boolean(options && options.alltime) || !server.wipeId
const rows = await events.leaderboard({
serverId: server.id,
wipeId: allTime ? null : server.wipeId,
sort: stat.sort,
limit: LIMIT,
presence: false,
})
const scope = allTime ? 'all time' : 'this wipe'
const title = `${server.name} — top ${stat.label}, ${scope}`
if (!rows.length) return { title, url: c.serverUrl(server), text: `Nobody is on the board for ${scope} yet.` }
const format = stat.format || String
return {
title,
url: c.serverUrl(server),
text: rows.map((r, i) => `${i + 1}. ${r.name || 'Unknown player'} — ${format(stat.value(r))}`).join('\n'),
}
}
module.exports = {
name: 'top',
description: 'The top ten players on a Rust server by kills, deaths, NPC kills or playtime',
options: [
{
name: 'stat',
type: 'string',
description: 'What to rank by (kills when left out)',
required: false,
choices: [
{ name: 'Kills', value: 'kills' },
{ name: 'Deaths', value: 'deaths' },
{ name: 'NPC kills', value: 'npckills' },
{ name: 'Playtime', value: 'playtime' },
],
},
{ name: 'server', type: 'string', description: 'Server name or id (needed when there are several)', required: false },
{ name: 'alltime', type: 'boolean', description: 'Every wipe rather than the current one', required: false },
],
access: 'everyone',
handler,
}

View File

@@ -0,0 +1,38 @@
// ── `/wipe [server]` — when it wipes next, and when it last did (D128) ──────
//
// Public: a wipe date is announced to bring players back. The next wipe is the
// operator's schedule, computed on this read (`model/servers/nextWipe.js`); a
// server with no schedule says so and shows only the last wipe it reported.
const c = require('./common')
function lines(server, now) {
return [
`Next: ${c.nextWipeText(server, now) || 'no schedule set'}`,
`Last: ${server.wipedAt ? c.when(server.wipedAt, now) : 'not reported yet'}`,
].join('\n')
}
async function handler({ options }) {
const now = Date.now()
const picked = c.pickServer(await c.listServers(now), options && options.server)
const refusal = c.pickRefusal(picked)
if (refusal) return refusal
if (picked.server) {
return { title: `${picked.server.name} — wipes`, url: c.serverUrl(picked.server), text: lines(picked.server, now) }
}
return {
title: 'Wipes',
url: c.pageUrl('/rust'),
fields: picked.all.slice(0, 25).map((s) => ({ name: s.name, value: lines(s, now), inline: false })),
}
}
module.exports = {
name: 'wipe',
description: 'When a Rust server wipes next, and when it last wiped',
options: [{ name: 'server', type: 'string', description: 'Server name or id (all servers when left out)', required: false }],
access: 'everyone',
handler,
}