Admin: Rust NPC profiles (the form RunicNPC reads, per server, shared or fleet, each server's push state and refusals, replaced profiles with Restore) and Rust NPC placements (the live map: click to place, pins for every placement; edit, rename, respawn, remove). The live map takes a pick handler and pins, unchanged for the public page. Public: the leaderboard ranks by a profile's kills (D250), and opening a row shows that player's kills by profile (D252); the killfeed names a RunicNPC NPC by its own name. Player: your own kills by profile. Titles: a rule on a profile's kills picks the profile. npcs.test.js covers the profile checks, adoption, the push, the picker and verb, the triggers, kill crediting, titles and placements (481 server tests). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
270 lines
12 KiB
JavaScript
270 lines
12 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, 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 && (
|
||
<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 : []
|
||
// 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={
|
||
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' }
|