From bd22e453ca1882c34cef036d62b9f58ecc26fd0e Mon Sep 17 00:00:00 2001 From: 44r0n7 <44r0n7+gitea@pm.me> Date: Wed, 28 Oct 2026 23:07:30 -0400 Subject: [PATCH] Resting faces steeper than the angle of repose topple: blown sand towers collapse (t93) Co-Authored-By: Claude Opus 5.5 --- docs/DESIGN.md | 15 +++++++ docs/PROJECT_MAP.md | 11 +++-- docs/TESTING.md | 7 +++ docs/agent-log.md | 45 ++++++++++++++++++++ src/sim/cells.js | 87 +++++++++++++++++++++++++++++--------- src/sim/material-tables.js | 1 + test/pile-blow.js | 21 +++++++++ test/run_all.py | 2 +- test/t_blow_collapse.js | 48 +++++++++++++++++++++ test/t_blow_collapse.py | 51 ++++++++++++++++++++++ 10 files changed, 264 insertions(+), 24 deletions(-) create mode 100644 test/pile-blow.js create mode 100644 test/t_blow_collapse.js create mode 100644 test/t_blow_collapse.py diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 578712d..e0d8ef3 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -570,6 +570,21 @@ above, including: Resting heaps do not enter this path; world generation keeps its recorded settling rules and seed layouts. +- **Nozzle-built sand towers topple from rest (t93 round 3, 2026-10-28).** The owner + reproduced vertical sand towers by continuously blowing straight up. Landing places grains + at or above impact; supported face grains need to release before the outer falling column + clears. A tall body now spills from a supported exposed face as well as a falling one. + Apply this to free-flowing powders (STICK=1); cohesive grains retain their stickier release + and do not inherit the outward motion. Require an open side and a steep drop, backed eight grains inward by a continuous column + at least 32 grains high (look at most 24 rows each way). This leaves short feeds and the + deliberately steeper small clay heaps on their existing settling rules. The spill is still + bounded to eight grains horizontally and vertically, checking every crossed cell. + Two cell flag bits retain the collapse direction while the released grain falls: up to two + grains outward and eight down per tick. Removing support passes that direction to the grain + immediately above, so the falling layer drains promptly. Contact below cancels this motion; + ordinary repose rules take over. Keep stamped moves, barriers, burning-bed protection and + worker independence. The existing worldgen POWDER_FAN switch disables new face releases. + ## 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 1f3bff8..8e9aeb6 100644 --- a/docs/PROJECT_MAP.md +++ b/docs/PROJECT_MAP.md @@ -225,9 +225,9 @@ Material definitions: ids, properties and metal families. ids, and drill/tank ti - functions: def, metalFamily - variables: EMPTY, GRASS, DIRT, LDIRT, SAND, GRAVEL, CLAY, STONE, GRANITE, BASALT, NMAT, MD, FIRETO -### src/sim/material-tables.js (54 lines) +### src/sim/material-tables.js (55 lines) Material tables: typed properties, casting groups, flags, parts and tool tiers. Typed property tables -- variables: PH, DENS, KC, ICAP, CASTMETAL, METAL_TIER, SOLIDMETAL, F_NAT, F_CAST, F_BURN, P_NONE, P_SCREW_U, P_SCREW_D, P_SCREW_L, P_SCREW_R, P_GRATE, P_GOPEN, P_WHEEL, P_INTAKE, P_FILTER, P_PIPE, P_SCREW_UR, P_SCREW_DR, P_SCREW_DL, P_SCREW_UL, SCREW_PARTS, SCREW_DIRS, IS_SCREW, SCREW_DX, SCREW_DY, BIT_TIERS, TANK_TIERS, REACH +- variables: PH, DENS, KC, ICAP, CASTMETAL, METAL_TIER, SOLIDMETAL, F_NAT, F_CAST, F_BURN, F_AVALANCHE, F_AVALANCHE_R, P_NONE, P_SCREW_U, P_SCREW_D, P_SCREW_L, P_SCREW_R, P_GRATE, P_GOPEN, P_WHEEL, P_INTAKE, P_FILTER, P_PIPE, P_SCREW_UR, P_SCREW_DR, P_SCREW_DL, P_SCREW_UL, SCREW_PARTS, SCREW_DIRS, IS_SCREW, SCREW_DX, SCREW_DY, BIT_TIERS, TANK_TIERS, REACH ### src/sim/pieces.js (179 lines) Everything the player can build (PIECES): blocks, machines and molds, with costs and descriptions; filter settings; wall materials and their cost. @@ -254,9 +254,9 @@ 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 (272 lines) +### src/sim/cells.js (321 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 +- functions: setCell, swapCells, swapCellData, move, enter, enterFree, powderSpill, avalancheMove, powder, passable, liquid, liquidSearch, gas, wakeBotPowder, simMove, moveChunk - variables: SORTP, SORTP_FUEL, LSTEP, LFILM, CORNER_SEAL, POWDER_FAN ### src/sim/heat.js (266 lines) @@ -630,11 +630,14 @@ 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-blow.js`: Shared reproduction: continuously blow plain bagged sand straight above a stationary bot. - `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. - `test/t_basics.js`: Basics: drilling to rubble, the vacuum and blowing, walls, the ingot mold and bars, belts, blueprints, save/load. +- `test/t_blow_collapse.js`: Owner's upward nozzle recipe, inline and with real shared-memory helpers. +- `test/t_blow_collapse.py`: Straight-up bagged sand nozzle in the real pixel frame loop. - `test/t_build.js`: Building: undo (Ctrl+Z), cut, replace a machine with Ctrl, pasting ghosts, and the fan heat warning. - `test/t_caps.py`: Capability detection, title notices and the required-WebGPU startup gate. - `test/t_conveyors.js`: Screw conveyors that never carry liquid (it flows through), screw and belt throughput, filters. diff --git a/docs/TESTING.md b/docs/TESTING.md index 180bc42..35fe880 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -86,6 +86,13 @@ checks waking within eight ticks separately from the probabilistic consumption o 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_blow_collapse.py` continuously blows bagged sand straight above a stationary bot + at nozzle radius 6 for 1200 real frame-loop ticks, then checks the pile after 480 idle + ticks with actual helper dispatch. Pixel captures are `shots/t93c-before.png` and + `shots/t93c-after.png`. `test/t_blow_collapse.js` repeats the recipe inline and with + 1/2/8 Node helpers, compares material and motion flags, conserves all bag/flying/landed + sand, and forces visits to check resting stability. It also directly checks a supported + face releasing immediately and a spill stopped by a cleat across a chunk boundary. - `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 df39261..5c3ad8a 100644 --- a/docs/agent-log.md +++ b/docs/agent-log.md @@ -6,6 +6,51 @@ open or not verified. Keep each entry short; the git history has the details. + +## 2026-10-28: t93 round 3, nozzle-built resting sand faces (Codex) + +- Reproduced the owner's continuous straight-up radius-6 sand nozzle recipe, not a + vacuum cut: 1200 toolBlow ticks under open sky. Matching original-code Node and real + pixel frame-loop browser controls fail the new regression. Browser, NVIDIA RTX 5070, + two active helpers: 79-grain cliff at blow end, still 61 after 480 idle ticks. Active + chunks remain; neither sticky rolls (sand already has STICK=1) nor missing wakes explain it. +- Cause: landFlyer/depositNear stack grains at or above impact, while supported exposed + grains only slide a single diagonal cell. The earlier fan rule requires empty space below + and misses the resting face. Simply extending the initial spill leaves the falling layer + blocking further release. Tall bodies now release from rest and carry a bounded outward + fall through the newly unsupported column; contact below cancels that motion. Cohesive + grains retain their existing release and do not inherit it. A continuous 32-grain backing + column eight grains inward distinguishes towers from short feeds. Read reach <=24, + write reach <=8, no per-grain allocation, existing worldgen switch retained. +- New shared nozzle fixture and headless/browser regressions cover the actual tool, direct + supported-face release, cross-chunk cleats, conservation, 0/1/2/8 helpers, exact material + and motion-flag bytes, and forced-awake stability. Larger equal-mass clay/sand check: + 7200 grains, final heights 90/75. Rejected broad release and persistent motion on contact + experiments after they changed small heaps, clay or transport checks; no old test weakened. +- Final headless suite passes. Served pixel browser nozzle check passes at exactly 480 idle + ticks: cliff 32 -> 1, all 40000 grains retained in bag/flight/pile, final pile sleeps. + Saved and inspected shots/t93c-before.png and shots/t93c-after.png; matching old-rule + captures are shots/t93c-control-{before,after}.png. Final full + python3 test/run_all.py --browser: ALL PASSED, 35 headless and 10 browser suites, + including nozzle 25, collapse 17, vacuum 44, threads 12, gameplay 361, both browser + collapse regressions, original worldgen hashes and liquids. Log: /tmp/t93c-all-browser.log. + Ticked round-3 feedback with a report and moved t93 to Review. Owner playtesting + remains open. No commit. +- Isolated real-GPU requested profile, no concurrent tests: original-code control -> final + total ms/tick: settled 1.9359 -> 1.9413 (+0.3%), heat 2.6679 -> 2.7080 (+1.5%). + Versus historical tools/perf-after-t88.json: +7.5%/+3.2%; the control already measures + +7.2%/+1.7%. Awake counts match (425/494). Movement ms/tick 0.9520 -> 0.9570 and + 1.3460 -> 1.3850; unchanged heat work 0.9060 -> 0.9067 and 1.2425 -> 1.2424. + Logs/JSON: /tmp/t93c-perf-{control,final}.{log,json}. Some real-loop frame-tail timings + are noisier and exceed the profiler's 10% marker; the requested isolated simulation + tick averages are within a few percent of the matching control. +- Files: src/sim/cells.js, src/sim/material-tables.js, test/pile-blow.js, + test/t_blow_collapse.{js,py}, test/run_all.py, docs/DESIGN.md, docs/TESTING.md, + regenerated docs/PROJECT_MAP.md, t93 task and this log. Further evidence: + /tmp/t93c-red.log, /tmp/t93c-browser-red.log, /tmp/t93c-browser-final.log, + /tmp/t93c-final-headless2.log. + + ## 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. diff --git a/src/sim/cells.js b/src/sim/cells.js index 70af7f5..cb6f6ae 100644 --- a/src/sim/cells.js +++ b/src/sim/cells.js @@ -78,9 +78,75 @@ const SORTP_FUEL = 0.003; // 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, POWDER_FAN = true; +// Every spill checks each crossed cell, including barriers outside the source chunk. +function powderSpill(i, x, y, m, dx, limit, avalanche) { + let reach = 0; + for (let step = 1; step <= limit; 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) return false; + let drop = 1; + for (let step = 2; step <= limit && 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; + } + if (avalanche) avalancheMove(i, x, y, dx, reach, drop); + else move(i, i + W * drop + dx * reach, x, y, x + dx * reach, y + drop); + return true; +} +// Removing support passes the collapse direction up the column; contact cancels it. +function avalancheMove(i, x, y, dx, reach, drop) { + const fl = F_AVALANCHE | (dx > 0 ? F_AVALANCHE_R : 0); + if (y && PH[mat[i - W]] === PH_POWDER && STICK[mat[i - W]] === 1) flags[i - W] = (flags[i - W] & ~F_AVALANCHE_R) | fl; + flags[i] |= fl; + move(i, i + dx * reach + W * drop, x, y, x + dx * reach, y + drop); +} function powder(i, x, y, m, barriers = cleatChunks[(y >> 5) * CW + (x >> 5)]) { if (y >= H - 1) return; const b = i + W; + if (flags[i] & F_AVALANCHE) { + const dx = flags[i] & F_AVALANCHE_R ? 1 : -1; + flags[i] &= ~(F_AVALANCHE | F_AVALANCHE_R); + let reach = 0; + for (let step = 1; step <= 2 && mat[b] === EMPTY; step++) { + const nx = x + dx * step, j = i + dx * step; + if (nx < 0 || nx >= W || mat[j] !== EMPTY || part[j] || cleatMask[j] || !passable(m, PH_POWDER, j, dx, 0)) break; + reach = step; + } + let drop = 0; + for (let step = 1; step <= 8 && y + step < H; step++) { + const j = i + dx * reach + W * step; + if (mat[j] !== EMPTY || part[j] || cleatMask[j] || !passable(m, PH_POWDER, j, 0, 1)) break; + drop = step; + } + if (drop && mat[b] === EMPTY && !part[i] && !cleatMask[i] && !(flags[i] & F_BURN)) { + avalancheMove(i, x, y, dx, reach, drop); return; + } + } + // Tall free-flowing bodies topple from rest; short feeds and cohesive grains keep their repose. + if (POWDER_FAN && STICK[m] === 1 && (mat[b] === EMPTY || PH[mat[b]] === PH_POWDER) && !part[i] && !((flags[i] | flags[b]) & 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 && !cleatMask[i] && ((PH[mat[b]] === PH_POWDER && y + 2 < H && mat[b + W + dx] === EMPTY) || + (y > 0 && PH[mat[i - W]] === PH_POWDER && mat[i - W + dx] === EMPTY && PH[mat[b - dx]] === PH_POWDER))) { + // Read at most 24 rows away: with eight-grain writes, same-colour chunks stay independent. + const insideX = x - dx * 8; + let depth = 0; + if (insideX >= 0 && insideX < W) { + const inside = i - dx * 8; + let up = 0; + while (up < 24 && y >= up && PH[mat[inside - W * up]] === PH_POWDER) up++; + let down = 1; + while (down <= 24 && up + down <= 32 && y + down < H && PH[mat[inside + W * down]] === PH_POWDER) down++; + depth = up + down - 1; + } + if (depth >= 32 && powderSpill(i, x, y, m, dx, 8, true)) 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) { @@ -91,25 +157,8 @@ function powder(i, x, y, m, barriers = cleatChunks[(y >> 5) * CW + (x >> 5)]) { // 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; - } + const reach = depth === 12 && stamp[b - dx] !== tickStamp ? 8 : 1; + if (powderSpill(i, x, y, m, dx, reach, false)) return; } } move(i, b, x, y, x, y + 1); return; diff --git a/src/sim/material-tables.js b/src/sim/material-tables.js index 4a7dee0..8cc8e08 100644 --- a/src/sim/material-tables.js +++ b/src/sim/material-tables.js @@ -28,6 +28,7 @@ const SOLIDMETAL = new Uint8Array(NMAT); for (const m of [COPPER, IRON, STEEL, T // ---------- Flags & parts ---------- const F_NAT = 1, F_CAST = 2, F_BURN = 4; +const F_AVALANCHE = 8, F_AVALANCHE_R = 16; // transient outward fall, cleared on contact const P_NONE = 0, P_SCREW_U = 1, P_SCREW_D = 2, P_SCREW_L = 3, P_SCREW_R = 4, P_GRATE = 5, P_GOPEN = 6, P_WHEEL = 7, P_INTAKE = 8, P_FILTER = 9, P_PIPE = 10, P_SCREW_UR = 11, P_SCREW_DR = 12, P_SCREW_DL = 13, P_SCREW_UL = 14; const SCREW_PARTS = [P_SCREW_U, P_SCREW_R, P_SCREW_D, P_SCREW_L, P_SCREW_UR, P_SCREW_DR, P_SCREW_DL, P_SCREW_UL]; const SCREW_DIRS = [[0, -1], [1, 0], [0, 1], [-1, 0], [1, -1], [1, 1], [-1, 1], [-1, -1]]; diff --git a/test/pile-blow.js b/test/pile-blow.js new file mode 100644 index 0000000..b797a9e --- /dev/null +++ b/test/pile-blow.js @@ -0,0 +1,21 @@ +// Shared reproduction: continuously blow plain bagged sand straight above a stationary bot. +function pileBlowSetup() { + pileCutWorld(); + for (let x=0;x=480?STONE:EMPTY;flags[i]=y>=480?F_NAT:0;temp[i]=AMB[y]; + } + bot.x=384-bot.w/2;bot.y=330-bot.h/2;bot.vx=bot.vy=0; + bag[SAND]=40000;bagTotal=40000;updateRoof(); +} +function pileBlowStroke() { toolBlow(383.5,250,6,SAND); } +function pileBlowProfile() { + let cliff=0,previous=0,grains=0,height=0; + for(let x=0;xv).length}; +} diff --git a/test/run_all.py b/test/run_all.py index 048777e..0ec6982 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_vacuum_collapse.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_blow_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_blow_collapse.js b/test/t_blow_collapse.js new file mode 100644 index 0000000..71f3b0c --- /dev/null +++ b/test/t_blow_collapse.js @@ -0,0 +1,48 @@ +// Owner's upward nozzle recipe, inline and with real shared-memory helpers. +const fs = require('fs'); +const {createHash} = require('crypto'); +const {load, suite, enableThreads} = require('./harness'); +const {run} = load(), {check, done} = suite(); +for (const file of ['pile-cut', 'pile-blow']) run(fs.readFileSync(require.resolve('./'+file+'.js'), 'utf8')); +const hash = () => {const h=createHash('sha256');for(const a of run('[mat,flags,life]'))h.update(a);return h.digest('hex');}; +// A supported grain must release immediately, without relying on gravity underneath it. +run(`pileBlowSetup();for(let y=270;y<340;y++)for(let x=356;x<376;x++)setCell(y*W+x,SAND,20,0); + powder(300*W+356,356,300,SAND);`); +check('a resting tall face topples promptly',run('mat[300*W+356]===EMPTY && mat[308*W+348]===SAND && (flags[299*W+356]&F_AVALANCHE)')); +run(`pileBlowSetup();for(let y=270;y<340;y++)for(let x=356;x<376;x++)setCell(y*W+x,SAND,20,0); + cleatMask[300*W+352]=1;powder(300*W+356,356,300,SAND);`); +check('resting face spill stops at a cross-chunk cleat',run('mat[308*W+353]===SAND && mat[308*W+348]===EMPTY')); +(async () => { + let expected; + for (const workers of [0, 1, 2, 8]) { + run('pileBlowSetup()'); + const stop = workers ? await enableThreads(run, workers) : null; + try { + run('for(let k=0;k<1200;k++){pileBlowStroke();simTick();}'); + const before = run('pileBlowProfile()'); + check(`fast upward nozzle builds a large pile (${workers} helpers)`, before.grains > 15000 && before.height > 100, JSON.stringify(before)); + run('bot.x=700;bot.y=100;for(let k=0;k<480;k++)simTick();'); + const after = run('pileBlowProfile()'); + check(`blown sand slumps within eight seconds (${workers} helpers)`, after.cliff <= 2, JSON.stringify(after)); + check('all blown sand is retained', after.grains + after.flying + after.bag === 40000); + if (workers) { + check('helpers actually process the blown pile', run('simPool.activeMax') > 0); + check('blown collapse matches inline material and motion flags', hash() === expected); + } else expected = hash(); + 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 blown sand stays put and sleeps', hash() === resting && run('awake.every(v=>!v)')); + } finally { if(stop) await stop(); } + } + const heights = []; + for (const material of ['SAND','LCLAY']) { + run(`pileBlowSetup();bag.fill(0);bagTotal=0;bot.x=700; + for(let y=360;y<480;y++)for(let x=350;x<410;x++)setCell(y*W+x,${material},20,0); + for(let k=0;k<1800;k++){awake.fill(3);simTick();}`); + heights.push(run(`(()=>{let height=0;for(let x=0;xheights[0],JSON.stringify(heights)); + done(); +})().catch(e => { console.error(e); process.exitCode = 1; }); diff --git a/test/t_blow_collapse.py b/test/t_blow_collapse.py new file mode 100644 index 0000000..a865239 --- /dev/null +++ b/test/t_blow_collapse.py @@ -0,0 +1,51 @@ +"""Straight-up bagged sand nozzle in the real pixel frame loop.""" +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': 900}) as (_, page, url): + errors = [] + page.on('pageerror', lambda e: errors.append(str(e))) + page.goto(url) + print('GPU', capabilities(page), flush=True) + page.evaluate("""() => { + const enter=enterGame;enterGame=()=>{enter();GAME.paused=true;}; + SET.workerCap=2;SET.maxSteps=1;SET.fog=false;SET.lighting='off';SET.cameraLead=false;SET.autosave=0; + startNewWorld(7,768,600); + }""") + page.wait_for_function('GAME.running', timeout=60000) + for file in ('pile-cut', 'pile-blow'): + page.evaluate((ROOT / f'test/{file}.js').read_text()) + page.evaluate("""() => { + pileBlowSetup();renderDirty.fill(1);GPU.world=null;SM.surf=-1; + document.querySelectorAll('.card,.toast').forEach(e=>e.remove()); + $('hud').hidden=true;GAME.hidePreviews=true;SET.zoom=3;resizeRender(); + snapCamera();updateCamera=()=>{}; + }""") + page.evaluate('startThreads()') + page.evaluate("""() => { + simPool.force=true;window.blowTicks=0;window.blowEnd=0; + const tool=toolTick;toolTick=()=>{tool();if(blowTicks<1200){pileBlowStroke();blowTicks++;}}; + const advance=simTick;simTick=()=>{ + advance(); + if(blowTicks===1200 && !blowEnd){blowEnd=tick;window.blowBefore=pileBlowProfile();GAME.paused=true;} + else if(blowEnd && tick===blowEnd+480){window.blowAfter=pileBlowProfile();GAME.paused=true;} + }; + GAME.paused=false; + }""") + page.wait_for_function('window.blowBefore', timeout=120000) + print('before', page.evaluate('blowBefore'), flush=True) + page.screenshot(path=str(ROOT / 'shots/t93c-before.png')) + page.evaluate('() => {bot.x=700;bot.y=100;GAME.paused=false;}') + page.wait_for_function('window.blowAfter', timeout=60000) + result=page.evaluate('({before:blowBefore,after:blowAfter,helpers:simPool.activeMax})') + page.screenshot(path=str(ROOT / 'shots/t93c-after.png')) + print('result', result, flush=True) + assert result['before']['grains']>15000 and result['before']['height']>100, result + assert result['after']['cliff']<=2 and result['helpers']>0, result + assert sum(result['after'][k] for k in ('grains','flying','bag'))==40000, result + assert not errors, errors + print('PASS upward nozzle pile collapses within 480 real-loop ticks with active helpers')