Hot loads: visible melt damage, rebuild-over repair, warning glow (t69)

This commit is contained in:
2026-10-29 18:30:26 -04:00
parent b655d754b9
commit 5fd8110470
14 changed files with 246 additions and 15 deletions
+15 -9
View File
@@ -284,7 +284,7 @@ Pipes, pumps and outlets: each joined line holds one liquid as a count, not as g
- functions: sideOf, pipeClosedSide, pipeJoins, rebuildPipes, pipeNetOf, pipeTakeShare, faceCell, pumpDraw, outletPour, pipesTick - functions: sideOf, pipeClosedSide, pipeJoins, rebuildPipes, pipeNetOf, pipeTakeShare, faceCell, pumpDraw, outletPour, pipesTick
- variables: PIPE_CAP, PUMP_RATE, OUTLET_RATE, PUMP_MAX_T, SIDE_V, PUMP_V, OUTLET_V, pipeNets, pipeNetAt, pipesDirty - variables: PIPE_CAP, PUMP_RATE, OUTLET_RATE, PUMP_MAX_T, SIDE_V, PUMP_V, OUTLET_V, pipeNets, pipeNetAt, pipesDirty
### src/sim/machine-tick.js (157 lines) ### src/sim/machine-tick.js (158 lines)
Per-tick machine work: steam engines, fans, belts, filters, screw conveyors (machinesTick). Per-tick machine work: steam engines, fans, belts, filters, screw conveyors (machinesTick).
- functions: engineIntake, fanBlow, machinesTick - functions: engineIntake, fanBlow, machinesTick
@@ -308,9 +308,9 @@ Cleats are rubber ribs standing up from a sloped belt's face, one every CLEAT_SP
- functions: ribMove, cleatFoot, ribCells, slopeUphill, slopeFaceAt, pushRow, scoop, pocketRoom, advanceCleat, slopeRunKey, slopeCleatsTick, rebuildCleatMask, expelFromRib, screenNudge, wakeCleatLoad - functions: ribMove, cleatFoot, ribCells, slopeUphill, slopeFaceAt, pushRow, scoop, pocketRoom, advanceCleat, slopeRunKey, slopeCleatsTick, rebuildCleatMask, expelFromRib, screenNudge, wakeCleatLoad
- variables: CLEAT_SPACING, CLEAT_HEIGHT, CLEAT_REACH, SCREEN_NUDGE_TICKS, cleatChunks, cleatMask, cleatCells, slopePhase, ribLog - variables: CLEAT_SPACING, CLEAT_HEIGHT, CLEAT_REACH, SCREEN_NUDGE_TICKS, cleatChunks, cleatMask, cleatCells, slopePhase, ribLog
### src/sim/blocks.js (237 lines) ### src/sim/blocks.js (268 lines)
Building blocks: placing, integrity, removing, lines and rectangles of blocks. Building blocks: placing, integrity, removing, lines and rectangles of blocks.
- functions: blockOf, blockCellList, pieceCost, setSlopeSeal, slopeSeal, slopeSealOwner, blockOwnsCell, clearStraySeals, wallCells, canPlaceBlock, placeBlock, blockIntegrity, removeWallCell, removeBlock, diagonalDrag, blockLine, blockRect, blockInReach, blockIs, placeBlocks - functions: blockOf, blockCellList, pieceCost, setSlopeSeal, slopeSeal, slopeSealOwner, blockOwnsCell, clearStraySeals, wallCells, screwDamageMask, screwLoadHeat, canPlaceBlock, placeBlock, blockIntegrity, removeWallCell, removeBlock, diagonalDrag, blockLine, blockRect, blockInReach, blockIs, placeBlocks
### src/sim/molds.js (317 lines) ### src/sim/molds.js (317 lines)
Molds: cavities for castings; ingot troughs empty themselves below. Removal returns set metal. Molds: cavities for castings; ingot troughs empty themselves below. Removal returns set metal.
@@ -350,9 +350,9 @@ 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 (93 lines) ### src/sim/undo.js (103 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, undoScrewRepair, undoLast, clearAllGhosts, ghostTick, ghostTick0
- variables: UNDO_MAX, undoStack, undoRec, ghostMissing, ghostIter - variables: UNDO_MAX, undoStack, undoRec, ghostMissing, ghostIter
### src/sim/bot.js (99 lines) ### src/sim/bot.js (99 lines)
@@ -487,6 +487,10 @@ Other overlays: floating text, rain and snow, flow arrows, filter tags, molds, t
- functions: spawnFloater, drawRain, drawFlowArrows, drawJoinMark, drawJoinMarks, flowLoaded, drawFlowRates, pingImage, drawPing, drawHoveredVein, drawPingRecharge, drawRunOutline, drawFilterTags, drawFloaters, moldLabel, drawMolds, drawBot, spawnDebris, spawnSpark, spawnSuck, drawParticles - functions: spawnFloater, drawRain, drawFlowArrows, drawJoinMark, drawJoinMarks, flowLoaded, drawFlowRates, pingImage, drawPing, drawHoveredVein, drawPingRecharge, drawRunOutline, drawFilterTags, drawFloaters, moldLabel, drawMolds, drawBot, spawnDebris, spawnSpark, spawnSuck, drawParticles
- variables: PING_SPEED, PING_HOLD, PING_FADE, PING_BANDS, PING_GLINTS, GLINT_MS - variables: PING_SPEED, PING_HOLD, PING_FADE, PING_BANDS, PING_GLINTS, GLINT_MS
### src/render/screw-heat.js (29 lines)
Persistent scorch and broken casing, or a warning before the 1000°C limit, in both looks.
- functions: drawScrewHeat
### src/ui/audio.js (230 lines) ### src/ui/audio.js (230 lines)
Audio: chiptune effects and generative music that changes with depth. Everything is synthesized with WebAudio, no samples. Audio: chiptune effects and generative music that changes with depth. Everything is synthesized with WebAudio, no samples.
- functions: auInit, auVolumes, rateOk, tone, noiseBurst, sfxPlace, playSfx, auAmbient, mtof, zoneForDepth, scaleNote, musicScheduler - functions: auInit, auVolumes, rateOk, tone, noiseBurst, sfxPlace, playSfx, auAmbient, mtof, zoneForDepth, scaleNote, musicScheduler
@@ -568,7 +572,7 @@ Hover work is shared by the cursor label and the vein outline.
- functions: oreLike, bitNeeded, pingVisible, cursorVein, hoveredVein, cursorLabelAlpha - functions: oreLike, bitNeeded, pingVisible, cursorVein, hoveredVein, cursorLabelAlpha
- variables: CURSOR_ORES, cursorVeinCache, cursorLabelKey, cursorLabelSince - variables: CURSOR_ORES, cursorVeinCache, cursorLabelKey, cursorLabelSince
### src/ui/info.js (208 lines) ### src/ui/info.js (216 lines)
Discovery cards, hover info (I) and the cursor label. Discovery cards, hover info (I) and the cursor label.
- functions: showCard, howText, discoveryCard, reactionsOf, hoverInfo, renderInfo, drawCursorLabel, pipeContentsText - functions: showCard, howText, discoveryCard, reactionsOf, hoverInfo, renderInfo, drawCursorLabel, pipeContentsText
@@ -582,7 +586,7 @@ Keyboard and mouse listeners.
- functions: isTyping - functions: isTyping
- variables: canvas - variables: canvas
### src/ui/actions.js (256 lines) ### src/ui/actions.js (258 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, pingWait, firePing, toolTick - functions: updateOverlayUI, toggleLens, doInteract, pickUnderCursor, aimBlock, stampOrigin, selModeHeld, startPaste, stopSelectAndPaste, plural, piecesIn, doUndo, endRmbRecord, dragDirFor, blockLabel, primaryPress, primaryRelease, secondaryPress, pingWait, firePing, toolTick
- variables: ovShown, PING_RECHARGE - variables: ovShown, PING_RECHARGE
@@ -605,7 +609,7 @@ Selection previews: labelled tags, copy and cut boxes, and paste ghosts. a small
- functions: drawTag, drawSelection, drawPastePreview - functions: drawTag, drawSelection, drawPastePreview
- variables: SEL_COL - variables: SEL_COL
### src/ui/events.js (42 lines) ### src/ui/events.js (43 lines)
Events from the simulation (emit) turned into sounds, toasts and cards. Events from the simulation (emit) turned into sounds, toasts and cards.
- functions: drainEvents - functions: drainEvents
@@ -618,7 +622,7 @@ Shared world colours, minimap and full map.
Browser-only clocks; simulation code stays usable without a browser or a clock. Browser-only clocks; simulation code stays usable without a browser or a clock.
- variables: PERF_MARKS, PERF_FRAME, perfMarks, perfTimes, perfLast - variables: PERF_MARKS, PERF_FRAME, perfMarks, perfTimes, perfLast
### src/ui/loop.js (149 lines) ### src/ui/loop.js (150 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
@@ -670,6 +674,8 @@ The frame loop and boot.
- `test/t_pipes.js`: Pipes, pumps and outlets move liquids; the Grinder grinds waste rock for trace ore. - `test/t_pipes.js`: Pipes, pumps and outlets move liquids; the Grinder grinds waste rock for trace ore.
- `test/t_racks.js`: Rack intake, FIFO storage, gate, removal and saved-state validation. - `test/t_racks.js`: Rack intake, FIFO storage, gate, removal and saved-state validation.
- `test/t_render_dirty.js`: Render invalidation must not depend on whether a grain moved or the chunk stayed awake. - `test/t_render_dirty.js`: Render invalidation must not depend on whether a grain moved or the chunk stayed awake.
- `test/t_screw_heat.js`: t69: melt feedback, full-cost repair without disturbing the load, and repair undo.
- `test/t_screw_heat.py`: t69 only: both looks, persistent damage, hover, first message and real click/G repair.
- `test/t_silo_contents.js`: Stored snow keeps its identity and remains in the world's water budget. - `test/t_silo_contents.js`: Stored snow keeps its identity and remains in the world's water budget.
- `test/t_sim_optimizations.js`: Optimized paths must match the full roof scan and the original heat loop exactly. - `test/t_sim_optimizations.js`: Optimized paths must match the full roof scan and the original heat loop exactly.
- `test/t_slope_belt_art.js`: Drawing geometry against real parts, without changing transport. - `test/t_slope_belt_art.js`: Drawing geometry against real parts, without changing transport.
+6
View File
@@ -14,6 +14,12 @@ prints the failing checks.
## Headless suites ## Headless suites
For t69, `node test/t_screw_heat.js` checks full-cost repair, load preservation, undo,
all screw directions, the unchanged melt threshold, and saved damage/message state.
`python3 test/t_screw_heat.py` is the focused browser check for both looks, hover,
first-melt message, fog, and real click/G repair with keyboard undo. It saves
`shots/t69-{pixel,smooth}.png` (cold tube, orange heat warning, cooled fractured tube).
Each suite is a plain Node script: Each suite is a plain Node script:
```js ```js
+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-29: t69 hot-load warning, visible damage and build-over repair (Codex)
- Added persistent scorched/fractured screw overlays in both looks, 900°C load warnings,
hover explanation and one first-melt card per world. Repair costs the full $25, keeps the
direction/load, and supports immediate/queued repair undo. Melt rule, worldgen and save format unchanged.
- Files: src/sim/{blocks,machine-tick,undo}.js, src/render/screw-heat.js,
src/ui/{actions,events,info,loop}.js, src/manifest.json, test/t_screw_heat.{js,py},
docs/{PROJECT_MAP,TESTING}.md, tasks/t69* and this log.
- Regressions failed first; all headless suites passed (22 heat/repair checks; /tmp/t69-headless-final.log).
Only browser test: python3 test/t_screw_heat.py (both looks, hover/card/fog, real click/G repair,
Ctrl+Z; no page errors). Inspected shots/t69-{pixel,smooth}.png: cold/warning/cooled broken
tube and first-melt card. Owner playtest pending. No commit.
## 2026-10-29: t72 rescue the bot from buried natural rock (Codex) ## 2026-10-29: t72 rescue the bot from buried natural rock (Codex)
- Added explored nearest/up-first nudges, a 256-point typed-array trail and messaged - Added explored nearest/up-first nudges, a 256-point typed-array trail and messaged
+1
View File
@@ -65,6 +65,7 @@
"render/world.js", "render/world.js",
"render/machines.js", "render/machines.js",
"render/overlays.js", "render/overlays.js",
"render/screw-heat.js",
"ui/audio.js", "ui/audio.js",
"ui/state.js", "ui/state.js",
"ui/threads.js", "ui/threads.js",
+29
View File
@@ -0,0 +1,29 @@
// Persistent scorch and broken casing, or a warning before the 1000°C limit, in both looks.
function drawScrewHeat() {
const ctx = R.ctx, zp = R.zp, v = viewCells(), s = BS * zp;
const x0 = Math.max(0, Math.floor(R.camX / BS)), x1 = Math.min(BW - 1, Math.floor((R.camX + v.w) / BS));
const y0 = Math.max(0, Math.floor(R.camY / BS)), y1 = Math.min(BH - 1, Math.floor((R.camY + v.h) / BS));
ctx.save();
for (let by = y0; by <= y1; by++) for (let bx = x0; bx <= x1; bx++) {
const b = by * BW + bx;
if (blockP[b] !== BP_SCREW || (SET.fog && fogSeen(bx * BS + 2, by * BS + 2) < 64)) continue;
const damage = screwDamageMask(b);
if (!damage && screwLoadHeat(b) < 900) continue;
const p = worldToScreenPx(bx * BS, by * BS);
if (damage) {
ctx.fillStyle = 'rgba(32,19,17,0.65)'; ctx.fillRect(p.x, p.y, s, s);
// Keep the actual holes visible beneath a jagged fracture, even after cooling.
ctx.strokeStyle = '#f18b68'; ctx.lineWidth = Math.max(1, zp * 0.3);
ctx.beginPath(); ctx.moveTo(p.x, p.y + s * 0.65);
ctx.lineTo(p.x + s * 0.4, p.y + s * 0.4); ctx.lineTo(p.x + s * 0.6, p.y + s * 0.65);
ctx.lineTo(p.x + s, p.y + s * 0.3); ctx.stroke();
ctx.setLineDash([zp, zp]); ctx.strokeRect(p.x, p.y, s, s); ctx.setLineDash([]);
} else {
ctx.fillStyle = 'rgba(255,126,38,0.3)'; ctx.fillRect(p.x, p.y, s, s);
ctx.strokeStyle = '#ffb54f'; ctx.lineWidth = Math.max(1, zp * 0.3); ctx.strokeRect(p.x, p.y, s, s);
ctx.fillStyle = '#ffdc85'; ctx.fillRect(p.x + s * 0.45, p.y + s * 0.15, zp * 0.4, s * 0.4);
ctx.fillRect(p.x + s * 0.45, p.y + s * 0.7, zp * 0.4, zp * 0.4);
}
}
ctx.restore();
}
+31
View File
@@ -46,8 +46,26 @@ function clearStraySeals() {
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));
} }
// Missing screw cells persist in part[] already; no separate damage state is needed.
function screwDamageMask(b) {
if (blockP[b] !== BP_SCREW) return 0;
const x = (b % BW) * BS, y = ((b / BW) | 0) * BS;
let mask = 0;
for (let k = 0; k < 16; k++) if (!IS_SCREW[part[(y + (k >> 2)) * W + x + (k & 3)]]) mask |= 1 << k;
return mask;
}
function screwLoadHeat(b) {
const x = (b % BW) * BS, y = ((b / BW) | 0) * BS;
let T = 0;
for (let k = 0; k < 16; k++) {
const i = (y + (k >> 2)) * W + x + (k & 3);
if (IS_SCREW[part[i]] && PH[mat[i]] === PH_POWDER && temp[i] > T) T = temp[i];
}
return T;
}
function canPlaceBlock(bx, by, code = BP_WALL) { function canPlaceBlock(bx, by, code = BP_WALL) {
if (bx < 0 || by < 0 || bx >= BW || by >= BH) return 'Out of bounds'; if (bx < 0 || by < 0 || bx >= BW || by >= BH) return 'Out of bounds';
if (code === BP_SCREW && screwDamageMask(by * BW + bx)) return '';
if (blockP[by * BW + bx]) return 'Something is already built there'; if (blockP[by * BW + bx]) return 'Something is already built there';
for (const i of blockCellList(bx, by)) { for (const i of blockCellList(bx, by)) {
if (mach[i] || (part[i] && slopeSealOwner(i) < 0)) return 'Something is already built there'; if (mach[i] || (part[i] && slopeSealOwner(i) < 0)) return 'Something is already built there';
@@ -66,6 +84,13 @@ function placeBlock(code, v, bx, by, free, extra) {
if (isWall(code) && !WALL_OF[v]) return 'Pick a wall material with R'; if (isWall(code) && !WALL_OF[v]) return 'Pick a wall material with R';
const cost = pieceCost(code, v); const cost = pieceCost(code, v);
if (!free) { if (!canAfford(cost, 1)) return needText(cost, 1); payCost(cost, 1); } if (!free) { if (!canAfford(cost, 1)) return needText(cost, 1); payCost(cost, 1); }
const b = by * BW + bx, damage = code === BP_SCREW ? screwDamageMask(b) : 0;
if (damage) {
undoNote({ t: 'screw-repair', bx, by, damage, paid: !free });
for (const i of blockCellList(bx, by)) { part[i] = SCREW_PARTS[blockV[b] & 7]; markPow(i, true); wakeIdx(i); }
stats.placed++;
return '';
}
const cells = isSlope(code) || isSlopeBelt(code) ? wallCells(code, bx, by) : blockCellList(bx, by); const cells = isSlope(code) || isSlopeBelt(code) ? wallCells(code, bx, by) : blockCellList(bx, by);
if (code !== BP_PIPE) evictCells(cells, by * BS); // a pipe is thin: what is there stays and flows past it if (code !== BP_PIPE) evictCells(cells, by * BS); // a pipe is thin: what is there stays and flows past it
for (const i of cells) { for (const i of cells) {
@@ -215,6 +240,7 @@ function blockInReach(bx, by) { return inBuildRange(bx * BS + BS / 2, by * BS +
// 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;
if (code === BP_SCREW && screwDamageMask(b)) return false;
if (isFilter(code)) return (blockV[b] & 128) === filterVariant(code, v) && sameFilter(filterInfo(b), cleanFilterCfg(extra)); if (isFilter(code)) return (blockV[b] & 128) === filterVariant(code, v) && sameFilter(filterInfo(b), cleanFilterCfg(extra));
return blockV[b] === v; return blockV[b] === v;
} }
@@ -224,6 +250,11 @@ function placeBlocks(code, v, list, extra, replace) {
for (const [bx, by] of list) { for (const [bx, by] of list) {
if (!blockInReach(bx, by)) { last = 'Out of reach'; continue; } if (!blockInReach(bx, by)) { last = 'Out of reach'; continue; }
const b = by * BW + bx; const b = by * BW + bx;
if (code === BP_SCREW && screwDamageMask(b)) {
const r = placeBlock(code, v, bx, by, false, extra);
if (r) { last = r; break; }
n++; continue;
}
if (replace && blockP[b]) { if (replace && blockP[b]) {
if (blockIs(b, code, v, extra)) continue; if (blockIs(b, code, v, extra)) continue;
if (blockP[b] === code && isFilter(code)) { undoNote({ t: 'filter', bx, by, v: blockV[b], extra: filterInfo(b) }); blockV[b] = filterVariant(code, v); setFilter(b, extra); filtersDirty = true; n++; continue; } // just re-set it if (blockP[b] === code && isFilter(code)) { undoNote({ t: 'filter', bx, by, v: blockV[b], extra: filterInfo(b) }); blockV[b] = filterVariant(code, v); setFilter(b, extra); filtersDirty = true; n++; continue; } // just re-set it
+1
View File
@@ -106,6 +106,7 @@ function machinesTick() {
if (stamp[i] === tickStamp) continue; if (stamp[i] === tickStamp) continue;
if (temp[i] > 1000 && rnd() < 0.02) { if (temp[i] > 1000 && rnd() < 0.02) {
part[i] = 0; markPow(i, false); emit({ t: 'warn', x: i % W, y: (i / W) | 0, text: 'melted' }); wakeIdx(i); part[i] = 0; markPow(i, false); emit({ t: 'warn', x: i % W, y: (i / W) | 0, text: 'melted' }); wakeIdx(i);
if (!ledger.screwMelt) { ledger.screwMelt = true; emit({ t: 'screw_melt' }); }
continue; continue;
} }
const s = netSat(i); const s = netSat(i);
+14 -4
View File
@@ -13,6 +13,13 @@ function undoRecord(label, fn) {
undoRec = rec; undoRec = rec;
try { return fn(); } finally { undoRec = prev; rec.label = typeof label === 'function' ? label(rec) : label; undoPush(rec); } try { return fn(); } finally { undoRec = prev; rec.label = typeof label === 'function' ? label(rec) : label; undoPush(rec); }
} }
function undoScrewRepair(bx, by, damage, paid) {
if (blockP[by * BW + bx] !== BP_SCREW) return false;
const cells = blockCellList(bx, by);
for (let j = 0; j < 16; j++) if (damage & (1 << j)) { const i = cells[j]; part[i] = 0; markPow(i, false); wakeIdx(i); }
if (paid) refundAt(pieceCost(BP_SCREW, 0), 1, bx * BS + 2, by * BS + 2);
return true;
}
function undoLast() { function undoLast() {
const rec = undoStack.pop(); const rec = undoStack.pop();
if (!rec) return 'Nothing to undo.'; if (!rec) return 'Nothing to undo.';
@@ -21,7 +28,10 @@ function undoLast() {
const fail = () => { fails++; }; const fail = () => { fails++; };
for (let k = rec.ops.length - 1; k >= 0; k--) { for (let k = rec.ops.length - 1; k >= 0; k--) {
const o = rec.ops[k]; const o = rec.ops[k];
if (o.t === 'block+') { if (blockP[o.by * BW + o.bx] === o.code) removeBlock(o.bx, o.by); } if (o.t === 'screw-repair') {
if (!undoScrewRepair(o.bx, o.by, o.damage, o.paid)) fail();
}
else if (o.t === 'block+') { if (blockP[o.by * BW + o.bx] === o.code) removeBlock(o.bx, o.by); }
else if (o.t === 'block-') { const r = placeBlock(o.code, isFilter(o.code) ? o.v & 128 : o.v, o.bx, o.by, false, o.extra); if (r) fail(r); } else if (o.t === 'block-') { const r = placeBlock(o.code, isFilter(o.code) ? o.v & 128 : o.v, o.bx, o.by, false, o.extra); if (r) fail(r); }
else if (o.t === 'filter') { const b = o.by * BW + o.bx; if (isFilter(blockP[b])) { blockV[b] = o.v & 128; setFilter(b, o.extra); filtersDirty = true; } } else if (o.t === 'filter') { const b = o.by * BW + o.bx; if (isFilter(blockP[b])) { blockV[b] = o.v & 128; setFilter(b, o.extra); filtersDirty = true; } }
else if (o.t === 'mach+') { const mm = machines.find(m => m && m.type === o.type && m.x === o.bx * BS && m.y === o.by * BS); if (mm && siloFull(mm)) fail('empty the silo first'); else if (mm) removeMachine(mm.id, true, false); } else if (o.t === 'mach+') { const mm = machines.find(m => m && m.type === o.type && m.x === o.bx * BS && m.y === o.by * BS); if (mm && siloFull(mm)) fail('empty the silo first'); else if (mm) removeMachine(mm.id, true, false); }
@@ -30,7 +40,7 @@ function undoLast() {
else if (o.t === 'mold-') { const r = placeMoldAt(o.kind, o.bx, o.by, o.wall); if (r) fail(r); } else if (o.t === 'mold-') { const r = placeMoldAt(o.kind, o.bx, o.by, o.wall); if (r) fail(r); }
else if (o.t === 'paste') { else if (o.t === 'paste') {
// ghosts still waiting are cleared; anything already built from them is taken back // ghosts still waiting are cleared; anything already built from them is taken back
for (const [bx, by, code] of o.blocks) { const b = by * BW + bx; if (ghostBlocks.has(b)) { ghostBlocks.delete(b); ghostBlocked.delete(b); } else if (blockP[b] === code) removeBlock(bx, by); } for (const [bx, by, code, damage] of o.blocks) { const b = by * BW + bx; if (ghostBlocks.has(b)) { ghostBlocks.delete(b); ghostBlocked.delete(b); } else if (damage) { if (!undoScrewRepair(bx, by, damage, true)) fail(); } else if (blockP[b] === code) removeBlock(bx, by); }
for (const g of o.machines) { const k = ghostMachines.indexOf(g); if (k >= 0) ghostMachines.splice(k, 1); else { const mm = machines.find(m => m && m.type === g.type && m.x === g.bx * BS && m.y === g.by * BS); if (mm) removeMachine(mm.id, true, false); } } for (const g of o.machines) { const k = ghostMachines.indexOf(g); if (k >= 0) ghostMachines.splice(k, 1); else { const mm = machines.find(m => m && m.type === g.type && m.x === g.bx * BS && m.y === g.by * BS); if (mm) removeMachine(mm.id, true, false); } }
for (const g of o.molds) { const k = ghostMolds.indexOf(g); if (k >= 0) ghostMolds.splice(k, 1); else { const mo = molds.find(m => m.kind === g.kind && m.x === g.bx * BS && m.y === g.by * BS); if (mo) removeMold(mo); } } for (const g of o.molds) { const k = ghostMolds.indexOf(g); if (k >= 0) ghostMolds.splice(k, 1); else { const mo = molds.find(m => m.kind === g.kind && m.x === g.bx * BS && m.y === g.by * BS); if (mo) removeMold(mo); } }
} }
@@ -56,13 +66,13 @@ function ghostTick0() {
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, swap]] = 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) && !screwDamageMask(b)) {
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 // a replacing ghost takes down what is there (refunded) and builds itself, like a replace in reach
if (swap && blockP[b]) { if (swap && blockP[b] && !(code === BP_SCREW && screwDamageMask(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 (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; } if (!canAfford(pieceCost(code, v), 1)) { ghostMissing++; continue; }
removeBlock(bx, by); removeBlock(bx, by);
+4 -2
View File
@@ -108,7 +108,7 @@ function dragDirFor(P, a, b) {
return Math.abs(dx) >= Math.abs(dy) ? (dx > 0 ? 1 : 0) : -1; return Math.abs(dx) >= Math.abs(dy) ? (dx > 0 ? 1 : 0) : -1;
} }
function blockLabel(P, rec) { function blockLabel(P, rec) {
const put = piecesIn(rec, 'block+'), took = piecesIn(rec, 'block-') + piecesIn(rec, 'filter'); const put = piecesIn(rec, 'block+') + piecesIn(rec, 'screw-repair'), took = piecesIn(rec, 'block-') + piecesIn(rec, 'filter');
return took ? `replacing ${plural(took, 'piece')}` : `placing ${plural(put, P.name.toLowerCase())}`; return took ? `replacing ${plural(took, 'piece')}` : `placing ${plural(put, P.name.toLowerCase())}`;
} }
@@ -179,7 +179,9 @@ function primaryRelease(shift) {
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;
// held G over a different piece: the ghost replaces it when the bot comes in range // 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++; ghostBlocks.set(b, GAME.replace && blockP[b] ? [code, v, ex, 1] : ex ? [code, v, ex] : [code, v]);
const damage = code === BP_SCREW ? screwDamageMask(b) : 0;
g.blocks.push(damage ? [bx, by, code, damage] : [bx, by, code]); ghosts++;
} }
if (ghosts) undoNote(g); if (ghosts) undoNote(g);
return res; return res;
+1
View File
@@ -20,6 +20,7 @@ function drainEvents() {
} else if (e.t === 'debris') spawnDebris(e.x, e.y, e.m); } else if (e.t === 'debris') spawnDebris(e.x, e.y, e.m);
else if (e.t === 'suck') spawnSuck(e.x, e.y, e.m); else if (e.t === 'suck') spawnSuck(e.x, e.y, e.m);
else if (e.t === 'warn') spawnFloater(e.x, e.y, e.text, '#ff8a72'); else if (e.t === 'warn') spawnFloater(e.x, e.y, e.text, '#ff8a72');
else if (e.t === 'screw_melt') showCard({ kind: 'Heat damage', title: 'Screw conveyor melted', body: ['Loads hotter than 1000°C melt holes in the tube. Grains spill through the holes.', 'Build a screw conveyor over the damaged block to repair it for the price of one piece.'] });
else if (e.t === 'sold') spawnFloater(e.x, e.y, '+' + fmtMoney(e.v)); else if (e.t === 'sold') spawnFloater(e.x, e.y, '+' + fmtMoney(e.v));
else if (e.t === 'order_new') { else if (e.t === 'order_new') {
const o = e.o; const o = e.o;
+8
View File
@@ -134,6 +134,14 @@ function hoverInfo(ax, ay) {
} }
else if (isWall(b) && PH[m] === PH_SOLID && !(flags[i] & F_NAT)) t1 = `${MD[m].name} ${isSlope(b) ? 'sloped wall' : 'block'}`; else if (isWall(b) && PH[m] === PH_SOLID && !(flags[i] & F_NAT)) t1 = `${MD[m].name} ${isSlope(b) ? 'sloped wall' : 'block'}`;
const jb = ((ay / BS) | 0) * BW + ((ax / BS) | 0); const jb = ((ay / BS) | 0) * BW + ((ax / BS) | 0);
if (b === BP_SCREW && !bar) {
if (screwDamageMask(jb)) {
t1 = 'Screw conveyor · melted'; warn = 'needs rebuilding';
t2.push('Melted from carrying a load hotter than 1000°C.', 'Build a screw conveyor over it to repair it for the price of one piece.');
} else if (screwLoadHeat(jb) >= 900) {
warn = 'near heat limit'; t2.push(`${Math.round(screwLoadHeat(jb))}°C load · melts above 1000°C`);
}
}
if (GAME.infoOverlay && !id && !bar && pieceEnds(blockP[jb], blockV[jb]).length) t2.push(`moving ${(flowRate[jb] || 0).toFixed(1)} grains/s`); if (GAME.infoOverlay && !id && !bar && pieceEnds(blockP[jb], blockV[jb]).length) t2.push(`moving ${(flowRate[jb] || 0).toFixed(1)} grains/s`);
for (let end = 0; end < 2 && !id && !bar; end++) { for (let end = 0; end < 2 && !id && !bar; end++) {
const miss = nearMiss(jb % BW, (jb / BW) | 0, end); const miss = nearMiss(jb % BW, (jb / BW) | 0, end);
+1
View File
@@ -79,6 +79,7 @@ function frame(now) {
if (GAME.editRun) drawRunOutline(GAME.editRun, true); else if (hb >= 0) drawRunOutline(filterRun(hb), false); if (GAME.editRun) drawRunOutline(GAME.editRun, true); else if (hb >= 0) drawRunOutline(filterRun(hb), false);
if (GAME.infoOverlay) { drawFlowArrows(); drawFlowRates(); } if (GAME.infoOverlay) { drawFlowArrows(); drawFlowRates(); }
drawJoinMarks(); drawJoinMarks();
drawScrewHeat();
drawPing(); drawPing();
drawHoveredVein(); drawHoveredVein();
drawPingRecharge(); drawPingRecharge();
+43
View File
@@ -0,0 +1,43 @@
// t69: melt feedback, full-cost repair without disturbing the load, and repair undo.
const {load, suite, flatWorld} = require('./harness');
const {run} = load(), {check, done} = suite();
flatWorld(run, 160, 120, 100);
run(`bot.x=40; bot.y=40; money=500; ledger={}; EV.length=0;
placeBlock(BP_SCREW, 4, 10, 10, true);
part[40*W+40]=0; markPow(40*W+40,false);
setCell(40*W+40,RBAS,950,0);`);
check('same screw builds over damage at full price', run(`undoRecord('repair',()=>placeBlocks(BP_SCREW,4,[[10,10]],null,false)).n===1 && money===475 && blockIntegrity(10*BW+10)===16`));
check('repair keeps the hot load in place', run('mat[40*W+40]===RBAS && temp[40*W+40]===950'));
run('undoLast()');
check('undo restores the hole and refunds repair cost', run('part[40*W+40]===0 && !isPow[40*W+40] && money===500 && blockIntegrity(10*BW+10)===15'));
check('G also repairs without a removal refund', run(`undoRecord('repair',()=>placeBlocks(BP_SCREW,4,[[10,10]],null,true)).n===1 && money===475`));
check('G over an intact screw costs nothing', run('placeBlocks(BP_SCREW,4,[[10,10]],null,true).n===0 && money===475'));
run('part[40*W+40]=0; money=0');
check('unaffordable repair leaves the damage alone', run('!!placeBlock(BP_SCREW,4,10,10,false) && part[40*W+40]===0 && money===0'));
run(`money=500; placeBlock(BP_SCREW,4,10,10,false);
tick=2; tickStamp=2;
for(const i of blockCellList(10,10)) {setCell(i,RBAS,1000,0);}
rebuildPower(); for(let k=0;k<500;k++) { for(const i of blockCellList(10,10)) {mat[i]=RBAS; stamp[i]=0;} machinesTick(); }`);
check('1000 degrees does not melt screws', run('blockIntegrity(10*BW+10)===16'));
run(`for(const i of blockCellList(10,10)) temp[i]=1100;
for(let k=0;k<500;k++) { for(const i of blockCellList(10,10)) {mat[i]=RBAS; stamp[i]=0;} machinesTick(); }`);
check('hot loads still melt cell by cell', run('blockIntegrity(10*BW+10)<16'));
check('first melt explains heat limit and repair once', run(`EV.filter(e=>e.t==='screw_melt').length===1 && ledger.screwMelt===true`));
run('const heatSave=serializeWorld(); deserializeWorld(heatSave.meta,heatSave.buffers)');
check('existing save data retains damage and first-message state', run('blockIntegrity(10*BW+10)<16 && ledger.screwMelt===true'));
run(`placeBlock(BP_SCREW,4,10,10,true); for(const i of blockCellList(10,10)) {mat[i]=EMPTY; temp[i]=1100;}
setCell(41*W+41,RBAS,899,0);`);
check('empty hot cells do not count as a hot load', run('screwLoadHeat(10*BW+10)===899'));
run('temp[41*W+41]=900');
check('warning can start before the melt limit', run('screwLoadHeat(10*BW+10)===900 && blockIntegrity(10*BW+10)===16'));
run('part[40*W+40]=0; ghostBlocks.set(10*BW+10,[BP_SCREW,4,null,1]); money=500; ghostTick()');
check('a queued G repair restores damage without refunding', run('blockIntegrity(10*BW+10)===16 && money===475 && !ghostBlocks.size'));
run(`part[40*W+40]=0; money=500;
undoRecord('queued repair',()=>{ghostBlocks.set(10*BW+10,[BP_SCREW,4,null,1]);undoNote({t:'paste',blocks:[[10,10,BP_SCREW,1]],machines:[],molds:[]});});
ghostTick();undoLast();`);
check('undo of a completed queued repair preserves the original damaged screw',run('blockP[10*BW+10]===BP_SCREW && blockIntegrity(10*BW+10)===15 && money===500'));
for(let v=0;v<8;v++) {
run(`removeBlock(10,10);placeBlock(BP_SCREW,${v},10,10,true); part[40*W+40]=0; markPow(40*W+40,false); placeBlock(BP_SCREW,${v},10,10,true)`);
check(`repair restores direction ${v}`,run(`part[40*W+40]===SCREW_PARTS[${v}] && blockIntegrity(10*BW+10)===16`));
}
done();
+79
View File
@@ -0,0 +1,79 @@
"""t69 only: both looks, persistent damage, hover, first message and real click/G repair."""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / 'tools'))
from use_venv import ensure_playwright; ensure_playwright()
from browser import launch, consume_gpu_option
consume_gpu_option()
ROOT = pathlib.Path(__file__).resolve().parents[1]
with launch(viewport={'width': 1280, 'height': 760}) as (_, page, url):
errors = []
page.on('pageerror', lambda e: errors.append(str(e)))
page.goto(url)
page.evaluate("""() => {
const enter=enterGame; enterGame=()=>{enter();GAME.paused=true;};
SET.look='pixel';SET.workerCap=0;SET.cameraLead=false;SET.autosave=0;
startNewWorld(7,768,600);
}""")
page.wait_for_function('GAME.running', timeout=60000)
page.evaluate("""() => {
mat.fill(EMPTY);part.fill(0);blockP.fill(0);blockV.fill(0);isPow.fill(0);
flags.fill(0);temp.fill(20);bgm.fill(0);fog.fill(255);
machines=[null];molds=[];ents=[];flyers=[];treeBases=[];saplings=[];
pcells.clear(); weather.next=1e15;weather.shower=null;surfY.fill(300);updateRoof();
bot.x=310;bot.y=210;bot.vx=bot.vy=0; money=500; ledger={};
for(let bx=72;bx<=84;bx++) placeBlock(BP_SCREW,1,bx,52,true);
for(let x=288;x<340;x++)for(let y=209;y<212;y++)setCell(y*W+x,RBAS,20,0);
for(let x=304;x<316;x++)for(let y=209;y<212;y++)temp[y*W+x]=950;
// Melt the right section with the real machine tick, keeping the scene paused.
for(let k=0;k<400;k++) {
for(let x=328;x<340;x++)for(let y=208;y<212;y++) {const i=y*W+x;mat[i]=RBAS;temp[i]=1100;stamp[i]=0;}
tick=2;tickStamp=2;machinesTick();
}
for(let x=288;x<340;x++)for(let y=208;y<212;y++)temp[y*W+x]=x>=304&&x<316?950:20;
document.querySelectorAll('.card-pop,.toast').forEach(e=>e.remove());
SET.cardSeconds=-1;drainEvents();R.floaters=[];
SET.fog=false;SET.zoom=12;SET.lighting='off';SET.glow='off';SET.minimap=false;
SET.cursorLabel='all';GAME.infoOverlay=false;GAME.hidePreviews=true;
resizeRender();snapCamera();renderDirty.fill(1);GPU.world=null;SM.surf=-1;
}""")
assert page.get_by_text('Screw conveyor melted', exact=True).is_visible()
assert page.evaluate("hoverInfo(330,210).t2.join(' ').includes('hotter than 1000°C')")
assert page.evaluate("hoverInfo(310,210).warn==='near heat limit'")
assert page.evaluate("!hoverInfo(290,210).warn")
for look in ['pixel', 'smooth']:
page.evaluate(f"SET.look='{look}'")
page.wait_for_function(f"R.smoothOn === {'true' if look == 'smooth' else 'false'}")
page.wait_for_timeout(150)
# The new overlay must alter pixels in both looks, and hide behind unexplored fog.
assert page.evaluate("""() => {
const sample=()=>{const p=worldToScreenPx(330,210);return Array.from(R.ctx.getImageData(p.x,p.y,1,1).data);};
R.ctx.clearRect(0,0,R.canvas.width,R.canvas.height); drawScrewHeat();
const shown=sample()[3];SET.fog=true;fog.fill(0);
R.ctx.clearRect(0,0,R.canvas.width,R.canvas.height);drawScrewHeat();
const hidden=sample()[3]; SET.fog=false;fog.fill(255);
return shown>0 && hidden===0;
}""")
page.wait_for_timeout(100)
page.screenshot(path=str(ROOT / f'shots/t69-{look}.png'))
print(f'PASS {look}: cold / warning / cooled melt damage, hover, fog and screenshot')
# Exercise the actual build input, then undo, then G + click on the same cooled damage.
page.evaluate("GAME.paused=false;devPaused=true;GAME.hidePreviews=false;GAME.tool=2;GAME.pieceIdx=PIECES.findIndex(p=>p.id==='screw');GAME.pieceDir.screw=1")
target = page.evaluate("worldToScreenPx(330,210)")
for replace in [False, True]:
if replace:
page.keyboard.down('g')
before = page.evaluate('money')
page.mouse.move(target['x'], target['y'])
page.wait_for_timeout(60)
page.mouse.click(target['x'], target['y'])
page.wait_for_timeout(100)
assert page.evaluate('blockIntegrity(52*BW+82)===16')
assert page.evaluate('money') == before - 25
if replace:
page.keyboard.up('g')
page.keyboard.press('Control+z')
assert page.evaluate('blockIntegrity(52*BW+82)<16')
assert page.evaluate('money') == before
assert not errors, errors
print('PASS first melt message; real click and G repair at full cost; keyboard undo; no page errors')