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
This commit is contained in:
2026-10-06 00:32:24 -05:00
parent bb3e25b5d7
commit 33c5178ef9
5 changed files with 295 additions and 1 deletions

View File

@@ -84,6 +84,8 @@ function defaults() {
// 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,
}
}
@@ -255,6 +257,14 @@ function checkBody(input) {
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: {
@@ -280,6 +290,7 @@ function checkBody(input) {
kitUse,
boss,
use,
loot,
},
}
}
@@ -378,6 +389,88 @@ function checkBoss(input) {
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 : [])
@@ -417,6 +510,9 @@ module.exports = {
USE_MODES,
checkUse,
checkBoss,
checkLoot,
LOOT_STARTS,
LOOT_CRATES,
kitsOf,
defaults,
parseMode,