diff --git a/client/src/api.js b/client/src/api.js
index c76eae6..2c51a89 100644
--- a/client/src/api.js
+++ b/client/src/api.js
@@ -56,6 +56,15 @@ export const servers = {
// gets; then what moves on it, already cut down to this viewer on the server.
map: (id) => req(`/public/rust/servers/${encodeURIComponent(id)}/map`),
mapLive: (id) => req(`/public/rust/servers/${encodeURIComponent(id)}/map/live`),
+
+ // RunicNPC (runicnpc stage 4). The profiles a leaderboard can rank by, one
+ // profile's ranking counted as the profile says (D247, D250), and one
+ // player's kills by profile, for an opened leaderboard row (D252).
+ npcProfiles: (id) => req(`/public/rust/servers/${encodeURIComponent(id)}/npc-profiles`),
+ npcLeaderboard: (id, { profile, wipe = null, limit = null } = {}) =>
+ req(`/public/rust/servers/${encodeURIComponent(id)}/npc-leaderboard${query({ profile, wipe, limit })}`),
+ npcKills: (id, steamId, { wipe = null } = {}) =>
+ req(`/public/rust/servers/${encodeURIComponent(id)}/players/${encodeURIComponent(steamId)}/npc-kills${query({ wipe })}`),
}
// One clan. Its roster comes back only for a viewer inside the operator's roster
@@ -94,6 +103,11 @@ export const playerServers = {
list: () => req('/player/rust/servers'),
}
+// Your own kills of each RunicNPC profile, current wipes, across your linked accounts (D252).
+export const playerNpcKills = {
+ list: () => req('/player/rust/npc-kills'),
+}
+
// R1's identity link, from the signed-in player's side.
//
// **The code is the whole of what goes up.** The site has no idea which server
@@ -220,6 +234,28 @@ export const adminZones = {
remove: (id) => req(`/admin/rust/zones/presets/${encodeURIComponent(id)}`, { method: 'DELETE' }),
}
+// ── admin · NPCs (runicnpc stage 4) ─────────────────────────────────────────
+//
+// The site's RunicNPC profiles (per server, shared or fleet), pushed to each
+// server; and each server's placements, which live on the server (D222), so
+// every placement call is a live round trip that fails while the game is off.
+const npcServer = (serverId) => `/admin/rust/npcs/servers/${encodeURIComponent(serverId)}`
+export const adminNpcs = {
+ read: () => req('/admin/rust/npcs'),
+ create: (body) => req('/admin/rust/npcs/profiles', { method: 'POST', body }),
+ update: (id, body) => req(`/admin/rust/npcs/profiles/${encodeURIComponent(id)}`, { method: 'PUT', body }),
+ remove: (id) => req(`/admin/rust/npcs/profiles/${encodeURIComponent(id)}`, { method: 'DELETE' }),
+ restore: (id) => req(`/admin/rust/npcs/profiles/${encodeURIComponent(id)}/restore`, { method: 'POST' }),
+ push: (serverId) => req(`${npcServer(serverId)}/push`, { method: 'POST' }),
+ placements: (serverId) => req(`${npcServer(serverId)}/placements`),
+ place: (serverId, body) => req(`${npcServer(serverId)}/placements`, { method: 'POST', body }),
+ setPlacement: (serverId, id, body) => req(`${npcServer(serverId)}/placements/${encodeURIComponent(id)}`, { method: 'PUT', body }),
+ removePlacement: (serverId, id) => req(`${npcServer(serverId)}/placements/${encodeURIComponent(id)}`, { method: 'DELETE' }),
+ renamePlacement: (serverId, id, to) =>
+ req(`${npcServer(serverId)}/placements/${encodeURIComponent(id)}/rename`, { method: 'POST', body: { to } }),
+ respawnPlacement: (serverId, id) => req(`${npcServer(serverId)}/placements/${encodeURIComponent(id)}/respawn`, { method: 'POST' }),
+}
+
// ── admin · mod configuration (R18) ───────────────────────────────────────
//
// Every call here is a LIVE round trip to a game host, which makes this the only
@@ -301,6 +337,8 @@ export default {
adminConfig,
adminVisibility,
adminZones,
+ adminNpcs,
+ playerNpcKills,
adminUserLinks,
adminUserPermissions,
BASE,
diff --git a/client/src/components/Leaderboard.jsx b/client/src/components/Leaderboard.jsx
index d4f7ca9..f389b66 100644
--- a/client/src/components/Leaderboard.jsx
+++ b/client/src/components/Leaderboard.jsx
@@ -9,6 +9,12 @@
// re-sorted itself under the reader's cursor every twenty seconds would be worse
// than one that is four minutes old, and the page has a `Refresh` on the tab
// strip for anybody who disagrees.
+//
+// RunicNPC (runicnpc stage 4). Where the server has NPC profiles, "Rank by"
+// offers each one's kills, counted as the profile says (D247, D250), and opening
+// a player's row shows their kills of each profile for the wipe shown (D252).
+
+import { useState } from 'react'
import { ErrorState, Loading, useAsync } from '../core.js'
import Empty from './Empty.jsx'
@@ -27,6 +33,52 @@ const COLUMNS = [
]
export default function Leaderboard({ serverId, wipeId, sort, onSort }) {
+ const { data: npc } = useAsync(() => api.servers.npcProfiles(serverId).catch(() => ({ profiles: [] })), [serverId])
+ const [profileId, setProfileId] = useState('')
+ const profiles = (npc && npc.profiles) || []
+ const picked = profiles.find((p) => String(p.id) === String(profileId))
+
+ const picker = profiles.length > 0 && (
+
+ )
+
+ if (picked) {
+ return (
+
+ {picker}
+
+
+ )
+ }
+
+ return (
+
+ {picker}
+
+
+ )
+}
+
+/** How a profile's kills are counted, in the reader's words (D247). */
+const SCOPE_NOTE = {
+ server: 'Counted on this server.',
+ name: 'Counted on every server with this NPC.',
+ profile: 'Counted wherever this NPC is placed.',
+}
+
+function Overall({ serverId, wipeId, sort, onSort }) {
+ const [open, setOpen] = useState(null)
const { data, loading, error } = useAsync(
() => api.servers.leaderboard(serverId, { wipe: wipeId, sort, limit: 50 }),
[serverId, wipeId, sort],
@@ -93,10 +145,15 @@ export default function Leaderboard({ serverId, wipeId, sort, onSort }) {
- {rows.map((row, index) => (
-
+ {rows.map((row, index) => [
+
setOpen(open === row.steamId ? null : row.steamId)}
+ title="Open for this player’s kills of each NPC"
+ style={{ borderTop: '1px solid var(--line-soft, var(--line))', cursor: 'pointer' }}
+ >
- {index + 1}
+ {open === row.steamId ? '▾' : '▸'} {index + 1}
{/* A player this module has never seen NAMED is shown by the tail
of their id rather than as a blank: the row is real, and a
nameless one reads as a rendering fault. */}
@@ -130,6 +187,77 @@ export default function Leaderboard({ serverId, wipeId, sort, onSort }) {
{showLastSeen && (
{ago(row.lastSeen)}
)}
+
,
+ open === row.steamId && (
+
+
+
+
+
+ ),
+ ])}
+
+
+
+ )
+}
+
+/** An opened row (D252): the player's kills of each RunicNPC profile, for the wipe shown. */
+function PlayerNpcKills({ serverId, steamId, wipeId }) {
+ const { data, loading, error } = useAsync(() => api.servers.npcKills(serverId, steamId, { wipe: wipeId }), [serverId, steamId, wipeId])
+ if (loading) return Loading…
+ if (error) return Their NPC kills could not be loaded.
+ const kills = (data && data.kills) || []
+ if (kills.length === 0) return No kills of this server’s NPC profiles{wipeId ? ' this wipe' : ''}.
+ return (
+
+ {kills.map((k, i) => (
+
+ {i > 0 && · }
+ {k.label} {count(k.kills)}
+
+ ))}
+
+ )
+}
+
+/** One profile's ranking (D250): who has killed the most of it. */
+function ProfileBoard({ serverId, wipeId, profile }) {
+ const { data, loading, error } = useAsync(
+ () => api.servers.npcLeaderboard(serverId, { profile: profile.id, wipe: wipeId, limit: 50 }),
+ [serverId, wipeId, profile.id],
+ )
+ if (loading) return
+ if (error) return
+ const rows = (data && data.leaderboard) || []
+ if (rows.length === 0) {
+ return
+ }
+ return (
+
))}
diff --git a/client/src/components/MapView.jsx b/client/src/components/MapView.jsx
index 75bf79e..ada424a 100644
--- a/client/src/components/MapView.jsx
+++ b/client/src/components/MapView.jsx
@@ -20,7 +20,7 @@ import { ErrorState, Loading, useAsync } from '../core.js'
import Empty from './Empty.jsx'
import usePolled from '../hooks/usePolled.js'
import { ago } from '../lib/format.js'
-import { boundsOf, countdown, grid, gridLabel, toLatLng } from '../lib/mapGeometry.js'
+import { boundsOf, countdown, fromLatLng, grid, gridLabel, toLatLng } from '../lib/mapGeometry.js'
import api, { BASE } from '../api.js'
const STYLE_ID = 'rust-leaflet-css'
@@ -64,7 +64,13 @@ const WORLD_NAMES = {
const AUDIENCE_WORDS = { public: 'everyone', signed_in: 'signed-in players', staff: 'staff only' }
-export default function MapView({ serverId, online }) {
+/**
+ * `onPick` and `pins` are for an admin page that places things on the map (the NPC
+ * placements page, runicnpc stage 4, D245): a click answers the world point under
+ * it, and `pins` (`{ x, z, label, colour, ring }`) are drawn above every layer. The
+ * public page passes neither.
+ */
+export default function MapView({ serverId, online, onPick = null, pins = null }) {
const { data: meta, loading, error } = useAsync(() => api.servers.map(serverId), [serverId])
const geometry = meta ? meta.geometry : null
@@ -101,6 +107,8 @@ export default function MapView({ serverId, online }) {
const container = useRef(null)
const mapRef = useRef(null)
const groups = useRef(null)
+ const pickRef = useRef(onPick)
+ pickRef.current = onPick
// The map itself: made once per server and geometry, torn down with them.
useEffect(() => {
@@ -121,7 +129,7 @@ export default function MapView({ serverId, online }) {
map.fitBounds(bounds)
const made = {}
- for (const id of ['grid', 'monuments', 'world', 'events', 'players', 'bases', 'mates']) made[id] = L.layerGroup().addTo(map)
+ for (const id of ['grid', 'monuments', 'world', 'events', 'players', 'bases', 'mates', 'pins']) made[id] = L.layerGroup().addTo(map)
// The labels are a layer of their own inside the grid's, shown only when a cell
// is wide enough on screen to hold one: at the fitted zoom a 20-cell map's
// labels overlap into a wall of text (found on the phase 14 walk).
@@ -171,6 +179,17 @@ export default function MapView({ serverId, online }) {
mapRef.current = map
groups.current = made
+ pickRef.current = onPick
+ if (onPick) {
+ container.current.style.cursor = 'crosshair'
+ map.on('click', (e) => {
+ const at = fromLatLng(geometry, e.latlng.lat, e.latlng.lng)
+ const half = geometry.worldSize / 2
+ if (at && pickRef.current && Math.abs(at.x) <= half && Math.abs(at.z) <= half) {
+ pickRef.current({ x: Math.round(at.x * 10) / 10, z: Math.round(at.z * 10) / 10, grid: gridLabel(geometry, at.x, at.z) })
+ }
+ })
+ }
return () => {
map.remove()
mapRef.current = null
@@ -225,6 +244,19 @@ export default function MapView({ serverId, online }) {
}
}, [leaflet, geometry, live.data])
+ // An admin page's own pins, above everything else.
+ useEffect(() => {
+ const made = groups.current
+ if (!leaflet || !made || !geometry) return
+ const { L } = leaflet
+ made.pins.clearLayers()
+ for (const p of pins || []) {
+ const marker = dot(L, toLatLng(geometry, p.x, p.z), p.colour || '#ffd54f', p.ring ? 9 : 6, p.ring ? 3 : 1)
+ if (p.label) marker.bindTooltip(escape(p.label))
+ marker.addTo(made.pins)
+ }
+ }, [leaflet, geometry, pins])
+
// The legend's checkboxes: a group is on the map or off it.
useEffect(() => {
const map = mapRef.current
diff --git a/client/src/entry.jsx b/client/src/entry.jsx
index fe97423..71fbc93 100644
--- a/client/src/entry.jsx
+++ b/client/src/entry.jsx
@@ -27,9 +27,11 @@ import ModConfig from './routes/admin/ModConfig.jsx'
import Visibility from './routes/admin/Visibility.jsx'
import ServerSettings from './routes/admin/ServerSettings.jsx'
import ZonePresets from './routes/admin/ZonePresets.jsx'
+import NpcProfiles from './routes/admin/NpcProfiles.jsx'
+import NpcPlacements from './routes/admin/NpcPlacements.jsx'
import UserRustSections from './routes/admin/UserRustSections.jsx'
import FooterStatus from './components/FooterStatus.jsx'
-import { IconEye, IconKey, IconLink, IconServer, IconSliders, IconZone } from './icons.jsx'
+import { IconEye, IconKey, IconLink, IconNpc, IconPlacement, IconServer, IconSliders, IconZone } from './icons.jsx'
// The module id, exactly as `module.json` spells it. Core keys the registry by it
// and prefixes every route path with it.
@@ -113,6 +115,10 @@ registry.registerRoutes(ID, {
// Zone presets (PLAN_REDESIGNS §3.1, D210). Core's step editor has single-
// value fields, so a zone's flags are ticked here and a step copies the set.
{ path: 'zones', element: },
+ // RunicNPC (runicnpc stage 4): the site's NPC profiles, pushed to each
+ // server, and each server's placements, created by clicking its live map.
+ { path: 'npcs', element: },
+ { path: 'npcs/placements', element: },
],
})
@@ -164,6 +170,8 @@ registry.registerNav(ID, {
{ label: 'Rust visibility', to: '/admin/rust/visibility', icon: IconEye },
{ label: 'Rust servers', to: '/admin/rust/servers', icon: IconServer },
{ label: 'Rust zone presets', to: '/admin/rust/zones', icon: IconZone },
+ { label: 'Rust NPC profiles', to: '/admin/rust/npcs', icon: IconNpc },
+ { label: 'Rust NPC placements', to: '/admin/rust/npcs/placements', icon: IconPlacement },
],
})
diff --git a/client/src/icons.jsx b/client/src/icons.jsx
index 3551108..f8c4df4 100644
--- a/client/src/icons.jsx
+++ b/client/src/icons.jsx
@@ -124,3 +124,25 @@ export const IconZone = () => (
)
+
+/**
+ * A figure — the admin sidebar's row for RunicNPC's profiles (runicnpc stage 4).
+ * A head and shoulders: the page is about who the NPCs are.
+ */
+export const IconNpc = () => (
+
+
+
+
+)
+
+/**
+ * A figure on a spot — the admin sidebar's row for NPC placements: where they stand.
+ */
+export const IconPlacement = () => (
+
+
+
+
+
+)
diff --git a/client/src/lib/feed.js b/client/src/lib/feed.js
index 3322595..32666f9 100644
--- a/client/src/lib/feed.js
+++ b/client/src/lib/feed.js
@@ -161,7 +161,9 @@ function death(frame, name) {
case 'npc':
return {
tone: 'death',
- actor: attacker(frame.attackerName) || 'Something',
+ // One of RunicNPC's (runicnpc stage 4): its own name, as typed on its profile,
+ // rather than the prefab it is built from. Older frames carry only the prefab.
+ actor: frame.attackerNpc || attacker(frame.attackerName) || 'Something',
verb: 'killed',
subject: name,
detail: where,
diff --git a/client/src/lib/mapGeometry.js b/client/src/lib/mapGeometry.js
index 87c5476..5dea995 100644
--- a/client/src/lib/mapGeometry.js
+++ b/client/src/lib/mapGeometry.js
@@ -34,6 +34,15 @@ export function toLatLng(g, x, z) {
return [(Number(z) + half) * s + m, (Number(x) + half) * s + m]
}
+/** The inverse of `toLatLng`: a point on the picture as world `{ x, z }` in metres (a click on the map). */
+export function fromLatLng(g, lat, lng) {
+ const s = scaleOf(g)
+ if (!(s > 0)) return null
+ const half = g.worldSize / 2
+ const m = g.oceanMargin || 0
+ return { x: (Number(lng) - m) / s - half, z: (Number(lat) - m) / s - half }
+}
+
/** The picture's bounds in the same space. */
export function boundsOf(g) {
return [[0, 0], [g.height, g.width]]
diff --git a/client/src/routes/admin/ChatTitles.jsx b/client/src/routes/admin/ChatTitles.jsx
index 3dd9f4b..2734988 100644
--- a/client/src/routes/admin/ChatTitles.jsx
+++ b/client/src/routes/admin/ChatTitles.jsx
@@ -34,6 +34,8 @@ export function statOptions(categories) {
return [
...(categories || []).map((c) => ({ id: c.stat, label: c.label, title: c.current })),
{ id: 'playtime', label: 'Playtime', title: null },
+ // RunicNPC (runicnpc stage 4, D250): one NPC profile's kills. The rule names the profile.
+ { id: 'profilekills', label: 'Kills of an NPC profile', title: null },
]
}
@@ -66,6 +68,8 @@ function Chip({ text, color }) {
export function TitlesForm({ server, categories, onSaved, onCancel }) {
const start = server.titles || { mode: 'first', max: 2, rules: [] }
const stats = statOptions(categories)
+ const { data: npc } = useAsync(() => api.servers.npcProfiles(server.id).catch(() => ({ profiles: [] })), [server.id])
+ const profiles = (npc && npc.profiles) || []
const [mode, setMode] = useState(start.mode)
const [max, setMax] = useState(start.max)
const [rules, setRules] = useState(start.rules.map((r) => ({ ...r })))
@@ -74,7 +78,7 @@ export function TitlesForm({ server, categories, onSaved, onCancel }) {
const setRule = (i, key) => (e) => {
const value = e.target.value
- setRules((list) => list.map((r, n) => (n === i ? { ...r, [key]: key === 'topN' ? Number(value) : value } : r)))
+ setRules((list) => list.map((r, n) => (n === i ? { ...r, [key]: key === 'topN' || key === 'profile' ? Number(value) : value } : r)))
}
const move = (i, by) => setRules((list) => {
const next = [...list]
@@ -117,6 +121,12 @@ export function TitlesForm({ server, categories, onSaved, onCancel }) {
+ {r.stat === 'profilekills' && (
+
+ )}
earn
{/* The category's title is a PLACEHOLDER, never a value: saved as the rule's own text it would stop a rename reaching this rule (§5.5). */}
diff --git a/client/src/routes/admin/NpcPlacements.jsx b/client/src/routes/admin/NpcPlacements.jsx
new file mode 100644
index 0000000..84d197f
--- /dev/null
+++ b/client/src/routes/admin/NpcPlacements.jsx
@@ -0,0 +1,289 @@
+// ── Admin · Rust · NPC placements (docs/runicnpc/PLAN.md stage 4) ─────────
+//
+// One server's RunicNPC placements. They live on the server (D222) — an admin's
+// `/rnpc place` in game and this page edit the same list — so every read and
+// write here is a live round trip, and a server whose game is off has nothing
+// to show.
+//
+// A new placement is made by clicking the live map (D245): the server puts the
+// point on the ground, checks it against the navmesh, and names it as in game
+// (D246). A roof or a building top is placed in game. Every answer that adds
+// NPCs carries the cost warning (D227).
+
+import { useEffect, useMemo, useState } from 'react'
+import { Link } from 'react-router-dom'
+
+import { ErrorState, Loading, useAsync } from '../../core.js'
+import MapView from '../../components/MapView.jsx'
+import api from '../../api.js'
+
+const inputStyle = {
+ background: 'var(--panel-flat, transparent)',
+ color: 'var(--text)',
+ border: '1px solid var(--line)',
+ borderRadius: 6,
+ padding: '6px 8px',
+ font: 'inherit',
+}
+
+const blank = (profile = '') => ({ profile, count: '1', respawn: '300', respawnMode: 'each', move: '', radius: '' })
+
+function formOf(p) {
+ const v = p.placement || {}
+ return {
+ profile: v.profile || '',
+ count: String(v.count || 1),
+ respawn: String(v.respawn || 300),
+ respawnMode: v.respawnMode || 'each',
+ move: (v.movement && v.movement.mode) || '',
+ radius: v.movement && v.movement.radius !== undefined ? String(v.movement.radius) : '',
+ }
+}
+
+function bodyOf(f) {
+ return {
+ profile: f.profile,
+ count: Number(f.count),
+ respawn: Number(f.respawn),
+ respawnMode: f.respawnMode,
+ ...(f.move ? { movement: { mode: f.move, radius: f.radius === '' ? undefined : Number(f.radius) } } : { movement: null }),
+ }
+}
+
+export default function NpcPlacements() {
+ const { data: meta, error: metaError } = useAsync(() => api.adminNpcs.read(), [])
+ const ready = useMemo(() => ((meta && meta.servers) || []).filter((s) => s.ready), [meta])
+ const [serverId, setServerId] = useState('')
+
+ useEffect(() => {
+ if (!serverId && ready.length) setServerId(ready[0].id)
+ }, [ready, serverId])
+
+ if (metaError) return
+ if (!meta) return
+
+ const server = ready.find((s) => s.id === serverId)
+ const profiles = (meta.profiles || []).filter((p) => !p.replaced && (p.allServers || p.servers.includes(serverId)))
+
+ return (
+
+
+ Where RunicNPC’s NPCs stand, and come back after they die. These live on the server: /rnpc place in
+ game changes the same list. Click the map to place NPCs there; the server puts them on the ground and names the
+ placement. A roof or the top of a building is placed in game. Profiles are on the{' '}
+ NPC profiles page.
+
+
+ {ready.length === 0 ? (
+
+ No server can take placements from the site yet: each needs RunicNPC with API 3.{' '}
+ {(meta.servers || []).map((s) => `${s.name || s.id}: ${s.absence}`).join(' · ')}
+
+ ) : (
+ <>
+
+ {server && }
+ >
+ )}
+
+ )
+}
diff --git a/client/src/routes/admin/NpcProfiles.jsx b/client/src/routes/admin/NpcProfiles.jsx
new file mode 100644
index 0000000..9e755b7
--- /dev/null
+++ b/client/src/routes/admin/NpcProfiles.jsx
@@ -0,0 +1,385 @@
+// ── Admin · Rust · NPC profiles (docs/runicnpc/PLAN.md stage 4) ──────────
+//
+// Where RunicNPC's profiles are authored (D216: no in-game editor). A profile is
+// for one server, several, or every server, like a zone preset, and the site
+// pushes each server its set, which RunicNPC then holds as managed (D221).
+//
+// The first push to a server adopts that server's own profiles as profiles for
+// it alone (D244); one whose name a site profile already had there is kept
+// aside as "replaced" (D251), listed below with a Restore. Each profile says how
+// its kills are counted on the leaderboard and in titles (D247).
+
+import { useState } from 'react'
+import { Link } from 'react-router-dom'
+
+import { ErrorState, Loading, useAsync } from '../../core.js'
+import api from '../../api.js'
+
+const inputStyle = {
+ background: 'var(--panel-flat, transparent)',
+ color: 'var(--text)',
+ border: '1px solid var(--line)',
+ borderRadius: 6,
+ padding: '6px 8px',
+ font: 'inherit',
+}
+
+const SCOPE_WORDS = {
+ server: 'Kills of this name on the server a leaderboard is for (the default)',
+ name: 'Kills of this name on every server',
+ profile: 'Kills of this profile only, on whichever servers it is pushed to',
+}
+
+const list = (text) => String(text || '').split(/[\n,]+/).map((s) => s.trim()).filter(Boolean)
+const str = (v) => (v === undefined || v === null ? '' : String(v))
+
+function formFrom(p, defaults) {
+ const b = { ...defaults, ...(p ? p.body : {}) }
+ return {
+ id: p ? p.id : null,
+ name: p ? p.name : '',
+ allServers: p ? p.allServers : false,
+ servers: p ? [...p.servers] : [],
+ killsScope: p ? p.killsScope : 'server',
+ names: (b.names || []).join('\n'),
+ kits: (b.kits || []).join(', '),
+ prefab: b.prefab,
+ role: b.role,
+ moveMode: (b.movement && b.movement.mode) || 'wander',
+ moveRadius: str(b.movement && b.movement.radius),
+ health: str(b.health),
+ damageDealt: str(b.damageDealt),
+ head: str(b.damageTaken && b.damageTaken.head),
+ body: str(b.damageTaken && b.damageTaken.body),
+ legs: str(b.damageTaken && b.damageTaken.legs),
+ aimCone: str(b.aimCone),
+ sense: str(b.ranges && b.ranges.sense),
+ loseTarget: str(b.ranges && b.ranges.loseTarget),
+ chase: str(b.ranges && b.ranges.chase),
+ attack: str(b.ranges && b.ranges.attack),
+ visionCone: str(b.visionCone),
+ sleepDistance: str(b.sleepDistance),
+ thresholds: (b.healthThresholds || []).map((t) => Math.round(t * 100)).join(', '),
+ }
+}
+
+/** What the page sends: numbers as typed (the server checks them, as RunicNPC would). */
+function bodyFrom(f) {
+ return {
+ name: f.name.trim(),
+ allServers: f.allServers,
+ servers: f.allServers ? [] : f.servers,
+ killsScope: f.killsScope,
+ body: {
+ names: list(f.names),
+ kits: list(f.kits),
+ prefab: f.prefab,
+ role: f.role,
+ movement: { mode: f.moveMode.trim(), radius: f.moveRadius },
+ health: f.health,
+ damageDealt: f.damageDealt,
+ damageTaken: { head: f.head, body: f.body, legs: f.legs },
+ aimCone: f.aimCone,
+ ranges: { sense: f.sense, loseTarget: f.loseTarget, chase: f.chase, attack: f.attack },
+ visionCone: f.visionCone,
+ sleepDistance: f.sleepDistance,
+ healthThresholds: list(f.thresholds).map((t) => Number(t) / 100),
+ },
+ }
+}
+
+/** One line on a server's RunicNPC and the last push to it. */
+function serverLine(s) {
+ if (!s.ready) return s.absence || 'no RunicNPC'
+ const parts = [`RunicNPC ${s.runicNpc.version || ''} · API ${s.runicNpc.api}`.replace(' ', ' ')]
+ const sync = s.sync
+ if (!sync || !sync.adoptedAt) parts.push('not pushed yet: its own profiles are adopted first')
+ else if (sync.state === 'ok') parts.push(`pushed ${new Date(sync.syncedAt).toLocaleString()}`)
+ else if (sync.state === 'failed') parts.push(`the last push failed: ${sync.error || 'no reason given'}`)
+ return parts.join(' · ')
+}
+
+export default function NpcProfiles() {
+ const [reloads, setReloads] = useState(0)
+ const { data, error: loadError } = useAsync(() => api.adminNpcs.read(), [reloads])
+ const [form, setForm] = useState(null)
+ const [busy, setBusy] = useState(false)
+ const [error, setError] = useState('')
+ const [notice, setNotice] = useState('')
+
+ if (loadError) return
+ if (!data) return
+
+ const servers = data.servers || []
+ const nameOf = new Map(servers.map((s) => [s.id, s.name || s.id]))
+ const inUse = (data.profiles || []).filter((p) => !p.replaced)
+ const replaced = (data.profiles || []).filter((p) => p.replaced)
+
+ const act = async (fn, done) => {
+ setBusy(true)
+ setError('')
+ setNotice('')
+ try {
+ const out = await fn()
+ if (done) setNotice(done(out))
+ setForm(null)
+ setReloads((n) => n + 1)
+ } catch (err) {
+ setError(err.message || 'That did not work.')
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ const save = (e) => {
+ e.preventDefault()
+ act(() => (form.id ? api.adminNpcs.update(form.id, bodyFrom(form)) : api.adminNpcs.create(bodyFrom(form))), () => 'Saved. It reaches each server on the next push, within half a minute.')
+ }
+
+ const push = (s) =>
+ act(
+ () => api.adminNpcs.push(s.id),
+ (r) => {
+ if (r.outcome === 'pushed') {
+ const refused = Object.entries(r.refused || {})
+ const adopted = (r.adopted || []).map((a) => `${a.name} (${a.outcome})`)
+ return `${nameOf.get(s.id)}: pushed ${r.profiles.length} profile(s)${adopted.length ? `; adopted ${adopted.join(', ')}` : ''}${refused.length ? `; RunicNPC refused ${refused.map(([n, why]) => `${n}: ${why}`).join('; ')}` : ''}.`
+ }
+ if (r.outcome === 'current') return `${nameOf.get(s.id)} already has the current set.`
+ return `${nameOf.get(s.id)}: ${r.outcome}${r.reason ? `, ${r.reason}` : ''}${r.error ? `, ${r.error}` : ''}.`
+ },
+ )
+
+ return (
+
+
+ RunicNPC’s NPC profiles: what an NPC wears (its Kits kits), how hard it is and how it moves. Each profile is for
+ one server, several, or every server, and each server is pushed its set. The first push to a server adopts the
+ profiles it already had, so nothing on it changes. Where NPCs stand is set on the{' '}
+ placements page, in game with /rnpc place, or by an
+ event’s Place NPCs step.
+
+ A server’s own profiles whose name a site profile already had there when the site first pushed to it. The site’s
+ is in use; these are kept in case you want them back.
+
+ {replaced.map((p) => (
+
+ {p.name}
+ from {nameOf.get(p.adoptedFrom) || p.adoptedFrom}
+
+
+
+ ))}
+
+ )}
+
+ {form && (
+ setForm(null)}
+ onDelete={form.id ? () => act(() => api.adminNpcs.remove(form.id), () => `${form.name} deleted. Its placements wait until a profile of that name returns.`) : null}
+ />
+ )}
+
+ )
+}
+
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() {
What you can do in game
{data && }
+
+
NPCs you have killed
+ {data && links.length > 0 ? :
Link a Steam account to see them.
}
)
}
diff --git a/server/router/player/rust.controller.js b/server/router/player/rust.controller.js
index 43dd2a2..64b13b0 100644
--- a/server/router/player/rust.controller.js
+++ b/server/router/player/rust.controller.js
@@ -47,7 +47,8 @@ async function ownNpcKills(req, res) {
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)
}
- res.json({ kills: rows.map((r) => ({ ...r, label: labels.get(`${r.serverId} ${r.profile}`) || r.profile })) })
+ 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' })
diff --git a/server/test/npcs.test.js b/server/test/npcs.test.js
new file mode 100644
index 0000000..706c3ec
--- /dev/null
+++ b/server/test/npcs.test.js
@@ -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)
+})