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

@@ -0,0 +1,70 @@
// ── The voice the module's own lines are said in (phase 17, D140) ──────────
//
// One fleet setting (§33.4 reading 5): the name of a permission group that has
// a BetterChat style, or nothing for plain chat. News lines and `rust.announce`
// lines are then said in that group's title and colours — composed here, said by
// our plugin with no player as the sender, so they look right whether or not
// BetterChat is loaded.
//
// The style is read at the moment a line is said, never copied into the
// setting: an operator who recolours the group changes the voice with it, and a
// group whose style is taken away stops being a voice rather than leaving a
// stale one behind.
const settingsDb = require('../visibility/visibility.db')
const chatStyle = require('./chatStyle')
const db = require('./permissions.db')
const model = require('./permissions.model')
const VOICE_KEY = 'announce.voice'
/** The chosen group's name, or '' for plain chat. */
async function chosen() {
return (await settingsDb.getSetting(VOICE_KEY)) || ''
}
/**
* The format a line is said in right now, or null for plain chat — which is
* also the answer when the chosen group has since lost its style or gone.
*/
async function currentFormat() {
const group = await chosen()
if (!group) return null
const fields = await db.getGroupChat(group)
return fields ? chatStyle.voiceFormat(fields) : null
}
/** The setting, and every group that could be a voice, for the admin page. */
async function describe() {
const [voice, rows] = await Promise.all([chosen(), db.listGroupChat()])
const options = []
for (const [group, fields] of model.chatByGroup(rows)) {
const format = chatStyle.voiceFormat(fields)
if (format) options.push({ group, title: fields.Title || group, format })
}
return { voice, options: options.sort((a, b) => a.group.localeCompare(b.group)) }
}
/**
* Choose the voice. A group is accepted only when it has a style a voice can be
* made from; '' goes back to plain chat. Resolves `{ ok }` or
* `{ ok: false, message }`.
*/
async function choose(group, userId = null) {
const name = model.normaliseName(group)
if (name) {
const fields = await db.getGroupChat(name)
if (!fields || !chatStyle.voiceFormat(fields)) {
return { ok: false, message: `The group "${name}" has no chat style, so it cannot be a voice. Give it one under Permissions first.` }
}
}
await settingsDb.setSetting(VOICE_KEY, name, userId)
return { ok: true, voice: name }
}
module.exports = { VOICE_KEY, chosen, currentFormat, describe, choose }