- 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>
213 lines
14 KiB
JavaScript
213 lines
14 KiB
JavaScript
// 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();
|