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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -361,6 +361,11 @@
|
||||
"path": "/api/v1/player/rust/link",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/rust/npcs/factions",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/v1/admin/rust/npcs/profiles/:pid",
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
-- registrant owned what.
|
||||
|
||||
-- RunicNPC (runicnpc PLAN.md stage 4). Children before `rust_npc_profiles`.
|
||||
DROP TABLE IF EXISTS rust_npc_factions;
|
||||
DROP TABLE IF EXISTS rust_npc_kills;
|
||||
DROP TABLE IF EXISTS rust_npc_sync;
|
||||
DROP TABLE IF EXISTS rust_npc_profile_servers;
|
||||
|
||||
@@ -1305,7 +1305,7 @@ CREATE TABLE IF NOT EXISTS rust_npc_profile_servers (
|
||||
-- server's own profiles were read and imported (D244); until then nothing is
|
||||
-- pushed there. `pushed` is the name → site profile id map of the last push,
|
||||
-- which is how a kill is credited to "this profile only" (D247). `state` is
|
||||
-- `ok`, `failed`, or `absent` (no RunicNPC, or one older than API 3).
|
||||
-- `ok`, `failed`, or `absent` (no RunicNPC, or one older than API 4).
|
||||
CREATE TABLE IF NOT EXISTS rust_npc_sync (
|
||||
server_id VARCHAR(64) NOT NULL PRIMARY KEY,
|
||||
adopted_at DATETIME NULL,
|
||||
@@ -1338,6 +1338,17 @@ CREATE TABLE IF NOT EXISTS rust_npc_kills (
|
||||
KEY idx_rust_npc_kills_site (site_profile_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- The faction table (runicnpc stage 5, D254, D268): how factions treat each
|
||||
-- other, one row per pair and both ways, stored with `a` before `b`. One table
|
||||
-- for the whole site, pushed to every server with its profiles. `scientists`
|
||||
-- and `animals` are Rust's own two; a pair that is not here is neutral.
|
||||
CREATE TABLE IF NOT EXISTS rust_npc_factions (
|
||||
a VARCHAR(40) NOT NULL,
|
||||
b VARCHAR(40) NOT NULL,
|
||||
relation VARCHAR(8) NOT NULL,
|
||||
PRIMARY KEY (a, b)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- A title rule on a profile's kills names the site profile it ranks (runicnpc
|
||||
-- stage 4, D250). Null for every other stat. No foreign key: a deleted profile
|
||||
-- leaves a rule that ranks nobody, which the form shows and an admin removes.
|
||||
|
||||
@@ -33,6 +33,7 @@ const voice = require('./model/permissions/voice')
|
||||
const npcs = require('./model/npcs/npcs.model')
|
||||
const npcsDb = require('./model/npcs/npcs.db')
|
||||
const npcProfile = require('./model/npcs/npcProfile')
|
||||
const clansDb = require('./model/clans/clans.db')
|
||||
const { serverFor, transportError, pluginError, perServer, bounded } = require('./eventLeases')
|
||||
|
||||
const log = core.logger('world')
|
||||
@@ -116,6 +117,11 @@ const PERMANENT = new Set([
|
||||
// runicnpc stage 4 (D243): a profile the server does not have, or no RunicNPC.
|
||||
'unknown-profile',
|
||||
'runicnpc-missing',
|
||||
// runicnpc stage 5 (D272): no zone of the run holds the point; a RunicNPC
|
||||
// older than the API escort, ally and tether need. An escort who is not on
|
||||
// the server (`escort-offline`) is left to retry: they may join.
|
||||
'no-zone',
|
||||
'runicnpc-old',
|
||||
])
|
||||
|
||||
const BUDGETS = [
|
||||
@@ -498,6 +504,7 @@ function placeVerb({ id, kind, budget, max, label, description, source, example
|
||||
description: `How widely to scatter a group, up to ${MAX_SPREAD} m. Left blank, 10.`,
|
||||
},
|
||||
...LOCATION_PARAMS,
|
||||
...(kind === 'npc' ? NPC_ORDER_PARAMS : []),
|
||||
],
|
||||
|
||||
async perform({ runId, idempotencyKey, params, verify }) {
|
||||
@@ -531,6 +538,13 @@ function placeVerb({ id, kind, budget, max, label, description, source, example
|
||||
if (missing) return { ok: false, retry: false, error: missing }
|
||||
}
|
||||
|
||||
// Stage 5: escort, ally and tether are RunicNPC's, so only a profile takes them.
|
||||
const orders = kind === 'npc' ? npcOrders(params, found.server) : { ok: true, wire: {} }
|
||||
if (!orders.ok) return { ok: false, retry: false, error: orders.error }
|
||||
if (profile === null && Object.keys(orders.wire).length) {
|
||||
return { ok: false, retry: false, error: "escort, ally and tether are for one of the site's NPC profiles (RunicNPC), not Rust's own scientists" }
|
||||
}
|
||||
|
||||
if (verify) return { ok: true }
|
||||
|
||||
return place(
|
||||
@@ -543,6 +557,7 @@ function placeVerb({ id, kind, budget, max, label, description, source, example
|
||||
count,
|
||||
...(spread === undefined ? {} : { spread }),
|
||||
...where.wire,
|
||||
...orders.wire,
|
||||
},
|
||||
profile !== null ? `NPCs of the profile "${profile}"` : known.label.toLowerCase(),
|
||||
)
|
||||
@@ -550,6 +565,76 @@ function placeVerb({ id, kind, budget, max, label, description, source, example
|
||||
}
|
||||
}
|
||||
|
||||
/** A Steam id: seventeen digits, as Rust's are. */
|
||||
const STEAM_ID = /^\d{17}$/
|
||||
|
||||
/**
|
||||
* Stage 5 (D269, D270, D272): what a RunicNPC profile's NPCs are told besides
|
||||
* their profile. Each is optional; the server refuses a step whose order it
|
||||
* cannot honour (an escort who is not on, a clan it does not have, no zone of
|
||||
* the run around the point), so nothing half-placed is left behind.
|
||||
*/
|
||||
const NPC_ORDER_PARAMS = [
|
||||
{
|
||||
name: 'escort',
|
||||
type: 'string',
|
||||
required: false,
|
||||
example: '76561198000000001',
|
||||
description:
|
||||
"A player's Steam id, or a {placeholder} from the run's start params: the NPCs keep close to that player and fight whoever attacks them, and walk back to their spot if the player dies or leaves (D269). The player must be on the server. RunicNPC profiles only.",
|
||||
},
|
||||
{
|
||||
name: 'allyClan',
|
||||
type: 'string',
|
||||
required: false,
|
||||
source: 'rust.options.clans',
|
||||
example: 'main/1234567',
|
||||
description:
|
||||
"A clan on the same server: the NPCs never target its members and defend them and what they own (D257). RunicNPC profiles only; give this or allyTeamOf, not both.",
|
||||
},
|
||||
{
|
||||
name: 'allyTeamOf',
|
||||
type: 'string',
|
||||
required: false,
|
||||
example: '76561198000000001',
|
||||
description:
|
||||
"A player's Steam id, or a {placeholder}: the NPCs are allied to that player and their team (D257, D270). RunicNPC profiles only.",
|
||||
},
|
||||
{
|
||||
name: 'tether',
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
example: true,
|
||||
description:
|
||||
'Keep the NPCs inside the zone this event made around the point (D272): a Make a zone step must come first. RunicNPC profiles only.',
|
||||
},
|
||||
]
|
||||
|
||||
/** The orders, checked, as the bridge reads them: `{ ok, wire }` or `{ ok: false, error }`. */
|
||||
function npcOrders(params, server) {
|
||||
const wire = {}
|
||||
const escort = String(params.escort === undefined || params.escort === null ? '' : params.escort).trim()
|
||||
if (escort) {
|
||||
if (!STEAM_ID.test(escort)) return { ok: false, error: `an escort is a player's Steam id, and "${escort}" is not one` }
|
||||
wire.escort = escort
|
||||
}
|
||||
const clan = String(params.allyClan === undefined || params.allyClan === null ? '' : params.allyClan).trim()
|
||||
const teamOf = String(params.allyTeamOf === undefined || params.allyTeamOf === null ? '' : params.allyTeamOf).trim()
|
||||
if (clan && teamOf) return { ok: false, error: 'an ally is a clan or a player and their team, not both' }
|
||||
if (clan) {
|
||||
const m = /^([^/]+)\/(-?\d{1,20})$/.exec(clan)
|
||||
if (!m) return { ok: false, error: `"${clan}" is not a clan from the list` }
|
||||
if (m[1] !== server.id) return { ok: false, error: `that clan is on ${m[1]}, and these NPCs are placed on ${server.id}` }
|
||||
wire.ally = { kind: 'clan', id: m[2] }
|
||||
}
|
||||
if (teamOf) {
|
||||
if (!STEAM_ID.test(teamOf)) return { ok: false, error: `an ally's team is named by a player's Steam id, and "${teamOf}" is not one` }
|
||||
wire.ally = { kind: 'player', id: teamOf }
|
||||
}
|
||||
if (params.tether === true || params.tether === 'true') wire.tether = true
|
||||
return { ok: true, wire }
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a profile cannot be placed on this server, from what the site knows, or
|
||||
* null (D243). A server without RunicNPC offers only Rust's own until stage 9;
|
||||
@@ -778,6 +863,23 @@ const OPTION_SOURCES = [
|
||||
return profiles.length ? [...profiles, ...own.map((row) => ({ ...row, group: "Rust's own" }))] : own
|
||||
},
|
||||
})),
|
||||
{
|
||||
// Stage 5 (D270): an ally for a Place NPCs step. From the site's own clan
|
||||
// mirror, so it answers with every server off. A row's value carries its
|
||||
// server, because a clan id means something only on its own server.
|
||||
id: 'rust.options.clans',
|
||||
label: 'Clans',
|
||||
description: "Each server's clans, as the site last read them.",
|
||||
searchable: true,
|
||||
async resolve({ q } = {}) {
|
||||
const term = String(q || '').trim().toLowerCase()
|
||||
const rows = (await clansDb.listActiveClans())
|
||||
.filter((c) => c.clanId !== null && c.clanId !== undefined)
|
||||
.map((c) => ({ value: `${c.serverId}/${c.clanId}`, label: `${c.name} (${c.memberCount})`, group: c.serverName || c.serverId }))
|
||||
.filter((r) => !term || r.label.toLowerCase().includes(term))
|
||||
return bounded(rows, 'rust.options.clans')
|
||||
},
|
||||
},
|
||||
{
|
||||
// D210: the admins' own presets. A row's VALUE is the options line itself,
|
||||
// so picking one writes that line into the step, and the step keeps its own
|
||||
|
||||
@@ -182,7 +182,7 @@ async function findByGameId(serverId, clanId) {
|
||||
/** Every clan still on a board, for core's `getTeams`. */
|
||||
async function listActiveClans() {
|
||||
return core.query(
|
||||
`SELECT c.external_id AS externalId, c.server_id AS serverId, s.name AS serverName,
|
||||
`SELECT c.external_id AS externalId, c.server_id AS serverId, s.name AS serverName, c.clan_id AS clanId,
|
||||
c.name, c.color, c.score, c.member_count AS memberCount
|
||||
FROM ${CLANS} c
|
||||
JOIN ${SERVERS} s ON s.id = c.server_id
|
||||
|
||||
@@ -19,7 +19,21 @@ const PREFAB_RULE = /^scientistnpc_[a-z0-9_]{1,40}$/
|
||||
/** The prefabs the form offers; RunicNPC accepts any plain `scientistnpc_*`. */
|
||||
const PREFABS = ['scientistnpc_roam', 'scientistnpc_heavy', 'scientistnpc_patrol', 'scientistnpc_roamtethered', 'scientistnpc_full_any']
|
||||
|
||||
const ROLES = ['roamer', 'sentry']
|
||||
const ROLES = ['roamer', 'sentry', 'guard']
|
||||
|
||||
/** Rust's own two factions (D254). Neither can be a profile's faction. */
|
||||
const BUILT_IN_FACTIONS = ['scientists', 'animals']
|
||||
|
||||
const RELATIONS = ['hostile', 'neutral', 'allied']
|
||||
|
||||
/** D259, D264, D267. */
|
||||
const TURRETS = ['default', 'ignore', 'always']
|
||||
|
||||
/** D260: the kit's extras a profile may opt into, each used up (D265). */
|
||||
const KIT_USES = ['heal', 'grenades', 'melee', 'rockets', 'flamethrower']
|
||||
|
||||
const RELATIONS_MAX = 40
|
||||
const FACTION_PAIRS_MAX = 400
|
||||
|
||||
/** D247: how a profile's kills are counted. `server` is the default. */
|
||||
const KILLS_SCOPES = ['server', 'name', 'profile']
|
||||
@@ -45,9 +59,60 @@ function defaults() {
|
||||
visionCone: -0.8,
|
||||
sleepDistance: 160,
|
||||
healthThresholds: [],
|
||||
// Stage 5 (D254–D261, D271). None of them makes a profile fight anything but players (D255).
|
||||
faction: null,
|
||||
relations: {},
|
||||
alertRadius: 40,
|
||||
turrets: 'default',
|
||||
hurtByPlayers: true,
|
||||
hurtsPlayers: true,
|
||||
kitUse: { heal: false, grenades: false, melee: false, rockets: false, flamethrower: false },
|
||||
}
|
||||
}
|
||||
|
||||
/** A faction name, one of Rust's two, or (where a profile names it) `profile:<name>`. */
|
||||
function isFactionKey(key, profileAllowed) {
|
||||
const k = String(key === undefined || key === null ? '' : key)
|
||||
if (BUILT_IN_FACTIONS.includes(k) || NAME_RULE.test(k)) return true
|
||||
return profileAllowed && k.startsWith('profile:') && NAME_RULE.test(k.slice(8))
|
||||
}
|
||||
|
||||
function pairKey(a, b) {
|
||||
return a < b ? `${a}|${b}` : `${b}|${a}`
|
||||
}
|
||||
|
||||
/**
|
||||
* The faction table (D254), one row per pair, both ways (D268), checked as
|
||||
* RunicNPC's `ValidateFactions` checks it, with its sentences. `{ ok, value }`
|
||||
* with the rows sorted, or `{ ok: false, error }`.
|
||||
*/
|
||||
function checkFactions(input) {
|
||||
if (!Array.isArray(input)) return { ok: false, error: 'factions: a list of { a, b, relation }' }
|
||||
if (input.length > FACTION_PAIRS_MAX) return { ok: false, error: `factions: at most ${FACTION_PAIRS_MAX} pairs` }
|
||||
const seen = new Set()
|
||||
const rows = []
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
const f = input[i] || {}
|
||||
const a = String(f.a === undefined || f.a === null ? '' : f.a).trim()
|
||||
const b = String(f.b === undefined || f.b === null ? '' : f.b).trim()
|
||||
const relation = String(f.relation === undefined || f.relation === null ? 'neutral' : f.relation)
|
||||
if (!isFactionKey(a, false) || !isFactionKey(b, false)) {
|
||||
return { ok: false, error: `factions[${i}]: a and b are faction names (1–40 of a-z, 0-9, _ and -), scientists or animals` }
|
||||
}
|
||||
if (a === b) return { ok: false, error: `factions[${i}]: a faction is always allied to itself` }
|
||||
if (BUILT_IN_FACTIONS.includes(a) && BUILT_IN_FACTIONS.includes(b)) {
|
||||
return { ok: false, error: `factions[${i}]: both are Rust's own, and RunicNPC does not change how Rust's NPCs treat each other` }
|
||||
}
|
||||
if (!RELATIONS.includes(relation)) return { ok: false, error: `factions[${i}]: '${relation}' is not hostile, neutral or allied` }
|
||||
const key = pairKey(a, b)
|
||||
if (seen.has(key)) return { ok: false, error: `factions[${i}]: ${a} and ${b} are given twice (one row is both ways, D268)` }
|
||||
seen.add(key)
|
||||
rows.push(a < b ? { a, b, relation } : { a: b, b: a, relation })
|
||||
}
|
||||
rows.sort((x, y) => x.a.localeCompare(y.a) || x.b.localeCompare(y.b))
|
||||
return { ok: true, value: rows }
|
||||
}
|
||||
|
||||
function num(value) {
|
||||
if (value === null || value === undefined || value === '') return NaN
|
||||
const n = Number(value)
|
||||
@@ -93,7 +158,7 @@ function checkBody(input) {
|
||||
const prefab = String(b.prefab || '').trim()
|
||||
if (!PREFAB_RULE.test(prefab)) return { ok: false, error: `prefab: '${prefab}' is not one of Rust's scientist prefabs (scientistnpc_*)` }
|
||||
|
||||
if (!ROLES.includes(b.role)) return { ok: false, error: `role: '${b.role}' is not roamer or sentry` }
|
||||
if (!ROLES.includes(b.role)) return { ok: false, error: `role: '${b.role}' is not roamer, sentry or guard` }
|
||||
|
||||
const movement = checkMovement(b.movement)
|
||||
if (!movement.ok) return movement
|
||||
@@ -125,6 +190,29 @@ function checkBody(input) {
|
||||
if (!thresholds || thresholds.some((t) => !(t > 0 && t < 1))) return { ok: false, error: 'healthThresholds: fractions between 0 and 1' }
|
||||
if (thresholds.length > THRESHOLDS_MAX) return { ok: false, error: `healthThresholds: at most ${THRESHOLDS_MAX}` }
|
||||
|
||||
// ---- stage 5, in RunicNPC's order and words ----
|
||||
const faction = b.faction === undefined || b.faction === null || String(b.faction).trim() === '' ? null : String(b.faction).trim()
|
||||
if (faction !== null && (!NAME_RULE.test(faction) || BUILT_IN_FACTIONS.includes(faction))) {
|
||||
return { ok: false, error: `faction: '${faction}' must be 1–40 of a-z, 0-9, _ and -, and not scientists or animals (Rust's own)` }
|
||||
}
|
||||
const rel = b.relations === undefined || b.relations === null ? {} : b.relations
|
||||
if (typeof rel !== 'object' || Array.isArray(rel)) return { ok: false, error: 'relations: an object, even an empty one' }
|
||||
const relations = {}
|
||||
for (const [key, value] of Object.entries(rel)) {
|
||||
if (!isFactionKey(key, true)) return { ok: false, error: `relations: '${key}' is not a faction, scientists, animals or profile:<name>` }
|
||||
if (!RELATIONS.includes(value)) return { ok: false, error: `relations.${key}: '${value}' is not hostile, neutral or allied` }
|
||||
relations[key] = value
|
||||
}
|
||||
if (Object.keys(relations).length > RELATIONS_MAX) return { ok: false, error: `relations: at most ${RELATIONS_MAX}` }
|
||||
const alertRadius = num(b.alertRadius)
|
||||
if (!(alertRadius >= 0)) return { ok: false, error: 'alertRadius: 0 (off) or more' }
|
||||
if (!TURRETS.includes(b.turrets)) return { ok: false, error: `turrets: '${b.turrets}' is not default, ignore or always` }
|
||||
if (typeof b.hurtByPlayers !== 'boolean' || typeof b.hurtsPlayers !== 'boolean') return { ok: false, error: 'hurtByPlayers and hurtsPlayers: true or false' }
|
||||
const kit = b.kitUse === undefined || b.kitUse === null ? {} : b.kitUse
|
||||
if (typeof kit !== 'object' || Array.isArray(kit)) return { ok: false, error: 'kitUse: an object, even an empty one' }
|
||||
const kitUse = {}
|
||||
for (const k of KIT_USES) kitUse[k] = kit[k] === true
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
@@ -141,6 +229,13 @@ function checkBody(input) {
|
||||
visionCone,
|
||||
sleepDistance,
|
||||
healthThresholds: [...new Set(thresholds)].sort((x, y) => y - x),
|
||||
faction,
|
||||
relations,
|
||||
alertRadius,
|
||||
turrets: b.turrets,
|
||||
hurtByPlayers: b.hurtByPlayers,
|
||||
hurtsPlayers: b.hurtsPlayers,
|
||||
kitUse,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -166,6 +261,12 @@ module.exports = {
|
||||
PREFAB_RULE,
|
||||
PREFABS,
|
||||
ROLES,
|
||||
BUILT_IN_FACTIONS,
|
||||
RELATIONS,
|
||||
TURRETS,
|
||||
KIT_USES,
|
||||
isFactionKey,
|
||||
checkFactions,
|
||||
KILLS_SCOPES,
|
||||
defaults,
|
||||
parseMode,
|
||||
|
||||
@@ -12,6 +12,7 @@ const PROFILES = 'rust_npc_profiles'
|
||||
const PROFILE_SERVERS = 'rust_npc_profile_servers'
|
||||
const SYNC = 'rust_npc_sync'
|
||||
const KILLS = 'rust_npc_kills'
|
||||
const FACTIONS = 'rust_npc_factions'
|
||||
const SERVERS = 'rust_servers'
|
||||
const STATE = 'rust_server_state'
|
||||
const PLAYERS = 'rust_players'
|
||||
@@ -194,6 +195,29 @@ async function markDirty(serverIds = null) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── The faction table (stage 5, D254) ─────────────────────────────────────
|
||||
|
||||
/** Every pair, `a` before `b`, sorted. */
|
||||
async function listFactions() {
|
||||
return (await core.query(`SELECT a, b, relation FROM ${FACTIONS} ORDER BY a ASC, b ASC`)).map((r) => ({ a: r.a, b: r.b, relation: r.relation }))
|
||||
}
|
||||
|
||||
/** Replaces the whole table, in one transaction; the caller has checked it (`checkFactions`). */
|
||||
async function replaceFactions(rows) {
|
||||
const conn = await core.pool.getConnection()
|
||||
try {
|
||||
await conn.beginTransaction()
|
||||
await conn.query(`DELETE FROM ${FACTIONS}`)
|
||||
for (const r of rows) await conn.query(`INSERT INTO ${FACTIONS} (a, b, relation) VALUES (?, ?, ?)`, [r.a, r.b, r.relation])
|
||||
await conn.commit()
|
||||
} catch (err) {
|
||||
await conn.rollback().catch(() => {})
|
||||
throw err
|
||||
} finally {
|
||||
conn.release()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Kills (D247) ────────────────────────────────────────────────────────────
|
||||
|
||||
async function addKills({ serverId, wipeId, steamId }, profile, siteProfileId, kills) {
|
||||
@@ -300,6 +324,8 @@ module.exports = {
|
||||
markAdopted,
|
||||
putSync,
|
||||
markDirty,
|
||||
listFactions,
|
||||
replaceFactions,
|
||||
addKills,
|
||||
scopeWhere,
|
||||
ranking,
|
||||
|
||||
@@ -18,8 +18,8 @@ const serversDb = require('../servers/servers.db')
|
||||
const db = require('./npcs.db')
|
||||
const shape = require('./npcProfile')
|
||||
|
||||
/** The RunicNPC API the bridge's `npc.*` commands need (D249). */
|
||||
const API_NEEDED = 3
|
||||
/** The RunicNPC API the bridge's `npc.*` commands need: 3 for placements (D249), 4 for the faction table and an event's orders (stage 5). */
|
||||
const API_NEEDED = 4
|
||||
|
||||
class NpcError extends Error {
|
||||
constructor(message, status = 400) {
|
||||
@@ -56,9 +56,11 @@ function npcAbsence(server) {
|
||||
/**
|
||||
* The profiles one server is pushed, as RunicNPC reads them, and the name → site
|
||||
* profile id map a kill is credited through (D247). Sorted, so an unchanged set
|
||||
* hashes the same on every tick.
|
||||
* hashes the same on every tick. The faction table (stage 5, D254) is the
|
||||
* site's one table, sent to every server with its profiles and hashed with them,
|
||||
* so an edit to it is pushed like an edit to a profile.
|
||||
*/
|
||||
function desiredFor(serverId, profiles) {
|
||||
function desiredFor(serverId, profiles, factions = []) {
|
||||
const set = {}
|
||||
const map = {}
|
||||
for (const p of active(profiles).filter((x) => covers(x, serverId)).sort((a, b) => a.name.localeCompare(b.name) || a.id - b.id)) {
|
||||
@@ -66,8 +68,12 @@ function desiredFor(serverId, profiles) {
|
||||
set[p.name] = p.body
|
||||
map[p.name] = p.id
|
||||
}
|
||||
const hash = crypto.createHash('sha256').update(JSON.stringify(Object.keys(set).sort().map((n) => [n, set[n]]))).digest('hex')
|
||||
return { profiles: set, map, hash }
|
||||
const rows = (factions || []).map((f) => ({ a: f.a, b: f.b, relation: f.relation }))
|
||||
const hash = crypto
|
||||
.createHash('sha256')
|
||||
.update(JSON.stringify([Object.keys(set).sort().map((n) => [n, set[n]]), rows]))
|
||||
.digest('hex')
|
||||
return { profiles: set, factions: rows, map, hash }
|
||||
}
|
||||
|
||||
// ── Adoption (D244, D251) ───────────────────────────────────────────────────
|
||||
@@ -101,10 +107,40 @@ async function adopt(serverId, theirs, userId = null) {
|
||||
return done
|
||||
}
|
||||
|
||||
/**
|
||||
* A standalone server's own faction table, adopted with its profiles (D244):
|
||||
* each pair the site's table does not already set is added, and where the site
|
||||
* sets a pair the site wins (D251). Returns the pairs added.
|
||||
*/
|
||||
async function adoptFactions(theirs) {
|
||||
const checked = shape.checkFactions(Array.isArray(theirs) ? theirs : [])
|
||||
if (!checked.ok || checked.value.length === 0) return []
|
||||
const mine = await db.listFactions()
|
||||
const have = new Set(mine.map((f) => `${f.a}|${f.b}`))
|
||||
const added = checked.value.filter((f) => !have.has(`${f.a}|${f.b}`))
|
||||
if (added.length) await db.replaceFactions(shape.checkFactions([...mine, ...added]).value)
|
||||
return added
|
||||
}
|
||||
|
||||
// ── The faction table (stage 5, D254, D268) ─────────────────────────────────
|
||||
|
||||
async function getFactions() {
|
||||
return db.listFactions()
|
||||
}
|
||||
|
||||
/** Replaces the site's faction table, checked as RunicNPC checks it, and pushes it to every server. */
|
||||
async function setFactions(input) {
|
||||
const checked = shape.checkFactions(input)
|
||||
if (!checked.ok) throw new NpcError(checked.error)
|
||||
await db.replaceFactions(checked.value)
|
||||
await db.markDirty(null)
|
||||
return db.listFactions()
|
||||
}
|
||||
|
||||
// ── The admin page ──────────────────────────────────────────────────────────
|
||||
|
||||
async function describe() {
|
||||
const [list, profiles, sync] = await Promise.all([db.listNpcServers(), db.listProfiles(), db.listSync()])
|
||||
const [list, profiles, sync, factions] = await Promise.all([db.listNpcServers(), db.listProfiles(), db.listSync(), db.listFactions()])
|
||||
const syncBy = new Map(sync.map((s) => [s.serverId, s]))
|
||||
return {
|
||||
servers: list.map((s) => {
|
||||
@@ -120,6 +156,13 @@ async function describe() {
|
||||
prefabs: shape.PREFABS,
|
||||
killsScopes: shape.KILLS_SCOPES,
|
||||
defaults: shape.defaults(),
|
||||
// Stage 5: the faction table, the factions the profiles name, and what the form offers.
|
||||
factions,
|
||||
factionNames: [...new Set(profiles.map((p) => p.body && p.body.faction).filter(Boolean))].sort(),
|
||||
builtInFactions: shape.BUILT_IN_FACTIONS,
|
||||
roles: shape.ROLES,
|
||||
turrets: shape.TURRETS,
|
||||
kitUses: shape.KIT_USES,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,9 +348,19 @@ function placementBody(input, { position }) {
|
||||
if (!m.ok) throw new NpcError(m.error)
|
||||
body.movement = m.value
|
||||
}
|
||||
// D272: a ZoneManager zone its NPCs never leave. The server checks that the
|
||||
// zone exists and holds the spot, and refuses the placement otherwise.
|
||||
const tether = p.tether === undefined || p.tether === null ? '' : String(p.tether).trim()
|
||||
if (tether) {
|
||||
if (!TETHER_RULE.test(tether)) throw new NpcError('tether: a ZoneManager zone id')
|
||||
body.tether = tether
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
/** RunicNPC's rule for a zone id: one plain word. */
|
||||
const TETHER_RULE = /^[A-Za-z0-9_.:-]{1,64}$/
|
||||
|
||||
function point(raw, { withY }) {
|
||||
const r = raw || {}
|
||||
const x = Number(r.x)
|
||||
@@ -424,6 +477,10 @@ async function playerKills({ serverId, steamId, wipeId = null }) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
adoptFactions,
|
||||
getFactions,
|
||||
setFactions,
|
||||
TETHER_RULE,
|
||||
API_NEEDED,
|
||||
PROFILE_PREFIX,
|
||||
NpcError,
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
//
|
||||
// A tick asks each server whether it needs a push — its first, a change to its
|
||||
// set, a restart, a failure worth retrying, or the quarter-hourly audit — and
|
||||
// pushes only then. A server without RunicNPC (or with one older than API 3) is
|
||||
// pushes only then. A server without RunicNPC (or with one older than API 4) is
|
||||
// recorded `absent` and left alone; its events keep Rust's own scientists
|
||||
// (D243).
|
||||
|
||||
@@ -94,8 +94,8 @@ async function pushOne(server, { npc, sync, force = false }) {
|
||||
return { server: server.id, outcome: 'absent', reason: model.npcAbsence(npc) }
|
||||
}
|
||||
|
||||
const profiles = await db.listProfiles()
|
||||
const first = model.desiredFor(server.id, profiles)
|
||||
const [profiles, factions] = await Promise.all([db.listProfiles(), db.listFactions()])
|
||||
const first = model.desiredFor(server.id, profiles, factions)
|
||||
const reason = reasonToPush({ server: npc, sync, hash: first.hash, force })
|
||||
if (!reason) return { server: server.id, outcome: 'current' }
|
||||
|
||||
@@ -112,12 +112,17 @@ async function pushOne(server, { npc, sync, force = false }) {
|
||||
// A server a site already manages has nothing of its own left to adopt: its
|
||||
// file is some site's last push. Only a standalone server's are its own.
|
||||
adopted = data.managed ? [] : await model.adopt(server.id, data.profiles || {})
|
||||
// Stage 5 (D254): its own faction table too, pair by pair, the site's winning.
|
||||
const adoptedFactions = data.managed ? [] : await model.adoptFactions(data.factions)
|
||||
if (data.managed) await db.markAdopted(server.id)
|
||||
if (adopted.length) log.info('adopted a server\'s own NPC profiles', { server: server.id, adopted })
|
||||
if (adoptedFactions.length) log.info('adopted a server\'s own faction pairs', { server: server.id, pairs: adoptedFactions.length })
|
||||
if (adoptedFactions.length) adopted.push(...adoptedFactions.map((f) => ({ name: `${f.a}↔${f.b}`, outcome: 'faction' })))
|
||||
}
|
||||
|
||||
const desired = adopted && adopted.length ? model.desiredFor(server.id, await db.listProfiles()) : first
|
||||
const pushed = await sidecar.npcProfilesSet(server, desired.profiles)
|
||||
const desired =
|
||||
adopted && adopted.length ? model.desiredFor(server.id, await db.listProfiles(), await db.listFactions()) : first
|
||||
const pushed = await sidecar.npcProfilesSet(server, desired.profiles, desired.factions)
|
||||
const data = pushed.ok ? pushed.data || {} : null
|
||||
if (!data || data.kind !== 'npc.ok') {
|
||||
const error = data ? data.message || data.reason || `answered ${data.kind}` : `the push did not arrive (${pushed.status})`
|
||||
@@ -126,7 +131,9 @@ async function pushOne(server, { npc, sync, force = false }) {
|
||||
return { server: server.id, outcome: 'failed', error, ...(adopted ? { adopted } : {}) }
|
||||
}
|
||||
|
||||
const refused = data.refused && typeof data.refused === 'object' ? data.refused : {}
|
||||
const refused = data.refused && typeof data.refused === 'object' ? { ...data.refused } : {}
|
||||
// A faction table RunicNPC refused is kept aside there, and the old one kept; it says why.
|
||||
if (data.factionsRefused) refused['(factions)'] = String(data.factionsRefused)
|
||||
await db.putSync(server.id, { state: 'ok', syncedHash: desired.hash, bootId: npc.bootId, pushed: desired.map, refused })
|
||||
log.info('pushed NPC profiles', { server: server.id, reason, profiles: Object.keys(desired.profiles).length, refused: Object.keys(refused).length })
|
||||
return { server: server.id, outcome: 'pushed', reason, profiles: Object.keys(desired.profiles), refused, ...(adopted ? { adopted } : {}) }
|
||||
|
||||
@@ -137,8 +137,20 @@ async function respawnPlacement(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Stage 5 (D254, D268): replaces the site's faction table, pushed to every server with the profiles. */
|
||||
async function setFactions(req, res) {
|
||||
try {
|
||||
const factions = await npcs.setFactions((req.body || {}).factions)
|
||||
await core.activity.log({ req, action: 'rust.npcs.factions.set', detail: { pairs: factions.length } })
|
||||
res.json({ factions })
|
||||
} catch (err) {
|
||||
fail(res, err, 'save the faction table')
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
describe,
|
||||
setFactions,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
|
||||
@@ -41,6 +41,7 @@ const placementBody = [
|
||||
body('respawn').optional().isFloat({ min: 1, max: 86400 }).withMessage('respawn is 1 to 86400 seconds'),
|
||||
body('respawnMode').optional().isIn(['each', 'group']).withMessage('respawnMode is each or group'),
|
||||
body('movement').optional({ nullable: true }).isObject().withMessage('movement is { mode, radius }'),
|
||||
body('tether').optional({ nullable: true }).isString().matches(/^[A-Za-z0-9_.:-]{0,64}$/).withMessage('tether is a ZoneManager zone id'),
|
||||
]
|
||||
|
||||
npcsRouter.get(
|
||||
@@ -53,6 +54,20 @@ npcsRouter.get(
|
||||
npcs.describe,
|
||||
)
|
||||
|
||||
npcsRouter.put(
|
||||
'/factions',
|
||||
// #swagger.tags = ['Admin · Rust']
|
||||
// #swagger.summary = 'Replace the NPC faction table'
|
||||
// #swagger.description = 'How factions treat each other (runicnpc stage 5, D254): one row per pair, both ways (D268), each `hostile`, `neutral` or `allied`. A pair that is not listed is neutral, and a faction is always allied to itself. `scientists` and `animals` are Rust’s own two; a pair of those two is refused, since RunicNPC does not change how Rust’s NPCs treat each other. Checked as RunicNPC checks it, stored as one table for the whole site, and pushed to every server with its profiles on the loop’s next tick. A profile with no faction settings still fights players only (D255).'
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["factions"], properties: { factions: { type: "array", items: { $ref: "#/components/schemas/RustNpcFactionPair" } } } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Saved', content: { "application/json": { schema: { type: "object", properties: { factions: { type: "array", items: { $ref: "#/components/schemas/RustNpcFactionPair" } } } } } } } */
|
||||
/* #swagger.responses[400] = { description: 'A pair RunicNPC would refuse, in its own words' } */
|
||||
requireRole('admin'),
|
||||
body('factions').isArray({ max: 400 }).withMessage('factions is a list of { a, b, relation }, at most 400'),
|
||||
validate,
|
||||
npcs.setFactions,
|
||||
)
|
||||
|
||||
npcsRouter.post(
|
||||
'/profiles',
|
||||
// #swagger.tags = ['Admin · Rust']
|
||||
@@ -121,7 +136,7 @@ npcsRouter.post(
|
||||
'/servers/:id/push',
|
||||
// #swagger.tags = ['Admin · Rust']
|
||||
// #swagger.summary = 'Push the NPC profiles to one server now'
|
||||
// #swagger.description = 'Rather than on the loop’s next tick. A server the site has never pushed to has its own profiles adopted first (D244). The answer says what happened: `pushed` (with the profiles and any RunicNPC refused), `absent` (no RunicNPC, or one older than API 3, and why) or `failed` (with the reason).'
|
||||
// #swagger.description = 'Rather than on the loop’s next tick. A server the site has never pushed to has its own profiles adopted first (D244). The answer says what happened: `pushed` (with the profiles and any RunicNPC refused), `absent` (no RunicNPC, or one older than API 4, and why) or `failed` (with the reason).'
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s id', schema: { type: 'string' } }
|
||||
/* #swagger.responses[200] = { description: 'What the push did' } */
|
||||
/* #swagger.responses[404] = { description: 'No such server' } */
|
||||
|
||||
@@ -499,7 +499,8 @@ const mapLive = (server) => request(server, '/map/live')
|
||||
* Each refusal is `data.kind` `npc.error` with a `reason` and RunicNPC's own sentence.
|
||||
*/
|
||||
const npcProfiles = (server) => request(server, '/npc/profiles')
|
||||
const npcProfilesSet = (server, profiles) => request(server, '/npc/profiles', { method: 'POST', body: { profiles } })
|
||||
const npcProfilesSet = (server, profiles, factions) =>
|
||||
request(server, '/npc/profiles', { method: 'POST', body: factions ? { profiles, factions } : { profiles } })
|
||||
const npcPlacements = (server) => request(server, '/npc/placements')
|
||||
const npcPlacement = (server, body) => request(server, '/npc/placement', { method: 'POST', body })
|
||||
|
||||
|
||||
@@ -1121,7 +1121,7 @@ module.exports = {
|
||||
names: { type: 'array', items: { type: 'string' }, example: ['Warden', 'Old Warden'] },
|
||||
kits: { type: 'array', items: { type: 'string' }, example: ['warden_rifle'] },
|
||||
prefab: { type: 'string', example: 'scientistnpc_roam' },
|
||||
role: { type: 'string', enum: ['roamer', 'sentry'], example: 'roamer' },
|
||||
role: { type: 'string', enum: ['roamer', 'sentry', 'guard'], example: 'roamer' },
|
||||
movement: { type: 'object', example: { mode: 'wander', radius: 20 } },
|
||||
health: { type: 'number', example: 250 },
|
||||
damageDealt: { type: 'number', example: 1 },
|
||||
@@ -1131,6 +1131,23 @@ module.exports = {
|
||||
visionCone: { type: 'number', example: -0.8 },
|
||||
sleepDistance: { type: 'number', example: 160 },
|
||||
healthThresholds: { type: 'array', items: { type: 'number' }, example: [0.5] },
|
||||
faction: { type: 'string', nullable: true, description: 'Its faction (stage 5, D254); null: its allies are its own profile’s NPCs (D256).', example: 'bandits' },
|
||||
relations: { type: 'object', additionalProperties: { type: 'string', enum: ['hostile', 'neutral', 'allied'] }, description: 'Its own exceptions, which win over the faction table: a faction, scientists, animals or profile:<name>.', example: { scientists: 'hostile' } },
|
||||
alertRadius: { type: 'number', description: 'Metres within which its allies join a fight one of them is in (D256); 0 is off.', example: 40 },
|
||||
turrets: { type: 'string', enum: ['default', 'ignore', 'always'], description: 'default: as Rust’s scientists; ignore: no turret targets it; always: the safe-zone sentries too (D259, D264, D267).', example: 'default' },
|
||||
hurtByPlayers: { type: 'boolean', description: 'Off: unkillable by players, on every server (D271).', example: true },
|
||||
hurtsPlayers: { type: 'boolean', description: 'Off: harmless to players; it still fights NPCs and turrets (D271).', example: true },
|
||||
kitUse: { type: 'object', description: 'The kit’s extras it uses, each used up (D260, D265).', example: { heal: false, grenades: false, melee: false, rockets: false, flamethrower: false } },
|
||||
},
|
||||
},
|
||||
RustNpcFactionPair: {
|
||||
type: 'object',
|
||||
description: 'One row of the faction table, both ways (D268). scientists and animals are Rust’s own; a pair of those two is refused.',
|
||||
required: ['a', 'b', 'relation'],
|
||||
properties: {
|
||||
a: { type: 'string', example: 'bandits' },
|
||||
b: { type: 'string', example: 'guards' },
|
||||
relation: { type: 'string', enum: ['hostile', 'neutral', 'allied'], example: 'hostile' },
|
||||
},
|
||||
},
|
||||
RustNpcProfile: {
|
||||
@@ -1174,7 +1191,7 @@ module.exports = {
|
||||
name: { type: 'string', example: 'Main · Vanilla' },
|
||||
enabled: { type: 'boolean', example: true },
|
||||
online: { type: 'boolean', nullable: true, example: true },
|
||||
runicNpc: { type: 'object', nullable: true, example: { loaded: true, version: '0.2.0', api: 3 } },
|
||||
runicNpc: { type: 'object', nullable: true, example: { loaded: true, version: '0.3.0', api: 4 } },
|
||||
ready: { type: 'boolean', example: true },
|
||||
absence: { type: 'string', nullable: true, example: null },
|
||||
sync: { type: 'object', nullable: true, example: { state: 'ok', adoptedAt: '2026-09-30T10:00:00.000Z', syncedAt: '2026-09-30T10:00:01.000Z', refused: {}, error: null } },
|
||||
@@ -1185,6 +1202,12 @@ module.exports = {
|
||||
prefabs: { type: 'array', items: { type: 'string' }, example: ['scientistnpc_roam'] },
|
||||
killsScopes: { type: 'array', items: { type: 'string' }, example: ['server', 'name', 'profile'] },
|
||||
defaults: { $ref: '#/components/schemas/RustNpcProfileBody' },
|
||||
factions: { type: 'array', items: { $ref: '#/components/schemas/RustNpcFactionPair' } },
|
||||
factionNames: { type: 'array', items: { type: 'string' }, description: 'The factions the profiles name.', example: ['bandits', 'guards'] },
|
||||
builtInFactions: { type: 'array', items: { type: 'string' }, example: ['scientists', 'animals'] },
|
||||
roles: { type: 'array', items: { type: 'string' }, example: ['roamer', 'sentry', 'guard'] },
|
||||
turrets: { type: 'array', items: { type: 'string' }, example: ['default', 'ignore', 'always'] },
|
||||
kitUses: { type: 'array', items: { type: 'string' }, example: ['heal', 'grenades', 'melee', 'rockets', 'flamethrower'] },
|
||||
},
|
||||
},
|
||||
RustNpcPlacements: {
|
||||
@@ -1220,6 +1243,7 @@ module.exports = {
|
||||
respawn: { type: 'number', example: 300 },
|
||||
respawnMode: { type: 'string', enum: ['each', 'group'], example: 'each' },
|
||||
movement: { type: 'object', nullable: true, example: { mode: 'route:gate', radius: 0 } },
|
||||
tether: { type: 'string', nullable: true, description: 'A ZoneManager zone its NPCs never leave (stage 5, D272). The server refuses one it does not have, or that does not hold the spot.', example: 'arena' },
|
||||
},
|
||||
},
|
||||
RustNpcBoardProfiles: {
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
// 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
|
||||
// stage 5: the sides fields, the faction table (pushed, hashed, adopted), an event's orders, a tether
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert')
|
||||
@@ -36,7 +37,7 @@ const npcSync = require('../npcSync')
|
||||
const world = require('../eventWorld')
|
||||
const titles = require('../model/titles/titles')
|
||||
|
||||
const READY = { loaded: true, version: '0.2.0', api: 3 }
|
||||
const READY = { loaded: true, version: '0.3.0', api: 4 }
|
||||
|
||||
function body(extra = {}) {
|
||||
return { ...shape.defaults(), names: ['Warden'], kits: ['warden_rifle'], ...extra }
|
||||
@@ -47,7 +48,7 @@ function npcServer(id, runicNpc = READY, extra = {}) {
|
||||
}
|
||||
|
||||
/** An in-memory stand-in for npcs.db, one test long. */
|
||||
function stubStore(t, { servers: list = [npcServer('main')], profiles = [], sync = [] } = {}) {
|
||||
function stubStore(t, { servers: list = [npcServer('main')], profiles = [], sync = [], factions = [] } = {}) {
|
||||
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 }]))
|
||||
@@ -76,9 +77,12 @@ function stubStore(t, { servers: list = [npcServer('main')], profiles = [], sync
|
||||
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 () => {}
|
||||
const table = factions.map((f) => ({ ...f }))
|
||||
npcsDb.listFactions = async () => table.map((f) => ({ ...f }))
|
||||
npcsDb.replaceFactions = async (rows) => table.splice(0, table.length, ...rows.map((f) => ({ ...f })))
|
||||
npcsDb.addKills = async (at, profile, siteProfileId, n) => kills.push({ ...at, profile, siteProfileId, n })
|
||||
t.after(() => Object.assign(npcsDb, saved))
|
||||
return { store, syncRows, kills }
|
||||
return { store, syncRows, kills, table }
|
||||
}
|
||||
|
||||
/** The sidecar and the server rows, recording what went up. */
|
||||
@@ -96,9 +100,9 @@ function stubWire(t, { kits = ['warden_rifle', 'bandit_smg'], own = { managed: f
|
||||
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.npcProfilesSet = async (srv, profiles, factions) => {
|
||||
calls.push({ server: srv.id, cmd: 'npc.profiles.set', profiles, factions })
|
||||
return { ok: true, data: { kind: 'npc.ok', refused: answers.refused || {}, ...(answers.factionsRefused ? { factionsRefused: answers.factionsRefused } : {}) } }
|
||||
}
|
||||
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) => {
|
||||
@@ -125,7 +129,7 @@ test('a profile is refused for what RunicNPC would refuse, in its own words', ()
|
||||
[{ 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"],
|
||||
[{ role: 'boss' }, "role: 'boss' is not roamer, sentry or guard"],
|
||||
[{ 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'],
|
||||
@@ -209,12 +213,12 @@ test('a server a site already manages is not adopted from', async (t) => {
|
||||
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 })] })
|
||||
test('a server without RunicNPC, or with API < 4, is recorded absent and never pushed', async (t) => {
|
||||
const { syncRows } = stubStore(t, { servers: [npcServer('main', null), npcServer('arena', { loaded: true, version: '0.2.0', api: 3 })] })
|
||||
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.match(out[1].reason, /API 3, and the site needs 4/)
|
||||
assert.strictEqual(syncRows.get('main').state, 'absent')
|
||||
assert.strictEqual(calls.length, 0)
|
||||
})
|
||||
@@ -386,3 +390,110 @@ test('an edit keeps the placement\'s spot unless a whole one is sent', async (t)
|
||||
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)
|
||||
})
|
||||
|
||||
// ── stage 5: sides, the faction table, an event's orders, a placement's tether ──
|
||||
|
||||
test('a profile\'s stage 5 fields default to "players only" and are refused as RunicNPC refuses them', () => {
|
||||
const ok = shape.checkBody(body())
|
||||
assert.strictEqual(ok.ok, true, ok.error)
|
||||
assert.deepStrictEqual(
|
||||
{ faction: ok.value.faction, relations: ok.value.relations, alertRadius: ok.value.alertRadius, turrets: ok.value.turrets, hurtByPlayers: ok.value.hurtByPlayers, hurtsPlayers: ok.value.hurtsPlayers },
|
||||
{ faction: null, relations: {}, alertRadius: 40, turrets: 'default', hurtByPlayers: true, hurtsPlayers: true },
|
||||
)
|
||||
assert.deepStrictEqual(ok.value.kitUse, { heal: false, grenades: false, melee: false, rockets: false, flamethrower: false })
|
||||
|
||||
const kept = shape.checkBody(body({ role: 'guard', faction: 'bandits', relations: { scientists: 'hostile', 'profile:warden': 'allied' }, kitUse: { heal: true, nonsense: true } }))
|
||||
assert.strictEqual(kept.ok, true, kept.error)
|
||||
assert.strictEqual(kept.value.role, 'guard')
|
||||
assert.deepStrictEqual(kept.value.relations, { scientists: 'hostile', 'profile:warden': 'allied' })
|
||||
assert.deepStrictEqual(kept.value.kitUse, { heal: true, grenades: false, melee: false, rockets: false, flamethrower: false })
|
||||
|
||||
const cases = [
|
||||
[{ faction: 'scientists' }, "faction: 'scientists' must be 1–40 of a-z, 0-9, _ and -, and not scientists or animals (Rust's own)"],
|
||||
[{ faction: 'Bad Name' }, "faction: 'Bad Name' must be"],
|
||||
[{ relations: { 'not a key': 'hostile' } }, "relations: 'not a key' is not a faction, scientists, animals or profile:<name>"],
|
||||
[{ relations: { scientists: 'angry' } }, "relations.scientists: 'angry' is not hostile, neutral or allied"],
|
||||
[{ alertRadius: -1 }, 'alertRadius: 0 (off) or more'],
|
||||
[{ turrets: 'sometimes' }, "turrets: 'sometimes' is not default, ignore or always"],
|
||||
[{ hurtByPlayers: 'no' }, 'hurtByPlayers and hurtsPlayers: true or false'],
|
||||
]
|
||||
for (const [extra, words] of cases) {
|
||||
const r = shape.checkBody(body(extra))
|
||||
assert.strictEqual(r.ok, false, JSON.stringify(extra))
|
||||
assert.ok(r.error.startsWith(words), `${r.error} / ${words}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('the faction table is one row per pair, both ways, refused in RunicNPC\'s words (D254, D268)', () => {
|
||||
const ok = shape.checkFactions([{ a: 'red', b: 'blue', relation: 'hostile' }, { a: 'scientists', b: 'red', relation: 'hostile' }])
|
||||
assert.strictEqual(ok.ok, true, ok.error)
|
||||
assert.deepStrictEqual(ok.value, [{ a: 'blue', b: 'red', relation: 'hostile' }, { a: 'red', b: 'scientists', relation: 'hostile' }])
|
||||
assert.match(shape.checkFactions([{ a: 'red', b: 'red', relation: 'hostile' }]).error, /always allied to itself/)
|
||||
assert.match(shape.checkFactions([{ a: 'scientists', b: 'animals', relation: 'hostile' }]).error, /both are Rust's own/)
|
||||
assert.match(shape.checkFactions([{ a: 'red', b: 'blue', relation: 'angry' }]).error, /'angry' is not hostile, neutral or allied/)
|
||||
assert.match(shape.checkFactions([{ a: 'red', b: 'blue', relation: 'hostile' }, { a: 'blue', b: 'red', relation: 'allied' }]).error, /given twice \(one row is both ways, D268\)/)
|
||||
assert.match(shape.checkFactions('red').error, /a list of/)
|
||||
})
|
||||
|
||||
test('the faction table is pushed with the profiles, moves the hash, and a refusal is recorded', async (t) => {
|
||||
const { syncRows } = stubStore(t, { profiles: [{ id: 1, name: 'warden', body: body(), allServers: true }], factions: [{ a: 'blue', b: 'red', relation: 'hostile' }] })
|
||||
const calls = stubWire(t, { answers: { factionsRefused: 'factions[0]: nope' } })
|
||||
const [out] = await npcSync.tick({ force: 'main' })
|
||||
assert.strictEqual(out.outcome, 'pushed')
|
||||
const pushed = calls.find((c) => c.cmd === 'npc.profiles.set')
|
||||
assert.deepStrictEqual(pushed.factions, [{ a: 'blue', b: 'red', relation: 'hostile' }])
|
||||
assert.strictEqual(syncRows.get('main').refused['(factions)'], 'factions[0]: nope')
|
||||
|
||||
const profiles = [{ id: 1, name: 'warden', body: body(), allServers: true, servers: [], replaced: false }]
|
||||
const a = npcs.desiredFor('main', profiles, [])
|
||||
const b = npcs.desiredFor('main', profiles, [{ a: 'blue', b: 'red', relation: 'hostile' }])
|
||||
assert.notStrictEqual(a.hash, b.hash, 'an edit to the table is pushed like an edit to a profile')
|
||||
})
|
||||
|
||||
test('a standalone server\'s own faction pairs are adopted, the site\'s winning (D244, D251)', async (t) => {
|
||||
const { table } = stubStore(t, { factions: [{ a: 'blue', b: 'red', relation: 'neutral' }] })
|
||||
stubWire(t, { own: { managed: false, profiles: {}, factions: [{ a: 'red', b: 'blue', relation: 'hostile' }, { a: 'green', b: 'red', relation: 'allied' }] } })
|
||||
const [out] = await npcSync.tick({ force: 'main' })
|
||||
assert.strictEqual(out.outcome, 'pushed')
|
||||
assert.deepStrictEqual(table, [{ a: 'blue', b: 'red', relation: 'neutral' }, { a: 'green', b: 'red', relation: 'allied' }])
|
||||
assert.deepStrictEqual(out.adopted, [{ name: 'green↔red', outcome: 'faction' }])
|
||||
})
|
||||
|
||||
test('the site\'s faction table is replaced whole, checked, and pushed everywhere', async (t) => {
|
||||
const { table } = stubStore(t)
|
||||
const saved = await npcs.setFactions([{ a: 'red', b: 'blue', relation: 'hostile' }])
|
||||
assert.deepStrictEqual(saved, [{ a: 'blue', b: 'red', relation: 'hostile' }])
|
||||
assert.deepStrictEqual(table, saved)
|
||||
await assert.rejects(npcs.setFactions([{ a: 'red', b: 'red', relation: 'hostile' }]), (err) => err.status === 400 && /allied to itself/.test(err.message))
|
||||
})
|
||||
|
||||
test('the Place NPCs step sends escort, ally and tether for a profile, and refuses them for Rust\'s own', async (t) => {
|
||||
stubStore(t, { 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 base = { prefab: 'profile:warden', count: 2, server: 'main', x: 10, z: 20 }
|
||||
const steam = '76561198000000001'
|
||||
|
||||
const done = await place.perform({ runId: 9, idempotencyKey: 'o1', params: { ...base, escort: steam, allyClan: 'main/1234567', tether: true } })
|
||||
assert.strictEqual(done.ok, true, done.error)
|
||||
const sent = calls.find((c) => c.cmd === 'world.place').body
|
||||
assert.deepStrictEqual({ escort: sent.escort, ally: sent.ally, tether: sent.tether }, { escort: steam, ally: { kind: 'clan', id: '1234567' }, tether: true })
|
||||
|
||||
const team = await place.perform({ runId: 9, idempotencyKey: 'o2', params: { ...base, allyTeamOf: steam }, verify: true })
|
||||
assert.strictEqual(team.ok, true, team.error)
|
||||
const bad = async (params) => (await place.perform({ runId: 9, idempotencyKey: 'ox', params: { ...base, ...params }, verify: true })).error
|
||||
assert.match(await bad({ escort: 'bob' }), /Steam id, and "bob" is not one/)
|
||||
assert.match(await bad({ allyClan: 'main/1', allyTeamOf: steam }), /a clan or a player and their team, not both/)
|
||||
assert.match(await bad({ allyClan: 'arena/1' }), /that clan is on arena/)
|
||||
assert.match(await bad({ prefab: 'npc.scientist', escort: steam }), /for one of the site's NPC profiles/)
|
||||
const plain = await place.perform({ runId: 9, idempotencyKey: 'o3', params: { ...base, tether: false }, verify: true })
|
||||
assert.strictEqual(plain.ok, true, 'an untouched order is no order')
|
||||
})
|
||||
|
||||
test('a placement may be held inside a zone (D272), and a bad zone id is refused here', async (t) => {
|
||||
stubStore(t)
|
||||
const calls = stubWire(t)
|
||||
await npcs.addPlacement('main', { profile: 'warden', position: { x: 1, z: 2 }, tether: 'arena_zone' })
|
||||
assert.strictEqual(calls.find((c) => c.cmd === 'npc.placement').body.placement.tether, 'arena_zone')
|
||||
await assert.rejects(npcs.addPlacement('main', { profile: 'warden', position: { x: 1, z: 2 }, tether: 'not a zone!' }), /tether: a ZoneManager zone id/)
|
||||
})
|
||||
|
||||
@@ -217,6 +217,59 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/rust/npcs/factions": {
|
||||
"put": {
|
||||
"tags": [
|
||||
"Admin · Rust"
|
||||
],
|
||||
"summary": "Replace the NPC faction table",
|
||||
"description": "How factions treat each other (runicnpc stage 5, D254): one row per pair, both ways (D268), each `hostile`, `neutral` or `allied`. A pair that is not listed is neutral, and a faction is always allied to itself. `scientists` and `animals` are Rust’s own two; a pair of those two is refused, since RunicNPC does not change how Rust’s NPCs treat each other. Checked as RunicNPC checks it, stored as one table for the whole site, and pushed to every server with its profiles on the loop’s next tick. A profile with no faction settings still fights players only (D255).",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Saved",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"factions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/RustNpcFactionPair"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "A pair RunicNPC would refuse, in its own words"
|
||||
}
|
||||
},
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"factions"
|
||||
],
|
||||
"properties": {
|
||||
"factions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/RustNpcFactionPair"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/rust/npcs/profiles": {
|
||||
"post": {
|
||||
"tags": [
|
||||
@@ -639,7 +692,7 @@
|
||||
"Admin · Rust"
|
||||
],
|
||||
"summary": "Push the NPC profiles to one server now",
|
||||
"description": "Rather than on the loop’s next tick. A server the site has never pushed to has its own profiles adopted first (D244). The answer says what happened: `pushed` (with the profiles and any RunicNPC refused), `absent` (no RunicNPC, or one older than API 3, and why) or `failed` (with the reason).",
|
||||
"description": "Rather than on the loop’s next tick. A server the site has never pushed to has its own profiles adopted first (D244). The answer says what happened: `pushed` (with the profiles and any RunicNPC refused), `absent` (no RunicNPC, or one older than API 4, and why) or `failed` (with the reason).",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
@@ -10889,7 +10942,8 @@
|
||||
"type": "array",
|
||||
"example": [
|
||||
"roamer",
|
||||
"sentry"
|
||||
"sentry",
|
||||
"guard"
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
@@ -11070,6 +11124,265 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"faction": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"nullable": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "Its faction (stage 5, D254); null: its allies are its own profile’s NPCs (D256)."
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "bandits"
|
||||
}
|
||||
}
|
||||
},
|
||||
"relations": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "object"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"enum": {
|
||||
"type": "array",
|
||||
"example": [
|
||||
"hostile",
|
||||
"neutral",
|
||||
"allied"
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "Its own exceptions, which win over the faction table: a faction, scientists, animals or profile:<name>."
|
||||
},
|
||||
"example": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"scientists": {
|
||||
"type": "string",
|
||||
"example": "hostile"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"alertRadius": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "number"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "Metres within which its allies join a fight one of them is in (D256); 0 is off."
|
||||
},
|
||||
"example": {
|
||||
"type": "number",
|
||||
"example": 40
|
||||
}
|
||||
}
|
||||
},
|
||||
"turrets": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"enum": {
|
||||
"type": "array",
|
||||
"example": [
|
||||
"default",
|
||||
"ignore",
|
||||
"always"
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "default: as Rust’s scientists; ignore: no turret targets it; always: the safe-zone sentries too (D259, D264, D267)."
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "default"
|
||||
}
|
||||
}
|
||||
},
|
||||
"hurtByPlayers": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "boolean"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "Off: unkillable by players, on every server (D271)."
|
||||
},
|
||||
"example": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"hurtsPlayers": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "boolean"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "Off: harmless to players; it still fights NPCs and turrets (D271)."
|
||||
},
|
||||
"example": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"kitUse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "object"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "The kit’s extras it uses, each used up (D260, D265)."
|
||||
},
|
||||
"example": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"heal": {
|
||||
"type": "boolean",
|
||||
"example": false
|
||||
},
|
||||
"grenades": {
|
||||
"type": "boolean",
|
||||
"example": false
|
||||
},
|
||||
"melee": {
|
||||
"type": "boolean",
|
||||
"example": false
|
||||
},
|
||||
"rockets": {
|
||||
"type": "boolean",
|
||||
"example": false
|
||||
},
|
||||
"flamethrower": {
|
||||
"type": "boolean",
|
||||
"example": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"RustNpcFactionPair": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "object"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "One row of the faction table, both ways (D268). scientists and animals are Rust’s own; a pair of those two is refused."
|
||||
},
|
||||
"required": {
|
||||
"type": "array",
|
||||
"example": [
|
||||
"a",
|
||||
"b",
|
||||
"relation"
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"a": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "bandits"
|
||||
}
|
||||
}
|
||||
},
|
||||
"b": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "guards"
|
||||
}
|
||||
}
|
||||
},
|
||||
"relation": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"enum": {
|
||||
"type": "array",
|
||||
"example": [
|
||||
"hostile",
|
||||
"neutral",
|
||||
"allied"
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "hostile"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11458,11 +11771,11 @@
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"example": "0.2.0"
|
||||
"example": "0.3.0"
|
||||
},
|
||||
"api": {
|
||||
"type": "number",
|
||||
"example": 3
|
||||
"example": 4
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11606,6 +11919,167 @@
|
||||
},
|
||||
"defaults": {
|
||||
"$ref": "#/components/schemas/RustNpcProfileBody"
|
||||
},
|
||||
"factions": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "array"
|
||||
},
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/RustNpcFactionPair"
|
||||
}
|
||||
}
|
||||
},
|
||||
"factionNames": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "array"
|
||||
},
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "The factions the profiles name."
|
||||
},
|
||||
"example": {
|
||||
"type": "array",
|
||||
"example": [
|
||||
"bandits",
|
||||
"guards"
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"builtInFactions": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "array"
|
||||
},
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
"type": "array",
|
||||
"example": [
|
||||
"scientists",
|
||||
"animals"
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"roles": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "array"
|
||||
},
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
"type": "array",
|
||||
"example": [
|
||||
"roamer",
|
||||
"sentry",
|
||||
"guard"
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"turrets": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "array"
|
||||
},
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
"type": "array",
|
||||
"example": [
|
||||
"default",
|
||||
"ignore",
|
||||
"always"
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"kitUses": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "array"
|
||||
},
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
"type": "array",
|
||||
"example": [
|
||||
"heal",
|
||||
"grenades",
|
||||
"melee",
|
||||
"rockets",
|
||||
"flamethrower"
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11947,6 +12421,27 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tether": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "string"
|
||||
},
|
||||
"nullable": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "A ZoneManager zone its NPCs never leave (stage 5, D272). The server refuses one it does not have, or that does not hold the spot."
|
||||
},
|
||||
"example": {
|
||||
"type": "string",
|
||||
"example": "arena"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user