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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
@@ -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' }),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user