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

@@ -199,7 +199,15 @@ async function describe() {
// On this page because it is the one that lists every server with a setting
// of its own, and it answers the same kind of question — what a server shows.
news: {
servers: servers.map((s) => ({ id: s.id, name: s.name, enabled: Boolean(s.enabled), on: Boolean(Number(s.announceNews)) })),
servers: servers.map((s) => ({
id: s.id,
name: s.name,
enabled: Boolean(s.enabled),
on: Boolean(Number(s.announceNews)),
// D142: where the post goes when the switch is on. A word this build
// does not know reads as chat, which every server can do.
delivery: s.newsDelivery === 'popup' ? 'popup' : 'chat',
})),
},
}
}
@@ -214,7 +222,7 @@ async function describe() {
* Resolves `{ ok, changed }`, or `{ ok: false, status, message }` — a refusal is a
* sentence the page can show.
*/
async function update({ fleet, servers, clanRoster, news } = {}, actor = null) {
async function update({ fleet, servers, clanRoster, news, newsDelivery } = {}, actor = null) {
if (fleet !== undefined && !isAudience(fleet)) {
return { ok: false, status: 400, message: `"${fleet}" is not an audience. Choose one of: ${AUDIENCES.join(', ')}.` }
}
@@ -249,6 +257,17 @@ async function update({ fleet, servers, clanRoster, news } = {}, actor = null) {
}
}
const deliveryChanges = Object.entries(newsDelivery || {})
for (const [id, value] of deliveryChanges) {
if (value !== 'chat' && value !== 'popup') {
return { ok: false, status: 400, message: `News goes to chat or to a popup on server ${id}, not "${value}".` }
}
// eslint-disable-next-line no-await-in-loop
if ((await db.getServerPresence(id)) === undefined) {
return { ok: false, status: 404, message: `There is no server called ${id}.` }
}
}
const userId = actor && actor.id != null ? actor.id : null
if (fleet !== undefined) await db.setSetting(PRESENCE_KEY, fleet, userId)
@@ -261,6 +280,10 @@ async function update({ fleet, servers, clanRoster, news } = {}, actor = null) {
// eslint-disable-next-line no-await-in-loop
await db.setServerNews(id, on)
}
for (const [id, delivery] of deliveryChanges) {
// eslint-disable-next-line no-await-in-loop
await db.setServerNewsDelivery(id, delivery)
}
// What was written, for the controller's audit row. Recorded there rather than
// here because the activity log takes the REQUEST (who, from where), and a
@@ -272,6 +295,7 @@ async function update({ fleet, servers, clanRoster, news } = {}, actor = null) {
...(clanRoster !== undefined ? { clanRoster } : {}),
servers: Object.fromEntries(changes.map(([id, value]) => [id, value === null ? 'inherit' : value])),
...(newsChanges.length ? { news: Object.fromEntries(newsChanges) } : {}),
...(deliveryChanges.length ? { newsDelivery: Object.fromEntries(deliveryChanges) } : {}),
},
}
}