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
225 lines
10 KiB
JavaScript
225 lines
10 KiB
JavaScript
// ── 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',
|
||
}
|