Shared drag placement; drags snap one block to join a diagonal (t67 phases 3-4)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-27 15:08:37 -04:00
co-authored by Claude Opus 5.5
parent c3f9387b24
commit 72efe85a1b
8 changed files with 97 additions and 20 deletions
+17
View File
@@ -4,6 +4,23 @@ 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: t67 phase 3 shared drag placement, and snapping (Claude Code)
- `dragPlan(P, a, b, shift)` in `src/ui/selection.js` gives code, direction, variant, blocks and
a planned map for every block piece. `primaryRelease` and the drag preview both use it (the
duplicated `blockLine(...)` expression and the screw/slope-only planned map are gone).
- Snapping: `joinSnap` in `src/sim/joins.js` moves a line one block up or down when its first
block would not join but would join seamlessly there with a diagonal piece involved, and
every block there is free. Fixes the owner's report that an uphill belt started beside a flat
belt on the same level did not join like screws do: it now snaps up to the seamless join. Also
covers t58's flat belt level with a downhill low end (snaps down) and a diagonal screw started
one block off. Straight beside straight never snaps. `joinIsDrop` (same-row feed into a slope's
low end) is now the one source for the drop, used by snapping and belt drawing.
- Files: `src/sim/joins.js`, `src/ui/selection.js`, `src/ui/actions.js`, `src/ui/previews.js`,
`src/render/slope-belts.js`, `test/t_joins.js`, `test/t_slope_belts.js`.
- Tests: `python3 test/run_all.py --browser` all passed; 9 snap checks plus a real
`primaryRelease` check. Open: phase 4's "doesn't connect" mark is not built yet.
## 2026-10-27: t66 dev console (Claude Code + Codex) ## 2026-10-27: t66 dev console (Claude Code + Codex)
- Backtick opens a dev console in any build. The first time in a world it warns that the save - Backtick opens a dev console in any build. The first time in a world it warns that the save
+5 -9
View File
@@ -7,24 +7,20 @@ function slopeBeltConnections(bx, by, code, planned = null) {
const join = endJoin(bx, by, end, planned); const join = endJoin(bx, by, end, planned);
if (join < 0) continue; if (join < 0) continue;
const nb = join >> 1, next = planned && planned.get(nb); const nb = join >> 1, next = planned && planned.get(nb);
const pcode = next ? next[0] : blockP[nb]; const slope = isSlopeBelt(next ? next[0] : blockP[nb]) &&
const slope = isSlopeBelt(pcode) && ((nb / BW) | 0) - by === beltGrade(code) * (nb % BW - bx); ((nb / BW) | 0) - by === beltGrade(code) * (nb % BW - bx);
// A same-row feed into a low end drops onto its face: drawn as two capped ends, still joined. // A same-row feed into a low end drops onto its face: drawn as two capped ends, still joined.
if (!slope && beltDrop(nb, by, beltGrade(code) * (end ? 1 : -1) > 0, pcode, join & 1)) continue; if (!slope && joinIsDrop(bx, by, end, join, planned)) continue;
joins |= (slope ? 4 : 1) << end; joins |= (slope ? 4 : 1) << end;
} }
return joins; return joins;
} }
// Does a same-row join between a piece in row `by` and block nb meet a slope's low end?
function beltDrop(nb, by, ownLow, pcode, pend) {
return ((nb / BW) | 0) === by && (ownLow || beltGrade(pcode) * (pend ? 1 : -1) > 0);
}
function flatBeltCaps(b, planned = null) { function flatBeltCaps(b, planned = null) {
const bx = b % BW, by = (b / BW) | 0; const bx = b % BW, by = (b / BW) | 0;
let caps = 0; let caps = 0;
for (let end = 0; end < 2; end++) { for (let end = 0; end < 2; end++) {
const join = endJoin(bx, by, end, planned), nb = join >> 1, next = planned && planned.get(nb); const join = endJoin(bx, by, end, planned);
if (join < 0 || beltDrop(nb, by, false, next ? next[0] : blockP[nb], join & 1)) caps |= 1 << end; if (join < 0 || joinIsDrop(bx, by, end, join, planned)) caps |= 1 << end;
} }
return caps; return caps;
} }
+32
View File
@@ -71,3 +71,35 @@ function endJoin(bx, by, endIndex, planned = null) {
function pieceJoins(bx, by, planned = null) { function pieceJoins(bx, by, planned = null) {
return (endJoin(bx, by, 0, planned) >= 0 ? 1 : 0) | (endJoin(bx, by, 1, planned) >= 0 ? 2 : 0); return (endJoin(bx, by, 0, planned) >= 0 ? 1 : 0) | (endJoin(bx, by, 1, planned) >= 0 ? 2 : 0);
} }
function endAt(b, end, planned = null) {
const p = planned && planned.get(b);
return pieceEnds(p ? p[0] : blockP[b], p ? p[1] : blockV[b])[end];
}
// A same-row join into a sloped belt's low end: the flat surface is level with the top of the
// slope's first block, so grains drop about 3 grains onto its face. Joined, but not seamless.
function joinIsDrop(bx, by, end, join, planned = null) {
if (join < 0 || (((join >> 1) / BW) | 0) !== by) return false;
const low = e => !!e && e.kind === 'belt-sloped' && e.dy > 0;
return low(endAt(by * BW + bx, end, planned)) || low(endAt(join >> 1, join & 1, planned));
}
// Rows to move a line so its first block joins: 0, -1 or +1. A line that already joins stays
// put; it moves only when one block up or down gives a seamless join involving a diagonal piece
// and every block there is free, so straight lines beside straight lines never jump.
function joinSnap(code, v, cells) {
if (!cells.length) return 0;
const joins = dy => {
const plan = new Map(cells.map(([x, y]) => [(y + dy) * BW + x, [code, v]]));
const x = cells[0][0], y = cells[0][1] + dy, own = pieceEnds(code, v);
for (let end = 0; end < own.length; end++) {
const j = endJoin(x, y, end, plan);
if (j < 0 || plan.has(j >> 1) || joinIsDrop(x, y, end, j, plan)) continue;
return own[end].diagonal || endAt(j >> 1, j & 1, plan).diagonal ? 2 : 1;
}
return 0;
};
if (!pieceEnds(code, v).length || joins(0)) return 0;
for (const dy of [-1, 1]) {
if (cells.every(([x, y]) => canPlaceBlock(x, y + dy, code) === '') && joins(dy) === 2) return dy;
}
return 0;
}
+3 -5
View File
@@ -166,11 +166,9 @@ function primaryRelease(shift) {
return; return;
} }
if (t === 'build' && GAME.drag) { if (t === 'build' && GAME.drag) {
const P = piece(), b = aimBlock(), code = pieceCode(P, b); const P = piece(), plan = dragPlan(P, GAME.drag, aimBlock(), shift), { code, v, cells: list } = plan;
const list = shift && !lineOnly(code) ? blockRect(GAME.drag.bx, GAME.drag.by, b.bx, b.by) : blockLine(GAME.drag.bx, GAME.drag.by, b.bx, b.by, isWall(code) || isSlopeBelt(code) || code === BP_SCREW); if (plan.dd >= 0) GAME.pieceDir[P.id] = plan.dd;
const dd = dragDirFor(P, GAME.drag, b); const ex = pieceExtra(P);
if (dd >= 0) GAME.pieceDir[P.id] = dd;
const v = pieceV(P), ex = pieceExtra(P);
// blocks past the bot's reach become ghosts it builds when it gets close, so a line can be any length // blocks past the bot's reach become ghosts it builds when it gets close, so a line can be any length
const near = list.filter(([bx, by]) => blockInReach(bx, by)), far = list.filter(([bx, by]) => !blockInReach(bx, by)); const near = list.filter(([bx, by]) => blockInReach(bx, by)), far = list.filter(([bx, by]) => !blockInReach(bx, by));
let ghosts = 0; let ghosts = 0;
+4 -6
View File
@@ -98,19 +98,17 @@ function drawPreviews() {
if (reachOk && (blowing || GAME.lmb)) drawAirStreaks(ax, ay, p, r, blowing); if (reachOk && (blowing || GAME.lmb)) drawAirStreaks(ax, ay, p, r, blowing);
} }
} else if (t === 'build') { } else if (t === 'build') {
const P = piece(), b = aimBlock(), code = pieceCode(P, b); const P = piece(), b = aimBlock(), plan = P.kind === 'block' ? dragPlan(P, GAME.drag, b, modShift()) : null;
const code = plan ? plan.code : pieceCode(P, b);
if (GAME.previewCode !== code) { GAME.previewCode = code; hudDirty = true; } if (GAME.previewCode !== code) { GAME.previewCode = code; hudDirty = true; }
drawGrid(b.bx, b.by, 9); drawGrid(b.bx, b.by, 9);
let cells = [], col = '120,200,190'; let cells = [], col = '120,200,190';
if (P.kind === 'block') { if (P.kind === 'block') {
const v = pieceV(P); const { v, dd, dir: dIdx, planned } = plan;
col = previewColor(code, v); col = previewColor(code, v);
cells = GAME.drag ? (modShift() && !lineOnly(code) ? blockRect(GAME.drag.bx, GAME.drag.by, b.bx, b.by) : blockLine(GAME.drag.bx, GAME.drag.by, b.bx, b.by, isWall(code) || isSlopeBelt(code) || code === BP_SCREW)) : [[b.bx, b.by]]; cells = plan.cells;
const dd = GAME.drag ? dragDirFor(P, GAME.drag, b) : -1;
if (dd !== GAME.dragDir) { GAME.dragDir = dd; hudDirty = true; } if (dd !== GAME.dragDir) { GAME.dragDir = dd; hudDirty = true; }
const dIdx = dd >= 0 ? dd : (GAME.pieceDir[P.id] || 0);
const afford = canAfford(pieceCostNow(P), cells.length); const afford = canAfford(pieceCostNow(P), cells.length);
const planned = code === BP_SCREW || isSlopeBelt(code) ? new Map(cells.map(([x, y]) => [y * BW + x, [code, isFilter(code) ? v : dIdx]])) : null;
for (const [bx, by] of cells) { for (const [bx, by] of cells) {
const err = canPlaceBlock(bx, by, code), rb = by * BW + bx; const err = canPlaceBlock(bx, by, code), rb = by * BW + bx;
const swap = GAME.replace && !!blockP[rb] && /already built/.test(err) && !blockIs(rb, code, v, pieceExtra(P)); const swap = GAME.replace && !!blockP[rb] && /already built/.test(err) && !blockIs(rb, code, v, pieceExtra(P));
+11
View File
@@ -87,6 +87,17 @@ function pieceV(P) {
if (P.filter) return filterVariant(pieceCode(P), (GAME.pieceDir[P.id] || 0) << 7); if (P.filter) return filterVariant(pieceCode(P), (GAME.pieceDir[P.id] || 0) << 7);
return GAME.pieceDir[P.id] || 0; return GAME.pieceDir[P.id] || 0;
} }
// One drag for the build tool and its preview: code, direction, variant, blocks, and the planned
// blocks the join rule sees. A line that joins one block up or down snaps there (joinSnap).
function dragPlan(P, a, b, shift) {
const code = pieceCode(P, b), dd = a ? dragDirFor(P, a, b) : -1, dir = dd >= 0 ? dd : (GAME.pieceDir[P.id] || 0);
const v = isWall(P.code) ? GAME.wallMat : P.filter ? filterVariant(code, dir << 7) : dir;
let cells = !a ? [[b.bx, b.by]] : shift && !lineOnly(code) ? blockRect(a.bx, a.by, b.bx, b.by)
: blockLine(a.bx, a.by, b.bx, b.by, isWall(code) || isSlopeBelt(code) || code === BP_SCREW);
const snap = lineOnly(code) ? joinSnap(code, v, cells) : 0;
if (snap) cells = cells.map(([x, y]) => [x, y + snap]);
return { code, dd, dir, v, cells, snap, planned: new Map(cells.map(([x, y]) => [y * BW + x, [code, v]])) };
}
function pieceExtra(P) { return P.filter ? { m: GAME.filterCfg.m.slice(), inv: GAME.filterCfg.inv } : undefined; } function pieceExtra(P) { return P.filter ? { m: GAME.filterCfg.m.slice(), inv: GAME.filterCfg.inv } : undefined; }
function filterBlockAt(x, y) { if (x < 0 || y < 0 || x >= W || y >= H) return -1; const b = ((y / BS) | 0) * BW + ((x / BS) | 0); return isFilter(blockP[b]) ? b : -1; } function filterBlockAt(x, y) { if (x < 0 || y < 0 || x >= W || y >= H) return -1; const b = ((y / BS) | 0) * BW + ((x / BS) | 0); return isFilter(blockP[b]) ? b : -1; }
function chipCost(cost) { const ok = canAfford(cost, 1); return `<span class="chip ${ok ? 'ok' : 'bad'}">${esc(costText(cost, 1))}</span>`; } function chipCost(cost) { const ok = canAfford(cost, 1); return `<span class="chip ${ok ? 'ok' : 'bad'}">${esc(costText(cost, 1))}</span>`; }
+20
View File
@@ -167,4 +167,24 @@ for(const grade of [-1,1])for(const below of [false,true]) {
})()`); })()`);
check(`grade ${grade} belt low end accepts sand from ${below?'below':'same row'} and carries it up`,r[0]&&r[1],r); check(`grade ${grade} belt low end accepts sand from ${below?'below':'same row'} and carries it up`,r[0]&&r[1],r);
} }
// Snapping (t67 phase 4): a drag moves one block up or down only to make a seamless join
// involving a diagonal piece, and only when every block there is free.
const snap = (setup, code, v, cells) => { flatWorld(run, 96, 96, 92); return run(`(()=>{${setup};return joinSnap(${code},${v},${JSON.stringify(cells)});})()`); };
check('an uphill belt started beside a flat belt on the same level snaps up one to join seamlessly',
snap('placeBlock(BP_BELT,1,10,10,true)', 'BP_BELT_UP', 1, [[11,10],[12,9],[13,8]]) === -1);
check('the snapped uphill belt then joins the flat belt',
run('(()=>{placeBlocks(BP_BELT_UP,1,[[11,9],[12,8],[13,7]]);return endJoin(10,10,1)===(9*BW+11)*2+0||endJoin(11,9,0)>=0;})()'));
check('an uphill belt already one block up stays put',
snap('placeBlock(BP_BELT,1,10,10,true)', 'BP_BELT_UP', 1, [[11,9],[12,8]]) === 0);
check('no snap when a block above is taken; the same-row drop join stays',
snap('placeBlock(BP_BELT,1,10,10,true);placeBlock(BP_WALL,RSTONE,12,8,true)', 'BP_BELT_UP', 1, [[11,10],[12,9]]) === 0);
check('a flat belt beside another flat belt one row off never jumps',
snap('placeBlock(BP_BELT,1,10,10,true)', 'BP_BELT', 1, [[11,11],[12,11],[13,11]]) === 0);
check('a flat belt started level with a downhill belt low end snaps down one (t58)',
snap('placeBlock(BP_BELT_DOWN,1,10,10,true);placeBlock(BP_BELT_DOWN,1,11,11,true)', 'BP_BELT', 1, [[12,11],[13,11]]) === 1);
check('a downhill screw started one block right and one lower than a screw end snaps up to the same row',
snap('placeBlock(BP_SCREW,1,10,10,true)', 'BP_SCREW', 5, [[11,11],[12,12]]) === -1);
check('a screw that already joins stays put',
snap('placeBlock(BP_SCREW,1,10,10,true)', 'BP_SCREW', 5, [[11,10],[12,11]]) === 0);
check('pieces with no join ends never snap', snap('', 'BP_PIPE', 0, [[11,10]]) === 0);
done(); done();
+5
View File
@@ -219,6 +219,11 @@ for(const id of ['belt','filter'])for(const [dx,dy] of [[3,-3],[3,3],[-3,3],[-3,
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}`)); 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}`));
} }
// The owner's drag: a flat belt, then an uphill drag started beside its end on the same level,
// snaps up one block through the real release so the two join seamlessly.
fresh();
run("globalThis.piece=()=>PIECE_BY_ID.belt;placeBlock(BP_BELT,1,19,20,true);GAME.drag={bx:20,by:20};GAME.aim={x:23*4,y:17*4};bot.x=80;bot.y=80;primaryRelease(false);");
check('uphill drag beside a flat belt end snaps up one and joins it', run('blockP[20*BW+20]===0 && blockP[19*BW+20]===BP_BELT_UP && blockP[16*BW+23]===BP_BELT_UP && endJoin(19,20,1)>=0 && !joinIsDrop(19,20,1,endJoin(19,20,1))'));
// Middle click on the belt band you see, even where it is drawn into the empty block next door. // Middle click on the belt band you see, even where it is drawn into the empty block next door.
fresh(); fresh();
const band=run(`(()=>{globalThis.R=globalThis.R||{smoothOn:false}; const band=run(`(()=>{globalThis.R=globalThis.R||{smoothOn:false};