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

@@ -12,6 +12,10 @@
// …and the announce leg, `rust.chat`, which says a published news post in the
// chat of every server whose switch is on (D104).
//
// Phase 17 gave both a delivery — chat, or a popup through PopupNotifications
// (D141, D142) — and a VOICE: the style of one permission group, which the
// plugin says the line in with no player as its sender (D140).
//
// ── Who decides what ─────────────────────────────────────────────────────────
//
// The GAME counts: presence, kills, the score (D81, D99). The SITE picks the
@@ -36,6 +40,7 @@ const servers = require('./model/servers/servers.model')
const permDb = require('./model/permissions/permissions.db')
const linksDb = require('./model/links/links.db')
const emit = require('./engagement/emit')
const voice = require('./model/permissions/voice')
const { serverFor, transportError, pluginError, perServer, bounded } = require('./eventLeases')
const { BUDGET_MS } = require('./eventWorld')
@@ -57,6 +62,9 @@ const MODES = ['everyone', 'top', 'minScore', 'random', 'topPercent']
/** The fleet, in `rust.announce`'s `server` param (D105). */
const EVERY_SERVER = '*'
/** Where a line goes (D141). Chat is the default and what every server can do. */
const DELIVERIES = ['chat', 'popup']
/** The plugin's refusals a second attempt would repeat. */
const PERMANENT = new Set([
'events-disabled',
@@ -68,6 +76,9 @@ const PERMANENT = new Set([
'too-many',
'too-long',
'kits-missing',
// Protocol 12: a popup on a server without PopupNotifications. Waiting does not
// install it.
'popup-unavailable',
])
const BUDGETS = [
@@ -620,6 +631,20 @@ const kitEntitle = {
},
}
/**
* The body of a chat line: its delivery, and the voice's format when the line
* goes to chat and a voice is chosen (D140). A popup is not a chat line and
* carries no format. Chat, the default, is left off the wire — the shape a
* protocol-11 caller sent — so a line with nothing new looks exactly as before.
*/
function lineBody(base, delivery, format) {
return {
...base,
...(delivery === 'popup' ? { delivery } : {}),
...(delivery !== 'popup' && format ? { format } : {}),
}
}
/**
* Say one line on one server, and classify the answer. `repeat` is a success:
* the plugin remembered the key, and the line was already said.
@@ -645,6 +670,10 @@ const announce = {
description: 'Which server, or * for every server (D105).' },
{ name: 'message', type: 'string', required: true, example: 'The airfield brawl starts in five minutes!',
description: `The line, up to ${MAX_CHAT} characters.` },
// Optional, and the action stays version 1: a bump would stop every step
// already written from dispatching until somebody re-saved it (§33.2).
{ name: 'delivery', type: 'string', required: false, example: 'chat', source: 'rust.options.delivery',
description: 'chat (the default), or popup — which needs PopupNotifications on the server, and is refused with a reason where it is missing (D141).' },
],
// One per server reached. `*` is priced at the enabled servers when core asks,
@@ -658,6 +687,10 @@ const announce = {
return { ok: false, retry: false, error: `a chat line is at most ${MAX_CHAT} characters, and this one is ${message.length}` }
}
const rawDelivery = params.delivery === undefined || params.delivery === null || params.delivery === '' ? 'chat' : params.delivery
const delivery = oneOf(rawDelivery, DELIVERIES)
if (!delivery) return { ok: false, retry: false, error: `a line is delivered to chat or to a popup, not to "${rawDelivery}"` }
const target = String(params.server || '').trim()
let list
if (target === EVERY_SERVER) {
@@ -671,7 +704,8 @@ const announce = {
if (verify) return { ok: true }
const body = { key: idempotencyKey || `run:${runId}`, message, event: true }
const format = delivery === 'chat' ? await voice.currentFormat() : null
const body = lineBody({ key: idempotencyKey || `run:${runId}`, message, event: true }, delivery, format)
const outcomes = await Promise.all(list.map(async (server) => ({ server, ...(await sayOn(server, body)) })))
const name = (o) => o.server.name || o.server.id
@@ -730,8 +764,14 @@ const LEG = {
const list = (await servers.listForPolling()).filter((s) => s.announceNews)
const key = chatKey(post, line)
// Read once for the post, not once per server: every server says it in the
// same voice (D140). Each server's own delivery decides chat or popup (D142).
const format = list.some((s) => s.newsDelivery !== 'popup') ? await voice.currentFormat() : null
const outcomes = await Promise.all(
list.map(async (server) => ({ server: server.name || server.id, ...(await sayOn(server, { key, message: line })) })),
list.map(async (server) => ({
server: server.name || server.id,
...(await sayOn(server, lineBody({ key, message: line }, server.newsDelivery, format))),
})),
)
return { ok: true, outcomes }
} catch (err) {
@@ -823,6 +863,10 @@ const OPTION_SOURCES = [
{ value: 'random', label: 'N drawn at random' },
{ value: 'topPercent', label: 'The top X per cent (ties in)' },
]),
fixed('rust.options.delivery', 'Delivery', 'Where a line goes (D141).', [
{ value: 'chat', label: 'Chat' },
{ value: 'popup', label: 'A popup — needs PopupNotifications on the server' },
]),
{
id: 'rust.options.chatservers',
label: 'Chat servers',
@@ -840,6 +884,7 @@ module.exports = {
MAX_RECIPIENTS,
MAX_CHAT,
EVERY_SERVER,
DELIVERIES,
BUDGETS,
ACTIONS,
LEG,
@@ -850,5 +895,6 @@ module.exports = {
kitReward,
chatLine,
chatKey,
lineBody,
refParts,
}