// ── 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:`. */ 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:` (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:` } 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:` } 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, }