From 9be04471eec8eff6d88cf1bb66798d22ce3a650f Mon Sep 17 00:00:00 2001 From: 44r0n7 <44r0n7+gitea@pm.me> Date: Sat, 24 Oct 2026 21:55:04 -0400 Subject: [PATCH] Physics test pass for sand, molten metal and gases; stuck gas finishes, steam is not deleted (t21) - New test/t_physics.js: heaps, sorting, sinking, world edges; molten copper levelling, setting, quenching, slag over copper, cavity filling; smoke, steam, fire, sealed pockets, the top row. - Gas in a sealed pocket kept its area asleep and never faded or condensed: gas() keeps its area awake while it lasts. - A rain drop or blown grain landing on steam deleted the steam's water: it goes to the clouds. Co-Authored-By: Claude Opus 5.5 --- docs/PROJECT_MAP.md | 5 +- docs/agent-log.md | 15 ++++ src/sim/cells.js | 2 + src/sim/vacuum.js | 10 ++- test/t_physics.js | 212 ++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 240 insertions(+), 4 deletions(-) create mode 100644 test/t_physics.js diff --git a/docs/PROJECT_MAP.md b/docs/PROJECT_MAP.md index b1588b6..6b51346 100644 --- a/docs/PROJECT_MAP.md +++ b/docs/PROJECT_MAP.md @@ -226,7 +226,7 @@ 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 (187 lines) +### src/sim/cells.js (189 lines) Cell helpers (setCell, move, enter) and the falling-sand movement of powders, liquids and gases (simMove). - functions: setCell, swapCells, move, enter, powder, passable, liquid, gas, simMove - variables: SORTP, LSTEP, LFILM @@ -293,7 +293,7 @@ Trees (background layer), acorns, saplings, and waking freezing water. - functions: cutTree, treeHeld, findTreeBases, treeSupportTick, woodRoom, treeFall, coldTick, plantTick, setTreeCell, saplingTick, igniteTree - variables: treeBases, saplings, treeDraw -### src/sim/vacuum.js (166 lines) +### src/sim/vacuum.js (172 lines) The vacuum, blowing grains out, grains in flight (flyers), and the flame tool. Material the vacuum would start on at the cursor: a bar, else the nearest loose grain. - functions: vacuumTargetAt, toolVacuum, nozzle, toolBlow, flyOpen, depositNear, bagTemp, landFlyer, flyTick, toolIgnite - variables: blowRR, FLY_G, FLY_Q @@ -472,6 +472,7 @@ The frame loop and boot. - `test/t_fog.js`: Fog: surface, sight, range, saves and simulation independence. - `test/t_furnace_repeat.js`: The starter furnaces keep working when fired again and again without cleaning, however the charge goes in: mixed, ore first with coal on top, or blown in through the vent. Each case plays furnace_play.js, which builds the design and fires it like a player. - `test/t_molds.js`: Molds and furnaces stay usable: ingots drop below and keep their stubs, plugs over molds are cleared, spoiled castings can be knocked out, grains never drop through a grate by teleporting, and slag lets the fire breathe. +- `test/t_physics.js`: Sand and other powders, molten metal and gases: heaps, sorting, sinking, setting, rising and fading. Water has its own suite (t_water.js). Tall worlds keep geothermal heat out of the way. - `test/t_pipes.js`: Pipes, pumps and outlets move liquids; the Grinder grinds waste rock for trace ore. - `test/t_racks.js`: Rack intake, FIFO storage, gate, removal and saved-state validation. - `test/t_smooth.py`: The smooth look: the WebGL pass compiles, draws, falls back cleanly, and the thermal lens still works. diff --git a/docs/agent-log.md b/docs/agent-log.md index 1c72116..dd11fd1 100644 --- a/docs/agent-log.md +++ b/docs/agent-log.md @@ -4,6 +4,21 @@ What agents have done, newest first. Add an entry for every piece of work: the d note or request it was for, what changed, the files touched, the tests run, and anything left open or not verified. Keep each entry short; the git history has the details. +## 2026-10-24: t21 phase 2: physics test pass for sand, molten metal and gases (Claude Code, Codex) + +- `test/t_physics.js` (41 checks): heaps, digging under a heap, ore sorting through ash, sand + sinking in a pond, world edges; molten copper levelling (slower than water), setting, sinking in + a pond, slag over copper, filling a cavity; smoke rising and fading (longer under a roof), steam + condensing, fire going out, gas in sealed pockets, gas leaving at the top row. Every scenario + checks that its areas go back to sleep. +- Found and fixed: gas stuck in a pocket never finished fading or condensing because its area + slept (`gas()` keeps its area awake: steady cost 1.34 -> 1.37 ms/tick); a rain drop or blown + grain landing on steam deleted that steam's water (`landFlyer`/`depositNear` in + `src/sim/vacuum.js` now send it to the clouds). +- Found, owner decided: a resting pile never sorts. To follow: piles near the bot keep sorting, + and sorting gets quick. +- Tests: `run_all.py --browser` ALL PASSED; seeds 777 and 77 generate the same worlds. + ## 2026-10-24: t21 phase 1: water test pass, faster pouring, rain spreads (Claude Code, Codex) - `test/t_water.js` (20 checks): conservation, levelling and sleep, pools pouring off ledges, diff --git a/src/sim/cells.js b/src/sim/cells.js index 78e9852..3d32027 100644 --- a/src/sim/cells.js +++ b/src/sim/cells.js @@ -126,6 +126,8 @@ function liquid(i, x, y, m) { } } function gas(i, x, y, m) { + // Even blocked gas must keep aging or condensing until it is gone. + awake[(y >> 5) * CW + (x >> 5)] = 3; if (m === FIRE) { const l = life[i]; if (l <= 1) { mat[i] = EMPTY; life[i] = 0; wakeXY(x, y); return; } diff --git a/src/sim/vacuum.js b/src/sim/vacuum.js index e0f5509..243773b 100644 --- a/src/sim/vacuum.js +++ b/src/sim/vacuum.js @@ -108,7 +108,10 @@ function depositNear(m, T, x, y, skip) { const xx = x + dx, yy = y + dy; if (xx < 0 || yy < 0 || xx >= W || yy >= H) continue; const j = yy * W + xx; - if (j !== skip && flyOpen(m, j)) { setCell(j, m, T, 0); return true; } + if (j !== skip && flyOpen(m, j)) { + if (mat[j] === STEAM) absorbSteam(xx); // displaced steam still belongs to the world's water + setCell(j, m, T, 0); return true; + } } } return false; @@ -121,7 +124,10 @@ function landFlyer(f, x, y) { for (let k = 0; k < 8; k++) { const yy = y - k; if (yy < 0) break; const j = yy * W + x; - if (flyOpen(f.m, j)) { setCell(j, f.m, f.T, 0); return; } + if (flyOpen(f.m, j)) { + if (mat[j] === STEAM) absorbSteam(x); // landing replaces the gas, so keep its water in the clouds + setCell(j, f.m, f.T, 0); return; + } } if (!depositNear(f.m, f.T, x, y)) { if (f.rain) absorbSteam(x); else bagAdd(f.m, 1, true); } // nowhere to go: back into the bag (rain: back into the clouds) } diff --git a/test/t_physics.js b/test/t_physics.js new file mode 100644 index 0000000..cdb9b98 --- /dev/null +++ b/test/t_physics.js @@ -0,0 +1,212 @@ +// Sand and other powders, molten metal and gases: heaps, sorting, sinking, setting, rising and +// fading. Water has its own suite (t_water.js). Tall worlds keep geothermal heat out of the way. +const { load, suite, flatWorld, rect, count, ticks } = require('./harness'); +const { run } = load(); +const { check, done } = suite(); +const started = Date.now(); +const awake = () => run('awake.reduce((n, v) => n + (v > 0), 0)'); +const grains = m => count(run, run(m)); +function world(w = 320, ground = 120) { run('WS = 12345;'); flatWorld(run, w, 600, ground); run('RS = 12345;'); } +// the top grain row of each column in x0..x1 (ground row if none) +const tops = (m, x0, x1, ground) => run(`(() => { const a = []; for (let x = ${x0}; x <= ${x1}; x++) { let y = ${ground}; for (let j = 0; j < ${ground}; j++) if (mat[j*W+x] === ${m}) { y = j; break; } a.push(y); } return a; })()`); + +// Sand and powders +// sand poured from one point makes a heap with sloping sides, then lies still +world(); +for (let k = 0; k < 400; k++) { run('if (mat[40*W+160] === EMPTY) setCell(40*W+160, SAND, AMB[40], 0);'); ticks(run, 2); } +ticks(run, 1800); +const heap = tops(run('SAND'), 100, 220, 120), peak = Math.min(...heap), wide = heap.filter(y => y < 120).length; +check('poured sand makes a sloping heap that lies still', grains('SAND') === 400 && 120 - peak >= 5 && wide >= 12 && wide <= 80 && awake() === 0, + `${grains('SAND')} grains, ${120 - peak} high, ${wide} wide, ${awake()} areas awake`); +// digging out the ground under the heap lets it fall in, and none is lost +run('for (let y = 120; y < 160; y++) for (let x = 140; x < 180; x++) setCell(y*W+x, EMPTY, AMB[y], 0);'); +ticks(run, 1800); +check('digging under a heap makes it fall into the hole', grains('SAND') === 400 && count(run, run('SAND'), 140, 120, 179, 159) > 300, + `${count(run, run('SAND'), 140, 120, 179, 159)} of ${grains('SAND')} in the hole`); +asleep('sand after digging'); +// heavy ore works its way down through light ash, slowly, while the pile is being stirred (a +// working furnace bed is always awake); a pile left at rest goes to sleep and stays as it is +world(); +rect(run, 150, 100, 151, 119, run('STONE')); rect(run, 170, 100, 171, 119, run('STONE')); +rect(run, 152, 112, 169, 119, run('ASH')); rect(run, 152, 108, 169, 111, run('CUORE')); +const oreY = (m) => run(`(() => { let s = 0, n = 0; for (let i = 0; i < N; i++) if (mat[i] === ${m}) { s += (i / W) | 0; n++; } return s / n; })()`); +const ore0 = oreY(run('CUORE')); ticks(run, 3600); const oreRest = oreY(run('CUORE')); +check('a resting pile of ore on ash goes to sleep as it is', Math.abs(oreRest - ore0) < 0.5 && awake() === 0, `ore rows ${ore0.toFixed(1)} -> ${oreRest.toFixed(1)}, ${awake()} awake`); +const stir = n => run(`for (let k = 0; k < ${n}; k++) { if (k % 8 === 0) wakeXY(160, 112); simTick(); }`); +stir(600); const ore1 = oreY(run('CUORE')); stir(5400); const ore2 = oreY(run('CUORE')); +check('stirred, heavy ore sinks down through light ash, slowly', ore1 - oreRest < 2 && ore2 - oreRest > 2 && grains('CUORE') === 72 && grains('ASH') === 144, + `ore rows ${oreRest.toFixed(1)} -> ${ore1.toFixed(1)} -> ${ore2.toFixed(1)}`); +ticks(run, 1200); asleep('ore after stirring stops'); +world(); +rect(run, 150, 100, 151, 119, run('STONE')); rect(run, 170, 100, 171, 119, run('STONE')); +rect(run, 152, 112, 169, 119, run('LDIRT')); rect(run, 152, 104, 169, 111, run('SAND')); +const sand0 = oreY(run('SAND')); ticks(run, 6000); +check('sand on dirt (close in weight) stays where it is', Math.abs(oreY(run('SAND')) - sand0) < 0.5, `sand rows ${sand0.toFixed(1)} -> ${oreY(run('SAND')).toFixed(1)}`); +asleep('a pile of similar weights'); +// sand dropped into a pond sinks, and the water ends up on top +world(); +rect(run, 150, 90, 151, 119, run('STONE')); rect(run, 190, 90, 191, 119, run('STONE')); +rect(run, 152, 100, 189, 119, run('WATER')); rect(run, 160, 70, 181, 79, run('SAND')); +const pond = run('worldWater()'); ticks(run, 3600); +const sandLow = count(run, run('SAND'), 152, 112, 189, 119), waterHigh = count(run, run('WATER'), 152, 90, 189, 111); +check('sand dropped in a pond sinks to the bottom, the water ends up on top', grains('SAND') === 220 && run('worldWater()') === pond && sandLow > 200 && waterHigh > 400, + `${sandLow} of ${grains('SAND')} sand in the bottom rows, ${waterHigh} water above, water ${pond} -> ${run('worldWater()')}`); +asleep('a sandy pond'); +// powders against the world's sides and on the bottom row neither leak nor wrap +world(80); +rect(run, 0, 60, 5, 79, run('SAND')); rect(run, 74, 60, 79, 79, run('SAND')); +ticks(run, 1200); +check('sand piled against the world edges stays in the world', grains('SAND') === 240 && count(run, run('SAND'), 0, 0, 79, 119) === 240, `${grains('SAND')} grains`); + +asleep('sand at the world edges'); +world(80, 600); run('AMB.fill(20); temp.fill(20);'); +rect(run, 30, 599, 49, 599, run('SAND')); ticks(run, 600); +check('sand on the bottom row neither leaks nor wraps', grains('SAND') === 20 && count(run, run('SAND'), 30, 599, 49, 599) === 20 && awake() === 0, + `${grains('SAND')}/20 total, ${count(run, run('SAND'), 30, 599, 49, 599)} still on bottom row, ${awake()} awake`); +// Filters already separate a mixed load in t_conveyors; this checks the grate itself. +world(80); +rect(run, 19, 60, 19, 119, run('STONE')); rect(run, 40, 60, 40, 119, run('STONE')); +run('for (let bx = 5; bx < 10; bx++) placeBlock(BP_GRATE, 0, bx, 25, true);'); +rect(run, 24, 70, 35, 79, run('SAND')); ticks(run, 1200); +check('sand stays on a grate without slipping through', grains('SAND') === 120 && count(run, run('SAND'), 20, 104, 39, 119) === 0 && awake() === 0, + `${grains('SAND')}/120 sand, ${count(run, run('SAND'), 20, 104, 39, 119)} below, ${awake()} awake`); +// Movement alone keeps the liquids molten and steam gaseous while measuring their flow. +function motion(n) { + run(`for (let k = 0; k < ${n}; k++) { tick++; tickStamp = (tickStamp % 255) + 1; simMove(); for (let c = 0; c < NCH; c++) if (awake[c]) awake[c]--; }`); +} +function asleep(name) { + check(`${name} settles and its areas sleep`, awake() === 0, `${awake()} awake areas`); +} + +// Molten metal +const levelTimes = []; +for (const m of ['WATER', 'MCU']) { + world(80); + rect(run, 38, 60, 41, 99, run(m)); + let flatAt = 0; + for (let t = 1; t <= 2400; t++) { + motion(1); + const a = tops(run(m), 0, 79, 120); + if (Math.max(...a) - Math.min(...a) <= 2 && Math.max(...a) < 120) { flatAt = t; break; } + } + levelTimes.push(flatAt); motion(600); + const a = tops(run(m), 0, 79, 120), uneven = Math.max(...a) - Math.min(...a); + check(`${m === 'MCU' ? 'molten copper' : 'water'} spreads into a level pool without losing grains`, flatAt > 0 && uneven <= 2 && grains(m) === 160 && awake() === 0, + `within 2 grains of level after ${flatAt} ticks, final difference ${uneven}, ${grains(m)}/160 grains, ${awake()} awake`); +} +check('molten copper takes longer than water to level', levelTimes[1] > levelTimes[0] && levelTimes[0] > 0, + `water ${levelTimes[0]} ticks, copper ${levelTimes[1]} ticks`); +world(80); +run('for (let x = 30; x < 50; x++) setCell(119*W+x, MCU, FRZ[MCU] + 1, 0);'); +const freezePoint = run('FRZ[MCU]'); +let firstSet = null; +for (let t = 0; t < 300 && firstSet === null; t++) { + ticks(run, 1); + firstSet = run('(() => { const i = mat.indexOf(COPPER); return i < 0 ? null : temp[i]; })()'); +} +ticks(run, 2400); +check('copper cools below its setting point and keeps all its metal', firstSet !== null && firstSet < freezePoint && grains('COPPER') === 20 && grains('MCU') === 0 && awake() === 0, + `first solid ${firstSet}°C, setting point ${freezePoint}°C, ${grains('COPPER')} solid + ${grains('MCU')} molten /20, ${awake()} awake`); +world(80); +rect(run, 19, 70, 19, 119, run('STONE')); rect(run, 60, 70, 60, 119, run('STONE')); +rect(run, 20, 100, 59, 119, run('WATER')); +run('for (let y = 70; y < 80; y++) for (let x = 38; x < 42; x++) setCell(y*W+x, MCU, 1160, 0);'); +run('WS = -1557203080; weather.wind = 0.3;'); // rain passing through the plume exercises displaced steam +const quenchWater = run('worldWater()'); let steamPeak = 0; +for (let t = 0; t < 60; t++) { ticks(run, 5); steamPeak = Math.max(steamPeak, grains('STEAM')); } +ticks(run, 3600); +const bottomCopper = count(run, run('COPPER'), 20, 110, 59, 119); +check('copper poured into a pond sinks, sets and makes steam without losses', bottomCopper === 40 && grains('COPPER') + grains('MCU') === 40 && steamPeak > 0 && run('worldWater()') === quenchWater, + `${bottomCopper}/40 copper at bottom, ${grains('COPPER')} solid + ${grains('MCU')} molten, peak ${steamPeak} steam, water ${quenchWater} -> ${run('worldWater()')}`); +asleep('a quenched copper pond'); +world(80); +rect(run, 29, 70, 29, 119, run('STONE')); rect(run, 50, 70, 50, 119, run('STONE')); +run('for (let y = 90; y < 100; y++) for (let x = 30; x < 50; x++) setCell(y*W+x, (x+y)%2 ? MCU : MSLAG, 1300, 0);'); +motion(2400); +const slagDensity = run('DENS[MSLAG]'), copperDensity = run('DENS[MCU]'); +const upperSlag = count(run, run('MSLAG'), 30, 110, 49, 114), lowerCopper = count(run, run('MCU'), 30, 115, 49, 119); +// Local density swaps leave small pockets mixed when the pool sleeps. +check('lighter molten slag mostly floats above molten copper', slagDensity < copperDensity && upperSlag >= 95 && lowerCopper >= 95 && oreY(run('MSLAG')) < oreY(run('MCU')) && grains('MSLAG') === 100 && grains('MCU') === 100, + `upper slag ${upperSlag}/100, lower copper ${lowerCopper}/100; densities slag ${slagDensity.toFixed(1)}, copper ${copperDensity}; mean rows slag ${oreY(run('MSLAG')).toFixed(1)}, copper ${oreY(run('MCU')).toFixed(1)}, grains ${grains('MSLAG')} + ${grains('MCU')}`); +asleep('separated slag and copper'); +// The other mold suites pre-fill their cavities; here the metal must flow in from above. +world(80); +run(`addMold('ingot', 8, 27, RSTONE, true); + for (let y = 100; y < 108; y++) { setCell(y*W+32, STONE, 20, 0); setCell(y*W+47, STONE, 20, 0); } + for (let y = 104; y < 106; y++) for (let x = 33; x < 47; x++) setCell(y*W+x, MCU, 1160, 0);`); +motion(1200); +const cavity = run('molds[0].cavs[0].length'), filled = run('molds[0].cavs[0].filter(i => mat[i] === MCU).length'); +check('a copper pour fills every grain of an ingot cavity', filled === cavity && grains('MCU') === 28 && awake() === 0, + `${filled}/${cavity} cavity filled, ${grains('MCU')}/28 total, ${awake()} awake`); + +// Landing must keep steam it displaces, for both the direct and nearby fallback paths. +for (const nearby of [false, true]) { + world(80); + rect(run, 39, 90, 39, 90, run('STEAM')); + run('flyers.push({ m: WATER, T: 20 });'); + const before = run('worldWater()'); + run(`${nearby ? 'depositNear(WATER, 20, 39, 90)' : 'landFlyer(flyers[0], 39, 90)'}; flyers.pop();`); + check(`a rain drop landing in steam keeps both grains${nearby ? ' when finding a nearby space' : ''}`, run('worldWater()') === before && grains('WATER') === 1, + `water total ${before} -> ${run('worldWater()')}, ${grains('WATER')} water, ${run('weather.moist.reduce((a,b) => a+b, 0)')} in clouds`); + ticks(run, 600); asleep('water after landing in steam'); +} + +// Gases +function chamber() { + world(80); + rect(run, 19, 60, 60, 60, run('STONE')); + rect(run, 19, 60, 19, 119, run('STONE')); rect(run, 60, 60, 60, 119, run('STONE')); + run('updateRoof();'); +} +function smoke() { + run('for (let y = 95; y < 100; y++) for (let x = 37; x < 43; x++) { setCell(y*W+x, SMOKE, AMB[y], 0); life[y*W+x] = 220; }'); +} +const smokeLeft = []; +for (const sealed of [false, true]) { + if (sealed) chamber(); else world(80); + smoke(); ticks(run, 30); + check(`smoke rises ${sealed ? 'toward a sealed roof' : 'outdoors'}`, oreY(run('SMOKE')) < 85, + `mean row ${oreY(run('SMOKE')).toFixed(1)} from 97, ${grains('SMOKE')}/30 smoke`); + ticks(run, 90); smokeLeft.push(grains('SMOKE')); + ticks(run, sealed ? 7200 : 600); + check(`smoke eventually fades ${sealed ? 'under a roof' : 'outdoors'} and sleeps`, grains('SMOKE') === 0 && awake() === 0, + `${grains('SMOKE')} smoke, ${awake()} awake`); +} +check('a sealed roof holds smoke longer than open air', smokeLeft[1] > smokeLeft[0], + `at 120 ticks: outdoors ${smokeLeft[0]}/30, roof ${smokeLeft[1]}/30`); +chamber(); +run('for (let y = 95; y < 100; y++) for (let x = 37; x < 43; x++) setCell(y*W+x, STEAM, 110, 0);'); +motion(100); +const roofSteam = count(run, run('STEAM'), 20, 61, 59, 63), steamTops = tops(run('STEAM'), 20, 59, 120); +const steamWidth = steamTops.filter(y => y === 61).length; +check('trapped gas spreads along the ceiling without crossing stone', roofSteam >= 25 && steamWidth > 6 && grains('STEAM') === 30 && count(run, run('STEAM'), 20, 61, 59, 119) === 30 && count(run, run('STEAM'), 0, 0, 79, 59) === 0, + `${roofSteam}/30 under roof, ${steamWidth} columns along ceiling, ${grains('STEAM')} total, 0 expected above roof: ${count(run, run('STEAM'), 0, 0, 79, 59)}`); +run('AMB.fill(20); temp.fill(20); for (let x = 20; x < 60; x++) wakeHeatXY(x, 61);'); +ticks(run, 1800); +check('steam collected under a roof condenses and runs down to the floor', grains('STEAM') === 0 && count(run, run('WATER'), 20, 118, 59, 119) === 30 && awake() === 0, + `${grains('STEAM')} steam, ${count(run, run('WATER'), 20, 118, 59, 119)}/30 water at floor, ${awake()} awake`); +chamber(); +run('for (let x = 30; x < 50; x++) { setCell(90*W+x, FIRE, 800, 0); life[90*W+x] = 25; }'); +ticks(run, 600); +const burning = run('flags.reduce((n, f) => n + !!(f & F_BURN), 0)'); +check('fire with no fuel goes out and leaves nothing burning', grains('FIRE') === 0 && burning === 0 && awake() === 0, + `${grains('FIRE')} flames, ${burning} burning grains, ${awake()} awake`); +// Fully blocked gas cannot keep itself awake by moving sideways. +for (const m of ['SMOKE', 'STEAM', 'FIRE']) { + world(80); + rect(run, 38, 89, 40, 91, run('STONE')); + run(`setCell(90*W+39, ${m}, 20, 0); life[90*W+39] = ${m === 'SMOKE' ? 10 : 25};`); + ticks(run, 1200); + check(`${m.toLowerCase()} in a sealed one-grain pocket finishes changing and sleeps`, grains(m) === 0 && awake() === 0 && (m !== 'STEAM' || grains('WATER') === 1), + `${grains(m)} gas left, ${grains('WATER')} water, ${awake()} awake`); +} +for (const m of ['SMOKE', 'STEAM', 'FIRE']) { + world(80); + run(`for (const x of [0, 40, W-1]) { setCell(x, ${m}, 110, 0); life[x] = 25; }`); + ticks(run, 10); + check(`${m.toLowerCase()} on the top row leaves cleanly`, grains(m) === 0 && awake() === 0 && (m !== 'STEAM' || run('worldWater()') === 3), + `${grains(m)} gas left, ${awake()} awake, water total ${run('worldWater()')}`); +} + +console.log(`Physics suite time: ${Date.now() - started} ms`); +done();