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 }

View File

@@ -55,7 +55,8 @@ adminRustRouter.put(
'/servers/:id',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Create or update a Rust server'
// #swagger.description = 'Writes one server row. `sidecarToken` is write-only — send it to set or rotate the credential, and omit it or send an empty string to leave the stored one untouched. The id is the slug every URL under the module carries.'
// #swagger.description = 'Writes one server row. `sidecarToken` is write-only — send it to set or rotate the credential, and omit it or send an empty string to leave the stored one untouched. The id is the slug every URL under the module carries. The wipe schedule (`wipeRule`, `wipeDay`, `wipeTime`, `wipeTz`, `wipeAnchor`, `wipeOnceAt`) is written only when `wipeRule` is present, so a body without it leaves the stored schedule alone. `wipeRule` is `none`, `forced`, `weekly` or `biweekly`; a weekly or biweekly rule needs `wipeDay` (0 = Sunday), `wipeTime` (`HH:MM`) and an IANA `wipeTz`, and a biweekly rule a `wipeAnchor` date on that day. `wipeOnceAt` is a one-off wipe in the future. A 400 carries one sentence per problem in `errors`.'
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/RustServerSave" } } } } */
/* #swagger.responses[204] = { description: 'Saved' } */
/* #swagger.responses[400] = { description: 'Invalid body' } */
requireRole('admin'),
@@ -72,6 +73,16 @@ adminRustRouter.put(
body('protocol').optional().isInt({ min: 1, max: 1000 }).toInt(),
body('enabled').optional().isBoolean().toBoolean(),
body('sortOrder').optional().isInt({ min: -1000, max: 1000 }).toInt(),
// The wipe schedule (phase 16, D130). Shape only here; the rules that span
// fields — a day for a weekly rule, an anchor on that day, a known zone, a
// one-off date in the future — are `nextWipe.validateSchedule`'s, in the
// controller, so the form gets one sentence per problem.
body('wipeRule').optional().isIn(['none', 'forced', 'weekly', 'biweekly']),
body('wipeDay').optional({ values: 'null' }).isInt({ min: 0, max: 6 }),
body('wipeTime').optional({ values: 'null' }).isString().isLength({ max: 5 }),
body('wipeTz').optional({ values: 'null' }).isString().isLength({ max: 64 }),
body('wipeAnchor').optional({ values: 'falsy' }).isISO8601({ strict: true }).isLength({ max: 10 }),
body('wipeOnceAt').optional({ values: 'falsy' }).isISO8601({ strict: true }),
validate,
admin.putServer,
)