diff --git a/docs/DESIGN.md b/docs/DESIGN.md index aae616e..0b0769e 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -590,12 +590,15 @@ above, including: 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. -- **New-world lakes wake immediately; keep the seed layout (t96, 2026-10-29).** Keep the - recorded worldgen settling speeds and material/temperature output. Wake water after the - final startup reset so unfinished lakes level without drilling. Retain tree candidates and - their random draws, then omit whole trees whose bases are wet or on an open bank connected - to water at base height after settling. This keeps trees out of the lake as it spreads. - Existing load wake scheduling and later snowmelt/flooding rules stay as they are. +- **Level lakes during world generation (t96, 2026-10-29).** The owner wants new worlds + to start with flat lakes instead of waiting 80–125 seconds for them to flow level. Changed + generated worlds are accepted: seeds no longer reproduce the old water layout. After the + existing settling loop, level four-connected water bodies of at least 50 grains, preserving + their exact volume. Fill reachable air/water from the bottom upward, respecting solid/powder + 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 diff --git a/docs/PROJECT_MAP.md b/docs/PROJECT_MAP.md index 8a64eeb..7205861 100644 --- a/docs/PROJECT_MAP.md +++ b/docs/PROJECT_MAP.md @@ -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 - 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. -- functions: setBuildRange, inBuildRange, inReach, forBrush, drillReach, inDrillReach, toolDig +- functions: setBuildRange, inBuildRange, inReach, forBrush, drillReach, inDrillReach - variables: BUILD_MAX, buildBox ### 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. - 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. -- functions: makeNoise, fbm, clearHardSpecks +- functions: makeNoise, fbm, clearHardSpecks, levelWorldWater - 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. - 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 - 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. -- 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 ### 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_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_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_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 2ae0aaf..3964118 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -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. `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 -wake/background-tree change. It also checks generated tree bases, startup wakes, and the -seed-7 lake leveling within two grains after 6,000 undisturbed ticks, fresh and reloaded. +7, 42 and 424242 at 2048x1024. t96 round 2 re-baselines only for the lake-levelling pass; +`t_corner_seal.js` also pins three smaller worlds with the same intentional change. The lake +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, unexplored-pocket rejection, newest open trail points, ring wrap/load reset, bounded searches, diff --git a/docs/agent-log.md b/docs/agent-log.md index a68e763..4e9b9e5 100644 --- a/docs/agent-log.md +++ b/docs/agent-log.md @@ -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 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) - Added persistent scorched/fractured screw overlays in both looks, 900°C load warnings, diff --git a/src/sim/worldgen-helpers.js b/src/sim/worldgen-helpers.js index d729f3e..b40266e 100644 --- a/src/sim/worldgen-helpers.js +++ b/src/sim/worldgen-helpers.js @@ -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 ---------- function makeNoise(rng) { const perm = new Uint8Array(512), gx = new Float32Array(256), gy = new Float32Array(256); @@ -59,3 +59,73 @@ function clearHardSpecks(live) { } 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; + } + } +} diff --git a/src/sim/worldgen.js b/src/sim/worldgen.js index 7b38961..4fd89b3 100644 --- a/src/sim/worldgen.js +++ b/src/sim/worldgen.js @@ -256,7 +256,10 @@ function* worldSteps(seedIn, w, h) { 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; + // Establish the dock's ground before levelling, so its foundation cannot erase water. + placeDock(spawnX + 34); yield [0.99, 'Finishing']; + levelWorldWater(); // 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; } 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]; } findTreeBases(); - placeDock(spawnX + 34); 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); resetEconomy(); resetWeather(); weather.base = worldWater(); diff --git a/test/run_all.py b/test/run_all.py index 0ec6982..14aa4c0 100644 --- a/test/run_all.py +++ b/test/run_all.py @@ -45,7 +45,7 @@ def main(): if browser: 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 - 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)) bad = 0 for name, code, out, secs in results: diff --git a/test/t_corner_seal.js b/test/t_corner_seal.js index 10b0479..86db030 100644 --- a/test/t_corner_seal.js +++ b/test/t_corner_seal.js @@ -4,10 +4,11 @@ const { createHash } = require('node:crypto'); const { run } = load(); const { check, done } = suite(); +// t96 re-baseline: only the generation lake-levelling pass changes these material bytes. for (const [seed, expected] of [ - [777, 'b6f7b493e93a59e38ee57d33f24690daa87e7be0f4b356b32225a3a6f1a9d001'], - [77, '5e08c24603686c8617dd1156b10612b91a05fd9ee8b3648dfc4ee32fc008383a'], - [4242, 'f7ca75d19eba9eae4894b21b95fe1b683bd54e1f65a415dcc296f3c23b5d7e05'], + [777, '1fac6b4b6420f328d7de87c0813e328303271e7605d85ce541e108361aff0d90'], + [77, 'de3ec38547585486924f53abb82cb3c0f76e0bd24b75873ddf96599b5886b52a'], + [4242, '57cd89b5518c3e342fc1b91243cd068dc2e38a2d42de84a1c2ee1cbf936c2f1a'], ]) { run(`generateWorld(${seed}, 512, 384)`); check(`seed ${seed} restores corner sealing`,run('CORNER_SEAL')===true); diff --git a/test/t_lakes.js b/test/t_lakes.js index 95b5d31..a9e13c7 100644 --- a/test/t_lakes.js +++ b/test/t_lakes.js @@ -1,9 +1,9 @@ -// Generated lakes wake without a tool, settle on their own, and have dry tree bases. -const { load, suite, ticks } = require('./harness'); +// Generated lakes start flat, conserve water, and have dry tree bases. +const { load, suite, ticks, count, rect } = require('./harness'); const { createHash } = require('node:crypto'); const { run } = load(); const { check, done } = suite(); -const water = run('WATER'); + function surfacePools() { return run(`(() => { const pools = []; let pool = null; @@ -17,35 +17,54 @@ function surfacePools() { })()`); } for (const [seed, expected] of [ - [7, '865ac320f743ff5ed573acab3333773b5ebc2e10f15600438dd15f1767acb72e'], - [42, '94930e108f7bd948c6f3dbe8b15b2950eed695cb3d57376d05a8ad781c2015b7'], - [424242, '8b8a4b44c2cc98d3ab86515eb55f38ce7cb3d6a283e29e0ed758af937ea8e8c8'], + [7, '669a4ee87496be27473d2987d9a8935be1a19b29a8f2433d306f32a1616f3bca'], + [42, 'efeadd2bff0ce3eaf81e5a08a998ce767e9667e3b27b8623e928f202669f640e'], + [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'); 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} 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} 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. - if (seed === 7) { - run('globalThis.lakeSave = serializeWorld();'); - for (const state of ['fresh', 'reloaded']) { - 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)`)); - } - } + ticks(run, 600); + const settled = surfacePools(); + check(`seed ${seed} stays flat after 600 ticks`, settled.length > 0 && settled.every(p => p.max - p.min <= 1), JSON.stringify(settled)); + 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)`)); } +// 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(); diff --git a/test/t_lakes.py b/test/t_lakes.py new file mode 100644 index 0000000..d982a9c --- /dev/null +++ b/test/t_lakes.py @@ -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;xp.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=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)