feat(rust): NPC sides, the faction table, an event's escort, ally and tether (runicnpc stage 5)
All checks were successful
PR Checks / client-build (pull_request) Successful in 31s
PR Checks / server-tests (pull_request) Successful in 39s
PR Checks / frozen-manifest (pull_request) Successful in 58s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-10-01 07:58:13 -05:00
parent b83fa8174c
commit 11619a4dc6
18 changed files with 1171 additions and 42 deletions

View File

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

View File

@@ -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 }) {
<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.tether ? ` · inside zone ${v.tether}` : ''}
{v.position ? ` · ${Math.round(v.position.x)}, ${Math.round(v.position.z)}` : ''}
</span>
<span style={{ marginLeft: 'auto', display: 'flex', gap: 6 }}>
@@ -282,6 +286,10 @@ function PlacementFields({ form, setForm, profiles, routes }) {
<input value={form.radius} onChange={set('radius')} inputMode="decimal" style={{ ...inputStyle, maxWidth: 80 }} />
</label>
)}
<label style={label} title="A ZoneManager zone on this server its NPCs never leave. The server refuses a zone it does not have, or one that does not hold the spot.">
<span style={{ color: 'var(--head)' }}>Keep inside zone</span>
<input value={form.tether} onChange={set('tether')} placeholder="none" maxLength={64} style={{ ...inputStyle, maxWidth: 160 }} />
</label>
</>
)}
</div>

View File

