From 5fd8110470dadfe55c807068e410e8639ed25256 Mon Sep 17 00:00:00 2001 From: 44r0n7 <44r0n7+gitea@pm.me> Date: Thu, 29 Oct 2026 18:30:26 -0400 Subject: [PATCH] Hot loads: visible melt damage, rebuild-over repair, warning glow (t69) --- docs/PROJECT_MAP.md | 24 +++++++----- docs/TESTING.md | 6 +++ docs/agent-log.md | 13 +++++++ src/manifest.json | 1 + src/render/screw-heat.js | 29 +++++++++++++++ src/sim/blocks.js | 31 ++++++++++++++++ src/sim/machine-tick.js | 1 + src/sim/undo.js | 18 +++++++-- src/ui/actions.js | 6 ++- src/ui/events.js | 1 + src/ui/info.js | 8 ++++ src/ui/loop.js | 1 + test/t_screw_heat.js | 43 ++++++++++++++++++++++ test/t_screw_heat.py | 79 ++++++++++++++++++++++++++++++++++++++++ 14 files changed, 246 insertions(+), 15 deletions(-) create mode 100644 src/render/screw-heat.js create mode 100644 test/t_screw_heat.js create mode 100644 test/t_screw_heat.py diff --git a/docs/PROJECT_MAP.md b/docs/PROJECT_MAP.md index d03b68c..8a64eeb 100644 --- a/docs/PROJECT_MAP.md +++ b/docs/PROJECT_MAP.md @@ -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. diff --git a/docs/TESTING.md b/docs/TESTING.md index c39280d..2ae0aaf 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -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 diff --git a/docs/agent-log.md b/docs/agent-log.md index c89c703..a68e763 100644 --- a/docs/agent-log.md +++ b/docs/agent-log.md @@ -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 diff --git a/src/manifest.json b/src/manifest.json index c2d95c1..4a8dabf 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -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", diff --git a/src/render/screw-heat.js b/src/render/screw-heat.js new file mode 100644 index 0000000..5af2a01 --- /dev/null +++ b/src/render/screw-heat.js @@ -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(); +} diff --git a/src/sim/blocks.js b/src/sim/blocks.js index e877f2b..aa84b19 100644 --- a/src/sim/blocks.js +++ b/src/sim/blocks.js @@ -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 diff --git a/src/sim/machine-tick.js b/src/sim/machine-tick.js index 21e3e52..05eee9e 100644 --- a/src/sim/machine-tick.js +++ b/src/sim/machine-tick.js @@ -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); diff --git a/src/sim/undo.js b/src/sim/undo.js index e62deac..81b4acd 100644 --- a/src/sim/undo.js +++ b/src/sim/undo.js @@ -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); diff --git a/src/ui/actions.js b/src/ui/actions.js index 96f60af..ba62f7f 100644 --- a/src/ui/actions.js +++ b/src/ui/actions.js @@ -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; diff --git a/src/ui/events.js b/src/ui/events.js index d8baee2..11f8598 100644 --- a/src/ui/events.js +++ b/src/ui/events.js @@ -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; diff --git a/src/ui/info.js b/src/ui/info.js index 6cc834c..893f22f 100644 --- a/src/ui/info.js +++ b/src/ui/info.js @@ -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); diff --git a/src/ui/loop.js b/src/ui/loop.js index 739c182..9ff6b7a 100644 --- a/src/ui/loop.js +++ b/src/ui/loop.js @@ -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(); diff --git a/test/t_screw_heat.js b/test/t_screw_heat.js new file mode 100644 index 0000000..88a2d98 --- /dev/null +++ b/test/t_screw_heat.js @@ -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(); diff --git a/test/t_screw_heat.py b/test/t_screw_heat.py new file mode 100644 index 0000000..306b4fd --- /dev/null +++ b/test/t_screw_heat.py @@ -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')