diff --git a/docs/DESIGN.md b/docs/DESIGN.md index eaf361f..578712d 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -555,6 +555,21 @@ above, including: keeps its chunk awake to retry. Burning fuel beds keep their tuned settling behavior. Grain stamps, sealed corners, transport barriers and per-chunk random streams still apply. + +- **Vacuum-cut loose faces spread while falling (t93 reopened, 2026-10-28).** The owner + reports that stone-only and mixed faces still stand after vacuuming into settled piles. + Sticky-step retries alone do not address the descending column formed by always choosing + straight down first. A falling grain on an exposed vertical face, backed by supported + powder, prefers an open outward diagonal. With at least 12 grains of backing above it + and unmoved support below that backing, it spills across up to eight clear grains and + falls up to eight clear grains. This is a local pressure approximation: the falling + outer column must drain quickly enough to let the body of the pile release. Shallow + faces keep the one-grain step, preserving small heaps and conveyor feeds. Check the + whole horizontal/downward path for occupancy, parts and cleats, including other chunks. + Keep one stamped move per tick and the existing eight-grain worker write halo. + Resting heaps do not enter this path; world generation keeps its recorded settling rules + and seed layouts. + ## Ideas not built yet - **Concrete**, as a stronger wall material from rubble. diff --git a/docs/PROJECT_MAP.md b/docs/PROJECT_MAP.md index 3519537..1f3bff8 100644 --- a/docs/PROJECT_MAP.md +++ b/docs/PROJECT_MAP.md @@ -254,10 +254,10 @@ Exploration only: never changes grains or the random stream. - functions: fogAlloc, fogMark, fogSeen, fogInit, fogLook, fogRevealAround, fogRevealAll - variables: FOG, FOG_SENSE, FOG_SENSE_FADE, fog, FW, FH -### src/sim/cells.js (240 lines) +### src/sim/cells.js (272 lines) Cell helpers (setCell, move, enter) and the falling-sand movement of powders, liquids and gases (simMove). - functions: setCell, swapCells, swapCellData, move, enter, enterFree, powder, passable, liquid, liquidSearch, gas, wakeBotPowder, simMove, moveChunk -- variables: SORTP, SORTP_FUEL, LSTEP, LFILM, CORNER_SEAL +- variables: SORTP, SORTP_FUEL, LSTEP, LFILM, CORNER_SEAL, POWDER_FAN ### src/sim/heat.js (266 lines) Heat: conduction, melting and freezing, reactions, fire and air, steel. @@ -630,6 +630,7 @@ The frame loop and boot. - `test/harness.js`: Loads the simulation (the src/sim/ files listed in src/manifest.json, joined in order into one script, the same way build.py joins them) so scenarios can drive it headlessly in Node. Errors point at the real file and line (sim/pipes.js:42), not at the joined script. - `test/light-cases.js`: Lighting behaviour checks executed by the GPU browser harness. - `test/light-readback.js`: Read float source channels, including transmittance, from the WGSL light pass. +- `test/pile-cut.js`: Shared Node/browser reproduction: vacuum the foot of settled 60-grain heaps, one stroke per tick. - `test/play.py`: 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'. - `test/run_all.py`: Run the test suites and report. - `test/t_bar_stacking.js`: Resting bars snap into pairs of grains and tip off narrow supports. @@ -668,6 +669,8 @@ The frame loop and boot. - `test/t_smooth.py`: The smooth look: the WebGPU pass compiles and draws, and the thermal lens still works. - `test/t_stone.js`: Uses for waste rock and sand: glass bricks from a fan furnace, the Saw, the silo and builders' orders. - `test/t_threads.js`: Shared allocation, checkerboard independence, and real worker-count determinism. +- `test/t_vacuum_collapse.js`: Real nozzle cuts into settled heaps, inline and through the real Node worker pool. +- `test/t_vacuum_collapse.py`: Vacuum cuts in the real pixel frame loop with active browser workers and hardware graphics. - `test/t_water.js`: Water conservation, settling, ledge pouring, boundaries, pipes and phase changes. Tall worlds keep geothermal heat out of the ponds. - `test/t_weather.js`: Weather: no more springs; steam rises into clouds and rains back; showers; snow when it is freezing. - `test/t_world.js`: Trees from acorns, charcoal, brick, density, water/lava/snow/ice, full-bag refunds, drill reach, ballistic blowing, snowy mountains. diff --git a/docs/TESTING.md b/docs/TESTING.md index e716497..180bc42 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -78,6 +78,14 @@ checks waking within eight ticks separately from the probabilistic consumption o gamepad and world sizes. Screenshots go to `shots/play/`. Pixel look by default; `python3 test/play.py shots/play smooth` uses the smooth look. +- `test/t_vacuum_collapse.py` cuts settled stone and mixed heaps with one normal-width + vacuum stroke per simulation tick in the real frame loop. It uses the shared + `test/pile-cut.js` fixture, requires actual helper dispatch (forced for this small world), + checks both faces within 180 ticks, and saves time-series pixel screenshots in `shots/`. + `test/t_vacuum_collapse.js` uses the same cuts inline and with Node helpers, including + bot-near/far, grain conservation, equal material bytes and forced-awake resting stability. + It also checks a broad 120-grain wall within 600 ticks and a spill stopped by cleats + across chunk edges, where the source chunk's one-grain barrier flag is clear. - `test/t_smooth.py` checks WebGPU smooth shapes, seams, joins, fog and the thermal lens. - `test/t_gpu_golden.py` compares 28 cropped frozen reference PNGs (384x288, under `test/fixtures/golden/`) against WebGPU: renderer, joins, sky and four light scenes, diff --git a/docs/agent-log.md b/docs/agent-log.md index ea5162f..df39261 100644 --- a/docs/agent-log.md +++ b/docs/agent-log.md @@ -5,6 +5,59 @@ note or request it was for, what changed, the files touched, the tests run, and open or not verified. Keep each entry short; the git history has the details. + +## 2026-10-28: t93 reopened, vacuum-cut faces drain in columns (Codex) + +- Read AGENTS, the t93 task/log and 807217b; inspected both new owner screenshots. + The remaining reproduced cause is straight-down movement taking priority over outward + fall: an exposed edge stays a descending column, blocking the next column's diagonal + exits. This is a movement/scan-order bottleneck, not lost wakes in this reproduction. + `setCell` wakes the cut; worker wakes merge each checkerboard pass. A radius-6 vacuum, + one call per tick into a settled 60-grain RSTONE heap, leaves a 39-grain cliff; after + 120 ticks it is still 18, with exactly the same series when all chunks are forced awake. + RSTONE already has STICK=1. Inline and actual Node helpers agree, bot near or far. +- Added outward downward movement only for a falling exposed vertical face backed by + supported powder. Retain one downward step per tick, occupancy, parts, barriers and + burning-grain guard. Resting heaps do not enter this path. `POWDER_FAN` goes to workers + and is disabled during the existing legacy worldgen settle to preserve all seed hashes. + Rejected a horizontal-step experiment (forced-awake creep) and a broad falling-edge + rule (spread free-falling blocks too far); no old test expectation was changed. +- New shared `test/pile-cut.js`: two 60-grain triangular heaps, 600 settling ticks, + stone and stone/copper ore/iron ore/dirt, then actual radius-6 vacuum strokes. + New Node suite passes 40 checks: cuts inline/2 helpers, near/far bot, conservation, + exact inline/helper material bytes, initially stable heaps and 600 forced-awake + stable ticks afterward. Browser suite uses isolated HTTP, real NVIDIA Blackwell GPU, + real frame loop and two helpers with confirmed dispatch (small fixture forces dispatch). + Verdict is recorded at exactly tick 180. Control with face spreading disabled: + cliff stone/mixed 33/32 at cut end, 14/13 at 120, 8/5 at 180; browser assertion fails. + Fixed browser reaches <=2 by 120 and passes at 180. Earlier Node red run fails eight + collapse checks (its mixed fixture included clay); existing clay collapse checks pass. +- Full `python3 test/run_all.py --browser`: ALL PASSED, 34 headless + 9 browser suites, + including collapse 17, threads 12, vacuum 40, gameplay 361 and browser vacuum 1. + Log: `/tmp/t93b-all-browser.log`. No existing test was weakened. Browser regression + is included in the suite runner. Original seed hashes and liquid-sinking checks pass. +- Saved and inspected `shots/t93b-before.png` (control at cut end) and + `shots/t93b-after.png` (fixed after 180 ticks): vertical cut faces become slopes. + Fixed series: `shots/t93b-time-{0,60,120,180}.png`; matching control series: + `shots/t93b-control-time-{0,60,120,180}.png`. Browser control log: + `/tmp/t93b-browser-matching-control.log`. Small initial browser passes stayed inline + despite a pool existing; final regression requires actual `simPool.activeMax > 0`. +- Requested idle profile, real GPU, no concurrent tests: + `python3 tools/perf.py --scenes settled,heat --looks pixel --zooms 6 --compare tools/perf-after-t88.json`. + Rule-disabled control -> final total ms/tick: settled 1.9855 -> 2.0226 (+1.9%), + heat 2.7027 -> 2.7734 (+2.6%). Against historical t88: +12.0%/+5.7%. + Movement ms/tick 0.9639 -> 0.9818 / 1.3481 -> 1.3885; unchanged heat work also + varies 0.9412 -> 0.9593 / 1.2724 -> 1.3019. End awake counts identical, 425/494. + These timings show small run-to-run differences, not exact equality; historical settled + time remains over the profiler's 10% comparison threshold. Evidence: + `/tmp/t93b-idle-control.json`, `/tmp/t93b-idle-final.json`, `/tmp/t93b-idle-summary.log`. + An earlier control profile overlapped headless checks and was excluded from this comparison. +- Files: `src/sim/cells.js`, `src/sim/workers.js`, `src/sim/worldgen.js`, + `test/pile-cut.js`, `test/t_vacuum_collapse.js`, `test/t_vacuum_collapse.py`, + `test/run_all.py`, `docs/DESIGN.md`, `docs/TESTING.md`, generated project map, + t93 task and this log. No commit. Owner playtesting of their larger worlds remains open. + + ## 2026-10-28: t92 keep pass-through, restore original loose-grain behaviour (Codex) - Read the 7293787 then dadf62a t92 history and original drill in 90f2d3f. Restored the diff --git a/src/sim/cells.js b/src/sim/cells.js index 63effb2..70af7f5 100644 --- a/src/sim/cells.js +++ b/src/sim/cells.js @@ -77,11 +77,43 @@ const SORTP_FUEL = 0.003; // times as far for one (a two-times range strands the far end of a 200-grain-wide ledge). // World generation settles with both at 1 and corner sealing off, so seeds keep the same world. let LSTEP = 8, LFILM = 4; -let CORNER_SEAL = true; +let CORNER_SEAL = true, POWDER_FAN = true; function powder(i, x, y, m, barriers = cleatChunks[(y >> 5) * CW + (x >> 5)]) { if (y >= H - 1) return; const b = i + W; - if ((!barriers || (!cleatMask[i] && !cleatMask[b])) && enterFree(m, PH_POWDER, b, 0, 1)) { move(i, b, x, y, x, y + 1); return; } + if ((!barriers || (!cleatMask[i] && !cleatMask[b])) && enterFree(m, PH_POWDER, b, 0, 1)) { + // A falling face fans outward; dropping straight down keeps a packed column in its way. + if (POWDER_FAN && mat[b] === EMPTY && !part[i] && !(flags[i] & F_BURN) && x > 0 && x < W - 1) { + const dx = mat[i - 1] === EMPTY && PH[mat[i + 1]] === PH_POWDER ? -1 : + mat[i + 1] === EMPTY && PH[mat[i - 1]] === PH_POWDER ? 1 : 0; + if (dx && y > 0 && PH[mat[i - W]] === PH_POWDER && mat[i - W + dx] === EMPTY && + PH[mat[b - dx]] === PH_POWDER) { + // Deep faces spill across the falling column, within the eight-grain worker halo. + let depth = 0; + while (depth < 12 && y >= depth && PH[mat[i - dx - W * depth]] === PH_POWDER) depth++; + let reach = 0; + for (let step = 1; step <= (depth === 12 && stamp[b - dx] !== tickStamp ? 8 : 1); step++) { + const nx = x + dx * step, side = i + dx * step, j = side + W; + if (nx < 0 || nx >= W || mat[side] !== EMPTY || part[side] || + cleatMask[side] || !passable(m, PH_POWDER, side, dx, 0)) break; + if (mat[j] === EMPTY && !part[j] && !cleatMask[j] && + passable(m, PH_POWDER, j, dx, 1)) reach = step; + } + if (reach) { + let drop = 1; + if (depth === 12 && stamp[b - dx] !== tickStamp) { + for (let step = 2; step <= 8 && y + step < H; step++) { + const j = i + W * step + dx * reach; + if (mat[j] !== EMPTY || part[j] || cleatMask[j] || !passable(m, PH_POWDER, j, 0, 1)) break; + drop = step; + } + } + move(i, i + W * drop + dx * reach, x, y, x + dx * reach, y + drop); return; + } + } + } + move(i, b, x, y, x, y + 1); return; + } // a grain at least twice as dense works its way down through a loose pile. Unlit fuel // packs too tight for that, so a mixed furnace charge stays mixed until the fire gets in. const t = mat[b]; diff --git a/src/sim/workers.js b/src/sim/workers.js index 78439bd..d0fa5da 100644 --- a/src/sim/workers.js +++ b/src/sim/workers.js @@ -86,11 +86,11 @@ function chunkPasses(heat) { } } function threadParams() { - return { tick,tickStamp,worldSeed,LSTEP,LFILM,SORTP,CORNER_SEAL, + return { tick,tickStamp,worldSeed,LSTEP,LFILM,SORTP,CORNER_SEAL,POWDER_FAN, seen, bot: {...bot}, skyY: weather.skyY, barriers: !!cleatCells.length, filterCfg }; } function threadSetParams(s) { - ({tick,tickStamp,worldSeed,LSTEP,LFILM,SORTP,CORNER_SEAL,filterCfg} = s); + ({tick,tickStamp,worldSeed,LSTEP,LFILM,SORTP,CORNER_SEAL,POWDER_FAN,filterCfg} = s); seen.set(s.seen); Object.assign(bot, s.bot); weather.skyY = s.skyY; cleatCells = s.barriers ? [1] : []; } diff --git a/src/sim/worldgen.js b/src/sim/worldgen.js index 876d274..d7eb1bc 100644 --- a/src/sim/worldgen.js +++ b/src/sim/worldgen.js @@ -245,7 +245,7 @@ function* worldSteps(seedIn, w, h) { yield [0.75, 'Settling loose ground']; // settle loose material at the water and sorting speeds the seeds were made with awake.fill(3); - const lstep = LSTEP, lfilm = LFILM, sortp = SORTP, cornerSeal = CORNER_SEAL; LSTEP = LFILM = 1; SORTP = 0.003; CORNER_SEAL = false; + const lstep = LSTEP, lfilm = LFILM, sortp = SORTP, cornerSeal = CORNER_SEAL, powderFan = POWDER_FAN; LSTEP = LFILM = 1; SORTP = 0.003; CORNER_SEAL = POWDER_FAN = false; for (let k = 0; k < 260; k++) { tick++; tickStamp = (tickStamp % 255) + 1; simMove(); @@ -253,7 +253,7 @@ function* worldSteps(seedIn, w, h) { if (!any) break; if ((k & 31) === 0) yield [0.75 + 0.24 * k / 260, 'Settling loose ground']; } - LSTEP = lstep; LFILM = lfilm; SORTP = sortp; CORNER_SEAL = cornerSeal; + LSTEP = lstep; LFILM = lfilm; SORTP = sortp; CORNER_SEAL = cornerSeal; POWDER_FAN = powderFan; yield [0.99, 'Finishing']; // re-ambient after settling, then start clean for (let i = 0; i < N; i++) { const m = mat[i]; if (m !== MAGMA) temp[i] = AMB[(i / w) | 0]; life[i] = PH[m] === PH_LIQUID && FRZ[m] ? 255 : 0; } diff --git a/test/pile-cut.js b/test/pile-cut.js new file mode 100644 index 0000000..3afedc4 --- /dev/null +++ b/test/pile-cut.js @@ -0,0 +1,34 @@ +// Shared Node/browser reproduction: vacuum the foot of settled 60-grain heaps, one stroke per tick. +function pileCutWorld() { + alloc(768, 600); worldSeed = 7; RS = 123; + for (let x = 0; x < W; x++) { surfY[x] = 224; dirtY[x] = 234; graniteY[x] = basaltY[x] = magmaY[x] = H; } + computeAmbient(); fillNoise(); + for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) { + const i = y * W + x; mat[i] = y >= 224 ? STONE : EMPTY; + flags[i] = y >= 224 ? F_NAT : 0; temp[i] = AMB[y]; + } + bag.fill(0); bagTotal = 0; player.tankTier = 3; + resetWeather(); weather.next = 1e15; updateRoof(); +} +function pileCutSetup(peak, mixed) { + const rubble = [RSTONE, CUORE, FEORE, LDIRT]; + for (let y = 164; y < 224; y++) for (let x = peak - (y - 164); x <= peak + (y - 164); x++) + setCell(y * W + x, mixed ? rubble[(x + y) % rubble.length] : RSTONE, AMB[y], 0); +} +function pileCutStroke(peak) { + bot.x = peak - 50; bot.y = 170; bot.vx = bot.vy = 0; + for (let y = 223; y >= 164; y--) for (let x = peak - 100; x < peak; x++) { + if (PH[mat[y * W + x]] !== PH_POWDER) continue; + return toolVacuum(Math.min(peak - 7, x), y - 3, 6, () => true).got; + } + return 0; +} +function pileCutProfile(peak) { + let cliff = 0, previous = 0, grains = 0; + for (let x = peak - 110; x <= peak + 110; x++) { + let y = 0; while (y < 224 && PH[mat[y * W + x]] !== PH_POWDER) y++; + const height = 224 - y; cliff = Math.max(cliff, Math.abs(height - previous)); previous = height; + for (; y < 224; y++) if (PH[mat[y * W + x]] === PH_POWDER) grains++; + } + return {cliff, grains}; +} diff --git a/test/run_all.py b/test/run_all.py index ebb06ab..048777e 100644 --- a/test/run_all.py +++ b/test/run_all.py @@ -45,7 +45,7 @@ def main(): if browser: subprocess.run([sys.executable, str(ROOT / 'build.py')], cwd=ROOT, check=True) # one at a time: two headless browsers at once can starve each other and flake - for name in ['play.py', 't_smooth.py', 't_dev_console.py', 't_caps.py', 't_gpu.py', 't_gpu_golden.py', 't_gpu_light.py', 'firefox_smoke.py']: + for name in ['play.py', 't_smooth.py', 't_dev_console.py', 't_caps.py', 't_gpu.py', 't_vacuum_collapse.py', 't_gpu_golden.py', 't_gpu_light.py', 'firefox_smoke.py']: results.append(run_py(name)) bad = 0 for name, code, out, secs in results: diff --git a/test/t_vacuum_collapse.js b/test/t_vacuum_collapse.js new file mode 100644 index 0000000..8a24794 --- /dev/null +++ b/test/t_vacuum_collapse.js @@ -0,0 +1,59 @@ +// Real nozzle cuts into settled heaps, inline and through the real Node worker pool. +const fs = require('fs'); +const {createHash} = require('crypto'); +const {load, suite, enableThreads, flatWorld} = require('./harness'); +const {run} = load(), {check, done} = suite(); +run(fs.readFileSync(require.resolve('./pile-cut.js'), 'utf8')); +const hash = () => createHash('sha256').update(run('mat')).digest('hex'); +// The longer spill must check barriers even when its starting chunk has no cleats. +flatWorld(run, 96, 160, 80); +run(`for(let y=18;y<=31;y++)setCell(y*W+29,RSTONE,AMB[y],0); + setCell(29*W+30,RSTONE,AMB[29],0);setCell(30*W+30,RSTONE,AMB[30],0); + cleatMask[30*W+34]=1;cleatMask[34*W+33]=1; + powder(30*W+30,30,30,RSTONE);`); +check('face spill stops before cleats across chunk edges',run('mat[30*W+30]===EMPTY && mat[33*W+33]===RSTONE && !cleatChunks[0]')); +(async () => { + const expected = new Map(); + for (const workers of [0, 2]) for (const mixed of [false, true]) for (const near of [false, true]) { + run(`pileCutWorld();pileCutSetup(160,${mixed});bot.x=700;bot.y=100;`); + const stop = workers ? await enableThreads(run, workers) : null; + try { + run('for(let k=0;k<600;k++)simTick();'); + const settled = hash(); + run('awake.fill(3);for(let k=0;k<60;k++)simTick();'); + check('uncut heap is settled', hash() === settled); + run(`for(let k=0;k<70;k++){pileCutStroke(160);simTick();}bot.x=${near ? 110 : 700};bot.y=170;`); + const before = run('pileCutProfile(160)'); + check('vacuum removes grains without losing them', run('bagTotal') > 500 && before.grains + run('bagTotal') === 3600, JSON.stringify(before)); + run('for(let k=0;k<180;k++)simTick();'); + const after = run('pileCutProfile(160)'), label = `${mixed ? 'mixed' : 'stone'}, ${workers} workers, bot ${near ? 'near' : 'far'}`; + check(`vacuum face slumps within 180 ticks (${label})`, after.cliff <= 2, `before ${before.cliff}, after ${after.cliff}`); + if (workers) check('helpers actually processed chunks', run('simPool.activeMax') > 0); + const key = `${mixed}/${near}`; + if (!workers) expected.set(key, hash()); + else check(`vacuum collapse matches inline bytes (${label})`, hash() === expected.get(key)); + run('for(let k=0;k<1200;k++){awake.fill(3);simTick();}'); + const resting = hash(); + run('for(let k=0;k<600;k++){awake.fill(3);simTick();}for(let k=0;k<3;k++)simTick();'); + check(`settled cut heap stays put and sleeps (${label})`, hash() === resting && run('awake.every(v=>!v)')); + } finally { if (stop) await stop(); } + } + let wideExpected; + for (const workers of [0, 2]) { + flatWorld(run, 768, 600, 224); + run('bot.x=740;bot.y=100;for(let y=104;y<224;y++)for(let x=256;x<576;x++)setCell(y*W+x,RSTONE,AMB[y],0);'); + const stop = workers ? await enableThreads(run, workers) : null; + try { + run('for(let k=0;k<600;k++)simTick();'); + const result = run(`(()=>{let cliff=0,previous=0,grains=0; + for(let x=0;x { console.error(e); process.exitCode = 1; }); diff --git a/test/t_vacuum_collapse.py b/test/t_vacuum_collapse.py new file mode 100644 index 0000000..49d2890 --- /dev/null +++ b/test/t_vacuum_collapse.py @@ -0,0 +1,58 @@ +"""Vacuum cuts in the real pixel frame loop with active browser workers and hardware graphics.""" +import pathlib +import sys +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / 'tools')) +from use_venv import ensure_playwright; ensure_playwright() +from browser import launch, consume_gpu_option, capabilities +consume_gpu_option() +ROOT = pathlib.Path(__file__).resolve().parents[1] +with launch(viewport={'width': 1440, 'height': 760}) as (_, page, url): + errors = [] + page.on('pageerror', lambda e: errors.append(str(e))) + page.goto(url) + caps = capabilities(page) + assert caps['isolated'] and caps['webgpu'], caps + print('GPU', caps, flush=True) + page.evaluate("""() => { + const enter = enterGame; enterGame = function() { enter(); GAME.paused = true; }; + SET.workerCap=2; SET.fog=false; SET.lighting='off'; SET.cameraLead=false; SET.autosave=0; + startNewWorld(7,768,600); + }""") + page.wait_for_function('GAME.running', timeout=60000) + page.evaluate((ROOT / 'test/pile-cut.js').read_text()) + page.evaluate("""() => { + pileCutWorld(); pileCutSetup(160,false); pileCutSetup(416,true); + bot.x=700;bot.y=100;for(let k=0;k<600;k++)simTick(); + renderDirty.fill(1);GPU.world=null;SM.surf=-1; + }""") + page.evaluate('startThreads()') + page.evaluate("""() => { + // Tiny isolated fixtures otherwise run inline below the production dispatch threshold. + simPool.force=true; + window.cutTicks=0;window.cutEnd=0; + const tool=toolTick;toolTick=()=>{ + tool(); + if(cutEnd && tick===cutEnd+180) window.cutResult={tick,cutEnd, + stone:pileCutProfile(160),mixed:pileCutProfile(416),helpers:simPool.activeMax}; + if(cutTicks<140){pileCutStroke(cutTicks%2?416:160);cutTicks++;if(cutTicks===140)cutEnd=tick+1;} + }; + document.querySelectorAll('.card,.toast').forEach(e=>e.remove()); + $('hud').hidden=true;GAME.hidePreviews=true;SET.zoom=3;resizeRender(); + bot.x=288-bot.w/2;bot.y=190-bot.h/2;snapCamera(); + // Keep both heaps in view while the bot follows each vacuum stroke. + updateCamera=()=>{}; GAME.paused=false; + }""") + page.wait_for_function('cutEnd>0', timeout=30000) + for delay in (0, 60, 120, 180): + page.wait_for_function(f'tick>=cutEnd+{delay}', timeout=15000) + result = page.evaluate('({tick,cutEnd,stone:pileCutProfile(160),mixed:pileCutProfile(416),helpers:simPool.activeMax})') + print('profile', delay, result, flush=True) + page.screenshot(path=str(ROOT / f'shots/t93b-time-{delay}.png')) + page.wait_for_function('window.cutResult', timeout=15000) + result = page.evaluate('cutResult') + page.evaluate('GAME.paused=true') + page.screenshot(path=str(ROOT / 'shots/t93b-after.png')) + assert result['helpers'] > 0, result + assert result['stone']['cliff'] <= 2 and result['mixed']['cliff'] <= 2, result + assert not errors, errors + print('PASS vacuum-cut stone and mixed faces slump within 180 real-loop ticks with browser helpers')