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:
@@ -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 <input value={value} onChange={(e) => 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) => (
|
||||
<input value={form[list][i][key]} onChange={(e) => setRow(list, i, key, e.target.value)} style={{ ...inputStyle, width, maxWidth: '100%' }} />
|
||||
)
|
||||
const head = { color: 'var(--head)', fontSize: '0.78rem' }
|
||||
|
||||
return (
|
||||
<fieldset style={fieldset}>
|
||||
<legend className="sans" style={legend}>Loot</legend>
|
||||
<label className="sans" style={{ display: 'flex', gap: 8, fontSize: '0.84rem' }}>
|
||||
<input type="checkbox" checked={form.lootOn} onChange={(e) => set('lootOn')(e.target.checked)} />
|
||||
Its own loot <span className="dim">(off: Rust’s scientist loot, as any scientist drops)</span>
|
||||
</label>
|
||||
{form.lootOn && (
|
||||
<>
|
||||
<div style={row}>
|
||||
<Field label="The corpse starts with">
|
||||
<select value={form.lootStart} onChange={(e) => set('lootStart')(e.target.value)} style={inputStyle}>
|
||||
<option value="rust">Rust’s scientist loot, and the clothes it wore</option>
|
||||
<option value="kit">What it carried: its kit, and the clothes it wore</option>
|
||||
<option value="none">Nothing at all, not even its clothes</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="The corpse stays">
|
||||
<select value={form.lootCorpse} onChange={(e) => set('lootCorpse')(e.target.value)} style={inputStyle}>
|
||||
<option value="rust">As long as Rust’s own (10 minutes)</option>
|
||||
<option value="seconds">For a number of seconds</option>
|
||||
<option value="none">Not at all: no corpse</option>
|
||||
</select>
|
||||
</Field>
|
||||
{form.lootCorpse === 'seconds' && <Field label="Seconds"><Num value={form.lootCorpseSeconds} onChange={set('lootCorpseSeconds')} /></Field>}
|
||||
</div>
|
||||
|
||||
<span className="sans" style={{ color: 'var(--head)', fontSize: '0.84rem' }}>Always rolled</span>
|
||||
<p className="sans dim" style={{ fontSize: '0.76rem', margin: 0 }}>Each row rolls on its own, with its chance. Item names are Rust’s short names: scrap, rifle.ak, explosive.timed.</p>
|
||||
{form.lootAlways.map((r, i) => (
|
||||
<div key={i} style={{ ...row, alignItems: 'end' }}>
|
||||
<Field label={i === 0 ? 'Item' : ''}>{cell('lootAlways', i, 'item', 200)}</Field>
|
||||
<Field label={i === 0 ? 'From' : ''}>{cell('lootAlways', i, 'min', 70)}</Field>
|
||||
<Field label={i === 0 ? 'To' : ''}>{cell('lootAlways', i, 'max', 70)}</Field>
|
||||
<Field label={i === 0 ? 'Chance (%)' : ''}>{cell('lootAlways', i, 'chance', 80)}</Field>
|
||||
<button type="button" className="btn" onClick={() => dropRow('lootAlways', i)}>Remove</button>
|
||||
</div>
|
||||
))}
|
||||
<div><button type="button" className="btn" onClick={() => addRow('lootAlways', { item: '', min: '1', max: '1', chance: '100' })}>Add a row</button></div>
|
||||
|
||||
<span className="sans" style={{ color: 'var(--head)', fontSize: '0.84rem' }}>The pool</span>
|
||||
<p className="sans dim" style={{ fontSize: '0.76rem', margin: 0 }}>
|
||||
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.
|
||||
</p>
|
||||
<div style={row}>
|
||||
<Field label="Picks"><Num value={form.lootPick} onChange={set('lootPick')} width={70} /></Field>
|
||||
<Field label="Nothing (weight)"><Num value={form.lootNothing} onChange={set('lootNothing')} width={90} /></Field>
|
||||
</div>
|
||||
{form.lootPool.map((r, i) => (
|
||||
<div key={i} style={{ ...row, alignItems: 'end' }}>
|
||||
<Field label={i === 0 ? 'Item' : ''}>{cell('lootPool', i, 'item', 200)}</Field>
|
||||
<Field label={i === 0 ? 'From' : ''}>{cell('lootPool', i, 'min', 70)}</Field>
|
||||
<Field label={i === 0 ? 'To' : ''}>{cell('lootPool', i, 'max', 70)}</Field>
|
||||
<Field label={i === 0 ? 'Weight' : ''}>{cell('lootPool', i, 'weight', 80)}</Field>
|
||||
<button type="button" className="btn" onClick={() => dropRow('lootPool', i)}>Remove</button>
|
||||
</div>
|
||||
))}
|
||||
<div><button type="button" className="btn" onClick={() => addRow('lootPool', { item: '', min: '1', max: '1', weight: '10' })}>Add a pool row</button></div>
|
||||
|
||||
<div style={row}>
|
||||
<Field label="The table goes into" hint="A crate stays until it is emptied, as Rust’s own do.">
|
||||
<select value={form.lootCrate} onChange={(e) => set('lootCrate')(e.target.value)} style={inputStyle}>
|
||||
<option value="">The corpse</option>
|
||||
{(data.lootCrates || Object.keys(CRATE_WORDS)).map((c) => <option key={c} value={c}>{CRATE_WORDS[c] || c}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
{form.lootCrate === 'locked' && (
|
||||
<Field label="Hack time (minutes)" hint="Rust’s own is 15. It counts once a player starts the hack.">
|
||||
<Num value={form.lootHackMinutes} onChange={set('lootHackMinutes')} width={90} />
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</fieldset>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
<LootSection form={form} setForm={setForm} data={data} fieldset={fieldset} legend={legend} row={row} />
|
||||
|
||||
<fieldset style={fieldset}>
|
||||
<legend className="sans" style={legend}>Sides</legend>
|
||||
<p className="sans dim" style={{ fontSize: '0.76rem', margin: 0 }}>
|
||||
|
||||
Reference in New Issue
Block a user