// ── 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 if (!meta) return const server = ready.find((s) => s.id === serverId) const profiles = (meta.profiles || []).filter((p) => !p.replaced && (p.allServers || p.servers.includes(serverId))) return (

Where RunicNPC’s NPCs stand, and come back after they die. These live on the server: /rnpc place 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{' '} NPC profiles page.

{ready.length === 0 ? (
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(' · ')}
) : ( <> {server && } )}
) } 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 (
{notice &&

{notice}

} {error &&

{error}

} { setError(''); setPicked(at) }} pins={pins} /> {picked && (
New placement at {picked.x}, {picked.z}{picked.grid ? ` (${picked.grid})` : ''} {profiles.length === 0 ? ( No NPC profile is on this server yet. ) : ( )}
)}

Placements

{loadError &&

{loadError.message}

} {!data && !loadError && } {data && data.cost &&

{data.cost}

} {data && placements.length === 0 &&

None on this server.

} {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 (
{p.id} {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)}` : ''} {confirming === p.id ? ( ) : ( )}
{p.waiting &&
Waits: {p.waiting}
} {p.note &&
{p.note}
} {p.lastError &&
Last spawn failed: {p.lastError}
} {renaming && renaming.id === p.id && (
{ 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 }} > setRenaming({ ...renaming, to: e.target.value })} pattern="[a-z0-9_\-]{1,40}" style={{ ...inputStyle, maxWidth: 240 }} />
)} {editing === p.id && (
{ 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 }} >
)}
) })}
) } /** `/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 (
{!sentry && ( <> {form.move === 'wander' && ( )} )}
) }