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:
111
server/commands/clan.command.js
Normal file
111
server/commands/clan.command.js
Normal 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,
|
||||
}
|
||||
Reference in New Issue
Block a user