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

@@ -56,6 +56,15 @@ export const servers = {
// gets; then what moves on it, already cut down to this viewer on the server.
map: (id) => req(`/public/rust/servers/${encodeURIComponent(id)}/map`),
mapLive: (id) => req(`/public/rust/servers/${encodeURIComponent(id)}/map/live`),
// RunicNPC (runicnpc stage 4). The profiles a leaderboard can rank by, one
// profile's ranking counted as the profile says (D247, D250), and one
// player's kills by profile, for an opened leaderboard row (D252).
npcProfiles: (id) => req(`/public/rust/servers/${encodeURIComponent(id)}/npc-profiles`),
npcLeaderboard: (id, { profile, wipe = null, limit = null } = {}) =>
req(`/public/rust/servers/${encodeURIComponent(id)}/npc-leaderboard${query({ profile, wipe, limit })}`),
npcKills: (id, steamId, { wipe = null } = {}) =>
req(`/public/rust/servers/${encodeURIComponent(id)}/players/${encodeURIComponent(steamId)}/npc-kills${query({ wipe })}`),
}
// One clan. Its roster comes back only for a viewer inside the operator's roster
@@ -94,6 +103,11 @@ export const playerServers = {
list: () => req('/player/rust/servers'),
}
// Your own kills of each RunicNPC profile, current wipes, across your linked accounts (D252).
export const playerNpcKills = {
list: () => req('/player/rust/npc-kills'),
}
// R1's identity link, from the signed-in player's side.
//
// **The code is the whole of what goes up.** The site has no idea which server
@@ -220,6 +234,28 @@ export const adminZones = {
remove: (id) => req(`/admin/rust/zones/presets/${encodeURIComponent(id)}`, { method: 'DELETE' }),
}
// ── admin · NPCs (runicnpc stage 4) ─────────────────────────────────────────
//
// The site's RunicNPC profiles (per server, shared or fleet), pushed to each
// server; and each server's placements, which live on the server (D222), so
// every placement call is a live round trip that fails while the game is off.
const npcServer = (serverId) => `/admin/rust/npcs/servers/${encodeURIComponent(serverId)}`
export const adminNpcs = {
read: () => req('/admin/rust/npcs'),
create: (body) => req('/admin/rust/npcs/profiles', { method: 'POST', body }),
update: (id, body) => req(`/admin/rust/npcs/profiles/${encodeURIComponent(id)}`, { method: 'PUT', body }),
remove: (id) => req(`/admin/rust/npcs/profiles/${encodeURIComponent(id)}`, { method: 'DELETE' }),
restore: (id) => req(`/admin/rust/npcs/profiles/${encodeURIComponent(id)}/restore`, { method: 'POST' }),
push: (serverId) => req(`${npcServer(serverId)}/push`, { method: 'POST' }),
placements: (serverId) => req(`${npcServer(serverId)}/placements`),
place: (serverId, body) => req(`${npcServer(serverId)}/placements`, { method: 'POST', body }),
setPlacement: (serverId, id, body) => req(`${npcServer(serverId)}/placements/${encodeURIComponent(id)}`, { method: 'PUT', body }),
removePlacement: (serverId, id) => req(`${npcServer(serverId)}/placements/${encodeURIComponent(id)}`, { method: 'DELETE' }),
renamePlacement: (serverId, id, to) =>
req(`${npcServer(serverId)}/placements/${encodeURIComponent(id)}/rename`, { method: 'POST', body: { to } }),
respawnPlacement: (serverId, id) => req(`${npcServer(serverId)}/placements/${encodeURIComponent(id)}/respawn`, { method: 'POST' }),
}
// ── admin · mod configuration (R18) ───────────────────────────────────────
//
// Every call here is a LIVE round trip to a game host, which makes this the only
@@ -301,6 +337,8 @@ export default {
adminConfig,
adminVisibility,
adminZones,
adminNpcs,
playerNpcKills,
adminUserLinks,
adminUserPermissions,
BASE,

View File

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

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

View File

@@ -27,9 +27,11 @@ import ModConfig from './routes/admin/ModConfig.jsx'
import Visibility from './routes/admin/Visibility.jsx'
import ServerSettings from './routes/admin/ServerSettings.jsx'
import ZonePresets from './routes/admin/ZonePresets.jsx'
import NpcProfiles from './routes/admin/NpcProfiles.jsx'
import NpcPlacements from './routes/admin/NpcPlacements.jsx'
import UserRustSections from './routes/admin/UserRustSections.jsx'
import FooterStatus from './components/FooterStatus.jsx'
import { IconEye, IconKey, IconLink, IconServer, IconSliders, IconZone } from './icons.jsx'
import { IconEye, IconKey, IconLink, IconNpc, IconPlacement, IconServer, IconSliders, IconZone } from './icons.jsx'
// The module id, exactly as `module.json` spells it. Core keys the registry by it
// and prefixes every route path with it.
@@ -113,6 +115,10 @@ registry.registerRoutes(ID, {
// Zone presets (PLAN_REDESIGNS §3.1, D210). Core's step editor has single-
// value fields, so a zone's flags are ticked here and a step copies the set.
{ path: 'zones', element: <ZonePresets /> },
// RunicNPC (runicnpc stage 4): the site's NPC profiles, pushed to each
// server, and each server's placements, created by clicking its live map.
{ path: 'npcs', element: <NpcProfiles /> },
{ path: 'npcs/placements', element: <NpcPlacements /> },
],
})
@@ -164,6 +170,8 @@ registry.registerNav(ID, {
{ label: 'Rust visibility', to: '/admin/rust/visibility', icon: IconEye },
{ label: 'Rust servers', to: '/admin/rust/servers', icon: IconServer },
{ label: 'Rust zone presets', to: '/admin/rust/zones', icon: IconZone },
{ label: 'Rust NPC profiles', to: '/admin/rust/npcs', icon: IconNpc },
{ label: 'Rust NPC placements', to: '/admin/rust/npcs/placements', icon: IconPlacement },
],
})

