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

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