// Trees from acorns, charcoal, brick, density, water/lava/snow/ice, full-bag // refunds, drill reach, ballistic blowing, snowy mountains. const { load, suite, flatWorld, rect, ticks, count } = require('./harness'); const { run } = load(); const { check, done } = suite(); const fresh = () => { flatWorld(run, 240, 160, 100); run('player.tankTier = 3; saplings = []; stats.grown = 0;'); }; const box = (x0, y0, x1, y1, m) => rect(run, x0, y0, x1, y1, m); const { createHash } = require('node:crypto'); const worldHash = () => { const hash = createHash('sha256'); for (const k of ['mat', 'temp', 'flags']) { const a = run(k); hash.update(new Uint8Array(a.buffer, a.byteOffset, a.byteLength)); } return hash.digest('hex'); }; run('generateWorld(4242, 512, 384)'); const generatedHash = worldHash(); const progress = []; run('generateWorld')(4242, 512, 384, (f, label) => progress.push([f, label])); check('generation progress rises to 1 with named steps', progress.length > 10 && progress[0][0] === 0 && progress.at(-1)[0] === 1 && progress.every(([f, label], i) => f >= 0 && f <= 1 && (!i || f >= progress[i - 1][0]) && typeof label === 'string' && label.length > 0)); check('generation callback keeps the same world', worldHash() === generatedHash); // 1. acorns sprout on soil under open sky and grow into a tree fresh(); box(100, 100, 140, 103, run('DIRT')); run('setCell(99*W+120, ACORN, AMB[99], 0); updateRoof();'); ticks(run, 1200); check('acorn sprouts into a sapling', run('saplings.length') === 1 && run('mat[99*W+120]') !== run('ACORN'), run('saplings.length')); ticks(run, 5600); const wood = run('bgm.reduce((a, v) => a + (v === WOOD), 0)'), leaves = run('bgm.reduce((a, v) => a + (v === LEAVES), 0)'); check('sapling grows into a full tree', run('stats.grown') === 1 && wood >= 14 && leaves > 40, `wood ${wood}, leaves ${leaves}`); // acorn on bare stone does not sprout run('setCell(99*W+60, ACORN, AMB[99], 0); updateRoof();'); box(55, 100, 65, 100, run('STONE')); ticks(run, 1500); check('acorn on stone stays an acorn', run('mat[99*W+60]') === run('ACORN') && run('saplings.length') === 0); // cutting the grown tree drops acorns run('bot.x = 114; bot.y = 86; for (let k=0;k<160;k++) toolDig(121, 99, 1)'); ticks(run, 20); check('felling a tree drops acorns and wood', count(run, run('ACORN')) >= 2 && count(run, run('LWOOD')) > 10, `${count(run, run('ACORN'))} acorns, ${count(run, run('LWOOD'))} wood`); // 2. charcoal: wood chunks heated in a sealed stone box (a kiln) char instead of burning fresh(); box(110, 80, 130, 99, run('STONE')); box(112, 82, 128, 97, run('LWOOD')); run('for (let y=80;y<=99;y++) for (let x=110;x<=130;x++) { const i=y*W+x; temp[i] = 520; wakeHeatXY(x,y); }'); for (let k = 0; k < 30; k++) { run('for (let y=80;y<=99;y++) for (let x=110;x<=130;x++) { const i=y*W+x; if (mat[i]===STONE) temp[i] = 560; }'); ticks(run, 20); } const ch = count(run, run('CHARCOAL'), 110, 80, 130, 99), wl = count(run, run('LWOOD'), 110, 80, 130, 99); check('sealed kiln turns wood into charcoal', ch > 100, `charcoal ${ch}, wood left ${wl}`); // smothered: a burning wood pile buried under dirt chars inside fresh(); box(110, 92, 130, 99, run('LWOOD')); run('for (let k=0;k<60;k++){ for (let x=112;x<130;x+=4) toolIgnite(x, 92); simTick(); }'); ticks(run, 60); box(106, 86, 134, 91, run('LDIRT')); ticks(run, 1800); check('a buried burning pile leaves charcoal', count(run, run('CHARCOAL')) > 20, count(run, run('CHARCOAL'))); // charcoal burns hot fresh(); box(110, 94, 130, 99, run('CHARCOAL')); run('for (let k=0;k<60;k++){ toolIgnite(120, 94); simTick(); }'); ticks(run, 600); const tc = run('(()=>{let t=0;for(let y=88;y<100;y++)for(let x=105;x<135;x++)t=Math.max(t,temp[y*W+x]);return Math.round(t)})()'); check('charcoal burns over 1100°C', tc > 1100, tc); // 3. brick: clay lumps mixed into a walled coal fire turn into brick rubble fresh(); box(104, 76, 107, 99, run('STONE')); box(133, 76, 136, 99, run('STONE')); box(104, 76, 136, 79, run('STONE')); run('for (let y=76;y<80;y++) for (let x=118;x<122;x++) setCell(y*W+x, EMPTY, AMB[y], 0)'); run('{ const r = mulberry32(5); for (let y=90;y<=99;y++) for (let x=108;x<=132;x++) setCell(y*W+x, r() < 0.4 ? LCLAY : COAL, AMB[y], 0); }'); run('bot.x = 115; bot.y = 82; for (let k=0;k<60;k++){ for (const x of [110,120,130]) { for (let y=90;y<99;y++) if (mat[y*W+x]===COAL && mat[(y-1)*W+x]===EMPTY) { toolIgnite(x,y); break; } } simTick(); }'); ticks(run, 3600); check('clay lumps in a walled coal fire become brick rubble', count(run, run('RBRICK')) > 15, `${count(run, run('RBRICK'))} brick, ${count(run, run('LCLAY'))} clay left`); // 4. density: a pile sorts heavy under light; wood floats, ore sinks fresh(); box(110, 84, 130, 99, run('COAL')); box(110, 80, 130, 83, run('CUORE')); const oreY0 = run('(()=>{let s=0,n=0;for(let i=0;i{let s=0,n=0;for(let i=0;i oreY0 + 3, `${oreY0.toFixed(1)} -> ${oreY1.toFixed(1)}`); fresh(); box(100, 80, 101, 99, run('STONE')); box(140, 80, 141, 99, run('STONE')); box(102, 86, 139, 99, run('WATER')); box(110, 70, 115, 73, run('LWOOD')); box(125, 70, 130, 73, run('RSTONE')); ticks(run, 900); const wy = run('(()=>{let s=0,n=0;for(let i=0;i{let s=0,n=0;for(let i=0;i 95, `wood y ${wy.toFixed(1)}, rubble y ${ry.toFixed(1)}`); // 5. lava meets water: steam and a basalt crust fresh(); box(100, 80, 101, 99, run('STONE')); box(140, 80, 141, 99, run('STONE')); run('for (let y=90;y<=99;y++) for (let x=102;x<=139;x++) setCell(y*W+x, MAGMA, 1250, 0)'); box(102, 84, 139, 87, run('WATER')); ticks(run, 240); check('water on lava flashes to steam and crusts it to basalt', count(run, run('BASALT')) > 20 && (count(run, run('STEAM')) + count(run, run('WATER'))) < 150, `basalt ${count(run, run('BASALT'))}, steam ${count(run, run('STEAM'))}, water ${count(run, run('WATER'))}`); // 6. snow melts by a fire; water freezes in cold air high up fresh(); box(110, 90, 116, 99, run('COAL')); box(117, 88, 126, 99, run('SNOW')); run('for (let y=88;y<=99;y++) for (let x=117;x<=126;x++) temp[y*W+x] = -5;'); run('bot.x = 108; bot.y = 80; for (let k=0;k<60;k++){ toolIgnite(112, 90); simTick(); }'); ticks(run, 1800); check('fire melts snow into water', count(run, run('SNOW')) < 100 && count(run, run('WATER')) + count(run, run('STEAM')) > 0, `snow ${count(run, run('SNOW'))}, water ${count(run, run('WATER'))}`); check('the air is below freezing high up', run('AMB[5]') < -1, run('AMB[5].toFixed(1)')); fresh(); box(100, 8, 140, 9, run('STONE')); box(100, 4, 101, 7, run('STONE')); box(139, 4, 140, 7, run('STONE')); run('for (let y=5;y<=7;y++) for (let x=102;x<=138;x++) setCell(y*W+x, WATER, AMB[y], 0)'); ticks(run, 2400); check('water up in the cold air freezes into ice', count(run, run('ICE')) > 20, `ice ${count(run, run('ICE'))}, water ${count(run, run('WATER'))}`); const sv = run('(()=>{ const s = serializeWorld(); deserializeWorld(s.meta, s.buffers); let t = 99; for (let i=0;i ${run('money')}`); // 8. drill reach grows with the bit fresh(); run('bot.x = 100; bot.y = 70;'); const far = run('JSON.stringify(toolDig(102, 102, 2))'); check('stone bit cannot reach 30 grains away', JSON.parse(far).dug === 0 && /Out of reach/.test(far), far); run('player.bitTier = 3;'); run('for (let k=0;k<30;k++) toolDig(102, 102, 2)'); check('steel bit reaches that far', count(run, run('RSTONE')) > 5, count(run, run('RSTONE'))); // 9. blown grains fly past the cursor until gravity brings them down fresh(); run('bot.x = 100; bot.y = 60; bag[SAND] = 200; bagTotal = 200;'); for (let k = 0; k < 20; k++) run('toolBlow(120, 55, 0, SAND); simTick();'); ticks(run, 200); const sx = run('(()=>{let s=0,n=0;for(let i=0;i 121 && run('flyers.length') === 0, `landed around x ${sx.toFixed(1)} (cursor at 120)`); // 10. a generated world has a snowy mountain run('generateWorld(4242, 2048, 1024, null)'); const snow = run('(()=>{let n=0;for(let i=0;i 80 && run(`AMB[${peak}]`) < 0, `snow ${snow}, peak y ${peak}, ${run(`AMB[${peak}].toFixed(1)`)}°C`); // the layout: plenty of sky, the first ores as shallow as ever, the deep layers stretched out below check('a third of a new world is sky', Math.abs(run('surfBase / H') - 0.3) < 0.01, run('surfBase')); const shallowOre = run('(()=>{let cu=0,fe=0;for(let x=0;x 2000 && shallowOre[1] > 200, shallowOre); check('the deep layers fill the extra depth', run('depthY(0.905) - surfBase') > 600 && run('Math.abs(depthY(0.28) - surfBase - 131) <= 1'), run('[depthY(0.28), depthY(0.5), depthY(0.905)].map(y => y - surfBase)')); check('it is still warm a hundred cells above the ground', run('AMB[surfBase - 100]') > 0, run('AMB[surfBase - 100].toFixed(1)')); // Compare ore density in the generated shallow and deep underground, excluding air. const oreDepth = run(`(() => { let shallow = 0, shallowOre = 0, deep = 0, deepOre = 0, deepCu = 0; const ores = [COALV, CUV, FEV, SNV, ZNV, AGV, AUV]; for (let y = 0; y < H; y++) { const d = y - surfBase; const fy = d <= shallowD ? d / shallowD * 0.3 : 0.3 + (d - shallowD) / (underD - shallowD) * 0.7; for (let x = 0; x < W; x++) { const m = mat[y * W + x]; if (y < surfY[x] || m === EMPTY) continue; const ore = ores.includes(m); if (fy < 0.25) { shallow++; if (ore) shallowOre++; } if (fy > 0.6) { deep++; if (ore) deepOre++; } if (fy > 0.5 && m === CUV) deepCu++; } } return [shallowOre / shallow, deepOre / deep, deepCu]; })()`); check('deep ground has at least three times the shallow ore density', oreDepth[1] >= 3 * oreDepth[0], `shallow ${oreDepth[0]}, deep ${oreDepth[1]}`); check('copper veins also form below half depth', oreDepth[2] > 0, oreDepth[2]); // Medium seed 424242: keep each ore's increase within its intended balance. run('generateWorld(424242, 3072, 1280, null)'); const oreBalance = run(`(() => { const ids = [COALV, CUV, FEV, ZNV, SNV, AGV, AUV], counts = ids.map(() => 0); let shallowHash = 2166136261; for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) { const m = mat[y * W + x], j = ids.indexOf(m); if (j >= 0) counts[j]++; if (depthU(y) < 0.25) shallowHash = Math.imul(shallowHash ^ (j >= 0 ? m : 0), 16777619) >>> 0; } return { counts, shallowHash }; })()`); const oldOre = [33493, 26508, 77679, 11797, 7723, 3638, 1681]; const oreNames = ['coal', 'copper', 'iron', 'zinc', 'tin', 'silver', 'gold']; oreNames.forEach((name, j) => { const ratio = oreBalance.counts[j] / oldOre[j]; // coal is lower than the rest: seams deep enough to light in the natural heat are left out const lo = j === 0 ? 3 : j < 5 ? 4 : 2.5, hi = j === 0 ? 4 : j < 5 ? 5 : 3.5; check(`${name} stays within its intended increase`, ratio >= lo && ratio <= hi, ratio.toFixed(2)); }); const totalOre = oreBalance.counts.reduce((a, b) => a + b, 0); check('total ore is about 4 to 4.5 times the original', totalOre >= 162519 * 4 && totalOre <= 162519 * 4.5, totalOre); check('shallow ore matches the original generator', oreBalance.shallowHash === 706875613, oreBalance.shallowHash); run('generateWorld(99, 4096, 1536, null)'); const marks = run('(()=>{let lakes=0,peaks=0,inLake=false;for(let x=1;x= 2 && marks[1] >= 2, marks); // --- round 5: trees under water, cut saplings, blown grains stay out of the bag fresh(); box(100, 100, 140, 103, run('DIRT')); run('for (let y = 82; y < 100; y++) for (let x = 120; x < 122; x++) bgm[y*W+x] = WOOD; for (let y = 74; y < 82; y++) for (let x = 115; x < 127; x++) bgm[y*W+x] = LEAVES;'); box(100, 84, 101, 99, run('STONE')); box(140, 84, 141, 99, run('STONE')); box(102, 84, 139, 99, run('WATER')); run('bot.x = 114; bot.y = 80; for (let k=0;k<200;k++) toolDig(120, 98, 1)'); ticks(run, 200); check('a tree under water can be cut down', run('bgm[90*W+120]') === 0 && count(run, run('LWOOD')) > 10, `${count(run, run('LWOOD'))} wood chunks`); fresh(); box(100, 100, 140, 103, run('DIRT')); run('setCell(99*W+120, ACORN, AMB[99], 0); updateRoof();'); ticks(run, 1200 + 3000); run('bot.x = 114; bot.y = 86; for (let k=0;k<160;k++) toolDig(120, 98, 1)'); ticks(run, 30); run('for (let i = 0; i < N; i++) if (mat[i] === ACORN) setCell(i, EMPTY, 20, 0)'); // keep its acorns from sprouting a new one ticks(run, 2400); const left = run('bgm.reduce((a, v) => a + (v === WOOD), 0)'); check('a half-grown tree that is cut down stays down', run('saplings.length') === 0 && left < 4, `trunk cells left ${left}, saplings ${run('saplings.length')}`); fresh(); run('bot.x = 110; bot.y = 85; bag[SAND] = 300; bagTotal = 300;'); for (let k = 0; k < 400 && run('bag[SAND]') > 0; k++) run('toolBlow(125, 99, 5, SAND); simTick();'); ticks(run, 400); check('blown grains never come back into the bag', run('bag[SAND]') === 0 && count(run, run('SAND')) === 300, `bag ${run('bag[SAND]')}, world ${count(run, run('SAND'))}`); // --- round 6: lifts, falling trees, machines without power, the bot and walls fresh(); // a shaft 16 wide, a column of up-tubes from its floor, turned sideways at the top to unload onto the ground run('for (let y = 100; y < 132; y++) for (let x = 120; x < 128; x++) setCell(y*W+x, EMPTY, AMB[y], 0)'); run('bot.x = 110; bot.y = 90;'); // unload well above the ground: a pile that reaches the outlet would block it run('for (let by = 19; by <= 32; by++) placeBlock(BP_TUBE, 0, 30, by, true); for (let bx = 30; bx <= 35; bx++) placeBlock(BP_TUBE, 1, bx, 18, true);'); box(120, 116, 127, 131, run('SAND')); const sand0 = count(run, run('SAND')); ticks(run, 1800); const lifted = count(run, run('SAND'), 136, 0, 239, 99); check('a column of upward screw tubes lifts sand out of a shaft, unpowered', lifted > sand0 * 0.8, `${lifted} of ${sand0} grains brought up and out`); fresh(); run('for (let bx = 25; bx <= 30; bx++) placeBlock(BP_BELT, 1, bx, 22, true); rebuildPower();'); box(101, 84, 104, 87, run('SAND')); ticks(run, 600); check('belts run without any power', count(run, run('SAND'), 110, 0, 239, 99) > 8, count(run, run('SAND'), 110, 0, 239, 99)); fresh(); box(100, 100, 140, 103, run('DIRT')); run('for (let y = 86; y < 100; y++) for (let x = 120; x < 122; x++) bgm[y*W+x] = WOOD; for (let y = 76; y < 86; y++) for (let x = 115; x < 127; x++) bgm[y*W+x] = LEAVES; findTreeBases();'); run('for (let y = 100; y < 104; y++) for (let x = 116; x < 126; x++) setCell(y*W+x, EMPTY, AMB[y], 0)'); ticks(run, 60); check('a tree with its ground dug out from under it falls', run('bgm[90*W+120]') === 0 && count(run, run('LWOOD')) > 10, count(run, run('LWOOD'))); fresh(); // a stone box of sand; the bot flies in through the wall: no sand may end up outside box(110, 80, 113, 99, run('STONE')); box(130, 80, 133, 99, run('STONE')); box(110, 96, 133, 99, run('STONE')); run('for (let y=80;y<100;y++) for (let x=110;x<134;x++) flags[y*W+x] = 0;'); box(114, 84, 129, 95, run('SAND')); const inside0 = count(run, run('SAND'), 114, 0, 129, 99); run('bot.x = 100; bot.y = 88;'); for (let k = 0; k < 120; k++) run('botStep(1, 0, false); simTick();'); for (let k = 0; k < 160; k++) run('botStep(-1, 0, false); simTick();'); ticks(run, 120); check('the bot flying through a wall into sand never pushes sand out', count(run, run('SAND'), 114, 0, 129, 99) === inside0, `${inside0} -> ${count(run, run('SAND'), 114, 0, 129, 99)} inside`); // a settled sand pile with the bot sitting in it: not one grain moves fresh(); box(110, 92, 133, 99, run('STONE')); box(114, 84, 129, 91, run('SAND')); ticks(run, 60); const pile = () => run('(() => { let s = ""; for (let y = 70; y < 92; y++) for (let x = 110; x < 134; x++) s += mat[y * W + x] === SAND ? 1 : 0; return s; })()'); const pile0 = pile(); run('bot.x = 119; bot.y = 84; bot.vx = bot.vy = 0;'); for (let k = 0; k < 90; k++) run('botStep(0, 0, false); simTick();'); check('the bot resting inside a sand pile leaves every grain where it was', pile() === pile0); // sand poured onto the bot falls through it and piles up just as it would with the bot elsewhere const pour = (bx) => { fresh(); box(110, 96, 133, 99, run('STONE')); run(`bot.x = ${bx}; bot.y = 86; bot.vx = bot.vy = 0;`); for (let k = 0; k < 140; k++) run('if (k_ % 2 === 0) setCell(60 * W + 120, SAND, AMB[60], 0); botStep(0, 0, false); simTick();'.replace('k_', String(k))); ticks(run, 60); return [count(run, run('SAND'), 110, 60, 133, 95), count(run, run('SAND'), 110, 60, 133, 84)]; }; const [withBot, aboveBot] = pour(118), [without] = pour(200); check('sand poured onto the bot falls through it to the ground', withBot === without && aboveBot === 0, `${withBot} grains landed (${without} with the bot away), ${aboveBot} held up above it`); // deep ore never sits in rock hot enough to light or melt it: a reloaded world wakes every area, // and burning coal or running gold down there kept thousands of areas busy forever run('generateWorld(77, 2048, 1024, null)'); const hotOre = run('(() => { let n = 0; for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) { const m = mat[y * W + x]; if ([COALV, CUV, FEV, SNV, ZNV, AGV, AUV].includes(m) && (IGN[m] ? AMB[y] > IGN[m] - 120 : AMB[y] > MELT[m] - 200)) n++; } return n; })()'); check('no ore is generated where the rock is hot enough to light or melt it', hotOre <= 20, hotOre); run('globalThis.__s = serializeWorld(); deserializeWorld(JSON.parse(JSON.stringify(__s.meta)), __s.buffers); for (let k = 0; k < 900; k++) simTick();'); const busy = run('(() => { let aw = 0, fl = 0; for (let c = 0; c < NCH; c++) if (awake[c]) aw++; for (let i = 0; i < N; i++) if (mat[i] === FLAME) fl++; return [aw, NCH, fl]; })()'); check('a reloaded world settles: no fires deep down, few areas still moving', busy[2] === 0 && busy[0] < busy[1] * 0.1, busy); done();