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
- 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).
- 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
- 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.
- 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)
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.
- 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.
- functions: undoNote, undoPush, undoRecord, undoLast, clearAllGhosts, ghostTick, ghostTick0
- functions: undoNote, undoPush, undoRecord, undoScrewRepair, undoLast, clearAllGhosts, ghostTick, ghostTick0
- variables: UNDO_MAX, undoStack, undoRec, ghostMissing, ghostIter
### 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
- 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)
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
@@ -568,7 +572,7 @@ Hover work is shared by the cursor label and the vein outline.
- functions: oreLike, bitNeeded, pingVisible, cursorVein, hoveredVein, cursorLabelAlpha
- 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.
- functions: showCard, howText, discoveryCard, reactionsOf, hoverInfo, renderInfo, drawCursorLabel, pipeContentsText
@@ -582,7 +586,7 @@ Keyboard and mouse listeners.
- functions: isTyping
- 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
- 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
@@ -605,7 +609,7 @@ Selection previews: labelled tags, copy and cut boxes, and paste ghosts. a small
- functions: drawTag, drawSelection, drawPastePreview
- 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.
- 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.
- 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.
- functions: scanVisible, frame, boot
- 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_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_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_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.
+6
View File
@@ -14,6 +14,12 @@ prints the failing checks.
## 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:
```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
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)
- 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/machines.js",
"render/overlays.js",
"render/screw-heat.js",
"ui/audio.js",
"ui/state.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) {
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) {
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';
for (const i of blockCellList(bx, by)) {
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';
const cost = pieceCost(code, v);
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);
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) {
@@ -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)?
function blockIs(b, code, v, extra) {
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));
return blockV[b] === v;
}
@@ -224,6 +250,11 @@ function placeBlocks(code, v, list, extra, replace) {
for (const [bx, by] of list) {
if (!blockInReach(bx, by)) { last = 'Out of reach'; continue; }
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 (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
+1
View File
@@ -106,6 +106,7 @@ function machinesTick() {
if (stamp[i] === tickStamp) continue;
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);
if (!ledger.screwMelt) { ledger.screwMelt = true; emit({ t: 'screw_melt' }); }
continue;
}
const s = netSat(i);
+14 -4
View File
@@ -13,6 +13,13 @@ function undoRecord(label, fn) {
undoRec = 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() {
const rec = undoStack.pop();
if (!rec) return 'Nothing to undo.';
@@ -21,7 +28,10 @@ function undoLast() {
const fail = () => { fails++; };
for (let k = rec.ops.length - 1; k >= 0; 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 === '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); }
@@ -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 === 'paste') {
// 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.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; }
const [b, [code, v, e, swap]] = nx.value;
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
ghostBlocks.delete(b); ghostBlocked.delete(b); 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 (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 (!canAfford(pieceCost(code, v), 1)) { ghostMissing++; continue; }
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;
}
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())}`;
}
@@ -179,7 +179,9 @@ function primaryRelease(shift) {
const b = by * BW + bx;
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
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);
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 === '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 === '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 === 'order_new') {
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'}`;
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`);
for (let end = 0; end < 2 && !id && !bar; 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.infoOverlay) { drawFlowArrows(); drawFlowRates(); }
drawJoinMarks();
drawScrewHeat();
drawPing();
drawHoveredVein();
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')