Files
Module-Rust/client/src/components/Leaderboard.jsx
wtclaude 481837e937
All checks were successful
PR Checks / server-tests (pull_request) Successful in 37s
PR Checks / client-build (pull_request) Successful in 24s
PR Checks / frozen-manifest (pull_request) Successful in 49s
feat(rust): RaidableBases — feed, raids map layer, Warlord, Spawn a raid base, profiles (PLAN_REDESIGNS §11.4)
PLAN_REDESIGNS §11.4 (D202-D208, D386-D391), protocol 14. Spec: docs
rust-link/PROTOCOL.md §20.12.

- raid.started, raid.completed, raid.purchased and raid.locked public,
  raid.ended staff's; raidableBases.js reads each row per viewer (raiders'
  and a buyer's names only for the presence audience, D376/D390; Steam ids,
  position and a run's key only for staff). Feed lines in the World filter.
- The step rust.raidbase.spawn, "Spawn a raid base" (ledgered, budget
  rust.raids): difficulty, profile and base from option sources listing each
  server's own names (D386), a spot as a world call's location with a height
  adjustment or an exact height, and PvP/NPCs/lock as on/off/blank. A base
  the raids helper made is a world resource, so eventWorld's revert and
  reconcile serve it and world.expired marks it expired; one it did not make
  records nothing and the run log says it despawns on its own timer. Refused
  at verify for a server that said it cannot; a dry run with a spot returns
  the spot check as detail.warnings (MODULE_API 1.16.0, D391). rb-not-ready
  retries in ten minutes.
- Warlord: raids_completed, title category raidscompleted, the leaderboard's
  Raid bases column (sort=raidsCompleted).
- The map's raids layer (D114), public by default with its own audience; the
  run's key is dropped before any viewer gets it.
- Server settings' line for RaidableBases; its profiles in Mod configuration
  under a virtual path (D205).
- Swagger fragment, bundle and README.

Tests: server 684/684 (raidableBases.test.js new, every name in it invented);
client 99/99, build and externals.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-16 15:18:52 -05:00

