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:
2026-09-30 04:42:05 -05:00
parent fa16f0ad2e
commit 7eae4a1e62
13 changed files with 1356 additions and 10 deletions

View File

@@ -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