@@ -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() {
))}
</section>
<FactionTable data={data} busy={busy} act={act} />
{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>
@@ -322,20 +370,25 @@ function ProfileForm({ form, setForm, data, busy, error, onSave, onCancel, onDel
<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>
<option value="guard">Guard: holds its spot, chases within its leash, walks back</option>
</select>
</Field>
</div>
</fieldset>
{form.role === 'roamer' && (
{form.role !== 'sentry' && (
<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>
{form.role === 'roamer' && (
<>
<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>
@@ -365,6 +418,54 @@ function ProfileForm({ form, setForm, data, busy, error, onSave, onCancel, onDel
</Field>
</fieldset>
<fieldset style={fieldset}>
<legend className="sans" style={legend}>Sides</legend>
<p className="sans dim" style={{ fontSize: '0.76rem', margin: 0 }}>
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.
</p>
<div style={row}>
<Field label="Faction" hint="Profiles of one faction are allies. Empty: its allies are its own profile’s NPCs.">
<input value={form.faction} onChange={(e) => set('faction')(e.target.value)} list="rnpc-factions" maxLength={40} style={{ ...inputStyle, maxWidth: 220 }} />
<datalist id="rnpc-factions">{(data.factionNames || []).map((n) => <option key={n} value={n} />)}</datalist>
</Field>
<Field label="Alert radius (m)" hint="Allies this close join a fight one of them is in, seen or not. 0: off.">
<Num value={form.alertRadius} onChange={set('alertRadius')} />
</Field>
</div>
<Field label="Its own exceptions" hint="One per line, winning over the faction table: scientists: hostile, animals: hostile, bandits: allied, profile:warden: hostile.">
<textarea value={form.relations} onChange={(e) => set('relations')(e.target.value)} rows={3} style={{ ...inputStyle, maxWidth: 420 }} />
</Field>
<Field label="Turrets">
<select value={form.turrets} onChange={(e) => set('turrets')(e.target.value)} style={{ ...inputStyle, maxWidth: 560 }}>
{(data.turrets || ['default', 'ignore', 'always']).map((t) => <option key={t} value={t}>{TURRET_WORDS[t] || t}</option>)}
</select>
</Field>
<label className="sans" style={{ display: 'flex', gap: 8, fontSize: '0.84rem' }}>
<input type="checkbox" checked={form.hurtByPlayers} onChange={(e) => set('hurtByPlayers')(e.target.checked)} />
Players can hurt it <span className="dim">(off: it cannot be killed by players, on any server)</span>
</label>
<label className="sans" style={{ display: 'flex', gap: 8, fontSize: '0.84rem' }}>
<input type="checkbox" checked={form.hurtsPlayers} onChange={(e) => set('hurtsPlayers')(e.target.checked)} />
It can hurt players <span className="dim">(off: it still fights NPCs and turrets)</span>
</label>
</fieldset>
<fieldset style={fieldset}>
<legend className="sans" style={legend}>The kit’s extras</legend>
<p className="sans dim" style={{ fontSize: '0.76rem', margin: 0 }}>
The kit’s main weapon is always used, and never runs out. Each extra is used only if ticked, and comes out of
the NPC’s inventory, so its loot is what is left.
</p>
{(data.kitUses || Object.keys(KIT_WORDS)).map((k) => (
<label key={k} className="sans" style={{ display: 'flex', gap: 8, fontSize: '0.84rem' }}>
<input type="checkbox" checked={Boolean(form.kitUse[k])} onChange={(e) => setForm((f) => ({ ...f, kitUse: { ...f.kitUse, [k]: e.target.checked } }))} />
{KIT_WORDS[k] || k}
</label>
))}
</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>)}
@@ -383,3 +484,54 @@ function ProfileForm({ form, setForm, data, busy, error, onSave, onCancel, onDel
</form>
)
}
/**
* The site's faction table (stage 5, D254): one row per pair, both ways (D268).
* A pair not listed is neutral; Rust's scientists and animals are the two
* built-in factions. Pushed to every server with its profiles.
*/
function FactionTable({ data, busy, act }) {
const [rows, setRows] = useState(null)
const shown = rows || (data.factions || []).map((f) => ({ ...f }))
const names = [...new Set([...(data.factionNames || []), ...(data.builtInFactions || ['scientists', 'animals'])])]
const change = (i, key, value) => setRows(shown.map((r, j) => (j === i ? { ...r, [key]: value } : r)))
const save = () =>
act(
() => api.adminNpcs.setFactions(shown.filter((r) => r.a && r.b)),
(out) => {
setRows(null)
return `Faction table saved: ${out.factions.length} pair(s). It reaches each server on the next push.`
},
)
return (
<section className="panel" style={{ padding: '14px 18px', marginBottom: 18 }}>
<h2 className="display" style={{ fontSize: '1rem', margin: '0 0 6px', color: 'var(--head)' }}>Faction table</h2>
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '0 0 8px' }}>
How factions treat each other. One row is both ways: <em>bandits ↔ guards: hostile</em> makes each hunt the other.
A pair that is not here is neutral, and a faction is always allied to itself. <em>scientists</em> and{' '}
<em>animals</em> are Rust’s own; a profile hostile to scientists fights them, and they only take cover.
</p>
<datalist id="rnpc-table-factions">{names.map((n) => <option key={n} value={n} />)}</datalist>
{shown.length === 0 && <p className="sans dim" style={{ fontSize: '0.8rem', margin: '0 0 8px' }}>Empty: every pair is neutral.</p>}
{shown.map((r, i) => (
<div key={i} className="sans" style={{ display: 'flex', gap: 8, alignItems: 'center', padding: '4px 0', fontSize: '0.84rem' }}>
<input value={r.a} onChange={(e) => change(i, 'a', e.target.value)} list="rnpc-table-factions" maxLength={40} style={{ ...inputStyle, maxWidth: 180 }} />
<span className="dim">↔</span>
<input value={r.b} onChange={(e) => change(i, 'b', e.target.value)} list="rnpc-table-factions" maxLength={40} style={{ ...inputStyle, maxWidth: 180 }} />
<select value={r.relation} onChange={(e) => change(i, 'relation', e.target.value)} style={inputStyle}>
<option value="hostile">hostile</option>
<option value="neutral">neutral</option>
<option value="allied">allied</option>
</select>
<button type="button" className="btn" onClick={() => setRows(shown.filter((_, j) => j !== i))} disabled={busy}>Remove</button>
</div>
))}
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<button type="button" className="btn" onClick={() => setRows([...shown, { a: '', b: '', relation: 'hostile' }])} disabled={busy}>Add a pair</button>
<button type="button" className="btn" onClick={save} disabled={busy || rows === null}>Save the table</button>
{rows !== null && <button type="button" className="btn" onClick={() => setRows(null)} disabled={busy}>Discard changes</button>}
</div>
</section>
)
}