Admin: Rust NPC profiles (the form RunicNPC reads, per server, shared or fleet, each server's push state and refusals, replaced profiles with Restore) and Rust NPC placements (the live map: click to place, pins for every placement; edit, rename, respawn, remove). The live map takes a pick handler and pins, unchanged for the public page. Public: the leaderboard ranks by a profile's kills (D250), and opening a row shows that player's kills by profile (D252); the killfeed names a RunicNPC NPC by its own name. Player: your own kills by profile. Titles: a rule on a profile's kills picks the profile. npcs.test.js covers the profile checks, adoption, the push, the picker and verb, the triggers, kill crediting, titles and placements (481 server tests). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
290 lines
14 KiB
JavaScript
290 lines
14 KiB
JavaScript
// ── Admin · Rust · NPC placements (docs/runicnpc/PLAN.md stage 4) ─────────
|
||
//
|
||
// One server's RunicNPC placements. They live on the server (D222) — an admin's
|
||
// `/rnpc place` in game and this page edit the same list — so every read and
|
||
// write here is a live round trip, and a server whose game is off has nothing
|
||
// to show.
|
||
//
|
||
// A new placement is made by clicking the live map (D245): the server puts the
|
||
// point on the ground, checks it against the navmesh, and names it as in game
|
||
// (D246). A roof or a building top is placed in game. Every answer that adds
|
||
// NPCs carries the cost warning (D227).
|
||
|
||
import { useEffect, useMemo, useState } from 'react'
|
||
import { Link } from 'react-router-dom'
|
||
|
||
import { ErrorState, Loading, useAsync } from '../../core.js'
|
||
import MapView from '../../components/MapView.jsx'
|
||
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 blank = (profile = '') => ({ profile, count: '1', respawn: '300', respawnMode: 'each', move: '', radius: '' })
|
||
|
||
function formOf(p) {
|
||
const v = p.placement || {}
|
||
return {
|
||
profile: v.profile || '',
|
||
count: String(v.count || 1),
|
||
respawn: String(v.respawn || 300),
|
||
respawnMode: v.respawnMode || 'each',
|
||
move: (v.movement && v.movement.mode) || '',
|
||
radius: v.movement && v.movement.radius !== undefined ? String(v.movement.radius) : '',
|
||
}
|
||
}
|
||
|
||
function bodyOf(f) {
|
||
return {
|
||
profile: f.profile,
|
||
count: Number(f.count),
|
||
respawn: Number(f.respawn),
|
||
respawnMode: f.respawnMode,
|
||
...(f.move ? { movement: { mode: f.move, radius: f.radius === '' ? undefined : Number(f.radius) } } : { movement: null }),
|
||
}
|
||
}
|
||
|
||
export default function NpcPlacements() {
|
||
const { data: meta, error: metaError } = useAsync(() => api.adminNpcs.read(), [])
|
||
const ready = useMemo(() => ((meta && meta.servers) || []).filter((s) => s.ready), [meta])
|
||
const [serverId, setServerId] = useState('')
|
||
|
||
useEffect(() => {
|
||
if (!serverId && ready.length) setServerId(ready[0].id)
|
||
}, [ready, serverId])
|
||
|
||
if (metaError) return <ErrorState error={metaError} />
|
||
if (!meta) return <Loading />
|
||
|
||
const server = ready.find((s) => s.id === serverId)
|
||
const profiles = (meta.profiles || []).filter((p) => !p.replaced && (p.allServers || p.servers.includes(serverId)))
|
||
|
||
return (
|
||
<div style={{ maxWidth: 1100 }}>
|
||
<p className="sans dim" style={{ fontSize: '0.82rem', marginTop: 0 }}>
|
||
Where RunicNPC’s NPCs stand, and come back after they die. These live on the server: <code>/rnpc place</code> in
|
||
game changes the same list. Click the map to place NPCs there; the server puts them on the ground and names the
|
||
placement. A roof or the top of a building is placed in game. Profiles are on the{' '}
|
||
<Link to="/admin/rust/npcs">NPC profiles page</Link>.
|
||
</p>
|
||
|
||
{ready.length === 0 ? (
|
||
<section className="panel sans" style={{ padding: '14px 18px', fontSize: '0.84rem' }}>
|
||
No server can take placements from the site yet: each needs RunicNPC with API 3.{' '}
|
||
{(meta.servers || []).map((s) => `${s.name || s.id}: ${s.absence}`).join(' · ')}
|
||
</section>
|
||
) : (
|
||
<>
|
||
<label className="sans" style={{ display: 'flex', gap: 8, alignItems: 'baseline', fontSize: '0.86rem', marginBottom: 12 }}>
|
||
<span style={{ color: 'var(--head)' }}>Server</span>
|
||
<select value={serverId} onChange={(e) => setServerId(e.target.value)} style={inputStyle}>
|
||
{ready.map((s) => <option key={s.id} value={s.id}>{s.name || s.id}</option>)}
|
||
</select>
|
||
</label>
|
||
{server && <ServerPlacements key={server.id} server={server} profiles={profiles} />}
|
||
</>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function ServerPlacements({ server, profiles }) {
|
||
const [reloads, setReloads] = useState(0)
|
||
const { data, error: loadError } = useAsync(() => api.adminNpcs.placements(server.id), [server.id, reloads])
|
||
const [picked, setPicked] = useState(null)
|
||
const [form, setForm] = useState(blank(profiles[0] ? profiles[0].name : ''))
|
||
const [editing, setEditing] = useState(null)
|
||
const [renaming, setRenaming] = useState(null)
|
||
const [confirming, setConfirming] = useState(null)
|
||
const [busy, setBusy] = useState(false)
|
||
const [error, setError] = useState('')
|
||
const [notice, setNotice] = useState('')
|
||
|
||
const act = async (fn, done) => {
|
||
setBusy(true)
|
||
setError('')
|
||
setNotice('')
|
||
try {
|
||
const out = await fn()
|
||
setNotice(done(out))
|
||
setReloads((n) => n + 1)
|
||
return true
|
||
} catch (err) {
|
||
setError(err.message || 'That did not work.')
|
||
return false
|
||
} finally {
|
||
setBusy(false)
|
||
}
|
||
}
|
||
|
||
const placements = (data && data.placements) || []
|
||
const pins = useMemo(() => {
|
||
const out = placements
|
||
.filter((p) => p.placement && p.placement.position)
|
||
.map((p) => ({
|
||
x: p.placement.position.x,
|
||
z: p.placement.position.z,
|
||
label: `${p.id} · ${p.placement.count} × ${p.placement.profile}${p.waiting ? ` · waits: ${p.waiting}` : ''}`,
|
||
colour: p.waiting ? '#9e9e9e' : '#ffd54f',
|
||
}))
|
||
if (picked) out.push({ x: picked.x, z: picked.z, label: 'New placement', colour: '#00e5ff', ring: true })
|
||
return out
|
||
}, [placements, picked])
|
||
|
||
const place = async (e) => {
|
||
e.preventDefault()
|
||
const ok = await act(
|
||
() => api.adminNpcs.place(server.id, { ...bodyOf(form), position: { x: picked.x, z: picked.z } }),
|
||
(r) => `Placed ${r.id}${r.position ? ` at ${Math.round(r.position.x)}, ${Math.round(r.position.z)} (${Math.round(r.position.y)} m up)` : ''}.${r.built ? ' It is on something players built: if that is destroyed, its NPCs stand on the nearest navmesh.' : ''} ${r.cost || ''}`,
|
||
)
|
||
if (ok) setPicked(null)
|
||
}
|
||
|
||
return (
|
||
<div style={{ display: 'grid', gap: 16 }}>
|
||
{notice && <p className="sans" style={{ fontSize: '0.84rem', color: 'var(--head)', margin: 0 }}>{notice}</p>}
|
||
{error && <p className="sans" style={{ color: 'var(--danger, #d98b84)', fontSize: '0.84rem', margin: 0 }}>{error}</p>}
|
||
|
||
<MapView serverId={server.id} online={server.online !== false} onPick={(at) => { setError(''); setPicked(at) }} pins={pins} />
|
||
|
||
{picked && (
|
||
<form onSubmit={place} className="panel sans" style={{ padding: '14px 18px', display: 'grid', gap: 10, fontSize: '0.84rem' }}>
|
||
<strong style={{ color: 'var(--head)' }}>
|
||
New placement at {picked.x}, {picked.z}{picked.grid ? ` (${picked.grid})` : ''}
|
||
</strong>
|
||
{profiles.length === 0 ? (
|
||
<span className="dim">No NPC profile is on this server yet.</span>
|
||
) : (
|
||
<PlacementFields form={form} setForm={setForm} profiles={profiles} routes={(data && data.routes) || []} />
|
||
)}
|
||
<div style={{ display: 'flex', gap: 8 }}>
|
||
<button type="submit" className="btn" disabled={busy || profiles.length === 0}>{busy ? 'Placing…' : 'Place'}</button>
|
||
<button type="button" className="btn" disabled={busy} onClick={() => setPicked(null)}>Cancel</button>
|
||
</div>
|
||
</form>
|
||
)}
|
||
|
||
<section className="panel sans" style={{ padding: '14px 18px', fontSize: '0.84rem' }}>
|
||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10 }}>
|
||
<h2 className="display" style={{ fontSize: '1rem', margin: 0, color: 'var(--head)' }}>Placements</h2>
|
||
<button type="button" className="btn" style={{ marginLeft: 'auto' }} disabled={busy} onClick={() => setReloads((n) => n + 1)}>Refresh</button>
|
||
</div>
|
||
{loadError && <p style={{ color: 'var(--danger, #d98b84)' }}>{loadError.message}</p>}
|
||
{!data && !loadError && <Loading />}
|
||
{data && data.cost && <p className="dim" style={{ fontSize: '0.78rem' }}>{data.cost}</p>}
|
||
{data && placements.length === 0 && <p className="dim">None on this server.</p>}
|
||
{placements.map((p) => {
|
||
const v = p.placement || {}
|
||
const move = v.movement ? `${v.movement.mode}${v.movement.mode === 'wander' ? ` ${v.movement.radius} m` : ''}` : 'the profile’s movement'
|
||
return (
|
||
<div key={p.id} style={{ borderTop: '1px solid var(--line-soft)', padding: '8px 0' }}>
|
||
<div style={{ display: 'flex', gap: 10, alignItems: 'baseline', flexWrap: 'wrap' }}>
|
||
<strong style={{ color: 'var(--head)' }}>{p.id}</strong>
|
||
<span className="dim">
|
||
{v.count} × {v.profile} · {p.alive} alive · respawn {v.respawn} s, {v.respawnMode} · {move}
|
||
{v.position ? ` · ${Math.round(v.position.x)}, ${Math.round(v.position.z)}` : ''}
|
||
</span>
|
||
<span style={{ marginLeft: 'auto', display: 'flex', gap: 6 }}>
|
||
<button type="button" className="btn" disabled={busy} onClick={() => { setEditing(editing === p.id ? null : p.id); setForm(formOf(p)) }}>Edit</button>
|
||
<button type="button" className="btn" disabled={busy} onClick={() => setRenaming(renaming && renaming.id === p.id ? null : { id: p.id, to: p.id })}>Rename</button>
|
||
<button type="button" className="btn" disabled={busy} onClick={() => act(() => api.adminNpcs.respawnPlacement(server.id, p.id), (r) => `Respawning ${r.respawned} NPC(s) of ${p.id}.`)}>Respawn</button>
|
||
{confirming === p.id ? (
|
||
<button type="button" className="btn" disabled={busy} onClick={() => { setConfirming(null); act(() => api.adminNpcs.removePlacement(server.id, p.id), () => `Removed ${p.id} and its NPCs.`) }}>
|
||
Confirm remove
|
||
</button>
|
||
) : (
|
||
<button type="button" className="btn" disabled={busy} onClick={() => setConfirming(p.id)}>Remove</button>
|
||
)}
|
||
</span>
|
||
</div>
|
||
{p.waiting && <div style={{ color: 'var(--danger, #d98b84)' }}>Waits: {p.waiting}</div>}
|
||
{p.note && <div className="dim">{p.note}</div>}
|
||
{p.lastError && <div className="dim">Last spawn failed: {p.lastError}</div>}
|
||
{renaming && renaming.id === p.id && (
|
||
<form
|
||
onSubmit={(e) => { e.preventDefault(); act(() => api.adminNpcs.renamePlacement(server.id, p.id, renaming.to), () => `Renamed ${p.id} to ${renaming.to}.`).then((ok) => ok && setRenaming(null)) }}
|
||
style={{ display: 'flex', gap: 8, marginTop: 6 }}
|
||
>
|
||
<input value={renaming.to} onChange={(e) => setRenaming({ ...renaming, to: e.target.value })} pattern="[a-z0-9_\-]{1,40}" style={{ ...inputStyle, maxWidth: 240 }} />
|
||
<button type="submit" className="btn" disabled={busy}>Rename</button>
|
||
</form>
|
||
)}
|
||
{editing === p.id && (
|
||
<form
|
||
onSubmit={(e) => { e.preventDefault(); act(() => api.adminNpcs.setPlacement(server.id, p.id, bodyOf(form)), (r) => `Changed ${p.id}. ${r.cost || ''}`).then((ok) => ok && setEditing(null)) }}
|
||
style={{ display: 'grid', gap: 8, marginTop: 6 }}
|
||
>
|
||
<PlacementFields form={form} setForm={setForm} profiles={profiles} routes={(data && data.routes) || []} />
|
||
<div style={{ display: 'flex', gap: 8 }}>
|
||
<button type="submit" className="btn" disabled={busy}>Save</button>
|
||
<button type="button" className="btn" disabled={busy} onClick={() => setEditing(null)}>Cancel</button>
|
||
</div>
|
||
</form>
|
||
)}
|
||
</div>
|
||
)
|
||
})}
|
||
</section>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/** `/rnpc place`'s options (D242, D246): profile, count, respawn, each or group, movement and radius. */
|
||
function PlacementFields({ form, setForm, profiles, routes }) {
|
||
const set = (key) => (e) => setForm((f) => ({ ...f, [key]: e.target.value }))
|
||
const profile = profiles.find((p) => p.name === form.profile)
|
||
const sentry = profile && profile.body.role === 'sentry'
|
||
const label = { display: 'grid', gap: 4 }
|
||
return (
|
||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'end' }}>
|
||
<label style={label}>
|
||
<span style={{ color: 'var(--head)' }}>Profile</span>
|
||
<select value={form.profile} onChange={set('profile')} style={inputStyle} required>
|
||
{!profile && <option value={form.profile}>{form.profile || 'Pick one'}</option>}
|
||
{profiles.map((p) => <option key={p.id} value={p.name}>{p.label} ({p.name})</option>)}
|
||
</select>
|
||
</label>
|
||
<label style={label}>
|
||
<span style={{ color: 'var(--head)' }}>How many</span>
|
||
<input value={form.count} onChange={set('count')} inputMode="numeric" style={{ ...inputStyle, maxWidth: 70 }} />
|
||
</label>
|
||
<label style={label}>
|
||
<span style={{ color: 'var(--head)' }}>Respawn (s)</span>
|
||
<input value={form.respawn} onChange={set('respawn')} inputMode="numeric" style={{ ...inputStyle, maxWidth: 90 }} />
|
||
</label>
|
||
<label style={label}>
|
||
<span style={{ color: 'var(--head)' }}>Come back</span>
|
||
<select value={form.respawnMode} onChange={set('respawnMode')} style={inputStyle}>
|
||
<option value="each">each, after its own death</option>
|
||
<option value="group">together, once all are dead</option>
|
||
</select>
|
||
</label>
|
||
{!sentry && (
|
||
<>
|
||
<label style={label}>
|
||
<span style={{ color: 'var(--head)' }}>Movement</span>
|
||
<select value={form.move} onChange={set('move')} style={inputStyle}>
|
||
<option value="">the profile’s own</option>
|
||
<option value="wander">wander</option>
|
||
<option value="monument">monument</option>
|
||
{routes.map((r) => <option key={r} value={`route:${r}`}>route: {r}</option>)}
|
||
</select>
|
||
</label>
|
||
{form.move === 'wander' && (
|
||
<label style={label}>
|
||
<span style={{ color: 'var(--head)' }}>Radius (m)</span>
|
||
<input value={form.radius} onChange={set('radius')} inputMode="decimal" style={{ ...inputStyle, maxWidth: 80 }} />
|
||
</label>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|