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

@@ -140,6 +140,90 @@ module.exports = {
stale: { type: 'boolean', example: false },
nextWipe: { $ref: '#/components/schemas/RustNextWipe' },
schedule: { $ref: '#/components/schemas/RustWipeSchedule' },
titles: { $ref: '#/components/schemas/RustTitleSettings' },
titlePush: {
type: 'object',
nullable: true,
description: 'What the last push of titles to this server found, since this site last started: how many players hold one and whether BetterChat was there to show them. Null before the first push.',
properties: {
count: { type: 'integer', example: 4 },
betterChat: { type: 'boolean', example: true },
at: { type: 'string', format: 'date-time' },
},
},
},
},
RustTitleSettings: {
type: 'object',
description: 'One server’s chat titles (phase 17, D135–D136): rules that rank the current wipe, in precedence order, and how many titles a player shows.',
properties: {
mode: {
type: 'string',
enum: ['first', 'all', 'upto'],
description: '`first` shows the first rule a player meets, `all` every one, `upto` at most `max`.',
example: 'first',
},
max: { type: 'integer', minimum: 1, maximum: 5, example: 2 },
rules: {
type: 'array',
maxItems: 10,
items: {
type: 'object',
properties: {
stat: { type: 'string', enum: ['kills', 'npckills', 'playtime'], example: 'kills' },
topN: { type: 'integer', minimum: 1, maximum: 10, example: 1 },
text: {
type: 'string',
maxLength: 24,
description: 'The title. `[`, `]`, `<`, `>`, `{` and `}` are taken out on save, so a title carries no markup of its own.',
example: 'Top Killer',
},
color: { type: 'string', example: '#ff8800' },
},
},
},
},
},
RustIntegrations: {
type: 'object',
description: 'Which optional mods a server has loaded right now (GET /admin/rust/servers/{id}/integrations), read live from the game.',
properties: {
ok: { type: 'boolean', example: true },
status: { type: 'string', description: 'The sidecar client’s one-word status when the game could not be asked.', example: 'ok' },
integrations: {
type: 'object',
nullable: true,
description: 'Null when the game could not be asked, or its plugin is older than protocol 12.',
properties: {
betterChat: {
type: 'object',
properties: { loaded: { type: 'boolean', example: true }, version: { type: 'string', example: '5.2.15' } },
},
popupNotifications: {
type: 'object',
properties: { loaded: { type: 'boolean', example: false } },
},
},
},
},
},
RustVoice: {
type: 'object',
description: 'The voice news and `rust.announce` lines are said in (D140): a styled permission group, or none for plain chat.',
properties: {
voice: { type: 'string', description: 'A group name, or empty for plain chat.', example: 'staff' },
options: {
type: 'array',
description: 'Every group that has a chat style — the only groups that can be a voice.',
items: {
type: 'object',
properties: {
group: { type: 'string', example: 'staff' },
title: { type: 'string', example: '[Staff]' },
format: { type: 'string', description: 'The line the voice makes, with `{message}` where the words go.', example: '[#55aaff][+15][Staff][/+][/#] [#ffffff][+15]{message}[/+][/#]' },
},
},
},
},
},
RustLink: {
@@ -309,6 +393,13 @@ module.exports = {
example: '*',
},
permissions: { type: 'array', items: { type: 'string', example: 'kits.vip' } },
chat: {
type: 'object',
nullable: true,
description: 'The group’s BetterChat style — all twelve fields as text — or null for a group without one (D138).',
additionalProperties: { type: 'string' },
example: { Title: '[VIP]', TitleColor: '#ffaa55', ChatFormat: '{Title} {Username}: {Message}' },
},
members: {
type: 'array',
items: {
@@ -366,6 +457,18 @@ module.exports = {
description: 'The state of the mirror, per configured server.',
items: { $ref: '#/components/schemas/RustPermissionSyncState' },
},
chatFields: {
type: 'array',
description: 'The twelve BetterChat group fields a style carries, with each one’s type and BetterChat’s default, for the style editor.',
items: {
type: 'object',
properties: {
name: { type: 'string', example: 'TitleColor' },
type: { type: 'string', enum: ['int', 'title', 'color', 'size', 'bool', 'format'], example: 'color' },
default: { type: 'string', nullable: true, example: '#55aaff' },
},
},
},
drift: {
type: 'array',
description: 'What a game holds that the site did not author. Reported, never undone.',
@@ -376,9 +479,15 @@ module.exports = {
serverId: { type: 'string', example: 'main' },
kind: {
type: 'string',
description: 'One of `grant`, `member`, `group-permission`.',
description: 'One of `grant`, `member`, `group-permission`, or `chat-field` for a style field changed in game.',
example: 'grant',
},
detail: {
type: 'string',
nullable: true,
description: 'For `chat-field`, the value the game holds now. Null for every other kind.',
example: '#ff0000',
},
subject: {
type: 'string',
description: 'A Steam id, or a group name.',
@@ -660,6 +769,12 @@ module.exports = {
name: { type: 'string', example: 'Main · Vanilla' },
enabled: { type: 'boolean', example: true },
on: { type: 'boolean', example: false },
delivery: {
type: 'string',
enum: ['chat', 'popup'],
description: 'Where the post goes when `on`: chat, or a popup — which needs PopupNotifications on that server (D142).',
example: 'chat',
},
},
},
},
@@ -924,6 +1039,12 @@ module.exports = {
additionalProperties: { type: 'boolean' },
example: { main: true },
},
newsDelivery: {
type: 'object',
description: 'A server id to where a news post goes on it: `chat` or `popup` (D142).',
additionalProperties: { type: 'string', enum: ['chat', 'popup'] },
example: { main: 'popup' },
},
map: {
type: 'object',
description: 'The live map’s switches. `fleet` maps a layer to an audience and `mates` to true or false; `servers` maps a server id to the same shape, where null follows the fleet.',