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

@@ -132,6 +132,11 @@ export const admin = {
// picture to draw one — which stalls that game for seconds (D109).
fetchMap: (id) => req(`/admin/rust/servers/${encodeURIComponent(id)}/map/fetch`, { method: 'POST' }),
renderMap: (id) => req(`/admin/rust/servers/${encodeURIComponent(id)}/map/render`, { method: 'POST' }),
// Phase 17: a server's chat titles, what optional mods it has, and the voice.
saveTitles: (id, body) => req(`/admin/rust/servers/${encodeURIComponent(id)}/titles`, { method: 'PUT', body }),
integrations: (id) => req(`/admin/rust/servers/${encodeURIComponent(id)}/integrations`),
voice: () => req('/admin/rust/voice'),
saveVoice: (group) => req('/admin/rust/voice', { method: 'PUT', body: { group } }),
}
// ── admin · permissions (R2) ──────────────────────────────────────────────

View File

@@ -12,7 +12,7 @@
import { ErrorState, Loading, useAsync } from '../core.js'
import Empty from './Empty.jsx'
import { ago, count, duration, shortId } from '../lib/format.js'
import { ago, contrastInk, count, duration, shortId } from '../lib/format.js'
import api from '../api.js'
// `sort` is the API's own vocabulary (`kills`, `deaths`, `npcKills`, `playtime`),
@@ -101,6 +101,26 @@ export default function Leaderboard({ serverId, wipeId, sort, onSort }) {
of their id rather than as a blank: the row is real, and a
nameless one reads as a rendering fault. */}
<strong style={{ color: 'var(--ink)' }}>{row.name || shortId(row.steamId)}</strong>
{/* The chat titles this player holds now (phase 17, D137) — the
same ones the game shows, ranked on the current wipe whichever
wipe this table is showing. Absent on an older module. */}
{(row.titles || []).map((title, i) => (
<span
key={`${title.text}-${i}`}
className="sans"
style={{
marginLeft: 6,
padding: '1px 6px',
borderRadius: 999,
fontSize: '0.7rem',
fontWeight: 600,
background: title.color,
color: contrastInk(title.color),
}}
>
{title.text}
</span>
))}
</td>
{COLUMNS.map((column) => (
<td key={column.key} style={{ ...cell, textAlign: 'right' }}>

View File

@@ -156,4 +156,24 @@ function toMillis(value) {
return Number.isNaN(parsed) ? null : parsed
}
export default { ago, clock, day, duration, count, prefab, shortId, nextWipe }
/**
* The ink that reads on a background of `hex` — black or white, whichever has
* the higher WCAG contrast. A chat title's colour is the operator's, chosen for
* a dark game chat, and this page is drawn in the reader's theme: yellow text on
* a white page is unreadable, so the colour becomes the chip and the text is
* picked for it (phase 17, D137). Anything that is not `#rrggbb` gets black on
* the caller's fallback.
*/
export function contrastInk(hex) {
const m = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(String(hex || ''))
if (!m) return '#000000'
const linear = (c) => {
const v = parseInt(c, 16) / 255
return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4
}
const L = 0.2126 * linear(m[1]) + 0.7152 * linear(m[2]) + 0.0722 * linear(m[3])
// Contrast against white is 1.05 / (L + 0.05); against black (L + 0.05) / 0.05.
return (L + 0.05) / 0.05 >= 1.05 / (L + 0.05) ? '#000000' : '#ffffff'
}
export default { ago, clock, day, duration, count, prefab, shortId, nextWipe, contrastInk }

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>
)}
</>
)
}

View File

