Vacuum controls: pick-up modes and separate pick-up and blow marks in the bag (t6, t15)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 01:54:12 -04:00
co-authored by Claude Opus 5.5
parent cc6f98a932
commit 255824ec6d
19 changed files with 213 additions and 118 deletions
+97 -20
View File
@@ -21,6 +21,15 @@ 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)
check('empty marks default to first mode', pg.evaluate("GAME.pickMode === 'first'"))
pg.evaluate("toggleCollect(CUORE); setPickMode('all')")
pg.reload(); pg.wait_for_timeout(300)
check('pick-up marks and a manual mode persist across reload', pg.evaluate("GAME.collect.has(CUORE) && GAME.pickMode === 'all'"))
pg.evaluate("localStorage.removeItem('deepgrain.pickMode')")
pg.reload(); pg.wait_for_timeout(300)
check('old pick-up marks default to marked mode', pg.evaluate("GAME.collect.has(CUORE) && GAME.pickMode === 'marked'"))
pg.evaluate("localStorage.removeItem('deepgrain.collect'); localStorage.removeItem('deepgrain.pickMode')")
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; }
@@ -228,20 +237,39 @@ with sync_playwright() as p:
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('vacuum (left) leaves other materials', others == 0, others)
# R ticks what is under the cursor; ticked materials come along with any left-click suck
check('vacuum defaults to What I click', ev("GAME.pickMode === 'first'"))
check('first mode locks onto dirt and leaves other grains', 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 === LDIRT && bag[LDIRT] > dirt && bag[RSTONE] === stone;
primaryRelease(false); GAME.lmb = false; GAME.aim = aim;
for (const [i, m, t, f] of cells) setCell(i, m, t, f);
bagTake(LDIRT, bag[LDIRT] - dirt); return ok;
})()"""))
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];
setCell(i, LDIRT, 20, 0); bag[LDIRT] = 0; GAME.aim = { x, y }; pickUnderCursor();
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 ticks the material under the cursor', ev(f'GAME.collect.has({tgt2[2]})'), ev('[...GAME.collect]'))
check('R marks pick-up and switches mode without changing blow', ev(f"GAME.collect.has({tgt2[2]}) && GAME.pickMode === 'marked'") and ev('[...GAME.blow]') == blow_before)
ev('renderBag()'); settle(50)
ticks = pg.locator('#baglist .tick.on')
if ticks.count(): ticks.first.click(); settle(80)
check('bag tick unticks it', not ev(f'GAME.collect.has({tgt2[2]})'), ev('[...GAME.collect]'))
marks = pg.locator('#baglist [data-pick][aria-pressed=true]')
if marks.count(): marks.first.click(); settle(80)
check('removing the last pick-up mark returns to first', ev(f"!GAME.collect.has({tgt2[2]}) && GAME.pickMode === 'first'"))
pg.screenshot(path=str(out / '02-vacuum.png'))
# 2b. the vacuum filter is fixed when you click: ore with stone around it, ore ticked
# 2b. the vacuum filter is fixed when you click: ore with stone around it, ore marked
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):
@@ -262,14 +290,14 @@ 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 = "";')
ev('GAME.collect = new Set([CUORE]); setPickMode("marked"); lastBagKey = "";')
suck_at(*ore)
check('with ore ticked, 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('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]')}")
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 ticked, 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(); lastBagKey = "";')
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(); setPickMode("first"); lastBagKey = "";')
mixed_pile(px0, py0); settle(400); refill('bag.fill(0)')
ore = find_ore()
suck_at(*ore, ms=500, shift=True, fly='d')
@@ -279,6 +307,26 @@ 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]')}")
pg.keyboard.press('t'); settle(80)
check('T cycles first to marked', ev("GAME.pickMode === 'marked'"))
before = ev('bagTotal')
suck_at(*ore, ms=100)
check('empty marked mode picks up nothing and explains why', ev('bagTotal') == before and 'Nothing marked to pick up. R over something, or ⬇ in the bag.' in ev('GAME.status'))
pg.keyboard.press('t'); settle(80)
check('T cycles marked to all', ev("GAME.pickMode === 'all'"))
mixed_pile(px0, py0); settle(400); refill('bag.fill(0)')
ore = find_ore(); suck_at(*ore, ms=500)
check('Everything mode picks up both materials', ev('bag[RSTONE] > 0 && bag[CUORE] > 0'))
pg.keyboard.press('t'); settle(80)
check('T cycles all to first', ev("GAME.pickMode === 'first'"))
for mode in ['marked', 'all', 'first']:
pg.click('#selcard [data-pick-mode]'); settle(80)
check(f'card chip cycles to {mode}', ev(f'GAME.pickMode === "{mode}"'))
ev("toggleCollect(CUORE); setPickMode('all'); toggleCollect(RSTONE)")
check('adding another mark switches a manual mode back to marked', ev("GAME.pickMode === 'marked'"))
ev("setPickMode('all'); toggleCollect(RSTONE)")
check('removing a mark keeps a manual mode', ev("GAME.pickMode === 'all'"))
ev("GAME.collect.clear(); setPickMode('first'); lastBagKey = ''; hudDirty = true")
# zoom jumps straight to the bot
pg.mouse.move(640, 400); settle(600)
pg.mouse.wheel(0, -100); pg.wait_for_timeout(60)
@@ -289,8 +337,8 @@ with sync_playwright() as p:
# 3. right button blows the chosen material out of the bot; the choice is sticky
refill('bag[COAL] = 120; bag[RSTONE] = 600; bag[CUORE] = 60; bag[LCLAY] = 200; bag[LWOOD] = 120')
ok = select('2', 'GAME.dropMat === COAL && !GAME.blowMix')
check('Q/E chooses coal to blow', ok, ev('GAME.dropMat'))
ok = select('2', 'GAME.blow.size === 1 && GAME.blow.has(COAL)')
check('Q/E chooses coal to blow', ok, ev('[...GAME.blow]'))
dx_, dy_ = cell_to_screen(bx - 14, gy - 6)
c0 = ev('bag[COAL]')
pg.mouse.move(dx_, dy_); pg.mouse.down(button='right'); settle(150)
@@ -300,21 +348,43 @@ with sync_playwright() as p:
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')
hold(dx_, dy_, 400, 'right'); settle(400)
check('chosen material stays chosen after it runs out', ev('bag[COAL] === 0 && GAME.dropMat === COAL'), ev('[bag[COAL], GAME.dropMat]'))
# tick coal and ore: Shift no longer changes what is blown, the selection does
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]'))
# mark coal and ore: Shift no longer changes what is blown, the selection does
refill('bag[COAL] = 60; bag[CUORE] = 60')
ev('GAME.collect = new Set([COAL, CUORE]); GAME.dropMat = COAL; GAME.blowMix = false; lastBagKey = ""; hudDirty = true;')
ev('GAME.collect = new Set([COAL, CUORE]); GAME.blow = new Set([COAL]); lastBagKey = ""; hudDirty = true;')
mx_, my_ = cell_to_screen(bx - 26, gy - 6)
pg.mouse.move(mx_, my_); pg.keyboard.down('Shift'); pg.mouse.down(button='right'); settle(400); pg.mouse.up(button='right'); pg.keyboard.up('Shift'); settle(600)
check('Shift + right click still blows only the chosen coal', ev('bag[CUORE]') == 60 and ev('bag[COAL]') < 60, ev('[bag[COAL], bag[CUORE]]'))
refill('bag[COAL] = 60; bag[CUORE] = 60'); settle(200)
pg.click('#baglist .mixrow .pick'); settle(200)
check('the bag offers the ticked mix as a choice', ev('GAME.blowMix') and 'Ticked mix' in pg.inner_text('#selcard'), pg.inner_text('#selcard')[:120])
pg.locator('#baglist .pick').filter(has_text='Coal').click(); settle(80)
check('clicking a bag name chooses one blow material', ev('GAME.blow.size === 1 && GAME.blow.has(COAL)'))
pick_before = ev('[...GAME.collect]')
pg.click(f'#baglist [data-blow="{ev("CUORE")}"]'); 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(f'#baglist [data-pick="{ev("RSTONE")}"]'); settle(80)
check('pick-up marks do not change blow marks', ev('[...GAME.blow]') == blow_before)
check('vacuum bag rows have both toggles', pg.locator('#baglist [data-pick]').count() == pg.locator('#baglist .bagrow').count() == pg.locator('#baglist [data-blow]').count())
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('choosing Ticked mix, right click blows an even ore/coal mix', dc > 5 and abs(dc - do_) <= 2, f'coal {dc}, ore {do_}')
ev('GAME.blowMix = false;')
ev('GAME.collect = new Set(); lastBagKey = "";')
check('right click blows an even marked 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]); setPickMode('marked'); 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.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("toggleBlow(BARCU)")
check('a bar blow mark 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(); setPickMode('first'); lastBagKey = ''; hudDirty = true")
refill('bag[COAL] = 120')
# 4. igniter lights it
@@ -654,6 +724,13 @@ 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(); setPickMode('first'); 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) && GAME.pickMode === 'marked'"))
ev("__pad.buttons[1].pressed = true; pollPad(); __pad.buttons[1].pressed = false; pollPad()")
check('gamepad B cycles pick-up mode', ev("GAME.pickMode === 'all'"))
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'))
ev("__pad.buttons[9].pressed = true;"); settle(100); ev("__pad.buttons[9].pressed = false;"); settle(100)
check('gamepad Start opens menu', ev("!$('m-pause').hidden"))
ev("__pad.buttons[13].pressed = true;"); settle(80); ev("__pad.buttons[13].pressed = false;"); settle(80)