diff --git a/docs/PROJECT_MAP.md b/docs/PROJECT_MAP.md index 02b064a..5ee11b7 100644 --- a/docs/PROJECT_MAP.md +++ b/docs/PROJECT_MAP.md @@ -297,7 +297,7 @@ Trees (background layer), acorns, saplings, and waking freezing water. - functions: cutTree, treeHeld, findTreeBases, treeSupportTick, woodRoom, treeFall, coldTick, plantTick, setTreeCell, saplingTick, igniteTree - variables: treeBases, saplings, treeDraw -### src/sim/vacuum.js (172 lines) +### src/sim/vacuum.js (176 lines) The vacuum, blowing grains out, grains in flight (flyers), and the flame tool. Material the vacuum would start on at the cursor: a bar, else the nearest loose grain. - functions: vacuumTargetAt, toolVacuum, nozzle, toolBlow, flyOpen, depositNear, bagTemp, landFlyer, flyTick, toolIgnite - variables: blowRR, FLY_G, FLY_Q @@ -467,7 +467,7 @@ Gamepad input. - functions: padPressed, pollPad - variables: PAD -### src/ui/previews.js (190 lines) +### src/ui/previews.js (192 lines) Cursor previews: shared grid helpers, air streaks and tool preview dispatch. - functions: blockRectPx, drawGrid, previewColor, drawAirStreaks, drawReachCircle, drawPreviews @@ -511,6 +511,7 @@ The frame loop and boot. - `test/t_furnace_repeat.js`: The starter furnaces keep working when fired again and again without cleaning, however the charge goes in: mixed, ore first with coal on top, or blown in through the vent. Each case plays furnace_play.js, which builds the design and fires it like a player. - `test/t_load.js`: Reload wakes nearby areas first, without losing distant water, fire or saved state. - `test/t_molds.js`: Molds and furnaces stay usable: ingots drop below and keep their stubs, plugs over molds are cleared, spoiled castings can be knocked out, grains never drop through a grate by teleporting, and slag lets the fire breathe. +- `test/t_nozzle.js`: Blowing aims within the nozzle's reach, with room for the brush and settling. - `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. diff --git a/docs/agent-log.md b/docs/agent-log.md index 4b33fcf..39365b5 100644 --- a/docs/agent-log.md +++ b/docs/agent-log.md @@ -4,6 +4,16 @@ 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-25: t34: the nozzle blows no further than it sucks (Claude Code, Codex) + +- `toolBlow()` (`src/sim/vacuum.js`) aims at the capped point from `nozzle()` when the cursor is + out of reach (thrown bars already did); `drawPreviews()` draws the blow brush there, in the blow + colour rather than the out-of-reach red. +- `test/t_nozzle.js`: aiming at 3x and 5x the reach lands sand within reach + 16 grains (brush and + settling, measured 15); inside reach it still lands around the cursor. play.py: the preview + sits at the capped point. `run_all.py --browser` ALL PASSED. Screenshot + `shots/t34-blow-out-of-reach.png`. + ## 2026-10-25: t21: a loaded world starts around the player first (Claude Code, Codex) - Owner: "lets speed up the first load speed. load in areas around the player first". diff --git a/src/sim/vacuum.js b/src/sim/vacuum.js index 243773b..69150ac 100644 --- a/src/sim/vacuum.js +++ b/src/sim/vacuum.js @@ -31,7 +31,7 @@ function toolVacuum(cx, cy, r, pick) { }); return { got, m: first, 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. +// Blow: grains leave the bot's nozzle and arc toward the cursor, within reach. // Liquids need the Flask. Bars are thrown, one at a time. function nozzle(tx, ty) { const cx = bot.x + bot.w / 2, cy = bot.y + bot.h / 2; @@ -65,6 +65,10 @@ function toolBlow(cx, cy, r, m, mix) { if (!e) return { placed: 0, status: 'No room to throw a bar from here.' }; bagTake(m, 1); return { placed: 1, status: '' }; } + if (!inReach(cx, cy)) { + cx = nz.ox + nz.dx * nz.dist - 0.5; + cy = nz.oy + nz.dy * nz.dist - 0.5; + } if (flyers.length > 3000) return { placed: 0, status: '' }; // nozzle 0 is a trickle for careful work, the widest a torrent const rate = 1 + r + ((r * r) >> 1); @@ -73,7 +77,7 @@ function toolBlow(cx, cy, r, m, mix) { for (let k = 0; k < n; k++) { const gm = pickM ? pickM() : m; if (!gm) break; - // aim each grain at a spot in the brush around the cursor, on an arc that passes + // aim each grain at a spot in the brush around the target, on an arc that passes // through it; past that point gravity carries it on until it hits something const a = rnd() * Math.PI * 2, rr = Math.sqrt(rnd()) * (r + 0.5); const tx = cx + 0.5 + Math.cos(a) * rr, ty = cy + 0.5 + Math.sin(a) * rr; diff --git a/src/ui/previews.js b/src/ui/previews.js index b612669..a3e42a0 100644 --- a/src/ui/previews.js +++ b/src/ui/previews.js @@ -80,7 +80,9 @@ function drawPreviews() { const r = t === 'ignite' ? 1 : GAME.brush[GAME.tool]; const reachOk = inReach(ax, ay); const blowing = t === 'vacuum' && GAME.rmb && !GAME.rmbConsumed; - ctx.strokeStyle = !reachOk ? 'rgba(239,106,85,0.8)' : t === 'drill' ? 'rgba(239,230,216,0.8)' : blowing ? 'rgba(255,210,122,0.9)' : t === 'vacuum' ? 'rgba(92,186,166,0.95)' : 'rgba(255,154,60,0.9)'; + const nz = blowing && !reachOk ? nozzle(ax + 0.5, ay + 0.5) : null; + const p = nz ? worldToScreenPx(nz.ox + nz.dx * nz.dist - 0.5, nz.oy + nz.dy * nz.dist - 0.5) : worldToScreenPx(ax, ay); + ctx.strokeStyle = !reachOk && !nz ? 'rgba(239,106,85,0.8)' : t === 'drill' ? 'rgba(239,230,216,0.8)' : blowing ? 'rgba(255,210,122,0.9)' : t === 'vacuum' ? 'rgba(92,186,166,0.95)' : 'rgba(255,154,60,0.9)'; ctx.lineWidth = Math.max(1, zp * 0.25); if (t === 'vacuum') ctx.setLineDash([zp, zp * 0.6]); ctx.beginPath(); ctx.arc(p.x + zp / 2, p.y + zp / 2, (r + 0.5) * zp, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]); diff --git a/test/play.py b/test/play.py index 6c06133..1f51797 100644 --- a/test/play.py +++ b/test/play.py @@ -434,7 +434,23 @@ with sync_playwright() as p: } })()""")) - # 3. right button blows the selection; exhausted materials stay selected + check('out-of-reach blow preview sits at the capped nozzle target', ev("""(() => { + const aim = GAME.aim, consumed = GAME.rmbConsumed, arc = R.ctx.arc; + const arcs = []; + try { + GAME.aim = { x: bot.x + bot.w / 2 + 3 * REACH, y: bot.y + bot.h / 2 }; + GAME.rmb = true; GAME.rmbConsumed = false; + R.ctx.arc = function (x, y, r, ...rest) { arcs.push({ x, y, r }); return arc.call(this, x, y, r, ...rest); }; + drawPreviews(); + const nz = nozzle(Math.floor(GAME.aim.x) + 0.5, Math.floor(GAME.aim.y) + 0.5); + const target = worldToScreenPx(nz.ox + nz.dx * nz.dist, nz.oy + nz.dy * nz.dist); + return arcs.some(p => Math.abs(p.x - target.x) < 0.01 && Math.abs(p.y - target.y) < 0.01 && Math.abs(p.r - (GAME.brush[1] + 0.5) * R.zp) < 0.01); + } finally { + R.ctx.arc = arc; GAME.aim = aim; GAME.rmb = false; GAME.rmbConsumed = consumed; + } + })()""")) + + # 3. right button blows the selection; exhausted materials are deselected refill('bag[COAL] = 120; bag[RSTONE] = 600; bag[CUORE] = 60; bag[LCLAY] = 200; bag[LWOOD] = 120') ok = select('2', 'GAME.blow.size === 1 && GAME.blow.has(COAL)') check('Q/E chooses coal to blow', ok, ev('[...GAME.blow]')) diff --git a/test/t_nozzle.js b/test/t_nozzle.js new file mode 100644 index 0000000..0c11f72 --- /dev/null +++ b/test/t_nozzle.js @@ -0,0 +1,32 @@ +// Blowing aims within the nozzle's reach, with room for the brush and settling. +const { load, suite, flatWorld, ticks, count } = require('./harness'); +const { run } = load(); +const { check, done } = suite(); + +function blowSand(distance, radius) { + flatWorld(run, 640, 160, 100); + run('RS = 12345; bot.x = 300 - bot.w / 2; bot.y = 80 - bot.h / 2; bag[SAND] = 40; bagTotal = 40;'); + run(`for (let k = 0; k < 40 && bag[SAND]; k++) { toolBlow(300 + ${distance}, 99, ${radius}, SAND); simTick(); }`); + ticks(run, 400); + check('all blown sand lands and stays out of the bag', count(run, run('SAND')) === 40 && run('flyers.length === 0 && bag[SAND] === 0')); + return run(`(() => { + let farthest = 0, cursorDistance = 0; + for (let i = 0; i < N; i++) if (mat[i] === SAND) { + const x = i % W + 0.5, y = Math.floor(i / W) + 0.5; + farthest = Math.max(farthest, Math.hypot(x - 300, y - 80)); + cursorDistance = Math.max(cursorDistance, Math.hypot(x - (300 + ${distance} + 0.5), y - 99.5)); + } + return { farthest, cursorDistance }; + })()`); +} + +// Widest brush, 40 grains on flat ground: measured 14.95 grains past REACH. +const BRUSH_AND_SETTLING_MARGIN = 16; +for (const distance of [run('3 * REACH'), run('5 * REACH')]) { + const landing = blowSand(distance, 6); + check('aiming far away lands sand within reach plus brush and settling', landing.farthest <= run('REACH') + BRUSH_AND_SETTLING_MARGIN, landing.farthest.toFixed(2)); +} +const nearby = blowSand(30, 0); +check('aiming inside reach still lands sand around the cursor', nearby.cursorDistance <= BRUSH_AND_SETTLING_MARGIN, nearby.cursorDistance.toFixed(2)); + +done();