// ── 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, 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: 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 : [] // 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' }