Fewer, smaller messages: mold percentages, markers for machine heat, short status lines

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-23 23:03:45 -04:00
co-authored by Claude Opus 5.5
parent 43eb4e23fd
commit 4d4d94cbd8
25 changed files with 164 additions and 106 deletions
+2
View File
@@ -175,6 +175,8 @@ above, including:
- **Ingot molds need no F.** They push junk and mixed metal out of the bottom as loose grains with the bars, throw slag away and rake the grate. Removing any mold returns its set metal: whole bars as bars, the rest as scrap. The owner asked what F was for once bars dropped on their own, and chose this over keeping F for cleaning.
- **Fewer, smaller messages.** Molds show only a small percentage, red when blocked or spoiled and gold when a casting waits for F; details are in the hover panel. Bars dropping, blocked molds and set castings no longer pop up cards or toasts. Fan heat is a red marker on the fan; melted tubes and machines get a short red label where they were. Status lines are one short sentence. The owner found the messages too large and too many.
## Ideas not built yet
- **Concrete**, as a stronger wall material from rubble.
+9 -8
View File
@@ -257,9 +257,9 @@ How belts, filters and screw tubes move what is on or in them. What rests on a b
Building blocks: placing, integrity, removing, lines and rectangles of blocks.
- functions: blockOf, blockCellList, pieceCost, canPlaceBlock, placeBlock, blockIntegrity, removeBlock, blockLine, blockRect, blockInReach, blockIs, placeBlocks
### src/sim/molds.js (328 lines)
### src/sim/molds.js (319 lines)
Molds: cavities for castings; ingot troughs empty themselves below. Removal returns set metal.
- functions: moldCellsAt, canPlaceMold, newMoldRecord, addMold, castMix, cavityState, troughState, clearCavity, moldPlugCells, rakeAbove, furnaceJunkHint, unplugMold, ejectIngot, checkMolds, moldAt, knockNote, tally, collectMold, removeMold
- functions: moldCellsAt, canPlaceMold, newMoldRecord, addMold, castMix, cavityState, troughState, clearCavity, moldPlugCells, rakeAbove, furnaceJunkHint, unplugMold, ejectIngot, checkMolds, moldAt, tally, collectMold, removeMold
- variables: DROSS, grains, barWord
### src/sim/items.js (134 lines)
@@ -341,13 +341,13 @@ drawWorld: the per-grain pixel buffer (pixel look), also the input of the smooth
- functions: drawWorld
- variables: TUBE_CLASS
### src/render/machines.js (152 lines)
### src/render/machines.js (157 lines)
Machine overlays: fans, engines, refiner, cutter, silo and the rest, drawn on top of the world.
- functions: cellRect, drawHazardRims, drawMachines, drawSilo
### src/render/overlays.js (230 lines)
### src/render/overlays.js (228 lines)
Other overlays: floating text, rain and snow, flow arrows, filter tags, molds, the bot, particles.
- functions: spawnFloater, drawRain, drawFlowArrows, drawRunOutline, drawFilterTags, drawFloaters, drawMolds, drawBot, spawnDebris, spawnSpark, spawnSuck, drawParticles
- functions: spawnFloater, drawRain, drawFlowArrows, drawRunOutline, drawFilterTags, drawFloaters, moldLabel, drawMolds, drawBot, spawnDebris, spawnSpark, spawnSuck, drawParticles
### src/render/smooth.js (430 lines)
Smooth look: a WebGL2 pass that turns the per-grain buffers drawWorld fills (colour + class, colour behind, trees, occupancy fields, light, glow) into rounded, outlined, softly lit shapes at screen resolution. The 2D canvas then draws this image as its world layer, and every overlay (machines, bot, previews, labels) goes on top as before.
@@ -402,7 +402,7 @@ The selected tool and piece: R / Q / E, drill width, filter picker, the selectio
- functions: pieceUsesWall, setDrillWidth, tuneUnderCursor, cycleItem, cycleWall, rotate, toggleCollect, pickFilterUnderCursor, toggleFilterMat, toggleCollectUnderCursor, setBrush, pieceV, pieceExtra, filterChoices, matList, filterParts, filterSentence, applyFilterRun, openFilterPicker, filterBlockAt, chipCost, pieceCostNow, renderSelCard, renderBag
- variables: lastBagKey
### src/ui/info.js (167 lines)
### src/ui/info.js (170 lines)
Discovery cards, hover info (I) and the cursor label.
- functions: showCard, howText, discoveryCard, reactionsOf, hoverInfo, renderInfo, drawCursorLabel
@@ -411,7 +411,7 @@ Keyboard and mouse listeners.
- functions: isTyping
- variables: canvas
### src/ui/actions.js (244 lines)
### src/ui/actions.js (242 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, toolTick
- variables: ovShown
@@ -426,7 +426,7 @@ Previews drawn under the cursor: build ghosts, drill aim and pulses, selections,
- functions: blockRectPx, drawGrid, previewColor, crackSeg, drawCracks, pulseRGB, fanPoints, drawDrillPulses, drawDrillAim, drawPreviews, drawTag, drawSelection, drawPastePreview
- variables: CRACK_N, CRACK_LV, SEL_COL
### src/ui/events.js (50 lines)
### src/ui/events.js (42 lines)
Events from the simulation (emit) turned into sounds, toasts and cards.
- functions: drainEvents
@@ -469,4 +469,5 @@ The frame loop and boot.
- `tools/experiments/worldgen.js`: World generation report: time to generate, how many cells of each material, where the bot starts, and what idle ticks and ticks after digging cost.
- `tools/scenes/belt-heap.js`: A belt feeding an upward screw: sand underneath a deep heap of copper ore. Run clear-sky.js first, e.g. python3 tools/shot.py /tmp/belt-heap.png --setup tools/scenes/clear-sky.js --js "$(cat tools/scenes/belt-heap.js)" --ticks 240 --zoom 7
- `tools/scenes/clear-sky.js`: A clear work area left of the bot's starting point (the trade dock is to its right): open sky 160 grains wide and 40 tall, over a flat stone floor. Money and a bag of building stuff to build with. Sets FLOOR (the first row of the floor), BX, BY (the leftmost block of the area that stands on the floor) and CAM (the middle of the area).
- `tools/scenes/mold-labels.js`: Run after clear-sky.js: filling, blocked, set, and hot machinery in one view.
- `tools/scenes/stone-uses.js`: The stone and sand machines: a half-full silo with its gate open over a belt, a stone cutter fed with rubble, and cut stone and glass brick walls. Run clear-sky.js first, e.g. python3 tools/shot.py /tmp/stone.png --setup tools/scenes/clear-sky.js --js "$(cat tools/scenes/stone-uses.js)" --ticks 240 --zoom 7
+22
View File
@@ -4,6 +4,28 @@ 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-23: fewer, smaller messages (Claude Code, with Codex)
Note: "lets change the engot mold and other status messages. they are way too large and just too
many messages…". The owner chose molds, machine warnings and shorter status lines.
- `src/render/overlays.js`: every mold always shows a small percentage (`moldLabel`), gold
`100% · F` for a set bit or tank, red when spoiled or when an ingot mold is blocked
(`mo.blocked`, set in `ejectIngot`, not saved). Details are in the hover panel (`info.js`),
which also carries the furnace-loading advice that F messages used to append.
- `src/ui/events.js`: no cards or toasts for bars dropping, blocked molds or set castings (the
sounds stay). Fan heat is a red ! marker on the fan (`mm.hot`, hover shows °C); melted tubes
and machines destroyed by heat get a short red floating label (`warn` event, coloured
`spawnFloater`) instead of a toast.
- Status lines shortened to one sentence across `src/sim` (molds, actions, blocks, blueprints,
drill, items, machines, tools, undo, vacuum) and `src/ui` (actions, selection).
- Tests: `play.py` records every status line and checks none is over 90 characters (longest
now 55); mold label and no-toast checks. `t_molds` (message lengths, `mo.blocked`), `t_build`
(hot fan: `mm.hot`, no toast), `t_world`. All 11 headless suites and `play.py` pass.
- Shots: `shots/messages-pixel.png`, `shots/messages-smooth.png` (filling, blocked, set bit,
hot fan).
- Open: the first-ever bar drop no longer shows an explaining card; the notebook still explains.
## 2026-10-23: ingot molds need no F; removing a mold returns its metal (Claude Code, with Codex)
Note: "now that the ingot molds drop the ingots, what is the purpose of F…". The owner chose no F
+5
View File
@@ -52,6 +52,11 @@ function drawMachines() {
const a = mm.angle + k * Math.PI * 2 / 3;
ctx.beginPath(); ctx.ellipse(cx + Math.cos(a) * sw * 0.18, cy + Math.sin(a) * sw * 0.18, sw * 0.17, sw * 0.07, a, 0, Math.PI * 2); ctx.fill();
}
if (mm.hot > 760) {
const sz = Math.max(10, zp * 2), x = sx + sw - sz * 0.6, y = sy - sz * 0.4;
ctx.save(); ctx.fillStyle = '#b84332'; ctx.beginPath(); ctx.roundRect(x, y, sz, sz, sz * 0.2); ctx.fill();
ctx.fillStyle = '#fff'; ctx.font = `700 ${sz * 0.85}px monospace`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('!', x + sz / 2, y + sz / 2); ctx.restore();
}
// direction arrow
const dx = [1, 0, -1, 0][mm.dir], dy = [0, 1, 0, -1][mm.dir];
ctx.strokeStyle = sat > 0.05 && mm.on ? 'rgba(160,220,255,0.7)' : 'rgba(160,170,180,0.35)'; ctx.lineWidth = Math.max(1, zp * 0.35);
+13 -15
View File
@@ -1,5 +1,5 @@
// Other overlays: floating text, rain and snow, flow arrows, filter tags, molds, the bot, particles.
function spawnFloater(x, y, text) { R.floaters = R.floaters || []; if (R.floaters.length < 30) R.floaters.push({ x, y, text, life: 70 }); }
function spawnFloater(x, y, text, colour) { R.floaters = R.floaters || []; if (R.floaters.length < 30) R.floaters.push({ x, y, text, colour, life: 70 }); }
// Rain as thin falling streaks, snow as soft flakes
function drawRain() {
if (!flyers.length) return;
@@ -133,33 +133,31 @@ function drawFloaters() {
if (f.life <= 0) { fl.splice(k, 1); continue; }
const s = worldToScreenPx(f.x, f.y), a = Math.min(1, f.life / 25);
ctx.fillStyle = `rgba(14,12,11,${0.6 * a})`; ctx.fillText(f.text, s.x + 1, s.y + 1);
ctx.fillStyle = `rgba(255,210,122,${a})`; ctx.fillText(f.text, s.x, s.y);
ctx.globalAlpha = a; ctx.fillStyle = f.colour || '#ffd27a'; ctx.fillText(f.text, s.x, s.y); ctx.globalAlpha = 1;
}
ctx.restore();
}
function drawMolds(hoverMold) {
const ctx = R.ctx, zp = R.zp;
function moldLabel(mo) { return mo.state === 'ready' && mo.kind !== 'ingot' ? '100% · F' : Math.round(mo.fill * 100) + '%'; }
function drawMolds() {
const ctx = R.ctx, zp = R.zp, fs = Math.max(9, Math.round(zp * 2));
ctx.save();
if (!R.monoFont) R.monoFont = getComputedStyle(document.documentElement).getPropertyValue('--font-mono').trim() || 'monospace';
ctx.font = `600 ${Math.max(10, Math.round(zp * 3.4))}px ${R.monoFont}`;
ctx.font = `600 ${fs}px ${R.monoFont}`;
ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
for (const mo of molds) {
const [sx, sy, sw, sh] = cellRect(mo.x, mo.y, mo.w, mo.h);
if (sx > R.canvas.width || sy > R.canvas.height || sx + sw < 0 || sy + sh < 0) continue;
const ready = mo.state === 'ready', flawed = mo.state === 'flawed';
const ready = mo.state === 'ready' && mo.kind !== 'ingot', bad = mo.state === 'flawed' || mo.blocked;
ctx.setLineDash([zp, zp]);
ctx.strokeStyle = ready ? 'rgba(255,200,90,0.95)' : flawed ? 'rgba(230,90,70,0.8)' : 'rgba(200,190,170,0.4)';
ctx.strokeStyle = bad ? '#ff8a72' : ready ? '#ffcf7a' : 'rgba(200,190,170,0.4)';
ctx.lineWidth = Math.max(1, zp * 0.3);
ctx.strokeRect(sx - zp * 0.5, sy - zp * 0.5, sw + zp, sh + zp);
ctx.setLineDash([]);
if (ready || flawed || mo === hoverMold || mo.state === 'filling' || mo.state === 'short') {
const label = ready ? `${mo.kind === 'ingot' ? (mo.ready.length === 1 ? 'Bar' : `${mo.ready.length} bars`) : mo.kind === 'bit' ? 'Bit' : 'Tank'} set${mo.kind === 'ingot' ? '' : ' · F'}` : flawed ? 'Flawed casting' : mo.state === 'short' ? 'Needs more metal' : mo.state === 'filling' ? `Filling ${Math.round(mo.fill * 100)}%` : MOLDS[mo.kind].name;
ctx.fillStyle = 'rgba(14,12,11,0.75)';
const tw = ctx.measureText(label).width + zp * 2;
ctx.fillRect(sx + sw / 2 - tw / 2, sy - zp * 5.2, tw, zp * 4.2);
ctx.fillStyle = ready ? '#ffcf7a' : flawed ? '#ff8a72' : '#e7dccb';
ctx.fillText(label, sx + sw / 2, sy - zp * 1.4);
}
const label = moldLabel(mo), tw = ctx.measureText(label).width + fs * 0.6;
ctx.fillStyle = 'rgba(14,12,11,0.75)';
ctx.fillRect(sx + sw / 2 - tw / 2, sy - zp - fs * 1.3, tw, fs * 1.3);
ctx.fillStyle = bad ? '#ff8a72' : ready ? '#ffcf7a' : '#e7dccb';
ctx.fillText(label, sx + sw / 2, sy - zp - fs * 0.15);
}
ctx.restore();
}
+3 -3
View File
@@ -3,7 +3,7 @@
function removePieceAt(x, y) {
droppedLast = 0;
const msg = removePieceAt0(x, y);
return msg && droppedLast ? msg.replace('taken back.', 'taken back. Your bag is full, so the rest dropped where it stood.') : msg;
return msg && droppedLast ? msg.replace('taken back.', 'taken back; bag full, excess dropped here.') : msg;
}
function removePieceAt0(x, y) {
if (x < 0 || y < 0 || x >= W || y >= H) return '';
@@ -15,7 +15,7 @@ function removePieceAt0(x, y) {
if (mach[i] && machines[mach[i]]) {
const P = PIECE_BY_ID[machines[mach[i]].type];
if (P.fixed) return '';
if (siloFull(machines[mach[i]])) return `Empty the silo first: it holds ${machines[mach[i]].sn.toLocaleString('en-US')} ${MD[machines[mach[i]].sm].name.toLowerCase()}. Press F on it to open its gate.`;
if (siloFull(machines[mach[i]])) return 'Empty the silo first; press F to open its gate.';
removeMachine(mach[i], true, false); return `${P.name} taken back.`;
}
const mo = moldAt(x, y);
@@ -80,7 +80,7 @@ function interactAt(x, y) {
return blockP[pb] === BP_PUMP ? (blockV[pb] & 4 ? 'Pump off.' : 'Pump on.') : (blockV[pb] & 4 ? 'Outlet closed.' : 'Outlet open.');
}
const id = mach[y * W + x];
if (id && machines[id] && machines[id].type === 'silo') { const mm = machines[id]; mm.open = !mm.open; return mm.open ? (mm.sn ? 'Silo gate open.' : 'Silo gate open. It is empty.') : 'Silo gate shut.'; }
if (id && machines[id] && machines[id].type === 'silo') { const mm = machines[id]; mm.open = !mm.open; return mm.open ? (mm.sn ? 'Silo gate open.' : 'Silo gate open; it is empty.') : 'Silo gate shut.'; }
if (id && machines[id] && machines[id].type === 'fan') { machines[id].on = !machines[id].on; powerDirty = true; return machines[id].on ? 'Fan on.' : 'Fan off.'; }
return '';
}
+1 -1
View File
@@ -8,7 +8,7 @@ function canPlaceBlock(bx, by) {
if (blockP[by * BW + bx]) return 'Something is already built there';
for (const i of blockCellList(bx, by)) {
if (mach[i] || part[i]) return 'Something is already built there';
if (PH[mat[i]] === PH_SOLID) return 'Needs open space. Clear the rock first.';
if (PH[mat[i]] === PH_SOLID) return 'Needs open space; clear the rock first.';
if (occ[i]) return 'A bar is in the way';
}
return '';
+1 -1
View File
@@ -80,7 +80,7 @@ function footprintBlocker(bx, by, bw, bh) {
for (let y = by * BS; y < (by + bh) * BS; y++) for (let x = bx * BS; x < (bx + bw) * BS; x++) {
if (x < 0 || y < 0 || x >= W || y >= H) return 'Out of bounds';
const i = y * W + x;
if (PH[mat[i]] === PH_SOLID && (flags[i] & F_NAT)) return 'Needs open space. Clear the rock first.';
if (PH[mat[i]] === PH_SOLID && (flags[i] & F_NAT)) return 'Needs open space; clear the rock first.';
if (occ[i]) return 'A bar is in the way';
if (mach[i] && machines[mach[i]] && PIECE_BY_ID[machines[mach[i]].type].fixed) return 'That cannot be replaced';
if (mach[i] && siloFull(machines[mach[i]])) return 'Empty the silo first';
+2 -2
View File
@@ -53,11 +53,11 @@ function toolPulse(ax, ay, width, tune) {
if (HARD[m] > tier.hard) {
const need = BIT_TIERS.find(b => b.hard >= HARD[m]);
const nb = need ? need.name.toLowerCase() : 'better bit';
status = HARD[m] > 6 ? `${MD[m].name} cannot be drilled.` : `${MD[m].name} is too hard for your ${tier.name.toLowerCase()}. Needs ${/^[aeiou]/.test(nb) ? 'an' : 'a'} ${nb}.`;
status = HARD[m] > 6 ? `${MD[m].name} cannot be drilled.` : `${MD[m].name} is too hard; needs ${/^[aeiou]/.test(nb) ? 'an' : 'a'} ${nb}.`;
}
break;
}
if (!status && d > reach) status = `Out of reach. A ${Wd.name} pulse from your ${tier.name.toLowerCase()} reaches ${reach} grains from the bot.`;
if (!status && d > reach) status = `Out of reach; a ${Wd.name} pulse reaches ${reach} grains.`;
return { fired: true, status };
}
const THIN_BONUS = 0.75, CRUMBLE_MAX = 16;
+1 -1
View File
@@ -131,4 +131,4 @@ function canAfford(cost, mult) { for (const [m, n] of cost) if ((m === CASH ? mo
function payCost(cost, mult) { for (const [m, n] of cost) { if (m === CASH) money -= n * mult; else bagTake(m, n * mult); } }
function fmtMoney(v) { return '$' + Math.round(v).toLocaleString('en-US'); }
function costText(cost, mult) { return cost.map(([m, n]) => { const k = n * (mult || 1); return m === CASH ? fmtMoney(k) : `${k} ${MD[m].name.toLowerCase()}${PH[m] === PH_ITEM && k !== 1 ? 's' : ''}`; }).join(' + '); }
function needText(cost, mult) { const c = cost.find(([m, n]) => (m === CASH ? money : bag[m]) < n * (mult || 1)); return c && c[0] === CASH ? `Costs ${fmtMoney(c[1] * (mult || 1))}; you have ${fmtMoney(money)}.` : 'Not enough ' + costText(cost, mult); }
function needText(cost, mult) { const c = cost.find(([m, n]) => (m === CASH ? money : bag[m]) < n * (mult || 1)); return c && c[0] === CASH ? `Costs ${fmtMoney(c[1] * (mult || 1))}; you have ${fmtMoney(money)}.` : 'Not enough ' + costText(c ? [c] : cost, mult) + '.'; }
+3 -3
View File
@@ -82,11 +82,11 @@ function machinesTick() {
const sat = POWER_FREE ? 1 : net ? net.sat : 0;
mm.angle += sat * 0.5 * (mm.on ? 1 : 0);
if (mm.on && sat > 0.05) fanBlow(mm, sat);
// a fan getting close to melting says so (once every few seconds)
// Keep the measured heat for the marker and hover panel.
if ((tick + mm.id) % 30 === 0) {
let hotT = 0;
for (let y = mm.y; y < mm.y + 4; y++) for (let x = mm.x; x < mm.x + 4; x++) { const tt = temp[y * W + x]; if (tt > hotT) hotT = tt; }
if (hotT > 760 && (!mm.warnT || tick - mm.warnT > 600)) { mm.warnT = tick; emit({ t: 'toast', text: `A fan is at ${Math.round(hotT)}°C and melts at 1085°C. Move it out of the heat: outside the furnace wall, blowing in through a grate.` }); }
mm.hot = Math.round(hotT);
}
}
// belts and filters move what rests on them every other tick: half a grain per tick
@@ -118,7 +118,7 @@ function machinesTick() {
if (m === EMPTY || PH[m] !== PH_POWDER) { if (m === EMPTY || PH[m] !== PH_SOLID) tubeIntake(i, p, dx, dy); continue; }
if (stamp[i] === tickStamp) continue;
if (temp[i] > 1000 && rnd() < 0.02) {
part[i] = 0; markPow(i, false); emit({ t: 'toast', text: 'A screw tube melted. Copper softens around 1000°C.' }); wakeIdx(i);
part[i] = 0; markPow(i, false); emit({ t: 'warn', x: i % W, y: (i / W) | 0, text: 'melted' }); wakeIdx(i);
continue;
}
const s = netSat(i);
+2 -2
View File
@@ -112,7 +112,7 @@ function canPlaceMachine(type, bx, by) {
for (const [cx, cy] of machineFootprint(type, bx * BS, by * BS)) {
const i = cy * W + cx;
if (mach[i] || part[i]) return 'Something is already built there';
if (PH[mat[i]] === PH_SOLID) return (flags[i] & F_NAT) ? 'Needs open space. Clear the rock first.' : 'Something is already built there';
if (PH[mat[i]] === PH_SOLID) return (flags[i] & F_NAT) ? 'Needs open space; clear the rock first.' : 'Something is already built there';
if (occ[i]) return 'A bar is in the way';
}
for (let y = by; y < by + P.bh; y++) for (let x = bx; x < bx + P.bw; x++) if (blockP[y * BW + x]) return 'Something is already built there';
@@ -150,5 +150,5 @@ function removeMachine(id, refund, scrap) {
}
machines[id] = null;
if (refund) { refundAt(PIECE_BY_ID[mm.type].cost, 1, mm.x + mm.w / 2, mm.y + mm.h / 2); undoNote({ t: 'mach-', type: mm.type, bx: mm.x / BS, by: mm.y / BS, dir: mm.dir, on: mm.on }); }
if (scrap) emit({ t: 'toast', text: `${PIECE_BY_ID[mm.type].name} was destroyed by heat.` });
if (scrap) emit({ t: 'warn', x: mm.x + mm.w / 2, y: mm.y, text: `${PIECE_BY_ID[mm.type].name} destroyed` });
}
+15 -24
View File
@@ -11,13 +11,13 @@ function canPlaceMold(kind, bx, by) {
if (bx < 0 || by < 0 || bx + md.bw > BW || by + md.bh > BH) return 'Out of bounds';
for (let y = by; y < by + md.bh; y++) for (let x = bx; x < bx + md.bw; x++) if (blockP[y * BW + x]) return 'Something is already built there';
const { walls, cavs } = moldCellsAt(kind, bx * BS, by * BS);
for (const i of walls.concat(...cavs)) if (!cellBuildable(i)) return PH[mat[i]] === PH_SOLID && (flags[i] & F_NAT) ? 'Needs open space. Clear the rock first.' : 'Something is already built there';
for (const i of walls.concat(...cavs)) if (!cellBuildable(i)) return PH[mat[i]] === PH_SOLID && (flags[i] & F_NAT) ? 'Needs open space; clear the rock first.' : 'Something is already built there';
if (moldAt(bx * BS + 1, by * BS + 1)) return 'There is already a mold there';
return null;
}
function newMoldRecord(kind, x, y, wall) {
const md = MOLDS[kind];
return { id: (molds.length ? molds[molds.length - 1].id : 0) + 1, kind, x, y, w: md.w, h: md.h, wall, cavs: moldCellsAt(kind, x, y).cavs, ready: [], state: 'empty', metal: 0, fill: 0, flaw: '' };
return { id: (molds.length ? molds[molds.length - 1].id : 0) + 1, kind, x, y, w: md.w, h: md.h, wall, cavs: moldCellsAt(kind, x, y).cavs, ready: [], state: 'empty', metal: 0, fill: 0, flaw: '', blocked: false };
}
function addMold(kind, bx, by, wallLoose, free) {
const md = MOLDS[kind];
@@ -132,7 +132,7 @@ function rakeAbove(mo) {
if (n) wakeXY(mo.x + (mo.w >> 1), mo.y - 8);
return n;
}
// After a knock-out: if the furnace over the mold is badly loaded, say what to change
// Hover advice when the furnace over the mold is badly loaded.
function furnaceJunkHint(mo) {
let ore = 0, fuel = 0, open = 0;
for (let x = mo.x; x < mo.x + mo.w; x++) for (let y = mo.y - 1, k = 0; y >= 0 && k < 48; y--, k++) {
@@ -161,6 +161,7 @@ function unplugMold(mo) {
const grains = n => `${n} ${n === 1 ? 'grain' : 'grains'}`;
const barWord = (m, n) => m === GLASS ? (n === 1 ? 'glass brick' : 'glass bricks') : n === 1 ? 'bar' : 'bars';
function ejectIngot(mo) {
mo.blocked = false;
let r = troughState(mo), n = 0;
const metal = r.metal, x = mo.x + (mo.w >> 1), y = mo.y + mo.h;
if (r.st === 'ready') {
@@ -173,6 +174,7 @@ function ejectIngot(mo) {
free = true;
if (spawnBar(BAR_OF[metal], sx, y, (rnd() - 0.5) * 0.15, 0.2, T)) n++;
}
mo.blocked = !n && !free;
if (n) {
// Take from the top so the short stub stays on the trough floor.
let take = n * GRAINS_PER_BAR;
@@ -203,7 +205,7 @@ function ejectIngot(mo) {
setCell(dest, loose, temp[i], 0); slot++; placed = true; break;
}
}
if (!placed) continue;
if (!placed) { mo.blocked = true; continue; }
}
setCell(i, EMPTY, AMB[(i / W) | 0], 0); pushed++;
}
@@ -241,16 +243,6 @@ function moldAt(x, y) {
for (const mo of molds) if (x >= mo.x && y >= mo.y && x < mo.x + mo.w && y < mo.y + mo.h) return mo;
return null;
}
// What a knock-out sent where, as one short sentence after the main message
function knockNote(scrap, other, chipped, raked) {
let s = '';
if (scrap) s += ` The leftover ${grains(scrap)} of scrap went into your bag${other ? ', with the grit that got in' : ''}.`;
else if (other) s += ' The grit that got in went into your bag.';
if (chipped && raked) s += ' The slag was chipped off and the grate raked clean.';
else if (chipped) s += ' The slag was chipped off.';
else if (raked) s += ' Slag and ash were raked off the grate.';
return s;
}
function tally(got) {
let scrap = 0, other = 0;
for (const [k, c] of got) { if (PH[k] === PH_POWDER && MELTTO[k] && CASTMETAL[MELTTO[k]] && !SMELTP[k]) scrap += c; else other += c; }
@@ -264,39 +256,38 @@ function collectMold(mo) {
const scrap = g.scrap + u.metal;
if (!scrap && !g.other && !g.dross && !u.chipped && !u.raked) return 'The mold is empty.';
emit({ t: 'sfx', s: 'ui' });
return 'Knocked the mold clean.' + knockNote(scrap, g.other, g.dross + u.chipped, u.raked);
return 'Knocked the mold clean.';
}
let msg = '';
if (mo.state === 'short' && !(mo.knockAsk != null && tick - mo.knockAsk >= 0 && tick - mo.knockAsk < 300)) {
// an unfinished casting can still be topped up, so the first press only says so
mo.knockAsk = tick;
return (mo.flaw || 'The casting is not full yet.').replace(', or press F to knock it out as scrap.', '.') + ' Press F again to knock it out as scrap.';
return 'Not full; press F again to knock it out as scrap.';
}
if (mo.state === 'flawed' || mo.state === 'short') {
// a spoiled or unfinished casting is knocked out as scrap, so the mold is ready for another go
const short = mo.state === 'short', g = tally(clearCavity(mo.cavs.flat())), u = unplugMold(mo);
const short = mo.state === 'short'; clearCavity(mo.cavs.flat()); unplugMold(mo);
mo.state = 'empty'; mo.ready = [];
emit({ t: 'sfx', s: 'ui' });
const what = mo.kind === 'bit' ? 'bit' : 'casting';
return (short ? `The ${what} was not full, so it was knocked out as scrap.` : `The ${what} was spoiled, so it was knocked out as scrap.`)
+ knockNote(g.scrap + u.metal, g.other, g.dross + u.chipped, u.raked) + furnaceJunkHint(mo);
return (short ? `Unfinished ${what} knocked out as scrap.` : `Spoiled ${what} knocked out as scrap.`);
}
const tier = METAL_TIER[mo.metal];
const cav = mo.cavs[0];
if (tier <= 0) { msg = `${MD[mo.metal] ? MD[mo.metal].name : 'That metal'} is too ${mo.metal === GLASS ? 'brittle' : 'soft'} for a ${mo.kind === 'bit' ? 'drill bit' : 'tank'}. The casting broke into scrap, which went into your bag.`; }
if (tier <= 0) { msg = `${MD[mo.metal] ? MD[mo.metal].name : 'That metal'} is too ${mo.metal === GLASS ? 'brittle' : 'soft'} for a ${mo.kind === 'bit' ? 'drill bit' : 'tank'}; returned as scrap.`; }
else if (mo.kind === 'bit') {
if (tier > player.bitTier) { player.bitTier = tier; msg = `New drill bit: ${BIT_TIERS[tier].name}.`; emit({ t: 'upgrade', kind: 'bit', tier }); }
else { msg = `You already have a ${BIT_TIERS[player.bitTier].name.toLowerCase()}. The casting broke into scrap, which went into your bag.`; }
else { msg = `You already have a ${BIT_TIERS[player.bitTier].name.toLowerCase()}; returned as scrap.`; }
} else {
if (tier > player.tankTier) { player.tankTier = tier; msg = `New bag: ${TANK_TIERS[tier].name}, holds ${TANK_TIERS[tier].cap.toLocaleString('en-US')}.`; emit({ t: 'upgrade', kind: 'tank', tier }); }
else { msg = 'Your tank is already as good. The casting broke into scrap, which went into your bag.'; }
else { msg = 'Your tank is already as good; returned as scrap.'; }
}
// a casting you could not use breaks into scrap, which goes to your bag
if (msg.startsWith('New')) for (const i of cav) setCell(i, EMPTY, AMB[(i / W) | 0], 0);
else clearCavity(cav);
const u = unplugMold(mo);
unplugMold(mo);
mo.state = 'empty'; stats.cast++;
return msg + knockNote(0, 0, u.chipped, u.raked) + furnaceJunkHint(mo);
return msg;
}
function removeMold(mo) {
undoNote({ t: 'mold-', kind: mo.kind, bx: mo.x / BS, by: mo.y / BS, wall: mo.wall });
+2 -2
View File
@@ -42,11 +42,11 @@ function toolDig(cx, cy, r) {
stats.dug += dug;
let status = '';
void 0;
if (far && !dug && !tooHard) status = `Out of reach. Your ${tier.name.toLowerCase()} reaches ${drillReach()} grains from the bot.`;
if (far && !dug && !tooHard) status = `Out of reach; your bit reaches ${drillReach()} grains.`;
if (tooHard && !dug) {
const need = BIT_TIERS.find(b => b.hard >= HARD[tooHard]);
const nb = need ? need.name.toLowerCase() : 'better bit';
status = HARD[tooHard] > 6 ? `${MD[tooHard].name} cannot be drilled.` : `${MD[tooHard].name} is too hard for your ${tier.name.toLowerCase()}. Needs ${/^[aeiou]/.test(nb) ? 'an' : 'a'} ${nb}.`;
status = HARD[tooHard] > 6 ? `${MD[tooHard].name} cannot be drilled.` : `${MD[tooHard].name} is too hard; needs ${/^[aeiou]/.test(nb) ? 'an' : 'a'} ${nb}.`;
}
return { dug, status, tooHard };
}
+3 -3
View File
@@ -17,8 +17,8 @@ function undoLast() {
const rec = undoStack.pop();
if (!rec) return 'Nothing to undo.';
const prev = undoRec; undoRec = null;
let fails = 0, why = '';
const fail = (r) => { fails++; if (r && !why) why = r; };
let fails = 0;
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); }
@@ -38,7 +38,7 @@ function undoLast() {
undoRec = prev;
powerDirty = true;
const n = rec.ops.length;
return fails ? `Undid ${rec.label}, but ${fails === n ? 'none' : fails} of it could not be put back${why ? ': ' + why.replace(/\.$/, '').toLowerCase() : ''}.` : `Undid ${rec.label}.`;
return fails ? `Undo incomplete: ${fails} of ${n} changes could not be put back.` : `Undid ${rec.label}.`;
}
function clearAllGhosts() { ghostBlocks.clear(); ghostMachines = []; ghostMolds = []; ghostBlocked.clear(); }
let ghostMissing = 0, ghostIter = null;
+3 -3
View File
@@ -28,7 +28,7 @@ function toolVacuum(cx, cy, r, pick) {
if (got & 1) emit({ t: 'suck', x, y, m });
if (--budget <= 0) return false;
});
return { got, full, status: full ? 'Bag full. Blow something out or cast a Tank mold.' : '' };
return { got, full, status: full ? 'Bag full; blow material out or cast a bigger tank.' : '' };
}
// Blow: grains leave the bot's nozzle and fly to the cursor, where they drop.
// Liquids need the Flask. Bars are thrown, one at a time.
@@ -45,11 +45,11 @@ function toolBlow(cx, cy, r, m, mix) {
let pickM = null;
if (mix && mix.length) {
const avail = mix.filter(k => bag[k] > 0 && PH[k] !== PH_ITEM && (PH[k] !== PH_LIQUID || player.flask));
if (!avail.length) return { placed: 0, status: 'Nothing ticked left to mix. Tick materials in your bag.' };
if (!avail.length) return { placed: 0, status: 'Tick materials in your bag to mix.' };
pickM = () => { for (let k = 0; k < avail.length; k++) { const c = avail[(blowRR++) % avail.length]; if (bag[c] > 0) return c; } return 0; };
m = avail[0];
}
if (!m) return { placed: 0, status: 'Pick something to blow out. Q and E cycle, or click it in your bag.' };
if (!m) return { placed: 0, status: 'Pick what to blow out: Q/E, or click it in your bag.' };
if (bag[m] <= 0) return { placed: 0, status: `No ${MD[m].name.toLowerCase()} left.` };
const ph = PH[m];
if (ph === PH_LIQUID && !player.flask) return { placed: 0, status: 'You need the Flask to carry liquids.' };
+14 -16
View File
@@ -30,7 +30,7 @@ function doInteract() {
openFilterPicker(Object.assign(filterInfo(fb), { dir: blockV[fb] & 128 ? 1 : 0 }), f => {
applyFilterRun(run, f);
GAME.filterCfg = { m: f.m.slice(), inv: f.inv }; GAME.pieceDir.filter = f.dir;
setStatus(`${run.length === 1 ? 'This filter' : `These ${run.length} filters`} now ${filterSentence(f)}, ${f.dir ? 'right' : 'left'}.`, 'info');
setStatus(`${run.length} ${run.length === 1 ? 'filter' : 'filters'} updated, carrying ${f.dir ? 'right' : 'left'}.`, 'info');
}, run.length > 1 ? `Filter (${run.length} in this row)` : 'Filter');
GAME.editRun = run;
return;
@@ -43,7 +43,7 @@ function doInteract() {
if (msg) break outer;
}
if (msg) { setStatus(msg, 'info'); if (!/^New (drill bit|bag)/.test(msg)) toast(msg); }
else { setStatus('Nothing to use there. F works on gates, molds, fans and filters.', 'info'); }
else { setStatus('Nothing to use there.', 'info'); }
}
// Pick (middle mouse): the piece under the cursor becomes the one you build, with its
// direction, wall material and filter settings. On loose material it picks that to blow out.
@@ -51,24 +51,22 @@ function pickUnderCursor() {
const ax = Math.floor(GAME.aim.x), ay = Math.floor(GAME.aim.y);
if (ax < 0 || ay < 0 || ax >= W || ay >= H) return;
const i = ay * W + ax, m = mat[i], bi = ((ay / BS) | 0) * BW + ((ax / BS) | 0);
let pid = null, note = '';
let pid = null;
const ghost = ghostBlocks.get(bi);
if (mach[i] && machines[mach[i]]) { const mm = machines[mach[i]]; pid = mm.type; if (PIECE_BY_ID[pid].dirs) GAME.pieceDir[pid] = mm.dir; }
else if (moldAt(ax, ay)) { const mo = moldAt(ax, ay); pid = mo.kind + 'mold'; GAME.wallMat = mo.wall; note = MD[WALL_OF[mo.wall]].name.toLowerCase(); }
else if (moldAt(ax, ay)) { const mo = moldAt(ax, ay); pid = mo.kind + 'mold'; GAME.wallMat = mo.wall; }
else if (blockP[bi] || ghost) {
const code = blockP[bi] || ghost[0], v = blockP[bi] ? blockV[bi] : ghost[1];
const P = PIECE_BY_CODE[code]; pid = P.id;
if (P.code === BP_WALL) { GAME.wallMat = v; note = MD[WALL_OF[v]].name.toLowerCase(); }
else if (P.filter) { GAME.filterCfg = blockP[bi] ? filterInfo(bi) : cleanFilterCfg(ghost[2]); GAME.pieceDir[P.id] = v >> 7; note = filterSentence(GAME.filterCfg); }
if (P.code === BP_WALL) { GAME.wallMat = v; }
else if (P.filter) { GAME.filterCfg = blockP[bi] ? filterInfo(bi) : cleanFilterCfg(ghost[2]); GAME.pieceDir[P.id] = v >> 7; }
else if (P.dirs) GAME.pieceDir[P.id] = v & 3;
if (P.dirs && !P.filter) note = P.dirs[GAME.pieceDir[P.id] || 0];
else if (P.filter) note = `${P.dirs[GAME.pieceDir[P.id] || 0]}, ${note}`;
}
if (pid) {
const P = PIECE_BY_ID[pid];
if (P.hidden) { setStatus(`${P.name} cannot be built.`); playSfx('error'); return; }
GAME.pieceIdx = P.index; GAME.buildCat = catOf(P.id); GAME.paste = false; selectTool(2); hudDirty = true; playSfx('ui');
setStatus(`Picked: ${P.name}${note ? ` (${note})` : ''}.`, 'info');
setStatus(`Picked: ${P.name}.`, 'info');
return;
}
const t = PH[m] === PH_POWDER || PH[m] === PH_LIQUID ? m : 0;
@@ -81,7 +79,7 @@ function stampOrigin(bw, bh) { const { bx, by } = aimBlock(); return { bx: bx -
// Copy and cut: hold C or X and drag a box with the left button; letting go does it.
function selModeHeld() { return GAME.running && !anyModal() ? (keys.has('x') ? 'cut' : keys.has('c') ? 'copy' : null) : null; }
function startPaste() {
if (!GAME.clip) { setStatus('Nothing copied yet. Hold C and drag a box over something you built.', 'info'); playSfx('error'); return; }
if (!GAME.clip) { setStatus('Hold C and drag over built pieces to copy.', 'info'); playSfx('error'); return; }
GAME.paste = true; GAME.drag = null; hudDirty = true; playSfx('ui');
}
function stopSelectAndPaste() { GAME.selDrag = null; GAME.paste = false; hudDirty = true; playSfx('ui'); }
@@ -145,7 +143,7 @@ function primaryPress(shift) {
}
if (!err) err = place();
});
if (err) { setStatus(err + (/already/.test(err) ? `${/[.!]$/.test(err) ? '' : '.'} Hold G to replace it.` : '')); playSfx('error'); } else { playSfx('place'); hudDirty = true; }
if (err) { setStatus(err + (/already/.test(err) ? `; hold G to replace it.` : '')); playSfx('error'); } else { playSfx('place'); hudDirty = true; }
}
}
}
@@ -156,11 +154,11 @@ function primaryRelease(shift) {
if (sd.mode === 'cut') {
let bp = null;
undoRecord(rec => `cutting ${plural(piecesIn(rec, 'block-') + piecesIn(rec, 'mach-') + piecesIn(rec, 'mold-'), 'piece')}`, () => { bp = cutRegion(sd.x, sd.y, ax, ay); });
if (bp) { GAME.clip = bp; GAME.paste = true; playSfx('decon'); setStatus(`Cut ${bp.bw}×${bp.bh} blocks into your bag. Click to paste it somewhere; Ctrl+Z puts it back.`, 'info'); }
if (bp) { GAME.clip = bp; GAME.paste = true; playSfx('decon'); setStatus(`Cut ${bp.bw}×${bp.bh} blocks; click to paste.`, 'info'); }
else { setStatus('Nothing you built is inside that box.'); playSfx('error'); }
} else {
const bp = copyRegion(sd.x, sd.y, ax, ay);
if (bp) { GAME.clip = bp; GAME.paste = true; playSfx('cast'); setStatus(`Copied ${bp.bw}×${bp.bh} blocks. Click to place it.`, 'info'); }
if (bp) { GAME.clip = bp; GAME.paste = true; playSfx('cast'); setStatus(`Copied ${bp.bw}×${bp.bh} blocks; click to place.`, 'info'); }
else { setStatus('Nothing you built is inside that box.'); playSfx('error'); }
}
return;
@@ -185,8 +183,8 @@ function primaryRelease(shift) {
if (ghosts) undoNote(g);
return res;
});
const hint = /already built/.test(r.status) ? `${/[.!]$/.test(r.status) ? '' : '.'} Hold G to replace it.` : '';
const gtxt = ghosts ? `${r.n ? `Placed ${plural(r.n, P.name.toLowerCase())}; ` : ''}${plural(ghosts, 'more')} out of reach ${ghosts === 1 ? 'is a ghost' : 'are ghosts'} your bot builds when it gets close.` : '';
const hint = /already built/.test(r.status) ? `; hold G to replace it.` : '';
const gtxt = ghosts ? `${r.n} placed; ${ghosts} ghosts waiting for the bot.` : '';
if (r.n || ghosts) { playSfx('place'); setStatus(gtxt || (r.status ? r.status + hint : ''), 'info'); } else { setStatus((r.status || 'Nothing placed.') + hint); playSfx('error'); }
GAME.drag = null; GAME.dragDir = -1; hudDirty = true;
}
@@ -219,7 +217,7 @@ function toolTick() {
if (r.status) setStatus(r.status);
}
} else if (t === 'ignite') {
if (inReach(ax, ay)) { const r = toolIgnite(ax, ay); playSfx('spark'); if (Math.random() < 0.6) spawnSpark(ax + 0.5, ay + 0.5); if (!r.fuel) setStatus('Nothing here burns. Aim at coal or wood that touches air.'); }
if (inReach(ax, ay)) { const r = toolIgnite(ax, ay); playSfx('spark'); if (Math.random() < 0.6) spawnSpark(ax + 0.5, ay + 0.5); if (!r.fuel) setStatus('Aim at coal or wood touching air to light it.'); }
else setStatus('Out of reach.');
}
}
+1 -9
View File
@@ -11,16 +11,7 @@ function drainEvents() {
const m = MILESTONES.find(x => x.id === e.id);
if (m && !['bit1', 'bit2', 'bit3', 'tank'].includes(m.id)) { showCard({ kind: 'Milestone', title: m.title, body: [m.msg, m.reward], accent: 'milestone' }); playSfx('milestone'); }
} else if (e.t === 'cast_ready') {
const metal = MD[e.metal] ? MD[e.metal].name : 'Metal';
const body = `The ${e.kind} casting has set. Press F on the mold to knock it out.`;
showCard({ kind: 'Casting ready', title: `${metal} ${e.kind}`, swatch: e.metal > 0 ? e.metal : 0, body: [body], accent: 'milestone' });
playSfx('cast');
} else if (e.t === 'ingot_drop') {
const metal = MD[e.metal] ? MD[e.metal].name : 'Metal', body = `${e.bars} ${barWord(e.metal, e.bars)} dropped out of the mold.`;
if (e.first) showCard({ kind: 'Casting', title: e.metal === GLASS ? 'Glass bricks' : `${metal} bars`, swatch: e.metal, body: [body], accent: 'milestone' });
else toast(body);
} else if (e.t === 'ingot_blocked') {
toast('Bars are ready in a mold but there is no room under it. Leave one open block under the mold: bars drop out of the bottom.');
} else if (e.t === 'upgrade') {
playSfx('upgrade'); if (SET.shake) R.shake = 1.4;
showCard({ kind: e.kind === 'bit' ? 'Drill bit upgraded' : 'Bag upgraded', title: e.kind === 'bit' ? BIT_TIERS[e.tier].name : TANK_TIERS[e.tier].name, accent: 'milestone',
@@ -28,6 +19,7 @@ function drainEvents() {
hudDirty = true;
} 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 === 'sold') spawnFloater(e.x, e.y, '+' + fmtMoney(e.v));
else if (e.t === 'order_new') {
const o = e.o;
+5 -2
View File
@@ -88,7 +88,7 @@ function hoverInfo(ax, ay) {
const mm = machines[id], P = PIECE_BY_ID[mm.type]; t1 = P.name;
if (mm.type === 'dock') { t2.push('sells what drops through its grate', `${orders.length} open ${orders.length === 1 ? 'order' : 'orders'}`); }
if (mm.type === 'wheel' || mm.type === 'engine') t2.push(`making ${mm.out.toFixed(1)} power`);
if (mm.type === 'fan') t2.push(mm.on ? 'on' : 'off · press F');
if (mm.type === 'fan') { t2.push(mm.on ? 'on' : 'off · press F'); if (mm.hot > 760) { t2.push(`${mm.hot}°C · melts at 1085°C`); warn = 'too hot'; } }
if (mm.type === 'silo') t2.push(mm.sn ? `holds ${mm.sn.toLocaleString('en-US')} / ${SILO_CAP.toLocaleString('en-US')} ${MD[mm.sm].name.toLowerCase()}` : `empty (holds ${SILO_CAP.toLocaleString('en-US')} grains of one material)`, mm.open ? 'gate open · F shuts it' : 'gate shut · F opens it');
if (mm.type === 'cutter') t2.push(mm.blocked ? 'stopped: clear the space under it' : tick - (mm.busyT || -999) < 30 ? 'cutting' : 'idle: drop stone rubble in', `${(stats.bricks || 0).toLocaleString('en-US')} bricks cut`);
if (mm.type === 'refiner') t2.push(mm.blocked ? 'stopped: clear the space under its chute' : tick - (mm.busyT || -999) < 30 ? 'grinding' : 'idle: drop rubble, gravel, sand or dirt in', `${(stats.refined || 0).toLocaleString('en-US')} ground, ${(stats.refinedOre || 0).toLocaleString('en-US')} ore found`);
@@ -123,7 +123,10 @@ function hoverInfo(ax, ay) {
const mo = moldAt(ax, ay);
if (mo) {
t1 = MOLDS[mo.kind].name;
t2 = [mo.state === 'ready' ? (mo.kind === 'ingot' ? `${mo.ready.length} ${mo.ready.length === 1 ? 'bar' : 'bars'} set · drop when there is room below` : `${MD[mo.metal].name} casting set · press F`) : mo.state === 'flawed' || mo.state === 'short' ? mo.flaw : mo.state === 'filling' ? `filling ${Math.round(mo.fill * 100)}%` : 'empty'];
t2 = [mo.state === 'ready' ? (mo.kind === 'ingot' ? `${mo.ready.length} ${mo.ready.length === 1 ? 'bar' : 'bars'} set` : '100% set · press F') : mo.state === 'empty' ? 'empty' : `${mo.state === 'short' ? 'not full' : mo.state} ${Math.round(mo.fill * 100)}%`];
if (mo.blocked) t2.push('blocked: leave one open block under it');
if (mo.flaw) t2.push(mo.flaw);
const hint = furnaceJunkHint(mo).trim(); if (hint) t2.push(hint);
if (player.lens) t2.push(fmtT(temp[i]));
}
const reachOk = toolId() === 'drill' ? inDrillReach(ax, ay, GAME.drillW) : inReach(ax, ay);
+4 -4
View File
@@ -4,7 +4,7 @@ function setDrillWidth(w) {
const n = Math.max(0, Math.min(PULSE_W.length - 1, w));
if (n === GAME.drillW) return;
GAME.drillW = n; drillWidth = n; hudDirty = true; playSfx('ui');
const what = ['a beam as wide as the bot: bores a tunnel fast and reaches furthest', 'a cone: the all-rounder', 'a broad fan: clears the most rock, but only close to the bot'][n];
const what = ['long beam', 'balanced cone', 'broad fan'][n];
setStatus(`${PULSE_W[n].name[0].toUpperCase() + PULSE_W[n].name.slice(1)} pulse, ${what} (reach ${drillReach(n)}).`, 'info');
}
// R with the drill: tune it to the material under the cursor, or back to everything
@@ -15,7 +15,7 @@ function tuneUnderCursor() {
const to = PH[m] === PH_SOLID ? YLD[m] : PH[m] === PH_POWDER ? m : 0;
GAME.drillTune = !to || to === GAME.drillTune ? 0 : to;
hudDirty = true; playSfx('ui');
setStatus(GAME.drillTune ? `Tuned to ${MD[GAME.drillTune].name.toLowerCase()}: only that shatters, and the pulses pass through everything else.` : 'Untuned: the drill shatters any rock it can.', 'info');
setStatus(GAME.drillTune ? `Tuned to ${MD[GAME.drillTune].name.toLowerCase()}: only it shatters.` : 'Untuned: the drill shatters any rock it can.', 'info');
}
function cycleItem(d) {
const t = toolId();
@@ -74,13 +74,13 @@ function toggleFilterMat(m) {
if (k >= 0) f.m.splice(k, 1); else f.m.push(m);
f.m.sort((a, b) => a - b);
hudDirty = true; lastBagKey = ''; playSfx('ui');
setStatus(`New filters ${filterSentence(f)}.`, 'info');
setStatus(`New filter selection updated.`, 'info');
}
function toggleCollectUnderCursor() {
const m = grainAt(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y));
if (!m) { setStatus('Aim at something loose to tick it.', 'info'); return; }
toggleCollect(m);
setStatus(`${MD[m].name} ${GAME.collect.has(m) ? 'ticked. While anything is ticked, the vacuum takes only ticked materials' : GAME.collect.size ? 'unticked' : 'unticked. Nothing is ticked, so the vacuum takes what you start on'}.`, 'info');
setStatus(`${MD[m].name} ${GAME.collect.has(m) ? 'ticked for vacuuming' : 'unticked'}.`, 'info');
}
function setBrush(d) {
const t = toolId();
+19 -2
View File
@@ -25,6 +25,7 @@ with sync_playwright() as p:
pg.wait_for_function('GAME.running === true', timeout=20000)
pg.wait_for_timeout(500)
ev = pg.evaluate
ev("window._maxStatus = ''; const _ss = setStatus; setStatus = function (s, k) { if (s && s.length > _maxStatus.length) _maxStatus = s; return _ss(s, k); }; 0")
look = sys.argv[2] if len(sys.argv) > 2 else 'pixel'
check('a new game starts fully zoomed in', ev('SET.zoom') == 12, ev('SET.zoom'))
# Ctrl+W closes the tab; with a world open the browser must ask first
@@ -286,6 +287,8 @@ with sync_playwright() as p:
check('mold placed by click', ev('molds.length') == 1, ev('GAME.status'))
ev('for (const cav of molds[0].cavs) for (const i of cav) setCell(i, MCU, 1160, 0)')
pg.wait_for_function("ents.filter(e => e.m === BARCU).length === 3", timeout=30000)
check('mold label shows a percentage', ev(r'/^\d+%/.test(moldLabel(molds[0]))'))
check('bar drops make no toast', ev("![...$('toasts').children].some(e => /dropped/i.test(e.textContent))"))
check('bars set and drop on their own', ev("ents.length === 3 && molds[0].state === 'short' && molds[0].ready.length === 0"), ev('[molds[0].state, ents.length]'))
pg.screenshot(path=str(out / '05-mold-ready.png'))
check('three bars drop below the mold without F', ev('ents.every(e => e.m === BARCU && e.iy >= molds[0].y + molds[0].h)'), ev('ents.map(e => [e.ix, e.iy])'))
@@ -297,6 +300,19 @@ with sync_playwright() as p:
hold(*cell_to_screen(*e0), 900)
check('vacuum picks up bars', ev('bag[BARCU]') >= 1, ev('bag[BARCU]'))
check('casting events stay quiet and damage labels are red', ev("""(() => {
const oldToast = toast, oldCard = showCard, oldSfx = playSfx, events = EV.splice(0), floaters = R.floaters;
let popups = 0, casts = 0;
toast = showCard = () => popups++; playSfx = s => { if (s === 'cast') casts++; };
R.floaters = [];
try {
emit({ t: 'ingot_drop', bars: 1, metal: COPPER, first: true });
emit({ t: 'ingot_blocked' }); emit({ t: 'cast_ready', kind: 'bit', metal: COPPER });
emit({ t: 'warn', x: 12, y: 34, text: 'melted' }); drainEvents();
return popups === 0 && casts === 1 && R.floaters.length === 1 && R.floaters[0].colour === '#ff8a72' && R.floaters[0].text === 'melted';
} finally { toast = oldToast; showCard = oldCard; playSfx = oldSfx; R.floaters = floaters; EV.push(...events); }
})()"""))
# 7. belt line by dragging, copied with a blueprint and pasted
refill('bag[BARCU] = 60'); ev('money = 1000')
ok = select('3', "piece().id === 'belt'")
@@ -344,7 +360,7 @@ with sync_playwright() as p:
ok = select('3', "piece().id === 'fan'")
pg.mouse.move(*blk(bbx - 4, bby + 1)); settle(400)
pg.mouse.down(); pg.mouse.up(); settle(100)
check('a fan on a belt says to hold G', 'Hold G' in ev('GAME.status'), ev('GAME.status'))
check('a fan on a belt says to hold G', 'hold G' in ev('GAME.status'), ev('GAME.status'))
pg.keyboard.down('g'); settle(100); pg.mouse.move(*blk(bbx - 4, bby + 1)); settle(200)
check('holding G shows replace mode', 'Replace · G' in ev("$('selcard').textContent"))
pg.screenshot(path=str(out / '07g-replace-fan.png'))
@@ -384,7 +400,7 @@ with sync_playwright() as p:
# two belts at the end of the line: plain dragging leaves them, G-dragging swaps them for filters
ev(f"placeBlock(BP_BELT, 1, {bbx-2}, {fy}, true); placeBlock(BP_BELT, 1, {bbx-1}, {fy}, true);")
drag(lambda: blk(bbx - 2, fy), lambda: blk(bbx - 1, fy))
check('dragging over built belts leaves them and says how to replace', ev(f'blockP[{fy}*BW+{bbx-2}] === BP_BELT') and 'Hold G' in ev('GAME.status'), ev('GAME.status'))
check('dragging over built belts leaves them and says how to replace', ev(f'blockP[{fy}*BW+{bbx-2}] === BP_BELT') and 'hold G' in ev('GAME.status'), ev('GAME.status'))
pg.keyboard.down('g'); settle(50)
pg.mouse.move(*blk(bbx - 2, fy)); settle(300)
pg.screenshot(path=str(out / '07c-replace-preview.png'))
@@ -527,6 +543,7 @@ with sync_playwright() as p:
pg2.screenshot(path=str(out / '11-deep-magma.png'))
pg2.close()
print('\n'.join(logs) or 'no console errors')
check('every status line is short (≤ 90 characters)', len(ev('_maxStatus')) <= 90, ev('_maxStatus'))
print('FAILS:', fails)
b.close()
sys.exit(1 if fails else 0)
+4 -2
View File
@@ -68,8 +68,10 @@ check('undo clears the ghosts and takes back what was built from them', belts()
fresh();
run("placeMachineAt('fan', 0, 22, 20); EV.length = 0;");
run('for (let y = 80; y < 84; y++) for (let x = 88; x < 92; x++) temp[y * W + x] = 900; for (let k = 0; k < 40; k++) { for (let y = 80; y < 84; y++) for (let x = 88; x < 92; x++) temp[y * W + x] = 900; simTick(); }');
const warn = run("EV.filter(e => e.t === 'toast' && /fan/i.test(e.text)).map(e => e.text).join(' | ')");
check('a fan at 900°C warns that it is about to melt', /1085/.test(warn) && fans() === 1, warn);
check('a hot fan stores its temperature without a toast', run("machines.some(m => m && m.type === 'fan' && m.hot > 760) && !EV.some(e => e.t === 'toast')"));
run("EV.length = 0; removeMachine(machines.find(m => m && m.type === 'fan').id, false, true);");
check('heat destruction emits a local warning', run("EV.some(e => e.t === 'warn' && e.text === 'Fan destroyed' && Number.isFinite(e.x) && Number.isFinite(e.y)) && !EV.some(e => e.t === 'toast')"));
// old saves: tiny specks of too-hard rock become the rock around them, once
fresh();
+13 -2
View File
@@ -5,6 +5,7 @@ const { load, suite, flatWorld, rect, ticks, count } = require('./harness');
const { run } = load();
const { check, done } = suite();
function fresh() { flatWorld(run, 240, 160, 100); run('bot.x = 110; bot.y = 70; bag.fill(0); bagTotal = 0; for (const e of ents.slice()) removeEnt(e);'); }
run("var moldMessages = []; const collectOriginal = collectMold; collectMold = function(mo) { const s = collectOriginal(mo); moldMessages.push(s); return s; }; 0");
const cavCount = (k, m) => run(`molds[${k}].cavs[0].filter(i => mat[i] === ${m}).length`);
// automatic drops leave the short stub on the trough floor for the next firing
@@ -20,11 +21,13 @@ check('five more grains top up the stub into another bar', run('ents.length ===
// blocked mouths retain their metal and warn once, including molds from old saves
fresh();
run('bag[RSTONE] = 100; bagTotal = 100; placeMoldAt("ingot", 30, 22, RSTONE); bag[RSTONE] = 0; bagTotal = 0; EV.length = 0; { const mo = molds[0]; for (const i of mo.cavs[0]) setCell(i, COPPER, 30, F_CAST); for (let y = mo.y + mo.h; y < mo.y + mo.h + 2; y++) for (let x = mo.x; x < mo.x + mo.w; x++) setCell(y * W + x, STONE, 30, 0); } checkMolds(); checkMolds();');
check('blocked bars have a warning flag', run('molds[0].blocked === true'));
check('blocked bars stay put and warn only once', run('ents.length === 0 && molds[0].state === "ready" && EV.filter(e => e.t === "ingot_blocked").length === 1') && cavCount(0, run('COPPER')) === 28);
msg = run('collectMold(molds[0])');
check('F on a blocked ingot mold changes nothing', run('ents.length === 0') && cavCount(0, run('COPPER')) === 28 && /empty themselves/.test(msg), msg);
run('{ const mo = molds[0]; for (let y = mo.y + mo.h; y < mo.y + mo.h + 2; y++) for (let x = mo.x; x < mo.x + mo.w; x++) setCell(y * W + x, EMPTY, 30, 0); }');
run('checkMolds()');
check('dropping bars clears the warning flag', run('molds[0].blocked === false'));
check('the next check drops bars only below it', run('ents.length === 3 && ents.every(e => e.iy === molds[0].y + molds[0].h) && !molds[0].blockedSaid'));
run('for (const e of ents.slice()) removeEnt(e); { const mo = molds[0]; for (const i of mo.cavs[0]) setCell(i, COPPER, 30, F_CAST); for (let x = mo.x; x < mo.x + mo.w; x++) setCell((mo.y + mo.h) * W + x, STONE, 30, 0); } checkMolds();');
check('a new blocked spell warns again', run('EV.filter(e => e.t === "ingot_blocked").length === 2'));
@@ -145,9 +148,9 @@ run('molds[0].cavs[0].forEach((i, k) => setCell(i, k & 1 ? COPPER : IRON, 30, F_
check('mixed metal all drops as scrap without bars', under('SCRCU') === 14 && under('SCRFE') === 14 && run('ents.length === 0 && molds[0].state === "empty" && bagTotal === 0'));
ingot();
run('setCell(molds[0].cavs[0][0], SAND, 30, 0); for (let y = 92; y < 96; y++) for (let x = 120; x < 136; x++) setCell(y * W + x, STONE, 30, 0); checkMolds();');
check('blocked junk stays in the trough', cavCount(0, run('SAND')) === 1 && under('SAND') === 0);
check('blocked junk stays in the trough', cavCount(0, run('SAND')) === 1 && under('SAND') === 0 && run('molds[0].blocked === true'));
run('for (let y = 92; y < 96; y++) for (let x = 120; x < 136; x++) setCell(y * W + x, EMPTY, 30, 0); checkMolds();');
check('junk drops once there is room', cavCount(0, run('SAND')) === 0 && under('SAND') === 1);
check('junk drops once there is room', cavCount(0, run('SAND')) === 0 && under('SAND') === 1 && run('molds[0].blocked === false'));
ingot();
run('setCell(molds[0].cavs[0][0], SAND, 30, 0); setCell(molds[0].cavs[0][1], MCU, 1200, 0); checkMolds();');
check('cleaning waits for molten metal in the cavity', cavCount(0, run('SAND')) === 1 && under('SAND') === 0);
@@ -184,4 +187,12 @@ check('metal wall refund does not count cavity metal twice', run('bag[BARCU] ===
fresh();
run('addMold("bit", 30, 20, RSTONE, true); molds[0].cavs[0].slice(0, 20).forEach((i, k) => setCell(i, k & 1 ? IRON : STEEL, 30, F_CAST)); removeMold(molds[0]);');
check('compatible iron and steel return as iron bars and scrap', run('bag[BARFE] === 2 && bag[SCRFE] === 4 && bag[SCRST] === 0'));
for (const kind of ['bit', 'tank']) for (const metal of ['COPPER', 'IRON', 'GLASS']) for (const upgraded of [false, true]) {
fresh();
run(`addMold("${kind}", 30, 20, RSTONE, true); player.bitTier = player.tankTier = ${upgraded ? 3 : 0}; molds[0].cavs[0].forEach(i => setCell(i, ${metal}, 30, F_CAST)); checkMolds(); collectMold(molds[0]);`);
}
for (const state of ['empty', 'filling']) {
fresh(); run(`addMold("bit", 30, 20, RSTONE, true); molds[0].state = "${state}"; collectMold(molds[0]);`);
}
check('every collected mold message is short', run('moldMessages.every(s => s.length <= 90)'), run('moldMessages.join(" | ")'));
done();
+1 -1
View File
@@ -102,7 +102,7 @@ run('player.tankTier = 0; bag[RSTONE] = 16; bag[LDIRT] = 1500 - 16; bagTotal = 1
run('placeBlocks(BP_WALL, RSTONE, [[30, 18]])');
run('bag[LDIRT] = 1500; bagTotal = 1500;');
const msg = run('removePieceAt(121, 73)');
check('removing a block with a full bag drops its rubble in place', count(run, run('RSTONE'), 120, 72, 123, 75) === 16 && /bag is full/.test(msg), msg);
check('removing a block with a full bag drops its rubble in place', count(run, run('RSTONE'), 120, 72, 123, 75) === 16 && /bag full/.test(msg), msg);
run('bag[BARCU] = 20; bag[LDIRT] = 1480; bagTotal = 1500; placeBlocks(BP_BELT, 1, [[32, 18]]); bag[LDIRT] += 1; bagTotal = 1500;');
const m0 = run('money'); run('removePieceAt(129, 73)'); ticks(run, 60);
check('a belt removed with a full bag still refunds its price as money', run('money') === m0 + 12, `${m0} -> ${run('money')}`);
+16
View File
@@ -0,0 +1,16 @@
// Run after clear-sky.js: filling, blocked, set, and hot machinery in one view.
const my = BY - 4;
const filling = addMold('ingot', BX + 3, my, RSTONE, true);
filling.cavs[0].slice(0, 11).forEach(i => setCell(i, MCU, 1160, 0));
const blocked = addMold('ingot', BX + 12, my, RSTONE, true);
blocked.cavs[0].forEach(i => setCell(i, COPPER, 30, F_CAST));
for (let y = blocked.y + blocked.h; y < blocked.y + blocked.h + 2; y++) for (let x = blocked.x; x < blocked.x + blocked.w; x++) setCell(y * W + x, STONE, 30, 0);
const bit = addMold('bit', BX + 23, my - 1, RSTONE, true);
bit.cavs[0].forEach(i => setCell(i, COPPER, 30, F_CAST));
checkMolds();
const fan = addMachine('fan', BX + 30, my, 0);
for (let y = fan.y; y < fan.y + fan.h; y++) for (let x = fan.x; x < fan.x + fan.w; x++) temp[y * W + x] = 900;
tick = (30 - fan.id % 30) % 30; machinesTick();
if (filling.fill < 0.35 || filling.fill > 0.45 || !blocked.blocked || bit.state !== 'ready' || !(fan.hot > 760)) throw Error('Message scene did not reach its intended states');
if (!hoverInfo(blocked.x + 1, blocked.y + 1).t2.includes('blocked: leave one open block under it') || !hoverInfo(bit.x + 1, bit.y + 1).t2.includes('100% set · press F') || !hoverInfo(fan.x + 1, fan.y + 1).t2.includes('900°C · melts at 1085°C')) throw Error('Missing hover details');
CAM = [filling.x + 60, filling.y + 4];