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
391 lines
16 KiB
JavaScript
391 lines
16 KiB
JavaScript
// ── The live map ──────────────────────────────────────────────────────────
|
|
//
|
|
// R9's page, as PLAN.md §30.2 drew it: the picture of the current map under
|
|
// four layers, each with its own switch, polled every ten seconds while the tab
|
|
// is visible (D14) and not at all while it is hidden.
|
|
//
|
|
// **Nothing here decides who may see what.** The server sends only the layers
|
|
// this viewer may see — a hidden one is absent from the answer, not present and
|
|
// hidden — so the checkboxes below are a reader's convenience and never a
|
|
// boundary. A layer the viewer cannot see is still LISTED, disabled, with who
|
|
// can: "staff only" explains an empty map where silence would imply an empty
|
|
// server (§23.3's shape).
|
|
//
|
|
// Leaflet arrives in a chunk of its own when this tab first mounts (D120, see
|
|
// `lib/leaflet.js`). The page draws with circle and div markers only, so
|
|
// Leaflet's image assets are never needed.
|
|
|
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
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, fromLatLng, grid, gridLabel, toLatLng } from '../lib/mapGeometry.js'
|
|
import api, { BASE } from '../api.js'
|
|
|
|
const STYLE_ID = 'rust-leaflet-css'
|
|
|
|
/** The narrowest a grid cell may be on screen, in pixels, and still carry its label. */
|
|
const LABEL_MIN_CELL_PX = 30
|
|
|
|
/** The layers, in the order the legend lists them, with their marker colours. */
|
|
const LAYERS = [
|
|
{ id: 'world', label: 'Monuments & world events' },
|
|
{ id: 'events', label: 'Site events' },
|
|
{ id: 'players', label: 'Players' },
|
|
{ id: 'bases', label: 'Bases' },
|
|
]
|
|
|
|
const COLOURS = {
|
|
monument: '#e8d9a8',
|
|
cargo: '#4fc3f7',
|
|
heli: '#ef5350',
|
|
chinook: '#ffa726',
|
|
bradley: '#a1887f',
|
|
supply: '#66bb6a',
|
|
crate: '#ffee58',
|
|
event: '#ce93d8',
|
|
online: '#ffffff',
|
|
sleeping: '#9e9e9e',
|
|
tc: '#ff7043',
|
|
vending: '#26a69a',
|
|
self: '#00e5ff',
|
|
mate: '#7cffb2',
|
|
}
|
|
|
|
const WORLD_NAMES = {
|
|
cargo: 'Cargo ship',
|
|
heli: 'Patrol helicopter',
|
|
chinook: 'Chinook',
|
|
bradley: 'Bradley APC',
|
|
supply: 'Supply drop',
|
|
crate: 'Locked crate',
|
|
}
|
|
|
|
const AUDIENCE_WORDS = { public: 'everyone', signed_in: 'signed-in players', staff: 'staff only' }
|
|
|
|
/**
|
|
* `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
|
|
|
|
const [leaflet, setLeaflet] = useState(null)
|
|
const [leafletError, setLeafletError] = useState(null)
|
|
const [shown, setShown] = useState({ grid: true, world: true, events: true, players: true, bases: true, mates: true })
|
|
|
|
useEffect(() => {
|
|
let alive = true
|
|
import('../lib/leaflet.js')
|
|
.then((mod) => {
|
|
if (!alive) return
|
|
if (typeof document !== 'undefined' && !document.getElementById(STYLE_ID)) {
|
|
const style = document.createElement('style')
|
|
style.id = STYLE_ID
|
|
style.textContent = mod.css
|
|
document.head.appendChild(style)
|
|
}
|
|
setLeaflet(mod)
|
|
})
|
|
.catch((err) => alive && setLeafletError(err))
|
|
return () => {
|
|
alive = false
|
|
}
|
|
}, [])
|
|
|
|
const anyLive = Boolean(meta && (LAYERS.some((l) => meta.layers[l.id].visible) || meta.mates.visible))
|
|
const live = usePolled(() => api.servers.mapLive(serverId), {
|
|
key: serverId,
|
|
intervalMs: (meta && meta.pollMs) || 10_000,
|
|
enabled: Boolean(geometry) && anyLive,
|
|
})
|
|
|
|
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(() => {
|
|
if (!leaflet || !geometry || !container.current) return undefined
|
|
const { L } = leaflet
|
|
const bounds = boundsOf(geometry)
|
|
const map = L.map(container.current, {
|
|
crs: L.CRS.Simple,
|
|
minZoom: -4,
|
|
maxZoom: 2,
|
|
zoomSnap: 0.25,
|
|
attributionControl: false,
|
|
// Some things sail off the edge: the rig's cargo ship spent the probe
|
|
// outside the picture entirely. The view may follow them a little way.
|
|
maxBounds: L.latLngBounds(bounds).pad(0.5),
|
|
})
|
|
if (meta.picture) L.imageOverlay(`${BASE}${meta.picture.path}`, bounds).addTo(map)
|
|
map.fitBounds(bounds)
|
|
|
|
const made = {}
|
|
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).
|
|
const labels = L.layerGroup()
|
|
const cellPx = () => geometry.gridCellSize * ((geometry.width - 2 * geometry.oceanMargin) / geometry.worldSize) * 2 ** map.getZoom()
|
|
const fitLabels = () => {
|
|
const want = cellPx() >= LABEL_MIN_CELL_PX
|
|
if (want && !made.grid.hasLayer(labels)) made.grid.addLayer(labels)
|
|
if (!want && made.grid.hasLayer(labels)) made.grid.removeLayer(labels)
|
|
}
|
|
map.on('zoomend', fitLabels)
|
|
|
|
const g = grid(geometry)
|
|
for (const [[x1, z1], [x2, z2]] of g.lines) {
|
|
L.polyline([toLatLng(geometry, x1, z1), toLatLng(geometry, x2, z2)], {
|
|
color: '#ffffff',
|
|
weight: 1,
|
|
opacity: 0.18,
|
|
interactive: false,
|
|
}).addTo(made.grid)
|
|
}
|
|
for (const label of g.labels) {
|
|
L.marker(toLatLng(geometry, label.x, label.z), {
|
|
interactive: false,
|
|
keyboard: false,
|
|
icon: L.divIcon({
|
|
className: '',
|
|
html: `<span style="font:600 10px/1 system-ui,sans-serif;color:rgba(255,255,255,.55);padding:2px 3px;display:block;white-space:nowrap">${label.text}</span>`,
|
|
iconSize: null,
|
|
iconAnchor: [0, 0],
|
|
}),
|
|
}).addTo(labels)
|
|
}
|
|
fitLabels()
|
|
|
|
for (const m of meta.monuments || []) {
|
|
L.circleMarker(toLatLng(geometry, m.x, m.z), {
|
|
radius: 4,
|
|
color: '#000',
|
|
weight: 1,
|
|
fillColor: COLOURS.monument,
|
|
fillOpacity: 0.9,
|
|
})
|
|
.bindTooltip(`${escape(m.label)} · ${escape(m.grid || gridLabel(geometry, m.x, m.z) || '')}`)
|
|
.addTo(made.monuments)
|
|
}
|
|
|
|
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
|
|
groups.current = null
|
|
}
|
|
// `meta` is replaced only when the server changes, with the geometry.
|
|
}, [leaflet, geometry]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
// What moves: every group cleared and redrawn from the latest answer. The
|
|
// counts are small — a busy server is a few hundred markers — and a redraw is
|
|
// simpler to get right than a diff.
|
|
useEffect(() => {
|
|
const made = groups.current
|
|
if (!leaflet || !made || !geometry) return
|
|
const { L } = leaflet
|
|
const answer = live.data || {}
|
|
const at = (p) => toLatLng(geometry, p.x, p.z)
|
|
for (const id of ['world', 'events', 'players', 'bases', 'mates']) made[id].clearLayers()
|
|
|
|
for (const w of answer.world || []) {
|
|
let tip = WORLD_NAMES[w.kind] || w.kind
|
|
if (w.kind === 'crate' && w.hackLeftSec != null) tip += ` — ${countdown(w.hackLeftSec)} left on the hack`
|
|
if (w.kind === 'crate' && w.hacked) tip += ' — hacked'
|
|
dot(L, at(w), COLOURS[w.kind] || COLOURS.crate, w.kind === 'cargo' ? 7 : 5).bindTooltip(escape(tip)).addTo(made.world)
|
|
}
|
|
|
|
const px = (metres) => metres * ((geometry.width - 2 * geometry.oceanMargin) / geometry.worldSize)
|
|
for (const e of answer.events || []) {
|
|
if (e.kind === 'zone') {
|
|
L.circle(at(e), { radius: px(Number(e.radius) || 0), color: COLOURS.event, weight: 2, fillOpacity: 0.12 })
|
|
.bindTooltip(escape(e.name ? `Event zone · ${e.name}` : 'Event zone'))
|
|
.addTo(made.events)
|
|
} else {
|
|
dot(L, at(e), COLOURS.event, 5).bindTooltip(e.kind === 'npc' ? 'Event NPC' : 'Event crate').addTo(made.events)
|
|
}
|
|
}
|
|
|
|
for (const p of answer.players || []) {
|
|
dot(L, at(p), p.online ? COLOURS.online : COLOURS.sleeping, p.online ? 5 : 4)
|
|
.bindTooltip(escape(`${p.name || p.steamId}${p.online ? (p.sleeping ? ' · sleeping' : '') : ' · asleep, offline'}`))
|
|
.addTo(made.players)
|
|
}
|
|
|
|
for (const b of answer.bases || []) {
|
|
dot(L, at(b), COLOURS[b.kind] || COLOURS.tc, 4).bindTooltip(b.kind === 'tc' ? 'Tool cupboard' : 'Vending machine').addTo(made.bases)
|
|
}
|
|
|
|
for (const m of answer.mates || []) {
|
|
dot(L, at(m), m.self ? COLOURS.self : COLOURS.mate, m.self ? 8 : 6, 2)
|
|
.bindTooltip(escape(m.self ? `You${m.online ? '' : ' (asleep, offline)'}` : m.name || 'Clan mate'))
|
|
.addTo(made.mates)
|
|
}
|
|
}, [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
|
|
const made = groups.current
|
|
if (!map || !made) return
|
|
const want = { grid: shown.grid, monuments: shown.world, world: shown.world, events: shown.events, players: shown.players, bases: shown.bases, mates: shown.mates }
|
|
for (const [id, on] of Object.entries(want)) {
|
|
if (on && !map.hasLayer(made[id])) made[id].addTo(map)
|
|
if (!on && map.hasLayer(made[id])) map.removeLayer(made[id])
|
|
}
|
|
}, [shown, leaflet, geometry])
|
|
|
|
const status = useMemo(() => liveStatus(live, anyLive, online), [live, anyLive, online])
|
|
|
|
if (loading) return <Loading />
|
|
if (error) return <ErrorState error={error} />
|
|
if (!geometry) {
|
|
return (
|
|
<Empty
|
|
title="No map yet"
|
|
message="This server has not described its map to the site yet. It will appear once the server is up and has said which map it is on."
|
|
/>
|
|
)
|
|
}
|
|
if (leafletError) return <ErrorState error={leafletError} />
|
|
|
|
return (
|
|
<div className="sans">
|
|
{!meta.picture && (
|
|
<p style={{ color: 'var(--dim)', fontSize: '0.8rem', marginTop: 0 }}>
|
|
This server has no picture of its map, so the layers are drawn on a plain background.
|
|
</p>
|
|
)}
|
|
<div style={{ position: 'relative', zIndex: 0 }}>
|
|
<div
|
|
ref={container}
|
|
role="application"
|
|
aria-label="Map of the server"
|
|
style={{
|
|
height: 'min(70vh, 760px)',
|
|
minHeight: 320,
|
|
borderRadius: 8,
|
|
border: '1px solid var(--line)',
|
|
background: geometry.background || '#0b3b4a',
|
|
}}
|
|
/>
|
|
{!leaflet && (
|
|
<div style={{ position: 'absolute', inset: 0, display: 'grid', placeItems: 'center' }}>
|
|
<Loading />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<p style={{ color: 'var(--dim)', fontSize: '0.78rem', margin: '8px 0 16px' }}>{status}</p>
|
|
|
|
<Legend meta={meta} live={live.data} shown={shown} onToggle={(id) => setShown((s) => ({ ...s, [id]: !s[id] }))} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function Legend({ meta, live, shown, onToggle }) {
|
|
const row = (id, label, swatches, visible, note) => (
|
|
<li key={id} style={{ display: 'flex', alignItems: 'baseline', gap: 10, padding: '6px 0', borderBottom: '1px solid var(--line-soft, var(--line))' }}>
|
|
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: visible ? 'pointer' : 'default', color: visible ? 'var(--ink)' : 'var(--dim)' }}>
|
|
<input type="checkbox" checked={visible && shown[id]} disabled={!visible} onChange={() => onToggle(id)} />
|
|
{label}
|
|
</label>
|
|
<span style={{ display: 'inline-flex', gap: 4 }}>
|
|
{swatches.map((c) => (
|
|
<span key={c} aria-hidden="true" style={{ width: 10, height: 10, borderRadius: '50%', background: c, display: 'inline-block', border: '1px solid rgba(0,0,0,.4)' }} />
|
|
))}
|
|
</span>
|
|
{note && <span style={{ color: 'var(--dim)', fontSize: '0.76rem', marginLeft: 'auto', textAlign: 'right' }}>{note}</span>}
|
|
</li>
|
|
)
|
|
|
|
const hiddenNote = (layer) => {
|
|
const words = AUDIENCE_WORDS[layer.audience] || AUDIENCE_WORDS.staff
|
|
if (layer.audience === 'signed_in') return 'Sign in to see this layer.'
|
|
return `Shown to ${words}.`
|
|
}
|
|
|
|
const swatches = {
|
|
world: [COLOURS.monument, COLOURS.cargo, COLOURS.heli, COLOURS.crate],
|
|
events: [COLOURS.event],
|
|
players: [COLOURS.online, COLOURS.sleeping],
|
|
bases: [COLOURS.tc, COLOURS.vending],
|
|
}
|
|
|
|
return (
|
|
<ul style={{ listStyle: 'none', margin: 0, padding: 0, fontSize: '0.86rem' }}>
|
|
{row('grid', 'Grid', [], true, null)}
|
|
{LAYERS.map((l) => {
|
|
const layer = meta.layers[l.id]
|
|
let note = layer.visible ? null : hiddenNote(layer)
|
|
if (l.id === 'players' && layer.cappedByPresence && !layer.visible) {
|
|
note = `${note} Limited by who may see who is online.`
|
|
}
|
|
if (layer.visible && l.id === 'players' && live && live.playersTruncated) note = 'Not every sleeper is shown.'
|
|
if (layer.visible && l.id === 'bases' && live && live.basesTruncated) note = 'Not every base is shown.'
|
|
return row(l.id, l.label, swatches[l.id], layer.visible, note)
|
|
})}
|
|
{meta.mates.visible &&
|
|
row('mates', 'You and your clan', [COLOURS.self, COLOURS.mate], true, 'Your own position, and clan mates who are online.')}
|
|
{!meta.mates.visible && meta.mates.on && meta.mates.signedIn && !meta.mates.linked &&
|
|
row('mates', 'You and your clan', [COLOURS.self, COLOURS.mate], false, 'Link your Steam account to see yourself and your clan here.')}
|
|
</ul>
|
|
)
|
|
}
|
|
|
|
function dot(L, latlng, colour, radius, weight = 1) {
|
|
return L.circleMarker(latlng, { radius, color: '#000', weight, fillColor: colour, fillOpacity: 0.95 })
|
|
}
|
|
|
|
/** Tooltips are HTML in Leaflet, and a player's name is text they typed. */
|
|
function escape(text) {
|
|
return String(text).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c])
|
|
}
|
|
|
|
function liveStatus(live, anyLive, online) {
|
|
if (!anyLive) return 'No moving layers are shown to you on this server.'
|
|
if (live.loading) return 'Asking the server where things are…'
|
|
const data = live.data
|
|
if (data && data.live === false) {
|
|
return online
|
|
? 'The server did not say where things are just now; it is asked again every ten seconds.'
|
|
: 'The server is offline, so nothing is moving on its map.'
|
|
}
|
|
if (live.error && !data) return 'Positions could not be loaded.'
|
|
return live.at ? `Positions as of ${ago(new Date(live.at).toISOString())}, refreshed every ten seconds while this tab is open.` : ''
|
|
}
|