The profile form's boss box (D273-D277): the bar's distance, where its lines are said, the spawn and death lines, and phases at health percentages, each with optional damage, aim, speed, ranges, a kit swap, adds of another profile and a line. The passive role (D278, D279, D288): press E answers with a chat line or a window, it moves as its profile says (now including `stand`), and it starts unhurtable. The site checks both as RunicNPC does, in its words; a phase's kit is checked on each server like the profile's own, and its adds must name a site profile that is not a boss. Public triggers rust.boss.spawned, rust.boss.phase and rust.boss.killed (D277) from the bridge's new frames. In the feed, a boss appearing is public; its death names the killer, so it sits behind the presence setting; its phases are staff's, as the game tells them only to players near it (D286). engagement-triggers.json regenerated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
722 lines
38 KiB
JavaScript
722 lines
38 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).
|
||
//
|
||
// 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),
|
||
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,
|
||
},
|
||
}
|
||
}
|
||
|
||
/** 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 }} />
|
||
}
|
||
|
||
/**
|
||
* 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>
|
||
)}
|
||
|
||
<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>
|
||
)
|
||
}
|