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:
2026-10-01 07:58:13 -05:00
parent b83fa8174c
commit 11619a4dc6
18 changed files with 1171 additions and 42 deletions

View File

@@ -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