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

@@ -14,6 +14,7 @@ const core = require('../../core')
const db = require('../../model/servers/servers.db')
const mapImages = require('../../mapImages')
const nextWipe = require('../../model/servers/nextWipe')
const servers = require('../../model/servers/servers.model')
const sidecar = require('../../sidecarClient')
@@ -28,6 +29,29 @@ async function listServers(req, res) {
}
}
/**
* The wipe schedule a save carries (phase 16, D130), normalised for storage — or
* `null` when the body carries none, which leaves the stored one alone.
*
* Only the fields the rule reads are kept: a weekly rule's day left behind after
* the operator switched to `forced` would be a value nothing reads and the form
* would show back to them as if it meant something.
*/
function scheduleFrom(body) {
if (body.wipeRule === undefined) return null
const rule = body.wipeRule
const weekly = rule === 'weekly' || rule === 'biweekly'
const blank = (v) => v === undefined || v === null || v === ''
return {
wipeRule: rule,
wipeDay: weekly ? Number(body.wipeDay) : null,
wipeTime: weekly ? body.wipeTime : null,
wipeTz: weekly ? body.wipeTz : null,
wipeAnchor: rule === 'biweekly' ? body.wipeAnchor : null,
wipeOnceAt: blank(body.wipeOnceAt) ? null : new Date(body.wipeOnceAt),
}
}
async function putServer(req, res) {
const { id } = req.params
const { name, sidecarBaseUrl, sidecarToken, protocol, enabled, sortOrder } = req.body
@@ -43,6 +67,13 @@ async function putServer(req, res) {
return res.status(400).json({ message: 'A new server needs its sidecar token' })
}
// Checked whole before anything is written, so a bad schedule saves nothing
// rather than half a row. The sentences go back as they are: the form shows
// them beside the fields.
const schedule = scheduleFrom(req.body)
const problems = schedule ? nextWipe.validateSchedule(req.body) : []
if (problems.length) return res.status(400).json({ message: problems.join(' '), errors: problems })
await db.upsertServer({
id,
name,
@@ -56,6 +87,7 @@ async function putServer(req, res) {
enabled: enabled === undefined ? true : enabled,
sortOrder: sortOrder === undefined ? 0 : sortOrder,
})
if (schedule) await db.setSchedule(id, schedule)
await core.activity.log({
req,
@@ -66,6 +98,9 @@ async function putServer(req, res) {
sidecarBaseUrl,
// Whether the credential was rotated, never the credential.
tokenChanged: Boolean(sidecarToken),
// The schedule as written, when the save carried one. Nothing in it is a
// secret, and "who moved the wipe" is a question players will ask.
...(schedule ? { schedule: { ...schedule, wipeOnceAt: schedule.wipeOnceAt ? schedule.wipeOnceAt.toISOString() : null } } : {}),
},
})
@@ -183,4 +218,4 @@ async function renderMap(req, res) {
}
}
module.exports = { listServers, putServer, deleteServer, testServer, fetchMap, renderMap }
module.exports = { scheduleFrom, listServers, putServer, deleteServer, testServer, fetchMap, renderMap }