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 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 23:00:15 -04:00
co-authored by Claude Opus 5.5
parent 3cb0182ac7
commit 8ce71f478a
4 changed files with 43 additions and 2 deletions
+11
View File
@@ -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 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. 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) ## 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 - Downhill sloped belts now draw as plain belts: no far wall and no ribs, placed or as ghosts or
+18 -2
View File
@@ -27,6 +27,22 @@ function slopeSealOwner(i) {
} }
return -1; 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) { function wallCells(code, bx, by) {
return blockCellList(bx, by).filter(i => !(isSlope(code) || isSlopeBelt(code)) || slopeCell(code, (i % W) & 3, ((i / W) | 0) & 3)); 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)) { if (isSlope(code) || isSlopeBelt(code)) {
const i = slopeSeal(code, bx, by); const i = slopeSeal(code, bx, by);
if (i >= 0) { if (i >= 0) {
const nb = (((i / W) | 0) >> 2) * BW + ((i % W) >> 2), nc = blockP[nb]; const nb = (((i / W) | 0) >> 2) * BW + ((i % W) >> 2);
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); } } 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); cleared.push(i);
} }
} }
+1
View File
@@ -119,6 +119,7 @@ function deserializeWorld(meta, buffers) {
seen = new Uint8Array(NMAT); meta.seen.forEach((v, k) => { if (k < NMAT) seen[k] = v; }); seen = new Uint8Array(NMAT); meta.seen.forEach((v, k) => { if (k < NMAT) seen[k] = v; });
ledger = meta.ledger || {}; ledger = meta.ledger || {};
Object.assign(stats, meta.stats || {}); Object.assign(stats, meta.stats || {});
clearStraySeals();
powerDirty = true; rebuildPower(); slopeCleatsTick(false); updateRoof(); 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 (!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 if (!meta.specks) clearHardSpecks(true); // older saves: tidy away tiny specks of too-hard rock once
+13
View File
@@ -238,4 +238,17 @@ const legacy=JSON.parse(require('zlib').gunzipSync(fs.readFileSync(__dirname+'/f
globalThis.legacyBeltSave=legacy; globalThis.legacyBeltSave=legacy;
run('deserializeWorld(legacyBeltSave.meta,legacyBeltSave.buffers)'); 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)')); 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<N;i++){const m=mat[i];if((m===BELTL||m===BELTR||part[i]===P_FILTER||m===RSTONE)&&!blockP[((i/W|0)>>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(); done();