Files
deepgrain-foundry/test/t_physics.js
T
44r0n7andClaude Opus 5.5 cce81e4eca Piles near the bot sort, and 20 times faster; burning fuel keeps the slow pace (t21)
- SORTP 0.003 -> 0.06: ore sinks through 8 rows of ash in about 7 s near the bot.
- wakeBotPowder(): every 8 ticks, areas with loose grains within 100 grains of the bot get a
  sorting chance; far away, resting piles still sleep.
- Sinking through burning fuel stays at 0.003, so buried burning piles still char and furnace
  beds behave as tuned. World generation settles at the old rate: seeds are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 22:05:03 -04:00

233 lines
15 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');
// 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();