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,163 @@
// ── Admin · Rust · Permissions — a group's chat style (phase 17, D138) ─────
//
// All twelve of BetterChat's group fields, on a group this site authors. A style
// is the whole of a BetterChat group or nothing, so the editor always shows all
// twelve, starting from BetterChat's own defaults.
//
// Three things the section says out loud, because each looks like success from
// here:
//
// • a server where BetterChat is not loaded holds nothing yet — the style
// lands when BetterChat does, at the next sync;
// • a field somebody changed in game is NOT overwritten — it shows under
// "Changed in game", to adopt or put back;
// • a field BetterChat refused (`InvalidValue`) is named with its server.
//
// Removing a style removes the group from BetterChat on the next sync (D139),
// which is BetterChat's own `chat group remove` — it has no quieter way.
import { useState } from 'react'
const LABELS = {
Priority: 'Priority (lower wins when a player is in several groups)',
Title: 'Title',
TitleColor: 'Title colour',
TitleSize: 'Title size',
TitleHidden: 'Hide the title',
TitleHiddenIfNotPrimary: 'Hide the title unless this is the player’s main group',
UsernameColor: 'Name colour',
UsernameSize: 'Name size',
MessageColor: 'Message colour',
MessageSize: 'Message size',
ChatFormat: 'Chat format',
ConsoleFormat: 'Console format',
}
/** BetterChat's defaults for this group, from the field list the server sent. */
function defaultsFor(group, fields) {
const out = {}
for (const f of fields) out[f.name] = f.default === null ? '' : f.default
out.Title = group === 'default' ? '[Player]' : `[${group}]`
return out
}
/** What each server's last sync said about this group's style. */
function styleNotes(group, servers) {
const notes = []
for (const s of servers) {
const chat = s.report && s.report.chat
if (!chat) continue
if (chat.loaded === false) {
notes.push(`${s.serverId}: BetterChat is not loaded, so nothing is styled there yet.`)
continue
}
for (const f of chat.failed || []) {
if (f.group !== group) continue
notes.push(`${s.serverId}: BetterChat refused ${f.field || 'the group'} (${f.reason}).`)
}
}
return notes
}
export default function ChatStyleSection({ group, fields, servers, busy, onSave }) {
const [editing, setEditing] = useState(null)
if (!fields || !fields.length) return null
const notes = group.chat ? styleNotes(group.name, servers) : []
const set = (name, value) => setEditing((e) => ({ ...e, [name]: value }))
return (
<>
<div className="field-label" style={{ marginTop: 18 }}>
Chat style (BetterChat)
</div>
{!editing && !group.chat && (
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '4px 0' }}>
No chat style. BetterChat, where it is installed, styles this group’s members as it does anybody else.
</p>
)}
{!editing && group.chat && (
<p className="sans" style={{ fontSize: '0.82rem', margin: '4px 0' }}>
<span style={{ color: /^#[0-9a-f]{6}$/i.test(group.chat.TitleColor) ? group.chat.TitleColor : undefined, fontWeight: 600 }}>
{group.chat.TitleHidden === 'true' ? '(title hidden)' : group.chat.Title}
</span>{' '}
<span className="dim" style={{ fontSize: '0.76rem' }}>
priority {group.chat.Priority} · <code>{group.chat.ChatFormat}</code>
</span>
</p>
)}
{notes.map((n) => (
<p key={n} className="sans" style={{ color: '#d08a2a', fontSize: '0.76rem', margin: '2px 0' }}>{n}</p>
))}
{!editing && (
<div style={{ display: 'flex', gap: 8, marginTop: 6 }}>
<button
type="button"
className="btn"
disabled={busy}
onClick={() => setEditing({ ...defaultsFor(group.name, fields), ...(group.chat || {}) })}
>
{group.chat ? 'Edit the style' : 'Give it a chat style'}
</button>
{group.chat && (
<button type="button" className="btn btn-ghost" disabled={busy} onClick={() => onSave(null)}>
Remove the style
</button>
)}
</div>
)}
{editing && (
<form
onSubmit={async (event) => {
event.preventDefault()
if (await onSave(editing)) setEditing(null)
}}
className="sans"
style={{ display: 'grid', gap: 8, marginTop: 8, fontSize: '0.82rem' }}
>
{fields.map((f) => (
<label key={f.name} style={{ display: 'grid', gridTemplateColumns: 'minmax(160px, 1fr) 2fr', gap: 8, alignItems: 'center' }}>
<span>{LABELS[f.name] || f.name}</span>
{f.type === 'bool' ? (
<select className="input" value={editing[f.name]} onChange={(e) => set(f.name, e.target.value)}>
<option value="false">No</option>
<option value="true">Yes</option>
</select>
) : f.type === 'color' ? (
<span style={{ display: 'flex', gap: 6 }}>
<input
type="color"
value={/^#[0-9a-f]{6}$/i.test(editing[f.name]) ? editing[f.name] : '#ffffff'}
onChange={(e) => set(f.name, e.target.value)}
aria-label={LABELS[f.name]}
style={{ width: 36, height: 28, padding: 0, border: 'none', background: 'none' }}
/>
<input className="input" value={editing[f.name]} onChange={(e) => set(f.name, e.target.value)} style={{ flex: 1 }} />
</span>
) : (
<input
className="input"
type={f.type === 'int' || f.type === 'size' ? 'number' : 'text'}
value={editing[f.name]}
onChange={(e) => set(f.name, e.target.value)}
/>
)}
</label>
))}
<p className="dim" style={{ fontSize: '0.74rem', margin: 0 }}>
A format must hold <code>{'{Message}'}</code> exactly once; <code>{'{Title}'}</code> and <code>{'{Username}'}</code>{' '}
are where the title and name go. A hand edit in game is reported rather than overwritten.
</p>
<div style={{ display: 'flex', gap: 8 }}>
<button type="submit" className="btn" disabled={busy}>Save the style</button>
<button type="button" className="btn btn-ghost" disabled={busy} onClick={() => setEditing(null)}>Cancel</button>
</div>
</form>
)}
</>
)
}