Files
Module-Rust/server/model/npcs/npcProfile.js
wtclaude 11619a4dc6 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
2026-10-01 07:58:13 -05:00

278 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── A RunicNPC profile, checked here as RunicNPC checks it (D238) ───────────
//
// The site authors profiles and pushes them to each server's RunicNPC, which
// checks them again and refuses what it cannot use (docs/runicnpc/API.md). The
// site checks first so an admin reads the reason on the form, not in a push
// report minutes later. The rules below are RunicNPC's `ValidateProfile`, in
// the same order and with the same sentences, less the one only the server can
// answer: whether it has each kit. That one is checked against the kit list
// each server last reported (`npcs.model.js`).
//
// Pure functions: no database, no sidecar.
/** RunicNPC's rule for a profile, placement or route name. */
const NAME_RULE = /^[a-z0-9_-]{1,40}$/
/** A plain scientist prefab, as RunicNPC resolves one (`scientistnpc_*`). */
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', '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']
const NAMES_MAX = 20
const DISPLAY_NAME_MAX = 32
const KITS_MAX = 20
const THRESHOLDS_MAX = 10
/** RunicNPC's defaults (API.md, D238), so a form may leave a value out. */
function defaults() {
return {
names: [],
kits: [],
prefab: 'scientistnpc_roam',
role: 'roamer',
movement: { mode: 'wander', radius: 20 },
health: 150,
damageDealt: 1,
damageTaken: { head: 1, body: 1, legs: 1 },
aimCone: 2,
ranges: { sense: 30, loseTarget: 40, chase: 40, attack: 30 },
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)
return Number.isFinite(n) ? n : NaN
}
/** `wander`, `monument` or `route:<name>` (D233). */
function parseMode(mode) {
const text = String(mode === undefined || mode === null ? '' : mode).trim()
if (text === 'wander' || text === 'monument') return { kind: text }
const m = /^route:([a-z0-9_-]{1,40})$/.exec(text)
return m ? { kind: 'route', route: m[1] } : null
}
/** A movement, checked: `{ ok, value }` or `{ ok: false, error }`. */
function checkMovement(input) {
const m = input || {}
const mode = String(m.mode === undefined || m.mode === null ? '' : m.mode).trim()
const parsed = parseMode(mode)
if (!parsed) return { ok: false, error: `movement.mode: '${mode}' is not wander, monument or route:<name>` }
const radius = m.radius === undefined || m.radius === null || m.radius === '' ? (parsed.kind === 'wander' ? 20 : 0) : num(m.radius)
if (Number.isNaN(radius) || radius < 0) return { ok: false, error: 'movement.radius: a number of metres' }
if (parsed.kind === 'wander' && !(radius > 0)) return { ok: false, error: "movement.radius: a wanderer's radius must be above 0" }
return { ok: true, value: { mode, radius } }
}
/**
* Checks a profile's body as the admin form sends it, merged over RunicNPC's
* defaults. Resolves `{ ok: true, value }` with the body RunicNPC will read, or
* `{ ok: false, error }` with RunicNPC's own sentence for the first problem.
*/
function checkBody(input) {
const b = { ...defaults(), ...(input || {}) }
const names = Array.isArray(b.names) ? b.names.map((n) => String(n === null || n === undefined ? '' : n).trim()) : null
if (!names || names.length === 0 || names.some((n) => !n)) return { ok: false, error: 'names: give at least one, and no blank ones' }
if (names.length > NAMES_MAX) return { ok: false, error: `names: at most ${NAMES_MAX}` }
if (names.some((n) => n.length > DISPLAY_NAME_MAX)) return { ok: false, error: `names: each at most ${DISPLAY_NAME_MAX} characters` }
const kits = Array.isArray(b.kits) ? [...new Set(b.kits.map((k) => String(k === null || k === undefined ? '' : k).trim()).filter(Boolean))] : null
if (!kits || kits.length === 0) return { ok: false, error: 'kits: give at least one; Kits is how an NPC is equipped (D217)' }
if (kits.length > KITS_MAX) return { ok: false, error: `kits: at most ${KITS_MAX}` }
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, sentry or guard` }
const movement = checkMovement(b.movement)
if (!movement.ok) return movement
const health = num(b.health)
if (!(health > 0)) return { ok: false, error: 'health: must be above 0' }
const damageDealt = num(b.damageDealt)
if (Number.isNaN(damageDealt) || damageDealt < 0) return { ok: false, error: 'damageDealt: must not be negative' }
const taken = b.damageTaken || {}
const damageTaken = { head: num(taken.head), body: num(taken.body), legs: num(taken.legs) }
if (Object.values(damageTaken).some((v) => Number.isNaN(v) || v < 0)) return { ok: false, error: 'damageTaken: head, body and legs must not be negative' }
const aimCone = num(b.aimCone)
if (Number.isNaN(aimCone) || aimCone < 0) return { ok: false, error: 'aimCone: must not be negative' }
const r = b.ranges || {}
const ranges = { sense: num(r.sense), loseTarget: num(r.loseTarget), chase: num(r.chase), attack: num(r.attack) }
if (!(ranges.sense > 0) || !(ranges.attack > 0) || !(ranges.loseTarget >= ranges.sense) || !(ranges.chase >= 0)) {
return { ok: false, error: 'ranges: sense and attack above 0, loseTarget at least sense, chase not negative' }
}
const visionCone = num(b.visionCone)
if (!(visionCone >= -1 && visionCone <= 1)) return { ok: false, error: 'visionCone: between -1 and 1' }
const sleepDistance = num(b.sleepDistance)
if (!(sleepDistance >= 0)) return { ok: false, error: 'sleepDistance: 0 (never sleeps) or more' }
const thresholds = Array.isArray(b.healthThresholds) ? b.healthThresholds.map(num) : null
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: {
names,
kits,
prefab,
role: b.role,
movement: movement.value,
health,
damageDealt,
damageTaken,
aimCone,
ranges,
visionCone,
sleepDistance,
healthThresholds: [...new Set(thresholds)].sort((x, y) => y - x),
faction,
relations,
alertRadius,
turrets: b.turrets,
hurtByPlayers: b.hurtByPlayers,
hurtsPlayers: b.hurtsPlayers,
kitUse,
},
}
}
/**
* A profile read from a server's own RunicNPC (D244), made into a body this
* module will save. Whatever RunicNPC holds is kept as it is; only a shape this
* module could not push back is refused, with the reason.
*/
function fromServer(name, body) {
if (!NAME_RULE.test(String(name || ''))) return { ok: false, error: `'${name}' is not a profile name RunicNPC could hold` }
return checkBody(body || {})
}
/** A player-facing label for a profile: its first NPC name, else its own name. */
function labelOf(profile) {
const names = profile && profile.body && Array.isArray(profile.body.names) ? profile.body.names : []
return names[0] || (profile && profile.name) || ''
}
module.exports = {
NAME_RULE,
PREFAB_RULE,
PREFABS,
ROLES,
BUILT_IN_FACTIONS,
RELATIONS,
TURRETS,
KIT_USES,
isFactionKey,
checkFactions,
KILLS_SCOPES,
defaults,
parseMode,
checkMovement,
checkBody,
fromServer,
labelOf,
}