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