// Basics: drilling to rubble, the vacuum and blowing, walls, the ingot mold and bars, belts, blueprints, save/load. const { load, suite, flatWorld, rect, ticks, count } = require('./harness'); const { run } = load(); const { check, done } = suite(); flatWorld(run, 240, 160, 100); run('player.tankTier = 3; player.flask = false;'); // 1. drill breaks stone loose; nothing goes into the bag run('bot.x = 116; bot.y = 92; for (let k=0;k<80;k++) toolDig(120, 104, 3)'); check('drill leaves bag empty', run('bagTotal') === 0, run('bagTotal')); const rub = count(run, run('RSTONE')); check('drill turns stone into loose rubble', rub > 20, rub); ticks(run, 60); // 2. vacuum with a filter: only rubble rect(run, 100, 96, 104, 99, run('COAL')); ticks(run, 30); const pickup = run('toolVacuum(120, 104, 6, m => m === RSTONE)'); const got = pickup.got; check('vacuum reports the first material picked up', pickup.m === run('RSTONE')); check('vacuum picks only the filtered material', run('bag[RSTONE]') > 0 && run('bag[COAL]') === 0, `${run('bag[RSTONE]')} rubble, ${run('bag[COAL]')} coal`); run('toolVacuum(102, 98, 4, m => true)'); check('empty vacuum reports no first material', run('toolVacuum(10, 10, 1, () => true).m') === 0); check('unfiltered vacuum picks coal', run('bag[COAL]') > 0, run('bag[COAL]')); // 3. drop: grains fall const c0 = run('bag[COAL]'); run('bot.x = 100; bot.y = 60;'); run('for (let k=0;k<5;k++) { toolBlow(80, 70, 1, COAL); simTick(); }'); check('blown coal flies out of the bot', run('flyers.length') > 0, run('flyers.length')); ticks(run, 120); check('blown coal leaves the bag and lands on the ground', run('bag[COAL]') < c0 && run('flyers.length') === 0 && count(run, run('COAL'), 60, 80, 100, 99) > 0, `${c0}->${run('bag[COAL]')} landed ${count(run, run('COAL'), 60, 80, 100, 99)}`); check('no selection explains how to select', run('toolBlow(80, 70, 1, 0).status') === 'Nothing selected: click or Q/E in the bag.'); check('unavailable blow mix explains none are in the bag', run('toolBlow(80, 70, 1, COAL, [CUORE, BARCU]).status') === 'None of the selected materials are in the bag.'); // bot flies through built walls but not through natural rock run('bag[RSTONE] += 64; bagTotal += 64; placeBlocks(BP_WALL, RSTONE, blockRect(30, 14, 30, 17))'); check('bot can overlap a built wall', !run('botOverlapSolid(121, 58)')); check('bot cannot overlap natural rock', run('botOverlapSolid(120, 98)')); // trees: water passes in front of a background trunk; cutting the base drops the tree run('for (let y = 80; y < 100; y++) for (let x = 140; x < 142; x++) bgm[y*W+x] = WOOD; for (let y = 72; y < 80; y++) for (let x = 136; x < 146; x++) bgm[y*W+x] = LEAVES;'); rect(run, 130, 96, 138, 99, run('WATER')); ticks(run, 120); check('water flows past a tree trunk', count(run, run('WATER'), 141, 90, 160, 99) > 0, count(run, run('WATER'), 141, 90, 160, 99)); run('bot.x = 134; bot.y = 88; for (let k=0;k<120;k++) toolDig(140, 99, 1)'); ticks(run, 5); check('cutting the trunk base brings the tree down as wood chunks', run('bgm[85*W+140]') === 0 && count(run, run('LWOOD')) > 20, count(run, run('LWOOD'))); // 4. wall blocks cost rubble and snap to the grid run('bag[RSTONE] = 200; bagTotal = 0; for (let k=0;k e.m === BARCU && e.iy >= molds[0].y + molds[0].h)')); ticks(run, 200); check('bars fall and come to rest', run('ents.every(e => Math.abs(e.vy) < 0.01)'), run('JSON.stringify(ents.map(e=>[e.ix,e.iy]))')); check('bars count as built-up copper', run('stats.bars') === 3); // 6. vacuum bars const bx = run('ents[0].ix + 2'), by = run('ents[0].iy + 1'); run(`toolVacuum(${bx}, ${by}, 4, m => m === BARCU)`); check('vacuum picks up bars', run('bag[BARCU]') >= 1 && run('ents.length') < 3, run('bag[BARCU]')); // 7. belt carries a bar: belt from block (40,24) to (46,24), powered by direct supply run('bot.x = 165; bot.y = 70; bag[BARCU] = 20; bagTotal = 0; for (let k=0;k x0 + 10, `${x0} -> ${x1}`); // 8. bar melts when heated run('const e = ents[ents.length-1]; for (let dy=-2; dy<4; dy++) for (let dx=-2; dx<6; dx++) temp[(e.iy+dy)*W + e.ix+dx] = 1300; e.T = 1100;'); ticks(run, 3); check('hot bar melts into molten copper', count(run, run('MCU')) > 0, count(run, run('MCU'))); // 9. machines snap to blocks run('bag[LWOOD] = 100; bagTotal = 0; for (let k=0;k m && m.type !== "dock").length, blocks: blockP.reduce((a,b)=>a+(b>0),0)})'); run('globalThis.__sv = serializeWorld(); deserializeWorld(__sv.meta, __sv.buffers);'); const after = run('JSON.stringify({bag: bagTotal, ents: ents.length, molds: molds.length, mach: machines.filter(m => m && m.type !== "dock").length, blocks: blockP.reduce((a,b)=>a+(b>0),0)})'); check('save/load keeps bag, bars, molds, machines and blocks', before === after, before + ' vs ' + after); // 12. vacuum catches grains in flight, before loose cells, with the same limits. flatWorld(run, 240, 160, 140); run('bot.x = 98; bot.y = 58; player.flask = false; EV.length = 0;'); run('flyers.push({x: 102.9, y: 62.9, m: SAND});'); const airborne = run('toolVacuum(100, 60, 3, () => true)'); check('vacuum takes airborne sand using its current cell', airborne.got === 1 && airborne.m === run('SAND') && run('bag[SAND] === 1 && flyers.length === 0')); check('airborne pickup emits a suck at the grain cell', run('EV.some(e => e.t === "suck" && e.x === 102 && e.y === 62 && e.m === SAND)')); run('flyers.push({x: 103, y: 63, m: SAND}, {x: 180, y: 60, m: SAND});'); check('vacuum leaves flyers outside the circular brush', run('toolVacuum(100, 60, 3, () => true).got === 0 && flyers.length === 2')); check('vacuum leaves flyers out of reach', run('toolVacuum(180, 60, 3, () => true).got === 0 && flyers.length === 2 && vacuumTargetAt(180, 60, 3) === 0')); run('flyers = [{x: 100, y: 60, m: WATER}];'); check('liquid flyers need the flask for pickup and targeting', run('toolVacuum(100, 60, 3, () => true).got === 0 && vacuumTargetAt(100, 60, 3) === 0 && flyers.length === 1')); run('player.flask = true;'); check('flask allows liquid flyer targeting', run('vacuumTargetAt(100, 60, 3) === WATER')); check('flask allows liquid flyer pickup', run('toolVacuum(100, 60, 3, () => true).got === 1 && bag[WATER] === 1 && flyers.length === 0')); run('flyers = [{x: 100, y: 60, m: WATER, rain: true}];'); const moisture = run('weather.moist.reduce((a, b) => a + b, 0)'); check('rain is neither targeted nor collected', run('vacuumTargetAt(100, 60, 3) === 0 && toolVacuum(100, 60, 3, () => true).got === 0 && flyers.length === 1 && bag[WATER] === 1') && moisture === run('weather.moist.reduce((a, b) => a + b, 0)')); run('flyers = [{x: 100, y: 60, m: COAL}, {x: 101, y: 60, m: SAND, bag: true}];'); check('pickup filter also applies to bag-blown flyers', run('toolVacuum(100, 60, 3, m => m === SAND).got === 1 && flyers.length === 1 && flyers[0].m === COAL && bag[COAL] === 0')); check('targeting finds a material only in flight', run('vacuumTargetAt(100, 60, 3) === COAL')); run('setCell(60*W+102, SAND, 20, 0);'); check('nearer flyer wins over a loose cell', run('vacuumTargetAt(100, 60, 3) === COAL')); run('flyers[0].x = 103;'); check('nearer loose cell wins over a flyer', run('vacuumTargetAt(100, 60, 3) === SAND')); run('spawnBar(BARCU, 100, 60, 0, 0);'); check('bars still take targeting priority', run('vacuumTargetAt(100, 60, 3) === BARCU')); run('ents = []; bag.fill(0); bag[SAND] = bagTotal = bagCap();'); const fullPickup = run('toolVacuum(100, 60, 3, () => true)'); check('full bag leaves both flyers and cells untouched', fullPickup.full && fullPickup.got === 0 && run('flyers.length === 1 && mat[60*W+102] === SAND && bagTotal === bagCap()')); run('bagTake(SAND, 1);'); const lastRoom = run('toolVacuum(100, 60, 3, () => true)'); check('last bag space goes to the flyer before the cell', lastRoom.full && lastRoom.got === 1 && lastRoom.m === run('COAL') && run('flyers.length === 0 && mat[60*W+102] === SAND && bagTotal === bagCap()')); run('bag.fill(0); bagTotal = 0; EV.length = 0; for (let k = 0; k < 50; k++) flyers.push({x: 100, y: 60, m: COAL});'); check('flyers share the 48-grain budget and leave cells for later', run('toolVacuum(100, 60, 3, () => true).got === 48 && flyers.length === 2 && bag[COAL] === 48 && mat[60*W+102] === SAND')); check('flyers emit suck on every other collected grain', run('EV.filter(e => e.t === "suck").length === 24')); check('remaining budget collects both flyers and cells', run('toolVacuum(100, 60, 3, () => true).got === 3 && flyers.length === 0 && bag[COAL] === 50 && bag[SAND] === 1')); // A real screw launches sand; hold the brush below its airborne arc, away from its mouth. flatWorld(run, 240, 160, 140); run('RS = 7; nextPowerBuild = 0; bot.x = 106; bot.y = 70; placeBlock(BP_SCREW, 1, 24, 15, true);'); rect(run, 96, 60, 99, 63, run('SAND')); const screwCatch = run(`(() => { let airborne = 0, caught = 0; for (let k = 0; k < 120; k++) { simTick(); const n = flyers.length; const result = toolVacuum(106, 70, 3, m => m === SAND); airborne += n - flyers.length; caught += result.got; } return {airborne, caught, bag: bag[SAND]}; })()`); check('vacuum held under a screw arc catches its launched sand', screwCatch.airborne === 16 && screwCatch.caught === 16 && screwCatch.bag === 16, JSON.stringify(screwCatch)); // Rock in the bag: solids go in as what drilling them gives, and older saves holding solids load // that way too, so the bag never holds something its list can't show. flatWorld(run, 120, 80, 60); check('putting rock in the bag stores its rubble', run('(() => { bag.fill(0); bagTotal = 0; bagAdd(BASALT, 10, true); bagAdd(CLAY, 5, true); return bag[BASALT] === 0 && bag[RBAS] === 10 && bag[CLAY] === 0 && bag[LCLAY] === 5 && bagTotal === 15; })()')); check('a save holding solid rock loads it as rubble with the same total', run(`(() => { bag.fill(0); bagTotal = 0; const s = serializeWorld(); s.meta.bag[BASALT] = 1000; s.meta.bag[CLAY] = 200; s.meta.bag[MSLAG] = 7; deserializeWorld(s.meta, s.buffers); return bag[BASALT] === 0 && bag[RBAS] === 1000 && bag[CLAY] === 0 && bag[LCLAY] === 200 && bag[MSLAG] === 7 && bagTotal === 1207; })()`)); done();