feat(rust): NPC profiles and placements pages, the profile leaderboard (runicnpc stage 4)
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
This commit is contained in:
@@ -9,6 +9,12 @@
|
||||
// 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'
|
||||
@@ -27,6 +33,52 @@ const COLUMNS = [
|
||||
]
|
||||
|
||||
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],
|
||||
@@ -93,10 +145,15 @@ export default function Leaderboard({ serverId, wipeId, sort, onSort }) {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, index) => (
|
||||
<tr key={row.steamId} style={{ borderTop: '1px solid var(--line-soft, var(--line))' }}>
|
||||
{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 }}>{index + 1}</span>
|
||||
<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. */}
|
||||
@@ -130,6 +187,77 @@ export default function Leaderboard({ serverId, wipeId, sort, onSort }) {
|
||||
{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>
|
||||
|
||||
Reference in New Issue
Block a user