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

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