Merge pull request 'feat(rust): RunicNPC profiles, placements, event NPCs and per-profile kills (runicnpc stage 4)' (#29) from feat/runicnpc-stage4 into edge

Reviewed-on: #29
This commit is contained in:
2026-09-30 19:52:12 +00:00
43 changed files with 5781 additions and 33 deletions

View File

@@ -42,6 +42,7 @@
"mapImages.js",
"mapLive.js",
"model",
"npcSync.js",
"package.json",
"permSync.js",
"router",

View File

@@ -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,

View File

@@ -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 && (
<label className="sans" style={{ display: 'flex', gap: 8, alignItems: 'baseline', fontSize: '0.82rem', marginBottom: 10 }}>
<span style={{ color: 'var(--dim)' }}>Rank by</span>
<select
value={profileId}
onChange={(e) => setProfileId(e.target.value)}
style={{ background: 'transparent', color: 'var(--ink)', border: '1px solid var(--line)', borderRadius: 6, padding: '4px 6px', font: 'inherit' }}
>
<option value="">Overall</option>
{profiles.map((p) => <option key={p.id} value={p.id}>{p.label} kills</option>)}
</select>
{picked && <span style={{ color: 'var(--dim)' }}>{SCOPE_NOTE[picked.killsScope] || ''}</span>}
</label>
)
if (picked) {
return (
<div>
{picker}
<ProfileBoard serverId={serverId} wipeId={wipeId} profile={picked} />
</div>
)
}
return (
<div>
{picker}
<Overall serverId={serverId} wipeId={wipeId} sort={sort} onSort={onSort} />
</div>
)
}
/** 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 }) {
</tr>
</thead>
<tbody>
{rows.map((row, index) => (
<tr key={row.steamId} style={{ borderTop: '1px solid var(--line-soft, var(--line))' }}>
{rows.map((row, index) => [
<tr
key={row.steamId}
onClick={() => 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' }}
>
<td style={cell}>
<span style={{ color: 'var(--dim)', marginRight: 8 }}>{index + 1}</span>
<span style={{ color: 'var(--dim)', marginRight: 8 }}>{open === row.steamId ? '▾' : '▸'} {index + 1}</span>
{/* 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 && (
<td style={{ ...cell, textAlign: 'right', color: 'var(--dim)' }}>{ago(row.lastSeen)}</td>
)}
</tr>,
open === row.steamId && (
<tr key={`${row.steamId}-npcs`}>
<td colSpan={COLUMNS.length + 1 + (showLastSeen ? 1 : 0)} style={{ ...cell, whiteSpace: 'normal', paddingLeft: 34 }}>
<PlayerNpcKills serverId={serverId} steamId={row.steamId} wipeId={wipeId} />
</td>
</tr>
),
])}
</tbody>
</table>
</div>
)
}
/** 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 <span className="sans" style={{ color: 'var(--dim)', fontSize: '0.8rem' }}>Loading…</span>
if (error) return <span className="sans" style={{ color: 'var(--dim)', fontSize: '0.8rem' }}>Their NPC kills could not be loaded.</span>
const kills = (data && data.kills) || []
if (kills.length === 0) return <span className="sans" style={{ color: 'var(--dim)', fontSize: '0.8rem' }}>No kills of this server’s NPC profiles{wipeId ? ' this wipe' : ''}.</span>
return (
<span className="sans" style={{ fontSize: '0.82rem' }}>
{kills.map((k, i) => (
<span key={k.profile}>
{i > 0 && <span style={{ color: 'var(--dim)' }}> · </span>}
{k.label} <strong style={{ color: 'var(--ink)' }}>{count(k.kills)}</strong>
</span>
))}
</span>
)
}
/** 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 <Loading />
if (error) return <ErrorState error={error} />
const rows = (data && data.leaderboard) || []
if (rows.length === 0) {
return <Empty title="No kills yet" message={`Nobody has killed a ${profile.label}${wipeId ? ' this wipe' : ''} yet.`} />
}
return (
<div style={{ overflowX: 'auto' }}>
<table className="sans" style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.86rem' }}>
<thead>
<tr style={{ textAlign: 'left', color: 'var(--dim)', fontSize: '0.72rem', letterSpacing: '0.08em', textTransform: 'uppercase' }}>
<th style={cell}>Player</th>
<th style={{ ...cell, textAlign: 'right' }}>{profile.label} kills</th>
</tr>
</thead>
<tbody>
{rows.map((row, index) => (
<tr key={row.steamId} style={{ borderTop: '1px solid var(--line-soft, var(--line))' }}>
<td style={cell}>
<span style={{ color: 'var(--dim)', marginRight: 8 }}>{index + 1}</span>
<strong style={{ color: 'var(--ink)' }}>{row.name || shortId(row.steamId)}</strong>
{(row.titles || []).map((title, i) => (
<span
key={`${title.text}-${i}`}
style={{ marginLeft: 6, padding: '1px 6px', borderRadius: 999, fontSize: '0.7rem', fontWeight: 600, background: title.color, color: contrastInk(title.color) }}
>
{title.text}
</span>
))}
</td>
<td style={{ ...cell, textAlign: 'right' }}>{count(row.kills)}</td>
</tr>
))}
</tbody>

View File

@@ -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

View File

@@ -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: <ZonePresets /> },
// 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: <NpcProfiles /> },
{ path: 'npcs/placements', element: <NpcPlacements /> },
],
})
@@ -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 },
],
})

View File

@@ -124,3 +124,25 @@ export const IconZone = () => (
<circle cx="12" cy="12" r="3" />
</Icon>
)
/**
* 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 = () => (
<Icon>
<circle cx="12" cy="8" r="3.5" />
<path d="M5 20c0-3.9 3.1-7 7-7s7 3.1 7 7" />
</Icon>
)
/**
* A figure on a spot — the admin sidebar's row for NPC placements: where they stand.
*/
export const IconPlacement = () => (
<Icon>
<circle cx="12" cy="6" r="2.5" />
<path d="M8 15c0-2.2 1.8-4 4-4s4 1.8 4 4" />
<ellipse cx="12" cy="19" rx="7" ry="2" />
</Icon>
)

View File

@@ -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,

View File

@@ -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]]

View File

@@ -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 }) {
<select value={r.stat} onChange={setRule(i, 'stat')} style={inputStyle} aria-label="Stat">
{stats.map((s) => <option key={s.id} value={s.id}>{s.label}</option>)}
</select>
{r.stat === 'profilekills' && (
<select value={r.profile || ''} onChange={setRule(i, 'profile')} style={inputStyle} aria-label="NPC profile" required>
<option value="" disabled>{profiles.length ? 'Which NPC?' : 'No NPC profile on this server'}</option>
{profiles.map((p) => <option key={p.id} value={p.id}>{p.label} ({p.name})</option>)}
</select>
)}
<span>earn</span>
{/* 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). */}
<input value={r.text} onChange={setRule(i, 'text')} maxLength={24} required={!fallback} placeholder={fallback || 'A title'} style={{ ...inputStyle, width: 160 }} aria-label="Title" />

View File

@@ -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 <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>
)
}

View File

@@ -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 <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.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>
{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 }} />
}
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) => set('role')(e.target.value)} style={inputStyle}>
<option value="roamer">Roamer: moves, chases and fights</option>
<option value="sentry">Sentry: stands still and shoots</option>
</select>
</Field>
</div>
</fieldset>
{form.role === 'roamer' && (
<fieldset style={fieldset}>
<legend className="sans" style={legend}>Moving</legend>
<div style={row}>
<Field label="How it moves" hint="wander, monument (Rust’s own paths there), or route:<name> for a route recorded in game. 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"><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>
<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>
)
}

View File

@@ -261,6 +261,37 @@ function Held({ accounts }) {
)
}
/**
* RunicNPC (runicnpc stage 4, D252): your kills of each NPC profile, this wipe,
* by server, across every Steam account you have linked. Nothing without a link.
*/
function NpcKills() {
const { data, loading, error } = useAsync(() => api.playerNpcKills.list(), [])
if (loading) return <Loading />
if (error) return <ErrorState error={error} />
const rows = (data && data.kills) || []
if (rows.length === 0) {
return <p className="sans dim" style={{ fontSize: '0.8rem', margin: 0 }}>None this wipe.</p>
}
const byServer = new Map()
for (const r of rows) byServer.set(r.server || r.serverId, [...(byServer.get(r.server || r.serverId) || []), r])
return (
<ul className="sans" style={{ listStyle: 'none', margin: 0, padding: 0, fontSize: '0.86rem', display: 'grid', gap: 6 }}>
{[...byServer.entries()].map(([serverId, list]) => (
<li key={serverId}>
<span className="dim">{serverId}: </span>
{list.map((k, i) => (
<span key={k.profile}>
{i > 0 && <span className="dim"> · </span>}
{k.label} <strong>{k.kills}</strong>
</span>
))}
</li>
))}
</ul>
)
}
export default function Account() {
// `useAsync` rather than this module's `usePolled`: nothing here changes unless
// the person looking at it changes it, and a page that re-asked every twenty
@@ -326,6 +357,9 @@ export default function Account() {
<div className="field-label" style={{ margin: '30px 0 12px' }}>What you can do in game</div>
{data && <Held accounts={links.length} />}
<div className="field-label" style={{ margin: '30px 0 12px' }}>NPCs you have killed</div>
{data && links.length > 0 ? <NpcKills /> : <p className="sans dim" style={{ fontSize: '0.8rem', margin: 0 }}>Link a Steam account to see them.</p>}
</div>
)
}

View File