303 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── 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.
//
// RunicNPC (runicnpc stage 4). Where the server has NPC profiles, "Rank by"
// offers each one's kills, counted as the profile says (D247, D250), and opening
// a player's row shows their kills of each profile for the wipe shown (D252).
import { useState } from 'react'
import { ErrorState, Loading, useAsync } from '../core.js'
import Empty from './Empty.jsx'
import { ago, coins, 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) },
// Rust's own world events (protocol 14, D356, D377): the counts Safecracker
// and Scavenger rank, on every server, since the game itself raises them.
{ key: 'cratesHacked', label: 'Crates hacked', sort: 'cratesHacked', value: (r) => count(r.cratesHacked) },
{ key: 'dropsLooted', label: 'Drops looted', sort: 'dropsLooted', value: (r) => count(r.dropsLooted) },
]
// The richest (PLAN_REDESIGNS §11.2.2): the last known Economics balance. The
// server adds it only for one wipe on a server that reports Economics, and says
// so with `economics` on the answer — so the column is there exactly when the
// rows carry it. Sorting by it ranks only balances above the starting balance
// (D336), which is why a fresh wipe's sort shows nobody.
// Treasure Hunter (PLAN_REDESIGNS §14.3.4, D375): Dangerous Treasures won. The
// server says `dangerousTreasures` where the server reports the plugin, or the
// rows already count a treasure, so the column is there exactly then.
const TREASURES = { key: 'treasuresWon', label: 'Treasures', sort: 'treasuresWon', value: (r) => count(r.treasuresWon) }
// Warden (PLAN_REDESIGNS §14.3.5, D375): guarded crates cleared, shown on the
// same rule from the server's `guardedCrate`.
const GUARDED = { key: 'guardedWon', label: 'Crates cleared', sort: 'guardedWon', value: (r) => count(r.guardedWon) }
// Warlord (PLAN_REDESIGNS §11.4, D202): RaidableBases bases completed, shown on
// the same rule from the server's `raidableBases`.
const RAIDS = { key: 'raidsCompleted', label: 'Raid bases', sort: 'raidsCompleted', value: (r) => count(r.raidsCompleted) }
const BALANCE = { key: 'balance', label: 'Coins', sort: 'balance', value: (r) => coins(r.balance) }
export default function Leaderboard({ serverId, wipeId, sort, onSort }) {
const { data: npc } = useAsync(() => api.servers.npcProfiles(serverId).catch(() => ({ profiles: [] })), [serverId])
const [profileId, setProfileId] = useState('')
const profiles = (npc && npc.profiles) || []
const picked = profiles.find((p) => String(p.id) === String(profileId))
const picker = profiles.length > 0 && (
<label className="sans" style={{ display: 'flex', gap: 8, alignItems: 'baseline', fontSize: '0.82rem', marginBottom: 10 }}>
<span style={{ color: 'var(--dim)' }}>Rank by</span>
<select
value={profileId}
onChange={(e) => setProfileId(e.target.value)}
style={{ background: 'transparent', color: 'var(--ink)', border: '1px solid var(--line)', borderRadius: 6, padding: '4px 6px', font: 'inherit' }}
>
<option value="">Overall</option>
{profiles.map((p) => <option key={p.id} value={p.id}>{p.label} kills</option>)}
</select>
{picked && <span style={{ color: 'var(--dim)' }}>{SCOPE_NOTE[picked.killsScope] || ''}</span>}
</label>
)
if (picked) {
return (
<div>
{picker}
<ProfileBoard serverId={serverId} wipeId={wipeId} profile={picked} />
</div>
)
}
return (
<div>
{picker}
<Overall serverId={serverId} wipeId={wipeId} sort={sort} onSort={onSort} />
</div>
)
}
/** How a profile's kills are counted, in the reader's words (D247). */
const SCOPE_NOTE = {
server: 'Counted on this server.',
name: 'Counted on every server with this NPC.',
profile: 'Counted wherever this NPC is placed.',
}
function Overall({ serverId, wipeId, sort, onSort }) {
const [open, setOpen] = useState(null)
const { data, loading, error } = useAsync(
() => api.servers.leaderboard(serverId, { wipe: wipeId, sort, limit: 50 }),
[serverId, wipeId, sort],
)
const rows = data ? data.leaderboard : []
const columns = [
...COLUMNS,
...(data && data.dangerousTreasures ? [TREASURES] : []),
...(data && data.guardedCrate ? [GUARDED] : []),
...(data && data.raidableBases ? [RAIDS] : []),
...(data && data.economics ? [BALANCE] : []),
]
// 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={
sort === 'balance' && wipeId
? 'Nobody holds more coins than the starting balance on this wipe yet.'
: 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}
onClick={() => setOpen(open === row.steamId ? null : row.steamId)}
title="Open for this player’s kills of each NPC"
style={{ borderTop: '1px solid var(--line-soft, var(--line))', cursor: 'pointer' }}
>
<td style={cell}>
<span style={{ color: 'var(--dim)', marginRight: 8 }}>{open === row.steamId ? '▾' : '▸'} {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>,
open === row.steamId && (
<tr key={`${row.steamId}-npcs`}>
<td colSpan={columns.length + 1 + (showLastSeen ? 1 : 0)} style={{ ...cell, whiteSpace: 'normal', paddingLeft: 34 }}>
<PlayerNpcKills serverId={serverId} steamId={row.steamId} wipeId={wipeId} />
</td>
</tr>
),
])}
</tbody>
</table>
</div>
)
}
/** An opened row (D252): the player's kills of each RunicNPC profile, for the wipe shown. */
function PlayerNpcKills({ serverId, steamId, wipeId }) {
const { data, loading, error } = useAsync(() => api.servers.npcKills(serverId, steamId, { wipe: wipeId }), [serverId, steamId, wipeId])
if (loading) return <span className="sans" style={{ color: 'var(--dim)', fontSize: '0.8rem' }}>Loading…</span>
if (error) return <span className="sans" style={{ color: 'var(--dim)', fontSize: '0.8rem' }}>Their NPC kills could not be loaded.</span>
const kills = (data && data.kills) || []
if (kills.length === 0) return <span className="sans" style={{ color: 'var(--dim)', fontSize: '0.8rem' }}>No kills of this server’s NPC profiles{wipeId ? ' this wipe' : ''}.</span>
return (
<span className="sans" style={{ fontSize: '0.82rem' }}>
{kills.map((k, i) => (
<span key={k.profile}>
{i > 0 && <span style={{ color: 'var(--dim)' }}> · </span>}
{k.label} <strong style={{ color: 'var(--ink)' }}>{count(k.kills)}</strong>
</span>
))}
</span>
)
}
/** One profile's ranking (D250): who has killed the most of it. */
function ProfileBoard({ serverId, wipeId, profile }) {
const { data, loading, error } = useAsync(
() => api.servers.npcLeaderboard(serverId, { profile: profile.id, wipe: wipeId, limit: 50 }),
[serverId, wipeId, profile.id],
)
if (loading) return <Loading />
if (error) return <ErrorState error={error} />
const rows = (data && data.leaderboard) || []
if (rows.length === 0) {
return <Empty title="No kills yet" message={`Nobody has killed a ${profile.label}${wipeId ? ' this wipe' : ''} 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', textTransform: 'uppercase' }}>
<th style={cell}>Player</th>
<th style={{ ...cell, textAlign: 'right' }}>{profile.label} kills</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>
<strong style={{ color: 'var(--ink)' }}>{row.name || shortId(row.steamId)}</strong>
{(row.titles || []).map((title, i) => (
<span
key={`${title.text}-${i}`}
style={{ marginLeft: 6, padding: '1px 6px', borderRadius: 999, fontSize: '0.7rem', fontWeight: 600, background: title.color, color: contrastInk(title.color) }}
>
{title.text}
</span>
))}
</td>
<td style={{ ...cell, textAlign: 'right' }}>{count(row.kills)}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
const cell = { padding: '8px 10px', whiteSpace: 'nowrap' }