From 7eae4a1e62ceddc8bccdb57ebaa7fe8e43a86d49 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 30 Sep 2026 04:42:05 -0500 Subject: [PATCH] feat(rust): NPC profiles and placements pages, the profile leaderboard (runicnpc stage 4) 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 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- client/src/api.js | 38 +++ client/src/components/Leaderboard.jsx | 134 +++++++- client/src/components/MapView.jsx | 38 ++- client/src/entry.jsx | 10 +- client/src/icons.jsx | 22 ++ client/src/lib/feed.js | 4 +- client/src/lib/mapGeometry.js | 9 + client/src/routes/admin/ChatTitles.jsx | 12 +- client/src/routes/admin/NpcPlacements.jsx | 289 ++++++++++++++++ client/src/routes/admin/NpcProfiles.jsx | 385 +++++++++++++++++++++ client/src/routes/player/Account.jsx | 34 ++ server/router/player/rust.controller.js | 3 +- server/test/npcs.test.js | 388 ++++++++++++++++++++++ 13 files changed, 1356 insertions(+), 10 deletions(-) create mode 100644 client/src/routes/admin/NpcPlacements.jsx create mode 100644 client/src/routes/admin/NpcProfiles.jsx create mode 100644 server/test/npcs.test.js diff --git a/client/src/api.js b/client/src/api.js index c76eae6..2c51a89 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -56,6 +56,15 @@ export const servers = { // gets; then what moves on it, already cut down to this viewer on the server. map: (id) => req(`/public/rust/servers/${encodeURIComponent(id)}/map`), mapLive: (id) => req(`/public/rust/servers/${encodeURIComponent(id)}/map/live`), + + // RunicNPC (runicnpc stage 4). The profiles a leaderboard can rank by, one + // profile's ranking counted as the profile says (D247, D250), and one + // player's kills by profile, for an opened leaderboard row (D252). + npcProfiles: (id) => req(`/public/rust/servers/${encodeURIComponent(id)}/npc-profiles`), + npcLeaderboard: (id, { profile, wipe = null, limit = null } = {}) => + req(`/public/rust/servers/${encodeURIComponent(id)}/npc-leaderboard${query({ profile, wipe, limit })}`), + npcKills: (id, steamId, { wipe = null } = {}) => + req(`/public/rust/servers/${encodeURIComponent(id)}/players/${encodeURIComponent(steamId)}/npc-kills${query({ wipe })}`), } // One clan. Its roster comes back only for a viewer inside the operator's roster @@ -94,6 +103,11 @@ export const playerServers = { list: () => req('/player/rust/servers'), } +// Your own kills of each RunicNPC profile, current wipes, across your linked accounts (D252). +export const playerNpcKills = { + list: () => req('/player/rust/npc-kills'), +} + // R1's identity link, from the signed-in player's side. // // **The code is the whole of what goes up.** The site has no idea which server @@ -220,6 +234,28 @@ export const adminZones = { remove: (id) => req(`/admin/rust/zones/presets/${encodeURIComponent(id)}`, { method: 'DELETE' }), } +// ── admin · NPCs (runicnpc stage 4) ───────────────────────────────────────── +// +// The site's RunicNPC profiles (per server, shared or fleet), pushed to each +// server; and each server's placements, which live on the server (D222), so +// every placement call is a live round trip that fails while the game is off. +const npcServer = (serverId) => `/admin/rust/npcs/servers/${encodeURIComponent(serverId)}` +export const adminNpcs = { + read: () => req('/admin/rust/npcs'), + create: (body) => req('/admin/rust/npcs/profiles', { method: 'POST', body }), + update: (id, body) => req(`/admin/rust/npcs/profiles/${encodeURIComponent(id)}`, { method: 'PUT', body }), + remove: (id) => req(`/admin/rust/npcs/profiles/${encodeURIComponent(id)}`, { method: 'DELETE' }), + restore: (id) => req(`/admin/rust/npcs/profiles/${encodeURIComponent(id)}/restore`, { method: 'POST' }), + push: (serverId) => req(`${npcServer(serverId)}/push`, { method: 'POST' }), + placements: (serverId) => req(`${npcServer(serverId)}/placements`), + place: (serverId, body) => req(`${npcServer(serverId)}/placements`, { method: 'POST', body }), + setPlacement: (serverId, id, body) => req(`${npcServer(serverId)}/placements/${encodeURIComponent(id)}`, { method: 'PUT', body }), + removePlacement: (serverId, id) => req(`${npcServer(serverId)}/placements/${encodeURIComponent(id)}`, { method: 'DELETE' }), + renamePlacement: (serverId, id, to) => + req(`${npcServer(serverId)}/placements/${encodeURIComponent(id)}/rename`, { method: 'POST', body: { to } }), + respawnPlacement: (serverId, id) => req(`${npcServer(serverId)}/placements/${encodeURIComponent(id)}/respawn`, { method: 'POST' }), +} + // ── admin · mod configuration (R18) ─────────────────────────────────────── // // Every call here is a LIVE round trip to a game host, which makes this the only @@ -301,6 +337,8 @@ export default { adminConfig, adminVisibility, adminZones, + adminNpcs, + playerNpcKills, adminUserLinks, adminUserPermissions, BASE, diff --git a/client/src/components/Leaderboard.jsx b/client/src/components/Leaderboard.jsx index d4f7ca9..f389b66 100644 --- a/client/src/components/Leaderboard.jsx +++ b/client/src/components/Leaderboard.jsx @@ -9,6 +9,12 @@ // re-sorted itself under the reader's cursor every twenty seconds would be worse // than one that is four minutes old, and the page has a `Refresh` on the tab // strip for anybody who disagrees. +// +// RunicNPC (runicnpc stage 4). Where the server has NPC profiles, "Rank by" +// offers each one's kills, counted as the profile says (D247, D250), and opening +// a player's row shows their kills of each profile for the wipe shown (D252). + +import { useState } from 'react' import { ErrorState, Loading, useAsync } from '../core.js' import Empty from './Empty.jsx' @@ -27,6 +33,52 @@ const COLUMNS = [ ] export default function Leaderboard({ serverId, wipeId, sort, onSort }) { + const { data: npc } = useAsync(() => api.servers.npcProfiles(serverId).catch(() => ({ profiles: [] })), [serverId]) + const [profileId, setProfileId] = useState('') + const profiles = (npc && npc.profiles) || [] + const picked = profiles.find((p) => String(p.id) === String(profileId)) + + const picker = profiles.length > 0 && ( + + ) + + if (picked) { + return ( +
+ {picker} + +
+ ) + } + + return ( +
+ {picker} + +
+ ) +} + +/** How a profile's kills are counted, in the reader's words (D247). */ +const SCOPE_NOTE = { + server: 'Counted on this server.', + name: 'Counted on every server with this NPC.', + profile: 'Counted wherever this NPC is placed.', +} + +function Overall({ serverId, wipeId, sort, onSort }) { + const [open, setOpen] = useState(null) const { data, loading, error } = useAsync( () => api.servers.leaderboard(serverId, { wipe: wipeId, sort, limit: 50 }), [serverId, wipeId, sort], @@ -93,10 +145,15 @@ export default function Leaderboard({ serverId, wipeId, sort, onSort }) { - {rows.map((row, index) => ( - + {rows.map((row, index) => [ + setOpen(open === row.steamId ? null : row.steamId)} + title="Open for this player’s kills of each NPC" + style={{ borderTop: '1px solid var(--line-soft, var(--line))', cursor: 'pointer' }} + > - {index + 1} + {open === row.steamId ? '▾' : '▸'} {index + 1} {/* A player this module has never seen NAMED is shown by the tail of their id rather than as a blank: the row is real, and a nameless one reads as a rendering fault. */} @@ -130,6 +187,77 @@ export default function Leaderboard({ serverId, wipeId, sort, onSort }) { {showLastSeen && ( {ago(row.lastSeen)} )} + , + open === row.steamId && ( + + + + + + ), + ])} + + + + ) +} + +/** An opened row (D252): the player's kills of each RunicNPC profile, for the wipe shown. */ +function PlayerNpcKills({ serverId, steamId, wipeId }) { + const { data, loading, error } = useAsync(() => api.servers.npcKills(serverId, steamId, { wipe: wipeId }), [serverId, steamId, wipeId]) + if (loading) return Loading… + if (error) return Their NPC kills could not be loaded. + const kills = (data && data.kills) || [] + if (kills.length === 0) return No kills of this server’s NPC profiles{wipeId ? ' this wipe' : ''}. + return ( + + {kills.map((k, i) => ( + + {i > 0 && · } + {k.label} {count(k.kills)} + + ))} + + ) +} + +/** One profile's ranking (D250): who has killed the most of it. */ +function ProfileBoard({ serverId, wipeId, profile }) { + const { data, loading, error } = useAsync( + () => api.servers.npcLeaderboard(serverId, { profile: profile.id, wipe: wipeId, limit: 50 }), + [serverId, wipeId, profile.id], + ) + if (loading) return + if (error) return + const rows = (data && data.leaderboard) || [] + if (rows.length === 0) { + return + } + return ( +
+ + + + + + + + + {rows.map((row, index) => ( + + + ))} diff --git a/client/src/components/MapView.jsx b/client/src/components/MapView.jsx index 75bf79e..ada424a 100644 --- a/client/src/components/MapView.jsx +++ b/client/src/components/MapView.jsx @@ -20,7 +20,7 @@ import { ErrorState, Loading, useAsync } from '../core.js' import Empty from './Empty.jsx' import usePolled from '../hooks/usePolled.js' import { ago } from '../lib/format.js' -import { boundsOf, countdown, grid, gridLabel, toLatLng } from '../lib/mapGeometry.js' +import { boundsOf, countdown, fromLatLng, grid, gridLabel, toLatLng } from '../lib/mapGeometry.js' import api, { BASE } from '../api.js' const STYLE_ID = 'rust-leaflet-css' @@ -64,7 +64,13 @@ const WORLD_NAMES = { const AUDIENCE_WORDS = { public: 'everyone', signed_in: 'signed-in players', staff: 'staff only' } -export default function MapView({ serverId, online }) { +/** + * `onPick` and `pins` are for an admin page that places things on the map (the NPC + * placements page, runicnpc stage 4, D245): a click answers the world point under + * it, and `pins` (`{ x, z, label, colour, ring }`) are drawn above every layer. The + * public page passes neither. + */ +export default function MapView({ serverId, online, onPick = null, pins = null }) { const { data: meta, loading, error } = useAsync(() => api.servers.map(serverId), [serverId]) const geometry = meta ? meta.geometry : null @@ -101,6 +107,8 @@ export default function MapView({ serverId, online }) { const container = useRef(null) const mapRef = useRef(null) const groups = useRef(null) + const pickRef = useRef(onPick) + pickRef.current = onPick // The map itself: made once per server and geometry, torn down with them. useEffect(() => { @@ -121,7 +129,7 @@ export default function MapView({ serverId, online }) { map.fitBounds(bounds) const made = {} - for (const id of ['grid', 'monuments', 'world', 'events', 'players', 'bases', 'mates']) made[id] = L.layerGroup().addTo(map) + for (const id of ['grid', 'monuments', 'world', 'events', 'players', 'bases', 'mates', 'pins']) made[id] = L.layerGroup().addTo(map) // The labels are a layer of their own inside the grid's, shown only when a cell // is wide enough on screen to hold one: at the fitted zoom a 20-cell map's // labels overlap into a wall of text (found on the phase 14 walk). @@ -171,6 +179,17 @@ export default function MapView({ serverId, online }) { mapRef.current = map groups.current = made + pickRef.current = onPick + if (onPick) { + container.current.style.cursor = 'crosshair' + map.on('click', (e) => { + const at = fromLatLng(geometry, e.latlng.lat, e.latlng.lng) + const half = geometry.worldSize / 2 + if (at && pickRef.current && Math.abs(at.x) <= half && Math.abs(at.z) <= half) { + pickRef.current({ x: Math.round(at.x * 10) / 10, z: Math.round(at.z * 10) / 10, grid: gridLabel(geometry, at.x, at.z) }) + } + }) + } return () => { map.remove() mapRef.current = null @@ -225,6 +244,19 @@ export default function MapView({ serverId, online }) { } }, [leaflet, geometry, live.data]) + // An admin page's own pins, above everything else. + useEffect(() => { + const made = groups.current + if (!leaflet || !made || !geometry) return + const { L } = leaflet + made.pins.clearLayers() + for (const p of pins || []) { + const marker = dot(L, toLatLng(geometry, p.x, p.z), p.colour || '#ffd54f', p.ring ? 9 : 6, p.ring ? 3 : 1) + if (p.label) marker.bindTooltip(escape(p.label)) + marker.addTo(made.pins) + } + }, [leaflet, geometry, pins]) + // The legend's checkboxes: a group is on the map or off it. useEffect(() => { const map = mapRef.current diff --git a/client/src/entry.jsx b/client/src/entry.jsx index fe97423..71fbc93 100644 --- a/client/src/entry.jsx +++ b/client/src/entry.jsx @@ -27,9 +27,11 @@ import ModConfig from './routes/admin/ModConfig.jsx' import Visibility from './routes/admin/Visibility.jsx' import ServerSettings from './routes/admin/ServerSettings.jsx' import ZonePresets from './routes/admin/ZonePresets.jsx' +import NpcProfiles from './routes/admin/NpcProfiles.jsx' +import NpcPlacements from './routes/admin/NpcPlacements.jsx' import UserRustSections from './routes/admin/UserRustSections.jsx' import FooterStatus from './components/FooterStatus.jsx' -import { IconEye, IconKey, IconLink, IconServer, IconSliders, IconZone } from './icons.jsx' +import { IconEye, IconKey, IconLink, IconNpc, IconPlacement, IconServer, IconSliders, IconZone } from './icons.jsx' // The module id, exactly as `module.json` spells it. Core keys the registry by it // and prefixes every route path with it. @@ -113,6 +115,10 @@ registry.registerRoutes(ID, { // Zone presets (PLAN_REDESIGNS §3.1, D210). Core's step editor has single- // value fields, so a zone's flags are ticked here and a step copies the set. { path: 'zones', element: }, + // RunicNPC (runicnpc stage 4): the site's NPC profiles, pushed to each + // server, and each server's placements, created by clicking its live map. + { path: 'npcs', element: }, + { path: 'npcs/placements', element: }, ], }) @@ -164,6 +170,8 @@ registry.registerNav(ID, { { label: 'Rust visibility', to: '/admin/rust/visibility', icon: IconEye }, { label: 'Rust servers', to: '/admin/rust/servers', icon: IconServer }, { label: 'Rust zone presets', to: '/admin/rust/zones', icon: IconZone }, + { label: 'Rust NPC profiles', to: '/admin/rust/npcs', icon: IconNpc }, + { label: 'Rust NPC placements', to: '/admin/rust/npcs/placements', icon: IconPlacement }, ], }) diff --git a/client/src/icons.jsx b/client/src/icons.jsx index 3551108..f8c4df4 100644 --- a/client/src/icons.jsx +++ b/client/src/icons.jsx @@ -124,3 +124,25 @@ export const IconZone = () => ( ) + +/** + * A figure — the admin sidebar's row for RunicNPC's profiles (runicnpc stage 4). + * A head and shoulders: the page is about who the NPCs are. + */ +export const IconNpc = () => ( + + + + +) + +/** + * A figure on a spot — the admin sidebar's row for NPC placements: where they stand. + */ +export const IconPlacement = () => ( + + + + + +) diff --git a/client/src/lib/feed.js b/client/src/lib/feed.js index 3322595..32666f9 100644 --- a/client/src/lib/feed.js +++ b/client/src/lib/feed.js @@ -161,7 +161,9 @@ function death(frame, name) { case 'npc': return { tone: 'death', - actor: attacker(frame.attackerName) || 'Something', + // One of RunicNPC's (runicnpc stage 4): its own name, as typed on its profile, + // rather than the prefab it is built from. Older frames carry only the prefab. + actor: frame.attackerNpc || attacker(frame.attackerName) || 'Something', verb: 'killed', subject: name, detail: where, diff --git a/client/src/lib/mapGeometry.js b/client/src/lib/mapGeometry.js index 87c5476..5dea995 100644 --- a/client/src/lib/mapGeometry.js +++ b/client/src/lib/mapGeometry.js @@ -34,6 +34,15 @@ export function toLatLng(g, x, z) { return [(Number(z) + half) * s + m, (Number(x) + half) * s + m] } +/** The inverse of `toLatLng`: a point on the picture as world `{ x, z }` in metres (a click on the map). */ +export function fromLatLng(g, lat, lng) { + const s = scaleOf(g) + if (!(s > 0)) return null + const half = g.worldSize / 2 + const m = g.oceanMargin || 0 + return { x: (Number(lng) - m) / s - half, z: (Number(lat) - m) / s - half } +} + /** The picture's bounds in the same space. */ export function boundsOf(g) { return [[0, 0], [g.height, g.width]] diff --git a/client/src/routes/admin/ChatTitles.jsx b/client/src/routes/admin/ChatTitles.jsx index 3dd9f4b..2734988 100644 --- a/client/src/routes/admin/ChatTitles.jsx +++ b/client/src/routes/admin/ChatTitles.jsx @@ -34,6 +34,8 @@ export function statOptions(categories) { return [ ...(categories || []).map((c) => ({ id: c.stat, label: c.label, title: c.current })), { id: 'playtime', label: 'Playtime', title: null }, + // RunicNPC (runicnpc stage 4, D250): one NPC profile's kills. The rule names the profile. + { id: 'profilekills', label: 'Kills of an NPC profile', title: null }, ] } @@ -66,6 +68,8 @@ function Chip({ text, color }) { export function TitlesForm({ server, categories, onSaved, onCancel }) { const start = server.titles || { mode: 'first', max: 2, rules: [] } const stats = statOptions(categories) + const { data: npc } = useAsync(() => api.servers.npcProfiles(server.id).catch(() => ({ profiles: [] })), [server.id]) + const profiles = (npc && npc.profiles) || [] const [mode, setMode] = useState(start.mode) const [max, setMax] = useState(start.max) const [rules, setRules] = useState(start.rules.map((r) => ({ ...r }))) @@ -74,7 +78,7 @@ export function TitlesForm({ server, categories, onSaved, onCancel }) { const setRule = (i, key) => (e) => { const value = e.target.value - setRules((list) => list.map((r, n) => (n === i ? { ...r, [key]: key === 'topN' ? Number(value) : value } : r))) + setRules((list) => list.map((r, n) => (n === i ? { ...r, [key]: key === 'topN' || key === 'profile' ? Number(value) : value } : r))) } const move = (i, by) => setRules((list) => { const next = [...list] @@ -117,6 +121,12 @@ export function TitlesForm({ server, categories, onSaved, onCancel }) { + {r.stat === 'profilekills' && ( + + )} earn {/* The category's title is a PLACEHOLDER, never a value: saved as the rule's own text it would stop a rename reaching this rule (§5.5). */} diff --git a/client/src/routes/admin/NpcPlacements.jsx b/client/src/routes/admin/NpcPlacements.jsx new file mode 100644 index 0000000..84d197f --- /dev/null +++ b/client/src/routes/admin/NpcPlacements.jsx @@ -0,0 +1,289 @@ +// ── 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' && ( + + )} + + )} +
+ ) +} diff --git a/client/src/routes/admin/NpcProfiles.jsx b/client/src/routes/admin/NpcProfiles.jsx new file mode 100644 index 0000000..9e755b7 --- /dev/null +++ b/client/src/routes/admin/NpcProfiles.jsx @@ -0,0 +1,385 @@ +// ── 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). + +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 list = (text) => String(text || '').split(/[\n,]+/).map((s) => s.trim()).filter(Boolean) +const str = (v) => (v === undefined || v === null ? '' : String(v)) + +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(', '), + } +} + +/** 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), + }, + } +} + +/** 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 + if (!data) return + + 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 ( +
+

+ 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{' '} + placements page, in game with /rnpc place, or by an + event’s Place NPCs step. +

+ +
+

Servers

+ {servers.length === 0 &&

No servers are configured yet.

} + {servers.map((s) => { + const refused = Object.entries((s.sync && s.sync.refused) || {}) + return ( +
+
+ {s.name || s.id} + {serverLine(s)} + {s.ready && ( + + )} +
+ {refused.length > 0 && ( +
+ RunicNPC refused: {refused.map(([n, why]) => `${n} (${why})`).join('; ')} +
+ )} +
+ ) + })} +
+ + {notice &&

{notice}

} + {error && !form &&

{error}

} + +
+
+

Profiles

+ +
+ {inUse.length === 0 && ( +

None yet. A server’s own profiles appear here after its first push.

+ )} + {inUse.map((p) => ( +
+
+ {p.label} + {p.name} + + {p.allServers ? 'every server' : p.servers.map((id) => nameOf.get(id) || id).join(', ')} + {p.adoptedFrom ? ` · adopted from ${nameOf.get(p.adoptedFrom) || p.adoptedFrom}` : ''} + + +
+ + {p.body.role} · {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'} + +
+ ))} +
+ + {replaced.length > 0 && ( +
+

Replaced

+

+ 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. +

+ {replaced.map((p) => ( +
+ {p.name} + from {nameOf.get(p.adoptedFrom) || p.adoptedFrom} + + +
+ ))} +
+ )} + + {form && ( + 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} + /> + )} +
+ ) +} + +function Field({ label, hint, children }) { + return ( + + ) +} + +function Num({ value, onChange, width = 110 }) { + return onChange(e.target.value)} inputMode="decimal" style={{ ...inputStyle, maxWidth: width }} /> +} + +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.id ? `Edit ${form.name}` : 'A new profile'}

+ + + set('name')(e.target.value)} required maxLength={40} pattern="[a-z0-9_\-]{1,40}" style={{ ...inputStyle, maxWidth: 320 }} /> + + +
+ For + + {!form.allServers && + servers.map((s) => ( + + ))} +
+ +
+ Looks + +
Player{profile.label} kills
+ {index + 1} + {row.name || shortId(row.steamId)} + {(row.titles || []).map((title, i) => ( + + {title.text} + + ))} + {count(row.kills)}