// ── 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 && ( ) if (picked) { return (
{picker}
) } return (
{picker}
) } /** 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 if (error) return if (rows.length === 0) { return ( ) } return (
{columns.map((column) => ( ))} {/* 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 && ( )} {rows.map((row, index) => [ 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' }} > {columns.map((column) => ( ))} {showLastSeen && ( )} , open === row.steamId && ( ), ])}
Player {column.sort ? ( ) : ( column.label )} Last seen
{open === row.steamId ? '▾' : '▸'} {index + 1} {/* 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. */} {row.name || shortId(row.steamId)} {/* 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) => ( {title.text} ))} {column.value(row)} {ago(row.lastSeen)}
) } /** 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 Loading… if (error) return Their NPC kills could not be loaded. const kills = (data && data.kills) || [] if (kills.length === 0) return No kills of this server’s NPC profiles{wipeId ? ' this wipe' : ''}. return ( {kills.map((k, i) => ( {i > 0 && · } {k.label} {count(k.kills)} ))} ) } /** 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 if (error) return const rows = (data && data.leaderboard) || [] if (rows.length === 0) { return } return (
{rows.map((row, index) => ( ))}
Player {profile.label} kills
{index + 1} {row.name || shortId(row.steamId)} {(row.titles || []).map((title, i) => ( {title.text} ))} {count(row.kills)}
) } const cell = { padding: '8px 10px', whiteSpace: 'nowrap' }