// 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(); if (filter) dir = grade > 0 ? 1 : 0; 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: filter && grade < 0 ? outlet : inlet, outlet, grade, left: filter && grade < 0, feedX: filter && grade < 0 ? 120 : 44 }; } 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+${s.feedX || 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 && ${s.left ? "flyers[j].x<40" : "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); // uphill pockets carry about as much as a flat belt, and downhill runs like one const floor = 60; check(`grade ${grade} carries a generous feed at least ${floor} grains/s`,r.out>0 && r.out/10>=floor,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,${s.feedX || 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} conserves its load and lets overflow roll back`,held===160 && (grade>0 || count(run,run('SAND'),0,0,63,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,${s.feedX || 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,${s.feedX || 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(`downhill screen grade ${grade} discharges its generous feed`,r.out/10>=45 && r.out/10<=90,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,s.left?128:39,20,s.left?128:39,s.inlet*4-1,run('STONE')); run(`for(let y=${s.inlet*4-8};y<${s.inlet*4};y++)for(let x=${s.left?112:40};x<${s.left?128: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) && ${s.left?"p.x<40":"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')?'filterVariant('+c+','+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})':''}`)); if(c.includes('BELT')) { run(`slopePhase[20*BW+20]=${d?1:7};rebuildPower();slopeCleatsTick(false);globalThis.savedPhase=serializeWorld();deserializeWorld(savedPhase.meta,savedPhase.buffers);`); // only an uphill run has cleats const up=(c==='BP_BELT_UP')===(d===1); check(`${c} saves the moving cleat phase and immediately restores its barriers`,run(`slopePhase[20*BW+20]===${d?1:7} && cleatCells.length${up?'>0':'===0'}`)); } 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')?'filterVariant('+c+'+'+(c.endsWith('UP')?1:-1)+',0)':1-d};})()`)); } for(const grade of [-1,1]) { const s=line(grade,true); run(`spawnBar(BARCU,${s.feedX || 44},${s.inlet*4-2},0,0);`);ticks(run,500); check(`sloped filter grade ${grade} slides a bar it does not pass downhill`,run(`ents.length===1 && ${s.left?'ents[0].ix<40':'ents[0].ix>=128'}`)); line(grade,true); run(`for(const b of filterCfg.keys())setFilter(b,{m:[BARCU],inv:0});spawnBar(BARCU,${s.feedX || 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);'); // The ribs hold the load: under a heavy feed no grain is ever inside a rib, and a grain on the bare // face slides down until a rib stops it, then rides up with that rib. { const s=line(-1); const inRib=run(`(()=>{let worst=0;for(let k=0;k<600;k++){for(let l=1;l<=12;l++){const i=(${s.inlet}*4-l)*W+44;if(mat[i]===EMPTY)setCell(i,SAND,20,0);} simTick();let n=0;for(const c of cleatCells)if(PH[mat[c]]===PH_POWDER)n++;if(n>worst)worst=n;}return worst;})()`); check('under a heavy feed no grain is ever inside a rib',inRib===0,inRib); line(-1); // a free face cell high on the run, halfway between two ribs const g=run(`(()=>{for(let x=90;x<100;x++){const b=((32-((x>>2)-16))*BW)+(x>>2),y=beltFaceY(blockP[b],x>>2,32-((x>>2)-16),x); let near=99;for(let h=0;h=3){setCell((y-1)*W+x,SAND,20,0);return x;}}return -1;})()`); const path=run(`(()=>{const xs=[];for(let k=0;k<40;k++){simTick();for(let i=0;i0 && Math.min(...path)Math.min(...path),JSON.stringify(path.slice(-8))); } // A piece added to a running belt takes up its cleat spacing, so no rib starts at the seam and // pushes its pocket into the one ahead (that stalled the whole run). for(const wait of [3,7,11,13]){ fresh(); run(`for(let k=0;k<9;k++)placeBlock(BP_BELT_UP,1,16+k,40-k,true);for(let k=1;k<=6;k++)placeBlock(BP_BELT,1,16-k,41,true); for(let t=0;t<${wait};t++)simTick(); for(let k=9;k<12;k++)placeBlock(BP_BELT_UP,1,16+k,40-k,true);for(let k=1;k<=6;k++)placeBlock(BP_BELT,1,27+k,29,true);`); const r=run(`(()=>{let added=0,past=0;for(let t=0;t<1500;t++){if(t<300&&t%10===0){const i=(41*4-1)*W+50;if(mat[i]===EMPTY){setCell(i,SAND,20,0);added++;}}simTick();} for(let i=0;i=112)past++;return {added,past,same:slopePhase[32*BW+24]===slopePhase[31*BW+25]};})()`); check(`a piece added ${wait} ticks later shares the run's phase and the run keeps carrying`,r.same && r.added>0 && r.past===r.added,JSON.stringify(r)); } // A stationary cleat blocks downhill crossings; the bare face still lets grains slide. for (const grade of [-1,1]) { const s=line(grade); run('rebuildPower();slopeCleatsTick(false);'); if(grade>0) check('a downhill run has no cleats',run('cleatCells.length===0')); else check(`grade ${grade} cleats block every downhill crossing`,run(`cleatCells.length>0 && cleatCells.every(i=>!enter(SAND,PH_POWDER,i,${grade},1) && !enter(SAND,PH_POWDER,i+W+${grade},${grade},1))`)); check(`grade ${grade} bare face slides between steps without stamping the load`,run(`(()=>{ for(let x=72;x<96;x++){ const by=${s.by}+((x>>2)-16)*${grade}, y=beltFaceY(blockP[by*BW+(x>>2)],x>>2,by,x)-1,i=y*W+x; if(cleatMask[i] || cleatMask[i+W+${grade}])continue; setCell(i,SAND,20,0);tick=3;tickStamp++;machinesTick(); const unstamped=stamp[i]!==tickStamp; for(let k=0;k<200 && mat[i]===SAND;k++)powder(i,x,y,SAND); return unstamped && mat[i]===EMPTY; } return false; })()`)); } fresh(); run('placeBlock(BP_FILTER_DOWN,128,20,20,true,{m:[],inv:0});setCell(79*W+80,GRAVEL,20,0);tickStamp++;tick=SCREEN_NUDGE_TICKS-1;screenNudge(80*W+80,1,null);'); check('screen waits between its configured nudges',run('mat[79*W+80]===GRAVEL && stamp[79*W+80]!==tickStamp')); run('tick++;screenNudge(80*W+80,1,null);'); check('screen nudges just its mesh contact downhill at the configured rate',run('mat[80*W+81]===GRAVEL && mat[79*W+80]===EMPTY')); fresh();run('placeBlock(BP_BELT_UP,1,20,32,true);'); 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 the slope shape and permitted travel direction`,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}})===${id==='filter'?(dx*dy>0?1:0):(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=id==='filter'?(dx*dy>0?128:0):(dx>0?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 preserve the slope and use its permitted direction`,run(`pieceCode(PIECE_BY_ID.${id})===${c} && pieceV(PIECE_BY_ID.${id})===${id==='filter'?v:1-v}`)); run('GAME.drag=aimBlock();GAME.replace=true;primaryRelease(false);GAME.replace=false;'); check(`${id} a single replacement keeps the selected direction`,run(`blockP[${20+dy}*BW+${20+dx}]===${c} && blockV[${20+dy}*BW+${20+dx}]===${id==='filter'?v:1-v}`)); } // Middle click on the belt band you see, even where it is drawn into the empty block next door. fresh(); const band=run(`(()=>{globalThis.R=globalThis.R||{smoothOn:false}; for(let k=0;k<4;k++)placeBlock(BP_BELT_UP,1,20+k,24-k,true);prepareSlopeBelts(0,0,W,H); for(let y=60;y<110;y++)for(let x=70;x<100;x++){const i=y*W+x,own=(y>>2)*BW+(x>>2); if(!blockP[own] && mat[i]===EMPTY && slopeBeltAt(i,x,y)>=0){GAME.pieceIdx=PIECE_BY_ID.wall.index;GAME.aim={x:x+0.5,y:y+0.5};pickUnderCursor();return GAME.pieceIdx===PIECE_BY_ID.belt.index;}} return 'no band cell outside its block';})()`); check('middle click on a sloped belt band outside its block picks the belt',band===true,band); 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]===0)')); // 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)')); // A slope's seal can sit in the open corner of a neighbouring slope or under a grate or screen; // taking both away must leave nothing behind (it used to leave an unremovable belt cell). const strayCells="(()=>{let n=0;for(let i=0;i>2)*BW+((i%W)>>2)]&&slopeSealOwner(i)<0)n++;}return n;})()"; for(const [first,second] of [['BP_FILTER_DOWN,128,10,12','BP_BELT_DOWN,0,10,11'],['BP_GRATE,0,10,12','BP_BELT_DOWN,0,10,11'],['BP_FILTER,0,10,12','BP_SLOPE_BL,RSTONE,10,11']]) for(const order of [0,1]) for(const built of [0,1]) { fresh(); const [a,b]=built?[second,first]:[first,second]; run(`placeBlock(${a},true,{m:[],inv:0});placeBlock(${b},true,{m:[],inv:0});`); run(order?'removeBlock(10,12);removeBlock(10,11);':'removeBlock(10,11);removeBlock(10,12);'); check(`removing ${first.split(',')[0]} and the ${second.split(',')[0]} seal inside it (built ${built}, removed ${order}) leaves no stray cell`,run(strayCells)===0,run(strayCells)); } fresh(); run("setCell(40*W+43,BELTL,20,0);globalThis.straySave=serializeWorld();deserializeWorld(straySave.meta,straySave.buffers);"); check('loading a world clears a stray belt cell left by the old removal bug',run('mat[40*W+43]===EMPTY')); done();