// ── 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
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.
No servers are configured yet.
} {servers.map((s) => { const refused = Object.entries((s.sync && s.sync.refused) || {}) return ({notice}
} {error && !form &&{error}
}None yet. A server’s own profiles appear here after its first push.
)} {inUse.map((p) => ({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}` : ''}
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}
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) => (How factions treat each other. One row is both ways: bandits ↔ guards: hostile makes each hunt the other. A pair that is not here is neutral, and a faction is always allied to itself. scientists and{' '} animals are Rust’s own; a profile hostile to scientists fights them, and they only take cover.
{shown.length === 0 &&Empty: every pair is neutral.
} {shown.map((r, i) => (