// ── 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 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.' }, ] /** * What a rule can rank (PLAN_REDESIGNS §5): the server's categories, each with * the title it shows now (D175), then playtime, which has no title of its own. */ export function statOptions(categories) { return [ ...(categories || []).map((c) => ({ id: c.stat, label: c.label, title: c.current })), { id: 'playtime', label: 'Playtime', title: null }, // RunicNPC (runicnpc stage 4, D250): one NPC profile's kills. The rule names the profile. { id: 'profilekills', label: 'Kills of an NPC profile', title: null }, ] } const statOf = (categories, id) => statOptions(categories).find((s) => s.id === id) || { id, label: id, title: null } /** A rule's title as players see it: its own text, else its category's. */ export const ruleTitle = (rule, categories) => rule.text || statOf(categories, rule.stat).title || '' /** One line summarising a server's titles, for its row on the list. */ export function titlesSummary(titles, push, categories) { const rules = (titles && titles.rules) || [] if (!rules.length) return 'No chat titles.' const list = rules.map((r) => `${ruleTitle(r, categories)} (top ${r.topN} ${statOf(categories, r.stat).label.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 ( {text || '…'} ) } export function TitlesForm({ server, categories, onSaved, onCancel }) { const start = server.titles || { mode: 'first', max: 2, rules: [] } const stats = statOptions(categories) const { data: npc } = useAsync(() => api.servers.npcProfiles(server.id).catch(() => ({ profiles: [] })), [server.id]) const profiles = (npc && npc.profiles) || [] 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' || key === 'profile' ? 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 (

Chat titles on {server.name}

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. Leave a title blank to use the category’s title, which is set once for every server below the list.

{rules.map((r, i) => { const fallback = statOf(categories, r.stat).title return (
{i + 1} Top {r.stat === 'profilekills' && ( )} earn {/* The category's title is a PLACEHOLDER, never a value: saved as the rule's own text it would stop a rename reaching this rule (§5.5). */}
) })} {rules.length < 10 && (
)}
{mode === 'upto' && ( )} {(MODES.find((m) => m.id === mode) || {}).hint || ''}
{error && {error}}
) } /** * The category titles (D175): one title per category for the whole site, which * every rule of that category without its own text shows. Each row saves on its * own, and Reset brings the module's default back. */ export function CategoryTitles({ categories, onSaved }) { const [drafts, setDrafts] = useState({}) const [busy, setBusy] = useState('') const [errors, setErrors] = useState({}) if (!categories || !categories.length) return null const save = async (stat, text) => { setBusy(stat) setErrors((e) => ({ ...e, [stat]: '' })) try { await api.admin.saveTitleCategory(stat, text) setDrafts((d) => { const next = { ...d } delete next[stat] return next }) onSaved() } catch (err) { setErrors((e) => ({ ...e, [stat]: err.message || 'That did not save.' })) } finally { setBusy('') } } return (

Category titles

The title each category gives, on every server. A rule that has its own title keeps it; every other rule of the category shows this one from the next push. Reset brings back the default.

Category Default This site’s title {categories.map((c) => { const draft = drafts[c.stat] const value = draft !== undefined ? draft : c.text || '' const changed = draft !== undefined && draft !== (c.text || '') return ( {c.label} {c.default} setDrafts((d) => ({ ...d, [c.stat]: e.target.value }))} style={inputStyle} aria-label={`${c.label} title`} /> {errors[c.stat] && {errors[c.stat]}} ) })}
) } /** A grid row's cells, without a wrapper element the grid would lay out as one cell. */ function Row({ children }) { return <>{children} } /** 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 if (!data) return 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 (

Announcement voice

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.

{data.voice && !current && (

The chosen group no longer has a chat style, so lines are said in plain chat until it has one again or another voice is chosen.

)} {current &&

The line: {current.format}

} {!data.options.length && (

No group has a chat style yet. Give one a style under Permissions.

)} {error &&

{error}

}
) } 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', }