Files
deepgrain-foundry/test/play.py
T
44r0n7andClaude Opus 5.5 95b5eeb99d Vacuum menu on F replaces the pick-up modes (t16)
Pick-up takes everything unless something is marked; F with the Vacuum
(when nothing in reach can be used) or the card chips open a menu with
pick-up and blow toggles per material. The bag rows lose their buttons.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 02:28:56 -04:00

831 lines
65 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Play-test in a real browser: mouse, keyboard and a mocked gamepad drive the game; save and continue.
Screenshots go to shots/play/ (or the folder given as the first argument). Pixel look unless the
second argument is 'smooth'."""
import json, sys, pathlib
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent / 'tools'))
from use_venv import ensure_playwright; ensure_playwright()
from playwright.sync_api import sync_playwright
root = pathlib.Path(__file__).resolve().parent.parent
out = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else root / 'shots' / 'play'); out.mkdir(parents=True, exist_ok=True)
url = (root / 'dist' / 'index.html').as_uri()
fails = []
def check(name, cond, extra=''):
print(('PASS ' if cond else 'FAIL ') + name + (f' [{extra}]' if extra != '' else ''))
if not cond: fails.append(name)
with sync_playwright() as p:
b = p.chromium.launch()
ctx = b.new_context(viewport={'width': 1280, 'height': 800})
pg = ctx.new_page()
logs = []
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; }
return false;
}"""))
# Migrate a real legacy save before starting the play-through.
pg.evaluate("generateWorld(12345, 512, 384, null)")
pg.evaluate("async () => { const s = serializeWorld(); await DB.put('main', { ...s, savedAt: Date.now(), playTime: 3661 }); }")
check('a failed migration leaves the legacy save intact', pg.evaluate("""async () => {
const get = DB.get;
DB.get = async function (k) { const v = await get.call(this, k); return k.endsWith(':main') && v ? { ...v, savedAt: -1 } : v; };
try { await migrateWorlds(); return false; }
catch (e) { return !!await get.call(DB, 'main') && await get.call(DB, 'worlds') === undefined; }
finally { DB.get = get; }
}"""))
pg.reload(); pg.wait_for_function("!$('title').hidden && !$('btn-continue').hidden")
check('legacy save becomes World 1 and retires its old key', pg.evaluate("async () => { const w = await listWorlds(); return w.length === 1 && w[0].name === 'World 1' && await DB.get('main') === undefined; }"))
pg.click('#btn-continue'); pg.wait_for_function('GAME.running')
check('Continue loads the migrated seed and playtime', pg.evaluate('worldSeed === 12345 && GAME.playTime >= 3661 && !!GAME.worldId'))
pg.evaluate('quitToTitle()')
names = {}
for seed in [222, 333]:
if pg.is_visible('#nw-seed'): pg.click('#btn-new')
pg.click('#btn-new')
pg.wait_for_function("document.activeElement === $('nw-name')")
names[seed] = 'Copper Valley' if seed == 222 else pg.input_value('#nw-name')
check(f'new world {seed} offers the next default name', pg.input_value('#nw-name') == f'World {2 if seed == 222 else 3}')
if seed == 222:
pg.fill('#nw-name', names[seed])
pg.screenshot(path=str(out / 'new-world.png'))
pg.fill('#nw-seed', str(seed)); pg.press('#nw-name' if seed == 222 else '#nw-seed', 'Enter')
pg.wait_for_function('GAME.running', timeout=20000)
check(f'new world {seed} shows the Welcome card', pg.evaluate("[...$('cards').children].some(c => c.textContent.includes('Welcome'))"))
check(f'pause menu shows world {seed} name', names[seed] in pg.evaluate("() => { openPause(); return $('pause-meta').textContent; }"))
pg.evaluate('quitToTitle()')
pg.wait_for_function("!$('title').hidden")
pg.click('#btn-worlds'); pg.locator('#m-worlds').wait_for(state='visible')
check('three independent worlds appear with their seeds', pg.locator('.world-row').count() == 3 and all(str(seed) in pg.inner_text('#worlds-list') for seed in [12345, 222, 333]))
# Both looks, at both requested sizes; title UI shares the same styling.
for look_shot in ['pixel', 'smooth']:
pg.evaluate(f"SET.look = '{look_shot}'")
for width, height, suffix in [(1280, 800, ''), (900, 700, '-900')]:
pg.set_viewport_size({'width': width, 'height': height})
pg.screenshot(path=str(root / 'shots' / f'worlds-{look_shot}{suffix}.png'))
pg.set_viewport_size({'width': 1280, 'height': 800}); pg.evaluate("SET.look = 'pixel'")
ids = pg.evaluate("async () => Object.fromEntries((await listWorlds()).map(w => [w.seed, w.id]))")
for seed in [222, 333]:
check(f'Worlds list shows world {seed} name', pg.locator(f'[data-world="{ids[str(seed)]}"] strong').inner_text() == names[seed])
row = pg.locator(f'[data-world="{ids[str(seed)]}"]')
row.get_by_role('button', name='Load', exact=True).click(); pg.wait_for_function('GAME.running')
check(f'Load switches to seed {seed}', pg.evaluate('worldSeed') == seed and pg.evaluate('GAME.worldId') == ids[str(seed)])
check(f'loading seed {seed} shows no cards left from another world', pg.evaluate("$('cards').children.length") == 0)
pg.evaluate('quitToTitle()'); pg.click('#btn-worlds')
row = pg.locator(f'[data-world="{ids["222"]}"]')
row.get_by_role('button', name='Rename', exact=True).click()
row.locator('input').fill('<b>x</b>'); row.get_by_role('button', name='Save', exact=True).click()
pg.wait_for_function("document.querySelector('.world-row strong') && [...document.querySelectorAll('.world-row strong')].some(e => e.textContent === '<b>x</b>')")
check('HTML in world names is text', row.locator('strong').inner_text() == '<b>x</b>' and row.locator('strong b').count() == 0)
pg.reload(); pg.wait_for_function("!$('title').hidden"); pg.click('#btn-worlds')
check('rename persists after reload', row.locator('strong').inner_text() == '<b>x</b>')
check('blank renames keep the old name', pg.evaluate("async id => { await renameWorld(id, ' '); return (await listWorlds()).find(w => w.id === id).name === '<b>x</b>'; }", ids['222']))
row.get_by_role('button', name='Rename', exact=True).click()
row.locator('input').evaluate("e => e.value = 'x'.repeat(60)")
row.get_by_role('button', name='Save', exact=True).click()
pg.wait_for_function("[...document.querySelectorAll('.world-row strong')].some(e => e.textContent === 'x'.repeat(40))")
check('rename boundary cuts names to 40 characters', row.locator('strong').inner_text() == 'x' * 40)
row.get_by_role('button', name='Delete', exact=True).click()
pg.locator('#dlg-foot').get_by_role('button', name='Cancel', exact=True).click()
check('cancelling deletion keeps the world', row.count() == 1)
row.get_by_role('button', name='Delete', exact=True).click()
pg.locator('#dlg-foot').get_by_role('button', name='Delete', exact=True).click()
pg.wait_for_function("document.querySelectorAll('.world-row').length === 2")
check('confirmed deletion removes both saves and the entry', pg.evaluate("async id => !(await listWorlds()).some(w => w.id === id) && !await DB.get(id + ':main') && !await DB.get(id + ':auto')", ids['222']))
check('each world chooses its newer autosave without changing another', pg.evaluate("""async id => {
const main = await DB.get(id + ':main');
await DB.put(id + ':auto', { ...main, savedAt: main.savedAt + 1, playTime: 7777 });
const sv = await worldNewest(id); return sv.playTime === 7777 && sv.worldId === id;
}""", ids['333']))
pg.keyboard.press('Escape'); pg.click('#btn-continue'); pg.wait_for_function('GAME.running')
check('Continue chooses the most recently saved world', pg.evaluate('worldSeed') == 333)
pg.evaluate('quitToTitle()')
if not pg.is_visible('#nw-seed'): pg.click('#btn-new')
blank_default = pg.evaluate("listWorlds().then(w => `World ${w.length + 1}`)")
pg.fill('#nw-name', ' ')
pg.fill('#nw-seed', '777'); pg.click('#btn-generate')
pg.wait_for_function('GAME.running === true', timeout=20000)
check('blank new world names use the default', pg.evaluate('GAME.worldName') == blank_default)
pg.wait_for_timeout(500)
ev = pg.evaluate
ev("window._maxStatus = ''; const _ss = setStatus; setStatus = function (s, k) { if (s && s.length > _maxStatus.length) _maxStatus = s; return _ss(s, k); }; 0")
# World sounds are heard around the bot at any zoom, and inaudible ones must not throttle nearby sounds.
ev("auInit(); window._noiseCalls = 0; window._noiseBurst = noiseBurst; noiseBurst = function (...args) { _noiseCalls++; return _noiseBurst(...args); }; 0")
ev("window._bc = (dx, dy) => [bot.x + bot.w / 2 + dx, bot.y + bot.h / 2 + dy]; 0")
try:
far = ev("(() => { const n = _noiseCalls; AU.last.sizzle = 0; const [x, y] = _bc(200, 0); playSfx('sizzle', 0, x, y); return [_noiseCalls - n, sfxPlace(x, y).gain, AU.last.sizzle]; })()")
check('a sound 200 grains from the bot is silent', far == [0, 0, 0], far)
near = ev("(() => { const n = _noiseCalls; AU.last.sizzle = 0; const [x, y] = _bc(10, 0); playSfx('sizzle', 0, x, y); return [_noiseCalls - n, sfxPlace(x, y).gain, AU.bus == null]; })()")
check('a sound next to the bot plays at full volume', near == [1, 1, True], near)
quiet = ev('sfxPlace(..._bc(60, 0))')
check('a sound 60 grains to the right is quieter and panned right', 0 < quiet['gain'] < 1 and quiet['pan'] > 0, quiet)
zoomed = ev("(() => { const z = SET.zoom; SET.zoom = 2; resizeRender(); const n = _noiseCalls; AU.last.sizzle = 0; const [x, y] = _bc(200, 0); const vis = x < R.vx0 + R.bw; playSfx('sizzle', 0, x, y); SET.zoom = z; resizeRender(); return [vis, _noiseCalls - n]; })()")
check('zoomed far out, a visible sound 200 grains away stays silent', zoomed == [True, 0], zoomed)
finally:
ev('noiseBurst = _noiseBurst; delete window._noiseBurst; delete window._noiseCalls; 0')
look = sys.argv[2] if len(sys.argv) > 2 else 'pixel'
check('a new game starts fully zoomed in', ev('SET.zoom') == 12, ev('SET.zoom'))
# Ctrl+W closes the tab; with a world open the browser must ask first
leave = "(() => { const e = new Event('beforeunload', { cancelable: true }); dispatchEvent(e); return e.defaultPrevented; })()"
check('leaving the page with a world open asks to confirm', ev(leave))
check('but not on the title screen', not ev(f'(() => {{ GAME.running = false; const r = {leave}; GAME.running = true; return r; }})()'))
ev("SET.zoom = 4; resizeRender(); R.snapNext = true;") # the rest of this play-through was laid out at zoom 4
ev(f"SET.look = '{look}'"); pg.wait_for_timeout(200)
check(f'the {look} look is drawing', ev('R.smoothOn') == (look == 'smooth'), ev('[SET.look, R.smoothOn, SM.ok]'))
# Camera lead changes the view, not the bot or its aim.
mouse = ev('[GAME.mouse.x, GAME.mouse.y]')
ev("openSettings('Display')")
pg.screenshot(path=str(out / '00-camera-settings.png'))
pg.click('#set-cameraLead')
check('camera lead toggle saves its value', ev("!SET.cameraLead && JSON.parse(localStorage.getItem('deepgrain.settings')).cameraLead === false"))
pg.keyboard.press('Escape')
paused = ev('GAME.paused')
ev("GAME.paused = true; SET.cameraLead = false; $('cards').replaceChildren()") # cards cover the corner
pg.mouse.move(1240, 740); pg.wait_for_timeout(1000)
check('camera test pointer reaches the world', ev('GAME.mouse.in && GAME.mouse.x === 1240 && GAME.mouse.y === 740'))
camera_offset = """(() => {
const v = viewCells(), x = bot.x + bot.w / 2 - v.w / 2, y = bot.y + bot.h / 2 - v.h / 2;
const tx = v.w >= W ? (W - v.w) / 2 : Math.max(0, Math.min(W - v.w, x));
const ty = v.h >= H ? (H - v.h) / 2 : Math.max(0, Math.min(H - v.h, y));
return [R.camX - tx, R.camY - ty];
})()"""
centred = ev(camera_offset)
check('camera stays centred with pointer lead off', all(abs(d) < 2 for d in centred), centred)
ev('SET.cameraLead = true')
pg.wait_for_timeout(1000)
led = ev(camera_offset)
check('camera leads toward the pointer when enabled', all(b - a > 2 for a, b in zip(centred, led)), led)
pg.mouse.move(*mouse)
ev(f'GAME.paused = {json.dumps(paused)}; saveSettings()')
def cell_to_screen(x, y):
return ev(f'(() => {{ const dpr = R.canvas.width / innerWidth; const s = worldToScreenPx({x}+0.5, {y}+0.5); return [s.x/dpr, s.y/dpr]; }})()')
def blk(bx, by): return cell_to_screen(bx * 4 + 2, by * 4 + 2)
def hold(x, y, ms, button='left'):
pg.mouse.move(x, y); pg.mouse.down(button=button); pg.wait_for_timeout(ms); pg.mouse.up(button=button)
def select(key, cond, step='e', n=16):
pg.keyboard.press(key); pg.wait_for_timeout(60)
for _ in range(n):
if ev(cond): return True
pg.keyboard.press(step); pg.wait_for_timeout(30)
return ev(cond)
def settle(ms=300): pg.wait_for_timeout(ms)
def drag(fa, fc, steps=6, shift=False, hold_key=None):
# move to the start, let the camera settle, then drag using fresh screen positions
pg.mouse.move(*fa()); pg.wait_for_timeout(900)
a = fa(); pg.mouse.move(*a); pg.wait_for_timeout(50); c = fc()
if shift: pg.keyboard.down('Shift')
if hold_key: pg.keyboard.down(hold_key); pg.wait_for_timeout(40)
pg.mouse.down(); pg.mouse.move(*c, steps=steps); pg.mouse.up()
if hold_key: pg.keyboard.up(hold_key)
if shift: pg.keyboard.up('Shift')
pg.wait_for_timeout(100)
def refill(js): ev(js + '; bagTotal = 0; for (let k = 0; k < NMAT; k++) bagTotal += bag[k]; hudDirty = true;')
ev('player.tankTier = 3')
# no trees right around the work area: a test fire that spreads into one would block the belt row
ev('(() => { const x0 = Math.floor(bot.x) - 70, x1 = Math.floor(bot.x) + 30; for (let y = 0; y < surfY[Math.floor(bot.x)] + 4; y++) for (let x = x0; x < x1; x++) bgm[y * W + x] = 0; findTreeBases(); })()')
bx, by = ev('[Math.floor(bot.x+2), Math.floor(bot.y+2)]')
gy = ev(f'surfY[{bx}]')
# 1. drill: rock breaks loose, nothing enters the bag
pg.mouse.move(*cell_to_screen(bx + 6, gy + 3)); settle(900)
sx, sy = cell_to_screen(bx + 6, gy + 3)
hold(sx, sy, 3500)
settle(700)
check('drill digs with the mouse', ev('stats.dug') > 20, ev('stats.dug'))
check('drill does not fill the bag', ev('bagTotal') == 0, ev('bagTotal'))
# the resonance drill's controls: Q/E pulse width (medium to start), R tunes to what is under the cursor
check('the drill starts on a medium pulse', ev('GAME.drillW') == 1, ev('GAME.drillW'))
pg.keyboard.press('e'); settle(60)
w_wide = ev('GAME.drillW')
pg.keyboard.press('q'); pg.keyboard.press('q'); pg.keyboard.press('q'); settle(60)
check('E widens the pulse and Q narrows it, stopping at the ends', w_wide == 2 and ev('GAME.drillW') == 0, f"{w_wide} then {ev('GAME.drillW')}")
check('the drill card names the pulse width', 'narrow pulse' in pg.inner_text('#selcard'), pg.inner_text('#selcard')[:80])
pg.keyboard.press('e'); settle(60)
pg.mouse.move(*cell_to_screen(bx + 6, gy + 8)); settle(200)
pg.keyboard.press('r'); settle(60)
tuned = ev('GAME.drillTune')
check('R tunes the drill to the rock under the cursor', tuned != 0, ev('GAME.drillTune && MD[GAME.drillTune].name'))
pg.keyboard.press('r'); settle(60)
check('and R again untunes it', ev('GAME.drillTune') == 0)
check('dig milestone', ev('!!ledger.dig'))
check('discovery cards appear for new materials', pg.locator('#cards .card-pop').count() > 0, pg.locator('#cards .card-pop').count())
check('no fixed tutorial panel', pg.locator('#objective').count() == 0)
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
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; }})()''')
check('loose material lies around the hole', tgt is not None)
pg.mouse.move(*cell_to_screen(tgt[0], tgt[1])); settle(900)
vx, vy = cell_to_screen(tgt[0], tgt[1])
hold(vx, vy, 1500)
settle(200)
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;
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;
})()"""))
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 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
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):
pg.mouse.move(*cell_to_screen(x, y)); settle(900)
if fly: pg.keyboard.down(fly)
if shift: pg.keyboard.down('Shift')
settle(40)
pg.mouse.move(*cell_to_screen(x, y)); pg.wait_for_timeout(40)
if ev(f'grainAt({x}, {y})') != ev(f'grainAt(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y))'): pg.mouse.move(*cell_to_screen(x, y)); pg.wait_for_timeout(20)
pg.mouse.down(); pg.wait_for_timeout(ms); pg.mouse.up()
if shift: pg.keyboard.up('Shift')
if fly: pg.keyboard.up(fly)
settle(100)
ev('bot.vx = bot.vy = 0;')
px0, py0 = bx - 7, gy - 12
def find_ore():
return 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] === CUORE && mat[(y-1)*W+x] === EMPTY && inReach(x, y)) return [x, y]; return null; }})()')
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]')}")
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 = "";')
mixed_pile(px0, py0); settle(400); refill('bag.fill(0)')
ore = find_ore()
suck_at(*ore, ms=500, shift=True, fly='d')
check('Shift held while flying boosts instead of taking everything', ev('bag[RSTONE]') == 0 and ev('bag[CUORE]') > 0, f"ore {ev('bag[CUORE]')}, stone {ev('bag[RSTONE]')}")
ev(f'bot.x = {bx} - 2; bot.y = {by} - 2; bot.vx = bot.vy = 0;'); settle(300)
mixed_pile(px0, py0); settle(400); refill('bag.fill(0)')
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'"))
ev("closeModal('m-dialog')")
# zoom jumps straight to the bot
pg.mouse.move(640, 400); settle(600)
pg.mouse.wheel(0, -100); pg.wait_for_timeout(60)
c1 = ev('[R.camX, R.camY]'); settle(900); c2 = ev('[R.camX, R.camY]')
check('zooming lands on the bot without a pan afterwards', abs(c1[0] - c2[0]) < 1.5 and abs(c1[1] - c2[1]) < 1.5, f'{c1} -> {c2}')
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
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]'))
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)
check('blown grains fly out of the bot', ev('flyers.length') > 0, ev('flyers.length'))
pg.screenshot(path=str(out / '03a-blowing.png'))
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')
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]'))
# 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.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.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]')
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('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_}')
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")
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; })()"))
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)'))
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")
refill('bag[COAL] = 120')
# 4. igniter lights it
pg.keyboard.press('4')
spot = ev(f'(() => {{ for (let r = 0; r < 20; r++) for (const s of [1, -1]) {{ const x = {bx-14} + r * s; for (let y = 1; y < H; y++) {{ const m = mat[y*W+x]; if (m === EMPTY) continue; if (m === COAL && mat[(y-1)*W+x] === EMPTY && inReach(x, y)) return [x, y]; break; }} }} return [{bx-14}, 0]; }})()')
pg.mouse.move(*cell_to_screen(*spot)); settle(900)
ix, iy = cell_to_screen(*spot)
hold(ix, iy, 2000)
settle(1500)
check('igniter lights coal', ev('stats.ignitions') > 0, ev('stats.ignitions'))
check('thermal lens unlocked', ev('player.lens'))
pg.keyboard.press('h'); settle(200)
check('lens toggles', ev('R.lens'))
check('the lens is named on screen and framed', 'Thermal lens' in ev("$('ovbadges').textContent") and 'lens' in ev("$('ovframe').className"), pg.inner_text('#ovbadges'))
pg.screenshot(path=str(out / '03-fire-lens.png'))
pg.keyboard.press('h'); settle(150)
check('and the label and frame go when it is off', pg.inner_text('#ovbadges').strip() == '' and ev("$('ovframe').className") == '', ev("$('ovframe').className"))
# 5. build: a line of stone wall blocks by dragging, a rectangle with Shift
ok = select('3', "piece().id === 'wall'")
ev('GAME.wallMat = RSTONE; hudDirty = true')
check('build tool starts on wall blocks', ok)
settle(150)
card = pg.inner_text('#selcard') if pg.is_visible('#selcard') else ''
check('the build tool card describes the build tool, not the piece', pg.inner_text('#selcard .name') == 'Build' and 'menu below' in card and ev('piece().name') not in card and ev("costText(pieceCostNow(piece()), 1) || 'free'") not in card and ev('piece().desc') not in card, card[:160])
check('the build menu has no status row', pg.locator('#buildbar .bb-status').count() == 0)
check('the build menu has no short description row', pg.locator('.bb-tip').count() == 0)
pg.keyboard.press('f'); settle(120)
check('F opens all wall materials', pg.is_visible('#m-dialog') and 'material' in pg.inner_text('#dlg-title') and pg.locator('.wp').count() == ev('WALL_LIST.length'))
check('wall material selection and costs match the build card', ev("[...document.querySelectorAll('.wp')].every(b => b.getAttribute('aria-checked') === String(+b.dataset.wall === GAME.wallMat) && b.classList.contains('short') === !canAfford(pieceCostNow(piece(), +b.dataset.wall), 1) && b.textContent.includes(costText(pieceCostNow(piece(), +b.dataset.wall), 1)))"))
check('owned wall materials come first', ev("[...document.querySelectorAll('.wp')].map(b => +b.dataset.wall).join() === WALL_LIST.filter(m => bag[m] > 0).concat(WALL_LIST.filter(m => !bag[m])).join()"))
for width, height, suffix in [(1280, 800, ''), (900, 700, '-900')]:
pg.set_viewport_size({'width': width, 'height': height}); settle(120)
check(f'wall picker fits at {width}', ev("$('dlg-body').scrollWidth <= $('dlg-body').clientWidth && document.querySelector('.wpick').scrollWidth <= document.querySelector('.wpick').clientWidth"))
pg.screenshot(path=str(out / f'wall-picker{suffix}.png'))
pg.set_viewport_size({'width': 1280, 'height': 800})
pg.click(f'.wp[data-wall="{ev("RBRICK")}"]'); settle(120)
check('choosing Brick closes the material menu', ev("GAME.wallMat === RBRICK && $('m-dialog').hidden"))
pg.keyboard.press('r'); settle(120)
check('R still cycles after picking a material', ev('GAME.wallMat !== RBRICK'))
pg.click('#selcard [data-wall-picker]'); settle(120)
check('the material chip opens the menu', pg.is_visible('#m-dialog'))
pg.click('#dlg-foot button'); settle(120)
ev('GAME.wallMat = RSTONE; hudDirty = true')
for id in ['wall', 'grate']:
pg.hover(f'#buildbar [data-piece="{id}"]'); settle(120)
check(f'hover shows the full {id} description and name', pg.is_visible('#bb-pop') and pg.inner_text('#bb-pop .bb-title') == ev(f'PIECE_BY_ID.{id}.name') and pg.inner_text('#bb-pop .bb-desc') == ev(f'PIECE_BY_ID.{id}.desc'))
pg.mouse.move(400, 250); settle(120)
check('leaving the build bar hides the popup', not pg.is_visible('#bb-pop'))
ev('GAME.usingPad = true; renderBuildBar()')
check('gamepad shows the selected piece popup', pg.is_visible('#bb-pop') and pg.inner_text('#bb-pop .bb-title') == ev('piece().name'))
ev('GAME.usingPad = false; renderBuildBar()')
for key in ['1', '2']:
pg.keyboard.press(key); settle(150)
check(f'tool {key} restores the top-left card', pg.is_visible('#selcard'))
pg.keyboard.press('3'); settle(150)
pg.mouse.click(*cell_to_screen(bx, gy - 20), button='middle'); settle(120)
check('empty middle-click pick shows its status in the top-left card', 'Nothing built here to pick.' in pg.inner_text('#selcard'))
c0 = ev('GAME.buildCat != null ? GAME.buildCat : catOf(piece().id)')
pg.keyboard.press('Shift+E')
check('Shift+E switches build tabs and selects a piece in the tab', ev('GAME.buildCat') == (c0 + 1) % ev('BUILD_CATS.length') and ev('BUILD_CATS[GAME.buildCat][1].includes(piece().id)'))
pg.keyboard.press('Shift+Q')
check('Shift+Q returns to the tab and restores its wall piece', ev('GAME.buildCat') == c0 and ev("piece().id === 'wall'"))
ev('selectBuildCat(0)')
pg.keyboard.press('Shift+Q')
check('Shift+Q wraps from the first build tab to the last', ev('GAME.buildCat === BUILD_CATS.length - 1'))
pg.keyboard.press('Shift+E')
check('Shift+E wraps back to the first build tab', ev('GAME.buildCat === 0'))
p0 = ev('piece().id')
pg.keyboard.press('e')
check('plain E cycles pieces without switching tabs', ev('piece().id') != p0 and ev('GAME.buildCat') == 0)
pg.keyboard.press('q')
check('plain Q returns to the piece without switching tabs', ev('piece().id') == p0 and ev('GAME.buildCat') == 0)
pg.mouse.move(*cell_to_screen(bx + 6, gy + 8)); settle(100)
ev('window._pickN = 0; window._pickOrig = pickUnderCursor; pickUnderCursor = function () { _pickN++; }; 0') # 0: evaluate would call a returned function
pg.keyboard.press('g'); settle(60)
g_picks = ev('_pickN')
pg.mouse.click(*cell_to_screen(bx + 6, gy + 8), button='middle'); settle(60)
check('G no longer picks; middle mouse still does', g_picks == 0 and ev('_pickN') == 1, f"G {g_picks}, then {ev('_pickN')}")
ev('pickUnderCursor = _pickOrig')
check('G leaves the build piece unchanged over the world', ev('piece().id') == p0)
ev("GAME.pieceIdx = PIECE_BY_ID.wall.index; GAME.wallMat = RSTONE; hudDirty = true")
bbx, bby = ev('[Math.floor((bot.x + 2) / 4), Math.floor((bot.y + 2) / 4)]')
rb = ev('bag[RSTONE]'); nb0 = ev('blockP.reduce((a, v) => a + (v === BP_WALL), 0)')
drag(lambda: blk(bbx + 3, bby - 4), lambda: blk(bbx + 8, bby - 4))
nb1 = ev('blockP.reduce((a, v) => a + (v === BP_WALL), 0)')
check('drag places a straight line of 6 wall blocks', nb1 - nb0 == 6, f'{nb1 - nb0} blocks, {ev("GAME.status")}')
check('each wall block costs 16 rubble', rb - ev('bag[RSTONE]') == 96, rb - ev('bag[RSTONE]'))
drag(lambda: blk(bbx - 10, bby - 8), lambda: blk(bbx - 8, bby - 6), 4, True)
nb2 = ev('blockP.reduce((a, v) => a + (v === BP_WALL), 0)')
check('Shift-drag fills a 3×3 rectangle', nb2 - nb1 == 9, nb2 - nb1)
r0 = ev('bag[RSTONE]')
pg.mouse.move(*blk(bbx + 3, bby - 4)); pg.mouse.down(button='right'); settle(120); pg.mouse.up(button='right'); settle(80)
check('right click takes a block back with a refund', ev('bag[RSTONE]') - r0 == 16 and ev('blockP.reduce((a, v) => a + (v === BP_WALL), 0)') == nb2 - 1, ev('bag[RSTONE]') - r0)
pg.keyboard.press('r'); settle(60)
check('R cycles the wall material', ev('GAME.wallMat') != ev('RSTONE'), ev('MD[GAME.wallMat].name'))
ev('GAME.wallMat = RSTONE')
pg.mouse.move(*blk(bbx + 5, bby - 12)); settle(150)
pg.screenshot(path=str(out / '04-blocks.png'))
# the bot flies through built blocks but not natural rock
wx_, wy_ = ev(f'[{(bbx + 5) * 4 + 1}, {(bby - 4) * 4 + 1}]')
check('bot can sit inside a built wall', not ev(f'botOverlapSolid({wx_ - 2}, {wy_ - 2})'))
check('bot cannot sit inside natural rock', ev(f'botOverlapSolid({bx}, {gy + 12})'))
# 6. ingot mold: click to place, pour, bars drop below, vacuum them
ok = select('3', "piece().id === 'ingotmold'")
check('E cycles to the ingot mold', ok)
pg.keyboard.press('f'); settle(120)
check('molds have a named material menu and their own cost', pg.inner_text('#dlg-title') == ev("piece().name + ' material'") and ev("[...document.querySelectorAll('.wp')].every(b => b.textContent.includes(costText(wallCost(+b.dataset.wall, MOLDS[piece().mold].walls), 1)))"))
pg.click('#dlg-foot button'); settle(120)
spot = ev(f'''(() => {{ for (let dx = -2; dx <= 10; dx++) for (let dy = 6; dy < 14; dy++) {{ const ox = {bbx} + dx, oy = {bby} - dy; if (!canPlaceMold('ingot', ox - 1, oy) && [0, 1, 2].every(k => entFreeAt((ox - 1) * 4 + 1 + k * 4, oy * 4 + MOLDS.ingot.h)) && inReach(ox * 4 + 8, oy * 4 + 2)) return [ox, oy]; }} return null; }})()''')
check('found a spot for the mold', spot is not None)
mx, my = blk(spot[0], spot[1])
pg.mouse.move(mx, my); settle(100); pg.mouse.down(); pg.mouse.up(); settle(100)
check('mold placed by click', ev('molds.length') == 1, ev('GAME.status'))
ev('for (const cav of molds[0].cavs) for (const i of cav) setCell(i, MCU, 1160, 0)')
pg.wait_for_function("ents.filter(e => e.m === BARCU).length === 3", timeout=30000)
check('mold label shows a percentage', ev(r'/^\d+%/.test(moldLabel(molds[0]))'))
check('bar drops make no toast', ev("![...$('toasts').children].some(e => /dropped/i.test(e.textContent))"))
check('bars set and drop on their own', ev("ents.length === 3 && molds[0].state === 'short' && molds[0].ready.length === 0"), ev('[molds[0].state, ents.length]'))
pg.screenshot(path=str(out / '05-mold-ready.png'))
check('three bars drop below the mold without F', ev('ents.every(e => e.m === BARCU && e.iy >= molds[0].y + molds[0].h)'), ev('ents.map(e => [e.ix, e.iy])'))
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)
ev("closeModal('m-dialog')")
check('F with vacuum uses what is in reach before opening the menu', ev("""(() => {
const old = interactAt; interactAt = () => 'Gate opened.';
try { doInteract(); } finally { interactAt = old; }
return $('m-dialog').hidden && GAME.status === 'Gate opened.';
})()"""))
settle(1500)
pg.screenshot(path=str(out / '06-bars.png'))
pg.keyboard.press('2'); settle(60)
e0 = ev('[ents[0].ix + 2, ents[0].iy + 1]')
pg.mouse.move(*cell_to_screen(*e0)); settle(900)
hold(*cell_to_screen(*e0), 900)
check('vacuum picks up bars', ev('bag[BARCU]') >= 1, ev('bag[BARCU]'))
check('casting events stay quiet and damage labels are red', ev("""(() => {
const oldToast = toast, oldCard = showCard, oldSfx = playSfx, events = EV.splice(0), floaters = R.floaters;
let popups = 0, casts = 0;
toast = showCard = () => popups++; playSfx = s => { if (s === 'cast') casts++; };
R.floaters = [];
try {
emit({ t: 'ingot_drop', bars: 1, metal: COPPER, first: true });
emit({ t: 'ingot_blocked' }); emit({ t: 'cast_ready', kind: 'bit', metal: COPPER });
emit({ t: 'warn', x: 12, y: 34, text: 'melted' }); drainEvents();
return popups === 0 && casts === 1 && R.floaters.length === 1 && R.floaters[0].colour === '#ff8a72' && R.floaters[0].text === 'melted';
} finally { toast = oldToast; showCard = oldCard; playSfx = oldSfx; R.floaters = floaters; EV.push(...events); }
})()"""))
# 7. belt line by dragging, copied with a blueprint and pasted
refill('bag[BARCU] = 60'); ev('money = 1000')
ok = select('3', "piece().id === 'belt'")
check('select belt', ok)
# keep the belt in open air, on a row clear of the dock and other machines
shift_ = ev(f'''(() => {{ for (const dx of [0, -2, -4, -6, -8, 2]) {{ let ok = true; for (let bx = {bbx} + dx - 6; bx <= {bbx} + dx + 2 && ok; bx++) {{ const c = {bby} * 4 * W + bx * 4; if (blockP[{bby} * BW + bx] || mach[c] || mach[c + 3] || mach[c + 3 * W] || !inReach(bx * 4 + 2, {bby} * 4 + 2)) ok = false; for (let y = {bby}*4 - 4; y < {bby}*4 + 8 && ok; y++) for (let x = bx*4; x < bx*4 + 4; x++) if (mach[y*W+x]) ok = false; }} if (ok) return dx; }} return 0; }})()''')
bbx += shift_
ev(f"for (let y = {bby*4 - 4}; y < {bby*4 + 8}; y++) for (let x = {(bbx-8)*4}; x < {(bbx+4)*4}; x++) if (!blockP[((y/4)|0)*BW + ((x/4)|0)]) setCell(y*W+x, EMPTY, 20, 0); for (const e of ents.slice()) if (e.ix > {(bbx-9)*4} && e.ix < {(bbx+4)*4} && e.iy > {bby*4 - 8} && e.iy < {bby*4 + 10}) removeEnt(e);")
drag(lambda: blk(bbx - 6, bby), lambda: blk(bbx + 2, bby))
nbelt = ev('blockP.reduce((a, v) => a + (v === BP_BELT), 0)')
if nbelt != 9:
print('DEBUG belt row', ev(f'''JSON.stringify((() => {{ const o = []; for (let bx = {bbx} - 6; bx <= {bbx} + 2; bx++) {{ const b = {bby} * BW + bx; o.push([bx, blockP[b], blockInReach(bx, {bby}), (() => {{ const r = []; for (let y = {bby}*4; y < {bby}*4+4; y++) for (let x = bx*4; x < bx*4+4; x++) {{ const m = mat[y*W+x]; if (m) r.push(MD[m].name); }} return [...new Set(r)].join('/'); }})()]); }} return o; }})())'''), ev('[bot.x, bot.y]'))
check('belt blocks drawn by drag', nbelt == 9, f'{nbelt} {ev("GAME.status")}')
check('belts dragged to the right run right', ev(f'(() => {{ for (let bx = {bbx} - 6; bx <= {bbx} + 2; bx++) if (blockV[{bby} * BW + bx] !== 1) return false; return true; }})()'))
# copy: hold C and drag a box over them
pg.mouse.move(*blk(bbx - 6, bby)); settle(300); pg.keyboard.down('c'); settle(200)
check('holding C shows copy on the card', pg.is_visible('#selcard') and 'Copy' in ev("$('selcard').textContent") and ev("selModeHeld()") == 'copy', ev("$('selcard').textContent")[:80])
pg.keyboard.up('c'); settle(100)
drag(lambda: cell_to_screen((bbx - 6) * 4 + 1, bby * 4 + 1), lambda: cell_to_screen((bbx + 2) * 4 + 2, bby * 4 + 2), 5, hold_key='c')
check('blueprint copied', ev('!!GAME.clip && GAME.paste && GAME.clip.blocks.length === 9'), ev('GAME.status'))
pg.mouse.move(*blk(bbx - 2, bby - 9)); settle(150)
pg.screenshot(path=str(out / '07-paste-preview.png'))
pg.mouse.down(); pg.mouse.up()
settle(1500)
nbelt2 = ev('blockP.reduce((a, v) => a + (v === BP_BELT), 0)')
check('ghosts get built from the bag', nbelt2 == 18, f'belts={nbelt2} ghosts={ev("ghostBlocks.size")}')
m_before = ev('money')
pg.keyboard.press('Control+z'); settle(300)
check('Ctrl+Z undoes the paste: what was built from it comes back down, refunded', ev('blockP.reduce((a, v) => a + (v === BP_BELT), 0)') == 9 and ev('ghostBlocks.size') == 0 and ev('money') > m_before, ev('[blockP.reduce((a, v) => a + (v === BP_BELT), 0), ghostBlocks.size, GAME.status]'))
pg.keyboard.press('Escape'); settle(150)
check('Esc stops pasting (and does not open the menu)', not ev('GAME.paste') and ev("$('m-pause').hidden"))
# a line dragged right to left runs left
ev(f"for (let y = {(bby+1)*4}; y < {(bby+2)*4}; y++) for (let x = {(bbx-8)*4}; x < {(bbx+4)*4}; x++) if (!blockP[((y/4)|0)*BW + ((x/4)|0)]) setCell(y*W+x, EMPTY, 20, 0);")
ok = select('3', "piece().id === 'belt'")
drag(lambda: blk(bbx - 2, bby + 1), lambda: blk(bbx - 6, bby + 1))
check('belts dragged to the left run left, and the card follows', ev(f'(() => {{ for (let bx = {bbx} - 6; bx <= {bbx} - 2; bx++) if (blockP[({bby} + 1) * BW + bx] !== BP_BELT || blockV[({bby} + 1) * BW + bx] !== 0) return false; return true; }})()') and ev('GAME.pieceDir.belt') == 0, ev(f'[blockP[({bby} + 1) * BW + {bbx} - 4], blockV[({bby} + 1) * BW + {bbx} - 4], GAME.status]'))
# cut: hold X and drag; it all comes down into the bag, ready to paste; Ctrl+Z puts it back
m_before = ev('money')
drag(lambda: cell_to_screen((bbx - 6) * 4 + 1, (bby + 1) * 4 + 1), lambda: cell_to_screen((bbx - 2) * 4 + 2, (bby + 1) * 4 + 2), 5, hold_key='x')
check('X-drag cuts the row: taken down, refunded, ready to paste', ev(f'blockP[({bby} + 1) * BW + {bbx} - 4]') == 0 and ev('money') > m_before and ev('GAME.paste && GAME.clip.blocks.length === 5'), ev('[money, GAME.status]'))
pg.keyboard.press('Escape'); settle(100)
pg.keyboard.press('Control+z'); settle(300)
check('Ctrl+Z puts the cut row back', ev(f'blockP[({bby} + 1) * BW + {bbx} - 4]') == ev('BP_BELT') and ev('money') == m_before, ev('[money, GAME.status]'))
# G with a machine: it replaces the belt it is placed on
ok = select('3', "piece().id === 'fan'")
pg.mouse.move(*blk(bbx - 4, bby + 1)); settle(400)
pg.mouse.down(); pg.mouse.up(); settle(100)
check('a fan on a belt says to hold G', 'hold G' in ev('GAME.status'), ev('GAME.status'))
pg.keyboard.down('g'); settle(100); pg.mouse.move(*blk(bbx - 4, bby + 1)); settle(200)
check('holding G shows replace mode', 'Replace · G' in ev("$('selcard').textContent"))
pg.screenshot(path=str(out / '07g-replace-fan.png'))
pg.mouse.down(); pg.mouse.up(); settle(100); pg.keyboard.up('g'); settle(100)
check('G-click puts the fan in place of the belt', ev(f"machines.some(m => m && m.type === 'fan' && m.x === ({bbx} - 4) * 4 && m.y === ({bby} + 1) * 4)") and ev(f'blockP[({bby} + 1) * BW + {bbx} - 4]') == 0, ev('GAME.status'))
x_before, y_before = ev('[bot.x, bot.y]')
pg.keyboard.down('g'); pg.keyboard.down('d'); settle(300)
pg.keyboard.up('d'); pg.keyboard.up('g')
check('flying with WASD works while G (replace) is held', ev('bot.x') > x_before, ev('bot.x') - x_before)
ev(f'bot.x = {x_before}; bot.y = {y_before}; R.snapNext = true;'); settle(200)
# pipes are laid by dragging, like belts
ok = select('3', "piece().id === 'pipe'")
check('pipes, pumps, outlets and the refiner are in the build menu', ok and all(x in ev("PIECES.filter(p => !p.hidden).map(p => p.id)") for x in ['pipe', 'pump', 'outlet', 'refiner']))
ev(f"for (let y = {(bby+2)*4}; y < {(bby+3)*4}; y++) for (let x = {(bbx-8)*4}; x < {(bbx+4)*4}; x++) if (!blockP[((y/4)|0)*BW + ((x/4)|0)]) setCell(y*W+x, EMPTY, 20, 0);")
drag(lambda: blk(bbx - 6, bby + 2), lambda: blk(bbx - 3, bby + 2))
check('dragging lays a line of pipe that joins into one line', ev(f'(() => {{ let n = 0; for (let bx = {bbx} - 6; bx <= {bbx} - 3; bx++) if (blockP[({bby} + 2) * BW + bx] === BP_PIPE) n++; return n; }})()') == 4 and ev(f'pipeNetOf(({bby} + 2) * BW + {bbx} - 6).blocks.length') == 4, ev('GAME.status'))
ok = select('3', "piece().id === 'fan'")
pg.keyboard.press('Control+z'); settle(200)
pg.keyboard.press('Control+z'); settle(200)
check('and Ctrl+Z swaps the belt back', ev(f'blockP[({bby} + 1) * BW + {bbx} - 4]') == ev('BP_BELT') and not ev(f"machines.some(m => m && m.type === 'fan' && m.x === ({bbx} - 4) * 4)"), ev('GAME.status'))
# 7b. filter: choose several materials with F, place a line, replace belts with G, edit the whole line
ev('seen[CUORE] = 1; seen[FEORE] = 1; seen[SAND] = 1; money = 1000; GAME.filterCfg = { m: [], inv: 0 };')
ok = select('3', "piece().id === 'filter'")
check('select filter', ok)
pg.keyboard.press('f'); settle(200)
check('F opens the filter picker', ev("!$('m-dialog').hidden") and pg.locator('#dlg-body .fp').count() >= 3, pg.locator('#dlg-body .fp').count())
pg.locator('#dlg-body .fp', has_text='Copper ore').click(); pg.locator('#dlg-body .fp', has_text='Iron ore').click(); pg.locator('#dlg-body .fd', has_text='Right').click(); settle(100)
pg.screenshot(path=str(out / '07b-filter-picker.png'))
pg.locator('#dlg-foot button', has_text='Set filter').click(); settle(150)
check('picking sets several materials and the direction', ev('JSON.stringify(GAME.filterCfg.m) === JSON.stringify([CUORE, FEORE].sort((a, b) => a - b)) && GAME.filterCfg.inv === 0 && GAME.pieceDir.filter === 1') and ev("$('m-dialog').hidden"), ev('JSON.stringify([GAME.filterCfg, GAME.pieceDir.filter])'))
fy = bby - 5
ev(f"for (let y = {fy*4 - 12}; y < {fy*4 + 12}; y++) for (let x = {(bbx-8)*4}; x < {(bbx+2)*4}; x++) if (!blockP[((y/4)|0)*BW + ((x/4)|0)]) setCell(y*W+x, EMPTY, 20, 0);")
drag(lambda: blk(bbx - 6, fy), lambda: blk(bbx - 3, fy))
nf = ev('blockP.reduce((a, v) => a + (v === BP_FILTER), 0)')
check('filters placed by drag, dropping both ores and carrying right', nf == 4 and ev(f'blockV[{fy}*BW+{bbx-5}]') == 128 and ev(f'filterInfo({fy}*BW+{bbx-5}).m.length') == 2, f"{nf} {ev(f'JSON.stringify(filterInfo({fy}*BW+{bbx-5}))')}")
# two belts at the end of the line: plain dragging leaves them, G-dragging swaps them for filters
ev(f"placeBlock(BP_BELT, 1, {bbx-2}, {fy}, true); placeBlock(BP_BELT, 1, {bbx-1}, {fy}, true);")
drag(lambda: blk(bbx - 2, fy), lambda: blk(bbx - 1, fy))
check('dragging over built belts leaves them and says how to replace', ev(f'blockP[{fy}*BW+{bbx-2}] === BP_BELT') and 'hold G' in ev('GAME.status'), ev('GAME.status'))
pg.keyboard.down('g'); settle(50)
pg.mouse.move(*blk(bbx - 2, fy)); settle(300)
pg.screenshot(path=str(out / '07c-replace-preview.png'))
drag(lambda: blk(bbx - 2, fy), lambda: blk(bbx - 1, fy))
pg.keyboard.up('g'); settle(50)
check('G-dragging replaces them with filters', ev(f'blockP[{fy}*BW+{bbx-2}] === BP_FILTER && blockP[{fy}*BW+{bbx-1}] === BP_FILTER') and ev(f'filterRun({fy}*BW+{bbx-5}).length') == 6, ev('GAME.status'))
pg.mouse.move(*blk(bbx - 5, fy)); settle(200)
check('hovering a filter says what it drops and carries', 'copper ore' in ev('JSON.stringify(hoverInfo(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y)))').lower())
ev(f"for (let y = {fy*4 - 10}; y < {fy*4 - 1}; y++) for (let x = {(bbx-6)*4}; x < {(bbx-5)*4 + 2}; x++) setCell(y*W+x, [SAND, CUORE, FEORE][(x+y)%3], 20, 0);")
settle(500)
pg.screenshot(path=str(out / '07d-filter-working.png'))
pg.keyboard.press('1'); settle(60)
pg.mouse.move(*blk(bbx - 4, fy)); settle(150)
pg.keyboard.press('f'); settle(200)
check('F on a placed filter opens the picker for its whole line', ev("!$('m-dialog').hidden") and '6 in this row' in pg.locator('#dlg-title').inner_text() and ev('GAME.editRun && GAME.editRun.length') == 6, pg.locator('#dlg-title').inner_text())
pg.locator('#dlg-body .fm', has_text='Carry these').click(); pg.locator('#dlg-body .fp', has_text='Copper ore').click(); pg.locator('#dlg-body .fp', has_text='Iron ore').click(); pg.locator('#dlg-body .fp', has_text='Sand').click(); settle(100)
pg.screenshot(path=str(out / '07e-filter-edit-line.png'))
pg.locator('#dlg-foot button', has_text='Set filter').click(); settle(150)
check('and changes the whole line to carry sand and drop the rest', ev(f'filterRun({fy}*BW+{bbx-6}).length') == 6 and ev(f'filterInfo({fy}*BW+{bbx-1}).inv') == 1 and ev(f'JSON.stringify(filterInfo({fy}*BW+{bbx-6}).m) === JSON.stringify([SAND])') and not ev('GAME.editRun'))
pg.mouse.move(*blk(bbx - 4, fy - 12)); settle(150)
pg.keyboard.press('i'); settle(300)
check('I turns on the info overlay', ev('GAME.infoOverlay === true'))
check('the info overlay is named on screen and framed', 'Info overlay' in ev("$('ovbadges').textContent") and 'info' in ev("$('ovframe').className"), pg.inner_text('#ovbadges'))
pg.screenshot(path=str(out / '07f-filter-tags.png'))
pg.keyboard.press('i'); settle(100)
check('and off again', ev('!GAME.infoOverlay'))
# 8. zoom range
for _ in range(12): pg.mouse.wheel(0, -100); pg.wait_for_timeout(20)
check('zoom reaches 12', ev('SET.zoom') == 12, ev('SET.zoom'))
pg.screenshot(path=str(out / '08-zoom12.png'))
for _ in range(8): pg.mouse.wheel(0, 100); pg.wait_for_timeout(20)
# 9. notebook + save + quit + continue
pg.keyboard.press('n'); settle(200)
check('notebook opens', ev("!$('m-notebook').hidden"))
ev("nbTab = 'Pieces'; renderNotebook()"); settle(100)
pg.screenshot(path=str(out / '09-notebook-pieces.png'))
ev("nbTab = 'Ledger'; renderNotebook()"); settle(100)
pg.screenshot(path=str(out / '09b-notebook-ledger.png'))
ev("nbTab = 'Blueprints'; renderNotebook()"); settle(100)
pg.screenshot(path=str(out / '09c-notebook-blueprints.png'))
pg.locator('[data-starter]').first.click(); settle(150)
check('starter design ready to place', ev("!!GAME.clip && GAME.paste && GAME.clip.molds.length === 1"))
pg.mouse.move(*blk(bbx + 2, bby - 16)); settle(200)
pg.screenshot(path=str(out / '09d-starter-ghost.png'))
pg.keyboard.press('Escape'); settle(100) # stop pasting
pg.keyboard.press('n'); settle(150)
pg.keyboard.press('Escape'); settle(100)
pg.keyboard.press('Escape'); settle(100)
check('pause opens', ev("!$('m-pause').hidden"))
pg.click('#pm-save'); settle(800)
bag_before = ev('bagTotal'); blocks_before = ev('blockP.reduce((a,v)=>a+(v>0),0)')
pg.click('#pm-title'); settle(1200)
check('back at title with continue', ev("!$('title').hidden && !$('btn-continue').hidden"))
pg.click('#btn-continue'); pg.wait_for_function('GAME.running === true', timeout=10000); settle(500)
check('continue restores bag and blocks', ev('bagTotal') == bag_before and ev('blockP.reduce((a,v)=>a+(v>0),0)') == blocks_before, f"{bag_before}->{ev('bagTotal')}")
# 9b. selling: goods sell the moment they drop into the dock; orders pay a lump sum and send a truck
ev("SET.cardSeconds = 9; orders = []; nextOrderT = 1e12; R.floaters = [];")
bot_home = ev('[bot.x, bot.y]')
ev("{ const dk = dock(); bot.x = dk.x + 12; bot.y = dk.y - 26; R.snapNext = true; }"); settle(400)
m0 = ev('money')
ev("{ const dk = dock(); for (let y = dk.y + 2; y < dk.y + 8; y++) for (let x = dk.x + 2; x < dk.x + 22; x++) setCell(y*W+x, (x+y)&1 ? CUORE : COAL, AMB[y], 0); }")
pg.wait_for_function(f'money > {m0} + 50', timeout=10000); settle(150)
check('goods are paid for as they drop through the grate', ev('money') > m0 + 50, f"{m0} -> {ev('money')}")
try: pg.wait_for_function('(R.floaters || []).length > 0', timeout=4000)
except Exception: pass
check('a "+$" floats up from the dock', ev('(R.floaters || []).length') > 0, ev('JSON.stringify([floatAcc, EV.length, tick, GAME.paused])'))
pg.screenshot(path=str(out / '09c-dock-sale.png'))
settle(300)
check('the wallet shows the new money', pg.locator('#w-money').inner_text().replace(',', '') == '$' + str(round(ev('money'))), pg.locator('#w-money').inner_text())
ev("seen[BARCU] = 1; orders.push({ id: 77, m: BARCU, qty: 2, got: 0, mult: 2, pay: 160, due: tick + 36000, mins: 10 }); orders.push({ id: 78, m: CUORE, qty: 300, got: 120, mult: 1.8, pay: 540, due: tick + 9000, mins: 5 }); hudDirty = true;")
settle(200)
check('the wallet lists open orders', pg.locator('#w-orders .ord').count() == 2, pg.locator('#w-orders').inner_text())
pg.screenshot(path=str(out / '09d-wallet-orders.png'), clip={'x': 0, 'y': 0, 'width': 1280, 'height': 260})
m1 = ev('money')
ev("{ const dk = dock(); spawnBar(BARCU, dk.x + 6, dk.y + 2, 0, 0); spawnBar(BARCU, dk.x + 12, dk.y + 2, 0, 0); }")
pg.wait_for_function('ordersDone > 0', timeout=10000); settle(200)
check('filling an order pays its lump sum', abs(ev('money') - m1 - 160) < 1, f"{m1} -> {ev('money')}")
check('an order-filled card appears, with no truck in it', pg.locator('.card-pop', has_text='Order filled').count() >= 1 and pg.locator('.card-pop', has_text='truck').count() == 0)
ev(f"bot.x = {bot_home[0]}; bot.y = {bot_home[1]}; R.snapNext = true;"); settle(400)
pg.click('#wallet'); settle(250)
check('the wallet opens the Market page', ev("!$('m-notebook').hidden && nbTab === 'Market'"))
d0 = ev('debt'); pg.locator('[data-pay="all"]').click(); settle(200)
check('paying toward the claim lowers the debt', ev('debt') < d0 and ev('money') == 0, f"debt {d0} -> {ev('debt')}")
pg.screenshot(path=str(out / '09f-market.png'))
pg.keyboard.press('Escape'); settle(150)
# 10. mocked gamepad
ev('''() => {
const mk = () => ({ connected: true, axes: [0,0,0,0], buttons: Array.from({length:17}, () => ({pressed:false, value:0})) });
window.__pad = mk();
navigator.getGamepads = () => [window.__pad];
}''')
pg.keyboard.press('1'); settle(100)
d0 = ev('stats.dug')
ev("__pad.axes[3] = 0.9; __pad.buttons[7].pressed = true; __pad.buttons[7].value = 1;")
settle(1500)
ev("__pad.buttons[7].pressed = false; __pad.buttons[7].value = 0;")
settle(200)
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("__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)
check('gamepad d-pad moves menu focus', ev("document.activeElement && document.activeElement.tagName") == 'BUTTON')
ev("__pad.buttons[1].pressed = true;"); settle(80); ev("__pad.buttons[1].pressed = false;"); settle(80)
check('gamepad B closes menu', ev("$('m-pause').hidden"))
pg.screenshot(path=str(out / '10-after.png'))
# 10b. world files: export from the pause menu as an ordinary download, import on the title screen
pg.keyboard.press('Escape'); settle(150)
with pg.expect_download() as dl_info:
pg.click('#pm-export')
dl = dl_info.value
wf = json.loads(pathlib.Path(dl.path()).read_text())
check('Export world file downloads a world file', wf.get('format') == 'deepgrain-world' and wf['meta']['W'] == ev('W'), dl.suggested_filename)
fog_want = ev('Array.from(fog)')
check('world export includes fog bytes', wf['buffers']['fog']['type'] == 'Uint8Array')
want = ev('JSON.stringify([W, H, Math.round(money), Math.round(debt), bagTotal])')
pg3 = ctx.new_page()
pg3.on('pageerror', lambda e: logs.append(f'PAGEERROR(import): {e}'))
pg3.goto(url); pg3.evaluate('SET.fog = false'); pg3.wait_for_timeout(300)
worlds_before_import = ev('listWorlds().then(w => w.length)')
pg3.set_input_files('#importfile', dl.path())
pg3.wait_for_function('GAME.running === true', timeout=60000)
got = pg3.evaluate('JSON.stringify([W, H, Math.round(money), Math.round(debt), bagTotal])')
check('importing it on the title screen brings the same world back', got == want, f'{want} -> {got}')
pg3.wait_for_function("async () => (await listWorlds()).length === " + str(worlds_before_import + 1))
check('import adds an independent saved world', pg3.evaluate('GAME.worldId') != ev('GAME.worldId') and pg3.evaluate('listWorlds().then(w => w.length)') == worlds_before_import + 1)
check('world file keeps fog bytes', pg3.evaluate('Array.from(fog)') == fog_want)
pg3.close()
pg.keyboard.press('Escape'); settle(150)
# Fog has its own paused scene; existing play checks keep the whole world visible.
fb = p.chromium.launch(args=['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'])
fp = fb.new_page(viewport={'width': 1280, 'height': 800})
fp.on('pageerror', lambda e: logs.append(f'PAGEERROR(fog): {e}'))
fp.add_init_script('window.__allowSoftwareGL = true')
fp.goto(url)
fp.evaluate('startNewWorld(4242, 512, 384)')
fp.wait_for_function('GAME.running')
fp.evaluate("""() => {
GAME.paused = true; SET.fog = true; SET.look = 'pixel'; SET.zoom = 6; SET.lighting = 'off';
SET.cameraLead = false; SET.cameraSmooth = 0; resizeRender();
for (let y = 130; y < 280; y++) for (let x = 160; x < 350; x++) setCell(y * W + x, STONE, 20, F_NAT);
for (let y = 140; y < 205; y++) for (let x = 240; x < 252; x++) setCell(y * W + x, EMPTY, 20, 0);
for (let y = 192; y < 205; y++) for (let x = 240; x < 280; x++) setCell(y * W + x, EMPTY, 20, 0);
bot.x = 243; bot.y = 192; fog.fill(0); fogInit(); fogLook(bot.x + 2.5, bot.y + 2.5);
snapCamera(); GAME.hidePreviews = true; $('cards').replaceChildren();
}""")
fp.wait_for_timeout(300)
sample = "(() => { drawWorld(); const k = (235 - R.vy0) * R.bw + 300 - R.vx0; return R.px[k] & 0xffffff; })()"
check('unexplored pixel is out-of-world black', fp.evaluate(sample) == 0x0a0a0c)
check('sky starts seen and draws colour', fp.evaluate("(() => { const cy=R.camY; R.camY=0; drawWorld(); const c=R.px[20*R.bw+20]&0xffffff; R.camY=cy; drawWorld(); return fogSeen(300,0)===255 && c!==0x0a0a0c; })()"))
check('unexplored canvas pixel is black', fp.evaluate("(() => {const s=worldToScreenPx(300.5,235.5);return Array.from(R.ctx.getImageData(s.x,s.y,1,1).data).slice(0,3).every(v=>v<=12)})()"))
check('hover and cursor share only Unexplored', fp.evaluate("JSON.stringify(hoverInfo(300,235)) === JSON.stringify({t1:'Unexplored',t2:[],t3:[]})"))
fp.evaluate("GAME.aim = {x:300,y:235}; renderInfo()")
check('info box says only Unexplored', fp.locator('#info').inner_text() == 'Unexplored')
check('cursor draws only Unexplored', fp.evaluate("""() => {
const old=R.ctx.fillText, inside=GAME.mouse.in, texts=[];
R.ctx.fillText=function(t) { texts.push(t); }; GAME.mouse.in=true;
drawCursorLabel(500,400,1); R.ctx.fillText=old; GAME.mouse.in=inside;
return texts.length===1 && texts[0]==='Unexplored';
}"""))
fp.screenshot(path=str(out / 'fog-pixel.png'))
fp.evaluate('R.lens = true')
check('thermal lens hides unexplored grains', fp.evaluate(sample) == 0x0a0a0c)
fp.evaluate('R.lens = false; SET.fog = false')
check('fog toggle shows the same rock', fp.evaluate(sample) != 0x0a0a0c)
fp.evaluate("SET.fog = true; SET.look = 'smooth'; SM.t0 = performance.now() + 100000")
fp.wait_for_timeout(500)
check('fog draws in the smooth look', fp.evaluate('R.smoothOn && SM.ok'))
check('unseen smooth grains have no terrain shape', fp.evaluate("(() => {const k=(235-R.vy0)*R.bw+300-R.vx0;return R.px[k]===0x040a0a0c && R.backPx[k]===0x000a0a0c && R.treePx[k]===0 && R.fieldPx[k]===255})()"))
fp.screenshot(path=str(out / 'fog-smooth.png'))
fb.close()
# 11. the largest world from the title screen
pg2 = ctx.new_page()
pg2.on('pageerror', lambda e: logs.append(f'PAGEERROR(large): {e}'))
pg2.goto(url); pg2.evaluate('SET.fog = false'); pg2.wait_for_timeout(300)
check('the size menu offers small, medium and large, and no deep world', pg2.evaluate("[...document.querySelectorAll('#nw-size option')].map(o => o.value).join(',')") == '2048x1024,3072x1280,4096x1536', pg2.evaluate("[...document.querySelectorAll('#nw-size option')].map(o => o.value).join(',')"))
pg2.click('#btn-new'); pg2.fill('#nw-seed', '4242'); pg2.select_option('#nw-size', '4096x1536'); pg2.click('#btn-generate')
pg2.wait_for_function('GAME.running === true', timeout=90000)
check('large world generates', pg2.evaluate('W === 4096 && H === 1536'), pg2.evaluate('[W, H]'))
pg2.evaluate("bot.x = 2000; bot.y = magmaY[2000] - 60; snapCamera();"); pg2.wait_for_timeout(800)
pg2.screenshot(path=str(out / '11-deep-magma.png'))
pg2.close()
print('\n'.join(logs) or 'no console errors')
check('every status line is short (≤ 90 characters)', len(ev('_maxStatus')) <= 90, ev('_maxStatus'))
ev('quitToTitle()'); ev("openSettings('Game')")
before_wipe = ev('listWorlds().then(w => w.length)')
pg.click('#set-wipe'); pg.locator('#dlg-foot').get_by_role('button', name='Cancel', exact=True).click()
check('cancelling Delete all worlds keeps every world', ev('listWorlds().then(w => w.length)') == before_wipe)
pg.click('#set-wipe'); pg.locator('#dlg-foot').get_by_role('button', name='Delete all worlds', exact=True).click()
pg.wait_for_function("async () => await DB.get('worlds') === undefined && $('btn-continue').hidden && $('btn-worlds').hidden")
check('Delete all worlds clears the saves and index', ev("DB.op('readonly', st => st.getAllKeys()).then(keys => keys.length === 0)"))
print('FAILS:', fails)
b.close()
sys.exit(1 if fails else 0)