Water pours off ledges several times faster, raining clouds spread boiled-off water (t21)

- Liquids step up to 8 cells a tick toward a drop (fewer the thicker they are), and a top-layer
  grain looks 4 times as far for one, so pools empty and thin film drains. A 100-wide pool
  empties in about 50 s; the old code left 24-650 grains stranded on wide ledges.
- Only a top-layer grain stepping sideways wakes the row ahead, so settled lakes still sleep.
- World generation settles at the old speed: seeds make the same worlds as before.
- Cooled steam under a roof no longer stays steam for ever (its heat area slept).
- Raining clouds keep drifting at half pace and rain over a band either side, so a waterfall into
  a hot shaft no longer rains straight back on its own lake.
- New test/t_water.js; t_weather rain-location checks; t_conveyors counts steam in flight;
  t_molds seeds the random burning-bed check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 20:44:06 -04:00
co-authored by Claude Opus 5.5
parent 99d04b4972
commit ea1dd387b1
10 changed files with 195 additions and 20 deletions
+2 -1
View File
@@ -24,7 +24,8 @@ ticks(run, 900);
const wOut = count(run, run('WATER'), 124, 0, 319, 119), sOut = count(run, run('SAND'), 124, 0, 319, 119);
check('a lift standing in a flooded pit brings up the sand but not the water', sOut > 150 && wOut === 0, `sand out ${sOut}, water out ${wOut}, water standing in the tubes ${tubeLiquid()}`);
const pitClouds = run('weather.moist.reduce((a, v) => a + v, 0)');
check('the pit keeps its water (bar what steams off into the clouds)', water0 - count(run, run('WATER')) - pitClouds <= 6, `${water0} -> ${count(run, run('WATER'))}, ${pitClouds.toFixed(0)} in the clouds`);
const pitSteam = count(run, run('STEAM')); // the pit's floor is hot: some is steam on its way up at any moment
check('the pit keeps its water (bar what steams off into the clouds)', water0 - count(run, run('WATER')) - pitSteam - pitClouds <= 6, `${water0} -> ${count(run, run('WATER'))}, ${pitSteam} steam, ${pitClouds.toFixed(0)} in the clouds`);
fresh();
run('for (let bx = 20; bx < 26; bx++) placeBlock(BP_TUBE, 1, bx, 25, true);');
rect(run, 84, 60, 95, 79, run('WATER'));
+3 -2
View File
@@ -94,8 +94,9 @@ run('for (let x = 100; x < 104; x++) setCell(98 * W + x, ASH, 1320, 0); for (let
run('for (let k = 0; k < 120; k++) { for (let y = 90; y < 100; y++) for (let x = 96; x < 108; x++) if (mat[y * W + x] === ASH) temp[y * W + x] = Math.max(temp[y * W + x], 1320); simTick(); }');
check('ash fuses into slag above 1250°C', count(run, run('ASH'), 96, 90, 107, 99) === 0 && count(run, run('MSLAG')) + count(run, run('SLAG')) === 4, `ash ${count(run, run('ASH'), 96, 90, 107, 99)}, slag ${count(run, run('MSLAG')) + count(run, run('SLAG'))}`);
check('ash in a cooler fire stays ash', count(run, run('ASH'), 108, 90, 116, 99) === 4);
// a mixed charge stays mixed until it burns; ore then works its way down through the burning bed
fresh();
// a mixed charge stays mixed until it burns; ore then works its way down through the burning bed.
// The sinking is random: seed it so the result does not hang on what earlier tests drew.
fresh(); run('RS = 7919;');
rect(run, 100, 90, 101, 99, run('STONE')); rect(run, 114, 90, 115, 99, run('STONE'));
run('{ const r = mulberry32(3); for (let y = 90; y <= 99; y++) for (let x = 102; x <= 113; x++) setCell(y * W + x, r() < 0.5 ? CUORE : COAL, AMB[y], 0); }');
const oreY = () => run('(()=>{let s=0,n=0;for(let i=0;i<N;i++) if(mat[i]===CUORE){s+=(i/W)|0;n++;} return s/n})()');
+116
View File
@@ -0,0 +1,116 @@
// Water conservation, settling, ledge pouring, boundaries, pipes and phase changes.
// Tall worlds keep geothermal heat out of the ponds.
const { load, suite, flatWorld, put, rect, count, ticks } = require('./harness');
const { run } = load();
const { check, done } = suite();
const started = Date.now();
const water = () => run('worldWater()');
const awake = () => run('awake.reduce((n, v) => n + (v > 0), 0)');
const grains = m => count(run, run(m));
function world(w = 320, ground = 120) {
flatWorld(run, w, 600, ground);
run('RS = 12345;');
}
function basin(x0, x1, top = 90) {
rect(run, x0 - 1, top, x0 - 1, 119, run('STONE'));
rect(run, x1 + 1, top, x1 + 1, 119, run('STONE'));
}
world(160); basin(40, 79);
rect(run, 40, 100, 79, 119, run('WATER'));
const closed = water(); ticks(run, 36000);
check('a stone pond keeps every grain for ten minutes and sleeps', water() === closed && awake() === 0, `${closed} -> ${water()}, ${awake()} awake areas; old: 800 water, 0 awake`);
world(240);
rect(run, 115, 60, 124, 99, run('WATER')); ticks(run, 3600);
const heights = run(`(() => { const a = []; for (let x = 0; x < W; x++) {
let y = 120; for (let j = 0; j < 120; j++) if (mat[j*W+x] === WATER) { y = j; break; }
a.push(y);
} return a; })()`);
const uneven = Math.max(...heights) - Math.min(...heights);
check('a falling column spreads into a flat sleeping pool', uneven <= 2 && awake() === 0 && water() === 400, `surface difference ${uneven}, ${awake()} awake, ${water()}/400 grains; old: difference 4, 0 awake, 400 water`);
// Old 2400-tick counts: 24 (40x20), 900 (100x20), 1440 (200x10), 0 (middle).
// Old supplied 7200-tick counts: 254 left for 100x20, 1027 for 200x10.
// Old code eventually sleeps with 24, 171 and 645 grains left, even at 100080 ticks.
// Deadlines below allow the new flow to reach <= 2% and <= 20 grains.
for (const [width, depth, middle] of [[40, 20, false], [100, 20, false], [200, 10, false], [40, 20, true]]) {
world();
const left = middle ? 140 : 0, right = left + width - 1;
rect(run, right + 1, 120, 319, 199, run('EMPTY'));
if (middle) rect(run, 0, 120, left - 1, 199, run('EMPTY'));
rect(run, left, 120 - depth, right, 119, run('WATER'));
const deadline = width === 200 ? 13200 : width === 100 ? 4500 : 900;
const before = water(); ticks(run, deadline);
const remaining = count(run, run('WATER'), left, 0, right, 119);
check(`${width}-wide pool pours off ${middle ? 'both sides' : 'the side ledge'} promptly`, remaining <= Math.min(20, before * 0.02) && water() === before, `${remaining}/${before} left at ${deadline} ticks, total ${water()}; old at same deadline: ${middle ? 0 : width === 40 ? 142 : width === 100 ? 524 : 706}`);
}
for (const edge of [0, 319]) {
world();
const left = edge === 0 ? 0 : 310, right = left + 9;
rect(run, edge === 0 ? 10 : 309, 90, edge === 0 ? 10 : 309, 119, run('STONE'));
rect(run, left, 100, right, 119, run('WATER')); ticks(run, 1800);
const local = count(run, run('WATER'), left, 90, right, 119);
check(`water rests against the ${edge === 0 ? 'left' : 'right'} world wall without wrapping or leaking`, local === 200 && grains('WATER') === 200 && water() === 200 && awake() === 0, `${local}/200 inside, ${grains('WATER')} worldwide, ${awake()} awake; old: 200 inside/worldwide, 0 awake`);
}
for (const y of [0, 599]) {
world(80, 600);
// Isolate movement from geothermal boiling at the bottom boundary.
run('AMB.fill(20); temp.fill(20);');
rect(run, 30, y, 39, y, run('WATER')); ticks(run, 1800);
check(`water dropped on the ${y === 0 ? 'top' : 'bottom'} row settles inside the world`, water() === 10 && count(run, run('WATER'), 0, 599, 79, 599) === 10 && awake() === 0, `${water()}/10 grains, ${awake()} awake; old: 10 water, 0 awake`);
}
for (const m of ['WATER', 'SAND', 'STEAM']) {
world(80, 600); run('AMB.fill(20); temp.fill(20);');
for (const x of [0, 79]) for (const y of [0, 599]) put(run, x, y, run(m));
ticks(run, 600);
check(`${m.toLowerCase()} at all four corners runs without throwing`, m !== 'WATER' || water() === 4, `${grains(m)} grains remain, water total ${water()}; old: ${m === 'STEAM' ? 0 : 4} grains, water total ${m === 'SAND' ? 0 : 4}`);
}
// t_pipes already checks line transport, conservation, valves and full pipes.
function pumpRig(sealed) {
world(240); basin(40, 71, 80);
rect(run, 40, 90, 71, 119, run('WATER'));
run(`placeBlock(BP_PUMP, 1, 17, 29, true);
for (let by = 19; by < 29; by++) placeBlock(BP_PIPE, 0, 17, by, true);
for (let bx = 18; bx < 30; bx++) placeBlock(BP_PIPE, 0, bx, 19, true);
placeBlock(BP_OUTLET, 0, 30, 19, true);`);
basin(120, sealed ? 123 : 159, 80);
run('updateRoof(); rebuildPipes();');
}
pumpRig(false);
const pumped = water(); ticks(run, 6000);
// The last 25 bottom-row grains are beyond the intake (x65..67); no drop draws them closer.
const unreachable = count(run, run('WATER'), 40, 80, 71, 119);
check('a pump drains a pond down to its intake', unreachable === 25 && count(run, run('WATER'), 40, 119, 64, 119) === 25 && water() === pumped, `${unreachable} unreachable, total ${pumped} -> ${water()}; old: 25 unreachable, 960 total`);
pumpRig(true);
const filling = water(); ticks(run, 2400);
const full = count(run, run('WATER'), 120, 80, 123, 119), held = run('pipeNets[0].n');
ticks(run, 600);
check('a full closed basin stops its outlet without losing water', full === 160 && count(run, run('WATER'), 120, 80, 123, 119) === full && run('pipeNets[0].n') === held && water() === filling, `${full}/160 in basin, line ${held} -> ${run('pipeNets[0].n')}, total ${filling} -> ${water()}; old: 160 basin, 384 line, 960 total`);
world(160); basin(40, 79);
run('for (let y = 60; y < 140; y++) AMB[y] = -6;');
rect(run, 40, 110, 79, 119, run('WATER'));
const frozen = water(); ticks(run, 6000);
check('a pond in freezing air ices over without losing water', grains('ICE') >= 40 && water() === frozen, `${grains('ICE')} ice, ${grains('WATER')} water, total ${water()}/${frozen}; old: 400 ice, 0 water, 400 total`);
run('AMB.fill(20); for (let y = 60; y < 140; y++) for (let x = 39; x <= 80; x++) wakeHeatXY(x, y);');
ticks(run, 6000);
check('a thawed pond returns all its water', grains('WATER') === frozen && water() === frozen, `${grains('WATER')}/${frozen} water, ${grains('ICE')} ice; old: 400 water, 0 ice`);
world(160); basin(40, 79, 80);
rect(run, 39, 79, 80, 79, run('STONE'));
run('for (let y = 116; y < 120; y++) for (let x = 40; x < 80; x++) setCell(y*W+x, COPPER, 600, 0);');
rect(run, 40, 115, 79, 115, run('WATER')); run('updateRoof();');
const boiling = water(); let peakSteam = 0;
for (let k = 0; k < 120; k++) { ticks(run, 5); peakSteam = Math.max(peakSteam, grains('STEAM')); }
check('water on hot copper boils into steam', peakSteam > 0 && water() === boiling, `peak ${peakSteam} steam, total ${water()}/${boiling}; old: peak 20 steam, 40 total`);
ticks(run, 24000);
check('steam under a roof returns to water without escaping', grains('WATER') === boiling && water() === boiling && grains('STEAM') === 0, `${grains('WATER')}/${boiling} water, ${grains('STEAM')} steam; old: 37 water, 3 steam`);
// a pool pouring into a shaft down to rock above 200°C: the water boils off and goes into the
// clouds, which spread it out downwind, so the pool really drains and no water is made or lost
flatWorld(run, 640, 400, 120);
const hotY = run('(() => { for (let y = 120; y < H; y++) if (AMB[y] > 200) return y; return -1; })()');
rect(run, 60, 100, 61, 119, run('STONE')); rect(run, 62, 105, 159, 119, run('WATER'));
rect(run, 160, 120, 163, hotY + 20, run('EMPTY'));
run(`for (let y = ${hotY}; y <= ${hotY + 21}; y++) for (let x = 159; x <= 164; x++) if (mat[y*W+x] === STONE) temp[y*W+x] = AMB[y]; weather.wind = 0.3; updateRoof();`);
const shaftPool = () => count(run, run('WATER'), 40, 0, 220, 119);
const shaftWater = water(), shaftPool0 = shaftPool();
ticks(run, 20000);
check('a pool pouring into a hot shaft drains away and no water is made or lost', water() === shaftWater && shaftPool() < shaftPool0 * 0.4, `pool ${shaftPool0} -> ${shaftPool()}, ${Math.round(run('weather.moist.reduce((a, v) => a + v, 0)'))} in the clouds, total ${shaftWater} -> ${water()}`);
console.log(`Water suite time: ${Date.now() - started} ms`);
done();
+12 -4
View File
@@ -27,16 +27,24 @@ rect(run, 150, 100, 169, 109, run('STEAM'));
run('updateRoof(); for (let y = 100; y < 110; y++) for (let x = 150; x < 170; x++) temp[y*W+x] = 60;');
ticks(run, 600);
check('under a roof steam still turns back into water', count(run, run('WATER')) > 120 && moist() === 0, `water ${count(run, run('WATER'))}, clouds ${moist()}`);
// clouds rain back out over the band where they gathered
// clouds rain back out over the band where they gathered, and a band either side
flatWorld(run, 512, 200, 120);
run('weather.moist[3] = 400; weather.wind = 0;');
ticks(run, 2);
check('a big cloud starts to rain', run('weather.raining[3]') === 1);
ticks(run, 900);
let drops = 0, near = 0; // where the drops fall (the water spreads out over the flat ground after it lands)
for (let k = 0; k < 90; k++) { ticks(run, 10); for (const x of JSON.parse(run('JSON.stringify(flyers.filter(f => f.rain).map(f => f.x | 0))'))) { drops++; if (x >= 2 * 64 && x < 5 * 64) near++; } }
const landed = count(run, run('WATER'));
const near = count(run, run('WATER'), 3 * 64 - 64, 0, 4 * 64 + 64, 199);
check('a cloud rains its water back down', landed + rainInAir(run('WATER')) + moist() > 390 && landed > 250, `landed ${landed}, in the air ${rainInAir(run('WATER'))}, left in the cloud ${moist().toFixed(0)}`);
check('close to where it gathered', near > landed * 0.85, `${near} of ${landed} within a band`);
check('it falls near where it gathered, give or take a band', drops > 0 && near > drops * 0.85, `${near} of ${drops} drops seen within a band`);
// with a wind the raining cloud keeps drifting, so the rain is spread out downwind instead of all
// falling back where the steam rose (a waterfall into a hot shaft would otherwise refill its own lake)
flatWorld(run, 1024, 200, 120);
run('weather.moist[3] = 400; weather.wind = 0.3;');
const fell = new Array(16).fill(0);
for (let k = 0; k < 150; k++) { ticks(run, 10); for (const x of JSON.parse(run('JSON.stringify(flyers.filter(f => f.rain).map(f => f.x | 0))'))) fell[(x / 64) | 0]++; }
const allRain = fell.reduce((a, v) => a + v, 0), downwind = fell.slice(2, 8).reduce((a, v) => a + v, 0);
check('in a wind the rain spreads out downwind of the cloud', allRain > 0 && downwind > allRain * 0.85 && fell[3] < allRain * 0.6, `by band ${fell.join(' ')}`);
// boiling water outdoors comes back as rain
flatWorld(run, 320, 200, 120);
rect(run, 140, 116, 179, 119, run('WATER'));