From 33c5178ef909261a98c3c33aba78a737f84199e9 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 6 Oct 2026 00:32:24 -0500 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- client/src/routes/admin/NpcProfiles.jsx | 124 ++++++++++++++++++++++++ server/eventWorld.js | 12 ++- server/model/npcs/npcProfile.js | 96 ++++++++++++++++++ server/model/npcs/npcs.model.js | 2 + server/test/npcs.test.js | 62 ++++++++++++ 5 files changed, 295 insertions(+), 1 deletion(-) diff --git a/client/src/routes/admin/NpcProfiles.jsx b/client/src/routes/admin/NpcProfiles.jsx index 3cc0bea..494cad2 100644 --- a/client/src/routes/admin/NpcProfiles.jsx +++ b/client/src/routes/admin/NpcProfiles.jsx @@ -142,6 +142,17 @@ function formFrom(p, defaults) { spawnLine: str(b.boss && b.boss.spawnLine), deathLine: str(b.boss && b.boss.deathLine), phases: ((b.boss && b.boss.phases) || []).map(phaseForm), + // Stage 7 (D290–D301). + lootOn: Boolean(b.loot), + lootStart: (b.loot && b.loot.start) || 'rust', + lootAlways: ((b.loot && b.loot.always) || []).map((r) => ({ item: str(r.item), min: str(r.min), max: str(r.max), chance: str(r.chance === undefined ? '' : Math.round(r.chance * 100)) })), + lootPick: str(b.loot && b.loot.pool ? b.loot.pool.pick : 0), + lootNothing: str(b.loot && b.loot.pool ? b.loot.pool.nothing : 0), + lootPool: ((b.loot && b.loot.pool && b.loot.pool.rows) || []).map((r) => ({ item: str(r.item), min: str(r.min), max: str(r.max), weight: str(r.weight) })), + lootCrate: (b.loot && b.loot.crate) || '', + lootHackMinutes: str(b.loot ? Math.round((b.loot.hackSeconds || 900) / 6) / 10 : 15), + lootCorpse: !b.loot || b.loot.corpseSeconds === null || b.loot.corpseSeconds === undefined ? 'rust' : b.loot.corpseSeconds === 0 ? 'none' : 'seconds', + lootCorpseSeconds: str(b.loot && b.loot.corpseSeconds ? b.loot.corpseSeconds : 120), useMode: (b.use && b.use.mode) || 'chat', useLines: ((b.use && b.use.lines) || []).join('\n'), useTitle: str(b.use && b.use.title), @@ -182,6 +193,16 @@ function bodyFrom(f) { ? { barDistance: f.barDistance, announce: f.announce, spawnLine: f.spawnLine, deathLine: f.deathLine, phases: f.phases.map(phaseBody) } : null, use: f.role === 'passive' ? { mode: f.useMode, lines: lines(f.useLines), title: f.useTitle, text: f.useText } : null, + loot: f.lootOn + ? { + start: f.lootStart, + always: f.lootAlways.filter((r) => r.item.trim()).map((r) => ({ item: r.item.trim(), min: r.min, max: r.max || r.min, chance: r.chance === '' ? 1 : Number(r.chance) / 100 })), + pool: { pick: f.lootPick, nothing: f.lootNothing, rows: f.lootPool.filter((r) => r.item.trim()).map((r) => ({ item: r.item.trim(), min: r.min, max: r.max || r.min, weight: r.weight })) }, + crate: f.lootCrate || null, + hackSeconds: Math.round(Number(f.lootHackMinutes) * 60), + corpseSeconds: f.lootCorpse === 'rust' ? null : f.lootCorpse === 'none' ? 0 : f.lootCorpseSeconds, + } + : null, }, } } @@ -379,6 +400,107 @@ function Num({ value, onChange, width = 110 }) { return onChange(e.target.value)} inputMode="decimal" style={{ ...inputStyle, maxWidth: width }} /> } +const CRATE_WORDS = { + wooden: 'A wooden box (6 slots)', + military: 'A military crate (6 slots)', + elite: 'An elite crate (12 slots)', + locked: 'A locked crate players hack (24 slots)', +} + +/** + * Loot (D290–D301): what the corpse starts with, a table of rows that always + * roll and a pool capped at "pick", where the table goes, and how long the + * corpse stays. A server with no site edits the same block in RunicNPC's file. + */ +function LootSection({ form, setForm, data, fieldset, legend, row }) { + const set = (key) => (value) => setForm((f) => ({ ...f, [key]: value })) + const setRow = (list, i, key, value) => setForm((f) => ({ ...f, [list]: f[list].map((r, j) => (j === i ? { ...r, [key]: value } : r)) })) + const addRow = (list, blank) => setForm((f) => ({ ...f, [list]: [...f[list], blank] })) + const dropRow = (list, i) => setForm((f) => ({ ...f, [list]: f[list].filter((_, j) => j !== i) })) + const cell = (list, i, key, width = 90) => ( + setRow(list, i, key, e.target.value)} style={{ ...inputStyle, width, maxWidth: '100%' }} /> + ) + const head = { color: 'var(--head)', fontSize: '0.78rem' } + + return ( +
+ Loot + + {form.lootOn && ( + <> +
+ + + + + + + {form.lootCorpse === 'seconds' && } +
+ + Always rolled +

Each row rolls on its own, with its chance. Item names are Rust’s short names: scrap, rifle.ak, explosive.timed.

+ {form.lootAlways.map((r, i) => ( +
+ {cell('lootAlways', i, 'item', 200)} + {cell('lootAlways', i, 'min', 70)} + {cell('lootAlways', i, 'max', 70)} + {cell('lootAlways', i, 'chance', 80)} + +
+ ))} +
+ + The pool +

+ At most “picks” rows are drawn, by weight, and never the same row twice. “Nothing” is the weight of drawing none, + so a kill can never drop more than the picks however lucky: the fair cap on rare items. +

+
+ + +
+ {form.lootPool.map((r, i) => ( +
+ {cell('lootPool', i, 'item', 200)} + {cell('lootPool', i, 'min', 70)} + {cell('lootPool', i, 'max', 70)} + {cell('lootPool', i, 'weight', 80)} + +
+ ))} +
+ +
+ + + + {form.lootCrate === 'locked' && ( + + + + )} +
+ + )} +
+ ) +} + /** * A boss's phases (D275): each at a health percentage, each part optional. The * adds' profile is offered from the site's profiles that are not bosses. @@ -602,6 +724,8 @@ function ProfileForm({ form, setForm, data, busy, error, onSave, onCancel, onDel )} + +
Sides

diff --git a/server/eventWorld.js b/server/eventWorld.js index b4810f2..d980f9b 100644 --- a/server/eventWorld.js +++ b/server/eventWorld.js @@ -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 } } diff --git a/server/model/npcs/npcProfile.js b/server/model/npcs/npcProfile.js index 9a1bd08..173c49c 100644 --- a/server/model/npcs/npcProfile.js +++ b/server/model/npcs/npcProfile.js @@ -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, diff --git a/server/model/npcs/npcs.model.js b/server/model/npcs/npcs.model.js index 2752cc3..7b7a7c9 100644 --- a/server/model/npcs/npcs.model.js +++ b/server/model/npcs/npcs.model.js @@ -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), } } diff --git a/server/test/npcs.test.js b/server/test/npcs.test.js index f6c851b..cd8012d 100644 --- a/server/test/npcs.test.js +++ b/server/test/npcs.test.js @@ -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)