From 8ce71f478a0b3ae5949cdef7c7a9f3d7906c082e Mon Sep 17 00:00:00 2001 From: 44r0n7 <44r0n7+gitea@pm.me> Date: Mon, 26 Oct 2026 23:00:15 -0400 Subject: [PATCH] Removing slopes no longer leaves unowned seal cells; loading clears them A slope's seal can sit in a cell its neighbour leaves open (a slope's open corner, or under a grate, screw or screen), but removal only cleared it when the neighbour was a sloped wall, so cutting a sloped belt next to a sloped filter or grate could leave a one-cell belt that nothing owned and nothing could remove. Removal now asks whether the neighbour fills that cell itself, and loading a world clears belt and screen cells that no block or seal owns. Co-Authored-By: Claude Opus 5.5 --- docs/agent-log.md | 11 +++++++++++ src/sim/blocks.js | 20 ++++++++++++++++++-- src/sim/save.js | 1 + test/t_slope_belts.js | 13 +++++++++++++ 4 files changed, 43 insertions(+), 2 deletions(-) diff --git a/docs/agent-log.md b/docs/agent-log.md index 3950284..3cad090 100644 --- a/docs/agent-log.md +++ b/docs/agent-log.md @@ -4,6 +4,17 @@ What agents have done, newest first. Add an entry for every piece of work: the d note or request it was for, what changed, the files touched, the tests run, and anything left open or not verified. Keep each entry short; the git history has the details. +## 2026-10-27: Unremovable one-cell belt left after cutting slopes (Claude Code) + +- The owner's exported save had a lone belt cell no block owned, so nothing could build there or + remove it. It was a sloped belt's seal: a seal may sit in a cell its neighbour leaves open (a + slope's open corner, or under a grate, screw or screen), but removal only let it go when the + neighbour was a sloped wall. Now `blockOwnsCell` decides (`src/sim/blocks.js`), and loading a + world clears belt and screen cells no block or seal owns (`clearStraySeals`, `save.js`). +- Found with a fuzz of random slope/belt/filter/wall/grate builds removed in random orders and by + cut (0 strays in 4000 runs after the fix). The owner's save loads clean and the spot is + buildable. Tests in `t_slope_belts.js` fail on the old code. `run_all --browser`: ALL PASSED. + ## 2026-10-27: t42 feedback: downhill belts drop the sidewall too; new tasks (Claude Code) - Downhill sloped belts now draw as plain belts: no far wall and no ribs, placed or as ghosts or diff --git a/src/sim/blocks.js b/src/sim/blocks.js index 9694aa5..71431d7 100644 --- a/src/sim/blocks.js +++ b/src/sim/blocks.js @@ -27,6 +27,22 @@ function slopeSealOwner(i) { } return -1; } +// Does block b's own piece fill cell i (with a part, or else with a material)? A slope's seal can +// sit in a cell its neighbour leaves open: a slope's open corner, or under a grate, screw or screen. +function blockOwnsCell(b, i, asPart) { + const c = blockP[b]; + if (!c || ((isSlope(c) || isSlopeBelt(c)) && !slopeCell(c, (i % W) & 3, ((i / W) | 0) & 3))) return false; + return asPart ? isFilter(c) : !(isFilter(c) || c === BP_GRATE || c === BP_SCREW || c === BP_PIPE); +} +// Belt and screen cells that a removed slope left behind in an empty block (a removal bug in +// older worlds): nothing owns them, so nothing could build there or take them away. +function clearStraySeals() { + for (let i = 0; i < N; i++) { + if (mat[i] !== BELTL && mat[i] !== BELTR && part[i] !== P_FILTER) continue; + if (blockP[(((i / W) | 0) >> 2) * BW + ((i % W) >> 2)] || slopeSealOwner(i) >= 0) continue; + if (part[i] === P_FILTER) { part[i] = 0; wakeIdx(i); } else { setCell(i, EMPTY, temp[i], 0); markPow(i, false); } + } +} function wallCells(code, bx, by) { return blockCellList(bx, by).filter(i => !(isSlope(code) || isSlopeBelt(code)) || slopeCell(code, (i % W) & 3, ((i / W) | 0) & 3)); } @@ -158,8 +174,8 @@ function removeBlock(bx, by) { if (isSlope(code) || isSlopeBelt(code)) { const i = slopeSeal(code, bx, by); if (i >= 0) { - const nb = (((i / W) | 0) >> 2) * BW + ((i % W) >> 2), nc = blockP[nb]; - if ((!nc || (isSlope(nc) && !slopeCell(nc, (i % W) & 3, ((i / W) | 0) & 3))) && (mat[i] === (isBelt(code) ? (v ? BELTR : BELTL) : WALL_OF[v]) || (isFilter(code) && part[i] === P_FILTER)) && !(flags[i] & F_NAT)) { if (isFilter(code)) { part[i] = 0; wakeIdx(i); } else { setCell(i, EMPTY, temp[i], 0); if (isBelt(code)) markPow(i, false); } } + const nb = (((i / W) | 0) >> 2) * BW + ((i % W) >> 2); + if (!blockOwnsCell(nb, i, isFilter(code)) && (mat[i] === (isBelt(code) ? (v ? BELTR : BELTL) : WALL_OF[v]) || (isFilter(code) && part[i] === P_FILTER)) && !(flags[i] & F_NAT)) { if (isFilter(code)) { part[i] = 0; wakeIdx(i); } else { setCell(i, EMPTY, temp[i], 0); if (isBelt(code)) markPow(i, false); } } cleared.push(i); } } diff --git a/src/sim/save.js b/src/sim/save.js index 2ce1fc6..d7ecfd8 100644 --- a/src/sim/save.js +++ b/src/sim/save.js @@ -119,6 +119,7 @@ function deserializeWorld(meta, buffers) { seen = new Uint8Array(NMAT); meta.seen.forEach((v, k) => { if (k < NMAT) seen[k] = v; }); ledger = meta.ledger || {}; Object.assign(stats, meta.stats || {}); + clearStraySeals(); powerDirty = true; rebuildPower(); slopeCleatsTick(false); updateRoof(); if (!weather.base) weather.base = worldWater(); // older saves: take the water it has now as its starting amount if (!meta.specks) clearHardSpecks(true); // older saves: tidy away tiny specks of too-hard rock once diff --git a/test/t_slope_belts.js b/test/t_slope_belts.js index f2ddd40..43d90ba 100644 --- a/test/t_slope_belts.js +++ b/test/t_slope_belts.js @@ -238,4 +238,17 @@ const legacy=JSON.parse(require('zlib').gunzipSync(fs.readFileSync(__dirname+'/f 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();