Files
Module-Rust/client/src/routes/admin/NpcPlacements.jsx
wtclaude 11619a4dc6
All checks were successful
PR Checks / client-build (pull_request) Successful in 31s
PR Checks / server-tests (pull_request) Successful in 39s
PR Checks / frozen-manifest (pull_request) Successful in 58s
feat(rust): NPC sides, the faction table, an event's escort, ally and tether (runicnpc stage 5)
RunicNPC stage 5 on the site (docs runicnpc/PLAN.md, D253-D272):

- profiles gain the guard role, faction, relations (its own exceptions),
  alertRadius, turrets, hurtByPlayers, hurtsPlayers and kitUse, checked in
  RunicNPC's order and words, and defaulting to "players only" (D255);
- the faction table: one site-wide table, one row per pair and both ways
  (D254, D268), stored in rust_npc_factions, edited on the NPC profiles page
  (PUT /admin/rust/npcs/factions), pushed to every server with its
  profiles and hashed with them, and a standalone server's own pairs
  adopted at its first push with the site's winning (D244, D251);
- the Place NPCs step takes escort (a Steam id or a {placeholder}), an ally
  (a clan from the new rust.options.clans source, or the team of a player)
  and tether (the zone this event made), for a RunicNPC profile only
  (D269, D270, D272);
- a placement may be held inside a zone (tether, D272);
- the bridge's RunicNPC API floor is 4.

Tests: 488 server, 66 client. routes.manifest.json regenerated against the
pinned core (one route added).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-01 07:58:13 -05:00

298 lines
15 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 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: '', tether: '' })
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) : '',
tether: v.tether || '',
}
}
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 }),
// Stage 5 (D272): a ZoneManager zone its NPCs never leave; empty for none.
tether: f.tether.trim() || 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.tether ? ` · inside zone ${v.tether}` : ''}
{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>
)}
<label style={label} title="A ZoneManager zone on this server its NPCs never leave. The server refuses a zone it does not have, or one that does not hold the spot.">
<span style={{ color: 'var(--head)' }}>Keep inside zone</span>
<input value={form.tether} onChange={set('tether')} placeholder="none" maxLength={64} style={{ ...inputStyle, maxWidth: 160 }} />
</label>
</>
)}
</div>
)
}