@@ -0,0 +1,224 @@
// ── Admin · Rust · Servers — chat titles and the voice (phase 17) ─────────
//
// Two things the servers page gained with BetterChat and PopupNotifications
// becoming optional (PLAN.md §33):
//
// • **A server's chat titles** (D135, D136): rules that rank the current wipe,
// in the operator's order, and how many a player shows. Saved with their own
// PUT, because they are not part of the server row — an operator retitling
// "Top Killer" must not have to re-type a sidecar address.
// • **The voice** (D140): the one styled permission group news and event lines
// are said in. A fleet setting, so it sits once at the foot of the page.
//
// Neither needs BetterChat to save. Titles are held by the plugin until BetterChat
// arrives, and the voice is said by our own plugin whether BetterChat is there or
// not; the "What this server has" line says which is which.
import { useState } from 'react'
import { ErrorState, Loading, useAsync } from '../../core.js'
import api from '../../api.js'
import { contrastInk } from '../../lib/format.js'
const STATS = [
{ id: 'kills', label: 'Kills' },
{ id: 'npckills', label: 'NPC kills' },
{ id: 'playtime', label: 'Playtime' },
]
const MODES = [
{ id: 'first', label: 'The first title they earn', hint: 'The rule highest in the list wins.' },
{ id: 'all', label: 'Every title they earn' },
{ id: 'upto', label: 'Up to a number of titles', hint: 'In list order.' },
]
const statLabel = (id) => (STATS.find((s) => s.id === id) || { label: id }).label
/** One line summarising a server's titles, for its row on the list. */
export function titlesSummary(titles, push) {
const rules = (titles && titles.rules) || []
if (!rules.length) return 'No chat titles.'
const list = rules.map((r) => `${r.text} (top ${r.topN} ${statLabel(r.stat).toLowerCase()})`).join(', ')
const shown = push ? ` Last pushed: ${push.count} player${push.count === 1 ? '' : 's'} hold one${push.betterChat ? '' : ' — BetterChat is not loaded, so they are not showing in game yet'}.` : ''
return `Chat titles: ${list}.${shown}`
}
/** A title as the leaderboard will show it. */
function Chip({ text, color }) {
return (
<span
className="sans"
style={{ padding: '1px 6px', borderRadius: 999, fontSize: '0.72rem', fontWeight: 600, background: color, color: contrastInk(color) }}
>
{text || '…'}
</span>
)
}
export function TitlesForm({ server, onSaved, onCancel }) {
const start = server.titles || { mode: 'first', max: 2, rules: [] }
const [mode, setMode] = useState(start.mode)
const [max, setMax] = useState(start.max)
const [rules, setRules] = useState(start.rules.map((r) => ({ ...r })))
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const setRule = (i, key) => (e) => {
const value = e.target.value
setRules((list) => list.map((r, n) => (n === i ? { ...r, [key]: key === 'topN' ? Number(value) : value } : r)))
}
const move = (i, by) => setRules((list) => {
const next = [...list]
const [r] = next.splice(i, 1)
next.splice(i + by, 0, r)
return next
})
const save = async (e) => {
e.preventDefault()
setBusy(true)
setError('')
try {
await api.admin.saveTitles(server.id, { mode, max: Number(max), rules })
onSaved()
} catch (err) {
setError(err.message || 'That did not save.')
} finally {
setBusy(false)
}
}
return (
<form onSubmit={save} className="panel" style={{ padding: '16px 18px', display: 'grid', gap: 12, marginBottom: 18 }}>
<h2 className="display" style={{ fontSize: '1.05rem', margin: 0, color: 'var(--head)' }}>Chat titles on {server.name}</h2>
<p className="sans dim" style={{ fontSize: '0.78rem', margin: 0 }}>
Each rule gives a title to the top players on this wipe for one stat. A stat of zero earns nothing, so a fresh wipe
has no titles until somebody plays. Titles show in game chat when BetterChat is installed, and beside the name on
the leaderboard here and in the app. Up to ten rules; a title is at most 24 characters and cannot carry markup.
</p>
{rules.map((r, i) => (
<div key={i} className="sans" style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center', fontSize: '0.84rem' }}>
<span className="dim" style={{ width: 18 }}>{i + 1}</span>
<span>Top</span>
<input type="number" min={1} max={10} value={r.topN} onChange={setRule(i, 'topN')} style={{ ...inputStyle, width: 60 }} aria-label="How many players" />
<select value={r.stat} onChange={setRule(i, 'stat')} style={inputStyle} aria-label="Stat">
{STATS.map((s) => <option key={s.id} value={s.id}>{s.label}</option>)}
</select>
<span>earn</span>
<input value={r.text} onChange={setRule(i, 'text')} maxLength={24} required style={{ ...inputStyle, width: 160 }} aria-label="Title" />
<input type="color" value={r.color} onChange={setRule(i, 'color')} aria-label="Colour" style={{ width: 36, height: 28, padding: 0, border: 'none', background: 'none' }} />
<Chip text={r.text} color={r.color} />
<span style={{ marginLeft: 'auto', display: 'flex', gap: 4 }}>
<button type="button" className="btn" disabled={i === 0} onClick={() => move(i, -1)} aria-label="Move up">↑</button>
<button type="button" className="btn" disabled={i === rules.length - 1} onClick={() => move(i, 1)} aria-label="Move down">↓</button>
<button type="button" className="btn" onClick={() => setRules((list) => list.filter((_, n) => n !== i))}>Remove</button>
</span>
</div>
))}
{rules.length < 10 && (
<div>
<button type="button" className="btn" onClick={() => setRules((list) => [...list, { stat: 'kills', topN: 1, text: '', color: '#ffaa55' }])}>
Add a rule
</button>
</div>
)}
<div className="sans" style={{ display: 'flex', flexWrap: 'wrap', gap: 12, alignItems: 'center', fontSize: '0.84rem' }}>
<label style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
A player shows
<select value={mode} onChange={(e) => setMode(e.target.value)} style={inputStyle}>
{MODES.map((m) => <option key={m.id} value={m.id}>{m.label}</option>)}
</select>
</label>
{mode === 'upto' && (
<label style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
at most
<input type="number" min={1} max={5} value={max} onChange={(e) => setMax(e.target.value)} style={{ ...inputStyle, width: 60 }} />
</label>
)}
<span className="dim" style={{ fontSize: '0.74rem' }}>{(MODES.find((m) => m.id === mode) || {}).hint || ''}</span>
</div>
<div className="sans" style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<button type="submit" className="btn" disabled={busy}>{busy ? 'Saving…' : 'Save titles'}</button>
<button type="button" className="btn" onClick={onCancel} disabled={busy}>Cancel</button>
{error && <span style={{ color: '#d08a2a', fontSize: '0.8rem' }}>{error}</span>}
</div>
</form>
)
}
/** What a server says it has loaded, as one sentence (§33.2 `integrations`). */
export function integrationsLine(r) {
if (!r || !r.integrations) {
return r && r.ok === false
? `The game could not be asked (${r.status}).`
: 'This server’s plugin is older than protocol 12, so it cannot say which optional mods it has.'
}
const one = (name, x, without) => (x && x.loaded ? `${name} ${x.version || ''} is loaded`.trim() : `${name} is not loaded — ${without}`)
return `${one('BetterChat', r.integrations.betterChat, 'titles and group styles wait for it')}. ${one('PopupNotifications', r.integrations.popupNotifications, 'a popup is refused, and chat still works')}.`
}
export function VoiceCard() {
const [reloads, setReloads] = useState(0)
const { data, error: loadError } = useAsync(() => api.admin.voice(), [reloads])
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
if (loadError) return <ErrorState error={loadError} />
if (!data) return <Loading />
const choose = async (group) => {
setBusy(true)
setError('')
try {
await api.admin.saveVoice(group)
setReloads((n) => n + 1)
} catch (err) {
setError(err.message || 'That did not save.')
} finally {
setBusy(false)
}
}
const current = data.options.find((o) => o.group === data.voice)
return (
<section className="panel sans" style={{ padding: '16px 18px', marginBottom: 18, fontSize: '0.84rem' }}>
<h2 className="display" style={{ fontSize: '1.05rem', margin: '0 0 6px', color: 'var(--head)' }}>Announcement voice</h2>
<p className="dim" style={{ fontSize: '0.78rem', margin: '0 0 10px' }}>
News posts and event announcements said in game chat can wear the title and colours of one permission group that
has a chat style. The line has no sender, so no player’s name appears. It works whether or not BetterChat is
installed. Popups are plain text.
</p>
<label style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
Say them as
<select value={data.voice} onChange={(e) => choose(e.target.value)} disabled={busy} style={inputStyle}>
<option value="">Plain chat</option>
{data.options.map((o) => <option key={o.group} value={o.group}>{o.group} — {o.title}</option>)}
</select>
</label>
{data.voice && !current && (
<p style={{ color: '#d08a2a', fontSize: '0.78rem', margin: '8px 0 0' }}>
The group “{data.voice}” no longer has a chat style, so lines are said in plain chat until it has one again or
another voice is chosen.
</p>
)}
{current && <p className="dim" style={{ fontSize: '0.74rem', margin: '8px 0 0' }}>The line: <code>{current.format}</code></p>}
{!data.options.length && (
<p className="dim" style={{ fontSize: '0.76rem', margin: '8px 0 0' }}>No group has a chat style yet. Give one a style under Permissions.</p>
)}
{error && <p style={{ color: '#d08a2a', fontSize: '0.8rem', margin: '8px 0 0' }}>{error}</p>}
</section>
)
}
const inputStyle = {
background: 'var(--panel-flat, transparent)',
color: 'var(--text)',
border: '1px solid var(--line)',
borderRadius: 'var(--radius-input, 6px)',
padding: '5px 8px',
fontSize: '0.84rem',
}

