feat(rust): RunicNPC profiles, placements, event NPCs and per-profile kills (runicnpc stage 4) #29
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]]
|
||||
|
||||
@@ -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" />
|
||||
|
||||
289
client/src/routes/admin/NpcPlacements.jsx
Normal file
289
client/src/routes/admin/NpcPlacements.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
385
client/src/routes/admin/NpcProfiles.jsx
Normal file
385
client/src/routes/admin/NpcProfiles.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
|
||||
388
server/test/npcs.test.js
Normal file
388
server/test/npcs.test.js
Normal 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)
|
||||
})
|
||||
Reference in New Issue
Block a user