// 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'); // Supported layers start asleep, just like a pile the bot walks up to. 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; })()`); function sortingPile(near, similar = false) { world(640); rect(run, 150, 100, 151, 119, run('STONE')); rect(run, 170, 100, 171, 119, run('STONE')); rect(run, 152, 112, 169, 119, run(similar ? 'LDIRT' : 'ASH')); rect(run, 152, similar ? 104 : 108, 169, 111, run(similar ? 'SAND' : 'CUORE')); run(`bot.x = ${near ? 158 : 530}; bot.y = 90; awake.fill(0);`); } sortingPile(true); let sortedAt = 0; for (let t = 8; t <= 600; t += 8) { ticks(run, 8); if (!sortedAt && count(run, run('CUORE'), 152, 116, 169, 119) === 72) sortedAt = t; } check('near the bot, resting ore sorts to the bottom of ash within 600 ticks', sortedAt > 0 && grains('CUORE') === 72 && grains('ASH') === 144, `all 72 ore at bottom after ${sortedAt || '>600'} ticks, ${grains('CUORE')} ore + ${grains('ASH')} ash`); ticks(run, 600); asleep('sorted ore near the bot'); sortingPile(false); const ore0 = oreY(run('CUORE')); ticks(run, 1200); check('far from the bot, resting ore on ash stays put and sleeps', oreY(run('CUORE')) === ore0 && grains('CUORE') === 72 && grains('ASH') === 144 && awake() === 0, `ore rows ${ore0} -> ${oreY(run('CUORE'))}, ${awake()} awake`); // Visiting an old sleeping pile must work, too (not only freshly placed grains). run('bot.x = 158;'); ticks(run, 600); check('approaching a sleeping pile starts sorting', count(run, run('CUORE'), 152, 116, 169, 119) === 72, `${count(run, run('CUORE'), 152, 116, 169, 119)}/72 ore at bottom`); for (const near of [true, false]) { sortingPile(near, true); const sand0 = oreY(run('SAND')); ticks(run, 6000); check(`sand on dirt (close in weight) stays put and sleeps ${near ? 'near' : 'far from'} the bot`, oreY(run('SAND')) === sand0 && grains('SAND') === 144 && grains('LDIRT') === 144 && awake() === 0, `sand rows ${sand0} -> ${oreY(run('SAND'))}, ${awake()} awake`); } world(); ticks(run, 8); run('wakeBotPowder();'); check('nearby areas without powder are skipped after one visit', awake() === 0, `${awake()} awake`); rect(run, 152, 116, 169, 119, run('SAND')); ticks(run, 16); run('wakeBotPowder();'); check('processed areas remember powder added after an empty visit', awake() > 0, `${awake()} awake`); // 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();