diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 46c1776..2bce138 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -98,6 +98,13 @@ Details and numbers live in the code and in `docs/systems/`. ## Decisions +- **Distant drill aim is normal (t41, 2026-10-26).** The owner normally points beyond the + drill's reach and expects players to do the same. Keep the pulse colour and light crosshair, + mark the pulse's far edge, and omit distance warnings for the drill. Other tools still warn. +- **Silo contents are visible without opening the gate (t38, 2026-10-26).** The owner wants + contents beside the cursor anywhere over the outline and a material-coloured fill window at + every zoom in both looks, while keeping the real steel silo shape. + - **Square corners hold grains (t39, 2026-10-26).** The owner wants square wall staircases to hold material instead of leaking through each touching corner. Powder, liquid and gas cannot step diagonally between two solid neighbours; powder neighbours still allow free piles to diff --git a/docs/PROJECT_MAP.md b/docs/PROJECT_MAP.md index 4746eca..cfd6837 100644 --- a/docs/PROJECT_MAP.md +++ b/docs/PROJECT_MAP.md @@ -287,7 +287,7 @@ Bars and other loose objects (ents), and the bag: adding, taking, costs and refu Tool helpers: reach, brushes, and plain digging. - functions: inReach, forBrush, drillReach, inDrillReach, toolDig -### src/sim/drill.js (161 lines) +### src/sim/drill.js (160 lines) The resonance drill: tuned pulses that shatter one material. - functions: drillOrigin, pulseRay, builtCell, toolPulse, crumbleAround, crumbly, shatterCell, pulseTick - variables: PULSE_W, PULSE_EVERY, PULSE_SPEED, PULSE_K, drillPulses, lastPulseTick, drillWidth, THIN_BONUS, CRUMBLE_MAX, THIN_BONUS_OFF @@ -366,9 +366,9 @@ Scale colour without changing the smooth-look class. - functions: fogColor, diagonalTubeJoins, diagonalTubeShape, diagonalTubeBlock, drawWorld - variables: TUBE_CLASS, diagonalJoins, diagonalAcross, diagonalAlong, diagonalCap -### src/render/machines.js (242 lines) +### src/render/machines.js (256 lines) Machine overlays: fans, engines, silo and the rest, drawn on top of the world. -- functions: cellRect, drawHazardRims, drawMachines, drawProcBlocks, drawSilo, drawSiloPixel, drawRackBlocks +- functions: cellRect, drawHazardRims, drawMachines, drawProcBlocks, drawSilo, drawSiloPixel, drawSiloWindow, drawRackBlocks ### src/render/overlays.js (229 lines) Other overlays: floating text, rain and snow, flow arrows, filter tags, molds, the bot, particles. @@ -457,7 +457,7 @@ Selection panels: current tool card and bag materials. - functions: renderSelCard, renderBag - variables: lastBagKey -### src/ui/info.js (189 lines) +### src/ui/info.js (195 lines) Discovery cards, hover info (I) and the cursor label. - functions: showCard, howText, discoveryCard, reactionsOf, hoverInfo, renderInfo, drawCursorLabel, pipeContentsText @@ -476,11 +476,11 @@ Gamepad input. - functions: padPressed, pollPad - variables: PAD -### src/ui/previews.js (237 lines) +### src/ui/previews.js (234 lines) Cursor previews: shared grid helpers, air streaks and tool preview dispatch. - functions: blockRectPx, drawGrid, previewColor, drawAirStreaks, drawReachCircle, drawPreviews, previewBlock -### src/ui/preview-drill.js (104 lines) +### src/ui/preview-drill.js (107 lines) Drill previews: shaken rock cracks, pulse wavefronts and aiming fan. Rock the drill has shaken but not yet broken shows cracks, more and finer as it nears breaking. Each crack is a contour line of a noise field (marching squares, cell by cell), so the lines run on unbroken from one cell to the next and look like real fractures, not a pattern. - functions: crackSeg, drawCracks, pulseRGB, fanPoints, drawDrillPulses, drawDrillAim - variables: CRACK_N, CRACK_LV @@ -527,6 +527,7 @@ The frame loop and boot. - `test/t_physics.js`: Sand and other powders, molten metal and gases: heaps, sorting, sinking, setting, rising and fading. Water has its own suite (t_water.js). Tall worlds keep geothermal heat out of the way. - `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_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. - `test/t_slope_belts.js`: Sloped belts: transport, joins, sorting, persistence and the actual drag controls. diff --git a/docs/agent-log.md b/docs/agent-log.md index 72c68dc..973ac64 100644 --- a/docs/agent-log.md +++ b/docs/agent-log.md @@ -4,6 +4,36 @@ 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-26: t38: show silo contents at the cursor and in a wide window (Codex) + +- `src/ui/info.js`: contents were confined to the bottom info plate (hidden below 720 px), + omitted from cursor labels, and cone gaps lacked mach ownership. Hover now resolves the + full 12x24 outline and shows contents beside the cursor; physical footprints stay unchanged. +- `src/render/machines.js`: shared framed three-grain window in both looks, fixed pixel grain + noise, visible near-empty row, lifted dark fuel colours, and stencils fitted beside the glass. + `tools/scenes/silo.js` adds snow and one grain of coal. All 31 accepted powders checked. +- `test/t_silo_contents.js`: 5 checks confirm snow intake, identity, water accounting, warm + storage and release. `test/play.py` checks hover/label at five outline points empty/filled, + narrow HUD, fill pixels at zooms 2-12 and stable drawing for all powders in both looks. +- Opened and inspected `shots/t38-silos-{pixel,smooth}-z{2,4,8}.png`. Updated t38 and DESIGN; + regenerated PROJECT_MAP. Final `python3 test/run_all.py --browser`: ALL PASSED (26 headless + suites, 352 play checks, 15 smooth checks). Task moved to Review; owner playtest remains. + +## 2026-10-26: t41: distant resonance-drill aim is normal (Codex) + +- `src/ui/preview-drill.js`, `previews.js`: keep tuned fan and light crosshair, add a solid far + edge in pulse colour, remove the drill-only red reach circle. `info.js` omits drill distance + warnings; other tools retain their warning, including beside silo contents. +- `src/sim/drill.js`: removed only the distance status; too-hard/cannot-be-drilled wording and + `toolDig` are unchanged. Notebook drill text remains accurate; gamepad code untouched. +- `test/t_drill.js`: new distance regression failed before fix, now passes; two former warning + expectations updated intentionally. `test/play.py`: held mouse aim makes no toast/warning, + hard granite still toasts, no red reach circle, gamepad aim stays within reach. +- Opened and inspected `shots/t41-drill-beyond-reach.png`. Focused browser checks pass. + Initial full browser run exposed test timing/paused-input fixture mistakes, corrected without + changing gameplay. Final `python3 test/run_all.py --browser`: ALL PASSED (26 headless + suites, 352 play checks, 15 smooth checks). Updated t41 and DESIGN; moved to Review. No commit. + ## 2026-10-26: t39: seal touching square-block corners (Codex) - `cells.js`: six inline checks reject diagonal moves between two PH_SOLID neighbours, with diff --git a/src/render/machines.js b/src/render/machines.js index bd99a23..71e30a1 100644 --- a/src/render/machines.js +++ b/src/render/machines.js @@ -119,7 +119,7 @@ function drawProcBlocks() { // glass that shows how full it is, a roof with the loading hatch. All in grain units from its corner. function drawSilo(ctx, mm, sx, sy, zp) { if (!R.smoothOn) { drawSiloPixel(ctx, mm, sx, sy, zp); return; } - const X = u => sx + u * zp, Y = u => sy + u * zp, full = Math.min(1, (mm.sn || 0) / SILO_CAP); + const X = u => sx + u * zp, Y = u => sy + u * zp; const steel = (x0, x1) => { const g = ctx.createLinearGradient(X(x0), 0, X(x1), 0); g.addColorStop(0, '#3b4249'); g.addColorStop(0.22, '#a9b3bb'); g.addColorStop(0.5, '#87919a'); g.addColorStop(1, '#353c43'); return g; }; ctx.save(); // legs, with short struts to the cone @@ -146,22 +146,16 @@ function drawSilo(ctx, mm, sx, sy, zp) { ctx.beginPath(); ctx.moveTo(X(1.3), Y(2)); ctx.lineTo(X(1.3), Y(19.8)); ctx.moveTo(X(2.3), Y(2)); ctx.lineTo(X(2.3), Y(19.8)); for (let yy = 2.6; yy < 19.8; yy += 1.1) { ctx.moveTo(X(1.3), Y(yy)); ctx.lineTo(X(2.3), Y(yy)); } ctx.stroke(); - // the sight glass: fills from the bottom in the colour of what is inside - const gx = 9.1, gy0 = 3, gy1 = 18.8, gh = gy1 - gy0; - ctx.fillStyle = '#14181c'; ctx.fillRect(X(gx), Y(gy0), zp * 1.1, zp * gh); - if (mm.sn > 0) { ctx.fillStyle = `rgb(${COLR[mm.sm]},${COLG[mm.sm]},${COLB[mm.sm]})`; ctx.fillRect(X(gx + 0.15), Y(gy1 - gh * full), zp * 0.8, zp * gh * full); } - ctx.strokeStyle = '#c9d1d8'; ctx.lineWidth = Math.max(1, zp * 0.1); - ctx.strokeRect(X(gx), Y(gy0), zp * 1.1, zp * gh); - for (let k = 1; k < 4; k++) { const yy = Y(gy1 - gh * k / 4); ctx.beginPath(); ctx.moveTo(X(gx - 0.35), yy); ctx.lineTo(X(gx), yy); ctx.stroke(); } + drawSiloWindow(ctx, mm, sx, sy, zp, false); // what it holds, stencilled on the drum if (zp >= 3) { renderMonoFont(); const fs = Math.max(8, Math.round(zp * 1.15)); ctx.font = `700 ${fs}px ${R.monoFont}`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = 'rgba(20,24,28,0.75)'; - ctx.fillText('SILO', X(5.6), Y(5)); + ctx.fillText('SILO', X(4.5), Y(5), 5 * zp); ctx.font = `600 ${Math.max(8, Math.round(zp * 0.95))}px ${R.monoFont}`; - ctx.fillText(mm.sn ? mm.sn.toLocaleString('en-US') : 'EMPTY', X(5.6), Y(16.8)); + ctx.fillText(mm.sn ? mm.sn.toLocaleString('en-US') : 'EMPTY', X(4.5), Y(16.8), 5 * zp); if (mm.sn) { ctx.fillStyle = `rgb(${COLR[mm.sm]},${COLG[mm.sm]},${COLB[mm.sm]})`; ctx.fillRect(X(4.4), Y(17.9), zp * 2.4, zp * 0.55); } } // roof with a rim, and the loading hatch (its lid stands open) @@ -173,7 +167,7 @@ function drawSilo(ctx, mm, sx, sy, zp) { ctx.restore(); } function drawSiloPixel(ctx, mm, sx, sy, zp) { - const full = Math.min(1, (mm.sn || 0) / SILO_CAP), col = `rgb(${COLR[mm.sm]},${COLG[mm.sm]},${COLB[mm.sm]})`; + const col = `rgb(${COLR[mm.sm]},${COLG[mm.sm]},${COLB[mm.sm]})`; const grain = (x, y, c) => { ctx.fillStyle = c; ctx.fillRect(sx + x * zp, sy + y * zp, zp, zp); }; // Steel shaded by column (light on the left, dark at the right edge), darker rib rows, and fixed // grain noise so it never shimmers. The drum is rows 2-19, the cone 20-22 narrowing to the gate. @@ -191,8 +185,7 @@ function drawSiloPixel(ctx, mm, sx, sy, zp) { grain(1, 21, '#2f353b'); grain(10, 21, '#2f353b'); for (let x = 4; x < 8; x++) grain(x, 23, '#1c1f23'); if (mm.open) for (let x = 8; x < 11; x++) grain(x, 23, '#c99a3c'); else for (let x = 3; x < 9; x++) grain(x, 23, '#6b737b'); - // the sight glass fills from the bottom in the colour of what is inside - for (let y = 3; y < 19; y++) grain(9, y, mm.sn > 0 && y >= 19 - Math.max(1, Math.round(16 * full)) ? col : '#14181c'); + drawSiloWindow(ctx, mm, sx, sy, zp, true); // roof: a rim, shoulders, the dark hatch and its lid standing open for (let x = 0; x < 12; x++) { grain(x, 1, x >= 3 && x < 9 ? '#15181b' : '#5b646c'); @@ -204,14 +197,35 @@ function drawSiloPixel(ctx, mm, sx, sy, zp) { renderMonoFont(); ctx.save(); ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = 'rgba(20,24,28,0.75)'; ctx.font = `700 ${Math.max(8, Math.round(zp * 1.15))}px ${R.monoFont}`; - ctx.fillText('SILO', sx + 5.6 * zp, sy + 5 * zp); + ctx.fillText('SILO', sx + 4.5 * zp, sy + 5 * zp, 5 * zp); ctx.font = `600 ${Math.max(8, Math.round(zp * 0.95))}px ${R.monoFont}`; - ctx.fillText(mm.sn ? mm.sn.toLocaleString('en-US') : 'EMPTY', sx + 5.6 * zp, sy + 16.8 * zp); + ctx.fillText(mm.sn ? mm.sn.toLocaleString('en-US') : 'EMPTY', sx + 4.5 * zp, sy + 16.8 * zp, 5 * zp); if (mm.sn) { grain(4, 18, col); grain(5, 18, col); grain(6, 18, col); } ctx.restore(); } } +// The same framed, three-grain window in both looks; one grain remains visible near empty. +function drawSiloWindow(ctx, mm, sx, sy, zp, pixel) { + const height = mm.sn > 0 ? Math.max(1, Math.round(16 * Math.min(1, mm.sn / SILO_CAP))) : 0; + ctx.fillStyle = '#c9d1d8'; ctx.fillRect(sx + 7 * zp, sy + 2 * zp, 5 * zp, 18 * zp); + ctx.fillStyle = '#14181c'; ctx.fillRect(sx + 8 * zp, sy + 3 * zp, 3 * zp, 16 * zp); + if (!height) return; + // Lift dark fuel slightly so coal stays distinct from the empty glass. + const lift = Math.max(COLR[mm.sm], COLG[mm.sm], COLB[mm.sm]) < 80 ? 32 : 0; + if (pixel) { + for (let y = 19 - height; y < 19; y++) for (let x = 8; x < 11; x++) { + const h = Math.imul(mm.x + x, 374761393) ^ Math.imul(mm.y + y, 668265263); + const n = lift + (((h ^ h >>> 13) >>> 0) % 3 - 1) * 8; + ctx.fillStyle = `rgb(${COLR[mm.sm] + n},${COLG[mm.sm] + n},${COLB[mm.sm] + n})`; + ctx.fillRect(sx + x * zp, sy + y * zp, zp, zp); + } + } else { + ctx.fillStyle = `rgb(${COLR[mm.sm] + lift},${COLG[mm.sm] + lift},${COLB[mm.sm] + lift})`; + ctx.fillRect(sx + 8 * zp, sy + (19 - height) * zp, 3 * zp, height * zp); + } +} + // Shelves show each block's fill and materials; the bottom gate belongs to the column. function drawRackBlocks() { const ctx = R.ctx; diff --git a/src/sim/drill.js b/src/sim/drill.js index d85ace5..974ab49 100644 --- a/src/sim/drill.js +++ b/src/sim/drill.js @@ -43,7 +43,7 @@ function toolPulse(ax, ay, width, tune) { // every cell in the cone is hit, with no untouched stripes between the rays drillPulses.push({ ox: o.x, oy: o.y, ang, w: drillWidth, jit: rnd(), n: Wd.rays, damp: Wd.damp, en: new Float32Array(Wd.rays).fill(Wd.en), s: new Float32Array(Wd.rays), r: 0, range, tune: tune || 0 }); lastPulseTick = tick; - // say why nothing breaks: the first solid on the centre line is too hard, or out of reach + // say why nothing breaks: the first solid on the centre line is too hard let status = ''; for (let k = 3; k < reach; k++) { const x = Math.floor(o.x + dx / d * k), y = Math.floor(o.y + dy / d * k); @@ -57,7 +57,6 @@ function toolPulse(ax, ay, width, tune) { } break; } - 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; diff --git a/src/ui/info.js b/src/ui/info.js index dfbf8eb..c610bc0 100644 --- a/src/ui/info.js +++ b/src/ui/info.js @@ -81,17 +81,23 @@ function hoverInfo(ax, ay) { if (ax < 0 || ay < 0 || ax >= W || ay >= H) return { t1: '', t2: ['Outside the world'], warn: '', air: true }; const i = ay * W + ax, m = mat[i]; let warn = ''; - let t1 = MD[m].name, t2 = []; - const p = part[i], id = mach[i]; + let t1 = MD[m].name, t2 = [], contents = ''; + const p = part[i]; + // Cone gaps have no machine cells; hover still belongs to the silo's whole outline. + const silo = machines.find(mm => mm && mm.type === 'silo' && ax >= mm.x && ax < mm.x + mm.w && ay >= mm.y && ay < mm.y + mm.h); + const id = silo ? silo.id : mach[i]; const b = blockP[((ay / BS) | 0) * BW + ((ax / BS) | 0)]; const bar = occ[i] ? ents.find(e => ax >= e.ix && ax < e.ix + BAR_W && ay >= e.iy && ay < e.iy + BAR_H) : null; - if (bar) { t1 = MD[bar.m].name; t2.push(fmtT(bar.T)); } + if (bar && !silo) { t1 = MD[bar.m].name; t2.push(fmtT(bar.T)); } else if (id && machines[id]) { 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.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 === 'silo') { + contents = mm.sn ? `${MD[mm.sm].name.toLowerCase()} ${mm.sn.toLocaleString('en-US')} / ${SILO_CAP.toLocaleString('en-US')}` : 'empty'; + 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'); + } } else if (b === BP_RACK) { const top = rackTop((ay >> 2) * BW + (ax >> 2)), counts = new Map(); let n = 0, capacity = 0; @@ -135,9 +141,9 @@ function hoverInfo(ax, ay) { if (!ok && toolId() === 'drill') warn = HARD[m] > 6 ? 'cannot be drilled' : 'too hard'; } if (!bar && PH[m] === PH_POWDER) t2.push('loose'); - if (!bar && m === EMPTY && bgm[i]) { t1 = bgm[i] === WOOD ? 'Tree trunk' : 'Leaves'; t2.unshift(bgm[i] === WOOD ? 'drill it for wood chunks' : 'water and sand pass through'); } + if (!id && !bar && m === EMPTY && bgm[i]) { t1 = bgm[i] === WOOD ? 'Tree trunk' : 'Leaves'; t2.unshift(bgm[i] === WOOD ? 'drill it for wood chunks' : 'water and sand pass through'); } if (flags[i] & F_BURN) t2.push('burning'); - const mo = moldAt(ax, ay); + const mo = silo ? null : 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` : '100% set · press F') : mo.state === 'empty' ? 'empty' : `${mo.state === 'short' ? 'not full' : mo.state} ${Math.round(mo.fill * 100)}%`]; @@ -146,12 +152,12 @@ function hoverInfo(ax, ay) { 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); + const reachOk = toolId() === 'drill' || inReach(ax, ay); if (!reachOk) { t2.push('out of reach'); warn = 'out of reach'; } // what it does, unless the cursor is on a machine or mold, which have their own details const what = bar ? bar.m : !mo && !id && !(p && p !== P_GRATE && p !== P_PIPE) && m !== BELTL && m !== BELTR && m !== PIPE ? (m === EMPTY && bgm[i] ? bgm[i] : m) : 0; const t3 = what ? reactionsOf(what) : []; - return { t1, t2, t3, warn, air: m === EMPTY && !bgm[i] && !bar && !mo && !id && !p && !b }; + return { t1, t2, t3, warn, contents, air: m === EMPTY && !bgm[i] && !bar && !mo && !id && !p && !b }; } function renderInfo() { const r = hoverInfo(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y)); @@ -167,8 +173,8 @@ function drawCursorLabel(px, py, r) { const ctx = R.ctx, dpr = R.canvas.width / window.innerWidth; const fs = Math.round(12 * dpr * SET.uiScale); if (!R.uiFont) R.uiFont = getComputedStyle(document.documentElement).getPropertyValue('--font-ui').trim() || 'sans-serif'; - const main = info.t1 + (player.lens && R.lens ? '' : ''); - const sub = info.warn || (R.lens ? info.t2.find(t => /°C$/.test(t)) || '' : ''); + const main = info.t1 + (info.contents && info.warn ? ` ${info.contents}` : ''); + const sub = info.warn || info.contents || (R.lens ? info.t2.find(t => /°C$/.test(t)) || '' : ''); ctx.save(); ctx.font = `600 ${fs}px ${R.uiFont}`; const w1 = ctx.measureText(main).width; @@ -182,7 +188,7 @@ function drawCursorLabel(px, py, r) { ctx.beginPath(); ctx.roundRect(x, y, w1 + w2 + padX * 2, h, 4 * dpr); ctx.fill(); ctx.textBaseline = 'middle'; ctx.font = `600 ${fs}px ${R.uiFont}`; ctx.fillStyle = '#efe6d8'; ctx.fillText(main, x + padX, y + h / 2); - if (sub) { ctx.font = `500 ${Math.round(fs * 0.9)}px ${R.uiFont}`; ctx.fillStyle = info.warn ? '#ef6a55' : '#ffd27a'; ctx.fillText(sub, x + padX + w1 + fs * 0.6, y + h / 2); } + if (sub) { ctx.font = `500 ${Math.round(fs * 0.9)}px ${R.uiFont}`; ctx.fillStyle = info.warn ? '#ef6a55' : info.contents ? '#efe6d8' : '#ffd27a'; ctx.fillText(sub, x + padX + w1 + fs * 0.6, y + h / 2); } ctx.restore(); } diff --git a/src/ui/preview-drill.js b/src/ui/preview-drill.js index 6f53173..b4a7656 100644 --- a/src/ui/preview-drill.js +++ b/src/ui/preview-drill.js @@ -74,7 +74,7 @@ function drawDrillAim(ax, ay) { const o = drillOrigin(), c = worldToScreenPx(o.x, o.y), p = worldToScreenPx(ax + 0.5, ay + 0.5); const d = Math.hypot(ax + 0.5 - o.x, ay + 0.5 - o.y), ang = Math.atan2(ay + 0.5 - o.y, ax + 0.5 - o.x); const Wd = PULSE_W[GAME.drillW], range = Math.max(3, Math.min(drillReach(GAME.drillW), d + 3)); - const ok = inDrillReach(ax, ay, GAME.drillW), col = ok ? pulseRGB(GAME.drillTune) : '239,106,85'; + const col = pulseRGB(GAME.drillTune); const mouth = fanPoints(o.x, o.y, ang, Wd, 2.5, 1), far = fanPoints(o.x, o.y, ang, Wd, range, 10); ctx.save(); // the outline gets a dark under-stroke so it reads on bright sky as well as dark rock @@ -84,6 +84,9 @@ function drawDrillAim(ax, ay) { ctx.strokeStyle = 'rgba(10,14,20,0.28)'; ctx.lineWidth = Math.max(2, zp * 0.3); ctx.stroke(); ctx.strokeStyle = `rgba(${col},0.6)`; ctx.lineWidth = Math.max(1, zp * 0.14); ctx.stroke(); ctx.setLineDash([]); ctx.lineDashOffset = 0; + // A solid far edge marks where the pulse stops, even with a distant cursor. + ctx.beginPath(); far.forEach((pt, k) => k ? ctx.lineTo(pt.x, pt.y) : ctx.moveTo(pt.x, pt.y)); + ctx.strokeStyle = `rgba(${col},0.85)`; ctx.lineWidth = Math.max(1, zp * 0.22); ctx.stroke(); // the emitter glows across the bot's face while it fires const hot = Math.max(0, 1 - (tick - lastPulseTick) / PULSE_EVERY); if (GAME.lmb && hot > 0) { @@ -95,7 +98,7 @@ function drawDrillAim(ax, ay) { ctx.fillStyle = g; ctx.fillRect(c.x - zp * 3.5, c.y - zp * 3.5, zp * 7, zp * 7); ctx.globalCompositeOperation = 'source-over'; } - ctx.strokeStyle = ok ? 'rgba(239,230,216,0.85)' : 'rgba(239,106,85,0.85)'; ctx.lineWidth = Math.max(1, zp * 0.2); + ctx.strokeStyle = 'rgba(239,230,216,0.85)'; ctx.lineWidth = Math.max(1, zp * 0.2); const k = zp * 0.9; ctx.beginPath(); ctx.moveTo(p.x - k * 1.6, p.y); ctx.lineTo(p.x - k * 0.5, p.y); ctx.moveTo(p.x + k * 0.5, p.y); ctx.lineTo(p.x + k * 1.6, p.y); ctx.moveTo(p.x, p.y - k * 1.6); ctx.lineTo(p.x, p.y - k * 0.5); ctx.moveTo(p.x, p.y + k * 0.5); ctx.lineTo(p.x, p.y + k * 1.6); ctx.stroke(); diff --git a/src/ui/previews.js b/src/ui/previews.js index f74820f..45b5457 100644 --- a/src/ui/previews.js +++ b/src/ui/previews.js @@ -70,9 +70,6 @@ function drawPreviews() { if (!reachOk && t !== 'drill' && !sel) { drawReachCircle(REACH, 0.35); } - if (t === 'drill' && !sel && !pasting && !inDrillReach(ax, ay, GAME.drillW)) { - drawReachCircle(drillReach(GAME.drillW), 0.45); - } drawDrillPulses(); if (t === 'drill' && !sel && !pasting) drawDrillAim(ax, ay); if (sel) drawSelection(sel, ax, ay); diff --git a/test/play.py b/test/play.py index 47e10ca..72e0db1 100644 --- a/test/play.py +++ b/test/play.py @@ -1134,6 +1134,7 @@ with sync_playwright() as p: settle(1500) ev("__pad.buttons[7].pressed = false; __pad.buttons[7].value = 0;") settle(200) + check('gamepad drill aim stays within its reach', ev('Math.hypot(GAME.padAim.x, GAME.padAim.y) <= drillReach(GAME.drillW)')) check('gamepad RT digs where the right stick aims', ev('stats.dug') > d0, f"{d0}->{ev('stats.dug')}") ev("__pad.buttons[5].pressed = true;"); settle(100); ev("__pad.buttons[5].pressed = false;"); settle(100) check('gamepad RB switches to the vacuum', ev('GAME.tool') == 1) @@ -1185,6 +1186,77 @@ with sync_playwright() as p: fp.goto(url) fp.evaluate('startNewWorld(4242, 512, 384)') fp.wait_for_function('GAME.running') + # Silo feedback uses real pointer positions; drawing checks sample an isolated canvas. + fp.evaluate("""() => { + GAME.paused = true; SET.fog = false; SET.look = 'pixel'; SET.zoom = 8; SET.cursorLabel = true; + SET.cameraLead = false; SET.cameraSmooth = 0; resizeRender(); + for (let y = 80; y < 140; y++) for (let x = 80; x < 160; x++) { setCell(y * W + x, EMPTY, 20, 0); bgm[y * W + x] = 0; } + window.testSilo = addMachine('silo', 28, 25, 0); + bot.x = 104; bot.y = 106; snapCamera(); $('cards').replaceChildren(); + }""") + for amount in [0, 2000]: + fp.evaluate(f'testSilo.sm = SAND; testSilo.sn = {amount}') + for name, dx, dy in [('drum', 5, 8), ('hatch with snow', 5, 0), ('cone gap', 1, 22), ('leg', 0, 23), ('gate', 5, 23)]: + fp.evaluate('setCell((testSilo.y) * W + testSilo.x + 5, SNOW, -10, 0)') + point = fp.evaluate(f'(() => {{ const p = worldToScreenPx(testSilo.x + {dx} + 0.5, testSilo.y + {dy} + 0.5); return [p.x * innerWidth / R.canvas.width, p.y * innerWidth / R.canvas.width]; }})()') + fp.mouse.move(*point); fp.wait_for_timeout(200) + want = 'sand 2,000 / 4,000' if amount else 'empty' + check(f'silo {name} hover shows {want}', fp.evaluate(f"hoverInfo(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y)).t1 === 'Silo' && hoverInfo(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y)).contents === '{want}' && $('info').textContent.includes('{('holds 2,000 / 4,000 sand' if amount else 'empty')}')")) + check(f'silo {name} cursor label draws {want} in normal colour', fp.evaluate("""() => { + const calls = [], ctx = R.ctx, text = ctx.fillText; + ctx.fillText = function(t) { calls.push([t, this.fillStyle]); }; + try { drawCursorLabel(500, 300, 1); } finally { ctx.fillText = text; } + const info = hoverInfo(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y)); + return calls.some(([t, c]) => t === info.contents && c === '#efe6d8'); + }""")) + check('snow in silo is named snow', fp.evaluate("testSilo.sm = SNOW; hoverInfo(testSilo.x + 5, testSilo.y + 8).contents === 'snow 2,000 / 4,000'")) + for look in ['pixel', 'smooth']: + check(f'{look} silo fill pixels show empty, near empty, half and full at zooms 2 through 12', fp.evaluate("""pixel => { + const canvas = document.createElement('canvas'); canvas.width = 160; canvas.height = 300; + const ctx = canvas.getContext('2d'), mm = { x: 112, y: 100, sm: SAND }; + const at = (x, y, z) => Array.from(ctx.getImageData(Math.floor(x * z), Math.floor(y * z), 1, 1).data).slice(0, 3).join(','); + for (let z = 2; z <= 12; z++) for (const [n, h] of [[0, 0], [1, 1], [2000, 8], [4000, 16]]) { + mm.sn = n; drawSiloWindow(ctx, mm, 0, 0, z, pixel); + for (let y = 3; y < 19; y++) if ((at(9.5, y + 0.5, z) !== '20,24,28') !== (y >= 19 - h)) return false; + } + // Every powder siloTick accepts must stand apart from dark glass, including coal. + for (let m = 1; m < NMAT; m++) if (PH[m] === PH_POWDER) { + mm.sm = m; mm.sn = 2000; drawSiloWindow(ctx, mm, 0, 0, 2, pixel); + const filled = at(9.5, 18.5, 2).split(',').map(Number); + if (Math.max(...filled.map((c, k) => Math.abs(c - [20, 24, 28][k]))) < 25) return false; + const image = ctx.getImageData(0, 0, 24, 48).data.slice().join(','); + drawSiloWindow(ctx, mm, 0, 0, 2, pixel); + if (ctx.getImageData(0, 0, 24, 48).data.join(',') !== image) return false; + } + return true; + }""", look == 'pixel')) + fp.set_viewport_size({'width': 700, 'height': 800}) + check('silo contents remain available when the narrow HUD hides the info plate', fp.evaluate("getComputedStyle($('info')).display === 'none' && hoverInfo(testSilo.x + 5, testSilo.y + 8).contents === 'snow 2,000 / 4,000'")) + fp.set_viewport_size({'width': 1280, 'height': 800}) + check('other tools still warn beyond reach', fp.evaluate("GAME.tool = 1; hoverInfo(testSilo.x + 80, testSilo.y).warn === 'out of reach'")) + # Holding the drill with a distant pointer is ordinary aim, but hard rock still warns. + fp.evaluate('bot.y = 130; snapCamera()') + fp.keyboard.press('1') + fp.mouse.move(1000, 400); fp.wait_for_timeout(100) + fp.evaluate("$('toasts').replaceChildren(); lastPulseTick = -99; player.bitTier = 0; GAME.drillTune = 0") + fp.evaluate('GAME.paused = false') + fp.mouse.down() + fp.evaluate('GAME.paused = true; for (let k = 0; k < 24; k++) { tick++; toolTick(); }') + check('held drill beyond reach makes no toast or distance label', fp.evaluate("GAME.lmb && !inDrillReach(GAME.aim.x, GAME.aim.y, GAME.drillW) && !$('toasts').children.length && !hoverInfo(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y)).warn && !hoverInfo(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y)).t2.includes('out of reach')")) + check('distant drill fan and crosshair stay normal with no red reach circle', fp.evaluate("""() => { + const ctx = R.ctx, stroke = ctx.stroke, circle = drawReachCircle, colours = []; let circles = 0; + ctx.stroke = function() { colours.push(this.strokeStyle); }; + drawReachCircle = () => circles++; + try { drawPreviews(); } finally { ctx.stroke = stroke; drawReachCircle = circle; } + return circles === 0 && !colours.some(c => c.includes('239, 106, 85')); + }""")) + fp.evaluate("""() => { + const o = drillOrigin(), dx = GAME.aim.x + 0.5 - o.x, dy = GAME.aim.y + 0.5 - o.y, d = Math.hypot(dx, dy); + for (let k = 6; k < 10; k++) setCell(Math.floor(o.y + dy / d * k) * W + Math.floor(o.x + dx / d * k), GRANITE, 20, F_NAT); + tick += 20; toolTick(); + }""") + check('held drill still toasts too hard with a distant pointer', fp.evaluate("$('toasts').textContent.includes('Granite is too hard; needs an iron bit.')")) + fp.mouse.up() fp.evaluate("""() => { GAME.paused = true; SET.fog = true; SET.look = 'pixel'; SET.zoom = 6; SET.lighting = 'off'; SET.cameraLead = false; SET.cameraSmooth = 0; resizeRender(); diff --git a/test/t_drill.js b/test/t_drill.js index aa563df..e967d3f 100644 --- a/test/t_drill.js +++ b/test/t_drill.js @@ -16,6 +16,11 @@ const med = { d: depth(), n: opened() }; check('held on a stone wall, medium pulses dig a hole and drop rubble', med.d >= 6 && run('stats.dug') > 30 && count(run, run('RSTONE')) > 30, `${med.d} deep, ${med.n} opened, ${count(run, run('RSTONE'))} rubble`); wall(); hold(150, 78, 1, 0, 0.3); check('rock that is shaking but has not broken yet shows it', run('(() => { let n = 0; for (let i = 0; i < N; i++) if (digp[i] > 0 && PH[mat[i]] === PH_SOLID) n++; return n; })()') > 5); +// A distant cursor chooses direction; the pulse still ends at its reach. +flatWorld(run, 240, 160, 100); +run('bot.x = 40; bot.y = 40; player.bitTier = 0; lastPulseTick = -99;'); +check('aiming past reach fires without a distance warning', run("toolPulse(220, 42, 1, 0).status") === ''); +check('a distant aim still caps the pulse at its reach', run('drillPulses[drillPulses.length - 1].range === drillReach(1)')); // the widths, with a copper bit so the reach is not what stops them const wall2 = () => { wall(); run('player.bitTier = 1;'); }; wall2(); hold(150, 78, 1, 0, 2); @@ -81,10 +86,10 @@ check('and the granite stays whole', count(run, run('GRANITE')) === 116 * 40, co wall(); run('drillPulses = []; tick += 20;'); const far = JSON.parse(run('JSON.stringify(toolPulse(200, 78, 1, 0))')); -check('aiming past the reach says how far the bit reaches', /Out of reach/.test(far.status), far.status); +check('aiming past the reach fires without a warning', far.fired && !far.status, far.status); run('drillPulses = []; tick += 20;'); const farW = JSON.parse(run('JSON.stringify(toolPulse(125, 78, 2, 0))')), nearM = JSON.parse(run('tick += 20; JSON.stringify(toolPulse(125, 78, 1, 0))')); -check('a spot a medium pulse reaches is out of reach for a wide one, and it says so', /Out of reach/.test(farW.status) && /wide/.test(farW.status) && !nearM.status, `${farW.status} | ${nearM.status}`); +check('aiming beyond a wide pulse also fires without a warning', farW.fired && !farW.status && nearM.fired && !nearM.status); // stray bits: a lump of rock left hanging when the rock round it breaks falls apart const hanging = (m) => { diff --git a/test/t_silo_contents.js b/test/t_silo_contents.js new file mode 100644 index 0000000..cc28312 --- /dev/null +++ b/test/t_silo_contents.js @@ -0,0 +1,17 @@ +// Stored snow keeps its identity and remains in the world's water budget. +const { load, suite, flatWorld, ticks } = require('./harness'); +const { run } = load(); +const { check, done } = suite(); +flatWorld(run, 240, 160, 120); +run("globalThis.snowSilo = addMachine('silo', 20, 20, 0); setCell(80 * W + 85, SNOW, -10, 0); stamp[80 * W + 85] = 0; tickStamp = 1;"); +const before = run('worldWater()'); +run('siloTick(snowSilo)'); +check('silo accepts snow and keeps its name', run('snowSilo.sm === SNOW && snowSilo.sn === 1')); +check('snow inside still counts as water', run('worldWater()') === before); +run('for (let y = 80; y < 104; y++) for (let x = 80; x < 92; x++) temp[y * W + x] = 100;'); +ticks(run, 180); +check('stored snow does not melt in a warm silo', run('snowSilo.sm === SNOW && snowSilo.sn === 1')); +run('snowSilo.open = true; siloTick(snowSilo)'); +check('opening the gate releases snow, then the silo is empty', run('snowSilo.sn === 0 && snowSilo.sm === EMPTY && mat[103 * W + 84 + (tick & 3)] === SNOW')); +check('releasing snow preserves the water count', run('worldWater()') === before); +done(); diff --git a/tools/scenes/silo.js b/tools/scenes/silo.js index 840491b..1295926 100644 --- a/tools/scenes/silo.js +++ b/tools/scenes/silo.js @@ -1,9 +1,11 @@ // Run after clear-sky.js; leave paused so the open gate keeps its demonstration charge. -for (let k = 0; k < 3; k++) { - const bx = BX + 10 + k * 7, by = BY - 5; +for (let k = 0; k < 5; k++) { + const bx = BX + 5 + k * 6, by = BY - 5; placeMachineAt('silo', 0, bx, by); const mm = machines.find(m => m && m.type === 'silo' && m.x === bx * 4 && m.y === by * 4); - mm.sm = k === 2 ? CUORE : SAND; mm.sn = k === 0 ? 0 : Math.floor(SILO_CAP * (k === 1 ? 0.5 : 0.95)); + mm.sm = k === 2 ? CUORE : k === 3 ? SNOW : COAL; + if (k === 1) mm.sm = SAND; + mm.sn = [0, 2000, 3800, 2800, 1][k]; mm.open = k === 2; } window.CAM = [(BX + 18.5) * 4, (BY - 2) * 4];