- Tubes run in eight directions: a drag at about 45° lays a diagonal run, R cycles all eight. - Diagonal runs carry grains at the straight rate (120 grains/s), join straight tubes and belts, keep grains in at the corners, pass water, and carry bars level. - New part ids 11-14 (saved ids are never renumbered); tube checks use an IS_TUBE table. - New test/t_diag_tubes.js (85 checks). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1241 lines
101 KiB
Python
1241 lines
101 KiB
Python
"""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;
|
||
}
|
||
})()"""))
|
||
|
||
check('out-of-reach blow preview sits at the capped nozzle target', ev("""(() => {
|
||
const aim = GAME.aim, consumed = GAME.rmbConsumed, arc = R.ctx.arc;
|
||
const arcs = [];
|
||
try {
|
||
GAME.aim = { x: bot.x + bot.w / 2 + 3 * REACH, y: bot.y + bot.h / 2 };
|
||
GAME.rmb = true; GAME.rmbConsumed = false;
|
||
R.ctx.arc = function (x, y, r, ...rest) { arcs.push({ x, y, r }); return arc.call(this, x, y, r, ...rest); };
|
||
drawPreviews();
|
||
const nz = nozzle(Math.floor(GAME.aim.x) + 0.5, Math.floor(GAME.aim.y) + 0.5);
|
||
const target = worldToScreenPx(nz.ox + nz.dx * nz.dist, nz.oy + nz.dy * nz.dist);
|
||
return arcs.some(p => Math.abs(p.x - target.x) < 0.01 && Math.abs(p.y - target.y) < 0.01 && Math.abs(p.r - (GAME.brush[1] + 0.5) * R.zp) < 0.01);
|
||
} finally {
|
||
R.ctx.arc = arc; GAME.aim = aim; GAME.rmb = false; GAME.rmbConsumed = consumed;
|
||
}
|
||
})()"""))
|
||
|
||
# 3. right button blows the selection; exhausted materials are deselected
|
||
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})'))
|
||
|
||
# Slopes use the same mouse drag path as walls, with T for their corner.
|
||
pg.click('[data-piece="slope"]'); settle(60)
|
||
check('Sloped wall is next to Wall in the build menu', ev("piece().id === 'slope' && BUILD_CATS[0][1].slice(0,2).join(',') === 'wall,slope'"))
|
||
ev('GAME.wallMat = RSTONE; GAME.pieceDir.slope = 0')
|
||
drag(lambda: blk(bbx - 5, bby - 12), lambda: blk(bbx - 2, bby - 9))
|
||
check('diagonal slope drag places four oriented blocks', ev(f'Array.from({{length:4}}, (_,k)=>blockP[({bby}-12+k)*BW+{bbx}-5+k]).every(c=>c===BP_SLOPE_BL)'))
|
||
pg.keyboard.press('Control+z'); settle(60)
|
||
pg.keyboard.press('t'); settle(60)
|
||
check('T cycles the single slope corner', ev('GAME.pieceDir.slope === 1'))
|
||
pg.mouse.move(*blk(bbx - 5, bby - 12)); settle(60); pg.mouse.down()
|
||
pg.mouse.move(*blk(bbx - 2, bby - 9)); settle(60)
|
||
pg.keyboard.press('t'); settle(60); pg.mouse.up(); settle(60)
|
||
check('T flips a diagonal slope above its face', ev(f'Array.from({{length:4}}, (_,k)=>blockP[({bby}-12+k)*BW+{bbx}-5+k]).every(c=>c===BP_SLOPE_TR)'))
|
||
pg.keyboard.press('Control+z'); settle(60)
|
||
pg.click('[data-piece="wall"]'); settle(60)
|
||
drag(lambda: blk(bbx - 5, bby - 12), lambda: blk(bbx - 2, bby - 9))
|
||
check('square wall drag can also place a diagonal', ev(f'Array.from({{length:4}}, (_,k)=>blockP[({bby}-12+k)*BW+{bbx}-5+k]).every(c=>c===BP_WALL)'))
|
||
pg.keyboard.press('Control+z'); settle(60)
|
||
|
||
# Diagonal tubes use real mouse placement, preview arrows, R and picking.
|
||
check('select screw tube', select('3', "piece().id === 'tube'"))
|
||
ev('GAME.pieceDir.tube = 0')
|
||
directions = []
|
||
for _ in range(8):
|
||
directions.append(ev('GAME.pieceDir.tube'))
|
||
pg.keyboard.press('r'); settle(40)
|
||
check('R cycles eight tube directions', directions == list(range(8)) and ev('GAME.pieceDir.tube') == 0)
|
||
drag(lambda: blk(bbx - 5, bby - 12), lambda: blk(bbx - 2, bby - 9))
|
||
check('diagonal tube drag places four down-right blocks', ev(f'Array.from({{length:4}}, (_,k)=>[blockP[({bby}-12+k)*BW+{bbx}-5+k],blockV[({bby}-12+k)*BW+{bbx}-5+k]]).every(([c,v])=>c===BP_TUBE && v===5)'))
|
||
pg.mouse.move(*blk(bbx - 4, bby - 11)); settle(60)
|
||
check('diagonal hover names its direction', ev(f"hoverInfo(({bbx}-4)*4+1,({bby}-11)*4+1).t1.includes('down-right')"))
|
||
pg.mouse.click(*blk(bbx - 4, bby - 11), button='middle'); settle(60)
|
||
check('picking a diagonal tube keeps its direction', ev("piece().id === 'tube' && GAME.pieceDir.tube === 5"))
|
||
pg.screenshot(path=str(out / '04-diagonal-tubes.png'))
|
||
pg.keyboard.press('Control+z'); settle(60)
|
||
|
||
# 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")
|
||
pg.mouse.move(*cell_to_screen(ev('molds[0].x') + 3, ev('molds[0].y') + 3)); settle(150)
|
||
# count toasts made by the key press itself: counting the ones on screen also caught an older toast expiring
|
||
ev("window._toastsMade = []; window._toastFn = toast; toast = function (t, o) { _toastsMade.push(t); return _toastFn(t, o); }; 0")
|
||
pg.keyboard.press('f'); settle(150)
|
||
made = ev("(() => { toast = _toastFn; return _toastsMade; })()")
|
||
check('F on an automatic ingot mold stays quiet without a toast', ev("$('m-dialog').hidden") and made == [], made)
|
||
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)
|