Files
Module-Rust/client/src/components/Leaderboard.jsx
wtclaude da1d74bdff
All checks were successful
PR Checks / client-build (pull_request) Successful in 30s
PR Checks / server-tests (pull_request) Successful in 34s
PR Checks / frozen-manifest (pull_request) Successful in -1m34s
feat(rust): Guarded Crate — feed, map, Warden, Place a guarded crate (PLAN_REDESIGNS §14.3.5)
Stage 6 of the §14.3 integration plan, on protocol 14. Spec: docs
rust-link/PROTOCOL.md §20.9.

- gc.started and gc.ended public, gc.failed staff's; guardedCrate.js reads
  each row per viewer (the clearer's name for the presence audience, Steam id,
  position and key for staff, `won` for everyone).
- Triggers rust.gc.started and rust.gc.ended (a crate a player cleared only).
- The step rust.gc.place: an event from rust.options.gc_events and a world
  call's location; refused at verify for Guarded Crate absent, NpcSpawn missing
  (D364), console unavailable, or an unknown event. Kept (D373); the run log's
  success line says so (D380). Budget rust.gc.crates.
- Warden (D375): guarded_won, title category guardedwon, Crates cleared column.
- Map marker, server settings line, swagger, engagement manifest, bundle, README.

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

298 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) }
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.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' }