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 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 21:55:04 -04:00
co-authored by Claude Opus 5.5
parent ea1dd387b1
commit 9be04471ee
5 changed files with 240 additions and 4 deletions
+3 -2
View File
@@ -226,7 +226,7 @@ Exploration only: never changes grains or the random stream.
- functions: fogAlloc, fogMark, fogSeen, fogInit, fogLook, fogRevealAround, fogRevealAll - functions: fogAlloc, fogMark, fogSeen, fogInit, fogLook, fogRevealAround, fogRevealAll
- variables: FOG, FOG_SENSE, FOG_SENSE_FADE, fog, FW, FH - 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). 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 - functions: setCell, swapCells, move, enter, powder, passable, liquid, gas, simMove
- variables: SORTP, LSTEP, LFILM - 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 - functions: cutTree, treeHeld, findTreeBases, treeSupportTick, woodRoom, treeFall, coldTick, plantTick, setTreeCell, saplingTick, igniteTree
- variables: treeBases, saplings, treeDraw - 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. 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 - functions: vacuumTargetAt, toolVacuum, nozzle, toolBlow, flyOpen, depositNear, bagTemp, landFlyer, flyTick, toolIgnite
- variables: blowRR, FLY_G, FLY_Q - 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_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_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_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_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_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. - `test/t_smooth.py`: The smooth look: the WebGL pass compiles, draws, falls back cleanly, and the thermal lens still works.
+15
View File
@@ -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 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. 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) ## 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, - `test/t_water.js` (20 checks): conservation, levelling and sleep, pools pouring off ledges,
+2
View File
@@ -126,6 +126,8 @@ function liquid(i, x, y, m) {
} }
} }
function gas(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) { if (m === FIRE) {
const l = life[i]; const l = life[i];
if (l <= 1) { mat[i] = EMPTY; life[i] = 0; wakeXY(x, y); return; } if (l <= 1) { mat[i] = EMPTY; life[i] = 0; wakeXY(x, y); return; }
+8 -2
View File
@@ -108,7 +108,10 @@ function depositNear(m, T, x, y, skip) {
const xx = x + dx, yy = y + dy; const xx = x + dx, yy = y + dy;
if (xx < 0 || yy < 0 || xx >= W || yy >= H) continue; if (xx < 0 || yy < 0 || xx >= W || yy >= H) continue;
const j = yy * W + xx; 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; return false;
@@ -121,7 +124,10 @@ function landFlyer(f, x, y) {
for (let k = 0; k < 8; k++) { for (let k = 0; k < 8; k++) {
const yy = y - k; if (yy < 0) break; const yy = y - k; if (yy < 0) break;
const j = yy * W + x; 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) 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)
} }
+212
View File
@@ -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();