Files
Module-Rust/server/model/npcs/npcProfile.js
wtclaude 33c5178ef9 feat(rust): loot on the NPC profile form, and the Place NPCs step's "drop loot" switch (runicnpc stage 7)
The profile form's Loot section (D290-D301):

- what the corpse starts with (Rust's scientist loot, the kit, or
  nothing);
- rows that always roll, each with its chance;
- a pool capped at its picks, with a "nothing" weight;
- which crate the table goes into, and a locked crate's hack time;
- how long the corpse stays.

The site checks the block as RunicNPC does, in its words. Whether an
item exists on a server is RunicNPC's to say on the push.

The Place NPCs step's dropLoot switch (D296), on by default and sent
only when off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-06 00:32:24 -05:00

524 lines
26 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']
/** Stage 6 adds passive (D278, D279): it never fights, and answers E. */
const ROLES = ['roamer', 'sentry', 'guard', 'passive']
/** D277: where a boss's lines are said. */
const ANNOUNCES = ['chat', 'popup']
/** D278: what pressing E on a passive NPC does. */
const USE_MODES = ['chat', 'window']
const LINE_MAX = 256
const USE_LINES_MAX = 20
const WINDOW_TITLE_MAX = 80
const WINDOW_TEXT_MAX = 2000
const PHASES_MAX = 10
const ADDS_MAX = 20
/** 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 },
// Stage 6 (D273, D278): neither, until the form turns one on.
boss: null,
use: null,
// Stage 7 (D290): none keeps Rust's scientist loot, as before.
loot: null,
}
}
/** 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`, `route:<name>` (D233), or `stand` for a passive NPC (D288). */
function parseMode(mode) {
const text = String(mode === undefined || mode === null ? '' : mode).trim()
if (text === 'wander' || text === 'monument' || text === 'stand') 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, route:<name> or (passive only) stand` }
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 || {}) }
// D279: a passive NPC cannot be hurt unless its profile says it can, as RunicNPC reads it.
if (b.role === 'passive' && (!input || input.hurtByPlayers === undefined || input.hurtByPlayers === null)) b.hurtByPlayers = false
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, guard or passive` }
const movement = checkMovement(b.movement)
if (!movement.ok) return movement
if (movement.value.mode === 'stand' && b.role !== 'passive') {
return { ok: false, error: "movement.mode: stand is a passive NPC's; a fighting NPC that stands is a sentry" }
}
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
// ---- stage 6 (D273–D279), in RunicNPC's order and words ----
const passive = b.role === 'passive'
const hasBoss = b.boss !== undefined && b.boss !== null
const hasUse = b.use !== undefined && b.use !== null
if (passive && hasBoss) return { ok: false, error: 'boss: a passive NPC never fights, so it cannot be a boss' }
if (passive && !hasUse) return { ok: false, error: 'use: a passive NPC says what pressing E on it does (D278)' }
if (hasUse && !passive) return { ok: false, error: 'use: only a passive NPC answers E (D278)' }
let use = null
if (hasUse) {
const checked = checkUse(b.use)
if (!checked.ok) return checked
use = checked.value
}
let boss = null
if (hasBoss) {
const checked = checkBoss(b.boss)
if (!checked.ok) return checked
boss = checked.value
}
// ---- stage 7 (D290–D301) ----
let loot = null
if (b.loot !== undefined && b.loot !== null) {
const checked = checkLoot(b.loot)
if (!checked.ok) return checked
loot = checked.value
}
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,
boss,
use,
loot,
},
}
}
function text(value) {
return value === undefined || value === null ? '' : String(value)
}
/** D278: `{ mode: chat|window, lines, title, text }`, checked as RunicNPC's `ValidateUse`. */
function checkUse(input) {
if (typeof input !== 'object' || Array.isArray(input)) return { ok: false, error: 'use: an object' }
const mode = input.mode === undefined || input.mode === null || input.mode === '' ? 'chat' : String(input.mode)
if (!USE_MODES.includes(mode)) return { ok: false, error: `use.mode: '${mode}' is not chat or window` }
const lines = Array.isArray(input.lines) ? input.lines.map((l) => text(l).trim()) : []
if (mode === 'chat' && (lines.length === 0 || lines.some((l) => !l))) {
return { ok: false, error: 'use.lines: a chat answer gives at least one line, and no blank ones' }
}
if (lines.length > USE_LINES_MAX) return { ok: false, error: `use.lines: at most ${USE_LINES_MAX}` }
if (lines.some((l) => l.length > LINE_MAX)) return { ok: false, error: `use.lines: each line is at most ${LINE_MAX} characters` }
const title = text(input.title).trim()
const body = text(input.text)
if (mode === 'window' && !title && !body.trim()) return { ok: false, error: 'use: a window has a title or some text' }
if (title.length > WINDOW_TITLE_MAX) return { ok: false, error: `use.title: at most ${WINDOW_TITLE_MAX} characters` }
if (body.length > WINDOW_TEXT_MAX) return { ok: false, error: `use.text: at most ${WINDOW_TEXT_MAX} characters` }
return { ok: true, value: { mode, lines: lines.filter(Boolean), title, text: body } }
}
/** Optional ranges in a phase: absent, or a whole set checked as a profile's. */
function phaseRanges(r) {
if (r === undefined || r === null) return { ok: true, value: null }
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 }
return { ok: true, value: ranges }
}
/** An optional number in a phase: absent (null) or a value. */
function optional(value) {
return value === undefined || value === null || value === '' ? null : num(value)
}
/**
* D273–D277: `{ barDistance, announce, spawnLine, deathLine, phases }`, checked as
* RunicNPC's `ValidateBoss`. Whether a phase's kit exists, and whether its adds'
* profile is a boss, are the model's (they need the servers and the other profiles).
*/
function checkBoss(input) {
if (typeof input !== 'object' || Array.isArray(input)) return { ok: false, error: 'boss: an object' }
const barDistance = input.barDistance === undefined || input.barDistance === null || input.barDistance === '' ? 100 : num(input.barDistance)
if (!(barDistance > 0 && barDistance <= 1000)) return { ok: false, error: 'boss.barDistance: above 0 and at most 1000 metres' }
const announce = input.announce === undefined || input.announce === null || input.announce === '' ? 'chat' : String(input.announce)
if (!ANNOUNCES.includes(announce)) return { ok: false, error: `boss.announce: '${announce}' is not chat or popup` }
const spawnLine = text(input.spawnLine).trim()
const deathLine = text(input.deathLine).trim()
if (spawnLine.length > LINE_MAX || deathLine.length > LINE_MAX) return { ok: false, error: `boss: a line is at most ${LINE_MAX} characters` }
const raw = input.phases === undefined || input.phases === null ? [] : input.phases
if (!Array.isArray(raw)) return { ok: false, error: 'boss.phases: a list, even an empty one' }
if (raw.length > PHASES_MAX) return { ok: false, error: `boss.phases: at most ${PHASES_MAX}` }
const seen = new Set()
const phases = []
for (let i = 0; i < raw.length; i++) {
const p = raw[i]
const at = `boss.phases[${i}]`
if (!p || typeof p !== 'object') return { ok: false, error: `${at}: it is empty` }
const fraction = num(p.at)
if (!(fraction > 0 && fraction < 1)) return { ok: false, error: `${at}.at: a fraction of its health between 0 and 1` }
if (seen.has(fraction)) return { ok: false, error: `${at}.at: two phases at ${fraction}` }
seen.add(fraction)
const phase = { at: fraction }
const damageDealt = optional(p.damageDealt)
if (damageDealt !== null && (Number.isNaN(damageDealt) || damageDealt < 0)) return { ok: false, error: `${at}.damageDealt: must not be negative` }
if (damageDealt !== null) phase.damageDealt = damageDealt
const aimCone = optional(p.aimCone)
if (aimCone !== null && (Number.isNaN(aimCone) || aimCone < 0)) return { ok: false, error: `${at}.aimCone: must not be negative` }
if (aimCone !== null) phase.aimCone = aimCone
const speed = optional(p.speed)
if (speed !== null && !(speed > 0 && speed <= 5)) return { ok: false, error: `${at}.speed: above 0 and at most 5 (times Rust's own)` }
if (speed !== null) phase.speed = speed
const ranges = phaseRanges(p.ranges)
if (!ranges.ok) return { ok: false, error: `${at}.ranges: sense and attack above 0, loseTarget at least sense, chase not negative` }
if (ranges.value) phase.ranges = ranges.value
const kit = text(p.kit).trim()
if (kit) phase.kit = kit
if (p.adds !== undefined && p.adds !== null) {
const profile = text(p.adds.profile).trim()
if (!NAME_RULE.test(profile)) return { ok: false, error: `${at}.adds.profile: a profile name` }
const count = num(p.adds.count)
if (!(Number.isInteger(count) && count >= 1 && count <= ADDS_MAX)) return { ok: false, error: `${at}.adds.count: 1 to ${ADDS_MAX}` }
phase.adds = { profile, count }
}
const line = text(p.line).trim()
if (line.length > LINE_MAX) return { ok: false, error: `${at}.line: at most ${LINE_MAX} characters` }
if (line) phase.line = line
phases.push(phase)
}
phases.sort((x, y) => y.at - x.at)
return { ok: true, value: { barDistance, announce, spawnLine, deathLine, phases } }
}
// ── Loot (runicnpc stage 7, D290–D301) ──────────────────────────────────
const LOOT_STARTS = ['rust', 'kit', 'none']
/** D292: the crates a table may go into, and their slots (stage 7 spike). */
const LOOT_CRATES = { wooden: 6, military: 6, elite: 12, locked: 24 }
/** An NPC corpse's main container. */
const CORPSE_SLOTS = 24
const LOOT_ROWS_MAX = 30
/** A Rust item's short name. Whether this server's Rust has it is RunicNPC's to say on the push. */
const ITEM_RULE = /^[a-z0-9_.-]{1,64}$/
function lootRow(r, at, pool) {
if (!r || typeof r !== 'object') return { ok: false, error: `${at}: it is empty` }
const item = text(r.item).trim()
if (!ITEM_RULE.test(item)) return { ok: false, error: `${at}.item: '${item}' is not an item's short name` }
const min = r.min === undefined || r.min === null || r.min === '' ? 1 : num(r.min)
const max = r.max === undefined || r.max === null || r.max === '' ? min : num(r.max)
if (!(Number.isInteger(min) && min >= 1 && Number.isInteger(max) && max >= min)) return { ok: false, error: `${at}: min at least 1, and max at least min` }
const row = { item, min, max }
if (pool) {
const weight = r.weight === undefined || r.weight === null || r.weight === '' ? 1 : num(r.weight)
if (!(weight > 0)) return { ok: false, error: `${at}.weight: above 0` }
row.weight = weight
} else {
const chance = r.chance === undefined || r.chance === null || r.chance === '' ? 1 : num(r.chance)
if (!(chance > 0 && chance <= 1)) return { ok: false, error: `${at}.chance: above 0 and at most 1` }
row.chance = chance
}
const skin = r.skin === undefined || r.skin === null || r.skin === '' ? 0 : num(r.skin)
if (!(Number.isInteger(skin) && skin >= 0)) return { ok: false, error: `${at}.skin: a workshop skin id, or 0` }
if (skin) row.skin = skin
return { ok: true, value: row }
}
/** D290–D301: the loot block, checked as RunicNPC's `ValidateLoot` checks it, in its words. */
function checkLoot(input) {
if (typeof input !== 'object' || Array.isArray(input)) return { ok: false, error: 'loot: an object' }
const start = input.start === undefined || input.start === null || input.start === '' ? 'rust' : String(input.start)
if (!LOOT_STARTS.includes(start)) return { ok: false, error: `loot.start: '${start}' is not rust, kit or none` }
const always = input.always === undefined || input.always === null ? [] : input.always
const pool = input.pool === undefined || input.pool === null ? {} : input.pool
const rows = pool.rows === undefined || pool.rows === null ? [] : pool.rows
if (!Array.isArray(always)) return { ok: false, error: 'loot.always: a list, even an empty one' }
if (typeof pool !== 'object' || !Array.isArray(rows)) return { ok: false, error: 'loot.pool: an object with rows, even an empty list' }
if (always.length > LOOT_ROWS_MAX || rows.length > LOOT_ROWS_MAX) return { ok: false, error: `loot: at most ${LOOT_ROWS_MAX} rows in always and in the pool` }
const value = { start, always: [], pool: { pick: 0, nothing: 0, rows: [] } }
for (let i = 0; i < always.length; i++) {
const r = lootRow(always[i], `loot.always[${i}]`, false)
if (!r.ok) return r
value.always.push(r.value)
}
for (let i = 0; i < rows.length; i++) {
const r = lootRow(rows[i], `loot.pool.rows[${i}]`, true)
if (!r.ok) return r
value.pool.rows.push(r.value)
}
const pick = pool.pick === undefined || pool.pick === null || pool.pick === '' ? 0 : num(pool.pick)
if (!(Number.isInteger(pick) && pick >= 0 && pick <= rows.length)) return { ok: false, error: 'loot.pool.pick: 0 to the number of pool rows' }
const nothing = pool.nothing === undefined || pool.nothing === null || pool.nothing === '' ? 0 : num(pool.nothing)
if (!(nothing >= 0)) return { ok: false, error: 'loot.pool.nothing: 0 or more' }
value.pool.pick = pick
value.pool.nothing = nothing
const crate = input.crate === undefined || input.crate === null || input.crate === '' ? null : String(input.crate)
if (crate !== null && !(crate in LOOT_CRATES)) return { ok: false, error: `loot.crate: '${crate}' is not wooden, military, elite or locked` }
const slots = crate === null ? CORPSE_SLOTS : LOOT_CRATES[crate]
if (value.always.length + pick > slots) {
return { ok: false, error: `loot: ${value.always.length} always-rows and ${pick} picked need ${value.always.length + pick} slots, and ${crate !== null ? `the ${crate} crate` : 'the corpse'} has ${slots}` }
}
value.crate = crate
const hackSeconds = input.hackSeconds === undefined || input.hackSeconds === null || input.hackSeconds === '' ? 900 : num(input.hackSeconds)
if (!(hackSeconds >= 1 && hackSeconds <= 86400)) return { ok: false, error: 'loot.hackSeconds: 1 to 86400' }
value.hackSeconds = hackSeconds
const corpseSeconds = input.corpseSeconds === undefined || input.corpseSeconds === null || input.corpseSeconds === '' ? null : num(input.corpseSeconds)
if (corpseSeconds !== null && !(corpseSeconds >= 0)) return { ok: false, error: "loot.corpseSeconds: null (Rust's own time), 0 (none) or more" }
value.corpseSeconds = corpseSeconds
value.dropTable = input.dropTable !== false
return { ok: true, value }
}
/** Every kit a profile names: its own, and each boss phase's. */
function kitsOf(body) {
const kits = new Set(body && Array.isArray(body.kits) ? body.kits : [])
for (const p of (body && body.boss && body.boss.phases) || []) if (p.kit) kits.add(p.kit)
return [...kits]
}
/**
* 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,
ANNOUNCES,
USE_MODES,
checkUse,
checkBoss,
checkLoot,
LOOT_STARTS,
LOOT_CRATES,
kitsOf,
defaults,
parseMode,
checkMovement,
checkBody,
fromServer,
labelOf,
}