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:
@@ -542,7 +542,7 @@ function placeVerb({ id, kind, budget, max, label, description, source, example
|
||||
const orders = kind === 'npc' ? npcOrders(params, found.server) : { ok: true, wire: {} }
|
||||
if (!orders.ok) return { ok: false, retry: false, error: orders.error }
|
||||
if (profile === null && Object.keys(orders.wire).length) {
|
||||
return { ok: false, retry: false, error: "escort, ally and tether are for one of the site's NPC profiles (RunicNPC), not Rust's own scientists" }
|
||||
return { ok: false, retry: false, error: "escort, ally, tether and drop loot are for one of the site's NPC profiles (RunicNPC), not Rust's own scientists" }
|
||||
}
|
||||
|
||||
if (verify) return { ok: true }
|
||||
@@ -608,6 +608,15 @@ const NPC_ORDER_PARAMS = [
|
||||
description:
|
||||
'Keep the NPCs inside the zone this event made around the point (D272): a Make a zone step must come first. RunicNPC profiles only.',
|
||||
},
|
||||
{
|
||||
// Runicnpc stage 7 (D296): on by default; a step older than the switch drops loot as before.
|
||||
name: 'dropLoot',
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
example: true,
|
||||
description:
|
||||
"Whether the NPCs drop their profile's loot table when killed (D296). On unless switched off; off, their corpse still holds what the profile starts it with. An event's own rewards are separate. RunicNPC profiles only.",
|
||||
},
|
||||
]
|
||||
|
||||
/** The orders, checked, as the bridge reads them: `{ ok, wire }` or `{ ok: false, error }`. */
|
||||
@@ -632,6 +641,7 @@ function npcOrders(params, server) {
|
||||
wire.ally = { kind: 'player', id: teamOf }
|
||||
}
|
||||
if (params.tether === true || params.tether === 'true') wire.tether = true
|
||||
if (params.dropLoot === false || params.dropLoot === 'false') wire.dropLoot = false
|
||||
return { ok: true, wire }
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -165,6 +165,8 @@ async function describe() {
|
||||
kitUses: shape.KIT_USES,
|
||||
announces: shape.ANNOUNCES,
|
||||
useModes: shape.USE_MODES,
|
||||
lootStarts: shape.LOOT_STARTS,
|
||||
lootCrates: Object.keys(shape.LOOT_CRATES),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -570,6 +570,68 @@ test('the Place NPCs step sends escort, ally and tether for a profile, and refus
|
||||
assert.strictEqual(plain.ok, true, 'an untouched order is no order')
|
||||
})
|
||||
|
||||
// ── stage 7: loot (D290–D301) ─────────────────────────────────────────────
|
||||
|
||||
const LOOT = {
|
||||
loot: {
|
||||
start: 'kit',
|
||||
always: [{ item: 'scrap', min: 100, max: 300, chance: 1 }, { item: 'rope', chance: 0.5 }],
|
||||
pool: { pick: 1, nothing: 85, rows: [{ item: 'lmg.m249', weight: 10 }, { item: 'explosive.timed', weight: 5 }] },
|
||||
crate: 'military',
|
||||
corpseSeconds: 0,
|
||||
},
|
||||
}
|
||||
|
||||
test('stage 7: a loot table is refused for what RunicNPC would refuse, in its own words', () => {
|
||||
const cases = [
|
||||
[{ loot: { start: 'some' } }, "loot.start: 'some' is not rust, kit or none"],
|
||||
[{ loot: { always: [{ item: 'Not An Item' }] } }, "loot.always[0].item: 'Not An Item' is not an item's short name"],
|
||||
[{ loot: { always: [{ item: 'scrap', chance: 0 }] } }, 'loot.always[0].chance: above 0 and at most 1'],
|
||||
[{ loot: { always: [{ item: 'scrap', min: 5, max: 2 }] } }, 'loot.always[0]: min at least 1, and max at least min'],
|
||||
[{ loot: { pool: { pick: 2, rows: [{ item: 'scrap' }] } } }, 'loot.pool.pick: 0 to the number of pool rows'],
|
||||
[{ loot: { pool: { rows: [{ item: 'scrap', weight: 0 }] } } }, 'loot.pool.rows[0].weight: above 0'],
|
||||
[{ loot: { crate: 'barrel' } }, "loot.crate: 'barrel' is not wooden, military, elite or locked"],
|
||||
[{ loot: { crate: 'wooden', always: ['a', 'b', 'c', 'd', 'e', 'f', 'g'].map((x) => ({ item: x })) } }, 'need 7 slots, and the wooden crate has 6'],
|
||||
[{ loot: { hackSeconds: 0 } }, 'loot.hackSeconds: 1 to 86400'],
|
||||
[{ loot: { corpseSeconds: -1 } }, "loot.corpseSeconds: null (Rust's own time), 0 (none) or more"],
|
||||
]
|
||||
for (const [extra, words] of cases) {
|
||||
const out = shape.checkBody(body(extra))
|
||||
assert.strictEqual(out.ok, false, JSON.stringify(extra))
|
||||
assert.ok(out.error.includes(words), `${out.error} / ${words}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('stage 7: a loot table reads back clean, with every default filled', () => {
|
||||
const out = shape.checkBody(body(LOOT))
|
||||
assert.strictEqual(out.ok, true, out.error)
|
||||
assert.deepStrictEqual(out.value.loot, {
|
||||
start: 'kit',
|
||||
always: [{ item: 'scrap', min: 100, max: 300, chance: 1 }, { item: 'rope', min: 1, max: 1, chance: 0.5 }],
|
||||
pool: { pick: 1, nothing: 85, rows: [{ item: 'lmg.m249', min: 1, max: 1, weight: 10 }, { item: 'explosive.timed', min: 1, max: 1, weight: 5 }] },
|
||||
crate: 'military',
|
||||
hackSeconds: 900,
|
||||
corpseSeconds: 0,
|
||||
dropTable: true,
|
||||
})
|
||||
assert.strictEqual(shape.checkBody(body()).value.loot, null, 'no loot block keeps Rust\'s scientist loot')
|
||||
})
|
||||
|
||||
test('stage 7: the Place NPCs step sends "drop loot" off only when switched off (D296)', async (t) => {
|
||||
stubStore(t, { profiles: [{ id: 1, name: 'warden', body: body(), servers: ['main'] }] })
|
||||
const calls = stubWire(t)
|
||||
const place = world.ACTIONS.find((a) => a.id === 'rust.npc.place')
|
||||
const base = { prefab: 'profile:warden', count: 1, server: 'main', x: 10, z: 20 }
|
||||
assert.ok(place.params.some((p) => p.name === 'dropLoot' && p.type === 'boolean'))
|
||||
|
||||
await place.perform({ runId: 9, idempotencyKey: 'l1', params: { ...base, dropLoot: false } })
|
||||
assert.strictEqual(calls.filter((c) => c.cmd === 'world.place').pop().body.dropLoot, false)
|
||||
await place.perform({ runId: 9, idempotencyKey: 'l2', params: { ...base } })
|
||||
assert.strictEqual('dropLoot' in calls.filter((c) => c.cmd === 'world.place').pop().body, false, 'on is the default and is not sent')
|
||||
const scientist = await place.perform({ runId: 9, idempotencyKey: 'l3', params: { ...base, prefab: 'npc.scientist', dropLoot: false }, verify: true })
|
||||
assert.match(scientist.error, /drop loot are for one of the site's NPC profiles/)
|
||||
})
|
||||
|
||||
test('a placement may be held inside a zone (D272), and a bad zone id is refused here', async (t) => {
|
||||
stubStore(t)
|
||||
const calls = stubWire(t)
|
||||
|
||||
Reference in New Issue
Block a user