// ── 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). // // Stage 5: a profile's side (its faction, its own exceptions, its alert radius), // turrets, the two PVE checkboxes and the kit's extras; and the site's one // faction table, pushed to every server with the profiles (D254, D268). 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 TURRET_WORDS = { default: 'As Rust’s scientists: player turrets shoot it, Outpost and Bandit Camp sentries do not', ignore: 'No turret shoots it', always: 'Every turret shoots it, Outpost’s and Bandit Camp’s sentries too', } const KIT_WORDS = { heal: 'Heals with syringes, medkits or bandages in a lull', grenades: 'Throws grenades at 5 to 20 m', melee: 'Switches to a melee weapon up close', rockets: 'Fires rockets at 15 to 80 m', flamethrower: 'Switches to a flamethrower within 7 m', } /** A profile's own exceptions as lines of `faction: relation`, and back. */ const relationsText = (r) => Object.entries(r || {}).map(([k, v]) => `${k}: ${v}`).join('\n') function relationsFrom(text) { const out = {} for (const line of String(text || '').split('\n')) { const at = line.lastIndexOf(':') if (at <= 0) continue const key = line.slice(0, at).trim() const value = line.slice(at + 1).trim() if (key && value) out[key] = value } return out } const list = (text) => String(text || '').split(/[\n,]+/).map((s) => s.trim()).filter(Boolean) const lines = (text) => String(text || '').split('\n').map((s) => s.trim()).filter(Boolean) const str = (v) => (v === undefined || v === null ? '' : String(v)) /** An optional number as typed: blank is "not set". */ const opt = (v) => (String(v).trim() === '' ? undefined : v) /** A boss phase as the form edits it: `at` in per cent, every other part optional (D275). */ function phaseForm(p = {}) { const r = p.ranges || {} return { at: p.at === undefined ? '' : str(Math.round(p.at * 100)), damageDealt: str(p.damageDealt), aimCone: str(p.aimCone), speed: str(p.speed), kit: str(p.kit), addsProfile: str(p.adds && p.adds.profile), addsCount: str(p.adds && p.adds.count), line: str(p.line), sense: str(r.sense), loseTarget: str(r.loseTarget), chase: str(r.chase), attack: str(r.attack), } } function phaseBody(p) { const out = { at: Number(p.at) / 100 } if (opt(p.damageDealt) !== undefined) out.damageDealt = p.damageDealt if (opt(p.aimCone) !== undefined) out.aimCone = p.aimCone if (opt(p.speed) !== undefined) out.speed = p.speed if (p.kit.trim()) out.kit = p.kit.trim() if (p.addsProfile.trim()) out.adds = { profile: p.addsProfile.trim(), count: p.addsCount } if (p.line.trim()) out.line = p.line.trim() if ([p.sense, p.loseTarget, p.chase, p.attack].some((v) => String(v).trim() !== '')) { out.ranges = { sense: p.sense, loseTarget: p.loseTarget, chase: p.chase, attack: p.attack } } return out } 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(', '), faction: str(b.faction), relations: relationsText(b.relations), alertRadius: str(b.alertRadius), turrets: b.turrets || 'default', hurtByPlayers: b.hurtByPlayers !== false, hurtsPlayers: b.hurtsPlayers !== false, kitUse: { ...(defaults.kitUse || {}), ...(b.kitUse || {}) }, // Stage 6 (D273–D278). bossOn: Boolean(b.boss), barDistance: str(b.boss ? b.boss.barDistance : 100), announce: (b.boss && b.boss.announce) || 'chat', spawnLine: str(b.boss && b.boss.spawnLine), deathLine: str(b.boss && b.boss.deathLine), phases: ((b.boss && b.boss.phases) || []).map(phaseForm), // Stage 7 (D290–D301). lootOn: Boolean(b.loot), lootStart: (b.loot && b.loot.start) || 'rust', lootAlways: ((b.loot && b.loot.always) || []).map((r) => ({ item: str(r.item), min: str(r.min), max: str(r.max), chance: str(r.chance === undefined ? '' : Math.round(r.chance * 100)) })), lootPick: str(b.loot && b.loot.pool ? b.loot.pool.pick : 0), lootNothing: str(b.loot && b.loot.pool ? b.loot.pool.nothing : 0), lootPool: ((b.loot && b.loot.pool && b.loot.pool.rows) || []).map((r) => ({ item: str(r.item), min: str(r.min), max: str(r.max), weight: str(r.weight) })), lootCrate: (b.loot && b.loot.crate) || '', lootHackMinutes: str(b.loot ? Math.round((b.loot.hackSeconds || 900) / 6) / 10 : 15), lootCorpse: !b.loot || b.loot.corpseSeconds === null || b.loot.corpseSeconds === undefined ? 'rust' : b.loot.corpseSeconds === 0 ? 'none' : 'seconds', lootCorpseSeconds: str(b.loot && b.loot.corpseSeconds ? b.loot.corpseSeconds : 120), useMode: (b.use && b.use.mode) || 'chat', useLines: ((b.use && b.use.lines) || []).join('\n'), useTitle: str(b.use && b.use.title), useText: str(b.use && b.use.text), } } /** 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), faction: f.faction.trim() || null, relations: relationsFrom(f.relations), alertRadius: f.alertRadius, turrets: f.turrets, hurtByPlayers: f.hurtByPlayers, hurtsPlayers: f.hurtsPlayers, kitUse: f.kitUse, boss: f.bossOn && f.role !== 'passive' ? { barDistance: f.barDistance, announce: f.announce, spawnLine: f.spawnLine, deathLine: f.deathLine, phases: f.phases.map(phaseBody) } : null, use: f.role === 'passive' ? { mode: f.useMode, lines: lines(f.useLines), title: f.useTitle, text: f.useText } : null, loot: f.lootOn ? { start: f.lootStart, always: f.lootAlways.filter((r) => r.item.trim()).map((r) => ({ item: r.item.trim(), min: r.min, max: r.max || r.min, chance: r.chance === '' ? 1 : Number(r.chance) / 100 })), pool: { pick: f.lootPick, nothing: f.lootNothing, rows: f.lootPool.filter((r) => r.item.trim()).map((r) => ({ item: r.item.trim(), min: r.min, max: r.max || r.min, weight: r.weight })) }, crate: f.lootCrate || null, hackSeconds: Math.round(Number(f.lootHackMinutes) * 60), corpseSeconds: f.lootCorpse === 'rust' ? null : f.lootCorpse === 'none' ? 0 : f.lootCorpseSeconds, } : null, }, } } /** A role change: a passive NPC starts unhurtable and standing (D279, D288); leaving passive, it walks again. */ function withRole(f, role) { if (role === 'passive' && f.role !== 'passive') return { ...f, role, hurtByPlayers: false, moveMode: 'stand' } if (role !== 'passive' && f.role === 'passive') return { ...f, role, hurtByPlayers: true, moveMode: f.moveMode === 'stand' ? 'wander' : f.moveMode } return { ...f, role } } /** 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.boss ? ' · boss' : ''} · {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 }} /> } const CRATE_WORDS = { wooden: 'A wooden box (6 slots)', military: 'A military crate (6 slots)', elite: 'An elite crate (12 slots)', locked: 'A locked crate players hack (24 slots)', } /** * Loot (D290–D301): what the corpse starts with, a table of rows that always * roll and a pool capped at "pick", where the table goes, and how long the * corpse stays. A server with no site edits the same block in RunicNPC's file. */ function LootSection({ form, setForm, data, fieldset, legend, row }) { const set = (key) => (value) => setForm((f) => ({ ...f, [key]: value })) const setRow = (list, i, key, value) => setForm((f) => ({ ...f, [list]: f[list].map((r, j) => (j === i ? { ...r, [key]: value } : r)) })) const addRow = (list, blank) => setForm((f) => ({ ...f, [list]: [...f[list], blank] })) const dropRow = (list, i) => setForm((f) => ({ ...f, [list]: f[list].filter((_, j) => j !== i) })) const cell = (list, i, key, width = 90) => ( setRow(list, i, key, e.target.value)} style={{ ...inputStyle, width, maxWidth: '100%' }} /> ) const head = { color: 'var(--head)', fontSize: '0.78rem' } return (
Loot {form.lootOn && ( <>
{form.lootCorpse === 'seconds' && }
Always rolled

Each row rolls on its own, with its chance. Item names are Rust’s short names: scrap, rifle.ak, explosive.timed.

{form.lootAlways.map((r, i) => (
{cell('lootAlways', i, 'item', 200)} {cell('lootAlways', i, 'min', 70)} {cell('lootAlways', i, 'max', 70)} {cell('lootAlways', i, 'chance', 80)}
))}
The pool

At most “picks” rows are drawn, by weight, and never the same row twice. “Nothing” is the weight of drawing none, so a kill can never drop more than the picks however lucky: the fair cap on rare items.

{form.lootPool.map((r, i) => (
{cell('lootPool', i, 'item', 200)} {cell('lootPool', i, 'min', 70)} {cell('lootPool', i, 'max', 70)} {cell('lootPool', i, 'weight', 80)}
))}
{form.lootCrate === 'locked' && ( )}
)}
) } /** * A boss's phases (D275): each at a health percentage, each part optional. The * adds' profile is offered from the site's profiles that are not bosses. */ function PhaseList({ form, setForm, profiles }) { const setPhase = (i, key, value) => setForm((f) => ({ ...f, phases: f.phases.map((p, j) => (j === i ? { ...p, [key]: value } : p)) })) const remove = (i) => setForm((f) => ({ ...f, phases: f.phases.filter((_, j) => j !== i) })) const add = () => setForm((f) => ({ ...f, phases: [...f.phases, phaseForm()] })) const addable = [...new Set(profiles.filter((p) => !p.replaced && !(p.body && p.body.boss) && p.name !== form.name).map((p) => p.name))].sort() const row = { display: 'flex', gap: 12, flexWrap: 'wrap' } return (
Phases

When its health falls to a phase’s percentage, the phase’s changes apply from then on. Leave a part blank to keep it as it was. A phase’s line is said only to the players near it and those who have hurt it.

{form.phases.map((p, i) => (
setPhase(i, 'at', v)} width={80} /> setPhase(i, 'damageDealt', v)} width={80} /> setPhase(i, 'aimCone', v)} width={80} /> setPhase(i, 'speed', v)} width={80} /> setPhase(i, 'kit', e.target.value)} style={{ ...inputStyle, maxWidth: 160 }} />
{p.addsProfile && setPhase(i, 'addsCount', v)} width={60} />} setPhase(i, 'sense', v)} width={70} /> setPhase(i, 'loseTarget', v)} width={70} /> setPhase(i, 'chase', v)} width={70} /> setPhase(i, 'attack', v)} width={70} />
setPhase(i, 'line', e.target.value)} maxLength={256} style={{ ...inputStyle, width: 420, maxWidth: '100%' }} />
))}
) } 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