// ── 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 if (!data) return 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 (

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{' '} placements page, in game with /rnpc place, or by an event’s Place NPCs step.

Servers

{servers.length === 0 &&

No servers are configured yet.

} {servers.map((s) => { const refused = Object.entries((s.sync && s.sync.refused) || {}) return (
{s.name || s.id} {serverLine(s)} {s.ready && ( )}
{refused.length > 0 && (
RunicNPC refused: {refused.map(([n, why]) => `${n} (${why})`).join('; ')}
)}
) })}
{notice &&

{notice}

} {error && !form &&

{error}

}

Profiles

{inUse.length === 0 && (

None yet. A server’s own profiles appear here after its first push.

)} {inUse.map((p) => (
{p.label} {p.name} {p.allServers ? 'every server' : p.servers.map((id) => nameOf.get(id) || id).join(', ')} {p.adoptedFrom ? ` · adopted from ${nameOf.get(p.adoptedFrom) || p.adoptedFrom}` : ''}
{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'}
))}
{replaced.length > 0 && (

Replaced

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.

{replaced.map((p) => (
{p.name} from {nameOf.get(p.adoptedFrom) || p.adoptedFrom}
))}
)} {form && ( 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} /> )}
) } function Field({ label, hint, children }) { return ( ) } function Num({ value, onChange, width = 110 }) { return 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.id ? `Edit ${form.name}` : 'A new profile'}

set('name')(e.target.value)} required maxLength={40} pattern="[a-z0-9_\-]{1,40}" style={{ ...inputStyle, maxWidth: 320 }} />
For {!form.allServers && servers.map((s) => ( ))}
Looks