Admin: Rust NPC profiles (the form RunicNPC reads, per server, shared or fleet, each server's push state and refusals, replaced profiles with Restore) and Rust NPC placements (the live map: click to place, pins for every placement; edit, rename, respawn, remove). The live map takes a pick handler and pins, unchanged for the public page. Public: the leaderboard ranks by a profile's kills (D250), and opening a row shows that player's kills by profile (D252); the killfeed names a RunicNPC NPC by its own name. Player: your own kills by profile. Titles: a rule on a profile's kills picks the profile. npcs.test.js covers the profile checks, adoption, the push, the picker and verb, the triggers, kill crediting, titles and placements (481 server tests). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
329 lines
15 KiB
JavaScript
329 lines
15 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 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 (
|
||
<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, 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 (
|
||
<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.
|
||
Leave a title blank to use the category’s title, which is set once for every server below the list.
|
||
</p>
|
||
|
||
{rules.map((r, i) => {
|
||
const fallback = statOf(categories, r.stat).title
|
||
return (
|
||
<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>
|
||
{r.stat === 'profilekills' && (
|
||
<select value={r.profile || ''} onChange={setRule(i, 'profile')} style={inputStyle} aria-label="NPC profile" required>
|
||
<option value="" disabled>{profiles.length ? 'Which NPC?' : 'No NPC profile on this server'}</option>
|
||
{profiles.map((p) => <option key={p.id} value={p.id}>{p.label} ({p.name})</option>)}
|
||
</select>
|
||
)}
|
||
<span>earn</span>
|
||
{/* 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). */}
|
||
<input value={r.text} onChange={setRule(i, 'text')} maxLength={24} required={!fallback} placeholder={fallback || 'A title'} 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 || fallback} 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>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* 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 (
|
||
<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)' }}>Category titles</h2>
|
||
<p className="dim" style={{ fontSize: '0.78rem', margin: '0 0 10px' }}>
|
||
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.
|
||
</p>
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(140px, 1fr) minmax(110px, auto) minmax(160px, 1.2fr) auto', gap: '6px 10px', alignItems: 'center' }}>
|
||
<span className="dim" style={{ fontSize: '0.74rem' }}>Category</span>
|
||
<span className="dim" style={{ fontSize: '0.74rem' }}>Default</span>
|
||
<span className="dim" style={{ fontSize: '0.74rem' }}>This site’s title</span>
|
||
<span />
|
||
{categories.map((c) => {
|
||
const draft = drafts[c.stat]
|
||
const value = draft !== undefined ? draft : c.text || ''
|
||
const changed = draft !== undefined && draft !== (c.text || '')
|
||
return (
|
||
<Row key={c.stat}>
|
||
<span>{c.label}</span>
|
||
<span className="dim">{c.default}</span>
|
||
<span style={{ display: 'grid', gap: 2 }}>
|
||
<input
|
||
value={value}
|
||
placeholder={c.default}
|
||
maxLength={24}
|
||
onChange={(e) => setDrafts((d) => ({ ...d, [c.stat]: e.target.value }))}
|
||
style={inputStyle}
|
||
aria-label={`${c.label} title`}
|
||
/>
|
||
{errors[c.stat] && <span style={{ color: '#d08a2a', fontSize: '0.74rem' }}>{errors[c.stat]}</span>}
|
||
</span>
|
||
<span style={{ display: 'flex', gap: 4 }}>
|
||
<button type="button" className="btn" disabled={!changed || busy === c.stat} onClick={() => save(c.stat, value)}>Save</button>
|
||
<button type="button" className="btn" disabled={!c.text || busy === c.stat} onClick={() => save(c.stat, '')}>Reset</button>
|
||
</span>
|
||
</Row>
|
||
)
|
||
})}
|
||
</div>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
/** 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 <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.name} — {o.title} ({o.where})</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
{data.voice && !current && (
|
||
<p style={{ color: '#d08a2a', fontSize: '0.78rem', margin: '8px 0 0' }}>
|
||
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.
|
||
</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',
|
||
}
|