diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 75597dd..45b8097 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -659,3 +659,7 @@ above, including: Nozzle, igniter, copy/cut and paste use open crosshairs in their existing colours; the drill keeps its light cross. Draw pointers in the game in both looks, leaving the aim point open. Menus keep the system pointer. +- **Carried liquids can always be blown out (t103, 2026-10-29).** The owner chose to require + the Flask only for picking liquids up: normally that is how liquids enter the bag, but water + given through the console must not get stuck. Single, selected-mix and Shift-everything blows + include carried liquids without the Flask. The console's `give` stays unchanged. diff --git a/docs/PROJECT_MAP.md b/docs/PROJECT_MAP.md index 8c98ab8..7e2b50c 100644 --- a/docs/PROJECT_MAP.md +++ b/docs/PROJECT_MAP.md @@ -347,7 +347,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 (203 lines) +### src/sim/vacuum.js (202 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 @@ -677,6 +677,7 @@ The frame loop and boot. - `test/t_joins.js`: The oracle states the geometric rule independently of the precomputed neighbour tables. - `test/t_lakes.js`: Generated lakes sit in a smooth bowl filled to its spill level: flat, contained, clay-bedded, stable, with natural banks, dry tree bases and the dock clear. - `test/t_lakes.py`: t96: flat generated lakes, fresh and reloaded, in the real pixel frame loop. +- `test/t_liquid_blow.js`: t103: carried liquids can always leave the bag; collecting them still needs the Flask. - `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. diff --git a/docs/TESTING.md b/docs/TESTING.md index 871e9af..3923761 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -14,6 +14,12 @@ prints the failing checks. ## Headless suites +For t103, `node test/t_liquid_blow.js` checks blowing console-given water and every liquid +without the Flask, water landing, mixed blowing, and the retained liquid pick-up gates. +`python3 test/t_dev_console.py` checks actual bag selection, right-click blowing water into +a pit, and Shift preview/blowing of water with sand without the Flask in the pixel look. +The inspected pit screenshot is `shots/t103-water-pit.png`. + For t45, `node test/t_stalls.js` checks the four-grain bed, sustained local jams, recovery, bars, cleated runs, slower screens and an overloaded closed loop. `test/t_threads.js` includes loaded belts and slopes, and hashes stall/animation state diff --git a/docs/agent-log.md b/docs/agent-log.md index dca9b79..1c6f8f7 100644 --- a/docs/agent-log.md +++ b/docs/agent-log.md @@ -4,6 +4,17 @@ What agents have done, newest first. Add an entry for every piece of work: the d note or request it was for, what changed, the files touched, the tests run, and anything left open or not verified. Keep each entry short; the git history has the details. +## 2026-10-29: t103 blow carried liquids without the Flask (Codex) + +- Removed only blow-side Flask gates, including Shift's HUD list; corrected the Flask hint. + Pick-up checks and console give unchanged. Recorded the owner decision and test recipe. +- Files: sim/{vacuum,progress}, ui/hud, test/{t_liquid_blow.js,t_dev_console.py}, + docs/{DESIGN,TESTING,PROJECT_MAP}, t103 and this log. Map regenerated. +- Final regression fails 16 checks against original vacuum.js; all 43 headless suites pass. + Console browser passes 36 checks; inspected shots/t103-water-pit.png. Full browser run: + golden-image mismatch reproduces identically on unmodified HEAD; smooth passes on rerun. + Other 11 browser suites pass; evidence in t103 and /tmp/t103-*.log. No commit; owner playtest remains. + ## 2026-10-29: t45 belt depth and visible transport stalls (Codex) - Four-grain belt bed; local half-second jam warnings, hover advice and frozen/resuming diff --git a/src/sim/progress.js b/src/sim/progress.js index 8e1ca53..bdcb3b8 100644 --- a/src/sim/progress.js +++ b/src/sim/progress.js @@ -31,7 +31,7 @@ const MILESTONES = [ { id: 'bars', title: 'Copper bars', msg: 'Bars sell for far more than the ore in them, and make metal wall blocks.', hint: 'Build an Ingot mold (Build, 3) right under the furnace with a floor of clay Grates between them: molten copper drips through, while the charge and the thick slag stay up. When the bars set, they drop out of the bottom of the mold onto whatever is under it; a belt carries them off. Leave space below, or pick up the bars with the Pneumatic nozzle. Ingot molds empty themselves: bars, junk and scrap drop below, slag is thrown away and the grate above is raked. Removing any mold returns its set metal to your bag: whole bars as bars, the rest as scrap. Copper that sets in the furnace melts again next firing.' }, { id: 'bit1', title: 'Copper drill bit', hint: 'Cast copper in a Bit mold. Building the furnace right on top works. A Grate over the funnel keeps the charge out. Let it set, then press F.', reward: 'Copper bit. You can now dig iron ore.' }, { id: 'auto', title: 'Moving parts', msg: 'Machines can now haul for you. Feed furnaces automatically, and lift ore out of your mines.', hint: 'Belts carry grains and bars resting on them. Screw conveyors push loose material along (never liquids); stacked pointing up they lift it out of a mine. A Filter lets one material fall through and carries the rest along, so you can sort ore from rubble. Move 300 grains with machines.' }, - { id: 'glass', title: 'Glass', hint: 'Sand melts near 1300°C. Still air will not get a fire that hot: point a Fan into the fuel. Molten glass runs through a grate like metal, so cast it in an Ingot mold for glass bricks, which sell well.', reward: 'Flask unlocked. The nozzle can suck up and blow out liquids.' }, + { id: 'glass', title: 'Glass', hint: 'Sand melts near 1300°C. Still air will not get a fire that hot: point a Fan into the fuel. Molten glass runs through a grate like metal, so cast it in an Ingot mold for glass bricks, which sell well.', reward: 'Flask unlocked. The nozzle can now pick up liquids.' }, { id: 'iron', title: 'Molten iron', msg: 'Iron is hard to keep molten. A brick mold right under the hot zone works best.', hint: 'Iron ore melts at 1538°C. That needs forced air, and stone slumps at 1250°C, so build the furnace from brick.' }, { id: 'bit2', title: 'Iron drill bit', hint: 'Cast iron in a Bit mold. A stone mold melts at iron temperatures, so build it from brick.', reward: 'Iron bit. Granite is now diggable.' }, { id: 'steel', title: 'Steel', msg: 'Cast it in a brick Bit mold for the best drill bit there is.', hint: 'Blow air through molten iron. Aim a Fan at a pool of it and the excess carbon burns off, turning it into steel.' }, diff --git a/src/sim/vacuum.js b/src/sim/vacuum.js index ba20196..afa869e 100644 --- a/src/sim/vacuum.js +++ b/src/sim/vacuum.js @@ -52,7 +52,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 arc toward the cursor, within reach. -// Liquids need the Flask. Bars are thrown, one at a time. +// Carried liquids can always be blown out. Bars are thrown, one at a time. function nozzle(tx, ty) { const cx = bot.x + bot.w / 2, cy = bot.y + bot.h / 2; let dx = tx - cx, dy = ty - cy; const d = Math.hypot(dx, dy) || 1; @@ -65,7 +65,7 @@ let blowRR = 0; function toolBlow(cx, cy, r, m, mix) { let pickM = null; if (mix && mix.length) { - const avail = mix.filter(k => !PARTMAT[k] && bag[k] > 0 && PH[k] !== PH_ITEM && (PH[k] !== PH_LIQUID || player.flask)); + const avail = mix.filter(k => !PARTMAT[k] && bag[k] > 0 && PH[k] !== PH_ITEM); if (!avail.length) return { placed: 0, status: 'None of the selected materials are in the bag.' }; 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]; @@ -74,7 +74,6 @@ function toolBlow(cx, cy, r, m, mix) { if (PARTMAT[m]) return { placed: 0, status: MD[m].name + ' is built from the build menu.' }; if (bag[m] <= 0) return { placed: 0, status: 'None of the selected materials are in the bag.' }; const ph = PH[m]; - if (ph === PH_LIQUID && !player.flask) return { placed: 0, status: 'You need the Flask to carry liquids.' }; const nz = nozzle(cx + 0.5, cy + 0.5); if (ph === PH_ITEM) { if (tick % 10 !== 0) return { placed: 0, status: '' }; diff --git a/src/ui/hud.js b/src/ui/hud.js index 5b8a143..7ec76b2 100644 --- a/src/ui/hud.js +++ b/src/ui/hud.js @@ -35,6 +35,6 @@ function bagMaterials() { } function blowList() { return [...GAME.blow].filter(m => bag[m] > 0); } // Shift + right click: an even mix of everything loose in the bag (bars never mix) -function blowAllList() { return bagMaterials().filter(m => PH[m] !== PH_ITEM && (PH[m] !== PH_LIQUID || player.flask)); } +function blowAllList() { return bagMaterials().filter(m => PH[m] !== PH_ITEM); } function blowingAll() { return toolId() === 'vacuum' && (GAME.rmb ? !GAME.rmbConsumed && GAME.blowAll : modShift()); } function piece() { return PIECES[GAME.pieceIdx]; } diff --git a/test/t_dev_console.py b/test/t_dev_console.py index 39beb63..e2b935d 100644 --- a/test/t_dev_console.py +++ b/test/t_dev_console.py @@ -101,6 +101,50 @@ with launch() as (browser, page, url): page.wait_for_function(f'tick === {tick + 5} && devSteps === 0') page.wait_for_timeout(150) check('step advances exactly five ticks then stays paused', page.evaluate('tick') == tick + 5) + # t103: real bag selection and mouse input, with deterministic dev stepping into a pit. + page.evaluate('''() => { + player.flask = false; bag.fill(0); bagTotal = 0; flyers.length = 0; + for (let y = 20; y <= 150; y++) for (let x = 60; x <= 190; x++) { + const i = y * W + x; + part[i] = mach[i] = bgm[i] = 0; + setCell(i, y >= 145 || x === 60 || x === 190 ? STONE : EMPTY, 20, 0); + } + bot.x = 100; bot.y = 100; bot.vx = bot.vy = 0; + SET.fog = false; SET.lighting = 'off'; SET.cameraLead = false; SET.zoom = 4; + SET.cardSeconds = 0; $('cards').replaceChildren(); + resizeRender(); snapCamera(); selectTool(1); GAME.blow.clear(); + lastBagKey = ''; hudDirty = true; + }''') + command('give water 500') + page.keyboard.press('Escape') + page.locator('#baglist .pick[title="Select Water"]').click() + check('console water is visible and selectable without the Flask', page.evaluate('!player.flask && bag[WATER] === 500 && GAME.blow.has(WATER)')) + point = page.evaluate('worldToScreenPx(130, 135)') + scale = page.evaluate('R.canvas.width / window.innerWidth') + page.mouse.move(point['x'] / scale, point['y'] / scale) + page.mouse.down(button='right') + page.evaluate('runDevCommand("step 4")') + page.wait_for_function('devSteps === 0') + page.mouse.up(button='right') + check('right click blows water without the Flask', page.evaluate('!player.flask && bag[WATER] < 500 && flyers.some(f => f.m === WATER)')) + page.evaluate('runDevCommand("step 240")') + page.wait_for_function('devSteps === 0') + check('water lands in the pit and leaves the bag count reduced', page.evaluate('''() => { + let landed = 0; + for (let y = 20; y < 145; y++) for (let x = 61; x < 190; x++) if (mat[y * W + x] === WATER) landed++; + return !player.flask && !flyers.length && landed > 0 && landed + bag[WATER] === 500; + }''')) + page.evaluate('runDevCommand("give sand 100"); hudDirty = true;') + page.keyboard.down('Shift') + page.wait_for_function("$('selcard').textContent.includes('Everything')") + check('Shift preview includes carried water without the Flask', page.evaluate("blowAllList().includes(WATER) && [...$('baglist').querySelectorAll('.bagrow.on .nm')].some(e => e.textContent === 'Water')")) + page.screenshot(path=str(SHOTS / 't103-water-pit.png')) + water = page.evaluate('bag[WATER]') + page.mouse.down(button='right') + page.evaluate('runDevCommand("step 4")') + page.wait_for_function('devSteps === 0') + page.mouse.up(button='right'); page.keyboard.up('Shift') + check('Shift right click blows both water and sand without the Flask', page.evaluate(f'!player.flask && bag[WATER] < {water} && bag[SAND] < 100')) # Drive the actual frame accumulator with controlled elapsed time and no scheduled frames. counts = page.evaluate('''() => { requestAnimationFrame = () => 0; @@ -133,7 +177,7 @@ with launch() as (browser, page, url): }''')) page.reload() page.wait_for_function("!$('title').hidden") - check('history is restored on a fresh page', page.evaluate("devInputHistory.items.slice(-1)[0] === 'step 5' && devRecall(devInputHistory, -1) === 'step 5'")) + check('history is restored on a fresh page', page.evaluate("devInputHistory.items.slice(-1)[0] === 'give water 500' && devRecall(devInputHistory, -1) === 'give water 500'")) check('no browser errors', not errors) browser.close() diff --git a/test/t_liquid_blow.js b/test/t_liquid_blow.js new file mode 100644 index 0000000..b274661 --- /dev/null +++ b/test/t_liquid_blow.js @@ -0,0 +1,35 @@ +// t103: carried liquids can always leave the bag; collecting them still needs the Flask. +const { load, suite, flatWorld, count } = require('./harness'); +const { run } = load(); +const { check, done } = suite(); + +for (const flask of [false, true]) { + flatWorld(run, 240, 160, 110); + run(`enableDevConsole(); player.flask = ${flask}; devCommand('give water 500');`); + check(`give water keeps Flask=${flask}`, run(`bag[WATER] === 500 && player.flask === ${flask}`)); + const blown = run('toolBlow(135, 100, 6, WATER).placed'); + check(`water leaves the bag with Flask=${flask}`, blown > 0 && run(`bag[WATER] === 500 - ${blown} && bagTotal === bag[WATER] && flyers.length === ${blown}`)); + run('for (let k = 0; k < 240; k++) flyTick();'); + check(`blown water lands with Flask=${flask}`, blown > 0 && count(run, run('WATER')) === blown && run('flyers.length === 0'), blown); + + run("devCommand('give sand 500'); blowRR = 0;"); + const mixed = run('toolBlow(135, 100, 6, SAND, [SAND, WATER]).placed'); + check(`mixed blow includes water with Flask=${flask}`, mixed > 1 && run('flyers.some(f => f.m === WATER) && flyers.some(f => f.m === SAND)')); + const before = run('bag[WATER]'); + run('toolVacuum(135, 100, 20, m => m === WATER);'); + check(`liquid pickup ${flask ? 'works with' : 'still needs'} the Flask`, run('bag[WATER]') >= before && (flask ? run('bag[WATER]') > before : run('bag[WATER]') === before)); +} + +flatWorld(run, 240, 160, 110); +run('player.flask = false; setCell(100 * W + 125, WATER, 20, 0);'); +check('water on the ground cannot be targeted or collected without the Flask', run('vacuumTargetAt(125, 100, 1) === 0 && toolVacuum(125, 100, 1, () => true).got === 0 && mat[100 * W + 125] === WATER')); +run('flyers.push({x:125, y:100, m:WATER});'); +check('flying water cannot be targeted or collected without the Flask', run('vacuumTargetAt(125, 100, 1) === 0 && toolVacuum(125, 100, 1, () => true).got === 0 && flyers.length === 1')); + +// Molten liquids use the same blow path, retaining their pouring temperature. +run('flyers.length = 0; enableDevConsole();'); +for (const m of run('Array.from({length: NMAT}, (_, m) => m).filter(m => PH[m] === PH_LIQUID)')) { + run(`devCommand('give ' + MD[${m}].name + ' 2');`); + check(`carried ${run(`MD[${m}].name`)} blows without the Flask`, run(`toolBlow(135, 100, 0, ${m}).placed === 1 && flyers[flyers.length - 1].T === bagTemp(${m})`)); +} +done();