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:
@@ -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" />
|
||||
|
||||
289
client/src/routes/admin/NpcPlacements.jsx
Normal file
289
client/src/routes/admin/NpcPlacements.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
385
client/src/routes/admin/NpcProfiles.jsx
Normal file
385
client/src/routes/admin/NpcProfiles.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user