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:
@@ -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 } }),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user