@@ -527,6 +527,198 @@
}
]
},
{
"id": "rust.npc.died",
"label": "A RunicNPC NPC died",
"description": "One of RunicNPC's NPCs was killed. A phase can wait for a number of them: \"8 guards died\".",
"kind": "event",
"subjectKey": "profile",
"audience": "staff",
"ceiling": "staff",
"version": 1,
"variables": [
{
"name": "serverId",
"type": "string",
"required": true,
"example": "main",
"description": "The server the event happened on, as configured in Admin -> Rust. Also the cooldown subject for broadcasts."
},
{
"name": "server",
"type": "string",
"required": true,
"example": "Runic Gateway | Main",
"description": "The server's display name."
},
{
"name": "serverUrl",
"type": "url",
"required": false,
"example": "/rust/servers/main",
"description": "Site-relative path to the server's page."
},
{
"name": "title",
"type": "string",
"required": false,
"example": "Main is back online",
"description": "A one-line headline naming what happened and where. Core generic bodies use it as the title."
},
{
"name": "intro",
"type": "string",
"required": false,
"example": "Main is back up and taking players.",
"description": "One sentence of detail. Core generic bodies use it as the body."
},
{
"name": "profile",
"type": "string",
"required": true,
"example": "guard",
"description": "The RunicNPC profile the NPC was made from. The cooldown subject, and what a phase gate usually names."
},
{
"name": "npc",
"type": "string",
"required": false,
"example": "Gate Guard",
"description": "The NPC's own name, as the victim's death screen shows it."
},
{
"name": "byEvent",
"type": "boolean",
"required": true,
"example": true,
"description": "Whether an event placed it (rather than an admin's placement or another plugin)."
},
{
"name": "runId",
"type": "string",
"required": false,
"example": "42",
"description": "The event run that placed it, when one did."
},
{
"name": "placement",
"type": "string",
"required": false,
"example": "guard-3",
"description": "The placement it came from, when it came from one."
},
{
"name": "killer",
"type": "string",
"required": false,
"example": "Marisol",
"description": "The player who landed the killing blow. Absent when no player did."
},
{
"name": "killerSteamId",
"type": "string",
"required": false,
"example": "76561198000000002",
"description": "Their Steam id."
},
{
"name": "contributors",
"type": "int",
"required": true,
"example": 3,
"description": "How many players took health from it, the killer included."
}
]
},
{
"id": "rust.npc.health",
"label": "A RunicNPC NPC fell to a health threshold",
"description": "One of RunicNPC's NPCs fell to a fraction of its health its profile names. A phase can wait for \"the boss below 50%\".",
"kind": "event",
"subjectKey": "profile",
"audience": "staff",
"ceiling": "staff",
"version": 1,
"variables": [
{
"name": "serverId",
"type": "string",
"required": true,
"example": "main",
"description": "The server the event happened on, as configured in Admin -> Rust. Also the cooldown subject for broadcasts."
},
{
"name": "server",
"type": "string",
"required": true,
"example": "Runic Gateway | Main",
"description": "The server's display name."
},
{
"name": "serverUrl",
"type": "url",
"required": false,
"example": "/rust/servers/main",
"description": "Site-relative path to the server's page."
},
{
"name": "title",
"type": "string",
"required": false,
"example": "Main is back online",
"description": "A one-line headline naming what happened and where. Core generic bodies use it as the title."
},
{
"name": "intro",
"type": "string",
"required": false,
"example": "Main is back up and taking players.",
"description": "One sentence of detail. Core generic bodies use it as the body."
},
{
"name": "profile",
"type": "string",
"required": true,
"example": "guard",
"description": "The RunicNPC profile the NPC was made from. The cooldown subject, and what a phase gate usually names."
},
{
"name": "npc",
"type": "string",
"required": false,
"example": "Gate Guard",
"description": "The NPC's own name, as the victim's death screen shows it."
},
{
"name": "byEvent",
"type": "boolean",
"required": true,
"example": true,
"description": "Whether an event placed it (rather than an admin's placement or another plugin)."
},
{
"name": "runId",
"type": "string",
"required": false,
"example": "42",
"description": "The event run that placed it, when one did."
},
{
"name": "placement",
"type": "string",
"required": false,
"example": "guard-3",
"description": "The placement it came from, when it came from one."
},
{
"name": "percent",
"type": "int",
"required": true,
"example": 50,
"description": "The threshold it fell to, as a percentage of its health: 50 for half."
}
]
},
{
"id": "rust.player.banned",
"label": "A player was banned",

View File

@@ -1,6 +1,16 @@
{
"$comment": "Generated inventory of the URLs module-rust serves - the module half of the freeze core keeps in server/routes.manifest.json. DERIVED as the difference between a core without this module and the same core with it, both at the pinned ref in ci/core-ref.json. Regenerate with the frozen-manifest job in .gitea/workflows/pr-checks.yml; see server/scripts/frozenManifest.js.",
"routes": [
{
"method": "DELETE",
"path": "/api/v1/admin/rust/npcs/profiles/:pid",
"tier": "public"
},
{
"method": "DELETE",
"path": "/api/v1/admin/rust/npcs/servers/:id/placements/:placement",
"tier": "public"
},
{
"method": "DELETE",
"path": "/api/v1/admin/rust/permissions/exceptions/:id",
@@ -56,6 +66,16 @@
"path": "/api/v1/admin/rust/config/:serverId/writes/:writeId",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/admin/rust/npcs",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/admin/rust/npcs/servers/:id/placements",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/admin/rust/permissions",
@@ -121,6 +141,11 @@
"path": "/api/v1/player/rust/links",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/player/rust/npc-kills",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/player/rust/permissions",
@@ -176,11 +201,26 @@
"path": "/api/v1/public/rust/servers/:id/map/live",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/public/rust/servers/:id/npc-leaderboard",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/public/rust/servers/:id/npc-profiles",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/public/rust/servers/:id/online",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/public/rust/servers/:id/players/:steamId/npc-kills",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/public/rust/servers/:id/wipes",
@@ -196,6 +236,36 @@
"path": "/api/v1/admin/rust/config/:serverId/file",
"tier": "public"
},
{
"method": "POST",
"path": "/api/v1/admin/rust/npcs/profiles",
"tier": "public"
},
{
"method": "POST",
"path": "/api/v1/admin/rust/npcs/profiles/:pid/restore",
"tier": "public"
},
{
"method": "POST",
"path": "/api/v1/admin/rust/npcs/servers/:id/placements",
"tier": "public"
},
{
"method": "POST",
"path": "/api/v1/admin/rust/npcs/servers/:id/placements/:placement/rename",
"tier": "public"
},
{
"method": "POST",
"path": "/api/v1/admin/rust/npcs/servers/:id/placements/:placement/respawn",
"tier": "public"
},
{
"method": "POST",
"path": "/api/v1/admin/rust/npcs/servers/:id/push",
"tier": "public"
},
{
"method": "POST",
"path": "/api/v1/admin/rust/permissions/drift/:id/accept",
@@ -291,6 +361,16 @@
"path": "/api/v1/player/rust/link",
"tier": "public"
},
{
"method": "PUT",
"path": "/api/v1/admin/rust/npcs/profiles/:pid",
"tier": "public"
},
{
"method": "PUT",
"path": "/api/v1/admin/rust/npcs/servers/:id/placements/:placement",
"tier": "public"
},
{
"method": "PUT",
"path": "/api/v1/admin/rust/permissions/groups/:id/permissions",

View File

@@ -48,6 +48,7 @@ const eventsDb = require('./model/events/events.db')
const eventWorld = require('./eventWorld')
const ingest = require('./ingest')
const mapImages = require('./mapImages')
const npcSync = require('./npcSync')
const permSync = require('./permSync')
const permissionsDb = require('./model/permissions/permissions.db')
const titleSync = require('./titleSync')
@@ -275,6 +276,9 @@ async function onBoot() {
permSync.start()
// The chat titles have a loop of their own for the same reason (phase 17).
titleSync.start()
// So do the NPC profiles, adopted and pushed to each server's RunicNPC
// (runicnpc stage 4, D244): a first push reads a server before it writes it.
npcSync.start()
refreshTimer = setInterval(refresh, REFRESH_MS)
ingestTimer = setInterval(ingestAll, INGEST_MS)
pruneTimer = setInterval(prune, PRUNE_MS)
@@ -300,6 +304,7 @@ async function onBoot() {
async function onShutdown() {
permSync.stop()
titleSync.stop()
npcSync.stop()
for (const timer of [refreshTimer, ingestTimer, pruneTimer, sweepTimer]) {
if (timer) clearInterval(timer)

View File

@@ -105,6 +105,13 @@ const STAFF_KINDS = Object.freeze([
'clan.member.added',
'clan.member.left',
'clan.member.kicked',
// RunicNPC (runicnpc stage 4). `npc.died` names the player who killed it and
// everyone who hurt it, so it is a roll call like `player.tally`: staff until
// an operator says otherwise. What the public sees of a kill is the tally's
// per-profile count on the leaderboard (D250).
'npc.died',
'npc.health',
'npc.placement.changed',
])
/**

View File

@@ -19,6 +19,12 @@
-- it knows this module registered, because it is the side that knows which
-- registrant owned what.
-- RunicNPC (runicnpc PLAN.md stage 4). Children before `rust_npc_profiles`.
DROP TABLE IF EXISTS rust_npc_kills;
DROP TABLE IF EXISTS rust_npc_sync;
DROP TABLE IF EXISTS rust_npc_profile_servers;
DROP TABLE IF EXISTS rust_npc_profiles;
-- Zone presets (PLAN_REDESIGNS §3.1). The server list before its preset.
DROP TABLE IF EXISTS rust_zone_preset_servers;
DROP TABLE IF EXISTS rust_zone_presets;

View File

@@ -1259,3 +1259,86 @@ CREATE TABLE IF NOT EXISTS rust_zone_preset_servers (
CONSTRAINT fk_rust_zone_preset_servers_server
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ── RunicNPC (docs/runicnpc/PLAN.md stage 4, D243–D252) ────────────────────
--
-- NPC profiles are authored here and pushed to each server's RunicNPC through
-- the bridge, which then marks that server managed (D221). The same shape as a
-- zone preset: one server, several, or every server (`all_servers`), and two
-- profiles of one name may not share a server. `body` is the profile as RunicNPC
-- reads it (D238), everything but the name, as JSON in a TEXT column.
--
-- `adopted_from` names the server a profile was read from on the site's first
-- push there (D244), and `replaced` marks one whose name a site profile already
-- had on that server (D251): kept for an admin to restore, and pushed nowhere.
--
-- `kills_scope` is D247's setting, how the profile's kills are counted: `server`
-- (the default, kills of that name on the server being looked at), `name`
-- (every server's kills of that name) or `profile` (this site profile's own,
-- wherever it was pushed).
CREATE TABLE IF NOT EXISTS rust_npc_profiles (
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(40) NOT NULL,
body TEXT NOT NULL,
all_servers TINYINT(1) NOT NULL DEFAULT 0,
kills_scope VARCHAR(16) NOT NULL DEFAULT 'server',
adopted_from VARCHAR(64) NULL,
replaced TINYINT(1) NOT NULL DEFAULT 0,
created_by INT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_rust_npc_profiles_name (name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS rust_npc_profile_servers (
profile_id INT UNSIGNED NOT NULL,
server_id VARCHAR(64) NOT NULL,
PRIMARY KEY (profile_id, server_id),
KEY idx_rust_npc_profile_servers_server (server_id),
CONSTRAINT fk_rust_npc_profile_servers_profile
FOREIGN KEY (profile_id) REFERENCES rust_npc_profiles (id) ON DELETE CASCADE,
CONSTRAINT fk_rust_npc_profile_servers_server
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- What the push last did on each server. `adopted_at` is set once, when the
-- server's own profiles were read and imported (D244); until then nothing is
-- pushed there. `pushed` is the name → site profile id map of the last push,
-- which is how a kill is credited to "this profile only" (D247). `state` is
-- `ok`, `failed`, or `absent` (no RunicNPC, or one older than API 3).
CREATE TABLE IF NOT EXISTS rust_npc_sync (
server_id VARCHAR(64) NOT NULL PRIMARY KEY,
adopted_at DATETIME NULL,
state VARCHAR(16) NOT NULL DEFAULT 'pending',
synced_hash CHAR(64) NULL,
boot_id VARCHAR(64) NULL,
pushed TEXT NULL,
refused TEXT NULL,
error VARCHAR(191) NULL,
last_attempt_at DATETIME NULL,
synced_at DATETIME NULL,
CONSTRAINT fk_rust_npc_sync_server
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Kills of RunicNPC's NPCs, per player, profile name and wipe (D247), from the
-- tally's `npcProfileKills`. `site_profile_id` is the site profile that name was
-- pushed as on that server when the kill arrived, 0 for one the site did not
-- push. The ranking reads it by the profile's `kills_scope`. No foreign keys, as
-- the other stats: a deleted profile keeps its history.
CREATE TABLE IF NOT EXISTS rust_npc_kills (
server_id VARCHAR(64) NOT NULL,
wipe_id VARCHAR(48) NOT NULL,
steam_id VARCHAR(32) NOT NULL,
profile VARCHAR(40) NOT NULL,
site_profile_id INT UNSIGNED NOT NULL DEFAULT 0,
kills INT UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (server_id, wipe_id, steam_id, profile, site_profile_id),
KEY idx_rust_npc_kills_profile (profile, server_id, wipe_id),
KEY idx_rust_npc_kills_site (site_profile_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- A title rule on a profile's kills names the site profile it ranks (runicnpc
-- stage 4, D250). Null for every other stat. No foreign key: a deleted profile
-- leaves a rule that ranks nobody, which the form shows and an admin removes.
ALTER TABLE rust_title_rules ADD COLUMN IF NOT EXISTS profile_id INT UNSIGNED NULL;

View File

@@ -159,6 +159,14 @@ const HEADLINES = Object.freeze({
title: `${d.player || d.steamId} was unbanned on ${d.server}`,
intro: `The ban on ${d.player || d.steamId} (${d.steamId}) was lifted.`,
}),
'rust.npc.died': (d) => ({
title: `${d.npc || d.profile} was killed on ${d.server}`,
intro: `${d.killer ? `${d.killer} killed` : 'Something killed'} ${d.npc || 'an NPC'} (${d.profile}) on ${d.server}.`,
}),
'rust.npc.health': (d) => ({
title: `${d.npc || d.profile} is below ${d.percent}% on ${d.server}`,
intro: `${d.npc || 'An NPC'} (${d.profile}) fell to ${d.percent}% of its health on ${d.server}.`,
}),
'rust.login.denied': (d) => ({
title: `A login to ${d.server} was not approved`,
intro: `${d.player || 'Someone'} (${d.steamId}) tried to join ${d.server} and was not let in within a minute.`,
@@ -369,7 +377,49 @@ async function onBan(server, item, frame) {
}) ? 1 : 0
}
/**
* RunicNPC's NPCs (runicnpc stage 4): a death, and a health threshold. What a
* phase gate counts, so the window is short: a death an hour old is history, not
* a wave falling. The dedupe key is the NPC's net id, which the game never
* reuses within a boot.
*/
const NPC_MAX_AGE_MS = 15 * 60 * 1000
function npcVars(server, frame) {
return {
...serverVars(server),
profile: str(frame.profile),
npc: str(frame.name),
byEvent: Boolean(frame.runId),
runId: str(frame.runId),
placement: str(frame.placement),
}
}
async function onNpc(server, item, frame) {
const t = frameTime(item, frame)
if (!stillNews(t, NPC_MAX_AGE_MS) || !str(frame.profile)) return 0
if (frame.kind === 'npc.died') {
const contributors = Array.isArray(frame.contributors) ? frame.contributors : []
return fire(T['rust.npc.died'], {
data: { ...npcVars(server, frame), killer: str(frame.killerName), killerSteamId: str(frame.killerId), contributors: contributors.length },
dedupeKey: dedupeKey('npc.died', server.id, frame.netId, t),
occurredAt: t,
}) ? 1 : 0
}
const percent = Math.round((Number(frame.threshold) || 0) * 100)
return fire(T['rust.npc.health'], {
data: { ...npcVars(server, frame), percent },
dedupeKey: dedupeKey('npc.health', server.id, frame.netId, percent),
occurredAt: t,
}) ? 1 : 0
}
const HANDLERS = Object.freeze({
'npc.died': onNpc,
'npc.health': onNpc,
'entity.destroyed': onRaid,
'server.wipe': onWipe,
'clan.member.left': onClan,

View File

@@ -407,7 +407,72 @@ const MODERATION = [
},
]
const TRIGGERS = Object.freeze([RAID, ...BROADCASTS, ACCOUNT, REWARD, ...CLANS, ...MODERATION])
// ── RunicNPC (docs/runicnpc/PLAN.md stage 4) ───────────────────────────────
//
// What an event's phase waits on: "8 guards died" (waves) and "the boss fell
// below 50%". Core counts a gate's firings from phase entry and does not know
// which run an NPC belongs to, so each firing says its profile, whether an event
// placed it and which run did; a gate's `where` names what it waits for. A
// profile only events place is the plain way to count only an event's NPCs.
//
// `staff`, both halves: a death names who killed it. A boss announced to
// players is stage 6's, with its own trigger.
const NPC_VARS = [
{ name: 'profile', type: 'string', required: true, example: 'guard',
description: 'The RunicNPC profile the NPC was made from. The cooldown subject, and what a phase gate usually names.' },
{ name: 'npc', type: 'string', required: false, example: 'Gate Guard',
description: 'The NPC\'s own name, as the victim\'s death screen shows it.' },
{ name: 'byEvent', type: 'boolean', required: true, example: true,
description: 'Whether an event placed it (rather than an admin\'s placement or another plugin).' },
{ name: 'runId', type: 'string', required: false, example: '42',
description: 'The event run that placed it, when one did.' },
{ name: 'placement', type: 'string', required: false, example: 'guard-3',
description: 'The placement it came from, when it came from one.' },
]
const NPCS = [
{
id: 'rust.npc.died',
label: 'A RunicNPC NPC died',
description: 'One of RunicNPC\'s NPCs was killed. A phase can wait for a number of them: "8 guards died".',
kind: 'event',
subjectKey: 'profile',
audience: 'staff',
ceiling: 'staff',
version: V1,
variables: [
...SERVER,
...HEADLINE,
...NPC_VARS,
{ name: 'killer', type: 'string', required: false, example: 'Marisol',
description: 'The player who landed the killing blow. Absent when no player did.' },
{ name: 'killerSteamId', type: 'string', required: false, example: '76561198000000002',
description: 'Their Steam id.' },
{ name: 'contributors', type: 'int', required: true, example: 3,
description: 'How many players took health from it, the killer included.' },
],
},
{
id: 'rust.npc.health',
label: 'A RunicNPC NPC fell to a health threshold',
description: 'One of RunicNPC\'s NPCs fell to a fraction of its health its profile names. A phase can wait for "the boss below 50%".',
kind: 'event',
subjectKey: 'profile',
audience: 'staff',
ceiling: 'staff',
version: V1,
variables: [
...SERVER,
...HEADLINE,
...NPC_VARS,
{ name: 'percent', type: 'int', required: true, example: 50,
description: 'The threshold it fell to, as a percentage of its health: 50 for half.' },
],
},
]
const TRIGGERS = Object.freeze([RAID, ...BROADCASTS, ACCOUNT, REWARD, ...CLANS, ...MODERATION, ...NPCS])
const TRIGGER_IDS = Object.freeze(Object.fromEntries(TRIGGERS.map((t) => [t.id, t.id])))

View File

@@ -30,6 +30,9 @@ const servers = require('./model/servers/servers.model')
const zones = require('./model/zones/zones.model')
const zoneOptions = require('./model/zones/zoneOptions')
const voice = require('./model/permissions/voice')
const npcs = require('./model/npcs/npcs.model')
const npcsDb = require('./model/npcs/npcs.db')
const npcProfile = require('./model/npcs/npcProfile')
const { serverFor, transportError, pluginError, perServer, bounded } = require('./eventLeases')
const log = core.logger('world')
@@ -110,6 +113,9 @@ const PERMANENT = new Set([
// and a dome asked of a server without ZoneDomes or the domes helper.
'bad-option',
'dome-unavailable',
// runicnpc stage 4 (D243): a profile the server does not have, or no RunicNPC.
'unknown-profile',
'runicnpc-missing',
])
const BUDGETS = [
@@ -472,7 +478,10 @@ function placeVerb({ id, kind, budget, max, label, description, source, example
required: true,
example,
source,
description: `Which of the server's own ${noun} to place.`,
description:
kind === 'npc'
? "Which NPCs: one of this site's NPC profiles (Admin → Rust NPC profiles, on a server with RunicNPC), or one of the server's own scientists."
: `Which of the server's own ${noun} to place.`,
},
{
name: 'count',
@@ -492,8 +501,12 @@ function placeVerb({ id, kind, budget, max, label, description, source, example
],
async perform({ runId, idempotencyKey, params, verify }) {
const known = PLACEABLE.find((x) => x.key === String(params.prefab || '').trim())
if (!known || known.kind !== kind) {
const picked = String(params.prefab || '').trim()
// D243: one of the site's RunicNPC profiles, beside Rust's own.
const profile = kind === 'npc' && picked.startsWith(npcs.PROFILE_PREFIX) ? picked.slice(npcs.PROFILE_PREFIX.length) : null
const known = profile === null ? PLACEABLE.find((x) => x.key === picked) : null
if (profile !== null ? !npcProfile.NAME_RULE.test(profile) : !known || known.kind !== kind) {
return { ok: false, retry: false, error: `"${params.prefab}" is not one of the ${noun} a Rust server places for events` }
}
@@ -513,6 +526,11 @@ function placeVerb({ id, kind, budget, max, label, description, source, example
const found = await serverFor(where.serverId)
if (!found.ok) return found
if (profile !== null) {
const missing = await profileMissing(found.server, profile)
if (missing) return { ok: false, retry: false, error: missing }
}
if (verify) return { ok: true }
return place(
@@ -521,17 +539,34 @@ function placeVerb({ id, kind, budget, max, label, description, source, example
{
runId: String(runId),
key: idempotencyKey,
prefab: known.key,
...(profile !== null ? { profile } : { prefab: known.key }),
count,
...(spread === undefined ? {} : { spread }),
...where.wire,
},
known.label.toLowerCase(),
profile !== null ? `NPCs of the profile "${profile}"` : known.label.toLowerCase(),
)
},
}
}
/**
* Why a profile cannot be placed on this server, from what the site knows, or
* null (D243). A server without RunicNPC offers only Rust's own until stage 9;
* a profile the site does not push there is not on it. What the server itself
* holds is the plugin's to answer (`unknown-profile`).
*/
async function profileMissing(server, profile) {
const [list, profiles] = await Promise.all([npcsDb.listNpcServers(), npcsDb.listProfiles()])
const here = list.find((s) => s.id === server.id)
const name = server.name || server.id
if (!npcs.npcReady(here)) return `${name} cannot place the profile "${profile}": ${npcs.npcAbsence(here)}. Pick one of the server's own scientists.`
if (!profiles.some((p) => !p.replaced && p.name === profile && npcs.covers(p, server.id))) {
return `the site has no NPC profile "${profile}" for ${name} (Admin → Rust NPC profiles)`
}
return null
}
const ACTIONS = [
{
...WORLD_COMMON,
@@ -688,7 +723,7 @@ const ACTIONS = [
max: MAX_NPCS,
label: 'Place NPCs',
description:
'Scientists or guards at a monument or a point. Taken away at teardown. The game does not save NPCs, so a restart ends them; the ledger then says so.',
"NPCs of one of the site's profiles (RunicNPC), or the server's own scientists or guards, at a monument or a point. Taken away at teardown. NPCs are never saved, so a restart ends them; the ledger then says so.",
source: 'rust.options.npcs',
example: 'npc.scientist',
}),
@@ -720,12 +755,27 @@ const OPTION_SOURCES = [
},
// From the mirror, so both answer with every server off (the field they fill
// must never be taken away by an outage, MODULE_API §2.4). One per verb (D97).
//
// D243: the NPC list puts the site's RunicNPC profiles first, then Rust's own,
// each group named. A site without a server that has RunicNPC has no profile
// rows, and its list reads as it always did.
...['crate', 'npc'].map((kind) => ({
id: kind === 'npc' ? 'rust.options.npcs' : 'rust.options.crates',
label: kind === 'npc' ? 'NPCs' : 'Crates',
description: `The ${kind === 'npc' ? 'NPCs' : 'crates'} a Rust server places for events.`,
description:
kind === 'npc'
? "The site's NPC profiles, on servers with RunicNPC, then the NPCs a Rust server places of its own."
: 'The crates a Rust server places for events.',
async resolve() {
return PLACEABLE.filter((p) => p.kind === kind).map((p) => ({ value: p.key, label: p.label }))
const own = PLACEABLE.filter((p) => p.kind === kind).map((p) => ({ value: p.key, label: p.label }))
if (kind !== 'npc') return own
let profiles = []
try {
profiles = await npcs.optionRows()
} catch (err) {
log.warn('could not list the NPC profiles for the picker', { error: err.message })
}
return profiles.length ? [...profiles, ...own.map((row) => ({ ...row, group: "Rust's own" }))] : own
},
})),
{

View File

@@ -40,6 +40,8 @@ const db = require('./model/events/events.db')
const engagement = require('./engagement/emit')
const eventWorld = require('./eventWorld')
const links = require('./model/links/links.model')
const npcs = require('./model/npcs/npcs.model')
const npcsDb = require('./model/npcs/npcs.db')
const permissionsDb = require('./model/permissions/permissions.db')
const sidecar = require('./sidecarClient')
@@ -158,6 +160,15 @@ async function apply(serverId, item, server = null) {
for (const [weapon, kills] of Object.entries(weapons)) {
await db.addWeaponKills(at, weapon, Math.floor(Number(kills) || 0))
}
// RunicNPC's NPCs, by profile name (D247). Credited to the site profile
// that name was last pushed as on this server, so "this profile only"
// can be counted; 0 for a name the site never pushed.
const profiles = frame.npcProfileKills && typeof frame.npcProfileKills === 'object' ? frame.npcProfileKills : {}
for (const [profile, kills] of Object.entries(profiles)) {
const n = Math.floor(Number(kills) || 0)
if (n > 0) await npcsDb.addKills(at, profile, await npcs.siteProfileFor(serverId, profile), n)
}
break
}

View File

@@ -0,0 +1,176 @@
// ── A RunicNPC profile, checked here as RunicNPC checks it (D238) ───────────
//
// The site authors profiles and pushes them to each server's RunicNPC, which
// checks them again and refuses what it cannot use (docs/runicnpc/API.md). The
// site checks first so an admin reads the reason on the form, not in a push
// report minutes later. The rules below are RunicNPC's `ValidateProfile`, in
// the same order and with the same sentences, less the one only the server can
// answer: whether it has each kit. That one is checked against the kit list
// each server last reported (`npcs.model.js`).
//
// Pure functions: no database, no sidecar.
/** RunicNPC's rule for a profile, placement or route name. */
const NAME_RULE = /^[a-z0-9_-]{1,40}$/
/** A plain scientist prefab, as RunicNPC resolves one (`scientistnpc_*`). */
const PREFAB_RULE = /^scientistnpc_[a-z0-9_]{1,40}$/
/** The prefabs the form offers; RunicNPC accepts any plain `scientistnpc_*`. */
const PREFABS = ['scientistnpc_roam', 'scientistnpc_heavy', 'scientistnpc_patrol', 'scientistnpc_roamtethered', 'scientistnpc_full_any']
const ROLES = ['roamer', 'sentry']
/** D247: how a profile's kills are counted. `server` is the default. */
const KILLS_SCOPES = ['server', 'name', 'profile']
const NAMES_MAX = 20
const DISPLAY_NAME_MAX = 32
const KITS_MAX = 20
const THRESHOLDS_MAX = 10
/** RunicNPC's defaults (API.md, D238), so a form may leave a value out. */
function defaults() {
return {
names: [],
kits: [],
prefab: 'scientistnpc_roam',
role: 'roamer',
movement: { mode: 'wander', radius: 20 },
health: 150,
damageDealt: 1,
damageTaken: { head: 1, body: 1, legs: 1 },
aimCone: 2,
ranges: { sense: 30, loseTarget: 40, chase: 40, attack: 30 },
visionCone: -0.8,
sleepDistance: 160,
healthThresholds: [],
}
}
function num(value) {
if (value === null || value === undefined || value === '') return NaN
const n = Number(value)
return Number.isFinite(n) ? n : NaN
}
/** `wander`, `monument` or `route:<name>` (D233). */
function parseMode(mode) {
const text = String(mode === undefined || mode === null ? '' : mode).trim()
if (text === 'wander' || text === 'monument') return { kind: text }
const m = /^route:([a-z0-9_-]{1,40})$/.exec(text)
return m ? { kind: 'route', route: m[1] } : null
}
/** A movement, checked: `{ ok, value }` or `{ ok: false, error }`. */
function checkMovement(input) {
const m = input || {}
const mode = String(m.mode === undefined || m.mode === null ? '' : m.mode).trim()
const parsed = parseMode(mode)
if (!parsed) return { ok: false, error: `movement.mode: '${mode}' is not wander, monument or route:<name>` }
const radius = m.radius === undefined || m.radius === null || m.radius === '' ? (parsed.kind === 'wander' ? 20 : 0) : num(m.radius)
if (Number.isNaN(radius) || radius < 0) return { ok: false, error: 'movement.radius: a number of metres' }
if (parsed.kind === 'wander' && !(radius > 0)) return { ok: false, error: "movement.radius: a wanderer's radius must be above 0" }
return { ok: true, value: { mode, radius } }
}
/**
* Checks a profile's body as the admin form sends it, merged over RunicNPC's
* defaults. Resolves `{ ok: true, value }` with the body RunicNPC will read, or
* `{ ok: false, error }` with RunicNPC's own sentence for the first problem.
*/
function checkBody(input) {
const b = { ...defaults(), ...(input || {}) }
const names = Array.isArray(b.names) ? b.names.map((n) => String(n === null || n === undefined ? '' : n).trim()) : null
if (!names || names.length === 0 || names.some((n) => !n)) return { ok: false, error: 'names: give at least one, and no blank ones' }
if (names.length > NAMES_MAX) return { ok: false, error: `names: at most ${NAMES_MAX}` }
if (names.some((n) => n.length > DISPLAY_NAME_MAX)) return { ok: false, error: `names: each at most ${DISPLAY_NAME_MAX} characters` }
const kits = Array.isArray(b.kits) ? [...new Set(b.kits.map((k) => String(k === null || k === undefined ? '' : k).trim()).filter(Boolean))] : null
if (!kits || kits.length === 0) return { ok: false, error: 'kits: give at least one; Kits is how an NPC is equipped (D217)' }
if (kits.length > KITS_MAX) return { ok: false, error: `kits: at most ${KITS_MAX}` }
const prefab = String(b.prefab || '').trim()
if (!PREFAB_RULE.test(prefab)) return { ok: false, error: `prefab: '${prefab}' is not one of Rust's scientist prefabs (scientistnpc_*)` }
if (!ROLES.includes(b.role)) return { ok: false, error: `role: '${b.role}' is not roamer or sentry` }
const movement = checkMovement(b.movement)
if (!movement.ok) return movement
const health = num(b.health)
if (!(health > 0)) return { ok: false, error: 'health: must be above 0' }
const damageDealt = num(b.damageDealt)
if (Number.isNaN(damageDealt) || damageDealt < 0) return { ok: false, error: 'damageDealt: must not be negative' }
const taken = b.damageTaken || {}
const damageTaken = { head: num(taken.head), body: num(taken.body), legs: num(taken.legs) }
if (Object.values(damageTaken).some((v) => Number.isNaN(v) || v < 0)) return { ok: false, error: 'damageTaken: head, body and legs must not be negative' }
const aimCone = num(b.aimCone)
if (Number.isNaN(aimCone) || aimCone < 0) return { ok: false, error: 'aimCone: must not be negative' }
const r = b.ranges || {}
const ranges = { sense: num(r.sense), loseTarget: num(r.loseTarget), chase: num(r.chase), attack: num(r.attack) }
if (!(ranges.sense > 0) || !(ranges.attack > 0) || !(ranges.loseTarget >= ranges.sense) || !(ranges.chase >= 0)) {
return { ok: false, error: 'ranges: sense and attack above 0, loseTarget at least sense, chase not negative' }
}
const visionCone = num(b.visionCone)
if (!(visionCone >= -1 && visionCone <= 1)) return { ok: false, error: 'visionCone: between -1 and 1' }
const sleepDistance = num(b.sleepDistance)
if (!(sleepDistance >= 0)) return { ok: false, error: 'sleepDistance: 0 (never sleeps) or more' }
const thresholds = Array.isArray(b.healthThresholds) ? b.healthThresholds.map(num) : null
if (!thresholds || thresholds.some((t) => !(t > 0 && t < 1))) return { ok: false, error: 'healthThresholds: fractions between 0 and 1' }
if (thresholds.length > THRESHOLDS_MAX) return { ok: false, error: `healthThresholds: at most ${THRESHOLDS_MAX}` }
return {
ok: true,
value: {
names,
kits,
prefab,
role: b.role,
movement: movement.value,
health,
damageDealt,
damageTaken,
aimCone,
ranges,
visionCone,
sleepDistance,
healthThresholds: [...new Set(thresholds)].sort((x, y) => y - x),
},
}
}
/**
* A profile read from a server's own RunicNPC (D244), made into a body this
* module will save. Whatever RunicNPC holds is kept as it is; only a shape this
* module could not push back is refused, with the reason.
*/
function fromServer(name, body) {
if (!NAME_RULE.test(String(name || ''))) return { ok: false, error: `'${name}' is not a profile name RunicNPC could hold` }
return checkBody(body || {})
}
/** A player-facing label for a profile: its first NPC name, else its own name. */
function labelOf(profile) {
const names = profile && profile.body && Array.isArray(profile.body.names) ? profile.body.names : []
return names[0] || (profile && profile.name) || ''
}
module.exports = {
NAME_RULE,
PREFAB_RULE,
PREFABS,
ROLES,
KILLS_SCOPES,
defaults,
parseMode,
checkMovement,
checkBody,
fromServer,
labelOf,
}

View File

@@ -0,0 +1,308 @@
// ── SQL for RunicNPC profiles, their push, and per-profile kills ───────────
//
// Profiles are this module's own (runicnpc PLAN.md stage 4). Placements are
// NOT stored here: they live on each server (D222), and the site reads and
// edits them through the bridge. What each server said about RunicNPC is read
// out of the state row's stored hello (`raw`), so the pages work while a server
// is off.
const core = require('../../core')
const PROFILES = 'rust_npc_profiles'
const PROFILE_SERVERS = 'rust_npc_profile_servers'
const SYNC = 'rust_npc_sync'
const KILLS = 'rust_npc_kills'
const SERVERS = 'rust_servers'
const STATE = 'rust_server_state'
const PLAYERS = 'rust_players'
/** The driver hands JSON_EXTRACT and TEXT back as strings. A bad value is no value. */
function json(value, fallback) {
if (value === null || value === undefined) return fallback
if (typeof value !== 'string') return value
try {
return JSON.parse(value)
} catch {
return fallback
}
}
function shape(row, serverIds) {
return {
id: Number(row.id),
name: row.name,
body: json(row.body, {}),
allServers: Boolean(row.allServers),
servers: serverIds,
killsScope: row.killsScope || 'server',
adoptedFrom: row.adoptedFrom || null,
replaced: Boolean(row.replaced),
updatedAt: row.updatedAt ? new Date(row.updatedAt).toISOString() : null,
}
}
const COLUMNS = `id, name, body, all_servers AS allServers, kills_scope AS killsScope,
adopted_from AS adoptedFrom, replaced, updated_at AS updatedAt`
/** Every profile, with the servers each names, by name. */
async function listProfiles() {
const [rows, links] = await Promise.all([
core.query(`SELECT ${COLUMNS} FROM ${PROFILES} ORDER BY name ASC, id ASC`),
core.query(`SELECT profile_id AS profileId, server_id AS serverId FROM ${PROFILE_SERVERS} ORDER BY server_id ASC`),
])
const by = new Map()
for (const l of links) {
const id = Number(l.profileId)
if (!by.has(id)) by.set(id, [])
by.get(id).push(l.serverId)
}
return rows.map((r) => shape(r, by.get(Number(r.id)) || []))
}
async function getProfile(id) {
const rows = await core.query(`SELECT ${COLUMNS} FROM ${PROFILES} WHERE id = ?`, [id])
if (!rows[0]) return null
const links = await core.query(`SELECT server_id AS serverId FROM ${PROFILE_SERVERS} WHERE profile_id = ? ORDER BY server_id ASC`, [id])
return shape(rows[0], links.map((l) => l.serverId))
}
/**
* Writes one profile and replaces its server list. The model has checked the
* servers and the name first, so nothing below has anything left to refuse.
*/
async function saveProfile({ id = null, name, body, allServers, servers, killsScope, adoptedFrom = null, replaced = false }, userId = null) {
let profileId = id
const args = [name, JSON.stringify(body), allServers ? 1 : 0, killsScope, replaced ? 1 : 0]
if (profileId) {
await core.query(
`UPDATE ${PROFILES} SET name = ?, body = ?, all_servers = ?, kills_scope = ?, replaced = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
[...args, profileId],
)
await core.query(`DELETE FROM ${PROFILE_SERVERS} WHERE profile_id = ?`, [profileId])
} else {
const res = await core.query(
`INSERT INTO ${PROFILES} (name, body, all_servers, kills_scope, replaced, adopted_from, created_by) VALUES (?, ?, ?, ?, ?, ?, ?)`,
[...args, adoptedFrom, userId],
)
profileId = Number(res.insertId)
}
if (!allServers && servers.length > 0) {
await core.query(
`INSERT INTO ${PROFILE_SERVERS} (profile_id, server_id) VALUES ${servers.map(() => '(?, ?)').join(', ')}`,
servers.flatMap((serverId) => [profileId, serverId]),
)
}
return profileId
}
async function deleteProfile(id) {
await core.query(`DELETE FROM ${PROFILES} WHERE id = ?`, [id])
}
/**
* Each configured server, in the operator's order, with what its last status
* said about RunicNPC (`{ loaded, version, api }`, null for a server that never
* said) and about its connection.
*/
async function listNpcServers() {
const rows = await core.query(
`SELECT s.id, s.name, s.enabled, st.online, st.boot_id AS bootId,
JSON_EXTRACT(st.raw, '$.integrations.runicNpc') AS runicNpc,
JSON_EXTRACT(st.raw, '$.worldReady') AS worldReady
FROM ${SERVERS} s
LEFT JOIN ${STATE} st ON st.server_id = s.id
ORDER BY s.sort_order ASC, s.id ASC`,
)
return rows.map((r) => {
const npc = json(r.runicNpc, null)
const ready = json(r.worldReady, null)
return {
id: r.id,
name: r.name,
enabled: Boolean(r.enabled),
online: r.online === null || r.online === undefined ? null : Boolean(Number(r.online)),
bootId: r.bootId || null,
worldReady: ready === null ? null : Boolean(ready),
runicNpc: npc && typeof npc === 'object' ? { loaded: Boolean(npc.loaded), version: npc.version || null, api: Number(npc.api) || 0 } : null,
}
})
}
// ── The push's record ───────────────────────────────────────────────────────
function syncShape(r) {
return {
serverId: r.serverId,
adoptedAt: r.adoptedAt ? new Date(r.adoptedAt).toISOString() : null,
state: r.state,
syncedHash: r.syncedHash || null,
bootId: r.bootId || null,
pushed: json(r.pushed, {}),
refused: json(r.refused, {}),
error: r.error || null,
lastAttemptAt: r.lastAttemptAt ? new Date(r.lastAttemptAt).toISOString() : null,
syncedAt: r.syncedAt ? new Date(r.syncedAt).toISOString() : null,
}
}
const SYNC_COLUMNS = `server_id AS serverId, adopted_at AS adoptedAt, state, synced_hash AS syncedHash, boot_id AS bootId,
pushed, refused, error, last_attempt_at AS lastAttemptAt, synced_at AS syncedAt`
async function listSync() {
return (await core.query(`SELECT ${SYNC_COLUMNS} FROM ${SYNC}`)).map(syncShape)
}
async function getSync(serverId) {
const rows = await core.query(`SELECT ${SYNC_COLUMNS} FROM ${SYNC} WHERE server_id = ?`, [serverId])
return rows[0] ? syncShape(rows[0]) : null
}
/** Records that a server's own profiles were read and imported (D244). Once. */
async function markAdopted(serverId) {
await core.query(
`INSERT INTO ${SYNC} (server_id, adopted_at) VALUES (?, CURRENT_TIMESTAMP)
ON DUPLICATE KEY UPDATE adopted_at = COALESCE(adopted_at, CURRENT_TIMESTAMP)`,
[serverId],
)
}
/** One push's outcome. A failure keeps the last good hash and map, so a retry is still a change. */
async function putSync(serverId, { state, syncedHash = null, bootId = null, pushed = null, refused = null, error = null }) {
const ok = state === 'ok'
await core.query(
`INSERT INTO ${SYNC} (server_id, state, synced_hash, boot_id, pushed, refused, error, last_attempt_at, synced_at)
VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, ${ok ? 'CURRENT_TIMESTAMP' : 'NULL'})
ON DUPLICATE KEY UPDATE state = VALUES(state),
synced_hash = ${ok ? 'VALUES(synced_hash)' : 'synced_hash'},
boot_id = ${ok ? 'VALUES(boot_id)' : 'boot_id'},
pushed = ${ok ? 'VALUES(pushed)' : 'pushed'},
refused = ${ok ? 'VALUES(refused)' : 'refused'},
error = VALUES(error),
last_attempt_at = CURRENT_TIMESTAMP,
synced_at = ${ok ? 'CURRENT_TIMESTAMP' : 'synced_at'}`,
[serverId, state, syncedHash, bootId, pushed ? JSON.stringify(pushed) : null, refused ? JSON.stringify(refused) : null, error ? String(error).slice(0, 191) : null],
)
}
/** Asks for a push on the next tick, after an admin's edit. */
async function markDirty(serverIds = null) {
if (Array.isArray(serverIds) && serverIds.length === 0) return
if (serverIds) {
await core.query(`UPDATE ${SYNC} SET synced_hash = NULL WHERE server_id IN (${serverIds.map(() => '?').join(', ')})`, serverIds)
} else {
await core.query(`UPDATE ${SYNC} SET synced_hash = NULL`)
}
}
// ── Kills (D247) ────────────────────────────────────────────────────────────
async function addKills({ serverId, wipeId, steamId }, profile, siteProfileId, kills) {
if (!serverId || !steamId || !profile || !(kills > 0)) return
await core.query(
`INSERT INTO ${KILLS} (server_id, wipe_id, steam_id, profile, site_profile_id, kills)
VALUES (?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE kills = kills + VALUES(kills)`,
[serverId, wipeId || '', steamId, String(profile).slice(0, 40), Number(siteProfileId) || 0, kills],
)
}
/**
* The WHERE clause for one profile's kills, by its scope (D247), and the page's
* wipe:
*
* `server` that name, on the server being looked at
* `name` that name, on every server
* `profile` that site profile, on every server it was pushed to
*
* `wipe`: a wipe id limits the server being looked at to that wipe; for a scope
* that reaches other servers, their CURRENT wipe is counted when the page shows
* this server's current wipe, and only this server's rows otherwise (another
* server's past wipes are not this page's). `null` is all time.
*/
function scopeWhere({ scope, name, siteProfileId, serverId, wipeId = null, currentWipe = false }) {
const where = []
const args = []
if (scope === 'profile') {
where.push('k.site_profile_id = ?')
args.push(siteProfileId)
} else {
where.push('k.profile = ?')
args.push(name)
}
if (scope === 'server') {
where.push('k.server_id = ?')
args.push(serverId)
if (wipeId !== null) {
where.push('k.wipe_id = ?')
args.push(wipeId)
}
} else if (wipeId !== null) {
if (currentWipe) {
where.push(`k.wipe_id = (SELECT COALESCE(st.wipe_id, '') FROM ${STATE} st WHERE st.server_id = k.server_id)`)
} else {
where.push('k.server_id = ? AND k.wipe_id = ?')
args.push(serverId, wipeId)
}
}
return { sql: where.join(' AND '), args }
}
/** One profile's ranking, most kills first, ties by Steam id as every other board. */
async function ranking(scope, limit = 50) {
const w = scopeWhere(scope)
return (await core.query(
`SELECT k.steam_id AS steamId, p.name, SUM(k.kills) AS kills
FROM ${KILLS} k
LEFT JOIN ${PLAYERS} p ON p.steam_id = k.steam_id
WHERE ${w.sql}
GROUP BY k.steam_id, p.name
HAVING kills > 0
ORDER BY kills DESC, k.steam_id ASC
LIMIT ?`,
[...w.args, Math.max(1, Math.min(200, Number(limit) || 50))],
)).map((r) => ({ steamId: String(r.steamId), name: r.name || null, value: Number(r.kills) || 0 }))
}
/** One player's kills by profile name on one server, for the wipe (or all time). */
async function playerKills({ serverId, steamId, wipeId = null }) {
return (await core.query(
`SELECT profile, SUM(kills) AS kills
FROM ${KILLS}
WHERE server_id = ? AND steam_id = ? ${wipeId !== null ? 'AND wipe_id = ?' : ''}
GROUP BY profile
ORDER BY kills DESC, profile ASC`,
wipeId !== null ? [serverId, steamId, wipeId] : [serverId, steamId],
)).map((r) => ({ profile: r.profile, kills: Number(r.kills) || 0 }))
}
/** A player's kills by server and profile name, current wipes, for their own account page. */
async function ownKills(steamIds) {
if (!steamIds || steamIds.length === 0) return []
return (await core.query(
`SELECT k.server_id AS serverId, k.profile, SUM(k.kills) AS kills
FROM ${KILLS} k
JOIN ${STATE} st ON st.server_id = k.server_id AND k.wipe_id = COALESCE(st.wipe_id, '')
WHERE k.steam_id IN (${steamIds.map(() => '?').join(', ')})
GROUP BY k.server_id, k.profile
ORDER BY k.server_id ASC, kills DESC`,
steamIds,
)).map((r) => ({ serverId: r.serverId, profile: r.profile, kills: Number(r.kills) || 0 }))
}
module.exports = {
listProfiles,
getProfile,
saveProfile,
deleteProfile,
listNpcServers,
listSync,
getSync,
markAdopted,
putSync,
markDirty,
addKills,
scopeWhere,
ranking,
playerKills,
ownKills,
}

View File

@@ -0,0 +1,452 @@
// ── RunicNPC profiles and placements (docs/runicnpc/PLAN.md stage 4) ───────
//
// Profiles are the site's: authored here, for one server, several, or the
// fleet (the zone-presets shape, D210), and pushed to each server's RunicNPC,
// which is then managed by this site (D221). Before the first push to a server
// its own profiles are read and adopted (D244); one whose name a site profile
// already has there is kept as "replaced" (D251).
//
// Placements are the SERVER's (D222). This model reads and edits them through
// the bridge and keeps no copy: a server that is off has no placements to show,
// and says so.
const crypto = require('node:crypto')
const client = require('../../sidecarClient')
const servers = require('../servers/servers.model')
const serversDb = require('../servers/servers.db')
const db = require('./npcs.db')
const shape = require('./npcProfile')
/** The RunicNPC API the bridge's `npc.*` commands need (D249). */
const API_NEEDED = 3
class NpcError extends Error {
constructor(message, status = 400) {
super(message)
this.status = status
}
}
function covers(profile, serverId) {
return profile.allServers || profile.servers.includes(serverId)
}
/** Profiles that are pushed: every one not kept aside as replaced (D251). */
function active(profiles) {
return profiles.filter((p) => !p.replaced)
}
/** Whether a server can take `npc.*` commands, from what it last said. */
function npcReady(server) {
return Boolean(server && server.runicNpc && server.runicNpc.loaded && server.runicNpc.api >= API_NEEDED)
}
/** Why a server cannot, in words, or null. */
function npcAbsence(server) {
if (!server) return 'no such server'
if (!server.runicNpc) return 'it has not said whether it has RunicNPC (a bridge older than protocol 13, or never reached)'
if (!server.runicNpc.loaded) return 'RunicNPC is not loaded on it'
if (server.runicNpc.api < API_NEEDED) return `its RunicNPC ${server.runicNpc.version || ''} answers API ${server.runicNpc.api}, and the site needs ${API_NEEDED}`.replace(' ', ' ')
return null
}
// ── What a server is sent ───────────────────────────────────────────────────
/**
* The profiles one server is pushed, as RunicNPC reads them, and the name → site
* profile id map a kill is credited through (D247). Sorted, so an unchanged set
* hashes the same on every tick.
*/
function desiredFor(serverId, profiles) {
const set = {}
const map = {}
for (const p of active(profiles).filter((x) => covers(x, serverId)).sort((a, b) => a.name.localeCompare(b.name) || a.id - b.id)) {
if (set[p.name]) continue
set[p.name] = p.body
map[p.name] = p.id
}
const hash = crypto.createHash('sha256').update(JSON.stringify(Object.keys(set).sort().map((n) => [n, set[n]]))).digest('hex')
return { profiles: set, map, hash }
}
// ── Adoption (D244, D251) ───────────────────────────────────────────────────
/**
* Imports a server's own profiles as profiles for that server alone, before the
* site first pushes there, so nothing on it changes. Where a site profile of the
* same name already covers it, the site's wins (D251): the server's own is kept,
* marked replaced, for an admin to restore. Returns what it did, per name.
*/
async function adopt(serverId, theirs, userId = null) {
const existing = await db.listProfiles()
const done = []
for (const [name, body] of Object.entries(theirs || {}).sort(([a], [b]) => a.localeCompare(b))) {
if (!shape.NAME_RULE.test(name)) {
done.push({ name, outcome: 'skipped', reason: 'not a name RunicNPC could hold' })
continue
}
const checked = shape.checkBody(body || {})
// Kept whole even when this module would refuse it on its form: adoption
// changes nothing on the server, and RunicNPC already said whether it uses it.
const kept = checked.ok ? checked.value : { ...shape.defaults(), ...(body || {}) }
const clash = active(existing).find((p) => p.name === name && covers(p, serverId))
await db.saveProfile(
{ name, body: kept, allServers: false, servers: [serverId], killsScope: 'server', adoptedFrom: serverId, replaced: Boolean(clash) },
userId,
)
done.push({ name, outcome: clash ? 'replaced' : 'adopted', ...(clash ? { by: clash.id } : {}) })
}
await db.markAdopted(serverId)
return done
}
// ── The admin page ──────────────────────────────────────────────────────────
async function describe() {
const [list, profiles, sync] = await Promise.all([db.listNpcServers(), db.listProfiles(), db.listSync()])
const syncBy = new Map(sync.map((s) => [s.serverId, s]))
return {
servers: list.map((s) => {
const row = syncBy.get(s.id) || null
return {
...s,
ready: npcReady(s),
absence: npcAbsence(s),
sync: row && { state: row.state, adoptedAt: row.adoptedAt, syncedAt: row.syncedAt, refused: row.refused, error: row.error },
}
}),
profiles: profiles.map((p) => ({ ...p, label: shape.labelOf(p) })),
prefabs: shape.PREFABS,
killsScopes: shape.KILLS_SCOPES,
defaults: shape.defaults(),
}
}
/**
* The kits each covered server that answers has; a server that does not answer
* is left to RunicNPC, which refuses a missing kit when the profile is pushed.
*/
async function checkKits(kits, covered) {
for (const s of covered) {
if (!s.enabled) continue
const row = await serversDb.getServer(s.id)
if (!row) continue
const result = await client.kits(servers.withToken(row))
const data = result.ok ? result.data || {} : null
if (!data || data.kind !== 'kits.list') continue
const have = new Set((data.kits || []).map((k) => String(k && k.name).toLowerCase()))
const missing = kits.find((k) => !have.has(k.toLowerCase()))
if (missing) throw new NpcError(`kits: ${s.name || s.id} has no kit '${missing}'`)
}
}
async function validate(input, id = null) {
const name = String((input && input.name) || '').trim()
if (!shape.NAME_RULE.test(name)) throw new NpcError('name: 1 to 40 of a-z, 0-9, _ and -, as RunicNPC names a profile')
const allServers = input.allServers === true
const requested = Array.isArray(input.servers) ? [...new Set(input.servers.map(String))] : []
if (!allServers && requested.length === 0) throw new NpcError('a profile is for at least one server, or for every server')
const killsScope = input.killsScope === undefined || input.killsScope === null || input.killsScope === '' ? 'server' : String(input.killsScope)
if (!shape.KILLS_SCOPES.includes(killsScope)) throw new NpcError(`killsScope: ${shape.KILLS_SCOPES.join(', ')}`)
const list = await db.listNpcServers()
const byId = new Map(list.map((s) => [s.id, s]))
for (const s of requested) {
if (!byId.has(s)) throw new NpcError(`no server "${s}"`, 404)
}
const checked = shape.checkBody(input.body)
if (!checked.ok) throw new NpcError(checked.error)
const mine = { allServers, servers: requested }
const others = active(await db.listProfiles()).filter((p) => p.id !== id && p.name === name)
for (const other of others) {
if (allServers && other.allServers) throw new NpcError(`a profile called "${name}" is already on every server`, 409)
const shared = list.find((s) => covers(mine, s.id) && covers(other, s.id))
if (shared) throw new NpcError(`a profile called "${name}" is already on ${shared.name || shared.id}`, 409)
}
const covered = allServers ? list : requested.map((s) => byId.get(s))
await checkKits(checked.value.kits, covered)
return { name, body: checked.value, allServers, servers: allServers ? [] : requested, killsScope }
}
/** The servers whose pushed set a change to this profile moves. */
function reach(profile) {
return profile.allServers ? null : profile.servers
}
async function create(input, userId = null) {
const clean = await validate(input)
const id = await db.saveProfile(clean, userId)
await db.markDirty(clean.allServers ? null : clean.servers)
return db.getProfile(id)
}
async function update(id, input) {
const existing = await db.getProfile(id)
if (!existing) throw new NpcError('no such profile', 404)
if (existing.replaced) throw new NpcError('this profile is kept aside as replaced (D251): restore it before editing it', 409)
const clean = await validate(input, existing.id)
await db.saveProfile({ ...clean, id: existing.id })
const before = reach(existing)
const after = clean.allServers ? null : clean.servers
await db.markDirty(before === null || after === null ? null : [...new Set([...before, ...after])])
return db.getProfile(existing.id)
}
/**
* Deletes a profile. Its placements on each server wait, and spawn again if a
* profile of that name returns (D237).
*/
async function remove(id) {
const existing = await db.getProfile(id)
if (!existing) throw new NpcError('no such profile', 404)
await db.deleteProfile(existing.id)
if (!existing.replaced) await db.markDirty(reach(existing))
return true
}
/**
* Brings a replaced profile back into use on its server (D251), when no site
* profile of its name covers that server any more.
*/
async function restore(id) {
const existing = await db.getProfile(id)
if (!existing) throw new NpcError('no such profile', 404)
if (!existing.replaced) throw new NpcError('this profile is in use already')
const clash = active(await db.listProfiles()).find((p) => p.name === existing.name && existing.servers.some((s) => covers(p, s)))
if (clash) {
const names = new Map((await db.listNpcServers()).map((srv) => [srv.id, srv.name || srv.id]))
const where = existing.servers.map((id) => names.get(id) || id).join(', ')
throw new NpcError(`the site's profile "${clash.name}" is on ${where}: change its servers or delete it first`, 409)
}
await db.saveProfile({ ...existing, replaced: false })
await db.markDirty(existing.servers)
return db.getProfile(existing.id)
}
// ── Placements, through the bridge (D245, D246) ─────────────────────────────
/** A refusal from the bridge, as an HTTP status and its own sentence. */
const REFUSALS = {
'runicnpc-missing': 409,
'runicnpc-old': 409,
'not-found': 404,
malformed: 400,
refused: 400,
}
async function reachable(serverId) {
const row = await serversDb.getServer(serverId)
if (!row) throw new NpcError(`no server "${serverId}"`, 404)
if (!row.enabled) throw new NpcError(`the Rust server "${row.name || serverId}" is switched off`, 409)
return servers.withToken(row)
}
function transport(server, result) {
const name = server.name || server.id
if (result.status === 'http-503') return new NpcError(`${name} has no game connected, so its placements cannot be read or changed now`, 503)
if (result.status === 'timeout' || result.status === 'http-504') return new NpcError(`${name} did not answer in time`, 504)
if (result.status === 'protocol-mismatch') return new NpcError(`${name}'s sidecar speaks a different protocol: update the module or the sidecar`, 502)
return new NpcError(`${name} could not be reached (${result.status})`, 502)
}
function answer(server, result, kind) {
if (!result.ok) throw transport(server, result)
const data = result.data || {}
if (data.kind === 'npc.error') throw new NpcError(data.message || data.reason || 'refused', REFUSALS[data.reason] || 400)
if (data.kind !== kind) throw new NpcError(`${server.name || server.id} answered something else (${data.kind || 'nothing'})`, 502)
return data
}
/**
* One server's placements, with its routes (for the movement picker) and the
* cost warning (D227). Each placement: its id, values, how many of its NPCs
* are alive, what it waits for (D237) and its note (D239).
*/
async function listPlacements(serverId) {
const server = await reachable(serverId)
const data = answer(server, await client.npcPlacements(server), 'npc.placements')
return {
placements: (data.placements || []).map((p) => ({
id: p.id,
placement: p.placement || {},
alive: Number(p.alive) || 0,
waiting: p.waiting || null,
note: p.note || null,
lastError: p.lastError || null,
})),
routes: Array.isArray(data.routes) ? data.routes : [],
cost: data.cost || null,
}
}
/** A placement's values from the form (D246): `/rnpc place`'s options, checked as RunicNPC does. */
function placementBody(input, { position }) {
const p = input || {}
const profile = String(p.profile || '').trim()
if (!shape.NAME_RULE.test(profile)) throw new NpcError('profile: a profile name')
const count = p.count === undefined || p.count === null || p.count === '' ? 1 : Number(p.count)
if (!Number.isInteger(count) || count < 1 || count > 50) throw new NpcError('count: a whole number, 1 to 50')
const respawn = p.respawn === undefined || p.respawn === null || p.respawn === '' ? 300 : Number(p.respawn)
if (!Number.isFinite(respawn) || respawn < 1 || respawn > 86400) throw new NpcError('respawn: seconds, 1 to 86400')
const respawnMode = p.respawnMode === undefined || p.respawnMode === null || p.respawnMode === '' ? 'each' : String(p.respawnMode)
if (respawnMode !== 'each' && respawnMode !== 'group') throw new NpcError('respawnMode: each or group')
const yaw = p.yaw === undefined || p.yaw === null || p.yaw === '' ? 0 : Number(p.yaw)
if (!Number.isFinite(yaw)) throw new NpcError('yaw: degrees')
const body = { profile, position, yaw, count, respawn, respawnMode }
if (p.movement && p.movement.mode) {
const m = shape.checkMovement(p.movement)
if (!m.ok) throw new NpcError(m.error)
body.movement = m.value
}
return body
}
function point(raw, { withY }) {
const r = raw || {}
const x = Number(r.x)
const z = Number(r.z)
if (!Number.isFinite(x) || !Number.isFinite(z)) throw new NpcError('position: x and z')
if (!withY) return { x, z }
const y = Number(r.y)
if (!Number.isFinite(y)) throw new NpcError('position: x, y and z')
return { x, y, z }
}
/**
* A new placement from a point on the live map (D245): x and z only. The server
* puts it on the ground there, checks it against the navmesh, and names it as in
* game (D246); the answer carries the name, where it landed and the cost warning.
*/
async function addPlacement(serverId, input) {
const server = await reachable(serverId)
const body = placementBody(input, { position: point(input && input.position, { withY: false }) })
const data = answer(server, await client.npcPlacement(server, { op: 'add', placement: body }), 'npc.ok')
return { id: data.id, position: data.position || null, built: Boolean(data.built), cost: data.cost || null }
}
/** New values for a placement. Its spot is kept unless the form sends one whole. */
async function setPlacement(serverId, id, input) {
const server = await reachable(serverId)
const current = (await listPlacements(serverId)).placements.find((p) => p.id === id)
if (!current) throw new NpcError(`there is no placement '${id}' on ${server.name || server.id}`, 404)
const position = input && input.position ? point(input.position, { withY: true }) : current.placement.position
const body = placementBody({ yaw: current.placement.yaw, ...input }, { position })
const data = answer(server, await client.npcPlacement(server, { op: 'set', id, placement: body }), 'npc.ok')
return { id: data.id || id, cost: data.cost || null }
}
async function changePlacement(serverId, op, body) {
const server = await reachable(serverId)
const data = answer(server, await client.npcPlacement(server, { op, ...body }), 'npc.ok')
return data
}
const removePlacement = (serverId, id) => changePlacement(serverId, 'remove', { id })
const renamePlacement = (serverId, id, to) => {
if (!shape.NAME_RULE.test(String(to || ''))) throw new NpcError('to: 1 to 40 of a-z, 0-9, _ and -')
return changePlacement(serverId, 'rename', { id, to })
}
const respawnPlacement = (serverId, id) => changePlacement(serverId, 'respawn', { id })
// ── Events: the "Place NPCs" picker (D243) ─────────────────────────────────
/** A profile's value in the picker: `profile:<name>`, beside Rust's own `npc.*`. */
const PROFILE_PREFIX = 'profile:'
/**
* The site's profiles, first, grouped, for any server that has RunicNPC. A site
* with no such server offers none (D243: Rust's own only until stage 9).
*/
async function optionRows() {
const [profiles, list] = await Promise.all([db.listProfiles(), db.listNpcServers()])
const ready = list.filter(npcReady)
if (ready.length === 0) return []
const seen = new Set()
const rows = []
for (const p of active(profiles)) {
if (!ready.some((s) => covers(p, s.id)) || seen.has(p.name)) continue
seen.add(p.name)
rows.push({ value: `${PROFILE_PREFIX}${p.name}`, label: `${shape.labelOf(p)} (${p.name})`, group: 'NPC profiles' })
}
return rows
}
// ── Kills (D247, D250, D252) ────────────────────────────────────────────────
/** The site profile a name was pushed as on a server, from the last push; 0 for none. */
async function siteProfileFor(serverId, name) {
const sync = await db.getSync(serverId)
return sync && sync.pushed && sync.pushed[name] ? Number(sync.pushed[name]) : 0
}
/** The profiles a server's leaderboard may rank by: those pushed to it, with their labels. */
async function boardProfiles(serverId) {
return active(await db.listProfiles())
.filter((p) => covers(p, serverId))
.map((p) => ({ id: p.id, name: p.name, label: shape.labelOf(p), killsScope: p.killsScope }))
}
/**
* One profile's ranking as seen from one server's page, counted as the profile
* says (D247). `wipeId` null is all time; `currentWipe` says whether it is the
* server's current wipe, which is what reaches other servers' current wipes.
*/
async function ranking({ serverId, profileId, wipeId = null, currentWipe = false, limit = 50 }) {
const profile = await db.getProfile(profileId)
if (!profile || profile.replaced) throw new NpcError('no such profile', 404)
if (!covers(profile, serverId)) throw new NpcError('that profile is not on this server', 404)
const rows = await db.ranking({ scope: profile.killsScope, name: profile.name, siteProfileId: profile.id, serverId, wipeId, currentWipe }, limit)
return { profile: { id: profile.id, name: profile.name, label: shape.labelOf(profile), killsScope: profile.killsScope }, rows }
}
/**
* The titles' standing for a `profilekills` rule (D250): the ranking of the
* profile on the server the rule is for, current wipe, as the profile counts.
*/
async function standing(serverId, profileId, wipeId, limit) {
const profile = await db.getProfile(profileId)
if (!profile || profile.replaced || !covers(profile, serverId)) return []
return db.ranking({ scope: profile.killsScope, name: profile.name, siteProfileId: profile.id, serverId, wipeId: wipeId || '', currentWipe: true }, limit)
}
/** One player's kills by profile on one server's page (D252), labelled where the site knows the profile. */
async function playerKills({ serverId, steamId, wipeId = null }) {
const [rows, profiles] = await Promise.all([db.playerKills({ serverId, steamId, wipeId }), boardProfiles(serverId)])
const byName = new Map(profiles.map((p) => [p.name, p]))
return rows.map((r) => ({ profile: r.profile, label: byName.has(r.profile) ? byName.get(r.profile).label : r.profile, kills: r.kills }))
}
module.exports = {
API_NEEDED,
PROFILE_PREFIX,
NpcError,
covers,
npcReady,
npcAbsence,
desiredFor,
adopt,
describe,
create,
update,
remove,
restore,
listPlacements,
addPlacement,
setPlacement,
removePlacement,
renamePlacement,
respawnPlacement,
optionRows,
siteProfileFor,
boardProfiles,
ranking,
standing,
playerKills,
}

View File

@@ -12,7 +12,7 @@ const SERVERS = 'rust_servers'
/** One server's rules, in precedence order. */
async function listRules(serverId) {
return core.query(
`SELECT id, stat, top_n AS topN, text, color
`SELECT id, stat, top_n AS topN, text, color, profile_id AS profile
FROM ${RULES}
WHERE server_id = ?
ORDER BY position ASC, id ASC`,
@@ -23,7 +23,7 @@ async function listRules(serverId) {
/** Every server's rules, for the admin list — one read rather than one per server. */
async function listAllRules() {
return core.query(
`SELECT server_id AS serverId, stat, top_n AS topN, text, color
`SELECT server_id AS serverId, stat, top_n AS topN, text, color, profile_id AS profile
FROM ${RULES}
ORDER BY server_id ASC, position ASC, id ASC`,
)
@@ -52,9 +52,9 @@ async function saveSettings(serverId, { mode, max, rules }) {
if (!rules.length) return
await core.query(
`INSERT INTO ${RULES} (server_id, position, stat, top_n, text, color)
VALUES ${rules.map(() => '(?, ?, ?, ?, ?, ?)').join(',')}`,
rules.flatMap((r, i) => [serverId, i, r.stat, r.topN, r.text, r.color]),
`INSERT INTO ${RULES} (server_id, position, stat, top_n, text, color, profile_id)
VALUES ${rules.map(() => '(?, ?, ?, ?, ?, ?, ?)').join(',')}`,
rules.flatMap((r, i) => [serverId, i, r.stat, r.topN, r.text, r.color, r.profile || null]),
)
}

View File

@@ -30,6 +30,9 @@ const crypto = require('node:crypto')
* `best` a column's MAX: the longest single kill (D174)
* `weapons` a named list's kills in `rust_weapon_kills` (§5.3)
* `gathered` a named list's amount in `rust_gather_totals` (D159)
* `npcProfile` one site NPC profile's kills, counted as the profile says
* (runicnpc stage 4, D247, D250). A rule on it names the profile,
* and carries its own text, like playtime.
*
* An admin may rename any category that has a default (D175); the ids are what
* a rule stores, so they never change.
@@ -59,6 +62,15 @@ const STATS = {
explosives: { label: 'Explosives thrown', title: 'Demolitionist', source: { sum: 'explosives' } },
missions: { label: 'Missions completed', title: 'Wayfarer', source: { sum: 'missions' } },
playtime: { label: 'Playtime', title: null, source: { sum: 'playtime_sec' } },
profilekills: { label: 'Kills of an NPC profile', title: null, source: { npcProfile: true } },
}
/**
* The standing a rule ranks by. One per stat, except a profile's kills, which
* are one per profile: two rules on two profiles rank two different boards.
*/
function standingKey(rule) {
return rule && rule.stat === 'profilekills' ? `profilekills:${rule.profile}` : rule && rule.stat
}
/** The categories an admin can rename: every one with a shipped title (D175). */
@@ -134,7 +146,11 @@ function validateSettings(body) {
const color = String(r.color || '').trim().toLowerCase()
if (!/^#[0-9a-f]{6}$/.test(color)) errors.push(`rule ${n}: colour must look like #ffaa55`)
clean.push({ stat: r.stat, topN, text, color })
// D250: a profile's kills name the site profile ranked.
const profile = r.stat === 'profilekills' ? Number(r.profile) : null
if (r.stat === 'profilekills' && !(Number.isInteger(profile) && profile > 0)) errors.push(`rule ${n}: pick the NPC profile whose kills it ranks`)
clean.push({ stat: r.stat, topN, text, color, ...(r.stat === 'profilekills' ? { profile } : {}) })
}
return errors.length ? { ok: false, errors } : { ok: true, value: { mode, max, rules: clean } }
@@ -194,7 +210,7 @@ function evaluate(rules, standings, { mode = 'first', max = 2 } = {}) {
for (const rule of rules || []) {
if (!STATS[rule.stat]) continue
const rows = (standings[rule.stat] || []).filter((row) => Number(row.value) > 0).slice(0, rule.topN)
const rows = (standings[standingKey(rule)] || []).filter((row) => Number(row.value) > 0).slice(0, rule.topN)
for (const row of rows) {
if (!held.has(row.steamId)) held.set(row.steamId, [])
@@ -230,6 +246,7 @@ function digest(set) {
module.exports = {
STATS,
standingKey,
CATEGORY_IDS,
categoryTitle,
validateCategory,

View File

@@ -9,6 +9,7 @@ const serversDb = require('../servers/servers.db')
const db = require('./titles.db')
const lists = require('./lists')
const titles = require('./titles')
const npcs = require('../npcs/npcs.model')
/** How long one server's answer is reused. The same as the push loop's tick. */
const MEMO_MS = 30 * 1000
@@ -25,10 +26,15 @@ function shapeSettings(mode, rules) {
return {
mode: titles.normaliseMode(mode && mode.mode),
max: mode && Number(mode.max) ? Number(mode.max) : 2,
rules: rules.map((r) => ({ stat: r.stat, topN: Number(r.topN), text: r.text, color: r.color })),
rules: rules.map(rule),
}
}
/** One stored rule as the form and the evaluator read it; a profile's kills name the profile (D250). */
function rule(r) {
return { stat: r.stat, topN: Number(r.topN), text: r.text, color: r.color, ...(r.stat === 'profilekills' ? { profile: Number(r.profile) || null } : {}) }
}
/** Every server's settings, keyed by id, for the admin server list. */
async function settingsByServer() {
const [modes, rules] = await Promise.all([db.listModes(), db.listAllRules()])
@@ -36,7 +42,7 @@ async function settingsByServer() {
for (const r of rules) {
const s = out.get(r.serverId)
if (s) s.rules.push({ stat: r.stat, topN: Number(r.topN), text: r.text, color: r.color })
if (s) s.rules.push(rule(r))
}
return out
@@ -102,14 +108,24 @@ async function heldFor(serverId, { wipeId, now = Date.now() } = {}) {
// One query per STAT, at the deepest top N any rule on it asks for, rather
// than one per rule: two rules on kills read the same rows.
// A profile's kills are one standing per profile (`titles.standingKey`).
const depth = new Map()
for (const r of rules) if (titles.STATS[r.stat]) depth.set(r.stat, Math.max(depth.get(r.stat) || 0, r.topN))
const ruleBy = new Map()
for (const r of rules) {
if (!titles.STATS[r.stat]) continue
const key = titles.standingKey(r)
depth.set(key, Math.max(depth.get(key) || 0, r.topN))
ruleBy.set(key, r)
}
await Promise.all(
[...depth.entries()].map(async ([stat, limit]) => {
const rows = await db.standings({ serverId, wipeId, source: titles.STATS[stat].source, lists, limit })
[...depth.entries()].map(async ([key, limit]) => {
const r = ruleBy.get(key)
const rows = r.stat === 'profilekills'
? await npcs.standing(serverId, r.profile, wipeId, limit)
: await db.standings({ serverId, wipeId, source: titles.STATS[r.stat].source, lists, limit })
// SUM() and MAX() come back as strings; the rules compare numbers.
standings[stat] = rows.map((r) => ({ steamId: r.steamId, value: Number(r.value) || 0 }))
standings[key] = rows.map((row) => ({ steamId: row.steamId, value: Number(row.value) || 0 }))
}),
)

135
server/npcSync.js Normal file
View File

@@ -0,0 +1,135 @@
// ── Pushing the site's NPC profiles to each server (runicnpc stage 4) ──────
//
// The site's profiles replace each server's RunicNPC profile set whole, as the
// permission sync replaces a server's permissions, and the server is then
// managed by this site: RunicNPC refuses in-game profile edits (D221).
//
// **Before the FIRST push to a server, its own profiles are adopted** (D244):
// read, imported as profiles for that server alone, and only then pushed back,
// so nothing on the server changes and its placements keep spawning. Where a
// site profile of the same name already covers it, the site's wins and the
// server's own is kept aside as replaced (D251).
//
// A tick asks each server whether it needs a push — its first, a change to its
// set, a restart, a failure worth retrying, or the quarter-hourly audit — and
// pushes only then. A server without RunicNPC (or with one older than API 3) is
// recorded `absent` and left alone; its events keep Rust's own scientists
// (D243).
const core = require('./core')
const db = require('./model/npcs/npcs.db')
const model = require('./model/npcs/npcs.model')
const servers = require('./model/servers/servers.model')
const sidecar = require('./sidecarClient')
const log = core.logger('npcs')
const TICK_MS = 30 * 1000
const AUDIT_MS = 15 * 60 * 1000
const FAIL_BACKOFF_MS = 2 * 60 * 1000
let timer = null
let lock = Promise.resolve()
/** One push at a time: an adoption racing a push would push before it imported. */
function withLock(fn) {
const run = lock.then(fn, fn)
lock = run.catch(() => {})
return run
}
function start() {
if (timer) return
timer = setInterval(() => {
tick().catch((err) => log.error('NPC profile push tick failed', { error: err.message }))
}, TICK_MS)
if (timer.unref) timer.unref()
}
function stop() {
if (!timer) return
clearInterval(timer)
timer = null
}
function age(value) {
const at = value ? new Date(value).getTime() : NaN
return Number.isFinite(at) ? Date.now() - at : Number.MAX_SAFE_INTEGER
}
/** Why this server needs a push now, or null. */
function reasonToPush({ server, sync, hash, force }) {
if (force) return 'requested'
if (server.worldReady === false) return null
if (server.online === false) return null
if (!sync || !sync.adoptedAt) return 'first'
if (sync.state === 'failed' && age(sync.lastAttemptAt) < FAIL_BACKOFF_MS && sync.syncedHash) return null
if (sync.state !== 'ok') return 'retry'
if (hash !== sync.syncedHash) return 'changed'
if (server.bootId && server.bootId !== sync.bootId) return 'restart'
if (age(sync.lastAttemptAt) >= AUDIT_MS) return 'audit'
return null
}
async function tick({ force = null } = {}) {
const [polling, npcServers, syncRows] = await Promise.all([servers.listForPolling(), db.listNpcServers(), db.listSync()])
const syncBy = new Map(syncRows.map((s) => [s.serverId, s]))
const npcBy = new Map(npcServers.map((s) => [s.id, s]))
const results = await Promise.allSettled(
polling
.filter((server) => force === null || force === server.id)
.map((server) => withLock(() => pushOne(server, { npc: npcBy.get(server.id) || null, sync: syncBy.get(server.id) || null, force: force !== null }))),
)
return results.map((r) => (r.status === 'fulfilled' ? r.value : { error: r.reason && r.reason.message }))
}
/**
* One server: adopt its own profiles if the site never has (D244), then push
* its set. Returns what happened, for the admin's "push now" and the tests.
*/
async function pushOne(server, { npc, sync, force = false }) {
if (!model.npcReady(npc)) {
if (!sync || sync.state !== 'absent') await db.putSync(server.id, { state: 'absent', error: model.npcAbsence(npc) })
return { server: server.id, outcome: 'absent', reason: model.npcAbsence(npc) }
}
const profiles = await db.listProfiles()
const first = model.desiredFor(server.id, profiles)
const reason = reasonToPush({ server: npc, sync, hash: first.hash, force })
if (!reason) return { server: server.id, outcome: 'current' }
let adopted = null
if (!sync || !sync.adoptedAt) {
const read = await sidecar.npcProfiles(server)
const data = read.ok ? read.data || {} : null
if (!data || data.kind !== 'npc.profiles') {
const error = data ? data.message || data.reason || `answered ${data.kind}` : `could not read its profiles (${read.status})`
await db.putSync(server.id, { state: 'failed', error })
log.warn('could not read a server\'s own NPC profiles', { server: server.id, error })
return { server: server.id, outcome: 'failed', error }
}
// A server a site already manages has nothing of its own left to adopt: its
// file is some site's last push. Only a standalone server's are its own.
adopted = data.managed ? [] : await model.adopt(server.id, data.profiles || {})
if (data.managed) await db.markAdopted(server.id)
if (adopted.length) log.info('adopted a server\'s own NPC profiles', { server: server.id, adopted })
}
const desired = adopted && adopted.length ? model.desiredFor(server.id, await db.listProfiles()) : first
const pushed = await sidecar.npcProfilesSet(server, desired.profiles)
const data = pushed.ok ? pushed.data || {} : null
if (!data || data.kind !== 'npc.ok') {
const error = data ? data.message || data.reason || `answered ${data.kind}` : `the push did not arrive (${pushed.status})`
await db.putSync(server.id, { state: 'failed', error })
log.warn('NPC profile push failed', { server: server.id, reason, error })
return { server: server.id, outcome: 'failed', error, ...(adopted ? { adopted } : {}) }
}
const refused = data.refused && typeof data.refused === 'object' ? data.refused : {}
await db.putSync(server.id, { state: 'ok', syncedHash: desired.hash, bootId: npc.bootId, pushed: desired.map, refused })
log.info('pushed NPC profiles', { server: server.id, reason, profiles: Object.keys(desired.profiles).length, refused: Object.keys(refused).length })
return { server: server.id, outcome: 'pushed', reason, profiles: Object.keys(desired.profiles), refused, ...(adopted ? { adopted } : {}) }
}
module.exports = { TICK_MS, AUDIT_MS, start, stop, tick, pushOne, reasonToPush }

View File

@@ -0,0 +1,153 @@
// ── Admin · Rust · NPCs: the handlers (docs/runicnpc/PLAN.md stage 4) ──────
//
// Thin: the model decides, and a refusal it throws carries its own status and
// sentence. Every write is logged in core's activity log, a placement's too,
// because a placement puts armed NPCs in the world.
const core = require('../../core')
const npcs = require('../../model/npcs/npcs.model')
const npcSync = require('../../npcSync')
const log = core.logger('npcs')
const by = (req) => (req.user ? req.user.id : null)
/** A model refusal answers with its own status; anything else is ours to log. */
function fail(res, err, what) {
if (err instanceof npcs.NpcError) return res.status(err.status).json({ message: err.message })
log.error(`failed to ${what}`, { error: err.message })
return res.status(500).json({ message: `Failed to ${what}` })
}
async function describe(req, res) {
try {
res.json(await npcs.describe())
} catch (err) {
fail(res, err, 'read the NPC profiles')
}
}
async function create(req, res) {
try {
const profile = await npcs.create(req.body || {}, by(req))
await core.activity.log({ req, action: 'rust.npcs.profile.create', detail: { id: profile.id, name: profile.name } })
res.status(201).json(profile)
} catch (err) {
fail(res, err, 'save the NPC profile')
}
}
async function update(req, res) {
try {
const profile = await npcs.update(Number(req.params.pid), req.body || {})
await core.activity.log({ req, action: 'rust.npcs.profile.update', detail: { id: profile.id, name: profile.name } })
res.json(profile)
} catch (err) {
fail(res, err, 'save the NPC profile')
}
}
async function remove(req, res) {
try {
await npcs.remove(Number(req.params.pid))
await core.activity.log({ req, action: 'rust.npcs.profile.delete', detail: { id: Number(req.params.pid) } })
res.json({ ok: true })
} catch (err) {
fail(res, err, 'delete the NPC profile')
}
}
async function restore(req, res) {
try {
const profile = await npcs.restore(Number(req.params.pid))
await core.activity.log({ req, action: 'rust.npcs.profile.restore', detail: { id: profile.id, name: profile.name } })
res.json(profile)
} catch (err) {
fail(res, err, 'restore the NPC profile')
}
}
async function push(req, res) {
try {
const [result] = await npcSync.tick({ force: req.params.id })
if (!result) return res.status(404).json({ message: 'No such server, or it is switched off' })
await core.activity.log({ req, action: 'rust.npcs.push', detail: { server: req.params.id, outcome: result.outcome } })
res.json(result)
} catch (err) {
fail(res, err, 'push the NPC profiles')
}
}
async function listPlacements(req, res) {
try {
res.json(await npcs.listPlacements(req.params.id))
} catch (err) {
fail(res, err, 'read the placements')
}
}
async function addPlacement(req, res) {
try {
const placed = await npcs.addPlacement(req.params.id, req.body || {})
await core.activity.log({ req, action: 'rust.npcs.placement.add', detail: { server: req.params.id, id: placed.id, profile: req.body.profile, position: placed.position } })
res.status(201).json(placed)
} catch (err) {
fail(res, err, 'place the NPCs')
}
}
async function setPlacement(req, res) {
try {
const out = await npcs.setPlacement(req.params.id, req.params.placement, req.body || {})
await core.activity.log({ req, action: 'rust.npcs.placement.update', detail: { server: req.params.id, id: req.params.placement } })
res.json(out)
} catch (err) {
fail(res, err, 'change the placement')
}
}
async function removePlacement(req, res) {
try {
await npcs.removePlacement(req.params.id, req.params.placement)
await core.activity.log({ req, action: 'rust.npcs.placement.delete', detail: { server: req.params.id, id: req.params.placement } })
res.json({ ok: true })
} catch (err) {
fail(res, err, 'remove the placement')
}
}
async function renamePlacement(req, res) {
try {
const out = await npcs.renamePlacement(req.params.id, req.params.placement, req.body.to)
await core.activity.log({ req, action: 'rust.npcs.placement.rename', detail: { server: req.params.id, from: req.params.placement, to: req.body.to } })
res.json({ id: out.id || req.body.to, previous: req.params.placement })
} catch (err) {
fail(res, err, 'rename the placement')
}
}
async function respawnPlacement(req, res) {
try {
const out = await npcs.respawnPlacement(req.params.id, req.params.placement)
await core.activity.log({ req, action: 'rust.npcs.placement.respawn', detail: { server: req.params.id, id: req.params.placement } })
res.json({ id: req.params.placement, respawned: Number(out.respawned) || 0 })
} catch (err) {
fail(res, err, 'respawn the placement')
}
}
module.exports = {
describe,
create,
update,
remove,
restore,
push,
listPlacements,
addPlacement,
setPlacement,
removePlacement,
renamePlacement,
respawnPlacement,
}

View File

@@ -0,0 +1,238 @@
// ── Admin · Rust · NPCs ───────────────────────────────────────────────────
//
// Mounted under the admin tier's `/rust` prefix, so every path here is
// `/api/v1/admin/rust/npcs` (docs/runicnpc/PLAN.md stage 4). Two pages' worth:
//
// profiles the site's RunicNPC profiles, for one server, several or the
// fleet, pushed to each server's RunicNPC (D221, D244, D247, D251)
// placements one server's placements, which live on that server (D222): read,
// edited, removed, renamed, respawned, and created by clicking the
// live map (D245, D246)
//
// **Every route is `requireRole('admin')`,** like every other page under Admin →
// Rust: a profile decides how hard an NPC hits the players it meets, and a
// placement puts armed NPCs in the world.
const core = require('../../core')
const express = core.express
const npcs = require('./npcs.controller')
const { requireRole, validate } = core.middleware
const { body, param } = core.validator
const npcsRouter = express.Router()
const NAME = /^[a-z0-9_-]{1,40}$/
const profileBody = [
body('name').isString().trim().matches(NAME).withMessage('name is 1 to 40 of a-z, 0-9, _ and -'),
body('body').isObject().withMessage('body is the profile, as RunicNPC reads it'),
body('allServers').optional().isBoolean().withMessage('allServers is true or false'),
body('servers').optional().isArray().withMessage('servers is a list of server ids'),
body('killsScope').optional().isIn(['server', 'name', 'profile']).withMessage('killsScope is server, name or profile'),
]
const serverParam = param('id').isString().isLength({ min: 1, max: 64 }).withMessage('id is a server id')
const placementParam = param('placement').isString().matches(NAME).withMessage('placement is a placement name')
const placementBody = [
body('profile').isString().trim().matches(NAME).withMessage('profile is a profile name'),
body('count').optional().isInt({ min: 1, max: 50 }).withMessage('count is 1 to 50'),
body('respawn').optional().isFloat({ min: 1, max: 86400 }).withMessage('respawn is 1 to 86400 seconds'),
body('respawnMode').optional().isIn(['each', 'group']).withMessage('respawnMode is each or group'),
body('movement').optional({ nullable: true }).isObject().withMessage('movement is { mode, radius }'),
]
npcsRouter.get(
'/',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'NPC profiles, and what each server said about RunicNPC'
// #swagger.description = 'Everything the NPC profiles page draws. Each server with what its last status said about RunicNPC (loaded, version, API), whether the site can manage it (`ready`, and `absence` saying why not), and what the last push did (`sync`: state, when its own profiles were adopted, when last pushed, the profiles RunicNPC refused and why). Every profile, a replaced one included (D251), with `label`, the first of its NPC names. The prefabs the form offers, the three ways a profile’s kills may be counted (D247), and RunicNPC’s defaults for a new profile. Read from the stored status, so it answers while a server is off.'
/* #swagger.responses[200] = { description: 'The page', content: { "application/json": { schema: { $ref: "#/components/schemas/RustNpcs" } } } } */
requireRole('admin'),
npcs.describe,
)
npcsRouter.post(
'/profiles',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Save a new NPC profile'
// #swagger.description = 'A RunicNPC profile (D238) for the servers it lists, or for every server (`allServers`). Checked as RunicNPC checks it, each kit against every covered server that answers (D217); a server that does not answer is left to RunicNPC, which refuses a missing kit when the profile is pushed. Two profiles of one name may not share a server. It reaches each server on the push loop’s next tick.'
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/RustNpcProfileInput" } } } } */
/* #swagger.responses[201] = { description: 'Saved', content: { "application/json": { schema: { $ref: "#/components/schemas/RustNpcProfile" } } } } */
/* #swagger.responses[400] = { description: 'A value RunicNPC would refuse, or a kit a covered server does not have' } */
/* #swagger.responses[404] = { description: 'A server that does not exist' } */
/* #swagger.responses[409] = { description: 'A profile of that name is already on one of the servers' } */
requireRole('admin'),
...profileBody,
validate,
npcs.create,
)
npcsRouter.put(
'/profiles/:pid',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Change an NPC profile'
// #swagger.description = 'Replaces the profile whole, under the same rules as saving a new one. Every server it was or is now on is pushed again; their placements of it respawn with the new values. A profile kept aside as replaced (D251) must be restored first.'
// #swagger.parameters['pid'] = { in: 'path', required: true, description: 'The profile’s id', schema: { type: 'integer' } }
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/RustNpcProfileInput" } } } } */
/* #swagger.responses[200] = { description: 'Saved', content: { "application/json": { schema: { $ref: "#/components/schemas/RustNpcProfile" } } } } */
/* #swagger.responses[400] = { description: 'A value RunicNPC would refuse, or a kit a covered server does not have' } */
/* #swagger.responses[404] = { description: 'No such profile, or a server that does not exist' } */
/* #swagger.responses[409] = { description: 'A profile of that name is already on one of the servers, or this one is kept aside as replaced' } */
requireRole('admin'),
param('pid').isInt({ min: 1 }).withMessage('pid is a profile id'),
...profileBody,
validate,
npcs.update,
)
npcsRouter.delete(
'/profiles/:pid',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Delete an NPC profile'
// #swagger.description = 'Removes it from every server it was on at the next push. Its placements there are kept and wait, showing “profile missing”, and spawn again if a profile of that name returns (D237). Its kills stay counted.'
// #swagger.parameters['pid'] = { in: 'path', required: true, description: 'The profile’s id', schema: { type: 'integer' } }
/* #swagger.responses[200] = { description: 'Deleted' } */
/* #swagger.responses[404] = { description: 'No such profile' } */
requireRole('admin'),
param('pid').isInt({ min: 1 }).withMessage('pid is a profile id'),
validate,
npcs.remove,
)
npcsRouter.post(
'/profiles/:pid/restore',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Put a replaced NPC profile back into use'
// #swagger.description = 'A server’s own profile, kept aside at adoption because a site profile of its name was already there (D251), is pushed to its server again. Refused while a site profile of that name still covers the server.'
// #swagger.parameters['pid'] = { in: 'path', required: true, description: 'The profile’s id', schema: { type: 'integer' } }
/* #swagger.responses[200] = { description: 'Restored', content: { "application/json": { schema: { $ref: "#/components/schemas/RustNpcProfile" } } } } */
/* #swagger.responses[400] = { description: 'It is in use already' } */
/* #swagger.responses[404] = { description: 'No such profile' } */
/* #swagger.responses[409] = { description: 'A site profile of its name still covers its server' } */
requireRole('admin'),
param('pid').isInt({ min: 1 }).withMessage('pid is a profile id'),
validate,
npcs.restore,
)
npcsRouter.post(
'/servers/:id/push',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Push the NPC profiles to one server now'
// #swagger.description = 'Rather than on the loop’s next tick. A server the site has never pushed to has its own profiles adopted first (D244). The answer says what happened: `pushed` (with the profiles and any RunicNPC refused), `absent` (no RunicNPC, or one older than API 3, and why) or `failed` (with the reason).'
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s id', schema: { type: 'string' } }
/* #swagger.responses[200] = { description: 'What the push did' } */
/* #swagger.responses[404] = { description: 'No such server' } */
requireRole('admin'),
serverParam,
validate,
npcs.push,
)
npcsRouter.get(
'/servers/:id/placements',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'One server’s NPC placements'
// #swagger.description = 'Read live from the server, which holds them (D222): each placement’s name and values, how many of its NPCs are alive, what it waits for (a missing profile or route, D237) and its note (standing on the nearest navmesh because its ground went, D239). Also the routes a placement may walk and the cost warning for what the server plans now (D227).'
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s id', schema: { type: 'string' } }
/* #swagger.responses[200] = { description: 'The placements', content: { "application/json": { schema: { $ref: "#/components/schemas/RustNpcPlacements" } } } } */
/* #swagger.responses[404] = { description: 'No such server' } */
/* #swagger.responses[409] = { description: 'The server has no RunicNPC, or one older than API 3' } */
/* #swagger.responses[503] = { description: 'The server’s game is not connected' } */
requireRole('admin'),
serverParam,
validate,
npcs.listPlacements,
)
npcsRouter.post(
'/servers/:id/placements',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Place NPCs at a point on the live map'
// #swagger.description = 'A new placement from a clicked point, `position` being x and z only (D245). The server puts it on the ground there (terrain or rock, never a building: a roof is placed in game), checks it against the navmesh as `/rnpc place` does, and names it after its profile and a number (D246). The answer carries the name, where it landed, whether that is on something players built, and the cost warning.'
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s id', schema: { type: 'string' } }
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/RustNpcPlacementInput" } } } } */
/* #swagger.responses[201] = { description: 'Placed' } */
/* #swagger.responses[400] = { description: 'RunicNPC refused it, with its reason (off the navmesh, under water, off the map, no such profile or route)' } */
/* #swagger.responses[503] = { description: 'The server’s game is not connected' } */
requireRole('admin'),
serverParam,
body('position').isObject().withMessage('position is { x, z }'),
...placementBody,
validate,
npcs.addPlacement,
)
npcsRouter.put(
'/servers/:id/placements/:placement',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Change an NPC placement'
// #swagger.description = 'New values for a placement: its profile, count, respawn delay and mode, and movement (D246). Its spot is kept unless a whole `position` (x, y, z) is sent. Its NPCs are removed and spawn again from the new values.'
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s id', schema: { type: 'string' } }
// #swagger.parameters['placement'] = { in: 'path', required: true, description: 'The placement’s name', schema: { type: 'string' } }
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/RustNpcPlacementInput" } } } } */
/* #swagger.responses[200] = { description: 'Changed' } */
/* #swagger.responses[400] = { description: 'RunicNPC refused it, with its reason' } */
/* #swagger.responses[404] = { description: 'No such placement' } */
requireRole('admin'),
serverParam,
placementParam,
...placementBody,
validate,
npcs.setPlacement,
)
npcsRouter.delete(
'/servers/:id/placements/:placement',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Remove an NPC placement'
// #swagger.description = 'Removes the placement and its NPCs, as `/rnpc remove` does.'
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s id', schema: { type: 'string' } }
// #swagger.parameters['placement'] = { in: 'path', required: true, description: 'The placement’s name', schema: { type: 'string' } }
/* #swagger.responses[200] = { description: 'Removed' } */
/* #swagger.responses[404] = { description: 'No such placement' } */
requireRole('admin'),
serverParam,
placementParam,
validate,
npcs.removePlacement,
)
npcsRouter.post(
'/servers/:id/placements/:placement/rename',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Rename an NPC placement'
// #swagger.description = 'As `/rnpc rename` (D241). Its live NPCs keep living under the new name.'
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s id', schema: { type: 'string' } }
// #swagger.parameters['placement'] = { in: 'path', required: true, description: 'The placement’s name', schema: { type: 'string' } }
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { to: { type: "string", example: "gate" } } } } } } */
/* #swagger.responses[200] = { description: 'Renamed' } */
/* #swagger.responses[400] = { description: 'The new name is taken or not a name' } */
/* #swagger.responses[404] = { description: 'No such placement' } */
requireRole('admin'),
serverParam,
placementParam,
body('to').isString().matches(NAME).withMessage('to is 1 to 40 of a-z, 0-9, _ and -'),
validate,
npcs.renamePlacement,
)
npcsRouter.post(
'/servers/:id/placements/:placement/respawn',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Respawn an NPC placement now'
// #swagger.description = 'As `/rnpc respawn`: its NPCs are removed and spawn again at once, whatever their respawn delay.'
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s id', schema: { type: 'string' } }
// #swagger.parameters['placement'] = { in: 'path', required: true, description: 'The placement’s name', schema: { type: 'string' } }
/* #swagger.responses[200] = { description: 'Respawning' } */
/* #swagger.responses[404] = { description: 'No such placement' } */
requireRole('admin'),
serverParam,
placementParam,
validate,
npcs.respawnPlacement,
)
module.exports = npcsRouter

View File

@@ -19,6 +19,7 @@ const servers = require('../../model/servers/servers.model')
const sidecar = require('../../sidecarClient')
const titleSync = require('../../titleSync')
const titles = require('../../model/titles/titles')
const npcs = require('../../model/npcs/npcs.model')
const titlesModel = require('../../model/titles/titles.model')
const voice = require('../../model/permissions/voice')
@@ -255,6 +256,14 @@ async function putTitles(req, res) {
const checked = titles.validateSettings(req.body)
if (!checked.ok) return res.status(400).json({ message: checked.errors.join(' '), errors: checked.errors })
// D250: a profile's kills rank a site profile this server is pushed.
const onServer = new Set((await npcs.boardProfiles(id)).map((p) => p.id))
const stray = checked.value.rules.findIndex((r) => r.stat === 'profilekills' && !onServer.has(r.profile))
if (stray >= 0) {
const errors = [`rule ${stray + 1}: that NPC profile is not on this server`]
return res.status(400).json({ message: errors[0], errors })
}
await titlesModel.saveSettings(id, checked.value)
await core.activity.log({
req,

View File

@@ -46,6 +46,10 @@ adminRustRouter.use('/visibility', require('./visibility.router'))
// step copies, saved by name for one server, several, or the fleet.
adminRustRouter.use('/zones', require('./zones.router'))
// RunicNPC (docs/runicnpc/PLAN.md stage 4): the site's NPC profiles, pushed to
// each server, and each server's placements, which live on the server (D222).
adminRustRouter.use('/npcs', require('./npcs.router'))
adminRustRouter.get(
'/servers',
// #swagger.tags = ['Admin · Rust']

View File

@@ -22,6 +22,8 @@
const core = require('../../core')
const links = require('../../model/links/links.model')
const npcs = require('../../model/npcs/npcs.model')
const npcsDb = require('../../model/npcs/npcs.db')
const permissions = require('../../model/permissions/permissions.model')
const servers = require('../../model/servers/servers.model')
@@ -36,6 +38,23 @@ async function listServers(req, res) {
}
}
/** GET /player/rust/npc-kills — the caller's kills of each NPC profile, current wipes (D252). */
async function ownNpcKills(req, res) {
try {
const held = await links.listForUser(req.user.id)
const rows = await npcsDb.ownKills(held.map((l) => String(l.steamId)).filter(Boolean))
const labels = new Map()
for (const serverId of new Set(rows.map((r) => r.serverId))) {
for (const p of await npcs.boardProfiles(serverId)) labels.set(`${serverId} ${p.name}`, p.label)
}
const names = new Map((await servers.listPublic()).map((srv) => [srv.id, srv.name || srv.id]))
res.json({ kills: rows.map((r) => ({ ...r, server: names.get(r.serverId) || r.serverId, label: labels.get(`${r.serverId} ${r.profile}`) || r.profile })) })
} catch (err) {
log.error('failed to read a player’s NPC kills', { error: err.message })
res.status(500).json({ message: 'Failed to read your NPC kills' })
}
}
/** GET /player/rust/links — the Steam accounts the caller holds. */
async function listLinks(req, res) {
try {
@@ -168,4 +187,4 @@ async function listPermissions(req, res) {
}
}
module.exports = { listServers, listLinks, confirmLink, removeLink, listPermissions }
module.exports = { listServers, listLinks, confirmLink, removeLink, listPermissions, ownNpcKills }

View File

@@ -58,6 +58,15 @@ const linkLimiter = rateLimit({
message: 'Too many link attempts. Please try again later.',
})
playerRustRouter.get(
'/npc-kills',
// #swagger.tags = ['Player · Rust']
// #swagger.summary = 'Your kills of each NPC profile'
// #swagger.description = 'The caller’s kills of RunicNPC’s NPCs, by server and profile, in each server’s current wipe, across every Steam account they have linked (docs/runicnpc/PLAN.md stage 4, D252). Empty with no linked account. Requires a session.'
/* #swagger.responses[200] = { description: 'The kills', content: { "application/json": { schema: { $ref: "#/components/schemas/RustNpcOwnKills" } } } } */
rust.ownNpcKills,
)
playerRustRouter.get(
'/servers',
// #swagger.tags = ['Player · Rust']

View File

@@ -16,7 +16,9 @@ const events = require('../../model/events/events.model')
const map = require('../../model/map/map.model')
const mapDb = require('../../model/map/map.db')
const mapLive = require('../../mapLive')
const npcs = require('../../model/npcs/npcs.model')
const servers = require('../../model/servers/servers.model')
const serversDb = require('../../model/servers/servers.db')
const titles = require('../../model/titles/titles.model')
const visibility = require('../../model/visibility/visibility.model')
@@ -130,6 +132,51 @@ async function listLeaderboard(req, res) {
}
}
/** GET /servers/:id/npc-profiles — what the leaderboard's profile picker offers (D250). */
async function listNpcProfiles(req, res) {
try {
res.json({ profiles: await npcs.boardProfiles(req.params.id) })
} catch (err) {
log.error('failed to read the NPC profiles', { server: req.params.id, error: err.message })
res.status(500).json({ message: 'Failed to read the NPC profiles' })
}
}
/** GET /servers/:id/npc-leaderboard?profile= — one profile's kills, counted as it says (D247, D250). */
async function npcLeaderboard(req, res) {
const profileId = Number(req.query.profile)
if (!Number.isInteger(profileId) || profileId < 1) return res.status(400).json({ message: 'profile is a profile id' })
try {
const state = await serversDb.getState(req.params.id)
const wipeId = req.query.wipe ? String(req.query.wipe) : null
const currentWipe = Boolean(wipeId && state && state.wipeId === wipeId)
const [ranked, held] = await Promise.all([
npcs.ranking({ serverId: req.params.id, profileId, wipeId, currentWipe, limit: req.query.limit }),
titles.currentFor(req.params.id),
])
res.json({ profile: ranked.profile, leaderboard: ranked.rows.map((row) => ({ ...row, kills: row.value, titles: held.get(row.steamId) || [] })) })
} catch (err) {
if (err instanceof npcs.NpcError) return res.status(err.status).json({ message: err.message })
log.error('failed to read an NPC leaderboard', { server: req.params.id, error: err.message })
return res.status(500).json({ message: 'Failed to read the leaderboard' })
}
}
/** GET /servers/:id/players/:steamId/npc-kills — one leaderboard row, opened (D252). */
async function playerNpcKills(req, res) {
const steamId = String(req.params.steamId || '')
if (!/^\d{1,20}$/.test(steamId)) return res.status(400).json({ message: 'steamId is a Steam id' })
try {
const wipeId = req.query.wipe ? String(req.query.wipe) : null
res.json({ steamId, kills: await npcs.playerKills({ serverId: req.params.id, steamId, wipeId }) })
} catch (err) {
log.error('failed to read a player’s NPC kills', { server: req.params.id, error: err.message })
res.status(500).json({ message: 'Failed to read the kills' })
}
}
async function listWipes(req, res) {
try {
res.json({ wipes: await events.wipes(req.params.id) })
@@ -353,6 +400,9 @@ module.exports = {
getServer,
listEvents,
listLeaderboard,
listNpcProfiles,
npcLeaderboard,
playerNpcKills,
listWipes,
listOnline,
listClans,

View File

@@ -92,6 +92,45 @@ rustRouter.get(
servers.listLeaderboard,
)
rustRouter.get(
'/servers/:id/npc-profiles',
// #swagger.tags = ['Public · Rust']
// #swagger.summary = 'The NPC profiles a server’s leaderboard can rank by'
// #swagger.description = 'The site’s RunicNPC profiles pushed to this server (docs/runicnpc/PLAN.md stage 4, D250): each one’s id, its name, a label (the first of its NPC names), and how its kills are counted (D247) — `server` (this server’s kills of that name), `name` (every server’s kills of that name) or `profile` (this site profile’s, wherever it was pushed). Empty on a site without RunicNPC.'
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } }
/* #swagger.responses[200] = { description: 'The profiles', content: { "application/json": { schema: { $ref: "#/components/schemas/RustNpcBoardProfiles" } } } } */
siteMode,
servers.listNpcProfiles,
)
rustRouter.get(
'/servers/:id/npc-leaderboard',
// #swagger.tags = ['Public · Rust']
// #swagger.summary = 'Who has killed the most NPCs of one profile'
// #swagger.description = 'One RunicNPC profile’s kills, most first, counted as the profile says (D247, D250). Per wipe when `wipe` is given, all time otherwise. A profile counted across servers reaches every server’s current wipe when `wipe` is this server’s current one, and only this server’s rows for an older wipe. Each row carries the chat `titles` the player holds now, as the main leaderboard’s do.'
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } }
// #swagger.parameters['profile'] = { in: 'query', required: true, description: 'The profile’s id, from npc-profiles', schema: { type: 'integer' } }
// #swagger.parameters['wipe'] = { in: 'query', required: false, description: 'Restrict to one wipe id', schema: { type: 'string' } }
// #swagger.parameters['limit'] = { in: 'query', required: false, description: 'Rows to return, capped at 200', schema: { type: 'integer' } }
/* #swagger.responses[200] = { description: 'The ranking', content: { "application/json": { schema: { $ref: "#/components/schemas/RustNpcLeaderboard" } } } } */
/* #swagger.responses[404] = { description: 'No such profile on this server' } */
siteMode,
servers.npcLeaderboard,
)
rustRouter.get(
'/servers/:id/players/:steamId/npc-kills',
// #swagger.tags = ['Public · Rust']
// #swagger.summary = 'One player’s kills of each NPC profile on a server'
// #swagger.description = 'What opening a leaderboard row shows (D252): the player’s kills of RunicNPC’s NPCs by profile on this server, for the wipe the page shows (`wipe`), or all time without it. Counted by profile name on this server, whatever a profile’s leaderboard counts; labelled with the site’s name for the profile where it has one. The same numbers the leaderboard already makes public, split by profile.'
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } }
// #swagger.parameters['steamId'] = { in: 'path', required: true, description: 'The player’s Steam id', schema: { type: 'string' } }
// #swagger.parameters['wipe'] = { in: 'query', required: false, description: 'Restrict to one wipe id', schema: { type: 'string' } }
/* #swagger.responses[200] = { description: 'The kills', content: { "application/json": { schema: { $ref: "#/components/schemas/RustNpcPlayerKills" } } } } */
siteMode,
servers.playerNpcKills,
)
rustRouter.get(
'/servers/:id/wipes',
// #swagger.tags = ['Public · Rust']

View File

@@ -490,6 +490,19 @@ const mapRender = (server, body) => request(server, '/map/render', { method: 'PO
/** Everything that moves on one server's map, every layer, unfiltered. The caller filters (§8.5). */
const mapLive = (server) => request(server, '/map/live')
/**
* RunicNPC (runicnpc PLAN.md stage 4, protocol 13). The server's own profiles as
* RunicNPC holds them (`npc.profiles`: `managed`, `profiles`, `refused`), read
* before the site's first push so it can adopt them (D244); a push, which
* replaces them whole and marks the server managed (`npc.ok` lists what
* RunicNPC refused); every placement; and one change to one placement, by `op`.
* Each refusal is `data.kind` `npc.error` with a `reason` and RunicNPC's own sentence.
*/
const npcProfiles = (server) => request(server, '/npc/profiles')
const npcProfilesSet = (server, profiles) => request(server, '/npc/profiles', { method: 'POST', body: { profiles } })
const npcPlacements = (server) => request(server, '/npc/placements')
const npcPlacement = (server, body) => request(server, '/npc/placement', { method: 'POST', body })
module.exports = {
TIMEOUT_MS,
LEASE_TIMEOUT_MS,
@@ -528,5 +541,9 @@ module.exports = {
mapChunk,
mapRender,
mapLive,
npcProfiles,
npcProfilesSet,
npcPlacements,
npcPlacement,
joinUrl,
}

View File

@@ -1114,6 +1114,140 @@ module.exports = {
presets: { type: 'array', items: { $ref: '#/components/schemas/RustZonePreset' } },
},
},
RustNpcProfileBody: {
type: 'object',
description: 'A RunicNPC profile as RunicNPC reads it (docs/runicnpc/API.md, D238). Anything left out takes RunicNPC’s default.',
properties: {
names: { type: 'array', items: { type: 'string' }, example: ['Warden', 'Old Warden'] },
kits: { type: 'array', items: { type: 'string' }, example: ['warden_rifle'] },
prefab: { type: 'string', example: 'scientistnpc_roam' },
role: { type: 'string', enum: ['roamer', 'sentry'], example: 'roamer' },
movement: { type: 'object', example: { mode: 'wander', radius: 20 } },
health: { type: 'number', example: 250 },
damageDealt: { type: 'number', example: 1 },
damageTaken: { type: 'object', example: { head: 1, body: 1, legs: 1 } },
aimCone: { type: 'number', example: 2 },
ranges: { type: 'object', example: { sense: 30, loseTarget: 40, chase: 40, attack: 30 } },
visionCone: { type: 'number', example: -0.8 },
sleepDistance: { type: 'number', example: 160 },
healthThresholds: { type: 'array', items: { type: 'number' }, example: [0.5] },
},
},
RustNpcProfile: {
type: 'object',
description: 'A site NPC profile (docs/runicnpc/PLAN.md stage 4) for one server, several, or every server. `adoptedFrom` names the server it was read from on the site’s first push there (D244); `replaced` marks one kept aside because a site profile of its name was already on that server (D251). `killsScope` is how its kills are counted (D247).',
properties: {
id: { type: 'integer', example: 4 },
name: { type: 'string', example: 'warden' },
label: { type: 'string', example: 'Warden' },
body: { $ref: '#/components/schemas/RustNpcProfileBody' },
allServers: { type: 'boolean', example: false },
servers: { type: 'array', items: { type: 'string' }, example: ['main'] },
killsScope: { type: 'string', enum: ['server', 'name', 'profile'], example: 'server' },
adoptedFrom: { type: 'string', nullable: true, example: null },
replaced: { type: 'boolean', example: false },
updatedAt: { type: 'string', format: 'date-time', nullable: true },
},
},
RustNpcProfileInput: {
type: 'object',
required: ['name', 'body'],
description: 'A profile to save. `servers` is required unless `allServers` is true. The name is RunicNPC’s: 1 to 40 of a-z, 0-9, _ and -.',
properties: {
name: { type: 'string', example: 'warden' },
body: { $ref: '#/components/schemas/RustNpcProfileBody' },
allServers: { type: 'boolean', example: false },
servers: { type: 'array', items: { type: 'string' }, example: ['main'] },
killsScope: { type: 'string', enum: ['server', 'name', 'profile'], example: 'server' },
},
},
RustNpcs: {
type: 'object',
description: 'The NPC profiles page: each server with what it said about RunicNPC and what the last push did, every profile, the prefabs the form offers, the kill-counting scopes, and RunicNPC’s defaults.',
properties: {
servers: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string', example: 'main' },
name: { type: 'string', example: 'Main · Vanilla' },
enabled: { type: 'boolean', example: true },
online: { type: 'boolean', nullable: true, example: true },
runicNpc: { type: 'object', nullable: true, example: { loaded: true, version: '0.2.0', api: 3 } },
ready: { type: 'boolean', example: true },
absence: { type: 'string', nullable: true, example: null },
sync: { type: 'object', nullable: true, example: { state: 'ok', adoptedAt: '2026-09-30T10:00:00.000Z', syncedAt: '2026-09-30T10:00:01.000Z', refused: {}, error: null } },
},
},
},
profiles: { type: 'array', items: { $ref: '#/components/schemas/RustNpcProfile' } },
prefabs: { type: 'array', items: { type: 'string' }, example: ['scientistnpc_roam'] },
killsScopes: { type: 'array', items: { type: 'string' }, example: ['server', 'name', 'profile'] },
defaults: { $ref: '#/components/schemas/RustNpcProfileBody' },
},
},
RustNpcPlacements: {
type: 'object',
description: 'One server’s placements, read live from it (D222), with the routes one may walk and the cost warning (D227).',
properties: {
placements: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string', example: 'warden-2' },
placement: { type: 'object', example: { profile: 'warden', position: { x: 100, y: 12.5, z: -340 }, yaw: 90, count: 3, respawn: 300, respawnMode: 'each' } },
alive: { type: 'integer', example: 3 },
waiting: { type: 'string', nullable: true, example: null },
note: { type: 'string', nullable: true, example: null },
lastError: { type: 'string', nullable: true, example: null },
},
},
},
routes: { type: 'array', items: { type: 'string' }, example: ['gate'] },
cost: { type: 'string', nullable: true, example: 'RunicNPC: 12 NPC(s) on this server…' },
},
},
RustNpcPlacementInput: {
type: 'object',
required: ['profile'],
description: '`/rnpc place`’s options (D246). To create, `position` is the clicked point, x and z only; to change, a whole x, y, z moves it, and none keeps its spot.',
properties: {
profile: { type: 'string', example: 'warden' },
position: { type: 'object', example: { x: 100, z: -340 } },
count: { type: 'integer', example: 3 },
respawn: { type: 'number', example: 300 },
respawnMode: { type: 'string', enum: ['each', 'group'], example: 'each' },
movement: { type: 'object', nullable: true, example: { mode: 'route:gate', radius: 0 } },
},
},
RustNpcBoardProfiles: {
type: 'object',
properties: {
profiles: { type: 'array', items: { type: 'object' }, example: [{ id: 4, name: 'warden', label: 'Warden', killsScope: 'server' }] },
},
},
RustNpcLeaderboard: {
type: 'object',
properties: {
profile: { type: 'object', example: { id: 4, name: 'warden', label: 'Warden', killsScope: 'server' } },
leaderboard: { type: 'array', items: { type: 'object' }, example: [{ steamId: '76561198000000002', name: 'Marisol', kills: 12, titles: [] }] },
},
},
RustNpcPlayerKills: {
type: 'object',
properties: {
steamId: { type: 'string', example: '76561198000000002' },
kills: { type: 'array', items: { type: 'object' }, example: [{ profile: 'warden', label: 'Warden', kills: 3 }] },
},
},
RustNpcOwnKills: {
type: 'object',
properties: {
kills: { type: 'array', items: { type: 'object' }, example: [{ serverId: 'main', profile: 'warden', label: 'Warden', kills: 3 }] },
},
},
RustVisibilityUpdate: {
type: 'object',
description: 'A change to who may see who is online. Either part may be omitted; a server set to null follows the fleet default again.',

View File

@@ -137,6 +137,12 @@ test('the classification covers exactly the event kinds the protocol defines, th
'config.outcome',
// Protocol 13 (§19, F8, D184). Which plugins a server runs and what each
// registers: an operator's inventory.
// Protocol 13, RunicNPC (runicnpc stage 4): an NPC's death names its killer
// and everyone who hurt it; its health and a placement's change are an
// operator's. All staff.
'npc.died',
'npc.health',
'npc.placement.changed',
'plugin.loaded',
'plugin.unloaded',
]

View File

@@ -48,6 +48,8 @@ test('the player tier serves the identity routes, the entitlement read, and noth
'GET /links',
// Phase 8: what the site has given the caller in game. Read-only on this
// tier by construction — the authoring routes are all admin.
// RunicNPC stage 4 (D252): the caller's own kills of each NPC profile. Read-only.
'GET /npc-kills',
'GET /permissions',
'GET /servers',
'POST /link',

388
server/test/npcs.test.js Normal file
View File

@@ -0,0 +1,388 @@
// ── RunicNPC: profiles, their push, placements, events and kills (stage 4) ──
//
// What each test guards (docs/runicnpc/PLAN.md stage 4):
//
// a profile is refused on the form for what RunicNPC would refuse, in its words
// a kit a covered server lacks is refused on save, naming the server
// two profiles of one name may not share a server (the zone-presets rule)
// the first push adopts a server's own profiles and changes nothing on it (D244)
// a site profile of the same name wins, and the server's is kept as replaced (D251)
// a replaced profile is restored only when nothing of its name covers its server
// a server without RunicNPC (or with API < 3) is recorded absent, never pushed
// a push happens on a change, a restart, a failure and the audit, and not otherwise
// the event picker lists the site's profiles first, then Rust's own (D243)
// the Place NPCs step sends a profile, and refuses one a server cannot place
// rust.npc.died and rust.npc.health fire with what a gate filters on
// a kill is credited to the site profile pushed under its name (D247)
// a title rule on a profile's kills names the profile (D250)
// a placement from the map goes up with x and z only (D245), and a refusal keeps its words
const test = require('node:test')
const assert = require('node:assert')
const { fakeCtx } = require('./_fakes')
require('../core')._reset()
const ctx = fakeCtx({ freeze: false })
require('../core').init(ctx)
const client = require('../sidecarClient')
const servers = require('../model/servers/servers.model')
const serversDb = require('../model/servers/servers.db')
const npcsDb = require('../model/npcs/npcs.db')
const npcs = require('../model/npcs/npcs.model')
const shape = require('../model/npcs/npcProfile')
const npcSync = require('../npcSync')
const world = require('../eventWorld')
const titles = require('../model/titles/titles')
const READY = { loaded: true, version: '0.2.0', api: 3 }
function body(extra = {}) {
return { ...shape.defaults(), names: ['Warden'], kits: ['warden_rifle'], ...extra }
}
function npcServer(id, runicNpc = READY, extra = {}) {
return { id, name: id.toUpperCase(), enabled: true, online: true, bootId: 'boot-1', worldReady: true, runicNpc, ...extra }
}
/** An in-memory stand-in for npcs.db, one test long. */
function stubStore(t, { servers: list = [npcServer('main')], profiles = [], sync = [] } = {}) {
const saved = { ...npcsDb }
const store = profiles.map((p, i) => ({ id: p.id || i + 1, allServers: false, servers: [], killsScope: 'server', adoptedFrom: null, replaced: false, ...p }))
const syncRows = new Map(sync.map((s) => [s.serverId, { ...s }]))
const kills = []
let next = 100
npcsDb.listNpcServers = async () => list
npcsDb.listProfiles = async () => store.map((p) => ({ ...p, servers: [...p.servers] }))
npcsDb.getProfile = async (id) => {
const p = store.find((x) => x.id === id)
return p ? { ...p, servers: [...p.servers] } : null
}
npcsDb.saveProfile = async (row) => {
const id = row.id || next++
const nextRow = { killsScope: 'server', adoptedFrom: null, replaced: false, ...row, id, servers: row.allServers ? [] : row.servers }
const at = store.findIndex((p) => p.id === id)
if (at >= 0) store[at] = { ...store[at], ...nextRow }
else store.push(nextRow)
return id
}
npcsDb.deleteProfile = async (id) => {
const at = store.findIndex((p) => p.id === id)
if (at >= 0) store.splice(at, 1)
}
npcsDb.listSync = async () => [...syncRows.values()]
npcsDb.getSync = async (id) => syncRows.get(id) || null
npcsDb.markAdopted = async (id) => syncRows.set(id, { serverId: id, state: 'pending', ...(syncRows.get(id) || {}), adoptedAt: '2026-09-30T00:00:00.000Z' })
npcsDb.putSync = async (id, row) => syncRows.set(id, { serverId: id, ...(syncRows.get(id) || {}), ...row, lastAttemptAt: new Date().toISOString() })
npcsDb.markDirty = async () => {}
npcsDb.addKills = async (at, profile, siteProfileId, n) => kills.push({ ...at, profile, siteProfileId, n })
t.after(() => Object.assign(npcsDb, saved))
return { store, syncRows, kills }
}
/** The sidecar and the server rows, recording what went up. */
function stubWire(t, { kits = ['warden_rifle', 'bandit_smg'], own = { managed: false, profiles: {} }, answers = {} } = {}) {
const calls = []
const saved = {
kits: client.kits, npcProfiles: client.npcProfiles, npcProfilesSet: client.npcProfilesSet,
npcPlacements: client.npcPlacements, npcPlacement: client.npcPlacement, worldPlace: client.worldPlace,
getServer: serversDb.getServer, listForPolling: servers.listForPolling,
}
serversDb.getServer = async (id) => ({ id, name: id.toUpperCase(), sidecarBaseUrl: `http://${id}:1`, sidecarTokenEnc: null, enabled: 1 })
servers.listForPolling = async () => [{ id: 'main', name: 'MAIN' }, { id: 'arena', name: 'ARENA' }]
client.kits = async () => ({ ok: true, data: { kind: 'kits.list', kits: kits.map((name) => ({ name })) } })
client.npcProfiles = async (srv) => {
calls.push({ server: srv.id, cmd: 'npc.profiles' })
return { ok: true, data: { kind: 'npc.profiles', ...own } }
}
client.npcProfilesSet = async (srv, profiles) => {
calls.push({ server: srv.id, cmd: 'npc.profiles.set', profiles })
return { ok: true, data: { kind: 'npc.ok', refused: answers.refused || {} } }
}
client.npcPlacements = async (srv) => ({ ok: true, data: { kind: 'npc.placements', placements: answers.placements || [], routes: ['gate'], cost: 'RunicNPC: 3 NPC(s)' } })
client.npcPlacement = async (srv, bodyOut) => {
calls.push({ server: srv.id, cmd: 'npc.placement', body: bodyOut })
return { ok: true, data: answers.placement || { kind: 'npc.ok', id: 'warden-1', position: { x: 1, y: 2, z: 3 }, built: false, cost: 'RunicNPC: 1 NPC(s)' } }
}
client.worldPlace = async (srv, bodyOut) => {
calls.push({ server: srv.id, cmd: 'world.place', body: bodyOut })
return { ok: true, data: { kind: 'world.ok', placed: [{ id: '77', kind: 'npc', prefab: `runicnpc:${bodyOut.profile}` }] } }
}
t.after(() => {
Object.assign(client, { kits: saved.kits, npcProfiles: saved.npcProfiles, npcProfilesSet: saved.npcProfilesSet, npcPlacements: saved.npcPlacements, npcPlacement: saved.npcPlacement, worldPlace: saved.worldPlace })
serversDb.getServer = saved.getServer
servers.listForPolling = saved.listForPolling
})
return calls
}
// ── the profile, as RunicNPC reads it ─────────────────────────────────────
test('a profile is refused for what RunicNPC would refuse, in its own words', () => {
const cases = [
[{ names: [] }, 'names: give at least one'],
[{ names: ['ok', ' '] }, 'names: give at least one'],
[{ kits: [] }, 'kits: give at least one'],
[{ prefab: 'player' }, "prefab: 'player' is not one of Rust's scientist prefabs"],
[{ role: 'guard' }, "role: 'guard' is not roamer or sentry"],
[{ movement: { mode: 'fly', radius: 5 } }, "movement.mode: 'fly' is not wander"],
[{ movement: { mode: 'wander', radius: 0 } }, "a wanderer's radius must be above 0"],
[{ health: 0 }, 'health: must be above 0'],
[{ damageTaken: { head: -1, body: 1, legs: 1 } }, 'damageTaken'],
[{ ranges: { sense: 30, loseTarget: 10, chase: 0, attack: 30 } }, 'loseTarget at least sense'],
[{ visionCone: 2 }, 'visionCone: between -1 and 1'],
[{ healthThresholds: [1.5] }, 'healthThresholds: fractions between 0 and 1'],
]
for (const [extra, words] of cases) {
const out = shape.checkBody(body(extra))
assert.strictEqual(out.ok, false, JSON.stringify(extra))
assert.ok(out.error.includes(words), `${out.error} / ${words}`)
}
const ok = shape.checkBody(body({ movement: { mode: 'route:gate' }, healthThresholds: [0.25, 0.5, 0.5] }))
assert.strictEqual(ok.ok, true)
assert.deepStrictEqual(ok.value.movement, { mode: 'route:gate', radius: 0 })
assert.deepStrictEqual(ok.value.healthThresholds, [0.5, 0.25], 'deduplicated, highest first')
})
test('a kit a covered server lacks is refused on save, naming the server', async (t) => {
stubStore(t, { servers: [npcServer('main'), npcServer('arena')] })
stubWire(t, { kits: ['bandit_smg'] })
await assert.rejects(
npcs.create({ name: 'warden', body: body(), servers: ['main'] }),
(err) => err.status === 400 && /MAIN has no kit 'warden_rifle'/.test(err.message),
)
})
test('two profiles of one name may not share a server; across servers they may', async (t) => {
stubStore(t, { servers: [npcServer('main'), npcServer('arena')], profiles: [{ id: 1, name: 'warden', body: body(), servers: ['main'] }] })
stubWire(t)
await assert.rejects(npcs.create({ name: 'warden', body: body(), allServers: true }), (err) => err.status === 409 && /already on MAIN/.test(err.message))
const made = await npcs.create({ name: 'warden', body: body(), servers: ['arena'] })
assert.deepStrictEqual(made.servers, ['arena'])
await assert.rejects(npcs.create({ name: 'Bad Name', body: body(), servers: ['main'] }), /name: 1 to 40/)
await assert.rejects(npcs.create({ name: 'x', body: body(), servers: ['main'], killsScope: 'fleet' }), /killsScope/)
})
// ── adoption and the push (D244, D251) ───────────────────────────────────
test('the first push adopts a server\'s own profiles, then pushes them back unchanged (D244)', async (t) => {
const { store, syncRows } = stubStore(t, { servers: [npcServer('main')] })
const own = { managed: false, profiles: { bandit: body({ names: ['Bandit'], kits: ['bandit_smg'] }), 'Bad Name': body() } }
const calls = stubWire(t, { own })
const [out] = await npcSync.tick({ force: 'main' })
assert.strictEqual(out.outcome, 'pushed')
assert.deepStrictEqual(out.adopted.map((a) => [a.name, a.outcome]), [['Bad Name', 'skipped'], ['bandit', 'adopted']])
assert.strictEqual(store.length, 1)
assert.deepStrictEqual({ name: store[0].name, servers: store[0].servers, adoptedFrom: store[0].adoptedFrom }, { name: 'bandit', servers: ['main'], adoptedFrom: 'main' })
const pushed = calls.find((c) => c.cmd === 'npc.profiles.set')
assert.deepStrictEqual(Object.keys(pushed.profiles), ['bandit'])
assert.deepStrictEqual(pushed.profiles.bandit.names, ['Bandit'])
assert.ok(syncRows.get('main').adoptedAt)
assert.deepStrictEqual(syncRows.get('main').pushed, { bandit: store[0].id })
})
test('a site profile of the same name wins, and the server\'s own is kept as replaced (D251)', async (t) => {
const site = { id: 1, name: 'bandit', body: body({ names: ['Site Bandit'] }), allServers: true }
const { store } = stubStore(t, { servers: [npcServer('main')], profiles: [site] })
const calls = stubWire(t, { own: { managed: false, profiles: { bandit: body({ names: ['Own Bandit'] }) } } })
const [out] = await npcSync.tick({ force: 'main' })
assert.deepStrictEqual(out.adopted, [{ name: 'bandit', outcome: 'replaced', by: 1 }])
const kept = store.find((p) => p.replaced)
assert.ok(kept && kept.adoptedFrom === 'main' && kept.body.names[0] === 'Own Bandit')
assert.deepStrictEqual(calls.find((c) => c.cmd === 'npc.profiles.set').profiles.bandit.names, ['Site Bandit'])
// Restore is refused while the site's still covers the server...
await assert.rejects(npcs.restore(kept.id), (err) => err.status === 409)
// ...and allowed once it no longer does.
await npcs.remove(1)
const back = await npcs.restore(kept.id)
assert.strictEqual(back.replaced, false)
})
test('a server a site already manages is not adopted from', async (t) => {
const { store } = stubStore(t)
stubWire(t, { own: { managed: true, profiles: { stale: body() } } })
const [out] = await npcSync.tick({ force: 'main' })
assert.strictEqual(out.outcome, 'pushed')
assert.deepStrictEqual(out.adopted, [])
assert.strictEqual(store.length, 0)
})
test('a server without RunicNPC, or with API < 3, is recorded absent and never pushed', async (t) => {
const { syncRows } = stubStore(t, { servers: [npcServer('main', null), npcServer('arena', { loaded: true, version: '0.1.0', api: 2 })] })
const calls = stubWire(t)
const out = await npcSync.tick()
assert.deepStrictEqual(out.map((o) => o.outcome), ['absent', 'absent'])
assert.match(out[1].reason, /API 2, and the site needs 3/)
assert.strictEqual(syncRows.get('main').state, 'absent')
assert.strictEqual(calls.length, 0)
})
test('a push happens on a change, a restart, a failure and the audit, and not otherwise', () => {
const server = { worldReady: true, online: true, bootId: 'b1' }
const now = new Date().toISOString()
const ok = { adoptedAt: now, state: 'ok', syncedHash: 'h', bootId: 'b1', lastAttemptAt: now }
assert.strictEqual(npcSync.reasonToPush({ server, sync: null, hash: 'h' }), 'first')
assert.strictEqual(npcSync.reasonToPush({ server, sync: ok, hash: 'h' }), null)
assert.strictEqual(npcSync.reasonToPush({ server, sync: ok, hash: 'other' }), 'changed')
assert.strictEqual(npcSync.reasonToPush({ server: { ...server, bootId: 'b2' }, sync: ok, hash: 'h' }), 'restart')
assert.strictEqual(npcSync.reasonToPush({ server, sync: { ...ok, lastAttemptAt: new Date(Date.now() - npcSync.AUDIT_MS - 1000).toISOString() }, hash: 'h' }), 'audit')
assert.strictEqual(npcSync.reasonToPush({ server, sync: { ...ok, state: 'failed', syncedHash: null }, hash: 'h' }), 'retry')
assert.strictEqual(npcSync.reasonToPush({ server: { ...server, worldReady: false }, sync: ok, hash: 'other' }), null, 'not before the world has loaded')
assert.strictEqual(npcSync.reasonToPush({ server: { ...server, online: false }, sync: null, hash: 'h' }), null)
})
test('what a server is pushed: its covering profiles, not the replaced, hashed stably', () => {
const profiles = [
{ id: 1, name: 'warden', body: body(), allServers: true, servers: [], replaced: false },
{ id: 2, name: 'bandit', body: body(), allServers: false, servers: ['arena'], replaced: false },
{ id: 3, name: 'old', body: body(), allServers: false, servers: ['main'], replaced: true },
]
const main = npcs.desiredFor('main', profiles)
assert.deepStrictEqual(Object.keys(main.profiles), ['warden'])
assert.deepStrictEqual(main.map, { warden: 1 })
assert.strictEqual(main.hash, npcs.desiredFor('main', [...profiles].reverse()).hash)
assert.notStrictEqual(main.hash, npcs.desiredFor('arena', profiles).hash)
})
// ── events (D243) ────────────────────────────────────────────────────────
test('the NPC picker lists the site\'s profiles first, then Rust\'s own, grouped (D243)', async (t) => {
stubStore(t, {
servers: [npcServer('main'), npcServer('arena', null)],
profiles: [{ id: 1, name: 'warden', body: body({ names: ['Warden'] }), servers: ['main'] }, { id: 2, name: 'ghost', body: body(), servers: ['arena'] }],
})
const rows = await world.OPTION_SOURCES.find((s) => s.id === 'rust.options.npcs').resolve()
assert.deepStrictEqual(rows[0], { value: 'profile:warden', label: 'Warden (warden)', group: 'NPC profiles' })
assert.ok(!rows.some((r) => r.value === 'profile:ghost'), 'a profile only on a server without RunicNPC is not offered')
assert.ok(rows.slice(1).every((r) => r.group === "Rust's own"))
})
test('with no server that has RunicNPC, the picker reads as it always did', async (t) => {
stubStore(t, { servers: [npcServer('main', null)], profiles: [{ id: 1, name: 'warden', body: body(), allServers: true }] })
const rows = await world.OPTION_SOURCES.find((s) => s.id === 'rust.options.npcs').resolve()
assert.deepStrictEqual(rows.map((r) => r.value), world.PLACEABLE.filter((p) => p.kind === 'npc').map((p) => p.key))
assert.ok(rows.every((r) => !r.group))
})
test('the Place NPCs step sends a profile, and refuses one a server cannot place', async (t) => {
stubStore(t, { servers: [npcServer('main'), npcServer('arena', { loaded: false })], profiles: [{ id: 1, name: 'warden', body: body(), servers: ['main'] }] })
const calls = stubWire(t)
const place = world.ACTIONS.find((a) => a.id === 'rust.npc.place')
const params = (server, prefab) => ({ prefab, count: 3, server, x: 10, z: 20 })
const done = await place.perform({ runId: 9, idempotencyKey: 'k1', params: params('main', 'profile:warden') })
assert.strictEqual(done.ok, true, done.error)
const sent = calls.find((c) => c.cmd === 'world.place').body
assert.strictEqual(sent.profile, 'warden')
assert.strictEqual(sent.prefab, undefined)
const noNpc = await place.perform({ runId: 9, idempotencyKey: 'k2', params: params('arena', 'profile:warden'), verify: true })
assert.strictEqual(noNpc.ok, false)
assert.match(noNpc.error, /RunicNPC is not loaded on it/)
const unknown = await place.perform({ runId: 9, idempotencyKey: 'k3', params: params('main', 'profile:nobody'), verify: true })
assert.match(unknown.error, /no NPC profile "nobody"/)
const own = await place.perform({ runId: 9, idempotencyKey: 'k4', params: params('arena', 'npc.scientist'), verify: true })
assert.strictEqual(own.ok, true, 'Rust\'s own still place anywhere')
})
// ── triggers, ingest and titles ──────────────────────────────────────────
test('rust.npc.died and rust.npc.health fire with what a gate filters on', async () => {
const emit = require('../engagement/emit')
const calls = ctx.events.emit.calls
const before = calls.length
const server = { id: 'main', name: 'Main' }
const t = Date.now()
await emit.onEvent(server, { kind: 'npc.died', frame: { kind: 'npc.died', t, netId: '5', profile: 'guard', name: 'Gate Guard', runId: '42', killerName: 'Marisol', killerId: '7656', contributors: [{ steamId: '7656', damage: 250 }] } })
await emit.onEvent(server, { kind: 'npc.health', frame: { kind: 'npc.health', t, netId: '6', profile: 'boss', name: 'Big', owner: 'placement:boss-1', placement: 'boss-1', threshold: 0.5 } })
await emit.onEvent(server, { kind: 'npc.died', frame: { kind: 'npc.died', t: t - 60 * 60 * 1000, netId: '8', profile: 'guard' } })
const fired = calls.slice(before)
assert.strictEqual(fired.length, 2, 'a death an hour old is history, not a wave falling')
const [died, health] = fired
assert.strictEqual(died[0], 'rust.npc.died')
assert.deepStrictEqual(
{ profile: died[1].data.profile, byEvent: died[1].data.byEvent, runId: died[1].data.runId, killer: died[1].data.killer, contributors: died[1].data.contributors },
{ profile: 'guard', byEvent: true, runId: '42', killer: 'Marisol', contributors: 1 },
)
assert.strictEqual(health[0], 'rust.npc.health')
assert.deepStrictEqual({ percent: health[1].data.percent, byEvent: health[1].data.byEvent, placement: health[1].data.placement }, { percent: 50, byEvent: false, placement: 'boss-1' })
})
test('a kill is credited to the site profile pushed under its name (D247)', async (t) => {
const { kills } = stubStore(t, { sync: [{ serverId: 'main', state: 'ok', pushed: { warden: 4 } }] })
const eventsDb = require('../model/events/events.db')
const saved = { ...eventsDb }
for (const k of ['touchWipe', 'insertEvent', 'touchPlayer', 'addStats', 'addGathered', 'addTitleStats', 'addWeaponKills']) eventsDb[k] = async () => {}
t.after(() => Object.assign(eventsDb, saved))
const ingest = require('../ingest')
await ingest.apply('main', { kind: 'player.tally', frame: { kind: 'player.tally', wipeId: 'w1', steamId: '7656', npcKills: 3, npcProfileKills: { warden: 2, bandit: 1, zero: 0 } } })
assert.deepStrictEqual(
kills.map((k) => [k.profile, k.siteProfileId, k.n, k.wipeId]),
[['warden', 4, 2, 'w1'], ['bandit', 0, 1, 'w1']],
)
})
test('a title rule on a profile\'s kills names the profile, and ranks it on its own (D250)', () => {
const bad = titles.validateSettings({ rules: [{ stat: 'profilekills', topN: 1, text: 'Warden Slayer', color: '#ff0000' }] })
assert.strictEqual(bad.ok, false)
assert.match(bad.errors[0], /pick the NPC profile/)
const good = titles.validateSettings({ rules: [
{ stat: 'profilekills', profile: 4, topN: 1, text: 'Warden Slayer', color: '#ff0000' },
{ stat: 'profilekills', profile: 5, topN: 1, text: 'Bandit Bane', color: '#00ff00' },
] })
assert.strictEqual(good.ok, true)
assert.deepStrictEqual(good.value.rules.map((r) => r.profile), [4, 5])
const held = titles.evaluate(good.value.rules, {
'profilekills:4': [{ steamId: 'a', value: 3 }],
'profilekills:5': [{ steamId: 'b', value: 9 }],
})
assert.deepStrictEqual(held.get('a'), [{ text: 'Warden Slayer', color: '#ff0000' }])
assert.deepStrictEqual(held.get('b'), [{ text: 'Bandit Bane', color: '#00ff00' }])
})
test('a profile\'s kills are counted as the profile says (D247)', () => {
const base = { name: 'warden', siteProfileId: 4, serverId: 'main' }
const server = npcsDb.scopeWhere({ ...base, scope: 'server', wipeId: 'w1' })
assert.match(server.sql, /k\.profile = \? AND k\.server_id = \? AND k\.wipe_id = \?/)
assert.deepStrictEqual(server.args, ['warden', 'main', 'w1'])
const name = npcsDb.scopeWhere({ ...base, scope: 'name', wipeId: 'w1', currentWipe: true })
assert.match(name.sql, /k\.profile = \? AND k\.wipe_id = \(SELECT/)
assert.deepStrictEqual(name.args, ['warden'])
const profile = npcsDb.scopeWhere({ ...base, scope: 'profile', wipeId: null })
assert.strictEqual(profile.sql, 'k.site_profile_id = ?')
assert.deepStrictEqual(profile.args, [4])
})
// ── placements (D245, D246) ─────────────────────────────────────────────
test('a placement from the map goes up with x and z only, and comes back named (D245, D246)', async (t) => {
stubStore(t)
const calls = stubWire(t)
const out = await npcs.addPlacement('main', { profile: 'warden', position: { x: 12.5, y: 99, z: -40 }, count: 3, respawnMode: 'group' })
assert.deepStrictEqual(out, { id: 'warden-1', position: { x: 1, y: 2, z: 3 }, built: false, cost: 'RunicNPC: 1 NPC(s)' })
const sent = calls.find((c) => c.cmd === 'npc.placement').body
assert.strictEqual(sent.op, 'add')
assert.deepStrictEqual(sent.placement.position, { x: 12.5, z: -40 }, 'the server grounds it, so no y goes up')
assert.deepStrictEqual({ count: sent.placement.count, respawn: sent.placement.respawn, respawnMode: sent.placement.respawnMode }, { count: 3, respawn: 300, respawnMode: 'group' })
await assert.rejects(npcs.addPlacement('main', { profile: 'warden', position: { x: 1 } }), /position: x and z/)
await assert.rejects(npcs.addPlacement('main', { profile: 'warden', position: { x: 1, z: 1 }, respawnMode: 'sometimes' }), /each or group/)
})
test('a refusal from the server keeps RunicNPC\'s own words and a fitting status', async (t) => {
stubStore(t)
stubWire(t, { answers: { placement: { kind: 'npc.error', reason: 'refused', message: 'A roamer cannot stand there: it is under water.' } } })
await assert.rejects(npcs.addPlacement('main', { profile: 'warden', position: { x: 1, z: 1 } }), (err) => err.status === 400 && err.message === 'A roamer cannot stand there: it is under water.')
stubWire(t, { answers: { placement: { kind: 'npc.error', reason: 'not-found', message: "there is no placement 'x'" } } })
await assert.rejects(npcs.removePlacement('main', 'x'), (err) => err.status === 404)
})
test('an edit keeps the placement\'s spot unless a whole one is sent', async (t) => {
stubStore(t)
const calls = stubWire(t, { answers: { placements: [{ id: 'warden-1', placement: { profile: 'warden', position: { x: 5, y: 6, z: 7 }, yaw: 90, count: 1, respawn: 300, respawnMode: 'each' }, alive: 1 }] } })
await npcs.setPlacement('main', 'warden-1', { profile: 'warden', count: 2 })
const sent = calls.find((c) => c.cmd === 'npc.placement').body
assert.deepStrictEqual({ op: sent.op, id: sent.id, position: sent.placement.position, yaw: sent.placement.yaw, count: sent.placement.count }, { op: 'set', id: 'warden-1', position: { x: 5, y: 6, z: 7 }, yaw: 90, count: 2 })
await assert.rejects(npcs.setPlacement('main', 'nope', { profile: 'warden' }), (err) => err.status === 404)
})

File diff suppressed because it is too large Load Diff