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
386 lines
19 KiB
JavaScript
386 lines
19 KiB
JavaScript
// ── 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>
|
||
)
|
||
}
|