// Sloped belts: transport, joins, sorting, persistence and the actual drag controls. const { load, suite, flatWorld, rect, ticks, count } = require('./harness'); const fs = require('fs'); const { run } = load(), { check, done } = suite(); function fresh() { flatWorld(run, 256, 224, 208); run('RS=7;nextPowerBuild=0;money=10000;AMB.fill(20);temp.fill(20);hot.fill(0);'); } fresh(); check('slope belts use new codes, without renumbering old slopes', run("typeof BP_BELT_UP !== 'undefined' && BP_BELT_UP > BP_SLOPE_TR")); function line(grade, filter = false, dir = 1) { fresh(); const by = grade < 0 ? 32 : 16, inlet = by + (grade < 0 ? 1 : 0), outlet = by + 9 * grade + (grade > 0 ? 1 : 0); run(`for(let k=0;k<10;k++) placeBlock(${filter ? 'BP_FILTER_UP' : 'BP_BELT_UP'}+${grade>0?1:0},${filter?dir*128:dir},16+k,${by}+k*${grade},true,{m:[SAND],inv:0}); for(let x=10;x<16;x++)placeBlock(BP_BELT,${dir},x,${inlet},true); for(let x=26;x<32;x++)placeBlock(BP_BELT,${dir},x,${outlet},true);`); return { by, inlet, outlet, grade }; } function collect(s, steps, feed = false, sink = 128) { return run(`(()=>{let out=0,added=0;for(let k=0;k<${steps};k++){ ${feed ? `for(let l=1;l<=4;l++){const i=(${s.inlet}*4-l)*W+44;if(mat[i]===EMPTY){setCell(i,SAND,20,0);added++;}}` : ''} simTick(); for(let i=0;i=${sink}){out++;setCell(i,EMPTY,20,0);} for(let j=flyers.length-1;j>=0;j--)if(flyers[j].m===SAND && flyers[j].x>=${sink}){out++;flyers.splice(j,1);} }return {out,added,left:mat.reduce((n,m)=>n+(m===SAND),0)+flyers.filter(f=>f.m===SAND).length};})()`); } const rates=[]; for(const grade of [0,-1,1]) { let s; if(grade) s=line(grade); else {fresh();run('for(let bx=10;bx<32;bx++)placeBlock(BP_BELT,1,bx,24,true);');s={inlet:24};} collect(s,220,true); const r=collect(s,600,true);rates.push(r.out/10); check(`grade ${grade} moves a four-grain load at flat belt speed`,r.out>0 && (grade===0 || Math.abs(r.out/10-rates[0])<=rates[0]*0.05),JSON.stringify(r)); const end=collect(s,600); check(`grade ${grade} leaves no load stuck`,end.left===0,JSON.stringify(end)); } console.log('Throughput grains/s flat/up/down: '+rates.join('/')); for(const grade of [-1,1]) { const s=line(grade); rect(run,40,s.inlet*4-8,55,s.inlet*4-1,run('SAND')); rect(run,39,20,39,s.inlet*4-1,run('STONE')); const r=collect(s,1000); check(`grade ${grade} conserves and delivers every grain across both flat joins`,r.out===128 && r.left===0,JSON.stringify(r)); line(grade); run(`spawnBar(BARCU,44,${s.inlet*4-2},0,0);`); ticks(run,500); check(`grade ${grade} carries a bar through both flat joins`,run('ents.length===1 && ents[0].ix>=128'),run('ents.map(e=>[e.ix,e.iy])')); } for(const grade of [-1,1]) { const s=line(grade); rect(run,68,s.by*4-12,71,s.by*4-5,run('WATER')); const before=count(run,run('WATER')); ticks(run,600); check(`water drains through grade ${grade} and is conserved`,count(run,run('WATER'))===before && count(run,run('WATER'),0,200,255,207)===before); // A completely full face held at its head must not unload backwards or through corners. line(grade); run(`for(let x=64;x<104;x++){const by=${s.by}+((x>>2)-16)*${grade},c=blockP[by*BW+(x>>2)],y=beltFaceY(c,x>>2,by,x);for(let l=1;l<=4;l++)setCell((y-l)*W+x,SAND,20,0);}`); rect(run,104,0,104,s.outlet*4-1,run('STONE')); ticks(run,300); const held=run('mat.reduce((n,m)=>n+(m===SAND),0)+flyers.filter(f=>f.m===SAND).length'); check(`blocked grade ${grade} holds every grain without slipping below its face`,held===160 && count(run,run('SAND'),0,0,63,207)===0 && count(run,run('SAND'),0,160,255,207)===0,held); rect(run,104,0,104,s.outlet*4-1,run('EMPTY')); const released=collect(s,800); check(`grade ${grade} resumes after the head clears`,released.out===160 && released.left===0,JSON.stringify(released)); line(grade); run(`for(let bx=26;bx<32;bx++)removeBlock(bx,${s.outlet});setCell((${s.by+9*grade}*4+${grade<0?0:3}-1)*W+103,SAND,20,0);tick=2;tickStamp++;machinesTick();`); check(`grade ${grade} launches off an open end along its face`,run(`flyers.length===1 && flyers[0].vx>0 && Math.sign(flyers[0].vy)===${grade}`)); run(`spawnBar(BARCU,44,${s.inlet*4-2},0,0);`); const barLaunch=run('(()=>{for(let k=0;k<400;k++){simTick();if(ents[0].ix>=104)return ents[0].vx>0;}return false;})()'); check(`grade ${grade} keeps a bar moving forward off an open end`,barLaunch); for(const diagonal of [false,true]) { line(grade); run(`for(let bx=26;bx<32;bx++)removeBlock(bx,${s.outlet});for(let k=0;k<3;k++)placeBlock(BP_SCREW,${diagonal?(grade<0?4:5):1},26+k,${s.by+9*grade+(grade<0?-1:0)}+k*${diagonal?grade:0},true);`); rect(run,40,s.inlet*4-8,55,s.inlet*4-1,run('SAND')); rect(run,39,20,39,s.inlet*4-1,run('STONE')); const r=collect(s,1000,false,116); check(`grade ${grade} unloads grains into a ${diagonal?'diagonal':'straight'} screw`,r.out===128 && r.left===0,JSON.stringify(r)); run(`spawnBar(BARCU,44,${s.inlet*4-2},0,0);`); ticks(run,600); check(`grade ${grade} unloads a bar into a ${diagonal?'diagonal':'straight'} screw`,run('ents.length===1 && ents[0].ix>=116 && !mat.some((m,i)=>occ[i] && PH[m]===PH_SOLID)'),run('ents.map(e=>[e.ix,e.iy])')); } } for(const grade of [-1,1]) { const s=line(grade,true); run('for(const b of filterCfg.keys())setFilter(b,{m:[SAND],inv:1});'); collect(s,220,true); const r=collect(s,600,true); console.log(`Filter throughput grade ${grade}, grains/s: ${r.out/10}`); check(`carrying filter grade ${grade} matches the belt feed`,r.out/10>=rates[0]*0.95,JSON.stringify(r)); } for(const grade of [-1,1]) for(const inv of [0,1]) { const s=line(grade,true); run(`for(const b of filterCfg.keys())setFilter(b,{m:[SAND],inv:${inv}});`); rect(run,39,20,39,s.inlet*4-1,run('STONE')); run(`for(let y=${s.inlet*4-8};y<${s.inlet*4};y++)for(let x=40;x<56;x++)setCell(y*W+x,(x+y)&1?SAND:GRAVEL,20,0);`); const result=run(`(()=>{const out=[0,0],below=[0,0];for(let k=0;k<1200;k++){ simTick(); for(let i=0;i=128){out[m]++;setCell(i,EMPTY,20,0);}else if(y>=180){below[m]++;setCell(i,EMPTY,20,0);}} for(let f=flyers.length-1;f>=0;f--){const p=flyers[f];if((p.m===SAND || p.m===GRAVEL) && p.x>=128){out[p.m===SAND?0:1]++;flyers.splice(f,1);}} }return {out,below,left:mat.reduce((n,m)=>n+(m===SAND || m===GRAVEL),0)+flyers.filter(f=>f.m===SAND || f.m===GRAVEL).length};})()`); console.log(`Sorting grade ${grade} inverse ${inv}: `+JSON.stringify(result)); check(`grade ${grade} ${inv?'carries selected':'drops selected'} and conserves the full mix`,result.out[inv?0:1]===64 && result.below[inv?1:0]===64 && result.left===0 && result.out[inv?1:0]===0 && result.below[inv?0:1]===0,JSON.stringify(result)); } for(const c of ['BP_BELT_UP','BP_BELT_DOWN','BP_FILTER_UP','BP_FILTER_DOWN'])for(const d of [0,1]) { fresh(); run(`placeBlock(${c},${c.includes('FILTER')?d*128:d},20,20,true,{m:[SAND,BARCU],inv:1});globalThis.saved=serializeWorld();deserializeWorld(saved.meta,saved.buffers);`); check(`${c} ${d} saves its shape, direction and settings`,run(`blockP[20*BW+20]===${c} && blockV[20*BW+20]===${c.includes('FILTER')?d*128:d} && blockIntegrity(20*BW+20)===10 ${c.includes('FILTER')?'&& sameFilter(filterInfo(20*BW+20),{m:[SAND,BARCU].sort((a,b)=>a-b),inv:1})':''}`)); run(`globalThis.clip=copyRegion(80,80,83,83);bot.x=96;bot.y=80;undoRecord('paste',()=>pasteGhost(clip,24,20));ghostTick();`); check(`${c} ${d} copy/paste keeps all settings`,run('blockP[20*BW+24]===blockP[20*BW+20] && blockV[20*BW+24]===blockV[20*BW+20] && sameFilter(filterInfo(20*BW+20),filterInfo(20*BW+24))')); run('undoLast();'); check(`${c} ${d} undo clears the paste including its seal`,run(`blockP[20*BW+24]===0 && blockCellList(24,20).every(i=>mat[i]===EMPTY && !part[i]) && mat[slopeSeal(${c},24,20)]===EMPTY && !part[slopeSeal(${c},24,20)]`)); run("undoRecord('cut',()=>cutRegion(80,80,83,83));undoLast();"); check(`${c} ${d} undo restores a cut`,run(`blockP[20*BW+20]===${c} && blockIntegrity(20*BW+20)===10`)); check(`${c} ${d} mirrors its shape and reverses horizontally`,run(`(()=>{const b=flipBlueprint(clip).blocks[0];return b[2]===${c}+${c.endsWith('UP')?1:-1} && b[3]===${c.includes('FILTER')?(1-d)*128:1-d};})()`)); } for(const grade of [-1,1]) { const s=line(grade,true); run(`spawnBar(BARCU,44,${s.inlet*4-2},0,0);`);ticks(run,500); check(`sloped filter grade ${grade} carries a bar it does not pass`,run('ents.length===1 && ents[0].ix>=128')); line(grade,true); run(`for(const b of filterCfg.keys())setFilter(b,{m:[BARCU],inv:0});spawnBar(BARCU,44,${s.inlet*4-2},0,0);`);ticks(run,500); check(`sloped filter grade ${grade} drops a selected bar through its open frame`,run('ents.length===1 && ents[0].ix<104 && ents[0].iy===206 && !mat.some((m,i)=>occ[i] && PH[m]===PH_SOLID)')); } // Reverse the motor without flipping the upward-facing triangle. for(const grade of [-1,1]) { const s=line(grade,false,0); rect(run,112,s.outlet*4-8,127,s.outlet*4-1,run('SAND')); rect(run,128,20,128,s.outlet*4-1,run('STONE')); const r=run(`(()=>{let out=0;for(let k=0;k<1000;k++){simTick();for(let i=0;i=0;f--)if(flyers[f].m===SAND && flyers[f].x<40){out++;flyers.splice(f,1);}}return {out,left:mat.reduce((n,m)=>n+(m===SAND),0)};})()`); check(`reversed grade ${grade} delivers every grain to the left`,r.out===128 && r.left===0,JSON.stringify(r)); line(grade,false,0);run(`spawnBar(BARCU,120,${s.outlet*4-2},0,0);`);ticks(run,500); check(`reversed grade ${grade} carries bars left across both joins`,run('ents.length===1 && ents[0].ix<40'),run('ents.map(e=>[e.ix,e.iy])')); } fresh(); run('placeBlock(BP_BELT_UP,1,20,32,true);for(let l=1;l<=CARRY_DEPTH;l++)setCell((131-l)*W+80,SAND,20,0);tickStamp++;carryColumn(130*W+80,1,null);'); check('a slope carries the same full 96-grain column as a flat belt',run('Array.from({length:CARRY_DEPTH},(_,k)=>mat[(129-k)*W+81]===SAND).every(Boolean)')); run(fs.readFileSync('src/render/slope-belts.js','utf8')); // The renderer prepares joins and power first; stripe pitch is measured along the rotated band. run('prepareSlopeBelts(0,0,W,H);'); check('sloped stripes vary along the face and travel with the motor',run('(()=>{const b=32*BW+20,c=(x,y,t)=>slopeBeltColor(b,x,y,t,0,0,0);return c(81,130,0)!==c(82,129,0) && c(81,130,0)===c(82,129,4);})()')); run(fs.readFileSync('src/ui/selection.js','utf8')); run(fs.readFileSync('src/ui/actions.js','utf8')); run("globalThis.GAME={pieceDir:{belt:1,filter:1},filterCfg:{m:[SAND],inv:0},wallMat:RSTONE};globalThis.hudDirty=false;globalThis.aimBlock=()=>({bx:25,by:15});globalThis.toolId=()=>'build';globalThis.playSfx=()=>{};"); for(const id of ['belt','filter'])for(const [dx,dy] of [[5,-4],[4,5],[-5,4],[-4,-5]]) { run('GAME.drag={bx:20,by:20};'); const c=(id==='belt'?19:21)+(dx*dy>0?1:0); check(`${id} drag ${dx},${dy} picks an upward face and runs along the drag`,run(`pieceCode(PIECE_BY_ID.${id},{bx:${20+dx},by:${20+dy}})===${c} && dragDirFor(PIECE_BY_ID.${id},GAME.drag,{bx:${20+dx},by:${20+dy}})===${dx>0?1:0}`)); } run("globalThis.setStatus=()=>{};globalThis.catOf=()=>0;globalThis.selectTool=()=>{};globalThis.aimBlock=()=>blockOf(GAME.aim.x,GAME.aim.y);"); for(const id of ['belt','filter'])for(const [dx,dy] of [[3,-3],[3,3],[-3,3],[-3,-3]]) { fresh(); run(`globalThis.piece=()=>PIECE_BY_ID.${id};GAME.drag={bx:20,by:20};GAME.aim={x:${(20+dx)*4},y:${(20+dy)*4}};bot.x=80;bot.y=80;primaryRelease(false);`); const c=(id==='belt'?19:21)+(dx*dy>0?1:0), v=dx>0?(id==='filter'?128:1):0; check(`${id} release really builds the diagonal in direction ${dx},${dy}`,run(`Array.from({length:4},(_,k)=>{const b=(20+k*${Math.sign(dy)})*BW+20+k*${Math.sign(dx)};return blockP[b]===${c} && blockV[b]===${v};}).every(Boolean)`)); run('pickUnderCursor();rotate();'); check(`${id} pick and R reverse the motor while preserving a single slope shape`,run(`pieceCode(PIECE_BY_ID.${id})===${c} && pieceV(PIECE_BY_ID.${id})===${id==='filter'?128-v:1-v}`)); run('GAME.drag=aimBlock();GAME.replace=true;primaryRelease(false);GAME.replace=false;'); check(`${id} a single replacement reverses the picked slope`,run(`blockP[${20+dy}*BW+${20+dx}]===${c} && blockV[${20+dy}*BW+${20+dx}]===${id==='filter'?128-v:1-v}`)); } run(fs.readFileSync('src/ui/selection-pickers.js','utf8')); const filterLine=line(-1,true); run("applyFilterRun(filterRun(32*BW+16),{m:[GRAVEL],inv:1,dir:0});"); check('the settings UI updates the matching diagonal filter run',run('filterCfg.size===10 && [...filterCfg.keys()].every(b=>sameFilter(filterInfo(b),{m:[GRAVEL],inv:1}) && blockV[b]===0)')); run('undoLast();'); check('undo restores diagonal filter settings and direction',run('filterCfg.size===10 && [...filterCfg.keys()].every(b=>sameFilter(filterInfo(b),{m:[SAND],inv:0}) && blockV[b]===128)')); // An actual v2 fixture, before either diagonal belt or filter existed. const legacy=JSON.parse(require('zlib').gunzipSync(fs.readFileSync(__dirname+'/fixtures/slopes-v2.json.gz'))); globalThis.legacyBeltSave=legacy; run('deserializeWorld(legacyBeltSave.meta,legacyBeltSave.buffers)'); check('old v2 saves keep all wall slope ids and materials',run('Array.from({length:4},(_,k)=>{const b=6*BW+2+k*3;return blockP[b]===15+k && blockV[b]===RSTONE && blockIntegrity(b)===10;}).every(Boolean)')); done();