Files
Module-Rust/client/src/routes/admin/NpcProfiles.jsx
wtclaude 33c5178ef9 feat(rust): loot on the NPC profile form, and the Place NPCs step's "drop loot" switch (runicnpc stage 7)
The profile form's Loot section (D290-D301):

- what the corpse starts with (Rust's scientist loot, the kit, or
  nothing);
- rows that always roll, each with its chance;
- a pool capped at its picks, with a "nothing" weight;
- which crate the table goes into, and a locked crate's hack time;
- how long the corpse stays.

The site checks the block as RunicNPC does, in its words. Whether an
item exists on a server is RunicNPC's to say on the push.

The Place NPCs step's dropLoot switch (D296), on by default and sent
only when off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-06 00:32:24 -05:00

846 lines
46 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── 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 <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.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'}
</span>
</div>
))}
</section>
<FactionTable data={data} busy={busy} act={act} />
{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 }} />
}
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) => (
<input value={form[list][i][key]} onChange={(e) => setRow(list, i, key, e.target.value)} style={{ ...inputStyle, width, maxWidth: '100%' }} />
)
const head = { color: 'var(--head)', fontSize: '0.78rem' }
return (
<fieldset style={fieldset}>
<legend className="sans" style={legend}>Loot</legend>
<label className="sans" style={{ display: 'flex', gap: 8, fontSize: '0.84rem' }}>
<input type="checkbox" checked={form.lootOn} onChange={(e) => set('lootOn')(e.target.checked)} />
Its own loot <span className="dim">(off: Rust’s scientist loot, as any scientist drops)</span>
</label>
{form.lootOn && (
<>
<div style={row}>
<Field label="The corpse starts with">
<select value={form.lootStart} onChange={(e) => set('lootStart')(e.target.value)} style={inputStyle}>
<option value="rust">Rust’s scientist loot, and the clothes it wore</option>
<option value="kit">What it carried: its kit, and the clothes it wore</option>
<option value="none">Nothing at all, not even its clothes</option>
</select>
</Field>
<Field label="The corpse stays">
<select value={form.lootCorpse} onChange={(e) => set('lootCorpse')(e.target.value)} style={inputStyle}>
<option value="rust">As long as Rust’s own (10 minutes)</option>
<option value="seconds">For a number of seconds</option>
<option value="none">Not at all: no corpse</option>
</select>
</Field>
{form.lootCorpse === 'seconds' && <Field label="Seconds"><Num value={form.lootCorpseSeconds} onChange={set('lootCorpseSeconds')} /></Field>}
</div>
<span className="sans" style={{ color: 'var(--head)', fontSize: '0.84rem' }}>Always rolled</span>
<p className="sans dim" style={{ fontSize: '0.76rem', margin: 0 }}>Each row rolls on its own, with its chance. Item names are Rust’s short names: scrap, rifle.ak, explosive.timed.</p>
{form.lootAlways.map((r, i) => (
<div key={i} style={{ ...row, alignItems: 'end' }}>
<Field label={i === 0 ? 'Item' : ''}>{cell('lootAlways', i, 'item', 200)}</Field>
<Field label={i === 0 ? 'From' : ''}>{cell('lootAlways', i, 'min', 70)}</Field>
<Field label={i === 0 ? 'To' : ''}>{cell('lootAlways', i, 'max', 70)}</Field>
<Field label={i === 0 ? 'Chance (%)' : ''}>{cell('lootAlways', i, 'chance', 80)}</Field>
<button type="button" className="btn" onClick={() => dropRow('lootAlways', i)}>Remove</button>
</div>
))}
<div><button type="button" className="btn" onClick={() => addRow('lootAlways', { item: '', min: '1', max: '1', chance: '100' })}>Add a row</button></div>
<span className="sans" style={{ color: 'var(--head)', fontSize: '0.84rem' }}>The pool</span>
<p className="sans dim" style={{ fontSize: '0.76rem', margin: 0 }}>
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.
</p>
<div style={row}>
<Field label="Picks"><Num value={form.lootPick} onChange={set('lootPick')} width={70} /></Field>
<Field label="Nothing (weight)"><Num value={form.lootNothing} onChange={set('lootNothing')} width={90} /></Field>
</div>
{form.lootPool.map((r, i) => (
<div key={i} style={{ ...row, alignItems: 'end' }}>
<Field label={i === 0 ? 'Item' : ''}>{cell('lootPool', i, 'item', 200)}</Field>
<Field label={i === 0 ? 'From' : ''}>{cell('lootPool', i, 'min', 70)}</Field>
<Field label={i === 0 ? 'To' : ''}>{cell('lootPool', i, 'max', 70)}</Field>
<Field label={i === 0 ? 'Weight' : ''}>{cell('lootPool', i, 'weight', 80)}</Field>
<button type="button" className="btn" onClick={() => dropRow('lootPool', i)}>Remove</button>
</div>
))}
<div><button type="button" className="btn" onClick={() => addRow('lootPool', { item: '', min: '1', max: '1', weight: '10' })}>Add a pool row</button></div>
<div style={row}>
<Field label="The table goes into" hint="A crate stays until it is emptied, as Rust’s own do.">
<select value={form.lootCrate} onChange={(e) => set('lootCrate')(e.target.value)} style={inputStyle}>
<option value="">The corpse</option>
{(data.lootCrates || Object.keys(CRATE_WORDS)).map((c) => <option key={c} value={c}>{CRATE_WORDS[c] || c}</option>)}
</select>
</Field>
{form.lootCrate === 'locked' && (
<Field label="Hack time (minutes)" hint="Rust’s own is 15. It counts once a player starts the hack.">
<Num value={form.lootHackMinutes} onChange={set('lootHackMinutes')} width={90} />
</Field>
)}
</div>
</>
)}
</fieldset>
)
}
/**
* 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 (
<div style={{ display: 'grid', gap: 10 }}>
<span className="sans" style={{ color: 'var(--head)', fontSize: '0.84rem' }}>Phases</span>
<p className="sans dim" style={{ fontSize: '0.76rem', margin: 0 }}>
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.
</p>
{form.phases.map((p, i) => (
<div key={i} style={{ border: '1px solid var(--line-soft)', borderRadius: 6, padding: '8px 10px', display: 'grid', gap: 8 }}>
<div style={row}>
<Field label="At health (%)"><Num value={p.at} onChange={(v) => setPhase(i, 'at', v)} width={80} /></Field>
<Field label="Damage dealt ×"><Num value={p.damageDealt} onChange={(v) => setPhase(i, 'damageDealt', v)} width={80} /></Field>
<Field label="Aim spread"><Num value={p.aimCone} onChange={(v) => setPhase(i, 'aimCone', v)} width={80} /></Field>
<Field label="Speed ×" hint="1.3: a third faster."><Num value={p.speed} onChange={(v) => setPhase(i, 'speed', v)} width={80} /></Field>
<Field label="Swaps to kit"><input value={p.kit} onChange={(e) => setPhase(i, 'kit', e.target.value)} style={{ ...inputStyle, maxWidth: 160 }} /></Field>
</div>
<div style={row}>
<Field label="Summons (profile)">
<select value={p.addsProfile} onChange={(e) => setPhase(i, 'addsProfile', e.target.value)} style={inputStyle}>
<option value="">nobody</option>
{[...new Set([p.addsProfile, ...addable].filter(Boolean))].map((n) => <option key={n} value={n}>{n}</option>)}
</select>
</Field>
{p.addsProfile && <Field label="How many" hint="1 to 20."><Num value={p.addsCount} onChange={(v) => setPhase(i, 'addsCount', v)} width={60} /></Field>}
<Field label="Notices at (m)"><Num value={p.sense} onChange={(v) => setPhase(i, 'sense', v)} width={70} /></Field>
<Field label="Forgets at"><Num value={p.loseTarget} onChange={(v) => setPhase(i, 'loseTarget', v)} width={70} /></Field>
<Field label="Chases to"><Num value={p.chase} onChange={(v) => setPhase(i, 'chase', v)} width={70} /></Field>
<Field label="Shoots from"><Num value={p.attack} onChange={(v) => setPhase(i, 'attack', v)} width={70} /></Field>
</div>
<div style={{ ...row, alignItems: 'end' }}>
<Field label="Line">
<input value={p.line} onChange={(e) => setPhase(i, 'line', e.target.value)} maxLength={256} style={{ ...inputStyle, width: 420, maxWidth: '100%' }} />
</Field>
<button type="button" className="btn" onClick={() => remove(i)}>Remove phase</button>
</div>
</div>
))}
<div>
<button type="button" className="btn" onClick={add}>Add a phase</button>
</div>
</div>
)
}
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) => setForm((f) => withRole(f, e.target.value))} style={inputStyle}>
<option value="roamer">Roamer: moves, chases and fights</option>
<option value="sentry">Sentry: stands still and shoots</option>
<option value="guard">Guard: holds its spot, chases within its leash, walks back</option>
<option value="passive">Passive: never fights; players press E to talk to it</option>
</select>
</Field>
</div>
</fieldset>
{form.role !== 'sentry' && (
<fieldset style={fieldset}>
<legend className="sans" style={legend}>Moving</legend>
<div style={row}>
{(form.role === 'roamer' || form.role === 'passive') && (
<>
<Field
label="How it moves"
hint={`wander, monument (Rust’s own paths there), or route:<name> for a route recorded in game${form.role === 'passive' ? ', or stand: it stays where it is placed and turns to whoever talks to it' : ''}. 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">
{form.role === 'passive' && <option value="stand" />}
<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>
{form.role === 'passive' && (
<fieldset style={fieldset}>
<legend className="sans" style={legend}>Press E</legend>
<p className="sans dim" style={{ fontSize: '0.76rem', margin: 0 }}>
What a player gets for pressing E while looking at it, from up to 3 m. It never fights, whatever happens to it.{' '}
<code>{'{player}'}</code> is the player’s name.
</p>
<Field label="Answer">
<select value={form.useMode} onChange={(e) => set('useMode')(e.target.value)} style={{ ...inputStyle, maxWidth: 420 }}>
<option value="chat">A line in that player’s chat, picked at random from the lines below</option>
<option value="window">A window with a title, the text and a Close button</option>
</select>
</Field>
{form.useMode === 'chat' ? (
<Field label="Lines" hint="One per line, up to 256 characters each.">
<textarea value={form.useLines} onChange={(e) => set('useLines')(e.target.value)} rows={4} style={{ ...inputStyle, maxWidth: 560 }} />
</Field>
) : (
<>
<Field label="Title" hint="Blank: the NPC’s name.">
<input value={form.useTitle} onChange={(e) => set('useTitle')(e.target.value)} maxLength={80} style={{ ...inputStyle, maxWidth: 420 }} />
</Field>
<Field label="Text" hint="Up to 2,000 characters.">
<textarea value={form.useText} onChange={(e) => set('useText')(e.target.value)} rows={6} maxLength={2000} style={{ ...inputStyle, maxWidth: 560 }} />
</Field>
</>
)}
</fieldset>
)}
{form.role !== 'passive' && (
<fieldset style={fieldset}>
<legend className="sans" style={legend}>Boss</legend>
<label className="sans" style={{ display: 'flex', gap: 8, fontSize: '0.84rem' }}>
<input type="checkbox" checked={form.bossOn} onChange={(e) => set('bossOn')(e.target.checked)} />
It is a boss <span className="dim">(a health bar on screen, phases, and lines said in game; it still moves and fights as its role does)</span>
</label>
{form.bossOn && (
<>
<div style={row}>
<Field label="Bar shown within (m)" hint="And to everyone who has hurt it, wherever they are.">
<Num value={form.barDistance} onChange={set('barDistance')} />
</Field>
<Field label="Lines said in" hint="A popup needs PopupNotifications on the server; chat is used where it is missing.">
<select value={form.announce} onChange={(e) => set('announce')(e.target.value)} style={inputStyle}>
{(data.announces || ['chat', 'popup']).map((a) => <option key={a} value={a}>{a}</option>)}
</select>
</Field>
</div>
<Field label="When it spawns, to everyone" hint="Blank says nothing. {name} is its name.">
<input value={form.spawnLine} onChange={(e) => set('spawnLine')(e.target.value)} maxLength={256} style={{ ...inputStyle, maxWidth: 560 }} />
</Field>
<Field label="When it dies, to everyone" hint="{killer} is the player who landed the killing blow.">
<input value={form.deathLine} onChange={(e) => set('deathLine')(e.target.value)} maxLength={256} style={{ ...inputStyle, maxWidth: 560 }} />
</Field>
<PhaseList form={form} setForm={setForm} profiles={data.profiles || []} />
</>
)}
</fieldset>
)}
<LootSection form={form} setForm={setForm} data={data} fieldset={fieldset} legend={legend} row={row} />
<fieldset style={fieldset}>
<legend className="sans" style={legend}>Sides</legend>
<p className="sans dim" style={{ fontSize: '0.76rem', margin: 0 }}>
With none of this set, it fights players only. Fighting Rust’s scientists, animals or other profiles is opted
into here or in the faction table, and costs about what fighting players does. It never walks into a monument
to look for scientists: it fights only what comes within its chase range of its spot.
</p>
<div style={row}>
<Field label="Faction" hint="Profiles of one faction are allies. Empty: its allies are its own profile’s NPCs.">
<input value={form.faction} onChange={(e) => set('faction')(e.target.value)} list="rnpc-factions" maxLength={40} style={{ ...inputStyle, maxWidth: 220 }} />
<datalist id="rnpc-factions">{(data.factionNames || []).map((n) => <option key={n} value={n} />)}</datalist>
</Field>
<Field label="Alert radius (m)" hint="Allies this close join a fight one of them is in, seen or not. 0: off.">
<Num value={form.alertRadius} onChange={set('alertRadius')} />
</Field>
</div>
<Field label="Its own exceptions" hint="One per line, winning over the faction table: scientists: hostile, animals: hostile, bandits: allied, profile:warden: hostile.">
<textarea value={form.relations} onChange={(e) => set('relations')(e.target.value)} rows={3} style={{ ...inputStyle, maxWidth: 420 }} />
</Field>
<Field label="Turrets">
<select value={form.turrets} onChange={(e) => set('turrets')(e.target.value)} style={{ ...inputStyle, maxWidth: 560 }}>
{(data.turrets || ['default', 'ignore', 'always']).map((t) => <option key={t} value={t}>{TURRET_WORDS[t] || t}</option>)}
</select>
</Field>
<label className="sans" style={{ display: 'flex', gap: 8, fontSize: '0.84rem' }}>
<input type="checkbox" checked={form.hurtByPlayers} onChange={(e) => set('hurtByPlayers')(e.target.checked)} />
Players can hurt it <span className="dim">(off: it cannot be killed by players, on any server)</span>
</label>
<label className="sans" style={{ display: 'flex', gap: 8, fontSize: '0.84rem' }}>
<input type="checkbox" checked={form.hurtsPlayers} onChange={(e) => set('hurtsPlayers')(e.target.checked)} />
It can hurt players <span className="dim">(off: it still fights NPCs and turrets)</span>
</label>
</fieldset>
<fieldset style={fieldset}>
<legend className="sans" style={legend}>The kit’s extras</legend>
<p className="sans dim" style={{ fontSize: '0.76rem', margin: 0 }}>
The kit’s main weapon is always used, and never runs out. Each extra is used only if ticked, and comes out of
the NPC’s inventory, so its loot is what is left.
</p>
{(data.kitUses || Object.keys(KIT_WORDS)).map((k) => (
<label key={k} className="sans" style={{ display: 'flex', gap: 8, fontSize: '0.84rem' }}>
<input type="checkbox" checked={Boolean(form.kitUse[k])} onChange={(e) => setForm((f) => ({ ...f, kitUse: { ...f.kitUse, [k]: e.target.checked } }))} />
{KIT_WORDS[k] || k}
</label>
))}
</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>
)
}
/**
* The site's faction table (stage 5, D254): one row per pair, both ways (D268).
* A pair not listed is neutral; Rust's scientists and animals are the two
* built-in factions. Pushed to every server with its profiles.
*/
function FactionTable({ data, busy, act }) {
const [rows, setRows] = useState(null)
const shown = rows || (data.factions || []).map((f) => ({ ...f }))
const names = [...new Set([...(data.factionNames || []), ...(data.builtInFactions || ['scientists', 'animals'])])]
const change = (i, key, value) => setRows(shown.map((r, j) => (j === i ? { ...r, [key]: value } : r)))
const save = () =>
act(
() => api.adminNpcs.setFactions(shown.filter((r) => r.a && r.b)),
(out) => {
setRows(null)
return `Faction table saved: ${out.factions.length} pair(s). It reaches each server on the next push.`
},
)
return (
<section className="panel" style={{ padding: '14px 18px', marginBottom: 18 }}>
<h2 className="display" style={{ fontSize: '1rem', margin: '0 0 6px', color: 'var(--head)' }}>Faction table</h2>
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '0 0 8px' }}>
How factions treat each other. One row is both ways: <em>bandits ↔ guards: hostile</em> makes each hunt the other.
A pair that is not here is neutral, and a faction is always allied to itself. <em>scientists</em> and{' '}
<em>animals</em> are Rust’s own; a profile hostile to scientists fights them, and they only take cover.
</p>
<datalist id="rnpc-table-factions">{names.map((n) => <option key={n} value={n} />)}</datalist>
{shown.length === 0 && <p className="sans dim" style={{ fontSize: '0.8rem', margin: '0 0 8px' }}>Empty: every pair is neutral.</p>}
{shown.map((r, i) => (
<div key={i} className="sans" style={{ display: 'flex', gap: 8, alignItems: 'center', padding: '4px 0', fontSize: '0.84rem' }}>
<input value={r.a} onChange={(e) => change(i, 'a', e.target.value)} list="rnpc-table-factions" maxLength={40} style={{ ...inputStyle, maxWidth: 180 }} />
<span className="dim">↔</span>
<input value={r.b} onChange={(e) => change(i, 'b', e.target.value)} list="rnpc-table-factions" maxLength={40} style={{ ...inputStyle, maxWidth: 180 }} />
<select value={r.relation} onChange={(e) => change(i, 'relation', e.target.value)} style={inputStyle}>
<option value="hostile">hostile</option>
<option value="neutral">neutral</option>
<option value="allied">allied</option>
</select>
<button type="button" className="btn" onClick={() => setRows(shown.filter((_, j) => j !== i))} disabled={busy}>Remove</button>
</div>
))}
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<button type="button" className="btn" onClick={() => setRows([...shown, { a: '', b: '', relation: 'hostile' }])} disabled={busy}>Add a pair</button>
<button type="button" className="btn" onClick={save} disabled={busy || rows === null}>Save the table</button>
{rows !== null && <button type="button" className="btn" onClick={() => setRows(null)} disabled={busy}>Discard changes</button>}
</div>
</section>
)
}