Vacuum: pick up what you click, blow out what's in hand (t20)

A drag takes only the material it starts on (Shift: everything). The blow
selection is the hand: click or Q/E for one material, Ctrl+click for a mix.
Empty materials leave the hand, an empty hand blows nothing, and a pickup
fills an empty hand. Removes the pick-up marks and the F vacuum menu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 13:34:44 -04:00
co-authored by Claude Opus 5.5
parent 841efd2546
commit 51bdd00a9f
18 changed files with 162 additions and 186 deletions
+52 -76
View File
@@ -21,11 +21,6 @@ with sync_playwright() as p:
pg.on('pageerror', lambda e: logs.append(f'PAGEERROR: {e}'))
pg.on('console', lambda m: logs.append(f'{m.type}: {m.text}') if m.type == 'error' else None)
pg.goto(url); pg.evaluate("SET.fog = false"); pg.wait_for_timeout(300)
pg.evaluate("toggleCollect(CUORE)")
pg.reload(); pg.wait_for_timeout(300)
check('pick-up marks persist across reload', pg.evaluate("GAME.collect.has(CUORE)"))
pg.evaluate("localStorage.removeItem('deepgrain.collect')")
pg.reload(); pg.wait_for_timeout(300)
check('storage transactions roll back when a write throws', pg.evaluate("""async () => {
try { await DB.op('readwrite', st => { st.put('partial', 'abort-test'); throw new Error('test abort'); }); }
catch (e) { return await DB.get('abort-test') === undefined; }
@@ -220,7 +215,7 @@ with sync_playwright() as p:
pg.screenshot(path=str(out / '01-dug.png'))
check('trees live in the background layer', ev('bgm.some(v => v === WOOD)') and ev('(() => { for (let i = 0; i < N; i++) if (mat[i] === LEAVES) return false; return true; })()'))
# 2. vacuum, left button: everything unless marked
# 2. vacuum, left button: lock onto what you click
pg.keyboard.press('2'); settle(100)
tgt = ev(f'''(() => {{ for (let r = 0; r < 30; r++) for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) {{
const x = {bx + 6} + dx, y = {gy + 3} + dy, m = mat[y*W+x]; if (PH[m] === PH_POWDER && inReach(x, y)) return [x, y, m]; }} return null; }})()''')
@@ -232,15 +227,35 @@ with sync_playwright() as p:
got = ev(f'bag[{tgt[2]}]')
others = ev(f'bagTotal - bag[{tgt[2]}]')
check('vacuum (left) picks the clicked material', got > 0, f"{ev(f'MD[{tgt[2]}].name')} x{got}")
check('unmarked vacuum takes both materials in a mixed pile', ev("""(() => {
const x = Math.floor(bot.x) + 6, y = Math.floor(bot.y) - 6, cells = [], dirt = bag[LDIRT], stone = bag[RSTONE];
for (let dy = -2; dy <= 2; dy++) for (let dx = -2; dx <= 2; dx++) { const i = (y+dy)*W+x+dx; cells.push([i, mat[i], temp[i], flags[i]]); setCell(i, dx < 0 ? RSTONE : LDIRT, 20, 0); }
const aim = GAME.aim; GAME.aim = { x, y }; GAME.lmb = true; primaryPress(false); toolTick();
const ok = GAME.vacFilter === -1 && bag[LDIRT] > dirt && bag[RSTONE] > stone;
primaryRelease(false); GAME.lmb = false; GAME.aim = aim;
# Drive one tick at a time so the pile cannot fall between the click and pickup.
ev("""window.vacCase = (mode, hand = []) => {
const x = Math.floor(bot.x) + 6, y = Math.floor(bot.y) - 6, cells = [], aim = GAME.aim, blow = GAME.blow;
const oldBag = bag.slice(), total = bagTotal, flyers0 = flyers.length;
for (let dy = -6; dy <= 6; dy++) for (let dx = -6; dx <= 6; dx++) { const i = (y+dy)*W+x+dx; cells.push([i, mat[i], temp[i], flags[i]]); setCell(i, EMPTY, 20, 0); }
bag.fill(0); bagTotal = 0; GAME.blow = new Set(hand); GAME.aim = { x, y }; GAME.lmb = true;
if (mode !== 'air') setCell(y*W+x+1, LDIRT, 20, 0);
if (mode !== 'air') setCell(y*W+x, CUORE, 20, 0);
primaryPress(mode === 'shift');
let waiting = true;
if (mode === 'air') { toolTick(); waiting = GAME.vacFilter === 0 && bagTotal === 0; setCell(y*W+x, CUORE, 20, 0); setCell(y*W+x+1, LDIRT, 20, 0); }
toolTick();
if (mode === 'ore') { GAME.aim = { x: x + 1, y }; toolTick(); }
const result = { ore: bag[CUORE], dirt: bag[LDIRT], dirtStays: mat[y*W+x+1] === LDIRT, filter: GAME.vacFilter, hand: [...GAME.blow], waiting };
primaryRelease(false); GAME.lmb = false; GAME.aim = aim; GAME.blow = blow;
for (const [i, m, t, f] of cells) setCell(i, m, t, f);
bagTake(LDIRT, bag[LDIRT] - dirt); bagTake(RSTONE, bag[RSTONE] - stone); return ok;
})()"""))
bag.set(oldBag); bagTotal = total; flyers.length = flyers0; return result;
}""")
r = ev("vacCase('ore')")
check('drag starting on ore takes only ore and leaves dirt', r['ore'] > 0 and r['dirt'] == 0 and r['dirtStays'] and r['filter'] == ev('CUORE'))
check('pickup puts ore in an empty hand', r['hand'] == [ev('CUORE')])
r = ev("vacCase('air')")
check('drag starting in air waits and locks onto the first loose grain', r['waiting'] and r['ore'] > 0 and r['dirt'] == 0 and r['filter'] == ev('CUORE'))
r = ev("vacCase('shift')")
check('Shift drag takes everything and puts the first pickup in hand', r['ore'] > 0 and r['dirt'] > 0 and r['filter'] == -1 and r['hand'] == [ev('CUORE')])
check('picking up ore keeps coal in hand', ev("vacCase('ore', [COAL]).hand.join() === String(COAL)"))
check('picking up ore keeps a chosen mix', ev("vacCase('ore', [COAL, RSTONE]).hand.join() === [COAL, RSTONE].join()"))
check('selecting Vacuum leaves an empty hand empty', ev("(() => { const old = GAME.blow; GAME.blow = new Set(); selectTool(0); selectTool(1); renderSelCard(); const ok = !GAME.blow.size && $('selcard').querySelector('.vacchoice').textContent === 'empty' && $('selcard').querySelector('.num.chip').textContent === 'none'; GAME.blow = old; return ok; })()"))
check('Q/E from an empty hand chooses the first or last bag material', ev("(() => { const old = GAME.blow; GAME.blow = new Set(); cycleItem(1); const first = blowList()[0] === bagMaterials()[0]; GAME.blow.clear(); cycleItem(-1); const last = blowList()[0] === bagMaterials().at(-1); GAME.blow = old; return first && last; })()"))
check('middle click chooses a loose material even with none in the bag', ev("""(() => {
const x = Math.floor(bot.x) + 6, y = Math.floor(bot.y) - 6, i = y*W+x;
const old = [mat[i], temp[i], flags[i]], aim = GAME.aim, blow = GAME.blow, n = bag[LDIRT];
@@ -248,21 +263,9 @@ with sync_playwright() as p:
const ok = GAME.blow.size === 1 && GAME.blow.has(LDIRT);
setCell(i, ...old); bag[LDIRT] = n; GAME.aim = aim; GAME.blow = blow; return ok;
})()"""))
# R marks pick-up without changing the blow choice
blow_before = ev('[...GAME.blow]')
tgt2 = ev(f'''(() => {{ for (let r = 0; r < 40; r++) for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) {{
const x = {bx + 6} + dx, y = {gy + 3} + dy, m = mat[y*W+x]; if (PH[m] === PH_POWDER && inReach(x, y)) return [x, y, m]; }} return null; }})()''')
check('a loose grain is available for R marking', tgt2 is not None)
if tgt2:
cx, cy = cell_to_screen(tgt2[0], tgt2[1])
pg.mouse.move(cx, cy); settle(80); pg.keyboard.press('r'); settle(80)
check('R marks pick-up without changing blow', ev(f"GAME.collect.has({tgt2[2]})") and ev('[...GAME.blow]') == blow_before)
ev('renderBag()'); settle(50)
pg.keyboard.press('r'); settle(80)
check('R again unmarks', ev(f"!GAME.collect.has({tgt2[2]})"))
pg.screenshot(path=str(out / '02-vacuum.png'))
# 2b. the vacuum filter is fixed when you click: ore with stone around it, ore marked
# 2b. the vacuum filter stays fixed while dragging through a mixed pile
def mixed_pile(x0, y0):
ev(f"{{ const r = mulberry32(3); for (let y = {y0}; y < {y0} + 6; y++) for (let x = {x0}; x < {x0} + 14; x++) setCell(y*W+x, r() < 0.5 ? CUORE : RSTONE, AMB[y], 0); }}")
def suck_at(x, y, ms=900, shift=False, fly=None):
@@ -283,14 +286,12 @@ with sync_playwright() as p:
mixed_pile(px0, py0); settle(400)
ore = find_ore()
refill('bag.fill(0)')
ev('GAME.collect = new Set([CUORE]); lastBagKey = "";')
suck_at(*ore)
check('with ore marked, clicking ore never pulls in stone', ev('bag[CUORE]') > 0 and ev('bag[RSTONE]') == 0, f"ore {ev('bag[CUORE]')}, stone {ev('bag[RSTONE]')}")
check('clicking ore never pulls in stone', ev('bag[CUORE]') > 0 and ev('bag[RSTONE]') == 0, f"ore {ev('bag[CUORE]')}, stone {ev('bag[RSTONE]')}")
mixed_pile(px0, py0); settle(400); refill('bag.fill(0)')
st = ev(f'(() => {{ for (let y = {py0} - 8; y < {gy} + 4; y++) for (let x = {px0} - 6; x < {px0} + 20; x++) if (mat[y*W+x] === RSTONE && mat[(y-1)*W+x] === EMPTY && inReach(x, y)) return [x, y]; return null; }})()')
suck_at(*st)
check('with ore marked, starting on stone rubble still takes only the ore', ev('bag[CUORE]') > 0 and ev('bag[RSTONE]') == 0, f"ore {ev('bag[CUORE]')}, stone {ev('bag[RSTONE]')}")
ev('GAME.collect = new Set([CUORE]); lastBagKey = "";')
check('starting on stone rubble takes only stone', ev('bag[RSTONE]') > 0 and ev('bag[CUORE]') == 0, f"ore {ev('bag[CUORE]')}, stone {ev('bag[RSTONE]')}")
mixed_pile(px0, py0); settle(400); refill('bag.fill(0)')
ore = find_ore()
suck_at(*ore, ms=500, shift=True, fly='d')
@@ -300,10 +301,9 @@ with sync_playwright() as p:
ore = find_ore()
suck_at(*ore, ms=500, shift=True)
check('Shift held standing still takes everything', ev('bag[RSTONE]') > 0 and ev('bag[CUORE]') > 0, f"ore {ev('bag[CUORE]')}, stone {ev('bag[RSTONE]')}")
ev("GAME.collect.clear(); lastBagKey = ''; hudDirty = true")
ev("GAME.aim = { x: Math.floor(bot.x), y: 1 }")
pg.keyboard.press('f'); settle(100)
check('F away from usable things opens Vacuum', ev("!$('m-dialog').hidden && $('dlg-title').textContent === 'Vacuum'"))
check('F away from usable things says nothing to use', ev("$('m-dialog').hidden && GAME.status === 'Nothing to use there.'"))
ev("closeModal('m-dialog')")
# zoom jumps straight to the bot
pg.mouse.move(640, 400); settle(600)
@@ -313,7 +313,7 @@ with sync_playwright() as p:
pg.mouse.wheel(0, 100); settle(300)
refill('bag.fill(0)')
# 3. right button blows the chosen material out of the bot; the choice is sticky
# 3. right button blows what is in hand; empty materials leave it
refill('bag[COAL] = 120; bag[RSTONE] = 600; bag[CUORE] = 60; bag[LCLAY] = 200; bag[LWOOD] = 120')
ok = select('2', 'GAME.blow.size === 1 && GAME.blow.has(COAL)')
check('Q/E chooses coal to blow', ok, ev('[...GAME.blow]'))
@@ -325,11 +325,14 @@ with sync_playwright() as p:
settle(350); pg.mouse.up(button='right'); settle(800)
check('right click blows coal out of the bag onto the ground', ev('bag[COAL]') < c0 and ev('flyers.length') == 0, f"{c0}->{ev('bag[COAL]')}")
refill('bag[COAL] = 3')
pg.locator('#baglist .pick').filter(has_text='Coal').click(); settle(80)
hold(dx_, dy_, 400, 'right'); settle(400)
check('chosen material stays chosen after it runs out', ev('bag[COAL] === 0 && GAME.blow.size === 1 && GAME.blow.has(COAL)'), ev('[bag[COAL], ...GAME.blow]'))
check('blowing the last material empties the hand', ev('bag[COAL] === 0 && !GAME.blow.size'), ev('[bag[COAL], ...GAME.blow]'))
check('empty hand blows nothing and explains how to choose', ev("(() => { const n = bagTotal, f = flyers.length; GAME.rmb = true; secondaryPress(false); toolTick(); GAME.rmb = false; return bagTotal === n && flyers.length === f && GAME.status === 'Nothing in hand: Q/E or click something in the bag.'; })()"))
check('a mix drops only the material that runs out', ev("(() => { const b = bag.slice(), total = bagTotal, f = flyers.length; bag.fill(0); bag[COAL] = 1; bag[CUORE] = 100; bagTotal = 101; GAME.blow = new Set([COAL, CUORE]); GAME.rmb = true; secondaryPress(false); toolTick(); GAME.rmb = false; const ok = !bag[COAL] && bag[CUORE] > 0 && GAME.blow.size === 1 && GAME.blow.has(CUORE); bag.set(b); bagTotal = total; flyers.length = f; return ok; })()"))
# Shift + right click blows an even mix of everything loose in the bag, without changing the choice
refill('bag[COAL] = 60; bag[CUORE] = 60')
ev('GAME.collect = new Set([COAL, CUORE]); GAME.blow = new Set([COAL]); lastBagKey = ""; hudDirty = true;')
ev('GAME.blow = new Set([COAL]); lastBagKey = ""; hudDirty = true;')
pg.keyboard.down('Shift'); settle(150)
check('Shift previews everything in the blow card', 'Everything · mix of' in pg.inner_text('#selcard'))
check('Shift lights every loose bag row', ev("blowAllList().every(m => [...$('baglist').querySelectorAll('.bagrow.on .nm')].some(e => e.textContent === MD[m].name))"))
@@ -353,59 +356,31 @@ with sync_playwright() as p:
check('Ctrl+click adds a bag item to the blow mix', ev('GAME.blow.size === 2 && GAME.blow.has(COAL) && GAME.blow.has(CUORE)') and 'Mix of 2' in pg.inner_text('#selcard'))
ore_row.click(modifiers=['Control']); settle(80)
check('Ctrl+click again takes it out of the mix', ev('GAME.blow.size === 1 && GAME.blow.has(COAL)'))
ev('GAME.collect = new Set(); lastBagKey = ""; hudDirty = true'); settle(80)
pg.locator('#baglist .pick').filter(has_text='Copper ore').click(button='right', modifiers=['Control']); settle(80)
check('Ctrl+right-click marks a bag item for pick-up', ev('GAME.collect.size === 1 && GAME.collect.has(CUORE) && GAME.blow.size === 1'))
pg.locator('#baglist .pick').filter(has_text='Copper ore').click(button='right', modifiers=['Control']); settle(80)
check('Ctrl+right-click again unmarks it', ev('!GAME.collect.size'))
pg.locator('#baglist .pick').filter(has_text='Copper ore').click(button='right'); settle(80)
check('a plain right click on the bag changes nothing', ev('!GAME.collect.size && GAME.blow.size === 1 && GAME.blow.has(COAL)'))
ev('GAME.collect = new Set([COAL, CUORE]); lastBagKey = ""; hudDirty = true'); settle(80)
pick_before = ev('[...GAME.collect]')
ev('GAME.blow = new Set([RSTONE]); hudDirty = true')
pg.click('#selcard [data-vac-menu]'); settle(80)
pg.click(f'#dlg-body [data-vm-blow="{ev("COAL")}"]'); settle(80)
pg.click(f'#dlg-body [data-vm-blow="{ev("RSTONE")}"]'); settle(80)
pg.click(f'#dlg-body [data-vm-blow="{ev("CUORE")}"]'); settle(80)
pg.get_by_role('button', name='Done', exact=True).click(); settle(100)
check('blow marks make a mix without changing pick-up marks', ev('GAME.blow.has(COAL) && GAME.blow.has(CUORE) && GAME.blow.size === 2') and ev('[...GAME.collect]') == pick_before and 'Mix of 2' in pg.inner_text('#selcard'))
blow_before = ev('[...GAME.blow]')
pg.click('#selcard [data-vac-menu]'); settle(80)
check('card opens vacuum menu', ev("!$('m-dialog').hidden"))
pg.click(f'#dlg-body [data-vm-pick="{ev("RSTONE")}"]'); settle(80)
check('menu pick button marks material', ev('GAME.collect.has(RSTONE)'))
pg.get_by_role('button', name='Pick up everything', exact=True).click(); settle(80)
check('Pick up everything clears marks and keeps menu open', ev("!GAME.collect.size && !$('m-dialog').hidden"))
pg.get_by_role('button', name='Done', exact=True).click(); settle(80)
check('pick-up marks do not change blow marks', ev('[...GAME.blow]') == blow_before)
check('right click on a bag row leaves the hand alone', ev('GAME.blow.size === 1 && GAME.blow.has(COAL)'))
check('bag rows have no pickup tag', '⬇' not in pg.inner_text('#baglist'))
check('bag hint describes the hand', pg.locator('#baglist .baghint').inner_text() == 'Click: put in hand · Ctrl+click: add to the mix · Q/E: next')
ore_row.click(modifiers=['Control']); settle(80)
check('vacuum bag has no pick or blow buttons', pg.locator('#baglist [data-pick], #baglist [data-blow]').count() == 0)
pg.mouse.move(mx_, my_); pg.mouse.down(button='right'); settle(500); pg.mouse.up(button='right'); settle(600)
dc, do_ = 60 - ev('bag[COAL]'), 60 - ev('bag[CUORE]')
check('right click blows an even marked ore/coal mix', dc > 5 and abs(dc - do_) <= 2, f'coal {dc}, ore {do_}')
check('right click blows an even ore/coal mix', dc > 5 and abs(dc - do_) <= 2, f'coal {dc}, ore {do_}')
pg.keyboard.press('e'); settle(80)
check('Q/E replaces the mix with one material', ev('GAME.blow.size === 1'))
ev("GAME.collect = new Set([COAL, CUORE]); GAME.blow = new Set([COAL, RSTONE]); GAME.status = ''; lastBagKey = ''; hudDirty = true")
ev("GAME.blow = new Set([COAL, RSTONE]); GAME.status = ''; lastBagKey = ''; hudDirty = true")
refill('bag[COAL] = 120; bag[CUORE] = 100; bag[RSTONE] = 600')
for width, height, suffix in [(1280, 800, ''), (900, 700, '-900')]:
pg.set_viewport_size({'width': width, 'height': height}); settle(200)
pg.screenshot(path=str(out / f'vacuum-card{suffix}.png'))
check(f'vacuum panel fits at {width}', ev("(() => { const c = $('selcard'), b = $('bagpanel'); return [c, b].every(e => { const r = e.getBoundingClientRect(); return r.left >= 0 && r.right <= innerWidth && r.bottom <= innerHeight && e.scrollWidth <= e.clientWidth; }) && [...$('baglist').querySelectorAll('.bagrow')].every(e => e.scrollWidth <= e.clientWidth); })()"))
pg.click('#selcard [data-vac-menu]'); settle(80)
pg.screenshot(path=str(out / f'vacuum-menu{suffix}.png'))
check(f'vacuum menu fits at {width}', ev("(() => { const e = $('dlg-body'), r = e.closest('.plate').getBoundingClientRect(); return e.scrollWidth <= e.clientWidth && document.querySelector('.vpick').scrollWidth <= document.querySelector('.vpick').clientWidth && r.left >= 0 && r.right <= innerWidth && r.bottom <= innerHeight; })()"))
check(f'vacuum menu has one line per material at {width}', ev("(() => { const rows = [...document.querySelectorAll('.vpick .vrow:not(.vhead)')]; return rows.length > 3 && rows.every(r => r.getBoundingClientRect().height <= 28); })()"))
pg.get_by_role('button', name='Blow one at a time', exact=True).click(); settle(80)
check(f'Blow one at a time keeps menu open at {width}', ev("GAME.blow.size === 1 && !$('m-dialog').hidden"))
pg.get_by_role('button', name='Done', exact=True).click(); settle(80)
pg.set_viewport_size({'width': 1280, 'height': 800})
pg.keyboard.press('3'); settle(80)
check('build bag has no vacuum toggles', pg.locator('#baglist .mark').count() == 0)
pg.keyboard.press('2'); settle(80)
ev("GAME.blow = new Set([COAL, CUORE]); toggleBlow(BARCU)")
check('a bar blow mark replaces the mix', ev('GAME.blow.size === 1 && GAME.blow.has(BARCU)'))
check('putting a bar in hand replaces the mix', ev('GAME.blow.size === 1 && GAME.blow.has(BARCU)'))
ev("toggleBlow(COAL)")
check('a grain blow mark replaces a bar', ev('GAME.blow.size === 1 && GAME.blow.has(COAL)'))
ev("GAME.collect.clear(); lastBagKey = ''; hudDirty = true")
check('putting a grain in hand replaces a bar', ev('GAME.blow.size === 1 && GAME.blow.has(COAL)'))
refill('bag[COAL] = 120')
# 4. igniter lights it
@@ -531,9 +506,9 @@ with sync_playwright() as p:
pg.keyboard.press('2'); settle(60)
ev("GAME.status = ''; GAME.statusT = 0"); n_toasts = ev("$('toasts').children.length")
pg.mouse.move(*cell_to_screen(ev('molds[0].x') + 3, ev('molds[0].y') + 3)); settle(150); pg.keyboard.press('f'); settle(150)
check('F on an automatic ingot mold opens vacuum menu without a toast', ev("!$('m-dialog').hidden") and ev("$('toasts').children.length") == n_toasts)
check('F on an automatic ingot mold stays quiet without a toast', ev("$('m-dialog').hidden") and ev("$('toasts').children.length") == n_toasts)
ev("closeModal('m-dialog')")
check('F with vacuum uses what is in reach before opening the menu', ev("""(() => {
check('F with vacuum still uses what is in reach', ev("""(() => {
const old = interactAt; interactAt = () => 'Gate opened.';
try { doInteract(); } finally { interactAt = old; }
return $('m-dialog').hidden && GAME.status === 'Gate opened.';
@@ -751,8 +726,9 @@ with sync_playwright() as p:
check('gamepad RT digs where the right stick aims', ev('stats.dug') > d0, f"{d0}->{ev('stats.dug')}")
ev("__pad.buttons[5].pressed = true;"); settle(100); ev("__pad.buttons[5].pressed = false;"); settle(100)
check('gamepad RB switches to the vacuum', ev('GAME.tool') == 1)
ev("GAME.collect.clear(); GAME.aim = { x: Math.floor(bot.x), y: Math.floor(bot.y) }; setCell(Math.floor(bot.y)*W+Math.floor(bot.x), COAL, 20, 0); __pad.buttons[2].pressed = true; pollPad(); __pad.buttons[2].pressed = false; pollPad()")
check('gamepad X keeps R pick-up marking', ev("GAME.collect.has(COAL)"))
ev("selectTool(2); GAME.pieceIdx = PIECE_BY_ID.belt.index; GAME.pieceDir.belt = 0; __pad.buttons[2].pressed = true; pollPad(); __pad.buttons[2].pressed = false; pollPad()")
check('gamepad X rotates with the Build tool', ev('GAME.pieceDir.belt === 1'))
ev('selectTool(1)')
ev("__pad.buttons[15].pressed = true; pollPad(); __pad.buttons[15].pressed = false; pollPad()")
check('gamepad d-pad chooses a single blow material', ev('GAME.blow.size === 1'))
+5 -2
View File
@@ -14,9 +14,12 @@ ticks(run, 60);
// 2. vacuum with a filter: only rubble
rect(run, 100, 96, 104, 99, run('COAL'));
ticks(run, 30);
const got = run('toolVacuum(120, 104, 6, m => m === RSTONE).got');
const pickup = run('toolVacuum(120, 104, 6, m => m === RSTONE)');
const got = pickup.got;
check('vacuum reports the first material picked up', pickup.m === run('RSTONE'));
check('vacuum picks only the filtered material', run('bag[RSTONE]') > 0 && run('bag[COAL]') === 0, `${run('bag[RSTONE]')} rubble, ${run('bag[COAL]')} coal`);
run('toolVacuum(102, 98, 4, m => true)');
check('empty vacuum reports no first material', run('toolVacuum(10, 10, 1, () => true).m') === 0);
check('unfiltered vacuum picks coal', run('bag[COAL]') > 0, run('bag[COAL]'));
// 3. drop: grains fall
const c0 = run('bag[COAL]');
@@ -25,7 +28,7 @@ run('for (let k=0;k<5;k++) { toolBlow(80, 70, 1, COAL); simTick(); }');
check('blown coal flies out of the bot', run('flyers.length') > 0, run('flyers.length'));
ticks(run, 120);
check('blown coal leaves the bag and lands on the ground', run('bag[COAL]') < c0 && run('flyers.length') === 0 && count(run, run('COAL'), 60, 80, 100, 99) > 0, `${c0}->${run('bag[COAL]')} landed ${count(run, run('COAL'), 60, 80, 100, 99)}`);
check('empty blow marks explain that nothing is left', run('toolBlow(80, 70, 1, COAL, [CUORE, BARCU]).status') === 'No marked materials left to blow.');
check('empty blow mix explains that nothing is in hand', run('toolBlow(80, 70, 1, COAL, [CUORE, BARCU]).status') === 'Nothing in hand: Q/E or click something in the bag.');
// bot flies through built walls but not through natural rock
run('bag[RSTONE] += 64; bagTotal += 64; placeBlocks(BP_WALL, RSTONE, blockRect(30, 14, 30, 17))');
check('bot can overlap a built wall', !run('botOverlapSolid(121, 58)'));