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
524 lines
26 KiB
JavaScript
524 lines
26 KiB
JavaScript
// ── 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,
|
||
}
|