Files
Module-Rust/server/commands/online.command.js
wtclaude 0670341198 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
2026-09-25 13:29:59 -05:00

94 lines
3.9 KiB
JavaScript

// ── `/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,
}