View File

@@ -27,6 +27,7 @@ import { useCallback, useState } from 'react'
import { ErrorState, Loading, useAsync } from '../../core.js'
import { ago } from '../../lib/format.js'
import api from '../../api.js'
import ChatStyleSection from './ChatStyle.jsx'
const FLEET = '*'
@@ -159,6 +160,28 @@ function ServerState({ row, onSync, busy }) {
function DriftRow({ row, onAdopt, onRevoke, busy }) {
const subject = row.username ? `${row.username} (${row.subject})` : row.subject
// Phase 17: a field of a group's chat style somebody changed in game. Adopt
// takes the game's value into the style; Revoke puts the site's value back.
if (row.kind === 'chat-field') {
return (
<Row>
<span style={{ minWidth: 0, flex: 1 }}>
<strong style={{ fontWeight: 500 }}>{row.object}</strong>{' '}
<span className="dim" style={{ fontSize: '0.78rem' }}>
of group {row.subject}’s chat style is <code>{row.detail === null ? '(empty)' : row.detail}</code> in game ·{' '}
{row.serverId} · seen {ago(row.firstSeen)}
</span>
</span>
<button type="button" className="btn btn-ghost" onClick={() => onAdopt(row)} disabled={busy}>
Adopt
</button>
<button type="button" className="btn btn-ghost" onClick={() => onRevoke(row)} disabled={busy}>
Put ours back
</button>
</Row>
)
}
return (
<Row>
<span style={{ minWidth: 0, flex: 1 }}>
@@ -199,7 +222,7 @@ function pendingSet(servers) {
return pending
}
function GroupCard({ group, catalogue, servers, pending, onChanged, setError }) {
function GroupCard({ group, catalogue, servers, pending, chatFields, onChanged, setError }) {
const [busy, setBusy] = useState(false)
const [member, setMember] = useState('')
const [permission, setPermission] = useState('')
@@ -227,6 +250,29 @@ function GroupCard({ group, catalogue, servers, pending, onChanged, setError })
}),
)
// The style is saved with the group, like its permissions (D138). Answers
// whether it saved, so the editor stays open on a refusal and shows why.
const saveStyle = async (chat) => {
setBusy(true)
setError('')
try {
await api.adminPermissions.saveGroup(group.name, {
title: group.title,
rank: group.rank,
scope: group.scope,
permissions: group.permissions,
chat,
})
await onChanged()
return true
} catch (err) {
setError(err.message || 'That style did not save.')
return false
} finally {
setBusy(false)
}
}
return (
<Card
title={group.title || group.name}
@@ -358,6 +404,8 @@ function GroupCard({ group, catalogue, servers, pending, onChanged, setError })
</button>
</form>
<ChatStyleSection group={group} fields={chatFields} servers={servers} busy={busy} onSave={saveStyle} />
{servers.length > 1 && group.scope !== FLEET && (
<p className="sans dim" style={{ fontSize: '0.74rem', margin: '10px 0 0' }}>
This group exists on {group.scope} only. The other servers never receive it.
@@ -453,7 +501,8 @@ export default function Permissions() {
<p className="sans dim" style={{ fontSize: '0.8rem', marginTop: 0 }}>
Nothing here is undone automatically. <strong>Adopt</strong> records it as the site’s
own, so it survives the next wipe; <strong>Revoke</strong> removes it from the game on
the next sync.
the next sync. A chat style field changed in game is adopted into the style — which then
reaches every server the group does — or put back to the site’s value.
</p>
{data.drift.map((row) => (
<DriftRow
@@ -564,6 +613,7 @@ export default function Permissions() {
catalogue={data.catalogue || []}
servers={servers}
pending={pendingSet(servers)}
chatFields={data.chatFields || []}
onChanged={reload}
setError={setError}
/>

View File

@@ -20,12 +20,16 @@
//
// Test and Delete act at once rather than on Save — they are questions put to a
// sidecar and a removal, not settings.
//
// Phase 17 added a server's chat titles and the announcement voice, in
// `ChatTitles.jsx`, each saved on its own.
import { useState } from 'react'
import { ErrorState, Loading, useAsync } from '../../core.js'
import api from '../../api.js'
import { ago, nextWipe } from '../../lib/format.js'
import { TitlesForm, VoiceCard, integrationsLine, titlesSummary } from './ChatTitles.jsx'
const RULES = [
{ id: 'none', label: 'No schedule', hint: 'Nothing is forecast, not even the monthly forced wipe.' },
@@ -139,6 +143,7 @@ export default function ServerSettings() {
const [error, setError] = useState('')
const [notes, setNotes] = useState({})
const [confirming, setConfirming] = useState(null)
const [titling, setTitling] = useState(null)
if (loadError) return <ErrorState error={loadError} />
if (!data) return <Loading />
@@ -178,6 +183,16 @@ export default function ServerSettings() {
}
}
const mods = async (server) => {
setNotes((n) => ({ ...n, [server.id]: 'Asking the game…' }))
try {
const line = integrationsLine(await api.admin.integrations(server.id))
setNotes((n) => ({ ...n, [server.id]: line }))
} catch (err) {
setNotes((n) => ({ ...n, [server.id]: err.message || 'The game could not be asked.' }))
}
}
const remove = async (server) => {
setConfirming(null)
try {
@@ -219,9 +234,12 @@ export default function ServerSettings() {
? `Next wipe ${nextWipe(s.nextWipe)}, from ${SOURCE[s.nextWipe.source] || s.nextWipe.source}.`
: 'No wipe schedule set.'}
</div>
<div className="dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>{titlesSummary(s.titles, s.titlePush)}</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 8 }}>
<button type="button" className="btn" onClick={() => { setError(''); setForm(formFrom(s)) }}>Edit</button>
<button type="button" className="btn" onClick={() => { setError(''); setTitling(null); setForm(formFrom(s)) }}>Edit</button>
<button type="button" className="btn" onClick={() => test(s)}>Test the sidecar</button>
<button type="button" className="btn" onClick={() => { setForm(null); setTitling(s) }}>Chat titles</button>
<button type="button" className="btn" onClick={() => mods(s)}>Optional mods</button>
{confirming === s.id ? (
<>
<button type="button" className="btn" onClick={() => remove(s)}>Delete {s.name} and everything recorded about it</button>
@@ -244,6 +262,17 @@ export default function ServerSettings() {
{form && (
<ServerForm form={form} set={set} busy={busy} error={error} onSave={save} onCancel={() => setForm(null)} />
)}
{titling && (
<TitlesForm
key={titling.id}
server={titling}
onSaved={() => { setTitling(null); setReloads((n) => n + 1) }}
onCancel={() => setTitling(null)}
/>
)}
<VoiceCard />
</div>
)
}

View File

@@ -100,6 +100,7 @@ export default function Visibility() {
const [clanRoster, setClanRoster] = useState('members')
const [servers, setServers] = useState({})
const [news, setNews] = useState({})
const [delivery, setDelivery] = useState({})
const [mapFleet, setMapFleet] = useState({})
const [mapServers, setMapServers] = useState({})
const [busy, setBusy] = useState(false)
@@ -114,6 +115,7 @@ export default function Visibility() {
setClanRoster((state.clans && state.clans.roster) || 'members')
setServers(Object.fromEntries(state.presence.servers.map((s) => [s.id, s.override || INHERIT])))
setNews(Object.fromEntries(((state.news && state.news.servers) || []).map((s) => [s.id, Boolean(s.on)])))
setDelivery(Object.fromEntries(((state.news && state.news.servers) || []).map((s) => [s.id, s.delivery || 'chat'])))
if (state.map) {
setMapFleet({ ...state.map.fleet })
setMapServers(Object.fromEntries(state.map.servers.map((s) => [s.id, mapOverridesToForm(s.overrides)])))
@@ -136,10 +138,11 @@ export default function Visibility() {
const dirtyClans = clanRoster !== clans.roster
const newsRows = (data.news && data.news.servers) || []
const dirtyNews = newsRows.filter((s) => Boolean(news[s.id]) !== Boolean(s.on))
const dirtyDelivery = newsRows.filter((s) => (delivery[s.id] || 'chat') !== (s.delivery || 'chat'))
const mapCard = data.map || null
const mapChanges = mapCard ? mapDiff(mapCard, mapFleet, mapServers) : null
const dirtyMap = Boolean(mapChanges)
const dirty = dirtyFleet || dirtyServers.length > 0 || dirtyClans || dirtyNews.length > 0 || dirtyMap
const dirty = dirtyFleet || dirtyServers.length > 0 || dirtyClans || dirtyNews.length > 0 || dirtyDelivery.length > 0 || dirtyMap
const effective = (id) => servers[id] || fleet
const widened = fleet !== 'staff' || rows.some((s) => effective(s.id) !== 'staff')
@@ -157,6 +160,7 @@ export default function Visibility() {
body.servers = Object.fromEntries(dirtyServers.map((s) => [s.id, servers[s.id] || null]))
}
if (dirtyNews.length) body.news = Object.fromEntries(dirtyNews.map((s) => [s.id, Boolean(news[s.id])]))
if (dirtyDelivery.length) body.newsDelivery = Object.fromEntries(dirtyDelivery.map((s) => [s.id, delivery[s.id] || 'chat']))
if (dirtyMap) body.map = mapChanges
load(await api.adminVisibility.save(body))
setSaved(true)
@@ -255,7 +259,8 @@ export default function Visibility() {
<Card title="News in game chat" subtitle="a published news post, said in each server’s chat">
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '0 0 8px' }}>
When a news post is published, its title is said in the chat of every server switched on
here. A server that is down when a post is published is skipped rather than told late.
here. A server that is down when a post is published is skipped rather than told late. A popup
needs PopupNotifications on that server; one without it refuses the post and says why.
</p>
{newsRows.length === 0 && (
<p className="sans dim" style={{ fontSize: '0.82rem', margin: 0 }}>No servers are configured yet.</p>
@@ -285,6 +290,17 @@ export default function Visibility() {
{!s.enabled && <span className="dim" style={{ fontSize: '0.74rem' }}> · disabled</span>}
</span>
<span className="dim" style={{ fontSize: '0.78rem' }}>{news[s.id] ? 'says news' : 'off'}</span>
{/* D142: where the post goes on this server when it is switched on. */}
<select
value={delivery[s.id] || 'chat'}
onChange={(e) => setDelivery((prev) => ({ ...prev, [s.id]: e.target.value }))}
disabled={!news[s.id]}
aria-label={`Where news goes on ${s.name}`}
style={{ marginLeft: 'auto', background: 'var(--panel-flat, transparent)', color: 'var(--text)', border: '1px solid var(--line)', borderRadius: 'var(--radius-input, 6px)', padding: '3px 6px', fontSize: '0.8rem' }}
>
<option value="chat">in chat</option>
<option value="popup">as a popup</option>
</select>
</label>
))}
</Card>

View File

@@ -11,7 +11,7 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { ago, clock, count, day, duration, nextWipe, prefab, shortId } from '../src/lib/format.js'
import { ago, clock, contrastInk, count, day, duration, nextWipe, prefab, shortId } from '../src/lib/format.js'
const NOW = Date.parse('2026-09-16T12:00:00Z')
@@ -107,3 +107,11 @@ test('the next wipe: the reader’s own clock, how far away, and whether it move
assert.equal(nextWipe(null, now), null)
assert.equal(nextWipe({ at: 'soon', source: 'rule' }, now), null)
})
test('a title chip’s ink is whichever of black and white reads on its colour', () => {
assert.equal(contrastInk('#ffff00'), '#000000')
assert.equal(contrastInk('#FFAA55'), '#000000')
assert.equal(contrastInk('#1a1a8c'), '#ffffff')
assert.equal(contrastInk('#ff0000'), '#000000')
assert.equal(contrastInk('red'), '#000000', 'not a hex colour: black, on the caller’s fallback')
})