diff --git a/docs/agent-log.md b/docs/agent-log.md index 80b1152..4061c97 100644 --- a/docs/agent-log.md +++ b/docs/agent-log.md @@ -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 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) - Backtick opens a dev console in any build. The first time in a world it warns that the save diff --git a/src/render/slope-belts.js b/src/render/slope-belts.js index c9880c7..e63e634 100644 --- a/src/render/slope-belts.js +++ b/src/render/slope-belts.js @@ -7,24 +7,20 @@ function slopeBeltConnections(bx, by, code, planned = null) { const join = endJoin(bx, by, end, planned); if (join < 0) continue; const nb = join >> 1, next = planned && planned.get(nb); - const pcode = next ? next[0] : blockP[nb]; - const slope = isSlopeBelt(pcode) && ((nb / BW) | 0) - by === beltGrade(code) * (nb % BW - bx); + const slope = isSlopeBelt(next ? next[0] : blockP[nb]) && + ((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. - 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; } 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) { const bx = b % BW, by = (b / BW) | 0; let caps = 0; for (let end = 0; end < 2; end++) { - const join = endJoin(bx, by, end, planned), nb = join >> 1, next = planned && planned.get(nb); - if (join < 0 || beltDrop(nb, by, false, next ? next[0] : blockP[nb], join & 1)) caps |= 1 << end; + const join = endJoin(bx, by, end, planned); + if (join < 0 || joinIsDrop(bx, by, end, join, planned)) caps |= 1 << end; } return caps; } diff --git a/src/sim/joins.js b/src/sim/joins.js index 837a7dd..5519760 100644 --- a/src/sim/joins.js +++ b/src/sim/joins.js @@ -71,3 +71,35 @@ function endJoin(bx, by, endIndex, 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); } +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; +} diff --git a/src/ui/actions.js b/src/ui/actions.js index be2ca81..f6ff395 100644 --- a/src/ui/actions.js +++ b/src/ui/actions.js @@ -166,11 +166,9 @@ function primaryRelease(shift) { return; } if (t === 'build' && GAME.drag) { - const P = piece(), b = aimBlock(), code = pieceCode(P, b); - 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); - const dd = dragDirFor(P, GAME.drag, b); - if (dd >= 0) GAME.pieceDir[P.id] = dd; - const v = pieceV(P), ex = pieceExtra(P); + const P = piece(), plan = dragPlan(P, GAME.drag, aimBlock(), shift), { code, v, cells: list } = plan; + if (plan.dd >= 0) GAME.pieceDir[P.id] = plan.dd; + const ex = pieceExtra(P); // 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)); let ghosts = 0; diff --git a/src/ui/previews.js b/src/ui/previews.js index 9aee92c..983ee05 100644 --- a/src/ui/previews.js +++ b/src/ui/previews.js @@ -98,19 +98,17 @@ function drawPreviews() { if (reachOk && (blowing || GAME.lmb)) drawAirStreaks(ax, ay, p, r, blowing); } } 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; } drawGrid(b.bx, b.by, 9); let cells = [], col = '120,200,190'; if (P.kind === 'block') { - const v = pieceV(P); + const { v, dd, dir: dIdx, planned } = plan; 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]]; - const dd = GAME.drag ? dragDirFor(P, GAME.drag, b) : -1; + cells = plan.cells; 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 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) { 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)); diff --git a/src/ui/selection.js b/src/ui/selection.js index 19d59fa..e183dc8 100644 --- a/src/ui/selection.js +++ b/src/ui/selection.js @@ -87,6 +87,17 @@ function pieceV(P) { if (P.filter) return filterVariant(pieceCode(P), (GAME.pieceDir[P.id] || 0) << 7); 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 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 `${esc(costText(cost, 1))}`; } diff --git a/test/t_joins.js b/test/t_joins.js index 0c63e47..d002b1d 100644 --- a/test/t_joins.js +++ b/test/t_joins.js @@ -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); } +// 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(); diff --git a/test/t_slope_belts.js b/test/t_slope_belts.js index 43d90ba..b8e1974 100644 --- a/test/t_slope_belts.js +++ b/test/t_slope_belts.js @@ -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}`)); } +// 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. fresh(); const band=run(`(()=>{globalThis.R=globalThis.R||{smoothOn:false};