From 9bc5a6c3afda86d45405413e939141d6894b618d Mon Sep 17 00:00:00 2001 From: 44r0n7 <44r0n7+gitea@pm.me> Date: Mon, 26 Oct 2026 18:27:10 -0400 Subject: [PATCH] Square wall corners hold grains, liquids and gas (t39) Powder, liquid and gas no longer step diagonally between two solid cells; flyers and diagonal tube intakes obey the same corner. World generation settles with the rule off, so seeds make the same worlds. Co-Authored-By: Claude Opus 5.5 --- docs/DESIGN.md | 8 +++++ docs/PROJECT_MAP.md | 9 +++--- docs/agent-log.md | 28 ++++++++++++++++ src/sim/cells.js | 15 +++++---- src/sim/conveyors.js | 1 + src/sim/vacuum.js | 5 ++- src/sim/worldgen.js | 4 +-- test/t_corner_seal.js | 75 +++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 131 insertions(+), 14 deletions(-) create mode 100644 test/t_corner_seal.js diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 04f6956..46c1776 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -98,6 +98,14 @@ Details and numbers live in the code and in `docs/systems/`. ## Decisions +- **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 + slide as before. Free flyers and diagonal screw intakes obey the same solid-corner boundary. + Screw lane transport is unchanged. World generation disables cell corner sealing while + settling loose ground, preserving existing seed layouts. This supersedes the square-wall + leakage described in the t36 decision below. + The newest are at the bottom. The dates are when the decision was made or built. **Up to 2026-10-19** (early design, stated by the owner): everything in "What the owner wants" diff --git a/docs/PROJECT_MAP.md b/docs/PROJECT_MAP.md index 102e69e..4746eca 100644 --- a/docs/PROJECT_MAP.md +++ b/docs/PROJECT_MAP.md @@ -230,10 +230,10 @@ Exploration only: never changes grains or the random stream. - functions: fogAlloc, fogMark, fogSeen, fogInit, fogLook, fogRevealAround, fogRevealAll - variables: FOG, FOG_SENSE, FOG_SENSE_FADE, fog, FW, FH -### src/sim/cells.js (207 lines) +### src/sim/cells.js (208 lines) Cell helpers (setCell, move, enter) and the falling-sand movement of powders, liquids and gases (simMove). - functions: setCell, swapCells, move, enter, powder, passable, liquid, gas, wakeBotPowder, simMove -- variables: SORTP, SORTP_FUEL, LSTEP, LFILM +- variables: SORTP, SORTP_FUEL, LSTEP, LFILM, CORNER_SEAL ### src/sim/heat.js (265 lines) Heat: conduction, melting and freezing, reactions, fire and air, steel. @@ -264,7 +264,7 @@ Pipes, pumps and outlets: each joined line holds one liquid as a count, not as g Per-tick machine work: steam engines, fans, belts, filters, screw tubes (machinesTick). - functions: engineIntake, fanBlow, machinesTick -### src/sim/conveyors.js (201 lines) +### src/sim/conveyors.js (202 lines) How belts, filters and screw tubes move what is on or in them. What rests on a belt or filter rides along: the whole stack standing on the carrying surface steps one cell toward d together (belts run front of the line first, so the way ahead is clear). If a powder grain is blocked, the load above it stands still and the belt slips underneath. A filter's own material drops through it instead, unless it is already full. - functions: conveyorStep, carryColumn, holdSlopeColumn, beltDrain, launchGrain, rebuildFilters, filterCarry, carryBeltsAndFilters, setFilter, filterRun, roomFor, tubeSpread, tubeIntake, diagonalTubeNext - variables: CARRY_DEPTH, filterR, filterL, filtersDirty @@ -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 (176 lines) +### src/sim/vacuum.js (179 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 @@ -514,6 +514,7 @@ The frame loop and boot. - `test/t_basics.js`: Basics: drilling to rubble, the vacuum and blowing, walls, the ingot mold and bars, belts, blueprints, save/load. - `test/t_build.js`: Building: undo (Ctrl+Z), cut, replace a machine with Ctrl, pasting ghosts, and the fan heat warning. - `test/t_conveyors.js`: Screw tubes that never carry liquid (it flows through), tube and belt throughput, filters. +- `test/t_corner_seal.js`: Corner-touching square walls seal loose grains without changing free piles or worldgen. - `test/t_diag_tubes.js`: Diagonal screws: sealed four-lane corner transfers, bars, joins and persistence. - `test/t_drill.js`: The resonance drill: pulses from the bot shake rock until it shatters. Widths trade area for speed, tuning shatters only one material and drains it out of the wall, built blocks are left alone, and the drill says why when nothing breaks. - `test/t_econ.js`: Economy: the dock (instant sale), orders, prices, the market dip, money for machines, alloys. diff --git a/docs/agent-log.md b/docs/agent-log.md index 7539540..72c68dc 100644 --- a/docs/agent-log.md +++ b/docs/agent-log.md @@ -4,6 +4,34 @@ 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: t39: seal touching square-block corners (Codex) + +- `cells.js`: six inline checks reject diagonal moves between two PH_SOLID neighbours, with + no new random calls. `worldgen.js` saves/disables/restores CORNER_SEAL alongside LSTEP/LFILM + while settling. No material ids, save fields or piece geometry changed. +- Reproduced two further leaks: free flyers (`vacuum.js`) and diagonal tube mouths pulling sand + between external walls (`conveyors.js`); blocked those steps only. Horizontal liquid scans + already check each intervening cell; vacuum collection removes grains into the bag. Bars + resolve ordinary movement by axis. Screw lane transport remains explicit and unchanged. +- `test/t_corner_seal.js`: 23 checks, including both staircase orientations for sand/water/steam, + all four diagonal intakes, a flyer, world hashes/flag restoration, and an open pile that remains + 57x17 with identical material layout and random state when sealing is disabled. +- All 25 headless suites pass. Before/after outputs: `t_slopes` square-funnel outflow 379 -> 339 + (more sand retained on steps); `t_drill` ore outside the wall 56 -> 55 (solid corners hold ore); + `t_world` awake chunks after 900 gameplay ticks following reload 148 -> 149 (different settling + after corner blocking). Disabling the checks restores drill/world baseline output exactly. + Other existing output is identical except timings. Evidence: /tmp/t39-before, /tmp/t39-after. +- Full mat/temp/flags hashes match original code for seeds 777/77/4242 at 512x384 and 2048x1024, + plus 424242 at 1024x640 (/tmp/t39-world-hashes-{before,after}.txt). +- Factory benchmark at 1024x640: idle 0.69 -> 0.68 ms/tick; 1/4/8 modules + 2.05/2.36/2.65 -> 2.13/2.44/2.70 ms/tick. Timing is indicative: settling changes, and this + fixture reported zero machine-moved grains, so it does not prove loaded transport performance. +- Rebuilt dist and regenerated PROJECT_MAP. Inspected `shots/t39-corners.png` in pixel look: + sand rests along the upper steps and pours through the central outlet. +- Final `python3 test/run_all.py --browser`: ALL PASSED (25 headless suites, 323 play checks, + 15 smooth checks). Updated t39 subtasks/report for Review and recorded the design decision. + No commit made; owner playtest remains. + ## 2026-10-25: t40 art pass: sloped belts and filters drawn as one belt (Claude Code, Codex) - Sloped runs draw as the flat belt (rollers, studs) or filter (tinted mesh) band turned to 45°, diff --git a/src/sim/cells.js b/src/sim/cells.js index ba9bfdd..ba99f20 100644 --- a/src/sim/cells.js +++ b/src/sim/cells.js @@ -67,8 +67,9 @@ let SORTP = 0.06; const SORTP_FUEL = 0.003; // Liquids step up to LSTEP cells toward a drop in one tick (fewer the thicker they are), and a top-layer grain looks LFILM // times as far for one (a two-times range strands the far end of a 200-grain-wide ledge). -// World generation settles with both at 1, so every seed keeps making the same world. +// World generation settles with both at 1 and corner sealing off, so seeds keep the same world. let LSTEP = 8, LFILM = 4; +let CORNER_SEAL = true; function powder(i, x, y, m) { if (y >= H - 1) return; const b = i + W; @@ -82,9 +83,9 @@ function powder(i, x, y, m) { if (rnd() < STICK[m]) { const d = (RS & 2) ? 1 : -1; let nx = x + d; - if (nx >= 0 && nx < W && enter(m, PH_POWDER, b + d, d, 1)) { move(i, b + d, x, y, nx, y + 1); return; } + if (nx >= 0 && nx < W && (!CORNER_SEAL || PH[mat[b]] !== PH_SOLID || PH[mat[i + d]] !== PH_SOLID) && enter(m, PH_POWDER, b + d, d, 1)) { move(i, b + d, x, y, nx, y + 1); return; } nx = x - d; - if (nx >= 0 && nx < W && enter(m, PH_POWDER, b - d, -d, 1)) { move(i, b - d, x, y, nx, y + 1); return; } + if (nx >= 0 && nx < W && (!CORNER_SEAL || PH[mat[b]] !== PH_SOLID || PH[mat[i - d]] !== PH_SOLID) && enter(m, PH_POWDER, b - d, -d, 1)) { move(i, b - d, x, y, nx, y + 1); return; } } } function passable(m, ph, j, dx, dy) { @@ -106,9 +107,9 @@ function liquid(i, x, y, m) { if (enter(m, PH_LIQUID, b, 0, 1)) { move(i, b, x, y, x, y + 1); return; } const d = (RS & 2) ? 1 : -1; let nx = x + d; - if (nx >= 0 && nx < W && enter(m, PH_LIQUID, b + d, d, 1)) { move(i, b + d, x, y, nx, y + 1); return; } + if (nx >= 0 && nx < W && (!CORNER_SEAL || PH[mat[b]] !== PH_SOLID || PH[mat[i + d]] !== PH_SOLID) && enter(m, PH_LIQUID, b + d, d, 1)) { move(i, b + d, x, y, nx, y + 1); return; } nx = x - d; - if (nx >= 0 && nx < W && enter(m, PH_LIQUID, b - d, -d, 1)) { move(i, b - d, x, y, nx, y + 1); return; } + if (nx >= 0 && nx < W && (!CORNER_SEAL || PH[mat[b]] !== PH_SOLID || PH[mat[i - d]] !== PH_SOLID) && enter(m, PH_LIQUID, b - d, -d, 1)) { move(i, b - d, x, y, nx, y + 1); return; } } if (rnd() > FLUID[m]) return; const sp = SPREAD[m] * (y === 0 || PH[mat[i - W]] !== PH_LIQUID ? LFILM : 1); @@ -153,9 +154,9 @@ function gas(i, x, y, m) { if (enter(m, PH_GAS, u, 0, -1)) { move(i, u, x, y, x, y - 1); return; } const d = (RS & 2) ? 1 : -1; let nx = x + d; - if (nx >= 0 && nx < W && enter(m, PH_GAS, u + d, d, -1)) { move(i, u + d, x, y, nx, y - 1); return; } + if (nx >= 0 && nx < W && (!CORNER_SEAL || PH[mat[u]] !== PH_SOLID || PH[mat[i + d]] !== PH_SOLID) && enter(m, PH_GAS, u + d, d, -1)) { move(i, u + d, x, y, nx, y - 1); return; } nx = x - d; - if (nx >= 0 && nx < W && enter(m, PH_GAS, u - d, -d, -1)) { move(i, u - d, x, y, nx, y - 1); return; } + if (nx >= 0 && nx < W && (!CORNER_SEAL || PH[mat[u]] !== PH_SOLID || PH[mat[i - d]] !== PH_SOLID) && enter(m, PH_GAS, u - d, -d, -1)) { move(i, u - d, x, y, nx, y - 1); return; } if (rnd() < 0.6) { nx = x + d; if (nx >= 0 && nx < W && mat[i + d] === EMPTY && passable(m, PH_GAS, i + d, d, 0)) { move(i, i + d, x, y, nx, y); } diff --git a/src/sim/conveyors.js b/src/sim/conveyors.js index 637e4b9..a9c9b88 100644 --- a/src/sim/conveyors.js +++ b/src/sim/conveyors.js @@ -169,6 +169,7 @@ function tubeIntake(i, p, dx, dy) { const oy = diagonal ? (k === 0 ? 0 : -dy) : k === 0 ? -dy : k === 1 ? dx : -dx; const nx = x + ox, ny = y + oy; if (nx < 0 || ny < 0 || nx >= W || ny >= H) continue; + if (ox && oy && PH[mat[y * W + nx]] === PH_SOLID && PH[mat[ny * W + x]] === PH_SOLID) continue; const j = ny * W + nx, m = mat[j]; if (m === EMPTY || occ[j] || stamp[j] === tickStamp) continue; if (PH[m] !== PH_POWDER) continue; // the screw only takes loose material; liquids just flow through diff --git a/src/sim/vacuum.js b/src/sim/vacuum.js index c8e993d..f4e055e 100644 --- a/src/sim/vacuum.js +++ b/src/sim/vacuum.js @@ -146,7 +146,10 @@ function flyTick() { if (cx < 0 || cx >= W) { f.vx = 0; continue; } // the world's edge stops it; it drops from there if (cy >= H) { landFlyer(f, px, H - 1); done = true; break; } if (cy < 0) { f.x = nx; f.y = ny; continue; } - const open = flyOpen(f.m, cy * W + cx); + // A free flyer must not cut across the point where two solid cells meet. + const sealed = cx !== px && cy !== py && px >= 0 && px < W && py >= 0 && py < H && + PH[mat[py * W + cx]] === PH_SOLID && PH[mat[cy * W + px]] === PH_SOLID; + const open = !sealed && flyOpen(f.m, cy * W + cx); if (!f.free) { // still inside whatever the bot is flying through: pass until the first open cell // a blocked nozzle spills: the grain backs up into the nearest open space diff --git a/src/sim/worldgen.js b/src/sim/worldgen.js index dc194b9..876d274 100644 --- a/src/sim/worldgen.js +++ b/src/sim/worldgen.js @@ -245,7 +245,7 @@ function* worldSteps(seedIn, w, h) { yield [0.75, 'Settling loose ground']; // settle loose material at the water and sorting speeds the seeds were made with awake.fill(3); - const lstep = LSTEP, lfilm = LFILM, sortp = SORTP; LSTEP = LFILM = 1; SORTP = 0.003; + const lstep = LSTEP, lfilm = LFILM, sortp = SORTP, cornerSeal = CORNER_SEAL; LSTEP = LFILM = 1; SORTP = 0.003; CORNER_SEAL = false; for (let k = 0; k < 260; k++) { tick++; tickStamp = (tickStamp % 255) + 1; simMove(); @@ -253,7 +253,7 @@ function* worldSteps(seedIn, w, h) { if (!any) break; if ((k & 31) === 0) yield [0.75 + 0.24 * k / 260, 'Settling loose ground']; } - LSTEP = lstep; LFILM = lfilm; SORTP = sortp; + LSTEP = lstep; LFILM = lfilm; SORTP = sortp; CORNER_SEAL = cornerSeal; yield [0.99, 'Finishing']; // re-ambient after settling, then start clean for (let i = 0; i < N; i++) { const m = mat[i]; if (m !== MAGMA) temp[i] = AMB[(i / w) | 0]; life[i] = PH[m] === PH_LIQUID && FRZ[m] ? 255 : 0; } diff --git a/test/t_corner_seal.js b/test/t_corner_seal.js new file mode 100644 index 0000000..986d0ed --- /dev/null +++ b/test/t_corner_seal.js @@ -0,0 +1,75 @@ +// Corner-touching square walls seal loose grains without changing free piles or worldgen. +const { load, suite, flatWorld, rect, count, ticks } = require('./harness'); +const { createHash } = require('node:crypto'); +const { run } = load(); +const { check, done } = suite(); + +for (const [seed, expected] of [ + [777, '040644fb50c891911383ef0206ea988677d7ec447a3e6637cefa99364e765861'], + [77, '2e6af816af54c8de4fe9c7188607f7af503ef844b2087245779bcf39e8330bdb'], + [4242, 'f09270f4ac5da9347bc277c9959e31ee797313b4a994598b4647dcf852532782'], +]) { + run(`generateWorld(${seed}, 512, 384)`); + check(`seed ${seed} restores corner sealing`,run('CORNER_SEAL')===true); + check(`seed ${seed} keeps its pre-seal material hash`, createHash('sha256').update(run('mat')).digest('hex') === expected); +} + +for (const material of ['SAND', 'WATER', 'STEAM']) for (const mirror of [false, true]) { + flatWorld(run, 160, 256, 224); + // Span the whole world so escaping an end cannot be confused with crossing a corner. + run(`RS=12345; AMB.fill(20); temp.fill(20); weather.skyY=-1000; + for(let bx=0;bx<40;bx++) placeBlock(BP_WALL,RSTONE,bx,${mirror ? '47-bx' : '8+bx'},true);`); + const gas = material === 'STEAM'; + rect(run, 70, gas ? 166 : 20, 89, gas ? 170 : 24, run(material)); + if (gas) { + // Isolate gas movement from cooling/condensation, including chunk sleeping. + run(`temp.fill(120); var gasLeaks=0; for(let k=0;k<500;k++){tick++;tickStamp=tickStamp%255+1;simMove(); + for(let y=0;y>2)' : '8+(x>>2)'})*4) gasLeaks++;}`); + } else ticks(run, 500); + const result = run(`(()=>{ + let leaked=0,held=0,x0=W,x1=0,y0=H,y1=0; + for(let y=0;y>2)' : '8+(x>>2)'})*4; + if(${gas ? 'y=top+4'}) leaked++; else held++; + x0=Math.min(x0,x);x1=Math.max(x1,x);y0=Math.min(y0,y);y1=Math.max(y1,y); + } + return {leaked,held,width:x1-x0+1,height:y1-y0+1}; + })()`); + check(`${material} stays ${gas ? 'under' : 'above'} ${mirror ? 'rising' : 'descending'} square stairs`, result.leaked === 0 && result.held === 100 && (!gas || run('gasLeaks')===0), JSON.stringify(result)+(gas ? ` crossings ${run('gasLeaks')}` : '')); + if (!gas) check(`${material} piles and slides along the steps`, result.height > 1 && count(run,run(material),70,0,89,223)<100, JSON.stringify(result)); +} + +// A screw mouth must not draw sand through two external wall cells either. +for (const direction of [4,5,6,7]) { + flatWorld(run,160,160,140); + run(`placeBlock(BP_TUBE,${direction},20,20,true); + var dx=TDX[TUBE_PARTS[${direction}]],dy=TDY[TUBE_PARTS[${direction}]]; + var x=dx>0?80:83,y=dy>0?80:83; + setCell((y-dy)*W+x-dx,SAND,20,0); + setCell((y-dy)*W+x,STONE,20,0);setCell(y*W+x-dx,STONE,20,0); + tubeIntake(y*W+x,part[y*W+x],dx,dy);`); + check(`diagonal tube ${direction} cannot pull through a solid corner`,run('mat[(y-dy)*W+x-dx]===SAND')); +} + +// A free flyer can take the same diagonal shortcut between wall cells. +flatWorld(run, 160, 160, 140); +run(`setCell(79*W+80,STONE,20,0);setCell(80*W+79,STONE,20,0); + flyers=[{x:79.5,y:79.5,vx:1,vy:1,age:0,m:SAND,T:20,free:true,run:0}];flyTick();`); +check('a flying grain cannot cross a sealed corner', run('flyers.length===0 && mat[79*W+79]===SAND')); + +flatWorld(run, 240, 160, 120); +run('RS=12345;'); +rect(run, 110, 40, 129, 59, run('SAND')); +ticks(run, 600); +const pile = run(`(()=>{let x0=W,x1=0,y0=H,y1=0; + for(let y=0;y=55 && pile[0]<=59 && pile[1]>=16 && pile[1]<=18 && count(run,run('SAND'))===400, pile); +const sealedPile = createHash('sha256').update(run('mat')).digest('hex'), sealedRS=run('RS'); +flatWorld(run,240,160,120); +run('RS=12345; CORNER_SEAL=false;'); +rect(run,110,40,129,59,run('SAND'));ticks(run,600); +check('open pile and random sequence exactly match unsealed movement', createHash('sha256').update(run('mat')).digest('hex')===sealedPile && run('RS')===sealedRS); +run('CORNER_SEAL=true;'); +done();