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
112 lines
4.9 KiB
JavaScript
112 lines
4.9 KiB
JavaScript
// ── `/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,
|
|
}
|