From 11619a4dc63675da03e6d99ba34e5ac2719a73ab Mon Sep 17 00:00:00 2001 From: wtclaude Date: Thu, 1 Oct 2026 07:58:13 -0500 Subject: [PATCH] feat(rust): NPC sides, the faction table, an event's escort, ally and tether (runicnpc stage 5) RunicNPC stage 5 on the site (docs runicnpc/PLAN.md, D253-D272): - profiles gain the guard role, faction, relations (its own exceptions), alertRadius, turrets, hurtByPlayers, hurtsPlayers and kitUse, checked in RunicNPC's order and words, and defaulting to "players only" (D255); - the faction table: one site-wide table, one row per pair and both ways (D254, D268), stored in rust_npc_factions, edited on the NPC profiles page (PUT /admin/rust/npcs/factions), pushed to every server with its profiles and hashed with them, and a standalone server's own pairs adopted at its first push with the site's winning (D244, D251); - the Place NPCs step takes escort (a Steam id or a {placeholder}), an ally (a clan from the new rust.options.clans source, or the team of a player) and tether (the zone this event made), for a RunicNPC profile only (D269, D270, D272); - a placement may be held inside a zone (tether, D272); - the bridge's RunicNPC API floor is 4. Tests: 488 server, 66 client. routes.manifest.json regenerated against the pinned core (one route added). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- client/src/api.js | 1 + client/src/routes/admin/NpcPlacements.jsx | 10 +- client/src/routes/admin/NpcProfiles.jsx | 164 ++++++- routes.manifest.json | 5 + server/db/purge.sql | 1 + server/db/schema.sql | 13 +- server/eventWorld.js | 102 +++++ server/model/clans/clans.db.js | 2 +- server/model/npcs/npcProfile.js | 105 ++++- server/model/npcs/npcs.db.js | 26 ++ server/model/npcs/npcs.model.js | 71 ++- server/npcSync.js | 19 +- server/router/admin/npcs.controller.js | 12 + server/router/admin/npcs.router.js | 17 +- server/sidecarClient.js | 3 +- server/swagger/doc.js | 28 +- server/test/npcs.test.js | 131 +++++- swagger-fragment.json | 503 +++++++++++++++++++++- 18 files changed, 1171 insertions(+), 42 deletions(-) diff --git a/client/src/api.js b/client/src/api.js index 2c51a89..1a26de7 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -242,6 +242,7 @@ export const adminZones = { const npcServer = (serverId) => `/admin/rust/npcs/servers/${encodeURIComponent(serverId)}` export const adminNpcs = { read: () => req('/admin/rust/npcs'), + setFactions: (factions) => req('/admin/rust/npcs/factions', { method: 'PUT', body: { factions } }), 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' }), diff --git a/client/src/routes/admin/NpcPlacements.jsx b/client/src/routes/admin/NpcPlacements.jsx index 84d197f..65a174b 100644 --- a/client/src/routes/admin/NpcPlacements.jsx +++ b/client/src/routes/admin/NpcPlacements.jsx @@ -26,7 +26,7 @@ const inputStyle = { font: 'inherit', } -const blank = (profile = '') => ({ profile, count: '1', respawn: '300', respawnMode: 'each', move: '', radius: '' }) +const blank = (profile = '') => ({ profile, count: '1', respawn: '300', respawnMode: 'each', move: '', radius: '', tether: '' }) function formOf(p) { const v = p.placement || {} @@ -37,6 +37,7 @@ function formOf(p) { respawnMode: v.respawnMode || 'each', move: (v.movement && v.movement.mode) || '', radius: v.movement && v.movement.radius !== undefined ? String(v.movement.radius) : '', + tether: v.tether || '', } } @@ -47,6 +48,8 @@ function bodyOf(f) { respawn: Number(f.respawn), respawnMode: f.respawnMode, ...(f.move ? { movement: { mode: f.move, radius: f.radius === '' ? undefined : Number(f.radius) } } : { movement: null }), + // Stage 5 (D272): a ZoneManager zone its NPCs never leave; empty for none. + tether: f.tether.trim() || null, } } @@ -188,6 +191,7 @@ function ServerPlacements({ server, profiles }) { {p.id} {v.count} × {v.profile} · {p.alive} alive · respawn {v.respawn} s, {v.respawnMode} · {move} + {v.tether ? ` · inside zone ${v.tether}` : ''} {v.position ? ` · ${Math.round(v.position.x)}, ${Math.round(v.position.z)}` : ''} @@ -282,6 +286,10 @@ function PlacementFields({ form, setForm, profiles, routes }) { )} + )} diff --git a/client/src/routes/admin/NpcProfiles.jsx b/client/src/routes/admin/NpcProfiles.jsx index 9e755b7..5f225b5 100644 --- a/client/src/routes/admin/NpcProfiles.jsx +++ b/client/src/routes/admin/NpcProfiles.jsx @@ -8,6 +8,10 @@ // 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). +// +// Stage 5: a profile's side (its faction, its own exceptions, its alert radius), +// turrets, the two PVE checkboxes and the kit's extras; and the site's one +// faction table, pushed to every server with the profiles (D254, D268). import { useState } from 'react' import { Link } from 'react-router-dom' @@ -30,6 +34,34 @@ const SCOPE_WORDS = { profile: 'Kills of this profile only, on whichever servers it is pushed to', } +const TURRET_WORDS = { + default: 'As Rust’s scientists: player turrets shoot it, Outpost and Bandit Camp sentries do not', + ignore: 'No turret shoots it', + always: 'Every turret shoots it, Outpost’s and Bandit Camp’s sentries too', +} + +const KIT_WORDS = { + heal: 'Heals with syringes, medkits or bandages in a lull', + grenades: 'Throws grenades at 5 to 20 m', + melee: 'Switches to a melee weapon up close', + rockets: 'Fires rockets at 15 to 80 m', + flamethrower: 'Switches to a flamethrower within 7 m', +} + +/** A profile's own exceptions as lines of `faction: relation`, and back. */ +const relationsText = (r) => Object.entries(r || {}).map(([k, v]) => `${k}: ${v}`).join('\n') +function relationsFrom(text) { + const out = {} + for (const line of String(text || '').split('\n')) { + const at = line.lastIndexOf(':') + if (at <= 0) continue + const key = line.slice(0, at).trim() + const value = line.slice(at + 1).trim() + if (key && value) out[key] = value + } + return out +} + const list = (text) => String(text || '').split(/[\n,]+/).map((s) => s.trim()).filter(Boolean) const str = (v) => (v === undefined || v === null ? '' : String(v)) @@ -60,6 +92,13 @@ function formFrom(p, defaults) { visionCone: str(b.visionCone), sleepDistance: str(b.sleepDistance), thresholds: (b.healthThresholds || []).map((t) => Math.round(t * 100)).join(', '), + faction: str(b.faction), + relations: relationsText(b.relations), + alertRadius: str(b.alertRadius), + turrets: b.turrets || 'default', + hurtByPlayers: b.hurtByPlayers !== false, + hurtsPlayers: b.hurtsPlayers !== false, + kitUse: { ...(defaults.kitUse || {}), ...(b.kitUse || {}) }, } } @@ -84,6 +123,13 @@ function bodyFrom(f) { visionCone: f.visionCone, sleepDistance: f.sleepDistance, healthThresholds: list(f.thresholds).map((t) => Number(t) / 100), + faction: f.faction.trim() || null, + relations: relationsFrom(f.relations), + alertRadius: f.alertRadius, + turrets: f.turrets, + hurtByPlayers: f.hurtByPlayers, + hurtsPlayers: f.hurtsPlayers, + kitUse: f.kitUse, }, } } @@ -220,6 +266,8 @@ export default function NpcProfiles() { ))} + + {replaced.length > 0 && (

Replaced

@@ -322,20 +370,25 @@ function ProfileForm({ form, setForm, data, busy, error, onSave, onCancel, onDel - {form.role === 'roamer' && ( + {form.role !== 'sentry' && (
Moving
- - set('moveMode')(e.target.value)} list="rnpc-modes" style={{ ...inputStyle, maxWidth: 220 }} /> - - - + {form.role === 'roamer' && ( + <> + + set('moveMode')(e.target.value)} list="rnpc-modes" style={{ ...inputStyle, maxWidth: 220 }} /> + + + + + )}
@@ -365,6 +418,54 @@ function ProfileForm({ form, setForm, data, busy, error, onSave, onCancel, onDel +
+ Sides +

+ With none of this set, it fights players only. Fighting Rust’s scientists, animals or other profiles is opted + into here or in the faction table, and costs about what fighting players does. It never walks into a monument + to look for scientists: it fights only what comes within its chase range of its spot. +

+
+ + set('faction')(e.target.value)} list="rnpc-factions" maxLength={40} style={{ ...inputStyle, maxWidth: 220 }} /> + {(data.factionNames || []).map((n) => + + + + +
+ +