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>
|
||||
|
||||
@@ -20,7 +20,7 @@ import { ErrorState, Loading, useAsync } from '../core.js'
|
||||
import Empty from './Empty.jsx'
|
||||
import usePolled from '../hooks/usePolled.js'
|
||||
import { ago } from '../lib/format.js'
|
||||
import { boundsOf, countdown, grid, gridLabel, toLatLng } from '../lib/mapGeometry.js'
|
||||
import { boundsOf, countdown, fromLatLng, grid, gridLabel, toLatLng } from '../lib/mapGeometry.js'
|
||||
import api, { BASE } from '../api.js'
|
||||
|
||||
const STYLE_ID = 'rust-leaflet-css'
|
||||
@@ -64,7 +64,13 @@ const WORLD_NAMES = {
|
||||
|
||||
const AUDIENCE_WORDS = { public: 'everyone', signed_in: 'signed-in players', staff: 'staff only' }
|
||||
|
||||
export default function MapView({ serverId, online }) {
|
||||
/**
|
||||
* `onPick` and `pins` are for an admin page that places things on the map (the NPC
|
||||
* placements page, runicnpc stage 4, D245): a click answers the world point under
|
||||
* it, and `pins` (`{ x, z, label, colour, ring }`) are drawn above every layer. The
|
||||
* public page passes neither.
|
||||
*/
|
||||
export default function MapView({ serverId, online, onPick = null, pins = null }) {
|
||||
const { data: meta, loading, error } = useAsync(() => api.servers.map(serverId), [serverId])
|
||||
const geometry = meta ? meta.geometry : null
|
||||
|
||||
@@ -101,6 +107,8 @@ export default function MapView({ serverId, online }) {
|
||||
const container = useRef(null)
|
||||
const mapRef = useRef(null)
|
||||
const groups = useRef(null)
|
||||
const pickRef = useRef(onPick)
|
||||
pickRef.current = onPick
|
||||
|
||||
// The map itself: made once per server and geometry, torn down with them.
|
||||
useEffect(() => {
|
||||
@@ -121,7 +129,7 @@ export default function MapView({ serverId, online }) {
|
||||
map.fitBounds(bounds)
|
||||
|
||||
const made = {}
|
||||
for (const id of ['grid', 'monuments', 'world', 'events', 'players', 'bases', 'mates']) made[id] = L.layerGroup().addTo(map)
|
||||
for (const id of ['grid', 'monuments', 'world', 'events', 'players', 'bases', 'mates', 'pins']) made[id] = L.layerGroup().addTo(map)
|
||||
// The labels are a layer of their own inside the grid's, shown only when a cell
|
||||
// is wide enough on screen to hold one: at the fitted zoom a 20-cell map's
|
||||
// labels overlap into a wall of text (found on the phase 14 walk).
|
||||
@@ -171,6 +179,17 @@ export default function MapView({ serverId, online }) {
|
||||
|
||||
mapRef.current = map
|
||||
groups.current = made
|
||||
pickRef.current = onPick
|
||||
if (onPick) {
|
||||
container.current.style.cursor = 'crosshair'
|
||||
map.on('click', (e) => {
|
||||
const at = fromLatLng(geometry, e.latlng.lat, e.latlng.lng)
|
||||
const half = geometry.worldSize / 2
|
||||
if (at && pickRef.current && Math.abs(at.x) <= half && Math.abs(at.z) <= half) {
|
||||
pickRef.current({ x: Math.round(at.x * 10) / 10, z: Math.round(at.z * 10) / 10, grid: gridLabel(geometry, at.x, at.z) })
|
||||
}
|
||||
})
|
||||
}
|
||||
return () => {
|
||||
map.remove()
|
||||
mapRef.current = null
|
||||
@@ -225,6 +244,19 @@ export default function MapView({ serverId, online }) {
|
||||
}
|
||||
}, [leaflet, geometry, live.data])
|
||||
|
||||
// An admin page's own pins, above everything else.
|
||||
useEffect(() => {
|
||||
const made = groups.current
|
||||
if (!leaflet || !made || !geometry) return
|
||||
const { L } = leaflet
|
||||
made.pins.clearLayers()
|
||||
for (const p of pins || []) {
|
||||
const marker = dot(L, toLatLng(geometry, p.x, p.z), p.colour || '#ffd54f', p.ring ? 9 : 6, p.ring ? 3 : 1)
|
||||
if (p.label) marker.bindTooltip(escape(p.label))
|
||||
marker.addTo(made.pins)
|
||||
}
|
||||
}, [leaflet, geometry, pins])
|
||||
|
||||
// The legend's checkboxes: a group is on the map or off it.
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
|
||||
Reference in New Issue
Block a user