// Sloped walls: flow, sealed joints, building, refunds and persistence. const { load, suite, flatWorld, rect, count, ticks } = require('./harness'); const { run } = load(); const { check, done } = suite(); // Wall selection drives the same saved slope codes and sealed funnel geometry. const fs = require('fs'); run(fs.readFileSync('src/ui/selection.js', 'utf8')); run(`const GAME = { pieceDir: {}, drag: null }; let hudDirty = false; function aimBlock() { return { bx: 23, by: 23 }; }`); function fresh() { flatWorld(run, 240, 192, 176); run('AMB.fill(20);temp.fill(20);'); } function funnel(sloped, material) { fresh(); run(`for (const [ax,ay,bx,by] of [[12,12,23,23],[36,12,25,23]]) { GAME.drag={bx:ax,by:ay}; const code=${sloped ? 'pieceCode(PIECE_BY_ID.wall,{bx,by})' : 'BP_WALL'}; for(const [x,y] of blockLine(ax,ay,bx,by,true)) placeBlock(code,RSTONE,x,y,true); } GAME.drag=null;`); rect(run, 80, 20, 119, 29, material); ticks(run, 1800); const out = count(run, material, 0, 96, 239, 175); check(`${sloped ? 'slope' : 'square'} funnel conserves 400 grains`, count(run, material) === 400); return out; } const sand = run('SAND'), water = run('WATER'); const slopeOut = funnel(true, sand), squareOut = funnel(false, sand); check('all sand leaves the sloped funnel', slopeOut === 400, slopeOut); check('square steps retain sand', squareOut < 400, squareOut); check('all water leaves the sloped funnel', funnel(true, water) === 400); for (const material of ['GRAVEL', 'CUORE', 'LDIRT']) check(`${material} slides down`, funnel(true, run(material)) === 400); fresh(); run('for (let k=0;k<12;k++) placeBlock(BP_SLOPE_BL,RSTONE,12+k,12+k,true); spawnBar(BARCU,52,35);'); ticks(run, 600); check('bars slide off the slope', run('ents.length === 1 && ents[0].iy > 96'), run('ents.map(e=>[e.ix,e.iy])')); for (let c = 15; c <= 18; c++) { fresh(); run(`bagAdd(RSTONE,100,true); bagAdd(BARCU,10,true);`); const before = run('bag[RSTONE]'); run(`placeBlock(${c},RSTONE,20,20);`); check(`slope ${c} costs half a wall`, before-run('bag[RSTONE]') === 8); check(`slope ${c} has its triangle`, run('blockIntegrity(20*BW+20)') === 10); run(`undoRecord('cut',()=>cutRegion(80,80,83,83)); undoLast();`); check(`undo restores orientation ${c}`, run(`blockP[20*BW+20] === ${c}`)); check(`copy and flip preserve material and mirror ${c}`, run(`(()=>{ const bp=flipBlueprint(copyRegion(80,80,83,83)); return bp.blocks[0][2] === ${15+((c-15)^1)} && bp.blocks[0][3]===RSTONE; })()`)); run(`const snap${c}=serializeWorld(); deserializeWorld(snap${c}.meta,snap${c}.buffers);`); check(`save restores orientation ${c}`, run(`blockP[20*BW+20] === ${c} && blockIntegrity(20*BW+20)===10`)); run(`pasteGhost(copyRegion(80,80,83,83),24,20); bot.x=96;bot.y=80;ghostTick();`); check(`paste builds orientation ${c}`, run(`blockP[20*BW+24]===${c} && blockV[20*BW+24]===RSTONE`)); run('removeBlock(24,20);removeBlock(20,20)'); check(`slope ${c} refunds half a wall`, run('bag[RSTONE]') === before); run(`placeBlock(${c},BARCU,20,20);removeBlock(20,20);`); check(`metal slope ${c} refunds one bar`, run('bag[BARCU]') === 10); } check('wall diagonal snaps within 30 percent', run('JSON.stringify(blockLine(2,3,7,7,true))') === JSON.stringify([[2,3],[3,4],[4,5],[5,6],[6,7],[7,8]])); check('belts stay straight', run('blockLine(2,3,7,8).every(p=>p[1]===3)')); check('drag chooses below or above and ignores drag reversal', run('slopeCode(5,5,0)===15 && slopeCode(-5,-5,0)===15 && slopeCode(5,-5,0)===16 && slopeCode(5,5,2)===18 && slopeCode(5,-5,2)===17')); // Probe every open cell along both sides, including all eleven joints. A grain may // leave an end, so measure crossing only within the interior of the diagonal run. for (let c = 15; c <= 18; c++) for (const m of ['SAND', 'WATER']) for (const side of [-1, 1]) { fresh(); const descending = c === 15 || c === 18; run(`for(let k=0;k<12;k++) placeBlock(${c},RSTONE,12+k,${descending ? '12+k' : '23-k'},true);`); const leaked = run(`(()=>{ const sideAt=(x,y)=>${descending ? 'y-x' : 'x+y-143'}; let leaks=0, probes=0; for(let y=52;y<92;y++) for(let x=52;x<92;x++) { const i=y*W+x, d=sideAt(x,y); if(mat[i]!==EMPTY || Math.sign(d)!==${side} || Math.abs(d)>2) continue; setCell(i,${m},20,0); probes++; // Exercise the real movement functions repeatedly without letting grains escape an end. for(let k=0;k<20 && mat[i]===${m};k++) { tickStamp++; ${m === 'WATER' ? 'liquid(i,x,y,WATER)' : 'powder(i,x,y,SAND)'}; } for(let yy=y;yy<=y+1;yy++) for(let xx=x-32;xx<=x+32;xx++) { if(xx<0 || xx>=W)continue; const j=yy*W+xx; if(mat[j]===${m}) { if(Math.sign(sideAt(xx,yy))===${-side}) leaks++; setCell(j,EMPTY,20,0); } } } return [leaks,probes]; })()`); check(`slope ${c} seals ${m} from side ${side}`, leaked[0] === 0 && leaked[1] > 0, leaked); } for (const c of [15, 16]) { fresh(); run(`for(let k=0;k<12;k++) placeBlock(${c},RSTONE,12+k,${c === 15 ? '12+k' : '23-k'},true);spawnBar(BARCU,${c === 15 ? 52 : 136},35);`); ticks(run,600); check(`bars reach the bottom of slope ${c}`,run('ents.length===1 && ents[0].iy>96')); } for(const c of [15,16,17,18]) { fresh(); check(`slope ${c} supports every wall material`,run(`WALL_LIST.every((v,k)=>{ const bx=4+k*3;bagAdd(v,16,true);const before=bag[v];if(placeBlock(${c},v,bx,20))return false;removeBlock(bx,20);return bag[v]===before; })`)); } fresh(); run('placeBlock(BP_SLOPE_BL,RSTONE,20,20,true);placeBlock(BP_WALL,RSTONE,20,21,true);removeBlock(20,21);'); check('removing an adjoining square wall restores the slope seal',run('mat[slopeSeal(BP_SLOPE_BL,20,20)]===STONE')); run('removeBlock(20,20)'); check('removal leaves no free seal grains behind',count(run,run('STONE'),80,80,83,87)===0); check('old saved slope codes select Wall', run("Array.from({length:4},(_,k)=>PIECE_BY_CODE[15+k].id).every(id=>id==='wall')")); check('single Wall starts square', run('pieceCode(PIECE_BY_ID.wall)===BP_WALL')); const shapes = []; for (let k=0;k<5;k++) { run('turnSlope()'); shapes.push(run('pieceCode(PIECE_BY_ID.wall)')); } check('T cycles square then four corners then square', JSON.stringify(shapes)===JSON.stringify([15,16,17,18,1]), shapes); run('GAME.drag={bx:20,by:20}'); check('diagonal Wall defaults below', run('pieceCode(PIECE_BY_ID.wall)===BP_SLOPE_BL')); run('turnSlope()'); check('T flips diagonal Wall above', run('pieceCode(PIECE_BY_ID.wall)===BP_SLOPE_TR')); run('GAME.drag=null'); check('drag flip preserves single square choice',run('pieceCode(PIECE_BY_ID.wall)===BP_WALL')); run('GAME.pieceDir.wall=3;GAME.drag={bx:20,by:23}'); check('straight Wall drag stays square even with a corner selected',run('pieceCode(PIECE_BY_ID.wall)===BP_WALL')); run('GAME.drag=null;GAME.pieceDir.wall=1'); check('single Wall slope shows its half-block price',run('pieceCostNow(PIECE_BY_ID.wall,RSTONE)[0][1]===8')); // Fixture saved before Wall absorbed the separate Sloped wall piece (save version 2). const legacy = JSON.parse(require('zlib').gunzipSync(fs.readFileSync(__dirname+'/fixtures/slopes-v2.json.gz'))); globalThis.legacySlopeSave = legacy; run('deserializeWorld(legacySlopeSave.meta,legacySlopeSave.buffers)'); check('old save loads all four slope corners unchanged',run('Array.from({length:4},(_,k)=>{const b=6*BW+2+k*3;return blockP[b]===15+k && blockV[b]===RSTONE && blockIntegrity(b)===10 && PIECE_BY_CODE[blockP[b]].id===\'wall\';}).every(Boolean)')); delete globalThis.legacySlopeSave; done();