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
303 lines
14 KiB
JavaScript
303 lines
14 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.
|
||
//
|
||
// 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' }
|