Level lakes at the end of world generation, keeping their water (t96)

Volume-preserving levelling pass for water bodies of 50+ cells; the dock foundation is placed
first so it cannot erase levelled water. Seed hashes re-baselined (water and air only).
Started by Codex, finished and reviewed by Claude after Codex hit its usage limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-29 19:28:23 -04:00
co-authored by Claude Opus 5.5
parent 75d4e8ee4e
commit ae78756fa2
10 changed files with 227 additions and 50 deletions
+9 -6
View File
@@ -590,12 +590,15 @@ above, including:
ordinary repose rules take over. Keep stamped moves, barriers, burning-bed protection and ordinary repose rules take over. Keep stamped moves, barriers, burning-bed protection and
worker independence. The existing worldgen POWDER_FAN switch disables new face releases. worker independence. The existing worldgen POWDER_FAN switch disables new face releases.
- **New-world lakes wake immediately; keep the seed layout (t96, 2026-10-29).** Keep the - **Level lakes during world generation (t96, 2026-10-29).** The owner wants new worlds
recorded worldgen settling speeds and material/temperature output. Wake water after the to start with flat lakes instead of waiting 80–125 seconds for them to flow level. Changed
final startup reset so unfinished lakes level without drilling. Retain tree candidates and generated worlds are accepted: seeds no longer reproduce the old water layout. After the
their random draws, then omit whole trees whose bases are wet or on an open bank connected existing settling loop, level four-connected water bodies of at least 50 grains, preserving
to water at base height after settling. This keeps trees out of the lake as it spreads. their exact volume. Fill reachable air/water from the bottom upward, respecting solid/powder
Existing load wake scheduling and later snowmelt/flooding rules stay as they are. walls and dry rims; place a partial top row from the middle outward. Establish the dock's
existing foundation first so it cannot erase newly levelled water. Keep the pinned settling
values, startup water wakes, and round-1 wet/water-connected-bank tree filtering. Existing
load wakes and later snowmelt/flooding rules stay as they are.
## Ideas not built yet ## Ideas not built yet
+10 -8
View File
@@ -322,9 +322,9 @@ Bars and other loose objects (ents), and the bag: adding, taking, costs and refu
- functions: entSolidCell, entSupportCount, entFreeAt, entMark, spawnBar, removeEnt, entScrewAt, entScrewFree, entScrewMove, entScrewLane, entScrewEdge, entScrewEnter, entScrewTick, entDiagonalScrewTick, entBeltScrewEnter, entSlopeBeltTick, entTick, entsInCircle, bagAdd, bagTake, giveOrDrop, refundAt, canAfford, payCost, fmtMoney, costText, needText - functions: entSolidCell, entSupportCount, entFreeAt, entMark, spawnBar, removeEnt, entScrewAt, entScrewFree, entScrewMove, entScrewLane, entScrewEdge, entScrewEnter, entScrewTick, entDiagonalScrewTick, entBeltScrewEnter, entSlopeBeltTick, entTick, entsInCircle, bagAdd, bagTake, giveOrDrop, refundAt, canAfford, payCost, fmtMoney, costText, needText
- variables: BAR_W, BAR_H, entCurM, droppedLast - variables: BAR_W, BAR_H, entCurM, droppedLast
### src/sim/tools.js (65 lines) ### src/sim/tools.js (30 lines)
Tool helpers: reach, brushes, and plain digging. Tool helpers: reach, brushes, and plain digging.
- functions: setBuildRange, inBuildRange, inReach, forBrush, drillReach, inDrillReach, toolDig - functions: setBuildRange, inBuildRange, inReach, forBrush, drillReach, inDrillReach
- variables: BUILD_MAX, buildBox - variables: BUILD_MAX, buildBox
### src/sim/drill.js (183 lines) ### src/sim/drill.js (183 lines)
@@ -378,12 +378,12 @@ The same chunk schedule runs inline or in workers. Workers only own a pass at a
simTick: one simulation step, in order. simTick: one simulation step, in order.
- functions: simTick - functions: simTick
### src/sim/worldgen-helpers.js (61 lines) ### src/sim/worldgen-helpers.js (131 lines)
World generation helpers: noise and removal of tiny hard rock specks. World generation helpers: noise and removal of tiny hard rock specks.
- functions: makeNoise, fbm, clearHardSpecks - functions: makeNoise, fbm, clearHardSpecks, levelWorldWater
- variables: SPECK_MIN - variables: SPECK_MIN
### src/sim/worldgen.js (303 lines) ### src/sim/worldgen.js (305 lines)
World generation: staged terrain, ore, caves and placement, with synchronous and async entry points. World generation: staged terrain, ore, caves and placement, with synchronous and async entry points.
- functions: generateWorld, generateWorldAsync - functions: generateWorld, generateWorldAsync
@@ -397,9 +397,9 @@ Saving and loading a world (serializeWorld / deserializeWorld) and old-save migr
- functions: serializeWorld, deserializeWorld, startLoadWake, wakeLoadArea, wakeLoadNearBot, loadWakeTick - functions: serializeWorld, deserializeWorld, startLoadWake, wakeLoadArea, wakeLoadNearBot, loadWakeTick
- variables: SAVE_VERSION, TOFF - variables: SAVE_VERSION, TOFF
### src/render/setup.js (190 lines) ### src/render/setup.js (188 lines)
Drawing (src/render/): browser only. This file: the renderer state R, colour tables (glow, thermal palettes, pipes, trees, sky), canvas setup and the camera. Drawing (src/render/): browser only. This file: the renderer state R, colour tables (glow, thermal palettes, pipes, trees, sky), canvas setup and the camera.
- functions: glowIndex, thermIdx, pipeCellColor, pipeBodyCell, thinPipeColor, beltLike, buildSky, initRender, resizeRender, viewCells, updateCamera, snapCamera, screenToWorld, worldToScreenPx, renderMonoFont - functions: glowIndex, thermIdx, pipeCellColor, pipeBodyCell, thinPipeColor, buildSky, initRender, resizeRender, viewCells, updateCamera, snapCamera, screenToWorld, worldToScreenPx, renderMonoFont
- variables: R, GLOW_W, GLOW_R, GLOW_G, GLOW_B, THERM, PC, TREE_R, TREE_G, TREE_B, BG, SKY_R, SKY_G, SKY_B - variables: R, GLOW_W, GLOW_R, GLOW_G, GLOW_B, THERM, PC, TREE_R, TREE_G, TREE_B, BG, SKY_R, SKY_G, SKY_B
### src/render/slope-belts.js (167 lines) ### src/render/slope-belts.js (167 lines)
@@ -664,7 +664,9 @@ The frame loop and boot.
- `test/t_gpu_light.py`: The former CPU light behavioural checks, now read from the actual GPU light grid. - `test/t_gpu_light.py`: The former CPU light behavioural checks, now read from the actual GPU light grid.
- `test/t_import_save.js`: Import must preserve the running world before reading or replacing it. - `test/t_import_save.js`: Import must preserve the running world before reading or replacing it.
- `test/t_joins.js`: The oracle states the geometric rule independently of the precomputed neighbour tables. - `test/t_joins.js`: The oracle states the geometric rule independently of the precomputed neighbour tables.
- `test/t_lakes.js`: Generated lakes wake without a tool, settle on their own, and have dry tree bases. - `test/t_lakes.py`: Pixel startup and fresh/reloaded flat lakes after 600 ticks; shots/t96-level.png.
- `test/t_lakes.js`: Generated lakes start flat, conserve water, and have dry tree bases.
- `test/t_lakes.py`: t96: flat generated lakes, fresh and reloaded, in the real pixel frame loop.
- `test/t_load.js`: Reload wakes nearby areas first, without losing distant water, fire or saved state. - `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_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_nozzle.js`: Blowing aims within the nozzle's reach, with room for the brush and settling.
+8 -3
View File
@@ -76,9 +76,14 @@ The conveyor fixture uses a wider feed pile, the screen fixture warms longer, an
checks waking within eight ticks separately from the probabilistic consumption of fuel. checks waking within eight ticks separately from the probabilistic consumption of fuel.
`test/t_lakes.js` pins SHA-256 over raw `mat` followed by Float32 `temp` bytes for seeds `test/t_lakes.js` pins SHA-256 over raw `mat` followed by Float32 `temp` bytes for seeds
7, 42 and 424242 at 2048x1024. These hashes predate t96 and must not be re-baselined for a 7, 42 and 424242 at 2048x1024. t96 round 2 re-baselines only for the lake-levelling pass;
wake/background-tree change. It also checks generated tree bases, startup wakes, and the `t_corner_seal.js` also pins three smaller worlds with the same intentional change. The lake
seed-7 lake leveling within two grains after 6,000 undisturbed ticks, fresh and reloaded. suite checks all five seeds (also 2 and 1): surface pools wider than 20 grains start within
one grain of level and stay so after 600 ticks, exact WATER count from the pre-pass 0.99
progress callback, dry tree bases and startup wakes. Small fixtures cover solid/powder rims,
volume, centered partial rows, the 50-grain cutoff and diagonal separation.
`python3 test/t_lakes.py` checks pixel startup and fresh/reloaded lakes after 600 ticks with
two browser helpers and no page errors; `shots/t96-level.png` shows the untouched new lake.
`test/t_bot_rescue.js` covers deep basalt burial, nearest/upward and diagonal nudges, `test/t_bot_rescue.js` covers deep basalt burial, nearest/upward and diagonal nudges,
unexplored-pocket rejection, newest open trail points, ring wrap/load reset, bounded searches, unexplored-pocket rejection, newest open trail points, ring wrap/load reset, bounded searches,
+20
View File
@@ -4,6 +4,26 @@ 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 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. open or not verified. Keep each entry short; the git history has the details.
## 2026-10-29: t96 round 2 — level lakes during generation (Codex)
- Level 50+ grain water bodies with typed-array flood fills, exact volume and centered partial
rows; keep settling switches, startup wakes and tree rules. Establish the existing dock
foundation first (otherwise seed 42 loses 39 grains). Recorded the owner's changed-seed decision.
- Files: src/sim/worldgen{,-helpers}.js, test/t_lakes.{js,py}, test/t_corner_seal.js,
docs/{DESIGN,TESTING,PROJECT_MAP}.md, tasks/t96* and this log.
- Eight regressions failed first; all 40 headless suites pass (59 lake checks). Six world hashes
re-baselined only for WATER/EMPTY redistribution; bypassing the pass restores all six originals.
Pixel browser fresh/reload seed 7 at 600 ticks passes with two helpers and no page errors.
Inspected shots/t96-level.png: flat lake, centered one-grain top row, dry bank trees, dock at right.
- Paired generation medians before→after (ms), seeds 7/42/424242: 871.09→903.88,
921.47→981.33, 1067.50→1119.16. Pass medians 13.67/22.17/15.27 ms; max 24.05
measured / 41.22 cold. Evidence: /tmp/t96-round2-{red,headless-final,browser,paired-times}.log.
Review pending; no full browser suite, no commit.
- Claude finished the run after Codex hit its usage limit: registered test/t_lakes.py in run_all.py's
browser list; full `run_all.py --browser` passed except the known flaky t_smooth.py (t99), which
passed 3/3 reruns. Found that 4 of 12 seeds' levelled lakes now reach the trade dock's wall
(none enter it); cause is the lake lip using the highest rim point (see t96).
## 2026-10-29: t69 hot-load warning, visible damage and build-over repair (Codex) ## 2026-10-29: t69 hot-load warning, visible damage and build-over repair (Codex)
- Added persistent scorched/fractured screw overlays in both looks, 900°C load warnings, - Added persistent scorched/fractured screw overlays in both looks, 900°C load warnings,
+71 -1
View File
@@ -1,4 +1,4 @@
// World generation helpers: noise and removal of tiny hard rock specks. // World generation helpers: noise, hard rock specks and lake levelling.
// ---------- Perlin noise ---------- // ---------- Perlin noise ----------
function makeNoise(rng) { function makeNoise(rng) {
const perm = new Uint8Array(512), gx = new Float32Array(256), gy = new Float32Array(256); const perm = new Uint8Array(512), gx = new Float32Array(256), gy = new Float32Array(256);
@@ -59,3 +59,73 @@ function clearHardSpecks(live) {
} }
return n; return n;
} }
// Grow each basin upward from its bottom: dry walls/rims are crossed only if filled to them.
function levelWorldWater() {
const seen = new Uint8Array(N), region = new Uint32Array(N), cells = new Int32Array(N), stack = new Int32Array(N);
let tag = 0;
for (let start = 0; start < N; start++) {
if (mat[start] !== WATER || seen[start]) continue;
let size = 0, sp = 1, bottom = start;
stack[0] = start; seen[start] = 1;
// Measure the original four-connected body before moving any of its water.
while (sp) {
const i = stack[--sp], x = i % W, y = (i / W) | 0;
cells[size++] = i;
if (i > bottom) bottom = i;
for (let d = 0; d < 4; d++) {
if ((d === 0 && !x) || (d === 1 && x === W - 1) || (d === 2 && !y) || (d === 3 && y === H - 1)) continue;
const j = i + (d === 0 ? -1 : d === 1 ? 1 : d === 2 ? -W : W);
if (mat[j] === WATER && !seen[j]) { seen[j] = 1; stack[sp++] = j; }
}
}
if (size < 50) continue;
let volume = size;
for (let k = 0; k < size; k++) { const i = cells[k]; mat[i] = EMPTY; flags[i] = 0; }
tag++; size = sp = 1; cells[0] = stack[0] = bottom; region[bottom] = tag;
for (let level = (bottom / W) | 0; level >= 0; level--) {
if (!sp && level < H - 1) {
for (let x = 0; x < W; x++) {
const i = level * W + x;
if (region[i + W] === tag && (mat[i] === EMPTY || mat[i] === WATER)) {
region[i] = tag; cells[size++] = i; stack[sp++] = i;
}
}
}
// At this height, fill every connected low space before rising another row.
while (sp) {
const i = stack[--sp], x = i % W, y = (i / W) | 0;
if (mat[i] === WATER) { volume++; seen[i] = 1; }
for (let d = 0; d < 4; d++) {
if ((d === 0 && !x) || (d === 1 && x === W - 1) || (d === 2 && y <= level) || (d === 3 && y === H - 1)) continue;
const j = i + (d === 0 ? -1 : d === 1 ? 1 : d === 2 ? -W : W);
if (region[j] === tag || (mat[j] !== EMPTY && mat[j] !== WATER)) continue;
region[j] = tag; cells[size++] = j; stack[sp++] = j;
}
}
if (size >= volume) break;
}
const body = cells.subarray(0, size);
body.sort(); // row order, with no objects allocated per grain
for (let k = 0; k < size; k++) { const i = cells[k]; mat[i] = EMPTY; flags[i] = 0; seen[i] = 1; }
for (let end = size; end > 0 && volume > 0;) {
const y = (cells[end - 1] / W) | 0;
let begin = end - 1;
while (begin > 0 && ((cells[begin - 1] / W) | 0) === y) begin--;
const width = end - begin;
if (volume >= width) {
for (let k = begin; k < end; k++) { mat[cells[k]] = WATER; flags[cells[k]] = F_NAT; }
volume -= width;
} else {
const left = cells[begin] % W, right = cells[end - 1] % W, mid = (left + right) >> 1;
for (let d = 0; volume > 0; d++) {
const x = mid + (d & 1 ? (d + 1) >> 1 : -(d >> 1));
if (x < left || x > right) continue;
const i = y * W + x;
if (region[i] === tag) { mat[i] = WATER; flags[i] = F_NAT; volume--; }
}
}
end = begin;
}
}
}
+4 -2
View File
@@ -256,7 +256,10 @@ function* worldSteps(seedIn, w, h) {
if ((k & 31) === 0) yield [0.75 + 0.24 * k / 260, 'Settling loose ground']; if ((k & 31) === 0) yield [0.75 + 0.24 * k / 260, 'Settling loose ground'];
} }
LSTEP = lstep; LFILM = lfilm; SORTP = sortp; CORNER_SEAL = cornerSeal; POWDER_FAN = powderFan; LSTEP = lstep; LFILM = lfilm; SORTP = sortp; CORNER_SEAL = cornerSeal; POWDER_FAN = powderFan;
// Establish the dock's ground before levelling, so its foundation cannot erase water.
placeDock(spawnX + 34);
yield [0.99, 'Finishing']; yield [0.99, 'Finishing'];
levelWorldWater();
// re-ambient after settling, then start clean // 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; } 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; }
tick = 0; tick = 0;
@@ -274,9 +277,8 @@ function* worldSteps(seedIn, w, h) {
if (!wet) for (let k = 0; k < tree.cells.length; k += 2) bgm[tree.cells[k]] = tree.cells[k + 1]; if (!wet) for (let k = 0; k < tree.cells.length; k += 2) bgm[tree.cells[k]] = tree.cells[k + 1];
} }
findTreeBases(); findTreeBases();
placeDock(spawnX + 34);
hot.fill(0); awake.fill(0); updateRoof(); hot.fill(0); awake.fill(0); updateRoof();
// Keep the generated grains intact, but let unfinished lakes level from the first tick. // Wake generated water so it responds from the first tick.
for (let i = 0; i < N; i++) if (mat[i] === WATER) wakeIdx(i); for (let i = 0; i < N; i++) if (mat[i] === WATER) wakeIdx(i);
resetEconomy(); resetWeather(); resetEconomy(); resetWeather();
weather.base = worldWater(); weather.base = worldWater();
+1 -1
View File
@@ -45,7 +45,7 @@ def main():
if browser: if browser:
subprocess.run([sys.executable, str(ROOT / 'build.py')], cwd=ROOT, check=True) subprocess.run([sys.executable, str(ROOT / 'build.py')], cwd=ROOT, check=True)
# one at a time: two headless browsers at once can starve each other and flake # one at a time: two headless browsers at once can starve each other and flake
for name in ['play.py', 't_smooth.py', 't_dev_console.py', 't_caps.py', 't_gpu.py', 't_vacuum_collapse.py', 't_blow_collapse.py', 't_gpu_golden.py', 't_gpu_light.py', 'firefox_smoke.py']: for name in ['play.py', 't_smooth.py', 't_dev_console.py', 't_caps.py', 't_gpu.py', 't_vacuum_collapse.py', 't_blow_collapse.py', 't_lakes.py', 't_gpu_golden.py', 't_gpu_light.py', 'firefox_smoke.py']:
results.append(run_py(name)) results.append(run_py(name))
bad = 0 bad = 0
for name, code, out, secs in results: for name, code, out, secs in results:
+4 -3
View File
@@ -4,10 +4,11 @@ const { createHash } = require('node:crypto');
const { run } = load(); const { run } = load();
const { check, done } = suite(); const { check, done } = suite();
// t96 re-baseline: only the generation lake-levelling pass changes these material bytes.
for (const [seed, expected] of [ for (const [seed, expected] of [
[777, 'b6f7b493e93a59e38ee57d33f24690daa87e7be0f4b356b32225a3a6f1a9d001'], [777, '1fac6b4b6420f328d7de87c0813e328303271e7605d85ce541e108361aff0d90'],
[77, '5e08c24603686c8617dd1156b10612b91a05fd9ee8b3648dfc4ee32fc008383a'], [77, 'de3ec38547585486924f53abb82cb3c0f76e0bd24b75873ddf96599b5886b52a'],
[4242, 'f7ca75d19eba9eae4894b21b95fe1b683bd54e1f65a415dcc296f3c23b5d7e05'], [4242, '57cd89b5518c3e342fc1b91243cd068dc2e38a2d42de84a1c2ee1cbf936c2f1a'],
]) { ]) {
run(`generateWorld(${seed}, 512, 384)`); run(`generateWorld(${seed}, 512, 384)`);
check(`seed ${seed} restores corner sealing`,run('CORNER_SEAL')===true); check(`seed ${seed} restores corner sealing`,run('CORNER_SEAL')===true);
+44 -25
View File
@@ -1,9 +1,9 @@
// Generated lakes wake without a tool, settle on their own, and have dry tree bases. // Generated lakes start flat, conserve water, and have dry tree bases.
const { load, suite, ticks } = require('./harness'); const { load, suite, ticks, count, rect } = require('./harness');
const { createHash } = require('node:crypto'); const { createHash } = require('node:crypto');
const { run } = load(); const { run } = load();
const { check, done } = suite(); const { check, done } = suite();
const water = run('WATER');
function surfacePools() { function surfacePools() {
return run(`(() => { return run(`(() => {
const pools = []; let pool = null; const pools = []; let pool = null;
@@ -17,35 +17,54 @@ function surfacePools() {
})()`); })()`);
} }
for (const [seed, expected] of [ for (const [seed, expected] of [
[7, '865ac320f743ff5ed573acab3333773b5ebc2e10f15600438dd15f1767acb72e'], [7, '669a4ee87496be27473d2987d9a8935be1a19b29a8f2433d306f32a1616f3bca'],
[42, '94930e108f7bd948c6f3dbe8b15b2950eed695cb3d57376d05a8ad781c2015b7'], [42, 'efeadd2bff0ce3eaf81e5a08a998ce767e9667e3b27b8623e928f202669f640e'],
[424242, '8b8a4b44c2cc98d3ab86515eb55f38ce7cb3d6a283e29e0ed758af937ea8e8c8'], [424242, '24b5e6ccc4e7d5eb9d54c3ff0fa5dfe84185b83e75b07261ac007b4f920f0640'],
[2, null],
[1, null],
]) { ]) {
run(`generateWorld(${seed}, 2048, 1024); weather.next = 1e15;`); run(`generateWorld(${seed}, 2048, 1024, (f, label) => {
if (f === 0.99 && label === 'Finishing') globalThis.waterBeforeLevel = mat.reduce((n, m) => n + (m === WATER), 0);
}); weather.next = 1e15;`);
const hash = createHash('sha256'); const hash = createHash('sha256');
for (const name of ['mat', 'temp']) hash.update(Buffer.from(run(name).buffer)); for (const name of ['mat', 'temp']) hash.update(Buffer.from(run(name).buffer));
check(`seed ${seed} preserves generated material and temperature bytes`, hash.digest('hex') === expected); const actual = hash.digest('hex');
if (expected) check(`seed ${seed} matches levelled generation hash`, actual === expected, actual);
check(`seed ${seed} conserves water through levelling`, run('mat.reduce((n, m) => n + (m === WATER), 0) === waterBeforeLevel'));
const initial = surfacePools();
check(`seed ${seed} starts with flat surface pools`, initial.length > 0 && initial.every(p => p.max - p.min <= 1), JSON.stringify(initial));
check(`seed ${seed} restores pinned generation switches`, run('LSTEP === 8 && LFILM === 4 && SORTP === 0.06 && CORNER_SEAL && POWDER_FAN')); check(`seed ${seed} restores pinned generation switches`, run('LSTEP === 8 && LFILM === 4 && SORTP === 0.06 && CORNER_SEAL && POWDER_FAN'));
check(`seed ${seed} starts every water area awake`, run(`mat.every((m, i) => m !== WATER || awake[((i / W) >> 5) * CW + ((i % W) >> 5)] > 0)`)); check(`seed ${seed} starts every water area awake`, run(`mat.every((m, i) => m !== WATER || awake[((i / W) >> 5) * CW + ((i % W) >> 5)] > 0)`));
check(`seed ${seed} has no submerged tree base`, run('treeBases.every(i => !treeHeld(i) || mat[i] !== WATER)')); check(`seed ${seed} has no submerged tree base`, run('treeBases.every(i => !treeHeld(i) || mat[i] !== WATER)'));
check(`seed ${seed} retains dry trees`, run('treeBases.filter(i => treeHeld(i)).length > 20')); check(`seed ${seed} retains dry trees`, run('treeBases.filter(i => treeHeld(i)).length > 20'));
// Seed 7 reproduces the owner's long slope. No drill, forced wakes, or terrain edits. ticks(run, 600);
if (seed === 7) { const settled = surfacePools();
run('globalThis.lakeSave = serializeWorld();'); check(`seed ${seed} stays flat after 600 ticks`, settled.length > 0 && settled.every(p => p.max - p.min <= 1), JSON.stringify(settled));
for (const state of ['fresh', 'reloaded']) { for (const lake of initial) check(`seed ${seed} original lake retains dry roots`, run(`treeBases.every(i => i % W < ${lake.x0} || i % W > ${lake.x1} || !treeHeld(i) || mat[i] !== WATER)`));
if (state === 'reloaded') run('deserializeWorld(lakeSave.meta, lakeSave.buffers); weather.next = 1e15;');
check(`${state} world starts with dry tree bases`, run('treeBases.every(i => !treeHeld(i) || mat[i] !== WATER)'));
const before = run('mat.slice(270 * W, 340 * W)');
const wakeTicks = state === 'fresh' ? 1 : 171;
ticks(run, wakeTicks);
check(`${state} lake moves within its startup wake window`, run('mat.subarray(270 * W, 340 * W)').some((m, i) => m !== before[i] && (m === water || before[i] === water)));
ticks(run, 6000 - wakeTicks);
const pools = surfacePools();
check(`${state} lake levels within two grains without disturbance`, pools.length > 0 && pools.every(p => p.max - p.min <= 2), JSON.stringify(pools));
// Reload can also thaw mountain snow into new puddles; check the original lake here.
const lake = pools.reduce((a, b) => a.width > b.width ? a : b);
check(`${state} original lake has no submerged tree base after settling`, run(`treeBases.every(i => i % W < ${lake.x0} || i % W > ${lake.x1} || !treeHeld(i) || mat[i] !== WATER)`));
}
} }
// A low rim with a much deeper empty basin beyond it must not steal the water.
for (const wall of ['STONE', 'SAND']) {
run('alloc(64, 64); mat.fill(STONE);');
rect(run, 5, 10, 24, 29, run('EMPTY'));
rect(run, 26, 10, 55, 45, run('EMPTY'));
rect(run, 25, 22, 25, 45, run(wall));
rect(run, 25, 10, 25, 21, run('EMPTY'));
rect(run, 5, 20, 14, 29, run('WATER'));
rect(run, 15, 27, 15, 29, run('WATER'));
run('globalThis.beforeLake = mat.slice(); levelWorldWater();');
check(`${wall} rim holds all 103 grains`, count(run, run('WATER')) === 103 && count(run, run('WATER'), 26, 0) === 0);
check(`${wall} basin fills from bottom upward`, count(run, run('WATER'), 5, 25, 24, 29) === 100);
check(`${wall} partial top row fills from the middle outward`, run('[13,14,15].every(x => mat[24 * W + x] === WATER)') && count(run, run('WATER'), 0, 0, 63, 24) === 3);
check(`${wall} levelling changes only water and air`, run('mat.every((m,i) => m === beforeLake[i] || ((m === EMPTY || m === WATER) && (beforeLake[i] === EMPTY || beforeLake[i] === WATER)))'));
} }
// Diagonally touching small bodies remain separate; exactly 50 grains is eligible.
run('alloc(64,64); mat.fill(STONE);');
rect(run, 5, 20, 11, 26, run('WATER'));
rect(run, 12, 27, 18, 33, run('WATER'));
rect(run, 5, 10, 11, 19, run('EMPTY'));
rect(run, 30, 10, 49, 29, run('EMPTY'));
rect(run, 30, 20, 34, 29, run('WATER'));
run('globalThis.smallBefore = mat.slice(); levelWorldWater();');
check('diagonally touching 49-grain pools are left intact', run('mat.every((m,i) => i % W >= 30 || m === smallBefore[i])'));
check('50-grain body levels and conserves volume', count(run, run('WATER'), 30, 28, 49, 29) === 40 && count(run, run('WATER'), 30, 0) === 50);
done(); done();
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env python3
"""t96: flat generated lakes, fresh and reloaded, in the real pixel frame loop."""
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / 'tools'))
from use_venv import ensure_playwright
ensure_playwright()
from browser import launch
errors = []
with launch(viewport={'width':1280,'height':760}) as (_, pg, url):
pg.on('pageerror', lambda e: errors.append(str(e)))
pg.goto(url)
pg.evaluate('CAPS_CHECK')
pg.evaluate('''() => { SET.workerCap=2; SET.fog=false; SET.look='pixel'; SET.autosave=0; SET.cameraLead=false;
const enter=enterGame; enterGame=function(){enter();GAME.paused=true;}; }''')
pg.evaluate('startNewWorld(7,2048,1024)')
pg.wait_for_function('GAME.running',timeout=60000)
pg.evaluate('''() => { weather.next=1e15; SET.zoom=3; resizeRender(); bot.x=880;bot.y=275; snapCamera();
document.querySelectorAll('.card,.toast').forEach(e=>e.remove());$('hud').hidden=true;
globalThis.lakeSave=serializeWorld(); }''')
assert pg.evaluate('treeBases.every(i=>!treeHeld(i)||mat[i]!==WATER)')
assert pg.evaluate('mat.every((m,i)=>m!==WATER||awake[((i/W)>>5)*CW+((i%W)>>5)]>0)')
measure = '''() => { const pools=[]; let p=null;
for(let x=0;x<W;x++){let y=0;while(y<H&&mat[y*W+x]===EMPTY)y++;
if(mat[y*W+x]!==WATER){p=null;continue;}
if(!p){p={width:0,min:y,max:y};pools.push(p);}
p.width++;p.min=Math.min(p.min,y);p.max=Math.max(p.max,y);}
return pools.filter(p=>p.width>20); }'''
pools=pg.evaluate(measure)
print('fresh startup',pools,flush=True)
assert pools and all(p['max']-p['min']<=1 for p in pools)
pg.wait_for_timeout(300)
(ROOT/'shots').mkdir(exist_ok=True)
pg.screenshot(path=str(ROOT/'shots/t96-level.png'))
for state in ('fresh','reloaded'):
if state=='reloaded':
pg.evaluate('''() => {deserializeWorld(lakeSave.meta,lakeSave.buffers);weather.next=1e15;}''')
pg.evaluate('startThreads()')
for batch in range(3):
pg.evaluate('for(let k=0;k<200;k++)simTick();')
result=pg.evaluate('''() => {let lo=H,hi=0,n=0,wet=0;
for(let x=650;x<1100;x++){let y=0;while(y<H&&mat[y*W+x]===EMPTY)y++;
if(mat[y*W+x]===WATER){lo=Math.min(lo,y);hi=Math.max(hi,y);n++;}}
for(const i of treeBases)if(i%W>=650&&i%W<1100&&treeHeld(i)&&mat[i]===WATER)wet++;
return {lo,hi,n,wet,workers:simPool?.workers.length};}''')
pools=pg.evaluate(measure)
assert pools and all(p['max']-p['min']<=1 for p in pools),pools
print(state,result,pools,flush=True)
assert result['n']>300 and result['hi']-result['lo']<=1 and result['wet']==0
pg.evaluate('snapCamera();renderDirty.fill(1);')
pg.wait_for_timeout(300)
pg.screenshot(path=str(ROOT/f'shots/t96-{state}-settled.png'))
assert not errors,errors
print('PASS t96 pixel flat startup and 600-tick fresh/reloaded lakes, dry roots, 2 browser helpers, no page errors',flush=True)