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:
@@ -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