Build range follows the screen; out-of-range replace ghosts replace (t64)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-27 16:41:32 -04:00
co-authored by Claude Opus 5.5
parent 6bc4fd5928
commit ef4ba6d24a
11 changed files with 77 additions and 16 deletions
+2 -1
View File
@@ -470,7 +470,8 @@ above, including:
building needs a range limit at all, since the bot needs no arms to build. Agreed: keep a limit, building needs a range limit at all, since the bot needs no arms to build. Agreed: keep a limit,
but make it about what's on screen, so anything the player can see and aim at builds directly but make it about what's on screen, so anything the player can see and aim at builds directly
and anything beyond stays a blueprint built when the bot comes near. No limit at all would let and anything beyond stays a blueprint built when the bot comes near. No limit at all would let
players build in places they've never been and link far claims instantly. Not built yet. players build in places they've never been and link far claims instantly. Built: the range is
the visible view box, at least 64 grains from its centre and at most 200 when zoomed far out.
- **Sticky materials slide freely on built surfaces (t61, 2026-10-27).** Stickiness (STICK) is the - **Sticky materials slide freely on built surfaces (t61, 2026-10-27).** Stickiness (STICK) is the
chance per tick a grain takes a diagonal step, which made clay lumps, snow, wood chunks, ash and chance per tick a grain takes a diagonal step, which made clay lumps, snow, wood chunks, ash and
dirt creep down sloped walls (a single clay grain never got down a 20-piece slope). The owner dirt creep down sloped walls (a single clay grain never got down a 20-piece slope). The owner
+6 -5
View File
@@ -315,9 +315,10 @@ Bars and other loose objects (ents), and the bag: adding, taking, costs and refu
- functions: entSolidCell, entSupportCount, entFreeAt, entMark, spawnBar, removeEnt, entScrewAt, entScrewFree, entScrewMove, entScrewLane, entScrewEdge, entScrewEnter, entScrewTick, entDiagonalScrewTick, entBeltScrewEnter, entSlopeBeltTick, entTick, entsInCircle, bagAdd, bagTake, giveOrDrop, refundAt, canAfford, payCost, fmtMoney, costText, needText - functions: entSolidCell, entSupportCount, entFreeAt, entMark, spawnBar, removeEnt, entScrewAt, entScrewFree, entScrewMove, entScrewLane, entScrewEdge, entScrewEnter, entScrewTick, entDiagonalScrewTick, entBeltScrewEnter, entSlopeBeltTick, entTick, entsInCircle, bagAdd, bagTake, giveOrDrop, refundAt, canAfford, payCost, fmtMoney, costText, needText
- variables: BAR_W, BAR_H, entCurM, droppedLast - variables: BAR_W, BAR_H, entCurM, droppedLast
### src/sim/tools.js (52 lines) ### src/sim/tools.js (65 lines)
Tool helpers: reach, brushes, and plain digging. Tool helpers: reach, brushes, and plain digging.
- functions: inReach, forBrush, drillReach, inDrillReach, toolDig - functions: setBuildRange, inBuildRange, inReach, forBrush, drillReach, inDrillReach, toolDig
- variables: BUILD_MAX, buildBox
### src/sim/drill.js (161 lines) ### src/sim/drill.js (161 lines)
The resonance drill: tuned pulses that shatter one material. The resonance drill: tuned pulses that shatter one material.
@@ -342,7 +343,7 @@ Player actions on built things: take back, place a machine or mold, gates, F to
Blueprints: copy, cut, flip, cost and paste (as ghosts) a region of built pieces. Blueprints: copy, cut, flip, cost and paste (as ghosts) a region of built pieces.
- functions: copyRegion, flipBlueprint, bpCost, pasteGhost, cutRegion, footprintBlocker, clearFootprint - functions: copyRegion, flipBlueprint, bpCost, pasteGhost, cutRegion, footprintBlocker, clearFootprint
### src/sim/undo.js (87 lines) ### src/sim/undo.js (93 lines)
Undo (Ctrl+Z) for building, and the bot building ghosts. Undo (Ctrl+Z) for building, and the bot building ghosts.
- functions: undoNote, undoPush, undoRecord, undoLast, clearAllGhosts, ghostTick, ghostTick0 - functions: undoNote, undoPush, undoRecord, undoLast, clearAllGhosts, ghostTick, ghostTick0
- variables: UNDO_MAX, undoStack, undoRec, ghostMissing, ghostIter - variables: UNDO_MAX, undoStack, undoRec, ghostMissing, ghostIter
@@ -511,7 +512,7 @@ Keyboard and mouse listeners.
- functions: isTyping - functions: isTyping
- variables: canvas - variables: canvas
### src/ui/actions.js (242 lines) ### src/ui/actions.js (243 lines)
What input does: overlays, F, pick, copy / cut / paste, undo, building and tool use each frame. Which overlays are on, shown as a coloured frame round the screen and a named badge What input does: overlays, F, pick, copy / cut / paste, undo, building and tool use each frame. Which overlays are on, shown as a coloured frame round the screen and a named badge
- functions: updateOverlayUI, toggleLens, doInteract, pickUnderCursor, aimBlock, stampOrigin, selModeHeld, startPaste, stopSelectAndPaste, plural, piecesIn, doUndo, endRmbRecord, dragDirFor, blockLabel, primaryPress, primaryRelease, secondaryPress, toolTick - functions: updateOverlayUI, toggleLens, doInteract, pickUnderCursor, aimBlock, stampOrigin, selModeHeld, startPaste, stopSelectAndPaste, plural, piecesIn, doUndo, endRmbRecord, dragDirFor, blockLabel, primaryPress, primaryRelease, secondaryPress, toolTick
- variables: ovShown - variables: ovShown
@@ -544,7 +545,7 @@ Shared world colours, minimap and full map.
- functions: resetMap, mapArea, updateMap, drawMap, mapFit, drawFullMap, openMap, zoomMap, zoomMinimap - functions: resetMap, mapArea, updateMap, drawMap, mapFit, drawFullMap, openMap, zoomMap, zoomMinimap
- variables: MAP_S, MAP - variables: MAP_S, MAP
### src/ui/loop.js (136 lines) ### src/ui/loop.js (137 lines)
The frame loop and boot. The frame loop and boot.
- functions: scanVisible, frame, boot - functions: scanVisible, frame, boot
- variables: lastT, acc, lastRender, frameMs, simMs, fpsSm, ambientT - variables: lastT, acc, lastRender, frameMs, simMs, fpsSm, ambientT
+13
View File
@@ -4,6 +4,19 @@ 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: t64 build range about the screen; out-of-range replace ghosts (Claude Code)
- `setBuildRange`/`inBuildRange` in `src/sim/tools.js`: the UI sets the view box each frame
(`src/ui/loop.js`); half-sizes clamped to REACH..BUILD_MAX (64..200 grains); with no view (tests)
a REACH box around the bot. Used by `blockInReach`, machine/mold placement and previews,
right-click take-back, hover reach, and ghost building for blocks, machines and molds.
- A replace (G) placed beyond range stores a replace flag on the ghost (`[code, v, extra, 1]`);
`ghostTick0` removes the old piece (refunded) and places the new one (charged), or re-sets a
filter's settings, when the bot comes in range.
- Files: `src/sim/tools.js`, `src/sim/blocks.js`, `src/sim/undo.js`, `src/ui/actions.js`,
`src/ui/previews.js`, `src/ui/info.js`, `src/ui/loop.js`, `test/t_build.js`, `docs/DESIGN.md`,
`docs/PROJECT_MAP.md`. Tests: `python3 test/run_all.py --browser` all passed. Not played by hand.
## 2026-10-27: t49 throughput overlay (Claude Code) ## 2026-10-27: t49 throughput overlay (Claude Code)
- The I overlay also shows grains per second at each line's open outlet, an orange glow on - The I overlay also shows grains per second at each line's open outlet, an orange glow on
+1 -1
View File
@@ -209,7 +209,7 @@ function blockRect(bx0, by0, bx1, by1) {
for (let y = Math.min(by0, by1); y <= Math.max(by0, by1); y++) for (let x = Math.min(bx0, bx1); x <= Math.max(bx0, bx1); x++) out.push([x, y]); for (let y = Math.min(by0, by1); y <= Math.max(by0, by1); y++) for (let x = Math.min(bx0, bx1); x <= Math.max(bx0, bx1); x++) out.push([x, y]);
return out; return out;
} }
function blockInReach(bx, by) { return inReach(bx * BS + BS / 2, by * BS + BS / 2); } function blockInReach(bx, by) { return inBuildRange(bx * BS + BS / 2, by * BS + BS / 2); }
// Is block b already exactly this piece (same kind, variant and, for a filter, settings)? // Is block b already exactly this piece (same kind, variant and, for a filter, settings)?
function blockIs(b, code, v, extra) { function blockIs(b, code, v, extra) {
if (blockP[b] !== code) return false; if (blockP[b] !== code) return false;
+13
View File
@@ -1,5 +1,18 @@
// Tool helpers: reach, brushes, and plain digging. // Tool helpers: reach, brushes, and plain digging.
// ---------- Tools ---------- // ---------- Tools ----------
// Building reaches about as far as the screen shows (t64): the view box the UI sets each frame
// (setBuildRange), at least REACH from its centre and at most BUILD_MAX; anything beyond becomes
// a blueprint ghost. With no view (tests, headless), a REACH box around the bot.
const BUILD_MAX = 200;
let buildBox = null;
function setBuildRange(cx, cy, w, h) {
const hw = Math.max(REACH, Math.min(BUILD_MAX, w / 2)), hh = Math.max(REACH, Math.min(BUILD_MAX, h / 2));
if (!buildBox) buildBox = { cx, cy, hw, hh }; else { buildBox.cx = cx; buildBox.cy = cy; buildBox.hw = hw; buildBox.hh = hh; }
}
function inBuildRange(x, y) {
if (!buildBox) { const dx = x - (bot.x + bot.w / 2), dy = y - (bot.y + bot.h / 2); return Math.abs(dx) <= REACH && Math.abs(dy) <= REACH; }
return Math.abs(x - buildBox.cx) <= buildBox.hw && Math.abs(y - buildBox.cy) <= buildBox.hh;
}
function inReach(x, y) { const dx = x - (bot.x + bot.w / 2), dy = y - (bot.y + bot.h / 2); return dx * dx + dy * dy <= REACH * REACH; } function inReach(x, y) { const dx = x - (bot.x + bot.w / 2), dy = y - (bot.y + bot.h / 2); return dx * dx + dy * dy <= REACH * REACH; }
function forBrush(cx, cy, r, fn) { function forBrush(cx, cy, r, fn) {
const r2 = (r + 0.5) * (r + 0.5); const r2 = (r + 0.5) * (r + 0.5);
+9 -3
View File
@@ -54,13 +54,19 @@ function ghostTick0() {
while (budget > 0 && checks-- > 0 && ghostBlocks.size) { while (budget > 0 && checks-- > 0 && ghostBlocks.size) {
let nx = ghostIter.next(); let nx = ghostIter.next();
if (nx.done) { ghostIter = ghostBlocks.entries(); nx = ghostIter.next(); if (nx.done) break; } if (nx.done) { ghostIter = ghostBlocks.entries(); nx = ghostIter.next(); if (nx.done) break; }
const [b, [code, v, e]] = nx.value; const [b, [code, v, e, swap]] = nx.value;
const bx = b % BW, by = (b / BW) | 0; const bx = b % BW, by = (b / BW) | 0;
if (blockP[b] === code && blockV[b] === (isFilter(code) ? v & 128 : v)) { if (blockP[b] === code && blockV[b] === (isFilter(code) ? v & 128 : v)) {
if (isFilter(code) && e && !sameFilter(filterInfo(b), cleanFilterCfg(e))) setFilter(b, e); // same piece, new settings if (isFilter(code) && e && !sameFilter(filterInfo(b), cleanFilterCfg(e))) setFilter(b, e); // same piece, new settings
ghostBlocks.delete(b); ghostBlocked.delete(b); continue; ghostBlocks.delete(b); ghostBlocked.delete(b); continue;
} }
if (!blockInReach(bx, by)) continue; if (!blockInReach(bx, by)) continue;
// a replacing ghost takes down what is there (refunded) and builds itself, like a replace in reach
if (swap && blockP[b]) {
if (blockP[b] === code && isFilter(code)) { blockV[b] = filterVariant(code, v); setFilter(b, e); filtersDirty = true; ghostBlocks.delete(b); ghostBlocked.delete(b); budget--; continue; }
if (!canAfford(pieceCost(code, v), 1)) { ghostMissing++; continue; }
removeBlock(bx, by);
}
const err = canPlaceBlock(bx, by, code); const err = canPlaceBlock(bx, by, code);
if (err) { if (err.startsWith('Needs')) ghostBlocked.add(b); continue; } if (err) { if (err.startsWith('Needs')) ghostBlocked.add(b); continue; }
if (!canAfford(pieceCost(code, v), 1)) { ghostMissing++; continue; } if (!canAfford(pieceCost(code, v), 1)) { ghostMissing++; continue; }
@@ -70,7 +76,7 @@ function ghostTick0() {
} }
for (let k = ghostMachines.length - 1; k >= 0 && budget > 0; k--) { for (let k = ghostMachines.length - 1; k >= 0 && budget > 0; k--) {
const g = ghostMachines[k], P = PIECE_BY_ID[g.type]; const g = ghostMachines[k], P = PIECE_BY_ID[g.type];
if (!inReach(g.bx * BS + P.w / 2, g.by * BS + P.h / 2)) continue; if (!inBuildRange(g.bx * BS + P.w / 2, g.by * BS + P.h / 2)) continue;
if (canPlaceMachine(g.type, g.bx, g.by)) continue; if (canPlaceMachine(g.type, g.bx, g.by)) continue;
if (!canAfford(P.cost, 1)) { ghostMissing++; continue; } if (!canAfford(P.cost, 1)) { ghostMissing++; continue; }
placeMachineAt(g.type, g.dir, g.bx, g.by); ghostMachines.splice(k, 1); budget--; placeMachineAt(g.type, g.dir, g.bx, g.by); ghostMachines.splice(k, 1); budget--;
@@ -78,7 +84,7 @@ function ghostTick0() {
} }
for (let k = ghostMolds.length - 1; k >= 0 && budget > 0; k--) { for (let k = ghostMolds.length - 1; k >= 0 && budget > 0; k--) {
const g = ghostMolds[k], md = MOLDS[g.kind]; const g = ghostMolds[k], md = MOLDS[g.kind];
if (!inReach(g.bx * BS + md.w / 2, g.by * BS + md.h / 2)) continue; if (!inBuildRange(g.bx * BS + md.w / 2, g.by * BS + md.h / 2)) continue;
if (canPlaceMold(g.kind, g.bx, g.by)) continue; if (canPlaceMold(g.kind, g.bx, g.by)) continue;
if (!canAfford(wallCost(g.wall, md.walls), 1)) { ghostMissing++; continue; } if (!canAfford(wallCost(g.wall, md.walls), 1)) { ghostMissing++; continue; }
addMold(g.kind, g.bx, g.by, g.wall, false); ghostMolds.splice(k, 1); budget--; addMold(g.kind, g.bx, g.by, g.wall, false); ghostMolds.splice(k, 1); budget--;
+4 -3
View File
@@ -131,7 +131,7 @@ function primaryPress(shift) {
else { else {
const { bw, bh } = pieceSize(P); const { bw, bh } = pieceSize(P);
const { bx, by } = stampOrigin(bw, bh); const { bx, by } = stampOrigin(bw, bh);
if (!inReach(bx * BS + bw * BS / 2, by * BS + bh * BS / 2)) { setStatus('Out of reach.'); playSfx('error'); return; } if (!inBuildRange(bx * BS + bw * BS / 2, by * BS + bh * BS / 2)) { setStatus('Out of reach.'); playSfx('error'); return; }
const place = () => P.kind === 'mold' ? placeMoldAt(P.mold, bx, by, GAME.wallMat) : placeMachineAt(P.id, GAME.pieceDir[P.id] || 0, bx, by); const place = () => P.kind === 'mold' ? placeMoldAt(P.mold, bx, by, GAME.wallMat) : placeMachineAt(P.id, GAME.pieceDir[P.id] || 0, bx, by);
const check = () => P.kind === 'mold' ? canPlaceMold(P.mold, bx, by) : canPlaceMachine(P.id, bx, by); const check = () => P.kind === 'mold' ? canPlaceMold(P.mold, bx, by) : canPlaceMachine(P.id, bx, by);
let err = ''; let err = '';
@@ -178,7 +178,8 @@ function primaryRelease(shift) {
for (const [bx, by] of far) { for (const [bx, by] of far) {
const b = by * BW + bx; const b = by * BW + bx;
if (bx < 0 || by < 0 || bx >= BW || by >= BH || blockIs(b, code, v, ex) || ghostBlocks.has(b)) continue; if (bx < 0 || by < 0 || bx >= BW || by >= BH || blockIs(b, code, v, ex) || ghostBlocks.has(b)) continue;
ghostBlocks.set(b, ex ? [code, v, ex] : [code, v]); g.blocks.push([bx, by, code]); ghosts++; // held G over a different piece: the ghost replaces it when the bot comes in range
ghostBlocks.set(b, GAME.replace && blockP[b] ? [code, v, ex, 1] : ex ? [code, v, ex] : [code, v]); g.blocks.push([bx, by, code]); ghosts++;
} }
if (ghosts) undoNote(g); if (ghosts) undoNote(g);
return res; return res;
@@ -230,7 +231,7 @@ function toolTick() {
if (r.placed) { playSfx('blow'); hudDirty = true; } else if (r.status) setStatus(r.status); if (r.placed) { playSfx('blow'); hudDirty = true; } else if (r.status) setStatus(r.status);
} else if (t === 'build') { } else if (t === 'build') {
const key = `${(ax / BS) | 0},${(ay / BS) | 0}`; const key = `${(ax / BS) | 0},${(ay / BS) | 0}`;
if (!GAME.rmbSeen.has(key) && inReach(ax, ay)) { if (!GAME.rmbSeen.has(key) && inBuildRange(ax, ay)) {
GAME.rmbSeen.add(key); GAME.rmbSeen.add(key);
const keep = undoRec; undoRec = GAME.rmbRec; const keep = undoRec; undoRec = GAME.rmbRec;
const msg = removePieceAt(ax, ay); const msg = removePieceAt(ax, ay);
+1 -1
View File
@@ -158,7 +158,7 @@ function hoverInfo(ax, ay) {
const hint = furnaceJunkHint(mo).trim(); if (hint) t2.push(hint); const hint = furnaceJunkHint(mo).trim(); if (hint) t2.push(hint);
if (player.lens) t2.push(fmtT(temp[i])); if (player.lens) t2.push(fmtT(temp[i]));
} }
const reachOk = toolId() === 'drill' || inReach(ax, ay); const reachOk = toolId() === 'drill' || (toolId() === 'build' ? inBuildRange(ax, ay) : inReach(ax, ay));
if (!reachOk) { t2.push('out of reach'); warn = 'out of reach'; } if (!reachOk) { t2.push('out of reach'); warn = 'out of reach'; }
// what it does, unless the cursor is on a machine or mold, which have their own details // what it does, unless the cursor is on a machine or mold, which have their own details
const what = bar ? bar.m : !mo && !id && !(p && p !== P_GRATE && p !== P_PIPE) && m !== BELTL && m !== BELTR && m !== PIPE ? (m === EMPTY && bgm[i] ? bgm[i] : m) : 0; const what = bar ? bar.m : !mo && !id && !(p && p !== P_GRATE && p !== P_PIPE) && m !== BELTL && m !== BELTR && m !== PIPE ? (m === EMPTY && bgm[i] ? bgm[i] : m) : 0;
+1
View File
@@ -54,6 +54,7 @@ function frame(now) {
// grid under the cursor does not slide around mid-line. // grid under the cursor does not slide around mid-line.
if (GAME.drag) { lookX = GAME.lookHold.x; lookY = GAME.lookHold.y; } else { GAME.lookHold.x = lookX; GAME.lookHold.y = lookY; } if (GAME.drag) { lookX = GAME.lookHold.x; lookY = GAME.lookHold.y; } else { GAME.lookHold.x = lookX; GAME.lookHold.y = lookY; }
if (!GAME.drag || Math.hypot(bot.vx, bot.vy) > 0.08) updateCamera(dt, lookX, lookY); if (!GAME.drag || Math.hypot(bot.vx, bot.vy) > 0.08) updateCamera(dt, lookX, lookY);
{ const v = viewCells(); setBuildRange(R.camX + v.w / 2, R.camY + v.h / 2, v.w, v.h); }
R.frame++; R.frame++;
drawWorld(); drawWorld();
drawCracks(); drawCracks();
+2 -2
View File
@@ -67,7 +67,7 @@ function drawPreviews() {
ctx.save(); ctx.save();
const reachOk = inReach(ax, ay); const reachOk = inReach(ax, ay);
const sel = GAME.selDrag ? GAME.selDrag.mode : selModeHeld(), pasting = !sel && GAME.paste && GAME.clip; const sel = GAME.selDrag ? GAME.selDrag.mode : selModeHeld(), pasting = !sel && GAME.paste && GAME.clip;
if (!reachOk && t !== 'drill' && !sel) { if (!reachOk && t !== 'drill' && t !== 'build' && !sel) {
drawReachCircle(REACH, 0.35); drawReachCircle(REACH, 0.35);
} }
drawDrillPulses(); drawDrillPulses();
@@ -143,7 +143,7 @@ function drawPreviews() {
const { bx, by } = stampOrigin(bw, bh); const { bx, by } = stampOrigin(bw, bh);
const err = P.kind === 'mold' ? canPlaceMold(P.mold, bx, by) : canPlaceMachine(P.id, bx, by); const err = P.kind === 'mold' ? canPlaceMold(P.mold, bx, by) : canPlaceMachine(P.id, bx, by);
const swap = !!err && GAME.replace && /already/.test(err) && !footprintBlocker(bx, by, bw, bh); const swap = !!err && GAME.replace && /already/.test(err) && !footprintBlocker(bx, by, bw, bh);
const ok = (!err || swap) && inReach(bx * BS + bw * BS / 2, by * BS + bh * BS / 2) && canAfford(pieceCostNow(P), 1); const ok = (!err || swap) && inBuildRange(bx * BS + bw * BS / 2, by * BS + bh * BS / 2) && canAfford(pieceCostNow(P), 1);
const [x, y, w, h] = blockRectPx(bx, by, bw, bh); const [x, y, w, h] = blockRectPx(bx, by, bw, bh);
if (swap && ok) { ctx.fillStyle = 'rgba(255,190,90,0.35)'; ctx.fillRect(x, y, w, h); } if (swap && ok) { ctx.fillStyle = 'rgba(255,190,90,0.35)'; ctx.fillRect(x, y, w, h); }
if (P.kind === 'mold') { if (P.kind === 'mold') {
+25
View File
@@ -79,4 +79,29 @@ run('for (let y = 120; y < 122; y++) for (let x = 60; x < 62; x++) setCell(y * W
const n = run('clearHardSpecks(true)'); const n = run('clearHardSpecks(true)');
check('a 4-grain speck of iron ore in stone becomes stone; a 100-grain vein stays', n === 1 && count(run, run('FEV')) === 100 && run('mat[120 * W + 60]') === run('STONE'), `${n} specks, ${count(run, run('FEV'))} vein`); check('a 4-grain speck of iron ore in stone becomes stone; a 100-grain vein stays', n === 1 && count(run, run('FEV')) === 100 && run('mat[120 * W + 60]') === run('STONE'), `${n} specks, ${count(run, run('FEV'))} vein`);
// t64: building reaches about as far as the screen shows, and a replace placed out of range
// becomes a ghost that replaces the old piece when the bot comes in range.
fresh();
check('with no view the build range is a REACH box around the bot', run('buildBox = null; inBuildRange(bot.x + bot.w / 2 + REACH, bot.y) && !inBuildRange(bot.x + bot.w / 2 + REACH + 2, bot.y)'));
check('the build range follows the view box', run('(() => { setBuildRange(200, 100, 300, 160); const r = inBuildRange(340, 100) && !inBuildRange(352, 100) && inBuildRange(200, 180) && !inBuildRange(200, 190); buildBox = null; return r; })()'));
check('a tiny view still reaches REACH and a huge one stops at BUILD_MAX', run('(() => { setBuildRange(500, 500, 20, 20000); const r = buildBox.hw === REACH && buildBox.hh === BUILD_MAX; buildBox = null; return r; })()'));
fresh();
const swap = run(`(() => {
placeBlock(BP_BELT, 1, 50, 20, true); const b = 20 * BW + 50, m0 = money;
ghostBlocks.set(b, [BP_SCREW, 1, undefined, 1]);
bot.x = 20; bot.y = 20; for (let k = 0; k < 30; k++) ghostTick();
const before = blockP[b];
bot.x = 50 * BS - 4; bot.y = 20 * BS - 10; for (let k = 0; k < 30; k++) ghostTick();
return { before, after: blockP[b], v: blockV[b], spent: m0 - money, ghosts: ghostBlocks.size };
})()`);
check('an out-of-range replace ghost leaves the old piece until the bot comes near', swap.before === run('BP_BELT'), JSON.stringify(swap));
check('then replaces it: the new piece is built and the ghost is gone', swap.after === run('BP_SCREW') && swap.v === 1 && swap.ghosts === 0, JSON.stringify(swap));
check('the replace charges the new piece and refunds the old one', swap.spent === 25 - 12, JSON.stringify(swap));
fresh();
check('a plain ghost over a different piece still leaves it alone', run(`(() => {
placeBlock(BP_BELT, 1, 30, 20, true); const b = 20 * BW + 30;
ghostBlocks.set(b, [BP_SCREW, 1]); bot.x = 30 * BS - 4; bot.y = 20 * BS - 10;
for (let k = 0; k < 30; k++) ghostTick();
return blockP[b] === BP_BELT && ghostBlocks.has(b);
})()`));
done(); done();