feat(rust): chat titles, BetterChat group styles, the voice and popups (phase 17)

PLAN.md §33, D134-D143. Protocol 12.

- Chat titles (D135-D137): per-server rules (stat, top N, text, colour)
  that rank the current wipe, and a mode (first | all | up to N). Worked
  out once in model/titles and read three ways: pushed whole to the game by
  a new titleSync loop (on change, restart or wipe), and on every
  leaderboard row as `titles`. Admin: PUT /servers/:id/titles.
- Group styles (D138, D139): a site group may carry all twelve BetterChat
  fields (rust_perm_group_chat). They ride perm.sync with `expect` from the
  pushed ledger, which gains a value column; a field changed in game is a
  `chat-field` drift row with the game's value, adopted into the style or
  put back. A withdrawn style is one `chat-group` retirement, never for
  `default`, cleared from the ledger only once BetterChat removed it.
- The voice (D140): one fleet setting naming a styled group; news and
  rust.announce chat lines carry its format and the plugin says them with
  no sender. Admin: GET/PUT /voice.
- Popups (D141, D142): rust.announce gains `delivery` (still version 1,
  from rust.options.delivery); each server gains news_delivery beside the
  news switch; `popup-unavailable` is not retried.
- GET /servers/:id/integrations reads, live, which optional mods a server
  has loaded. README lists BetterChat and PopupNotifications as optional.

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 17:48:22 -05:00
parent fb5a581a94
commit 1b70cef5be
43 changed files with 3371 additions and 72 deletions

View File

@@ -145,4 +145,62 @@ adminRustRouter.post(
admin.renderMap,
)
// ── The optional mods (phase 17) ──────────────────────────────────────────
adminRustRouter.put(
'/servers/:id/titles',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Set a server’s chat titles'
// #swagger.description = 'Replaces the server’s title rules and how many titles a player shows. A rule ranks the CURRENT wipe by `kills`, `npckills` or `playtime` and gives its top N (1–10) a title; only a stat above zero counts, so a fresh wipe gives no titles. Rules are in precedence order, at most ten. `mode` is `first` (the first rule a player meets), `all`, or `upto` `max` (1–5). The titles are shown in game by BetterChat when it is loaded — they are pushed whether or not it is, and it shows them as soon as it is — and on the web and app leaderboards. A 400 carries one sentence per problem in `errors`.'
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } }
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/RustTitleSettings" } } } } */
/* #swagger.responses[200] = { description: 'Saved; answers the server’s settings as stored' } */
/* #swagger.responses[400] = { description: 'A rule, the mode or N is not valid' } */
/* #swagger.responses[404] = { description: 'No such server' } */
requireRole('admin'),
param('id').isString().isLength({ min: 1, max: 64 }),
body('mode').optional().isIn(['first', 'all', 'upto']),
body('rules').isArray({ max: 10 }).withMessage('rules is a list of at most ten'),
validate,
admin.putTitles,
)
adminRustRouter.get(
'/servers/:id/integrations',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Which optional mods a server has loaded'
// #swagger.description = 'Asks the game, live, whether BetterChat and PopupNotifications are loaded, and which versions. Both are optional: without BetterChat titles and group styles wait for it, and without PopupNotifications a popup is refused with a reason. `integrations` is null when the game could not be asked or its plugin is older than protocol 12, and `status` then says why.'
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } }
/* #swagger.responses[200] = { description: 'What the server has loaded', content: { "application/json": { schema: { $ref: "#/components/schemas/RustIntegrations" } } } } */
/* #swagger.responses[404] = { description: 'No such server, or it is disabled' } */
requireRole('admin'),
param('id').isString().isLength({ min: 1, max: 64 }),
validate,
admin.integrations,
)
adminRustRouter.get(
'/voice',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'The voice announcements are said in'
// #swagger.description = 'The permission group whose BetterChat style news and `rust.announce` lines are said in, or empty for plain chat, and every group that could be one. The line is composed on the site and said by the bridge plugin with no player as its sender, so it works whether or not BetterChat is loaded.'
/* #swagger.responses[200] = { description: 'The voice and the groups that could be one', content: { "application/json": { schema: { $ref: "#/components/schemas/RustVoice" } } } } */
requireRole('admin'),
admin.getVoice,
)
adminRustRouter.put(
'/voice',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Choose the voice announcements are said in'
// #swagger.description = '`group` is a permission group with a chat style, or empty for plain chat. A group without a style is refused.'
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: 'object', properties: { group: { type: 'string', example: 'staff' } } } } } } */
/* #swagger.responses[200] = { description: 'Saved; answers the new state', content: { "application/json": { schema: { $ref: "#/components/schemas/RustVoice" } } } } */
/* #swagger.responses[400] = { description: 'That group has no chat style' } */
requireRole('admin'),
body('group').optional({ values: 'null' }).isString().isLength({ max: 64 }),
validate,
admin.putVoice,
)
module.exports = adminRustRouter