View File

@@ -124,3 +124,25 @@ export const IconZone = () => (
<circle cx="12" cy="12" r="3" />
</Icon>
)
/**
* A figure — the admin sidebar's row for RunicNPC's profiles (runicnpc stage 4).
* A head and shoulders: the page is about who the NPCs are.
*/
export const IconNpc = () => (
<Icon>
<circle cx="12" cy="8" r="3.5" />
<path d="M5 20c0-3.9 3.1-7 7-7s7 3.1 7 7" />
</Icon>
)
/**
* A figure on a spot — the admin sidebar's row for NPC placements: where they stand.
*/
export const IconPlacement = () => (
<Icon>
<circle cx="12" cy="6" r="2.5" />
<path d="M8 15c0-2.2 1.8-4 4-4s4 1.8 4 4" />
<ellipse cx="12" cy="19" rx="7" ry="2" />
</Icon>
)

View File

@@ -161,7 +161,9 @@ function death(frame, name) {
case 'npc':
return {
tone: 'death',
actor: attacker(frame.attackerName) || 'Something',
// One of RunicNPC's (runicnpc stage 4): its own name, as typed on its profile,
// rather than the prefab it is built from. Older frames carry only the prefab.
actor: frame.attackerNpc || attacker(frame.attackerName) || 'Something',
verb: 'killed',
subject: name,
detail: where,

View File

@@ -34,6 +34,15 @@ export function toLatLng(g, x, z) {
return [(Number(z) + half) * s + m, (Number(x) + half) * s + m]
}
/** The inverse of `toLatLng`: a point on the picture as world `{ x, z }` in metres (a click on the map). */
export function fromLatLng(g, lat, lng) {
const s = scaleOf(g)
if (!(s > 0)) return null
const half = g.worldSize / 2
const m = g.oceanMargin || 0
return { x: (Number(lng) - m) / s - half, z: (Number(lat) - m) / s - half }
}
/** The picture's bounds in the same space. */
export function boundsOf(g) {
return [[0, 0], [g.height, g.width]]

View File

@@ -34,6 +34,8 @@ export function statOptions(categories) {
return [
...(categories || []).map((c) => ({ id: c.stat, label: c.label, title: c.current })),
{ id: 'playtime', label: 'Playtime', title: null },
// RunicNPC (runicnpc stage 4, D250): one NPC profile's kills. The rule names the profile.
{ id: 'profilekills', label: 'Kills of an NPC profile', title: null },
]
}
@@ -66,6 +68,8 @@ function Chip({ text, color }) {
export function TitlesForm({ server, categories, onSaved, onCancel }) {
const start = server.titles || { mode: 'first', max: 2, rules: [] }
const stats = statOptions(categories)
const { data: npc } = useAsync(() => api.servers.npcProfiles(server.id).catch(() => ({ profiles: [] })), [server.id])
const profiles = (npc && npc.profiles) || []
const [mode, setMode] = useState(start.mode)
const [max, setMax] = useState(start.max)
const [rules, setRules] = useState(start.rules.map((r) => ({ ...r })))
@@ -74,7 +78,7 @@ export function TitlesForm({ server, categories, onSaved, onCancel }) {
const setRule = (i, key) => (e) => {
const value = e.target.value
setRules((list) => list.map((r, n) => (n === i ? { ...r, [key]: key === 'topN' ? Number(value) : value } : r)))
setRules((list) => list.map((r, n) => (n === i ? { ...r, [key]: key === 'topN' || key === 'profile' ? Number(value) : value } : r)))
}
const move = (i, by) => setRules((list) => {
const next = [...list]
@@ -117,6 +121,12 @@ export function TitlesForm({ server, categories, onSaved, onCancel }) {
<select value={r.stat} onChange={setRule(i, 'stat')} style={inputStyle} aria-label="Stat">
{stats.map((s) => <option key={s.id} value={s.id}>{s.label}</option>)}
</select>
{r.stat === 'profilekills' && (
<select value={r.profile || ''} onChange={setRule(i, 'profile')} style={inputStyle} aria-label="NPC profile" required>
<option value="" disabled>{profiles.length ? 'Which NPC?' : 'No NPC profile on this server'}</option>
{profiles.map((p) => <option key={p.id} value={p.id}>{p.label} ({p.name})</option>)}
</select>
)}
<span>earn</span>
{/* The category's title is a PLACEHOLDER, never a value: saved as the rule's own text it would stop a rename reaching this rule (§5.5). */}
<input value={r.text} onChange={setRule(i, 'text')} maxLength={24} required={!fallback} placeholder={fallback || 'A title'} style={{ ...inputStyle, width: 160 }} aria-label="Title" />

View File

@@ -0,0 +1,289 @@
// ── Admin · Rust · NPC placements (docs/runicnpc/PLAN.md stage 4) ─────────
//
// One server's RunicNPC placements. They live on the server (D222) — an admin's
// `/rnpc place` in game and this page edit the same list — so every read and
// write here is a live round trip, and a server whose game is off has nothing
// to show.
//
// A new placement is made by clicking the live map (D245): the server puts the
// point on the ground, checks it against the navmesh, and names it as in game
// (D246). A roof or a building top is placed in game. Every answer that adds
// NPCs carries the cost warning (D227).
import { useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { ErrorState, Loading, useAsync } from '../../core.js'
import MapView from '../../components/MapView.jsx'
import api from '../../api.js'
const inputStyle = {
background: 'var(--panel-flat, transparent)',
color: 'var(--text)',
border: '1px solid var(--line)',
borderRadius: 6,
padding: '6px 8px',
font: 'inherit',
}
const blank = (profile = '') => ({ profile, count: '1', respawn: '300', respawnMode: 'each', move: '', radius: '' })
function formOf(p) {
const v = p.placement || {}
return {
profile: v.profile || '',
count: String(v.count || 1),
respawn: String(v.respawn || 300),
respawnMode: v.respawnMode || 'each',
move: (v.movement && v.movement.mode) || '',
radius: v.movement && v.movement.radius !== undefined ? String(v.movement.radius) : '',
}
}
function bodyOf(f) {
return {
profile: f.profile,
count: Number(f.count),
respawn: Number(f.respawn),
respawnMode: f.respawnMode,
...(f.move ? { movement: { mode: f.move, radius: f.radius === '' ? undefined : Number(f.radius) } } : { movement: null }),
}
}
export default function NpcPlacements() {
const { data: meta, error: metaError } = useAsync(() => api.adminNpcs.read(), [])
const ready = useMemo(() => ((meta && meta.servers) || []).filter((s) => s.ready), [meta])
const [serverId, setServerId] = useState('')
useEffect(() => {
if (!serverId && ready.length) setServerId(ready[0].id)
}, [ready, serverId])
if (metaError) return <ErrorState error={metaError} />
if (!meta) return <Loading />
const server = ready.find((s) => s.id === serverId)
const profiles = (meta.profiles || []).filter((p) => !p.replaced && (p.allServers || p.servers.includes(serverId)))
return (
<div style={{ maxWidth: 1100 }}>
<p className="sans dim" style={{ fontSize: '0.82rem', marginTop: 0 }}>
Where RunicNPC’s NPCs stand, and come back after they die. These live on the server: <code>/rnpc place</code> in
game changes the same list. Click the map to place NPCs there; the server puts them on the ground and names the
placement. A roof or the top of a building is placed in game. Profiles are on the{' '}
<Link to="/admin/rust/npcs">NPC profiles page</Link>.
</p>
{ready.length === 0 ? (
<section className="panel sans" style={{ padding: '14px 18px', fontSize: '0.84rem' }}>
No server can take placements from the site yet: each needs RunicNPC with API 3.{' '}
{(meta.servers || []).map((s) => `${s.name || s.id}: ${s.absence}`).join(' · ')}
</section>
) : (
<>
<label className="sans" style={{ display: 'flex', gap: 8, alignItems: 'baseline', fontSize: '0.86rem', marginBottom: 12 }}>
<span style={{ color: 'var(--head)' }}>Server</span>
<select value={serverId} onChange={(e) => setServerId(e.target.value)} style={inputStyle}>
{ready.map((s) => <option key={s.id} value={s.id}>{s.name || s.id}</option>)}
</select>
</label>
{server && <ServerPlacements key={server.id} server={server} profiles={profiles} />}
</>
)}
</div>
)
}
function ServerPlacements({ server, profiles }) {
const [reloads, setReloads] = useState(0)
const { data, error: loadError } = useAsync(() => api.adminNpcs.placements(server.id), [server.id, reloads])
const [picked, setPicked] = useState(null)
const [form, setForm] = useState(blank(profiles[0] ? profiles[0].name : ''))
const [editing, setEditing] = useState(null)
const [renaming, setRenaming] = useState(null)
const [confirming, setConfirming] = useState(null)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [notice, setNotice] = useState('')
const act = async (fn, done) => {
setBusy(true)
setError('')
setNotice('')
try {
const out = await fn()
setNotice(done(out))
setReloads((n) => n + 1)
return true
} catch (err) {
setError(err.message || 'That did not work.')
return false
} finally {
setBusy(false)
}
}
const placements = (data && data.placements) || []
const pins = useMemo(() => {
const out = placements
.filter((p) => p.placement && p.placement.position)
.map((p) => ({
x: p.placement.position.x,
z: p.placement.position.z,
label: `${p.id} · ${p.placement.count} × ${p.placement.profile}${p.waiting ? ` · waits: ${p.waiting}` : ''}`,
colour: p.waiting ? '#9e9e9e' : '#ffd54f',
}))
if (picked) out.push({ x: picked.x, z: picked.z, label: 'New placement', colour: '#00e5ff', ring: true })
return out
}, [placements, picked])
const place = async (e) => {
e.preventDefault()
const ok = await act(
() => api.adminNpcs.place(server.id, { ...bodyOf(form), position: { x: picked.x, z: picked.z } }),
(r) => `Placed ${r.id}${r.position ? ` at ${Math.round(r.position.x)}, ${Math.round(r.position.z)} (${Math.round(r.position.y)} m up)` : ''}.${r.built ? ' It is on something players built: if that is destroyed, its NPCs stand on the nearest navmesh.' : ''} ${r.cost || ''}`,
)
if (ok) setPicked(null)
}
return (
<div style={{ display: 'grid', gap: 16 }}>
{notice && <p className="sans" style={{ fontSize: '0.84rem', color: 'var(--head)', margin: 0 }}>{notice}</p>}
{error && <p className="sans" style={{ color: 'var(--danger, #d98b84)', fontSize: '0.84rem', margin: 0 }}>{error}</p>}
<MapView serverId={server.id} online={server.online !== false} onPick={(at) => { setError(''); setPicked(at) }} pins={pins} />
{picked && (
<form onSubmit={place} className="panel sans" style={{ padding: '14px 18px', display: 'grid', gap: 10, fontSize: '0.84rem' }}>
<strong style={{ color: 'var(--head)' }}>
New placement at {picked.x}, {picked.z}{picked.grid ? ` (${picked.grid})` : ''}
</strong>
{profiles.length === 0 ? (
<span className="dim">No NPC profile is on this server yet.</span>
) : (
<PlacementFields form={form} setForm={setForm} profiles={profiles} routes={(data && data.routes) || []} />
)}
<div style={{ display: 'flex', gap: 8 }}>
<button type="submit" className="btn" disabled={busy || profiles.length === 0}>{busy ? 'Placing…' : 'Place'}</button>
<button type="button" className="btn" disabled={busy} onClick={() => setPicked(null)}>Cancel</button>
</div>
</form>
)}
<section className="panel sans" style={{ padding: '14px 18px', fontSize: '0.84rem' }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10 }}>
<h2 className="display" style={{ fontSize: '1rem', margin: 0, color: 'var(--head)' }}>Placements</h2>
<button type="button" className="btn" style={{ marginLeft: 'auto' }} disabled={busy} onClick={() => setReloads((n) => n + 1)}>Refresh</button>
</div>
{loadError && <p style={{ color: 'var(--danger, #d98b84)' }}>{loadError.message}</p>}
{!data && !loadError && <Loading />}
{data && data.cost && <p className="dim" style={{ fontSize: '0.78rem' }}>{data.cost}</p>}
{data && placements.length === 0 && <p className="dim">None on this server.</p>}
{placements.map((p) => {
const v = p.placement || {}
const move = v.movement ? `${v.movement.mode}${v.movement.mode === 'wander' ? ` ${v.movement.radius} m` : ''}` : 'the profile’s movement'
return (
<div key={p.id} style={{ borderTop: '1px solid var(--line-soft)', padding: '8px 0' }}>
<div style={{ display: 'flex', gap: 10, alignItems: 'baseline', flexWrap: 'wrap' }}>
<strong style={{ color: 'var(--head)' }}>{p.id}</strong>
<span className="dim">
{v.count} × {v.profile} · {p.alive} alive · respawn {v.respawn} s, {v.respawnMode} · {move}
{v.position ? ` · ${Math.round(v.position.x)}, ${Math.round(v.position.z)}` : ''}
</span>
<span style={{ marginLeft: 'auto', display: 'flex', gap: 6 }}>
<button type="button" className="btn" disabled={busy} onClick={() => { setEditing(editing === p.id ? null : p.id); setForm(formOf(p)) }}>Edit</button>
<button type="button" className="btn" disabled={busy} onClick={() => setRenaming(renaming && renaming.id === p.id ? null : { id: p.id, to: p.id })}>Rename</button>
<button type="button" className="btn" disabled={busy} onClick={() => act(() => api.adminNpcs.respawnPlacement(server.id, p.id), (r) => `Respawning ${r.respawned} NPC(s) of ${p.id}.`)}>Respawn</button>
{confirming === p.id ? (
<button type="button" className="btn" disabled={busy} onClick={() => { setConfirming(null); act(() => api.adminNpcs.removePlacement(server.id, p.id), () => `Removed ${p.id} and its NPCs.`) }}>
Confirm remove
</button>
) : (
<button type="button" className="btn" disabled={busy} onClick={() => setConfirming(p.id)}>Remove</button>
)}
</span>
</div>
{p.waiting && <div style={{ color: 'var(--danger, #d98b84)' }}>Waits: {p.waiting}</div>}
{p.note && <div className="dim">{p.note}</div>}
{p.lastError && <div className="dim">Last spawn failed: {p.lastError}</div>}
{renaming && renaming.id === p.id && (
<form
onSubmit={(e) => { e.preventDefault(); act(() => api.adminNpcs.renamePlacement(server.id, p.id, renaming.to), () => `Renamed ${p.id} to ${renaming.to}.`).then((ok) => ok && setRenaming(null)) }}
style={{ display: 'flex', gap: 8, marginTop: 6 }}
>
<input value={renaming.to} onChange={(e) => setRenaming({ ...renaming, to: e.target.value })} pattern="[a-z0-9_\-]{1,40}" style={{ ...inputStyle, maxWidth: 240 }} />
<button type="submit" className="btn" disabled={busy}>Rename</button>
</form>
)}
{editing === p.id && (
<form
onSubmit={(e) => { e.preventDefault(); act(() => api.adminNpcs.setPlacement(server.id, p.id, bodyOf(form)), (r) => `Changed ${p.id}. ${r.cost || ''}`).then((ok) => ok && setEditing(null)) }}
style={{ display: 'grid', gap: 8, marginTop: 6 }}
>
<PlacementFields form={form} setForm={setForm} profiles={profiles} routes={(data && data.routes) || []} />
<div style={{ display: 'flex', gap: 8 }}>
<button type="submit" className="btn" disabled={busy}>Save</button>
<button type="button" className="btn" disabled={busy} onClick={() => setEditing(null)}>Cancel</button>
</div>
</form>
)}
</div>
)
})}
</section>
</div>
)
}
/** `/rnpc place`'s options (D242, D246): profile, count, respawn, each or group, movement and radius. */
function PlacementFields({ form, setForm, profiles, routes }) {
const set = (key) => (e) => setForm((f) => ({ ...f, [key]: e.target.value }))
const profile = profiles.find((p) => p.name === form.profile)
const sentry = profile && profile.body.role === 'sentry'
const label = { display: 'grid', gap: 4 }
return (
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'end' }}>
<label style={label}>
<span style={{ color: 'var(--head)' }}>Profile</span>
<select value={form.profile} onChange={set('profile')} style={inputStyle} required>
{!profile && <option value={form.profile}>{form.profile || 'Pick one'}</option>}
{profiles.map((p) => <option key={p.id} value={p.name}>{p.label} ({p.name})</option>)}
</select>
</label>
<label style={label}>
<span style={{ color: 'var(--head)' }}>How many</span>
<input value={form.count} onChange={set('count')} inputMode="numeric" style={{ ...inputStyle, maxWidth: 70 }} />
</label>
<label style={label}>
<span style={{ color: 'var(--head)' }}>Respawn (s)</span>
<input value={form.respawn} onChange={set('respawn')} inputMode="numeric" style={{ ...inputStyle, maxWidth: 90 }} />
</label>
<label style={label}>
<span style={{ color: 'var(--head)' }}>Come back</span>
<select value={form.respawnMode} onChange={set('respawnMode')} style={inputStyle}>
<option value="each">each, after its own death</option>
<option value="group">together, once all are dead</option>
</select>
</label>
{!sentry && (
<>
<label style={label}>
<span style={{ color: 'var(--head)' }}>Movement</span>
<select value={form.move} onChange={set('move')} style={inputStyle}>
<option value="">the profile’s own</option>
<option value="wander">wander</option>
<option value="monument">monument</option>
{routes.map((r) => <option key={r} value={`route:${r}`}>route: {r}</option>)}
</select>
</label>
{form.move === 'wander' && (
<label style={label}>
<span style={{ color: 'var(--head)' }}>Radius (m)</span>
<input value={form.radius} onChange={set('radius')} inputMode="decimal" style={{ ...inputStyle, maxWidth: 80 }} />
</label>
)}
</>
)}
</div>
)
}

View File

@@ -0,0 +1,385 @@
// ── Admin · Rust · NPC profiles (docs/runicnpc/PLAN.md stage 4) ──────────
//
// Where RunicNPC's profiles are authored (D216: no in-game editor). A profile is
// for one server, several, or every server, like a zone preset, and the site
// pushes each server its set, which RunicNPC then holds as managed (D221).
//
// The first push to a server adopts that server's own profiles as profiles for
// it alone (D244); one whose name a site profile already had there is kept
// aside as "replaced" (D251), listed below with a Restore. Each profile says how
// its kills are counted on the leaderboard and in titles (D247).
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { ErrorState, Loading, useAsync } from '../../core.js'
import api from '../../api.js'
const inputStyle = {
background: 'var(--panel-flat, transparent)',
color: 'var(--text)',
border: '1px solid var(--line)',
borderRadius: 6,
padding: '6px 8px',
font: 'inherit',
}
const SCOPE_WORDS = {
server: 'Kills of this name on the server a leaderboard is for (the default)',
name: 'Kills of this name on every server',
profile: 'Kills of this profile only, on whichever servers it is pushed to',
}
const list = (text) => String(text || '').split(/[\n,]+/).map((s) => s.trim()).filter(Boolean)
const str = (v) => (v === undefined || v === null ? '' : String(v))
function formFrom(p, defaults) {
const b = { ...defaults, ...(p ? p.body : {}) }
return {
id: p ? p.id : null,
name: p ? p.name : '',
allServers: p ? p.allServers : false,
servers: p ? [...p.servers] : [],
killsScope: p ? p.killsScope : 'server',
names: (b.names || []).join('\n'),
kits: (b.kits || []).join(', '),
prefab: b.prefab,
role: b.role,
moveMode: (b.movement && b.movement.mode) || 'wander',
moveRadius: str(b.movement && b.movement.radius),
health: str(b.health),
damageDealt: str(b.damageDealt),
head: str(b.damageTaken && b.damageTaken.head),
body: str(b.damageTaken && b.damageTaken.body),
legs: str(b.damageTaken && b.damageTaken.legs),
aimCone: str(b.aimCone),
sense: str(b.ranges && b.ranges.sense),
loseTarget: str(b.ranges && b.ranges.loseTarget),
chase: str(b.ranges && b.ranges.chase),
attack: str(b.ranges && b.ranges.attack),
visionCone: str(b.visionCone),
sleepDistance: str(b.sleepDistance),
thresholds: (b.healthThresholds || []).map((t) => Math.round(t * 100)).join(', '),
}
}
/** What the page sends: numbers as typed (the server checks them, as RunicNPC would). */
function bodyFrom(f) {
return {
name: f.name.trim(),
allServers: f.allServers,
servers: f.allServers ? [] : f.servers,
killsScope: f.killsScope,
body: {
names: list(f.names),
kits: list(f.kits),
prefab: f.prefab,
role: f.role,
movement: { mode: f.moveMode.trim(), radius: f.moveRadius },
health: f.health,
damageDealt: f.damageDealt,
damageTaken: { head: f.head, body: f.body, legs: f.legs },
aimCone: f.aimCone,
ranges: { sense: f.sense, loseTarget: f.loseTarget, chase: f.chase, attack: f.attack },
visionCone: f.visionCone,
sleepDistance: f.sleepDistance,
healthThresholds: list(f.thresholds).map((t) => Number(t) / 100),
},
}
}
/** One line on a server's RunicNPC and the last push to it. */
function serverLine(s) {
if (!s.ready) return s.absence || 'no RunicNPC'
const parts = [`RunicNPC ${s.runicNpc.version || ''} · API ${s.runicNpc.api}`.replace(' ', ' ')]
const sync = s.sync
if (!sync || !sync.adoptedAt) parts.push('not pushed yet: its own profiles are adopted first')
else if (sync.state === 'ok') parts.push(`pushed ${new Date(sync.syncedAt).toLocaleString()}`)
else if (sync.state === 'failed') parts.push(`the last push failed: ${sync.error || 'no reason given'}`)
return parts.join(' · ')
}
export default function NpcProfiles() {
const [reloads, setReloads] = useState(0)
const { data, error: loadError } = useAsync(() => api.adminNpcs.read(), [reloads])
const [form, setForm] = useState(null)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [notice, setNotice] = useState('')
if (loadError) return <ErrorState error={loadError} />
if (!data) return <Loading />
const servers = data.servers || []
const nameOf = new Map(servers.map((s) => [s.id, s.name || s.id]))
const inUse = (data.profiles || []).filter((p) => !p.replaced)
const replaced = (data.profiles || []).filter((p) => p.replaced)
const act = async (fn, done) => {
setBusy(true)
setError('')
setNotice('')
try {
const out = await fn()
if (done) setNotice(done(out))
setForm(null)
setReloads((n) => n + 1)
} catch (err) {
setError(err.message || 'That did not work.')
} finally {
setBusy(false)
}
}
const save = (e) => {
e.preventDefault()
act(() => (form.id ? api.adminNpcs.update(form.id, bodyFrom(form)) : api.adminNpcs.create(bodyFrom(form))), () => 'Saved. It reaches each server on the next push, within half a minute.')
}
const push = (s) =>
act(
() => api.adminNpcs.push(s.id),
(r) => {
if (r.outcome === 'pushed') {
const refused = Object.entries(r.refused || {})
const adopted = (r.adopted || []).map((a) => `${a.name} (${a.outcome})`)
return `${nameOf.get(s.id)}: pushed ${r.profiles.length} profile(s)${adopted.length ? `; adopted ${adopted.join(', ')}` : ''}${refused.length ? `; RunicNPC refused ${refused.map(([n, why]) => `${n}: ${why}`).join('; ')}` : ''}.`
}
if (r.outcome === 'current') return `${nameOf.get(s.id)} already has the current set.`
return `${nameOf.get(s.id)}: ${r.outcome}${r.reason ? `, ${r.reason}` : ''}${r.error ? `, ${r.error}` : ''}.`
},
)
return (
<div style={{ maxWidth: 960 }}>
<p className="sans dim" style={{ fontSize: '0.82rem', marginTop: 0 }}>
RunicNPC’s NPC profiles: what an NPC wears (its Kits kits), how hard it is and how it moves. Each profile is for
one server, several, or every server, and each server is pushed its set. The first push to a server adopts the
profiles it already had, so nothing on it changes. Where NPCs stand is set on the{' '}
<Link to="/admin/rust/npcs/placements">placements page</Link>, in game with <code>/rnpc place</code>, or by an
event’s <em>Place NPCs</em> step.
</p>
<section className="panel" style={{ padding: '14px 18px', marginBottom: 18 }}>
<h2 className="display" style={{ fontSize: '1rem', margin: '0 0 8px', color: 'var(--head)' }}>Servers</h2>
{servers.length === 0 && <p className="sans dim" style={{ fontSize: '0.82rem', margin: 0 }}>No servers are configured yet.</p>}
{servers.map((s) => {
const refused = Object.entries((s.sync && s.sync.refused) || {})
return (
<div key={s.id} className="sans" style={{ fontSize: '0.8rem', padding: '5px 0', borderTop: '1px solid var(--line-soft)' }}>
<div style={{ display: 'flex', gap: 10, alignItems: 'baseline', flexWrap: 'wrap' }}>
<strong style={{ color: 'var(--head)' }}>{s.name || s.id}</strong>
<span className="dim">{serverLine(s)}</span>
{s.ready && (
<button type="button" className="btn" style={{ marginLeft: 'auto' }} disabled={busy} onClick={() => push(s)}>
Push now
</button>
)}
</div>
{refused.length > 0 && (
<div style={{ color: 'var(--danger, #d98b84)', marginTop: 2 }}>
RunicNPC refused: {refused.map(([n, why]) => `${n} (${why})`).join('; ')}
</div>
)}
</div>
)
})}
</section>
{notice && <p className="sans" style={{ fontSize: '0.84rem', color: 'var(--head)' }}>{notice}</p>}
{error && !form && <p className="sans" style={{ color: 'var(--danger, #d98b84)', fontSize: '0.84rem' }}>{error}</p>}
<section className="panel" style={{ padding: '14px 18px', marginBottom: 18 }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10 }}>
<h2 className="display" style={{ fontSize: '1rem', margin: 0, color: 'var(--head)' }}>Profiles</h2>
<button type="button" className="btn" style={{ marginLeft: 'auto' }} onClick={() => { setError(''); setForm(formFrom(null, data.defaults)) }}>
New profile
</button>
</div>
{inUse.length === 0 && (
<p className="sans dim" style={{ fontSize: '0.82rem', margin: '8px 0 0' }}>None yet. A server’s own profiles appear here after its first push.</p>
)}
{inUse.map((p) => (
<div key={p.id} className="sans" style={{ borderTop: '1px solid var(--line-soft)', padding: '8px 0', fontSize: '0.84rem' }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10, flexWrap: 'wrap' }}>
<strong style={{ color: 'var(--head)' }}>{p.label}</strong>
<code className="dim">{p.name}</code>
<span className="dim" style={{ fontSize: '0.76rem' }}>
{p.allServers ? 'every server' : p.servers.map((id) => nameOf.get(id) || id).join(', ')}
{p.adoptedFrom ? ` · adopted from ${nameOf.get(p.adoptedFrom) || p.adoptedFrom}` : ''}
</span>
<button type="button" className="btn" style={{ marginLeft: 'auto' }} onClick={() => { setError(''); setForm(formFrom(p, data.defaults)) }}>
Edit
</button>
</div>
<span className="dim" style={{ fontSize: '0.76rem' }}>
{p.body.role} · {p.body.health} health · kits {(p.body.kits || []).join(', ')} · {p.body.role === 'sentry' ? 'stands still' : p.body.movement && p.body.movement.mode}
{' · '}kills counted: {p.killsScope === 'server' ? 'per server' : p.killsScope === 'name' ? 'every server with this name' : 'this profile only'}
</span>
</div>
))}
</section>
{replaced.length > 0 && (
<section className="panel" style={{ padding: '14px 18px', marginBottom: 18 }}>
<h2 className="display" style={{ fontSize: '1rem', margin: '0 0 6px', color: 'var(--head)' }}>Replaced</h2>
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '0 0 6px' }}>
A server’s own profiles whose name a site profile already had there when the site first pushed to it. The site’s
is in use; these are kept in case you want them back.
</p>
{replaced.map((p) => (
<div key={p.id} className="sans" style={{ display: 'flex', gap: 10, alignItems: 'baseline', borderTop: '1px solid var(--line-soft)', padding: '6px 0', fontSize: '0.84rem' }}>
<code>{p.name}</code>
<span className="dim">from {nameOf.get(p.adoptedFrom) || p.adoptedFrom}</span>
<button type="button" className="btn" style={{ marginLeft: 'auto' }} disabled={busy} onClick={() => act(() => api.adminNpcs.restore(p.id), () => `${p.name} is back in use on ${nameOf.get(p.adoptedFrom) || p.adoptedFrom}.`)}>
Restore
</button>
<button type="button" className="btn" disabled={busy} onClick={() => act(() => api.adminNpcs.remove(p.id), () => `${p.name} deleted.`)}>
Delete
</button>
</div>
))}
</section>
)}
{form && (
<ProfileForm
form={form}
setForm={setForm}
data={data}
busy={busy}
error={error}
onSave={save}
onCancel={() => setForm(null)}
onDelete={form.id ? () => act(() => api.adminNpcs.remove(form.id), () => `${form.name} deleted. Its placements wait until a profile of that name returns.`) : null}
/>
)}
</div>
)
}
function Field({ label, hint, children }) {
return (
<label className="sans" style={{ display: 'grid', gap: 4, fontSize: '0.84rem' }}>
<span style={{ color: 'var(--head)' }}>{label}</span>
{children}
{hint && <span className="dim" style={{ fontSize: '0.74rem' }}>{hint}</span>}
</label>
)
}
function Num({ value, onChange, width = 110 }) {
return <input value={value} onChange={(e) => onChange(e.target.value)} inputMode="decimal" style={{ ...inputStyle, maxWidth: width }} />
}
function ProfileForm({ form, setForm, data, busy, error, onSave, onCancel, onDelete }) {
const servers = data.servers || []
const set = (key) => (value) => setForm((f) => ({ ...f, [key]: value }))
const toggleServer = (id) => setForm((f) => ({ ...f, servers: f.servers.includes(id) ? f.servers.filter((x) => x !== id) : [...f.servers, id] }))
const fieldset = { border: '1px solid var(--line-soft)', borderRadius: 8, padding: '10px 14px', display: 'grid', gap: 10 }
const legend = { color: 'var(--head)', fontSize: '0.86rem', padding: '0 6px' }
const row = { display: 'flex', gap: 14, flexWrap: 'wrap' }
return (
<form onSubmit={onSave} className="panel" style={{ padding: '16px 18px', display: 'grid', gap: 14 }}>
<h2 className="display" style={{ fontSize: '1.05rem', margin: 0, color: 'var(--head)' }}>{form.id ? `Edit ${form.name}` : 'A new profile'}</h2>
<Field label="Name" hint="RunicNPC’s name for it: 1 to 40 of a-z, 0-9, _ and -. It is what /rnpc place and an event step name.">
<input value={form.name} onChange={(e) => set('name')(e.target.value)} required maxLength={40} pattern="[a-z0-9_\-]{1,40}" style={{ ...inputStyle, maxWidth: 320 }} />
</Field>
<fieldset style={fieldset}>
<legend className="sans" style={legend}>For</legend>
<label className="sans" style={{ display: 'flex', gap: 8, fontSize: '0.84rem' }}>
<input type="checkbox" checked={form.allServers} onChange={(e) => set('allServers')(e.target.checked)} />
Every server, including servers added later
</label>
{!form.allServers &&
servers.map((s) => (
<label key={s.id} className="sans" style={{ display: 'flex', gap: 8, fontSize: '0.84rem' }}>
<input type="checkbox" checked={form.servers.includes(s.id)} onChange={() => toggleServer(s.id)} />
{s.name || s.id}
{!s.ready && <span className="dim">({s.absence})</span>}
</label>
))}
</fieldset>
<fieldset style={fieldset}>
<legend className="sans" style={legend}>Looks</legend>
<Field label="NPC names" hint="One per line. Each NPC is given one of these; the first is the profile’s name on the leaderboard.">
<textarea value={form.names} onChange={(e) => set('names')(e.target.value)} rows={3} style={{ ...inputStyle, maxWidth: 360 }} />
</Field>
<Field label="Kits" hint="Kits kits, separated by commas. One is picked for each NPC. Every server the profile is for must have each one.">
<input value={form.kits} onChange={(e) => set('kits')(e.target.value)} style={{ ...inputStyle, maxWidth: 480 }} />
</Field>
<div style={row}>
<Field label="Prefab">
<select value={form.prefab} onChange={(e) => set('prefab')(e.target.value)} style={inputStyle}>
{[...new Set([form.prefab, ...(data.prefabs || [])])].map((p) => <option key={p} value={p}>{p}</option>)}
</select>
</Field>
<Field label="Role">
<select value={form.role} onChange={(e) => set('role')(e.target.value)} style={inputStyle}>
<option value="roamer">Roamer: moves, chases and fights</option>
<option value="sentry">Sentry: stands still and shoots</option>
</select>
</Field>
</div>
</fieldset>
{form.role === 'roamer' && (
<fieldset style={fieldset}>
<legend className="sans" style={legend}>Moving</legend>
<div style={row}>
<Field label="How it moves" hint="wander, monument (Rust’s own paths there), or route:<name> for a route recorded in game. A placement may change it.">
<input value={form.moveMode} onChange={(e) => set('moveMode')(e.target.value)} list="rnpc-modes" style={{ ...inputStyle, maxWidth: 220 }} />
<datalist id="rnpc-modes"><option value="wander" /><option value="monument" /></datalist>
</Field>
<Field label="Wander radius (m)"><Num value={form.moveRadius} onChange={set('moveRadius')} /></Field>
<Field label="Sleeps with nobody within (m)" hint="0: never sleeps."><Num value={form.sleepDistance} onChange={set('sleepDistance')} /></Field>
</div>
</fieldset>
)}
<fieldset style={fieldset}>
<legend className="sans" style={legend}>Fighting</legend>
<div style={row}>
<Field label="Health"><Num value={form.health} onChange={set('health')} /></Field>
<Field label="Damage dealt ×" hint="1 is the weapon’s own."><Num value={form.damageDealt} onChange={set('damageDealt')} /></Field>
<Field label="Aim spread" hint="Rust’s scientists: 2."><Num value={form.aimCone} onChange={set('aimCone')} /></Field>
<Field label="Vision cone" hint="−1 to 1."><Num value={form.visionCone} onChange={set('visionCone')} /></Field>
</div>
<div style={row}>
<Field label="Damage taken × head"><Num value={form.head} onChange={set('head')} /></Field>
<Field label="× body"><Num value={form.body} onChange={set('body')} /></Field>
<Field label="× legs"><Num value={form.legs} onChange={set('legs')} /></Field>
</div>
<div style={row}>
<Field label="Notices players at (m)"><Num value={form.sense} onChange={set('sense')} /></Field>
<Field label="Forgets them at (m)"><Num value={form.loseTarget} onChange={set('loseTarget')} /></Field>
<Field label="Chases up to (m from home)" hint="0: no limit."><Num value={form.chase} onChange={set('chase')} /></Field>
<Field label="Shoots from (m)"><Num value={form.attack} onChange={set('attack')} /></Field>
</div>
<Field label="Health thresholds (%)" hint="An event phase can wait for these: 50 raises “below 50%” once. Separated by commas.">
<input value={form.thresholds} onChange={(e) => set('thresholds')(e.target.value)} style={{ ...inputStyle, maxWidth: 220 }} />
</Field>
</fieldset>
<Field label="Kills count" hint="How the leaderboard and chat titles count this profile’s kills.">
<select value={form.killsScope} onChange={(e) => set('killsScope')(e.target.value)} style={{ ...inputStyle, maxWidth: 520 }}>
{(data.killsScopes || ['server', 'name', 'profile']).map((s) => <option key={s} value={s}>{SCOPE_WORDS[s] || s}</option>)}
</select>
</Field>
{error && <p className="sans" style={{ color: 'var(--danger, #d98b84)', fontSize: '0.84rem', margin: 0 }}>{error}</p>}
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button type="submit" className="btn" disabled={busy}>{busy ? 'Saving…' : 'Save'}</button>
<button type="button" className="btn" onClick={onCancel} disabled={busy}>Cancel</button>
{onDelete && (
<button type="button" className="btn" style={{ marginLeft: 'auto' }} onClick={onDelete} disabled={busy}>Delete profile</button>
)}
</div>
</form>
)
}

View File

@@ -261,6 +261,37 @@ function Held({ accounts }) {
)
}
/**
* RunicNPC (runicnpc stage 4, D252): your kills of each NPC profile, this wipe,
* by server, across every Steam account you have linked. Nothing without a link.
*/
function NpcKills() {
const { data, loading, error } = useAsync(() => api.playerNpcKills.list(), [])
if (loading) return <Loading />
if (error) return <ErrorState error={error} />
const rows = (data && data.kills) || []
if (rows.length === 0) {
return <p className="sans dim" style={{ fontSize: '0.8rem', margin: 0 }}>None this wipe.</p>
}
const byServer = new Map()
for (const r of rows) byServer.set(r.server || r.serverId, [...(byServer.get(r.server || r.serverId) || []), r])
return (
<ul className="sans" style={{ listStyle: 'none', margin: 0, padding: 0, fontSize: '0.86rem', display: 'grid', gap: 6 }}>
{[...byServer.entries()].map(([serverId, list]) => (
<li key={serverId}>
<span className="dim">{serverId}: </span>
{list.map((k, i) => (
<span key={k.profile}>
{i > 0 && <span className="dim"> · </span>}
{k.label} <strong>{k.kills}</strong>
</span>
))}
</li>
))}
</ul>
)
}
export default function Account() {
// `useAsync` rather than this module's `usePolled`: nothing here changes unless
// the person looking at it changes it, and a page that re-asked every twenty
@@ -326,6 +357,9 @@ export default function Account() {
<div className="field-label" style={{ margin: '30px 0 12px' }}>What you can do in game</div>
{data && <Held accounts={links.length} />}
<div className="field-label" style={{ margin: '30px 0 12px' }}>NPCs you have killed</div>
{data && links.length > 0 ? <NpcKills /> : <p className="sans dim" style={{ fontSize: '0.8rem', margin: 0 }}>Link a Steam account to see them.</p>}
</div>
)
}