feat(rust): chat titles rank twenty-three conditions, and admins name the categories (PLAN_REDESIGNS §5, D172-D175, D209)

- Schema: fourteen title columns on rust_player_wipe_stats (the two
  best_* distances move by GREATEST, the rest are sums), rust_weapon_kills
  (kills by weapon prefab name) and rust_title_categories (the admin's
  title per category). purge.sql drops the two tables.
- Ingest: player.tally's new fields, in one statement per frame, none
  for an older plugin's frame.
- Titles: 23 categories plus playtime, each naming where it is read
  from (a sum, a MAX, npc_kills - animal_kills in signed arithmetic, or
  a named list). The bow/melee/blade/revolver/wood/ore/plants lists are
  one file, applied when a title is read. "NPC kills" ranks human NPCs
  only (D209).
- A rule's text may be empty, meaning the category's title: the rule's
  own, then the admin's, then the default. Typed-only-markup is still
  refused. A rename forgets every server's cached answer.
- Admin API: GET /admin/rust/title-categories and
  PUT /admin/rust/title-categories/:stat (empty text resets); the server
  list returns them too. Swagger fragment and routes.manifest.json
  regenerated (63 routes, against the pinned core).
- Screen: every category in the rule form, the category's title as a
  placeholder, and a Category titles section with Save and Reset.

The weapon lists are unverified until the rig probe runs.

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-28 23:32:59 -05:00
parent afb16112b0
commit aea94b5d7a
18 changed files with 1150 additions and 65 deletions

View File

@@ -134,6 +134,9 @@ export const admin = {
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 }),
// PLAN_REDESIGNS §5.5 (D175): a category's title for the whole site; '' resets it.
saveTitleCategory: (stat, text) =>
req(`/admin/rust/title-categories/${encodeURIComponent(stat)}`, { method: 'PUT', body: { text } }),
integrations: (id) => req(`/admin/rust/servers/${encodeURIComponent(id)}/integrations`),
voice: () => req('/admin/rust/voice'),
saveVoice: (group) => req('/admin/rust/voice', { method: 'PUT', body: { group } }),

View File

@@ -20,25 +20,33 @@ 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
/**
* 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 },
]
}
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) {
export function titlesSummary(titles, push, categories) {
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 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}`
}
@@ -55,8 +63,9 @@ function Chip({ text, color }) {
)
}
export function TitlesForm({ server, onSaved, onCancel }) {
export function TitlesForm({ server, categories, onSaved, onCancel }) {
const start = server.titles || { mode: 'first', max: 2, rules: [] }
const stats = statOptions(categories)
const [mode, setMode] = useState(start.mode)
const [max, setMax] = useState(start.max)
const [rules, setRules] = useState(start.rules.map((r) => ({ ...r })))
@@ -95,27 +104,32 @@ export function TitlesForm({ server, onSaved, onCancel }) {
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) => (
{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>)}
{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" />
{/* 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} color={r.color} />
<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' }])}>
@@ -149,6 +163,84 @@ export function TitlesForm({ server, onSaved, onCancel }) {
)
}
/**
* 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) {

View File

@@ -29,7 +29,7 @@ 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'
import { CategoryTitles, TitlesForm, VoiceCard, integrationsLine, titlesSummary } from './ChatTitles.jsx'
const RULES = [
{ id: 'none', label: 'No schedule', hint: 'Nothing is forecast, not even the monthly forced wipe.' },
@@ -164,6 +164,7 @@ export default function ServerSettings() {
if (loadError) return <ErrorState error={loadError} />
if (!data) return <Loading />
const servers = data.servers || []
const categories = data.titleCategories || []
const set = (key) => (e) => {
const value = e && e.target ? (e.target.type === 'checkbox' ? e.target.checked : e.target.value) : e
@@ -250,7 +251,7 @@ 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 className="dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>{titlesSummary(s.titles, s.titlePush, categories)}</div>
{zoneHelperNote(s.zoneHelper) && (
<div style={{ fontSize: '0.76rem', marginTop: 2, color: 'var(--warn, #d9c184)' }}>{zoneHelperNote(s.zoneHelper)}</div>
)}
@@ -286,11 +287,14 @@ export default function ServerSettings() {
<TitlesForm
key={titling.id}
server={titling}
categories={categories}
onSaved={() => { setTitling(null); setReloads((n) => n + 1) }}
onCancel={() => setTitling(null)}
/>
)}
<CategoryTitles categories={categories} onSaved={() => setReloads((n) => n + 1)} />
<VoiceCard />
</div>
)