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
142 lines
6.1 KiB
JavaScript
142 lines
6.1 KiB
JavaScript
// ── The leaderboard ───────────────────────────────────────────────────────
|
|
//
|
|
// Per wipe when a wipe is selected, all-time when it is not (R12). The two are
|
|
// the same rows summed differently rather than two sets of counters, so they can
|
|
// never disagree — which is worth knowing here because it means "All time" is
|
|
// not a slower or less accurate answer, it is the same table without a WHERE.
|
|
//
|
|
// It does NOT poll. A leaderboard moves on the scale of a session; a table that
|
|
// re-sorted itself under the reader's cursor every twenty seconds would be worse
|
|
// than one that is four minutes old, and the page has a `Refresh` on the tab
|
|
// strip for anybody who disagrees.
|
|
|
|
import { ErrorState, Loading, useAsync } from '../core.js'
|
|
import Empty from './Empty.jsx'
|
|
import { ago, contrastInk, count, duration, shortId } from '../lib/format.js'
|
|
import api from '../api.js'
|
|
|
|
// `sort` is the API's own vocabulary (`kills`, `deaths`, `npcKills`, `playtime`),
|
|
// and the column it maps to is this file's. Keeping them in one list is what
|
|
// stops a header that sorts by something other than what it says.
|
|
const COLUMNS = [
|
|
{ key: 'kills', label: 'Kills', sort: 'kills', value: (r) => count(r.kills) },
|
|
{ key: 'deaths', label: 'Deaths', sort: 'deaths', value: (r) => count(r.deaths) },
|
|
{ key: 'npcKills', label: 'NPC kills', sort: 'npcKills', value: (r) => count(r.npcKills) },
|
|
{ key: 'structures', label: 'Structures', sort: null, value: (r) => count(r.structures) },
|
|
{ key: 'playtimeSec', label: 'Played', sort: 'playtime', value: (r) => duration(r.playtimeSec) },
|
|
]
|
|
|
|
export default function Leaderboard({ serverId, wipeId, sort, onSort }) {
|
|
const { data, loading, error } = useAsync(
|
|
() => api.servers.leaderboard(serverId, { wipe: wipeId, sort, limit: 50 }),
|
|
[serverId, wipeId, sort],
|
|
)
|
|
|
|
const rows = data ? data.leaderboard : []
|
|
// Present on every row or on none — the server decides per request.
|
|
const showLastSeen = rows.some((row) => 'lastSeen' in row)
|
|
|
|
if (loading) return <Loading />
|
|
if (error) return <ErrorState error={error} />
|
|
|
|
if (rows.length === 0) {
|
|
return (
|
|
<Empty
|
|
title="No scores yet"
|
|
message={
|
|
wipeId
|
|
? 'Nobody has done anything countable on this wipe yet.'
|
|
: 'This server has not reported anything countable yet.'
|
|
}
|
|
/>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div style={{ overflowX: 'auto' }}>
|
|
<table className="sans" style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.86rem' }}>
|
|
<thead>
|
|
<tr style={{ textAlign: 'left', color: 'var(--dim)', fontSize: '0.72rem', letterSpacing: '0.08em' }}>
|
|
<th style={{ ...cell, textTransform: 'uppercase' }}>Player</th>
|
|
{COLUMNS.map((column) => (
|
|
<th key={column.key} style={{ ...cell, textAlign: 'right', textTransform: 'uppercase' }}>
|
|
{column.sort ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => onSort(column.sort)}
|
|
aria-label={`Sort by ${column.label}`}
|
|
style={{
|
|
cursor: 'pointer',
|
|
background: 'none',
|
|
border: 'none',
|
|
padding: 0,
|
|
font: 'inherit',
|
|
letterSpacing: 'inherit',
|
|
textTransform: 'inherit',
|
|
color: column.sort === sort ? 'var(--accent-bright)' : 'var(--dim)',
|
|
}}
|
|
>
|
|
{column.label}
|
|
</button>
|
|
) : (
|
|
column.label
|
|
)}
|
|
</th>
|
|
))}
|
|
{/* The server withholds `lastSeen` below the operator's presence
|
|
audience — a gather tally refreshes it every minute somebody plays,
|
|
so it would name who is online. The column goes with it rather
|
|
than rendering a row of dashes that look like "never". */}
|
|
{showLastSeen && (
|
|
<th style={{ ...cell, textAlign: 'right', textTransform: 'uppercase' }}>Last seen</th>
|
|
)}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rows.map((row, index) => (
|
|
<tr key={row.steamId} style={{ borderTop: '1px solid var(--line-soft, var(--line))' }}>
|
|
<td style={cell}>
|
|
<span style={{ color: 'var(--dim)', marginRight: 8 }}>{index + 1}</span>
|
|
{/* A player this module has never seen NAMED is shown by the tail
|
|
of their id rather than as a blank: the row is real, and a
|
|
nameless one reads as a rendering fault. */}
|
|
<strong style={{ color: 'var(--ink)' }}>{row.name || shortId(row.steamId)}</strong>
|
|
{/* The chat titles this player holds now (phase 17, D137) — the
|
|
same ones the game shows, ranked on the current wipe whichever
|
|
wipe this table is showing. Absent on an older module. */}
|
|
{(row.titles || []).map((title, i) => (
|
|
<span
|
|
key={`${title.text}-${i}`}
|
|
className="sans"
|
|
style={{
|
|
marginLeft: 6,
|
|
padding: '1px 6px',
|
|
borderRadius: 999,
|
|
fontSize: '0.7rem',
|
|
fontWeight: 600,
|
|
background: title.color,
|
|
color: contrastInk(title.color),
|
|
}}
|
|
>
|
|
{title.text}
|
|
</span>
|
|
))}
|
|
</td>
|
|
{COLUMNS.map((column) => (
|
|
<td key={column.key} style={{ ...cell, textAlign: 'right' }}>
|
|
{column.value(row)}
|
|
</td>
|
|
))}
|
|
{showLastSeen && (
|
|
<td style={{ ...cell, textAlign: 'right', color: 'var(--dim)' }}>{ago(row.lastSeen)}</td>
|
|
)}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const cell = { padding: '8px 10px', whiteSpace: 'nowrap' }
|