Files
deepgrain-foundry/test/play.py
T

1185 lines
97 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, re
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)
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;
}"""))
check('async generation preserves the world and yields frames with labels', pg.evaluate("""async () => {
const hash = a => { let h = 2166136261; for (const v of a) h = Math.imul(h ^ v, 16777619); return h; };
generateWorld(424242, 2048, 1024); const want = hash(mat);
let frames = 0, active = true, labels = [];
const frameCount = () => { if (active) { frames++; requestAnimationFrame(frameCount); } };
requestAnimationFrame(frameCount);
showLoading(true, 0, '', 'Generating world');
try {
await generateWorldAsync(424242, 2048, 1024, (f, s) => { showLoading(true, f, s); labels.push($('loadstep').textContent); });
return hash(mat) === want && frames > 1 && labels.includes('Settling loose ground') && labels.every(Boolean);
} finally { active = false; showLoading(false); }
}"""))
# Record progress at the browser's own frame boundaries, without slowing the operation.
pg.add_init_script("""
window.__loads = [];
const watch = () => { if (typeof GAME !== 'undefined' && GAME.loading) __loads.push([$('loading').querySelector('.label').textContent, $('loadstep').textContent, $('loadbar').style.width]); requestAnimationFrame(watch); };
requestAnimationFrame(watch);
""")
# 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 paints loading steps', pg.evaluate("__loads.some(([t, s]) => t === 'Loading world' && s === 'Building the world') && !GAME.loading && $('loading').hidden"))
check('Continue loads the migrated seed and playtime', pg.evaluate('worldSeed === 12345 && GAME.playTime >= 3661 && !!GAME.worldId'))
pg.evaluate("""() => {
window.__pack = packBuffers;
packBuffers = async b => { await new Promise(r => setTimeout(r, 600)); return __pack(b); };
window.__saving = saveWorld('main', true);
}""")
pg.locator('#saving').wait_for(state='visible')
pg.screenshot(path=str(out / 'saving-progress.png'))
check('slow save shows the saving chip', pg.is_visible('#saving'))
pg.evaluate('async () => { await __saving; packBuffers = __pack; }')
check('saving chip hides when the save ends', not pg.is_visible('#saving'))
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')")
auto_name = pg.input_value('#nw-name')
check(f'new world {seed} offers a two-word camp name', bool(re.fullmatch(r'[A-Z][a-z]+ [A-Z][a-z]+', auto_name)))
check(f'new world {seed} default matches its seed', auto_name == pg.evaluate("async () => claimName(parseInt($('nw-seed').value, 10), (await listWorlds()).map(w => w.name))"))
check('default name is selected for replacement', pg.evaluate("$('nw-name').selectionStart === 0 && $('nw-name').selectionEnd === $('nw-name').value.length"))
pg.fill('#nw-seed', '42')
first_name = pg.input_value('#nw-name')
pg.fill('#nw-seed', str(seed))
expected = pg.evaluate("async seed => claimName(seed, (await listWorlds()).map(w => w.name))", seed)
check(f'changing seed to {seed} updates the camp name', pg.input_value('#nw-name') == expected and expected != first_name)
names[seed] = 'Copper Valley' if seed == 222 else expected
if seed == 222:
pg.fill('#nw-name', names[seed])
pg.fill('#nw-seed', '42')
check('changing the seed keeps a typed world name', pg.input_value('#nw-name') == names[seed])
pg.fill('#nw-seed', str(seed))
pg.screenshot(path=str(out / 'new-world.png'))
pg.press('#nw-name' if seed == 222 else '#nw-seed', 'Enter')
pg.wait_for_function('GAME.running', timeout=20000)
check(f'new world {seed} paints a moving generation bar', pg.evaluate("__loads.some(([t, s, w]) => t === 'Generating world' && parseFloat(w) > 0 && parseFloat(w) < 90) && !GAME.loading"))
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 => claimName(777, w.map(w => w.name)))")
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]'))
# Maps use the real frame loop and keyboard, including settings persistence.
pg.wait_for_function('MAP.image && MAP.init >= Math.ceil(W / 32) * Math.ceil(H / 32)')
check('minimap lives in bottom right with its controls', ev("!!document.querySelector('#hud .br #minimap canvas') && ['mini-plus', 'mini-minus', 'mini-map'].every(id => $(id).tagName === 'BUTTON')"))
check('cards live in bottom centre above build bar', ev("$('cards').parentElement.matches('#hud .bc') && $('cards').nextElementSibling === $('buildbar')"))
ev('resetMap(); openMap()')
pg.wait_for_function('MAP.init >= Math.ceil(W / 32) * Math.ceil(H / 32)')
check('open map redraws when the initial cache fill finishes', ev("(() => { const c = $('map-canvas'), x = Math.floor(c.width / 2 + (W / 2 - MAP.x) * MAP.zoom), y = Math.floor(c.height / 2 + (10 - MAP.y) * MAP.zoom); return c.getContext('2d').getImageData(x, y, 1, 1).data[2] === 142; })()"))
pg.keyboard.press('Escape')
mz = ev('SET.minimapZoom')
pg.click('#mini-plus'); check('minimap plus zooms in', ev('SET.minimapZoom') == mz / 2)
pg.click('#mini-minus'); check('minimap minus restores saved zoom', ev("JSON.parse(localStorage.getItem('deepgrain.settings')).minimapZoom") == mz)
pg.keyboard.press('m'); check('M opens map and pauses', ev("!$('m-map').hidden && GAME.paused"))
pg.screenshot(path=str(out / 'map-full.png'))
z = ev('MAP.zoom'); pg.click('#map-plus'); check('full map zooms in', ev('MAP.zoom') > z)
pg.mouse.move(740, 400)
anchor = ev('[MAP.x + 100 / MAP.zoom, MAP.y]')
pg.mouse.wheel(0, -100); pg.wait_for_timeout(100)
check('wheel zoom stays anchored to the cursor', ev(f'Math.abs(MAP.x + 100 / MAP.zoom - {anchor[0]}) < 2'))
pan = ev('[MAP.x, MAP.y]')
pg.mouse.move(640, 400); pg.mouse.down(); pg.mouse.move(690, 430); pg.mouse.up()
check('left drag pans the map', ev(f'MAP.x < {pan[0]} && MAP.y < {pan[1]}'))
pg.click('#map-centre'); check('centre button finds bot', ev('Math.abs(MAP.x - bot.x) < 2'))
pg.keyboard.press('Escape'); check('Esc closes map', ev("$('m-map').hidden && !GAME.paused"))
pg.keyboard.press('m'); pg.keyboard.press('m'); check('M closes map', ev("$('m-map').hidden && !GAME.paused"))
muted = ev('SET.muted'); pg.keyboard.press('Shift+m'); check('Shift M toggles mute', ev('SET.muted') != muted)
pg.keyboard.press('Shift+m')
ev("openSettings('Display')"); pg.click('#set-minimap'); pg.wait_for_timeout(150)
check('Show minimap setting hides panel', not pg.is_visible('#minimap'))
pg.click('#set-minimap'); pg.keyboard.press('Escape')
check('map cache tracks nearby changed grains', ev("""async () => {
GAME.paused = true;
const x = Math.floor(bot.x / MAP_S) * MAP_S, y = Math.floor(bot.y / MAP_S) * MAP_S;
const saved = []; for (let yy = y; yy < y + MAP_S; yy++) for (let xx = x; xx < x + MAP_S; xx++) saved.push([xx, yy, mat[yy * W + xx], temp[yy * W + xx], flags[yy * W + xx]]);
const pixel = () => Array.from(MAP.ctx.getImageData(x / MAP_S, y / MAP_S, 1, 1).data).join(',');
const before = pixel(); for (const [xx, yy] of saved) setCell(yy * W + xx, GOLD, 20);
await new Promise(r => setTimeout(r, 200)); const changed = pixel() !== before;
for (const [xx, yy, m, t, fl] of saved) setCell(yy * W + xx, m, t, fl);
GAME.paused = false; return changed;
}"""))
ev('SET.fog = true'); pg.wait_for_function('MAP.fog && MAP.init >= Math.ceil(W / 32) * Math.ceil(H / 32)')
check('unexplored map pixel is black', ev("Array.from(MAP.ctx.getImageData(0, Math.floor((H - 32) / MAP_S), 1, 1).data).slice(0, 3).every(v => v === 0)"))
ev('SET.fog = false'); pg.wait_for_timeout(400)
pg.screenshot(path=str(out / 'map-hud.png'))
timings = ev("""async () => {
const sample = async on => { SET.minimap = on; let sum = 0; for (let n = 0; n < 150; n++) { await new Promise(requestAnimationFrame); if (n >= 30) sum += frameMs; } return sum / 120; };
const off = await sample(false), on = await sample(true); return [off, on];
}""")
check('frame comparison uses a Medium world', ev('W === 3072 && H === 1280'))
check('Medium minimap frame cost stays within 5 ms', timings[1] <= timings[0] + 5, f'off {timings[0]:.2f} ms; on {timings[1]:.2f} ms')
# 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(1000, 740); pg.wait_for_timeout(1000)
check('camera test pointer reaches the world', ev('GAME.mouse.in && GAME.mouse.x === 1000 && 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; })()'))
ev("SET.cardSeconds = 0; $('cards').replaceChildren()") # centred cards cover the mouse work area; discovery was checked above
# 2. vacuum, left button: lock onto what you click
pg.keyboard.press('2'); settle(100)
check('Pneumatic nozzle card title and width match the selected tool', ev("GAME.tool === 1 && $('selcard').querySelector('.name').textContent === 'Pneumatic nozzle' && $('selcard').firstElementChild.querySelector('.chip').textContent === `width ${GAME.brush[1]}` && !$('selcard').querySelector('.sub .chip')"))
check('Pneumatic nozzle name appears in the tool list and hotbar title', ev("TOOLS[1].name === 'Pneumatic nozzle' && $('hotbar').children[1].title.startsWith('Pneumatic nozzle')"))
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}")
# Drive one tick at a time so the pile cannot fall between the click and pickup.
ev("""window.vacCase = (mode, selection = []) => {
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(selection); 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, mode === 'dirt' ? LDIRT : CUORE, 20, 0);
if (selection.length) { setCell(y*W+x+2, COAL, 20, 0); setCell(y*W+x+3, CUORE, 20, 0); }
primaryPress(mode === 'shift');
let waiting = true;
if (mode === 'air') { toolTick(); waiting = selection.length ? GAME.vacFilter === -2 : GAME.vacFilter === 0 && bagTotal === 0; setCell(y*W+x, CUORE, 20, 0); setCell(y*W+x+1, LDIRT, 20, 0); }
toolTick();
if (selection.length) { GAME.aim = { x: x + 2, y }; toolTick(); }
if (mode === 'ore') { GAME.aim = { x: x + 1, y }; toolTick(); }
const result = { coal: bag[COAL], ore: bag[CUORE], dirt: bag[LDIRT], dirtStays: mat[y*W+x+1] === LDIRT, filter: GAME.vacFilter, selection: [...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);
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 leaves the selection empty', r['selection'] == [])
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', [COAL])")
check('Shift drag takes everything and keeps the selection', r['ore'] > 0 and r['dirt'] > 0 and r['filter'] == -1 and r['selection'] == [ev('COAL')])
for mode in ['dirt', 'air']:
r = ev(f"vacCase('{mode}', [COAL, CUORE])")
check(f'selected coal and ore are picked starting on {mode}, dirt stays', r['coal'] > 0 and r['ore'] > 0 and r['dirt'] == 0 and r['dirtStays'] and r['filter'] == -2 and r['selection'] == ev('[COAL, CUORE]'))
check('selecting Pneumatic nozzle leaves an empty selection empty', ev("(() => { const old = GAME.blow; GAME.blow = new Set(); selectTool(0); selectTool(1); renderSelCard(); const ok = !GAME.blow.size && $('selcard').querySelector('.vacchoice').textContent === 'nothing selected' && $('selcard').querySelector('.num.chip').textContent === 'none'; GAME.blow = old; return ok; })()"))
check('Q/E when selection is absent from the bag chooses first or last', ev("(() => { const old = GAME.blow, n = bag[SAND]; bag[SAND] = 0; GAME.blow = new Set([SAND]); cycleItem(-1); const last = blowList()[0] === bagMaterials().at(-1); GAME.blow = new Set([SAND]); cycleItem(1); const first = blowList()[0] === bagMaterials()[0]; bag[SAND] = n; GAME.blow = old; return first && last; })()"))
check('Q/E from an empty selection 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];
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;
})()"""))
pg.screenshot(path=str(out / '02-vacuum.png'))
ev('GAME.blow.clear(); hudDirty = true')
# 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):
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)')
suck_at(*ore)
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('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')
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("$('toasts').innerHTML = ''; GAME.aim = { x: Math.floor(bot.x), y: 1 }")
pg.keyboard.press('f'); settle(100)
check('F away from usable things says nothing to use', ev("$('m-dialog').hidden && GAME.status === 'Nothing to use there.'"))
check('F away from usable things shows exactly one info toast', ev("[...document.querySelectorAll('#toasts .toast.info')].filter(e => e.textContent === 'Nothing to use there.').length === 1"))
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)')
# t25: draw both nozzle effects without advancing the simulation RNG.
check('nozzle air streak previews draw and preserve the simulation RNG', ev("""(() => {
const aim = GAME.aim, filter = GAME.vacFilter, blow = GAME.blow, consumed = GAME.rmbConsumed;
const particles = SET.particles, fog = SET.fog, coal = bag[COAL];
try {
SET.particles = true; SET.fog = false;
GAME.aim = { x: Math.floor(bot.x + bot.w / 2 + 16), y: Math.floor(bot.y + bot.h / 2) };
GAME.vacFilter = -1; GAME.rmbConsumed = false;
bag[COAL] = 10; GAME.blow = new Set([COAL]);
const before = RS;
for (let i = 0; i < 5; i++) {
GAME.lmb = true; GAME.rmb = false; drawPreviews();
GAME.lmb = false; GAME.rmb = true; drawPreviews();
}
return toolId() === 'vacuum' && RS === before;
} finally {
GAME.lmb = false; GAME.rmb = false;
GAME.aim = aim; GAME.vacFilter = filter; GAME.blow = blow; GAME.rmbConsumed = consumed;
SET.particles = particles; SET.fog = fog; bag[COAL] = coal;
}
})()"""))
# 3. right button blows the selection; exhausted materials stay selected
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')
ev('GAME.blow.clear(); lastBagKey = ""; hudDirty = true'); settle(80)
pg.locator('#baglist .pick').filter(has_text='Coal').click(); settle(80)
hold(dx_, dy_, 400, 'right'); settle(400)
check('blowing the last coal deselects it', ev('bag[COAL] === 0 && !GAME.blow.size'))
check('a selection emptied by blowing picks up what you click again', ev("(() => { GAME.vacFilter = 0; primaryPress(false); const f = GAME.vacFilter; primaryRelease(false); return f !== -2; })()"))
check('a material selected before you have any stays selected', ev("(() => { GAME.blow = new Set([COAL]); const n = bagTotal, f = flyers.length; GAME.rmb = true; secondaryPress(false); toolTick(); GAME.rmb = false; return GAME.blow.has(COAL); })()"))
check('selected but none in bag blows nothing', ev("(() => { const n = bagTotal, f = flyers.length; GAME.rmb = true; secondaryPress(false); toolTick(); GAME.rmb = false; renderSelCard(); return bagTotal === n && flyers.length === f && GAME.status === 'None of the selected materials are in the bag.' && $('selcard').querySelector('.vacchoice').textContent === 'none in bag'; })()"))
check('nothing selected blows nothing and explains how to choose', ev("(() => { GAME.blow.clear(); const n = bagTotal, f = flyers.length; GAME.rmb = true; secondaryPress(false); toolTick(); GAME.rmb = false; return bagTotal === n && flyers.length === f && GAME.status === 'Nothing selected: click or Q/E in the bag.'; })()"))
ev("$('toasts').innerHTML = ''; GAME.blow.clear(); GAME.rmb = true; secondaryPress(false); for (let i = 0; i < 30; i++) toolTick(); GAME.rmb = false; renderSelCard()")
check('holding blow with nothing selected shows exactly one error toast', ev("[...document.querySelectorAll('#toasts .toast.err')].filter(e => e.textContent.includes('Nothing selected')).length === 1"))
check('the tool card has no status line', ev("document.querySelector('#selcard .status') === null"))
pg.wait_for_timeout(300)
pg.screenshot(path=str(out / '03b-empty-selection-toast.png'))
ev("""(() => {
$('toasts').innerHTML = '';
const tool = GAME.tool, aim = GAME.aim;
GAME.tool = 3; GAME.aim = { x: bot.x + 500, y: bot.y }; GAME.lmb = true;
for (let i = 0; i < 30; i++) toolTick();
GAME.lmb = false; GAME.tool = tool; GAME.aim = aim;
})()""")
check('holding the Igniter out of reach shows exactly one error toast', ev("[...document.querySelectorAll('#toasts .toast.err')].filter(e => e.textContent === 'Out of reach.').length === 1"))
check('a mix drops exhausted coal and continues blowing ore', 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(); const ore = bag[CUORE]; toolTick(); GAME.rmb = false; const ok = !bag[COAL] && ore > 0 && bag[CUORE] < ore && 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.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))"))
pg.screenshot(path=str(out / '03b-blow-all-preview.png'))
pg.keyboard.up('Shift'); settle(150)
check('releasing Shift restores the chosen blow material', ev("$('selcard').querySelector('.vacchoice').textContent.includes(MD[COAL].name) && !$('selcard').querySelector('.vacchoice').textContent.includes('Everything')"))
check('releasing Shift lights only coal', ev("(() => { const rows = [...$('baglist').querySelectorAll('.bagrow.on')]; return rows.length === 1 && rows[0].querySelector('.nm').textContent === MD[COAL].name; })()"))
bot_at = ev('[bot.x, bot.y]')
pg.keyboard.down('d'); pg.keyboard.down('Shift'); settle(150)
check('Shift while flying does not preview everything', 'Everything' not in pg.locator('#selcard .vacchoice').inner_text())
pg.keyboard.up('Shift'); pg.keyboard.up('d')
ev(f'bot.x = {bot_at[0]}; bot.y = {bot_at[1]}; bot.vx = bot.vy = 0;'); settle(150)
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 blows everything loose in the bag', ev('bag[CUORE]') < 60 and ev('bag[COAL]') < 60 and ev('GAME.blow.size === 1 && GAME.blow.has(COAL)'), ev('[bag[COAL], bag[CUORE]]'))
refill('bag[COAL] = 60; bag[CUORE] = 60'); ev('GAME.blow.clear(); lastBagKey = ""; hudDirty = true'); 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)'))
ore_row = pg.locator('#baglist .pick').filter(has_text='Copper ore')
ore_row.click(modifiers=['Control']); settle(80)
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)'))
pg.locator('#baglist .pick').filter(has_text='Copper ore').click(button='right'); settle(80)
check('right click on a bag row leaves the selection 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 selection', "Click: select only this · Ctrl+click: add to the selection (hold Ctrl to see everything you've found) · Q/E: next" in pg.locator('#bagfoot .baghint').inner_text())
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 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.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); })()"))
ev("seen[SAND] = 1; bagTotal -= bag[SAND]; bag[SAND] = 0; GAME.blow = new Set([COAL]); lastBagKey = ''; renderBag(); window.bagPositions = [...$('baglist').querySelectorAll('.bagrow:not(.none)')].map(e => e.getBoundingClientRect().top); window.dispatchEvent(new KeyboardEvent('keydown', {key:'Control', ctrlKey:true})); renderBag()")
check('Ctrl reveals known materials below stable in-bag rows', ev("""GAME.ctrl && !!$('baglist').querySelector('.bagsep') && !!$('baglist').querySelector('.bagrow.none .pick[title="Select Sand"]') && bagPositions.every((y, i) => y === $('baglist').querySelectorAll('.bagrow:not(.none)')[i].getBoundingClientRect().top)"""))
pg.screenshot(path=str(out / 'vacuum-ctrl-900.png'))
pg.locator('#baglist .none .pick[title="Select Sand"]').click(modifiers=['Control']); settle(80)
check('Ctrl click selects a material not in the bag and keeps its row after release', ev("GAME.blow.has(SAND) && !GAME.ctrl && !!$('baglist').querySelector('.bagrow.none.on .pick[title=\"Select Sand\"]')"))
check('drag picks the newly selected missing material', ev("(() => { const aim = GAME.aim, x = Math.floor(bot.x)+6, y = Math.floor(bot.y)-6, i = y*W+x, old = [mat[i],temp[i],flags[i]]; setCell(i,SAND,20,0); GAME.aim = {x,y}; GAME.lmb = true; primaryPress(false); toolTick(); primaryRelease(false); GAME.lmb = false; GAME.aim = aim; const ok = bag[SAND] > 0; setCell(i,...old); return ok; })()"))
# Ctrl held (keydown seen) but the click itself arrives without ctrlKey, as on some Linux setups: still adds
ev("GAME.blow = new Set([COAL]); seen[GRAVEL] = 1; bagTotal -= bag[GRAVEL]; bag[GRAVEL] = 0; window.dispatchEvent(new KeyboardEvent('keydown', {key:'Control', ctrlKey:true})); renderBag()")
pg.locator('#baglist .none .pick[title="Select Gravel"]').click(); settle(80)
check('a click while Ctrl is held adds even without ctrlKey on the click', ev('GAME.blow.has(COAL) && GAME.blow.has(GRAVEL)'), ev('[...GAME.blow]'))
ev("GAME.blow.delete(GRAVEL); GAME.blow.add(SAND)")
ev("GAME.blow.delete(SAND); bagTotal -= bag[SAND]; bag[SAND] = 0; window.dispatchEvent(new KeyboardEvent('keyup', {key:'Control'})); renderBag()")
check('Ctrl release hides unselected missing materials', ev("""!GAME.ctrl && !$('baglist').querySelector('.bagrow.none .pick[title="Select Sand"]')"""))
ev("window.dispatchEvent(new KeyboardEvent('keydown', {key:'Meta', metaKey:true})); renderBag()")
check('Meta reveals known materials too', ev("""GAME.ctrl && !!$('baglist').querySelector('.bagsep')"""))
ev("window.dispatchEvent(new KeyboardEvent('keyup', {key:'Meta'})); renderBag()")
check('Meta release hides unselected missing materials', ev('!GAME.ctrl'))
ev("window.dispatchEvent(new KeyboardEvent('keydown', {key:'Control', ctrlKey:true})); window.dispatchEvent(new Event('blur')); renderBag()")
check('blur clears the modifier', ev('!GAME.ctrl'))
r = ev("""(() => { const old = seen.slice(), sel = GAME.blow; for (const m of BAG_ORDER) seen[m] = 1; GAME.blow = new Set([COAL]); GAME.ctrl = true; lastBagKey = ''; renderBag();
const b = $('baglist'), f = $('bagfoot'), h = f.querySelector('.baghint'), c = f.querySelector('.bagclear'), fr = f.getBoundingClientRect(), br = b.getBoundingClientRect();
const out = [b.scrollHeight > b.clientHeight, !b.contains(h) && !b.contains(c), fr.top >= br.bottom - 1 && fr.bottom <= innerHeight, c.getBoundingClientRect().width >= f.clientWidth - 2, c.textContent];
seen.set(old); GAME.blow = sel; GAME.ctrl = false; lastBagKey = ''; renderBag(); return out; })()""")
check('a long Ctrl list scrolls while the hint and a full-width Clear stay visible below it', r[0] and r[1] and r[2] and r[3] and r[4] == 'Clear selection', r)
# Ctrl + wheel over the bag scrolls the list instead of zooming the page
r = ev("""(() => { const b = $('baglist'), mh = b.style.maxHeight; b.style.maxHeight = '40px'; b.scrollTop = 0;
const w = new WheelEvent('wheel', { deltaY: 30, ctrlKey: true, bubbles: true, cancelable: true }); b.querySelector('.pick').dispatchEvent(w);
const out = [w.defaultPrevented, b.scrollTop]; b.style.maxHeight = mh; b.scrollTop = 0; return out; })()""")
check('Ctrl + wheel over the bag scrolls it and never zooms the page', r[0] and r[1] > 0, r)
r = ev("(() => { const w = new WheelEvent('wheel', { deltaY: 30, ctrlKey: true, bubbles: true, cancelable: true }); $('selcard').dispatchEvent(w); return w.defaultPrevented; })()")
check('Ctrl + wheel over other HUD panels never zooms the page', r)
pg.locator('[data-bag-clear]').click(); settle(80)
check('Clear clears the selection', ev('!GAME.blow.size'))
pg.locator('#baglist .pick').filter(has_text='Coal').click(); settle(80)
pg.locator('#baglist .pick').filter(has_text='Coal').click(); settle(80)
check('plain click on the only selected row clears it', ev('!GAME.blow.size'))
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('selecting a bar replaces the mix', ev('GAME.blow.size === 1 && GAME.blow.has(BARCU)'))
ev("toggleBlow(COAL)")
check('selecting a grain replaces a bar', ev('GAME.blow.size === 1 && GAME.blow.has(COAL)'))
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)
ev("$('toasts').innerHTML = ''")
pg.mouse.click(*cell_to_screen(bx, gy - 20), button='middle'); settle(120)
check('empty middle-click pick shows its status in one info toast', ev("GAME.status === 'Nothing built here to pick.' && [...document.querySelectorAll('#toasts .toast.info')].filter(e => e.textContent === GAME.status).length === 1"))
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 stays quiet without a toast', ev("$('m-dialog').hidden") and ev("$('toasts').children.length") == n_toasts)
ev("closeModal('m-dialog')")
check('F with vacuum still uses what is in reach', ev("""(() => {
$('toasts').innerHTML = '';
const old = interactAt; interactAt = () => 'Gate opened.';
try { doInteract(); } finally { interactAt = old; }
return $('m-dialog').hidden && GAME.status === 'Gate opened.';
})()"""))
check('a successful F interaction shows its message exactly once', ev("[...$('toasts').children].filter(e => e.textContent === 'Gate opened.').length === 1"))
settle(1500)
pg.screenshot(path=str(out / '06-bars.png'))
pg.keyboard.press('2'); settle(60)
ev('GAME.blow.clear(); lastBagKey = ""; hudDirty = true')
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 hoppers 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', 'grinder', 'saw']))
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'))
# 7a. hopper blocks: the Machines tab has Grinder and Saw instead of the Refiner and Stone cutter; drag a row
check('the Machines tab has Grinder, Saw, Press and Bar rack', ev("(() => { const ids = BUILD_CATS.find(c => c[0] === 'Machines')[1]; return ids.includes('grinder') && ids.includes('saw') && ids.includes('press') && ids.includes('rack') && !ids.includes('refiner') && !ids.includes('cutter'); })()"))
ev('money = 1000')
check('Bar rack has a Machines menu tile', select('3', "piece().id === 'rack'") and pg.locator('[data-piece="rack"]').count() == 1)
ok = select('3', "piece().id === 'grinder'")
ev(f"for (let y = {(bby+3)*4}; y < {(bby+4)*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 + 3), lambda: blk(bbx - 4, bby + 3))
check('dragging a row of Grinders places three blocks', ok and ev(f'(() => {{ let n = 0; for (let bx = {bbx} - 6; bx <= {bbx} - 4; bx++) if (blockP[({bby} + 3) * BW + bx] === BP_GRIND) n++; return n; }})()') == 3, ev('GAME.status'))
pg.keyboard.press('Control+z'); settle(200)
check('Ctrl+Z takes the Grinder row back', ev(f'(() => {{ for (let bx = {bbx} - 6; bx <= {bbx} - 4; bx++) if (blockP[({bby} + 3) * BW + bx]) return false; return true; }})()'))
check('drawing Grinder wheels and the Saw blade leaves the sim RNG unchanged', ev(f'''(() => {{
const bx = {bbx} - 6, by = {bby} + 3;
const grinder = placeBlock(BP_GRIND, 0, bx, by, true), saw = placeBlock(BP_SAW, 0, bx + 1, by, true);
try {{
rebuildProcessors();
const before = RS;
drawProcBlocks();
return grinder === '' && saw === '' && RS === before;
}} finally {{ removeBlock(bx, by); removeBlock(bx + 1, by); rebuildProcessors(); }}
}})()'''))
check('rack hover shows stored kinds, capacity and gate', ev(f'''(() => {{
const bx = {bbx} - 6, by = {bby} + 3;
if (placeBlock(BP_RACK, 0, bx, by, true)) return false;
try {{
const s = rackState.get(by * BW + bx); rackPush(s, BARCU, 2); rackPush(s, BARFE, 1);
const before = RS; drawRackBlocks();
const h = hoverInfo(bx * BS, by * BS);
interactAt(bx * BS, by * BS);
const opened = hoverInfo(bx * BS, by * BS);
return RS === before && h.t1 === 'Bar rack' && h.t2.includes('3 / 50 bars') && h.t2.some(t => t.includes('2 copper bar')) && h.t2.some(t => t.includes('1 iron bar')) && h.t2.includes('closed: F lets bars out') && opened.t2.includes('open: letting bars out');
}} finally {{ removeBlock(bx, by); }}
}})()'''))
# 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)
check('saved buffers are compressed and smaller than raw buffers', ev("""async () => {
const sv = await DB.get(`${GAME.worldId}:main`), raw = serializeWorld().buffers;
return Object.values(sv.buffers).every(b => b.gz === true) &&
Object.values(sv.buffers).reduce((n, b) => n + b.bytes.byteLength, 0) <
Object.values(raw).reduce((n, b) => n + b.byteLength, 0);
}"""))
check('buffer packing preserves types and subarray bounds', ev("""async () => {
for (const C of [Uint8Array, Uint8ClampedArray, Int8Array, Uint16Array, Int16Array, Uint32Array, Int32Array, Float32Array, Float64Array, BigInt64Array, BigUint64Array]) {
const a = new C(4); a[1] = C === BigInt64Array || C === BigUint64Array ? 17n : 17;
const b = (await unpackBuffers(await packBuffers({a: a.subarray(1, 3)}))).a;
if (b.constructor !== C || b.length !== 2 || b[0] !== a[1] || b[1] !== a[2]) return false;
}
try { await unpackBuffers({a: {gz: true, type: 'constructor'}}); return false; }
catch (e) { return e.message.includes('Unknown saved buffer type'); }
}"""))
check('packing falls back to raw arrays without CompressionStream', ev("""async () => {
const cs = window.CompressionStream, a = new Uint16Array([7, 42]);
try { window.CompressionStream = undefined; return (await unpackBuffers(await packBuffers({a}))).a === a; }
finally { window.CompressionStream = cs; }
}"""))
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')}")
check('normal load still restores an old raw save', ev("""async () => {
GAME.paused = true;
const s = serializeWorld(), id = GAME.worldId;
await DB.op('readwrite', st => st.put({...s, savedAt: Date.now() + 1000, playTime: 4567}, `${id}:main`));
const sv = await worldNewest(id);
bagTotal = 0; worldSeed = 0;
return await startFromSave(sv) && worldSeed === s.meta.seed && GAME.playTime === 4567 &&
bagTotal === s.meta.bag.reduce((n, v) => n + v, 0);
}"""))
# Careers use their own browser storage so the play-through stays untouched.
career_play_paused = ev('GAME.paused'); ev('GAME.paused = true')
cc = b.new_context(viewport={'width': 1280, 'height': 800})
cp = cc.new_page(); cp.on('pageerror', lambda e: logs.append(f'PAGEERROR: {e}'))
cp.goto(url); cp.wait_for_function("!$('title').hidden")
ev = cp.evaluate
ev("async () => { SET.cardSeconds = 9; SET.fog = false; await startNewWorld(424242, 512, 384, 'First claim'); GAME.paused = true; }")
check('fresh world has a career with claim 1', ev("async () => { const c = await DB.get('career:' + GAME.careerId); return c.claimNo === 1 && c.claims.length === 1 && c.claims[0] === GAME.worldId && (await listWorlds())[0].claimNo === 1; }"))
check('unpaid claim cannot open another', ev('async () => !await openNewClaim() && career.claims.length === 1'))
ev("openNotebook('Claims')")
cp.wait_for_function("$('nb-body').textContent.includes('paid off.')")
check('Claims explains the unpaid requirement', ev("!$('claim-open') && $('nb-body').textContent.includes('Machines on other claims wait')"))
ev("debt = 0; money = 4999; renderNotebook()")
cp.wait_for_function("$('claim-open') && $('claim-open').disabled")
check('Claims disables opening without the down payment', ev("$('claim-open').disabled && $('nb-body').textContent.includes('You need $5,000')"))
check('insufficient funds cannot open another claim', ev('async () => !await openNewClaim() && money === 4999'))
ev("""() => {
money = 10000; earned = 12345; bag.fill(0); bag[RSTONE] = 91; bag[COAL] = 13; bagTotal = 104;
player.bitTier = 2; seen[GOLD] = 1; ledger.dig = 42; stats.dug = 321;
for (let y = 36; y < 40; y++) for (let x = 40; x < 44; x++) setCell(y * W + x, EMPTY, 20, 0);
undoRecord('Wall', () => { const err = placeBlock(BP_WALL, RSTONE, 10, 9); if (err) throw new Error(err); });
window.claimTest = { id: GAME.worldId, seed: worldSeed, bag: Array.from(bag), career: GAME.careerId };
renderNotebook();
}""")
cp.wait_for_function("$('claim-open') && !$('claim-open').disabled")
cp.click('#claim-open'); cp.locator('#dlg-foot').get_by_role('button', name='Cancel', exact=True).click()
check('opening a claim asks for confirmation', ev('career.claims.length === 1 && money === 10000'))
cp.click('#claim-open'); cp.locator('#dlg-foot').get_by_role('button', name='Open claim', exact=True).click()
cp.wait_for_function('career.claims.length === 2 && !claimBusy', timeout=20000)
ev('GAME.paused = true')
check('new claim carries money, bag, upgrades and knowledge', ev("money === 5000 && earned === 12345 && JSON.stringify(Array.from(bag)) === JSON.stringify(claimTest.bag) && player.bitTier === 2 && seen[GOLD] === 1 && ledger.dig === 42 && stats.dug === 321"))
check('claim 2 has doubled debt, a new seed and the same dimensions', ev('debt === 40000 && worldSeed !== claimTest.seed && W === 512 && H === 384'))
check('claim 2 is stored with its career and name', ev("async () => { const c = await DB.get('career:' + GAME.careerId); const w = (await listWorlds()).find(w => w.id === GAME.worldId); return c.claims.length === 2 && c.claimNo === 2 && w.name !== 'Claim 2' && /^[A-Z][a-z]+ [A-Z][a-z]+$/.test(w.name) && w.name === GAME.worldName && w.career === c.id && w.claimNo === 2; }"))
check('new claim shows its arrival card', ev("$('cards').textContent.includes(GAME.worldName) && $('cards').textContent.includes('Claim 2.') && $('cards').textContent.includes('$40,000')"))
ev("openNotebook('Claims')")
cp.wait_for_function("$('nb-body').querySelectorAll('[data-travel]').length === 1")
check('Claims lists both claims with here, size and paid off', ev("$('nb-body').textContent.includes(GAME.worldName + ' · here') && $('nb-body').textContent.includes('Claim 2') && $('nb-body').textContent.includes('512×384') && $('nb-body').textContent.includes('paid off ✓')"))
check('camp names are stable and varied', ev("claimName(424242) === claimName(424242) && new Set(Array.from({length: 20}, (_, i) => claimName(i))).size >= 16"))
check('camp names skip taken names and repeated words', ev("""() => {
const names = Array.from({length: 1000}, (_, i) => claimName(i));
return names.every(n => n.split(' ')[0] !== n.split(' ')[1]) && claimName(42, [claimName(42)]) !== claimName(42);
}"""))
check('every claim has a rename button', cp.locator('[data-rename]').count() == 2)
cp.locator('[data-rename="' + ev('GAME.worldId') + '"]').click()
check('rename is prefilled with the current name', cp.locator('#claim-name').input_value() == ev('GAME.worldName'))
cp.fill('#claim-name', 'Silver Camp')
cp.locator('#dlg-foot').get_by_role('button', name='Cancel', exact=True).click()
check('cancel keeps the claim name', ev("GAME.worldName !== 'Silver Camp'"))
cp.locator('[data-rename="' + ev('GAME.worldId') + '"]').click()
cp.fill('#claim-name', ' Silver Camp ')
cp.locator('#dlg-foot').get_by_role('button', name='Save', exact=True).click()
cp.wait_for_function("$('nb-body').textContent.includes('Silver Camp · here')")
check('renaming the current claim updates storage and game', ev("async () => GAME.worldName === 'Silver Camp' && (await listWorlds()).find(w => w.id === GAME.worldId).name === 'Silver Camp'"))
cp.screenshot(path=str(out / '09-claims.png'))
cp.click('[data-travel]'); cp.locator('#dlg-foot').get_by_role('button', name='Cancel', exact=True).click()
check('travelling asks for confirmation', ev('GAME.worldId !== claimTest.id'))
cp.click('[data-travel]'); cp.locator('#dlg-foot').get_by_role('button', name='Travel', exact=True).click()
cp.wait_for_function('GAME.worldId === claimTest.id && !claimBusy', timeout=20000)
ev('GAME.paused = true')
check('travel restores old buildings with shared money and bag', ev('blockP[9 * BW + 10] === BP_WALL && money === 5000 && JSON.stringify(Array.from(bag)) === JSON.stringify(claimTest.bag) && player.bitTier === 2 && debt === 0'))
ev("openNotebook('Claims')")
cp.wait_for_function("$('claim-open') && !$('claim-open').disabled")
check('returning to claim 1 still offers claim 3 at $80,000', ev("$('claim-open').textContent.includes('$80,000')"))
cp.screenshot(path=str(out / '09-claims-open.png'))
ev('quitToTitle()'); cp.wait_for_function("!$('btn-continue').hidden")
cp.click('#btn-continue'); cp.wait_for_function('GAME.running')
ev('GAME.paused = true')
check('Continue keeps the career state', ev('GAME.worldId === claimTest.id && GAME.careerId === claimTest.career && money === 5000 && player.bitTier === 2 && JSON.stringify(Array.from(bag)) === JSON.stringify(claimTest.bag)'))
check('failed claim save keeps the old world and money', ev("""async () => {
const op = DB.op; let writes = 0;
DB.op = function(mode, fn) { if (mode === 'readwrite' && ++writes === 2) return Promise.reject(new Error('test full')); return op.call(this, mode, fn); };
try { return !await openNewClaim() && GAME.worldId === claimTest.id && money === 5000 && debt === 0 && blockP[9 * BW + 10] === BP_WALL && career.claims.length === 2; }
finally { DB.op = op; }
}"""))
check('old world gets a career without changing its money or bag', ev("""async () => {
const s = serializeWorld(), id = newWorldId();
s.meta.economy.money = 777; s.meta.bag[RSTONE] = 37;
await putWorldEntry({ id, name: 'Old world', seed: s.meta.seed, w: W, h: H });
await DB.put(id + ':main', { ...s, savedAt: Date.now() });
await startFromSave(await worldNewest(id)); GAME.paused = true;
const c = await DB.get('career:' + GAME.careerId);
return money === 777 && bag[RSTONE] === 37 && c.claimNo === 1 && c.claims[0] === id;
}"""))
check('broken career data falls back to the world state', ev("""async () => {
await saveWorld('main', true); const id = GAME.worldId, cid = GAME.careerId;
const c = await DB.get('career:' + cid);
for (const bad of [{ ...c, bag: ['bad'] }, { ...c, player: { ...c.player, bitTier: 999 } }, { ...c, stats: [] }, { ...c, money: Infinity }, { ...c, seen: [3] }, { ...c, ledger: null }, undefined]) {
if (bad) await DB.put('career:' + cid, bad); else await DB.del('career:' + cid);
if (!await startFromSave(await worldNewest(id)) || money !== 777 || bag[RSTONE] !== 37) return false;
GAME.paused = true;
}
return true;
}"""))
check('deleting claims updates membership and deletes the last career', ev("""async () => {
const cid = claimTest.career, c = await DB.get('career:' + cid);
await deleteWorld(c.claims[0]);
const left = await DB.get('career:' + cid);
if (left.claims.length !== 1 || left.claimNo !== 2) return false;
await deleteWorld(left.claims[0]);
return await DB.get('career:' + cid) === undefined;
}"""))
cc.close(); ev = pg.evaluate
ev(f'GAME.paused = {json.dumps(career_play_paused)}')
# 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.blow = new Set([COAL]); __pad.buttons[1].pressed = true; pollPad(); __pad.buttons[1].pressed = false; pollPad()")
check('gamepad B clears the nozzle selection', ev('!GAME.blow.size'))
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'))
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("[...$('toasts').children].some(e => e.textContent === 'World imported.')")
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 black', fp.evaluate(sample) == 0)
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) == 0)
fp.evaluate('R.lens = false; SET.fog = false')
check('fog toggle shows the same rock', fp.evaluate(sample) != 0)
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]===0x04000000 && R.backPx[k]===0 && 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, large and huge', pg2.evaluate("[...document.querySelectorAll('#nw-size option')].map(o => o.value).join(',')") == '2048x1024,3072x1280,4096x1536,6144x2048', pg2.evaluate("[...document.querySelectorAll('#nw-size option')].map(o => o.value).join(',')"))
check('Medium remains the default size', pg2.input_value('#nw-size') == '3072x1280')
pg2.click('#btn-new'); pg2.fill('#nw-seed', '4242'); pg2.select_option('#nw-size', '6144x2048'); pg2.click('#btn-generate')
pg2.wait_for_function("GAME.loading && parseFloat($('loadbar').style.width) > 10 && parseFloat($('loadbar').style.width) < 85", timeout=30000)
pg2.screenshot(path=str(out / 'generation-progress.png'))
pg2.wait_for_function('GAME.running === true', timeout=90000)
check('huge world generates', pg2.evaluate('W === 6144 && H === 2048'), 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)