diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 93baeed..479339e 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -590,27 +590,26 @@ 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. -- **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. -- **Lakes fill only to their lower rim and keep clear of spawn (t96 round 3, 2026-10-29).** - The generator took the basin's `lip` as `Math.min(surfY)`, the highest ground (y grows - downward), so water was poured above the lower rim and ran out toward low ground; once - levelled it reached the trade dock on 4 of 12 seeds. The owner chose option 2: the lip is - now the lower of the two rim edges, minus one, so a lake is filled to just under that rim, - and each lake's half-width is capped so its edge stays 80 grains from spawn (and the dock). - Lakes are smaller and lower: about 180-250 grains wide instead of 250-330, 5-15 grains - lower. The random draws keep their order. - Dug to the lower rim, the high side ended in a vertical cliff as tall as the gap between the - rims (15-18 grains on seeds 766733, 7, 42; owner screenshots). From each edge inward the - ground may now fall at most 0.7 grains a column and is never raised above the natural - surface, so the high side is a sloped bank down to the water. +- **Lakes are bowls filled to their spill level (t96, 2026-10-29).** Three patches the same + day (a levelling pass, a lower-rim lip, a 0.7-grain bank cap) each fixed one symptom and + exposed the next: lakes started tilted, then flooded to the dock, then had a cliff, then a + straight ramp. The real cause was pouring water to a guessed `lip` (which was the highest + ground, since y grows downward) so it ran out over the lower rim during settling. Banks and + beds are solid and never moved. Owner decisions: fix it properly, planned lakes only (no + extra ponds), clay lake beds. The method, from depression filling (Priority-Flood, Barnes et + al.): every basin fills to its spill level, the lowest point of its rim, so water is flat and + contained by construction. The generated surface is one height per column with no overhangs + (caves start 10+ rows down), so this is the 1D form: each column's water top is the lower of + the highest ground to its left and to its right (`spillLevels` in worldgen-helpers.js). + Generation: each lake goes to the lowest smoothed ground within 60 grains of its planned spot, + keeping 80 grains from spawn and the dock; the land is lowered by a smoothstep bowl (flat floor + in the middle 40%, banks easing in and out, a little noise) on top of its own shape, so banks + keep the land's contours and meet it with no step; on sloping ground the bowl deepens (up to + 3x) until the water is at least 12 deep. Water fills only to the spill level within the bowl. + The bed (top 3 rows under water) is clay and grass stops at the waterline. No caves or loose + sand/gravel pockets within 40 rows under a lake or 12 columns of its edge, so nothing can open + a hole in its bed. Trees keep 6 columns from the water. The levelling pass, the lip, the bank + cap and the post-settle tree re-check are gone. Generation time is unchanged (within 5%). ## Ideas not built yet diff --git a/docs/PROJECT_MAP.md b/docs/PROJECT_MAP.md index 7205861..9dba3b9 100644 --- a/docs/PROJECT_MAP.md +++ b/docs/PROJECT_MAP.md @@ -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 (131 lines) -World generation helpers: noise and removal of tiny hard rock specks. -- functions: makeNoise, fbm, clearHardSpecks, levelWorldWater +### src/sim/worldgen-helpers.js (74 lines) +World generation helpers: noise, hard rock specks and lake spill levels. +- functions: makeNoise, fbm, clearHardSpecks, spillLevels - variables: SPECK_MIN -### src/sim/worldgen.js (305 lines) +### src/sim/worldgen.js (309 lines) World generation: staged terrain, ore, caves and placement, with synchronous and async entry points. - functions: generateWorld, generateWorldAsync @@ -664,8 +664,7 @@ 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.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.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_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. diff --git a/docs/TESTING.md b/docs/TESTING.md index 9ba76a5..da3ee69 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -76,14 +76,15 @@ 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. 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. +7, 42 and 424242 at 2048x1024; `t_corner_seal.js` pins three smaller worlds and `t_world.js` +the shallow-ore hash, all re-baselined for the t96 lake rebuild (changes only inside lake spans). +For seeds 7, 42, 424242, 2, 1, 5 and 6 (and Huge seed 766733 without ticks) every lake is at +least 12 deep with one at least 120 wide, exactly flat, held by a dry bank at or above its +surface on both sides, clay-bedded, with no ground step over 3 grains within 40 columns, clear +of the dock, with no submerged tree base; after 3000 ticks each lake keeps its exact surface and +water count (no leaks). A hand-made surface checks `spillLevels`. `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. +two browser helpers and no page errors; `shots/t96-level.png` shows the 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 33977fd..2975f63 100644 --- a/docs/agent-log.md +++ b/docs/agent-log.md @@ -4,6 +4,28 @@ 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 rebuilt: lakes are bowls filled to their spill level (Claude) + +- Owner: the round-4 bank was a flat, unnatural ramp; fix lake generation properly instead of + patching. Mapped worldgen first: banks and beds are solid (only sand, gravel, snow and liquids + move while settling), so the original tilt was water poured above the lower rim spilling out, + not slumping banks (Claude's earlier explanation was wrong). Researched depression filling + (Priority-Flood) and its 1D form. Owner chose planned lakes only and clay beds. +- src/sim/worldgen.js: lake goes to the lowest smoothed ground within 60 grains of its plan (80 + from spawn); smoothstep bowl added to the land's own shape, deepened up to 3x until 12+ deep; + water fills to `spillLevels` (new, worldgen-helpers.js) within the bowl; clay bed (3 rows), + grass stops at the waterline; no caves or sand/gravel pockets within 40 rows under a lake or 12 + columns of it; noTree 6 columns from water. Removed levelWorldWater, the lip, the 0.7 bank + cap, the dock-first ordering and the post-settle tree re-check. +- Measured on 10 seeds (incl. owner's 766733/12446/23565 at 6144x2048): lakes 120-231 wide, + 14-47 deep, exactly flat, banks no steeper than 3 per column, no wet trees, each lake's + surface and water identical after 3000 ticks (underground aquifers still drain, as before). + Screenshots of all four seeds checked in the smooth/pixel looks. Generation time within 5%. +- Tests: t_lakes.js rewritten (flat, held by banks, clay bed, no cliff, real lake, dock clear, + stable 3000 ticks, spillLevels fixture; Huge seed 766733); hashes re-baselined in t_lakes, + t_corner_seal and t_world (shallow ore: 724 of 27,985 cells, all within old/new lake spans). + Full `run_all.py --browser`: ALL PASSED. + ## 2026-10-29: t96 round 3, lakes fill to their lower rim (Claude) - Owner chose option 2. src/sim/worldgen.js: lake `lip` is the lower rim edge minus one (was diff --git a/src/sim/worldgen-helpers.js b/src/sim/worldgen-helpers.js index b40266e..18fbf98 100644 --- a/src/sim/worldgen-helpers.js +++ b/src/sim/worldgen-helpers.js @@ -1,4 +1,4 @@ -// World generation helpers: noise, hard rock specks and lake levelling. +// World generation helpers: noise, hard rock specks and lake spill levels. // ---------- Perlin noise ---------- function makeNoise(rng) { const perm = new Uint8Array(512), gx = new Float32Array(256), gy = new Float32Array(256); @@ -60,72 +60,15 @@ 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; - } +// Water top for each column of [x0, x1] on the 1D side-view surface: the lower of the highest +// ground to its left and to its right (Priority-Flood reduced to one dimension; y grows +// downward). Writes out[x0..x1] and returns the deepest water. +function spillLevels(x0, x1, out) { + for (let x = x0, top = surfY[x0]; x <= x1; x++) { top = Math.min(top, surfY[x]); out[x] = top; } + let deepest = 0; + for (let x = x1, top = surfY[x1]; x >= x0; x--) { + top = Math.min(top, surfY[x]); out[x] = Math.max(out[x], top); + deepest = Math.max(deepest, surfY[x] - out[x]); } + return deepest; } diff --git a/src/sim/worldgen.js b/src/sim/worldgen.js index d291099..438b60c 100644 --- a/src/sim/worldgen.js +++ b/src/sim/worldgen.js @@ -67,31 +67,38 @@ function* worldSteps(seedIn, w, h) { else dirtY[x] = Math.min(dirtY[x], surfY[x] + 20); } } - // lakes: a basin dug below the lower of its two rims, filled to just under that rim (y grows - // downward, so the lower rim is the larger surfY); kept 80 grains clear of spawn and the dock - const lakeTop = new Int16Array(w).fill(h), noTree = new Uint8Array(w), desAt = new Uint8Array(w); + // Lakes: lower the land in the nearest valley with a smooth bump (no slope at its edges, so the + // banks keep the land's own shape), then fill the basin to its spill level. Water is flat and + // contained by construction; lakes keep 80 grains clear of spawn and the dock. + const lakeTop = new Int16Array(w).fill(h), noTree = new Uint8Array(w), lakeBed = new Uint8Array(w), desAt = new Uint8Array(w); for (const f of feats) { if (f.k !== 'lake') continue; - const lw = Math.min(Math.round(90 + rng() * 40), Math.abs(f.x - spawnX) - 80), deep = 22 + rng() * 8; - const lip = Math.max(surfY[Math.max(0, f.x - lw)], surfY[Math.min(w - 1, f.x + lw)]) - 1; - const x0 = Math.max(0, f.x - lw), x1 = Math.min(w - 1, f.x + lw), nat = surfY.slice(x0, x1 + 1); - for (let x = x0; x <= x1; x++) { - const u = (x - f.x) / lw, depth = Math.max(0, 1 - u * u) * deep; - if (depth > 0) surfY[x] = Math.max(surfY[x], Math.round(lip + depth)); - lakeTop[x] = lip + 2; + const lwDraw = Math.round(90 + rng() * 40), deep = 22 + rng() * 8; + // water gathers in valleys: take the lowest smoothed ground within 60 grains of the plan + let c = f.x, low = -1; + for (let x = f.x - 60; x <= f.x + 60; x++) { + if (x - lwDraw < 0 || x + lwDraw >= w || Math.abs(x - spawnX) - 80 < lwDraw * 0.85) continue; + let s = 0; for (let k = -15; k <= 15; k++) s += surfY[x + k]; + if (s > low) { low = s; c = x; } } - // Dug to the lower rim, the high side would end in a cliff as tall as the gap between the - // rims: from each edge inward the ground may fall at most 0.7 grains a column, never above nature. - for (const d of [1, -1]) { - let prev = surfY[d > 0 ? Math.max(0, x0 - 1) : Math.min(w - 1, x1 + 1)]; - for (let x = d > 0 ? x0 : x1; x >= x0 && x <= x1; x += d) { - prev = Math.min(prev + 0.7, surfY[x]); - surfY[x] = Math.max(nat[x - x0], Math.round(prev)); - prev = Math.max(prev, surfY[x]); + const lw = Math.min(lwDraw, Math.abs(c - spawnX) - 80), x0 = Math.max(0, c - lw), x1 = Math.min(w - 1, c + lw); + const nat = surfY.slice(x0, x1 + 1); + // on sloping ground the low rim spills early, so deepen the bump until the lake holds water + for (let scale = 1; scale <= 3; scale += 0.25) { + for (let x = x0; x <= x1; x++) { + // a bowl: flat floor in the middle 40%, banks easing in and out along a smoothstep + const t = Math.min(1, Math.max(0, (1 - Math.abs(x - c) / lw) / 0.6)), b = t * t * (3 - 2 * t); + surfY[x] = nat[x - x0] + Math.round(deep * scale * b * (1 + 0.15 * nDetail(x / 25, 6.6))); } + if (spillLevels(x0, x1, lakeTop) >= 12) break; } - for (let x = x0; x <= x1; x++) dirtY[x] = Math.max(dirtY[x], surfY[x] + 8); - for (let x = Math.max(0, f.x - lw - 6); x <= Math.min(w - 1, f.x + lw + 6); x++) noTree[x] = 1; + let wet0 = w, wet1 = -1; + for (let x = x0; x <= x1; x++) { + dirtY[x] = Math.max(dirtY[x], surfY[x] + 8); + if (lakeTop[x] < surfY[x]) { wet0 = Math.min(wet0, x); wet1 = Math.max(wet1, x); } else lakeTop[x] = h; + } + for (let x = Math.max(0, wet0 - 6); x <= Math.min(w - 1, wet1 + 6); x++) noTree[x] = 1; + for (let x = Math.max(0, wet0 - 12); x <= Math.min(w - 1, wet1 + 12); x++) lakeBed[x] = 1; } for (const f of feats) { if (f.k !== 'desert') continue; @@ -110,8 +117,9 @@ function* worldSteps(seedIn, w, h) { if (y >= lakeTop[x]) m = WATER; } else { const d = y - sy; - const inDes = desAt[x] === 1; - if (y < dirtY[x]) m = d === 0 && !inDes ? GRASS : DIRT; + const inDes = desAt[x] === 1, bed = lakeBed[x] === 1; + // a lake bottom is clay; the grass stops at the waterline + if (y < dirtY[x]) m = lakeTop[x] < sy && d < 3 ? CLAY : d === 0 && !inDes ? GRASS : DIRT; else if (y < graniteY[x] + nWarp(x / 70, y / 45) * 26 + nWarp(x / 22 + 40, y / 15) * 8 + nPocket(x / 9, y / 9) * 3) m = STONE; else if (y < basaltY[x] + nWarp(x / 64 + 50, y / 40) * 22 + nWarp(x / 20 + 90, y / 14) * 7 + nPocket(x / 7, y / 7) * 3) m = GRANITE; else m = BASALT; @@ -126,8 +134,9 @@ function* worldSteps(seedIn, w, h) { else if (m === STONE) { if (d < 150 && pk > 0.52) m = DIRT; else if (d < 110 && pk2 > 0.5) m = CLAY; - else if (pk < -0.56) m = GRAVEL; - else if (pk2 < -0.6 && d < 220) m = SAND; + // no loose pockets close under a lake: one falling into a cave would open a hole in its bed + else if (pk < -0.56 && (!bed || d > 40)) m = GRAVEL; + else if (pk2 < -0.6 && d < 220 && (!bed || d > 40)) m = SAND; } // ores if (m === STONE || m === DIRT || m === GRANITE || m === BASALT || m === CLAY) { @@ -160,8 +169,8 @@ function* worldSteps(seedIn, w, h) { // no ore where the rock is naturally hot enough to light or melt it: it would burn or run forever if (m !== host && (IGN[m] ? AMB[y] > IGN[m] - 120 : MELT[m] && AMB[y] > MELT[m] - 200)) m = host; } - // caves - if (d > 9) { + // caves (kept deeper under lakes so none opens into one) + if (d > (bed ? 40 : 9)) { const t1 = Math.abs(fbm(nCave, x / 130, y / 80, 2)); const big = fy > 0.2 ? fbm(nCave2, x / 95, y / 64, 3) : -1; const small = nCave3(x / 26, y / 20); @@ -202,14 +211,12 @@ function* worldSteps(seedIn, w, h) { } yield [0.72, 'Snow and trees']; // trees - const trees = []; for (let x = 12; x < w - 12;) { const sy = surfY[x]; const ok = mat[sy * w + x] === GRASS && mat[sy * w + x + 1] === GRASS && !noTree[x] && Math.abs(x - spawnX) > 6 && !(x > spawnX + 14 && x < spawnX + 72); // none where the dock goes if (ok && rng() < 0.7) { - const tree = { x, y: sy - 1, cells: [] }; trees.push(tree); const th = 9 + Math.floor(rng() * 8); - const setW = (xx, yy) => { if (xx < 0 || yy < 1 || xx >= w) return; const i = yy * w + xx; if (mat[i] === EMPTY) { bgm[i] = WOOD; tree.cells.push(i, WOOD); } }; + const setW = (xx, yy) => { if (xx < 0 || yy < 1 || xx >= w) return; const i = yy * w + xx; if (mat[i] === EMPTY) bgm[i] = WOOD; }; for (let k = 1; k <= th; k++) for (let dx = 0; dx < 2; dx++) setW(x + dx, sy - k); // two branches for (const side of [-1, 1]) { const by = sy - th + 3 + Math.floor(rng() * 3); for (let k = 1; k <= 3; k++) setW(x + (side < 0 ? -k : 1 + k), by - (k >> 1)); } @@ -220,7 +227,7 @@ function* worldSteps(seedIn, w, h) { for (let yy = Math.floor(cy - r); yy <= cy + r; yy++) for (let xx = Math.floor(cx - r); xx <= cx + r; xx++) { if (xx < 0 || yy < 1 || xx >= w) continue; const ex = (xx - cx) / r, ey = (yy - cy) / (r * 0.85); - if (ex * ex + ey * ey <= 1 && hash2(xx, yy, worldSeed + 7) % 9 !== 0) { const i = yy * w + xx; if (mat[i] === EMPTY && !bgm[i]) { bgm[i] = LEAVES; tree.cells.push(i, LEAVES); } } + if (ex * ex + ey * ey <= 1 && hash2(xx, yy, worldSeed + 7) % 9 !== 0) { const i = yy * w + xx; if (mat[i] === EMPTY && !bgm[i]) bgm[i] = LEAVES; } } } x += 16 + Math.floor(rng() * 20); @@ -268,26 +275,11 @@ 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; - // Settling can carry lakes past noTree; leave their connected low banks treeless too. - bgm.fill(0); - for (const tree of trees) { - let wet = false; - for (let root = tree.x; root <= tree.x + 1 && !wet; root++) for (const d of [-1, 1]) { - for (let x = root; x >= 0 && x < w; x += d) { - const m = mat[tree.y * w + x]; - if (m === WATER) { wet = true; break; } - if (m !== EMPTY && PH[m] !== PH_GAS) break; - } - } - if (!wet) for (let k = 0; k < tree.cells.length; k += 2) bgm[tree.cells[k]] = tree.cells[k + 1]; - } findTreeBases(); hot.fill(0); awake.fill(0); updateRoof(); // Wake generated water so it responds from the first tick. diff --git a/test/t_corner_seal.js b/test/t_corner_seal.js index f41147b..b0b83ad 100644 --- a/test/t_corner_seal.js +++ b/test/t_corner_seal.js @@ -4,11 +4,11 @@ const { createHash } = require('node:crypto'); const { run } = load(); const { check, done } = suite(); -// t96 re-baseline: only lake generation (rim height, spawn clearance, levelling) changes these material bytes. +// t96 re-baseline: only lake generation (bowl, spill level, clay bed, no caves under lakes) changes these bytes. for (const [seed, expected] of [ - [777, 'dc0a19b076f341bcd944e1692651446198f57c7353db5fdea36d6bdd886ab6ff'], - [77, 'aa979290d81a758e6ff68fa22b06513fb546aa73a80d09e69b35899a46758a0b'], - [4242, '25737574612fe0395a2b0304a1ac698f817aa68bcd9e2b13a24ba794201ba6dc'], + [777, '6c75cd15278a0d8c952bfb0b0f7f6185c597bd8f607d112d5970f8bdff4b1f2f'], + [77, 'c1f3b0ac9b6d0a566e0051fa3f52388fa626ee714c3e18feed235721569ba056'], + [4242, '63f6d90358f3e53be577a724cc5265a6e54380c431196cd3ed24cfd064285924'], ]) { 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 2ac8c66..18b3828 100644 --- a/test/t_lakes.js +++ b/test/t_lakes.js @@ -1,80 +1,82 @@ -// Generated lakes start flat, conserve water, and have dry tree bases. -const { load, suite, ticks, count, rect } = require('./harness'); +// 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. +const { load, suite, ticks } = require('./harness'); const { createHash } = require('node:crypto'); const { run } = load(); const { check, done } = suite(); +// Surface pools with their depth, water count, and the ground top of the dry column on each side. function surfacePools() { return run(`(() => { + const top = x => { let y = 0; while (y < H && (mat[y * W + x] === EMPTY || mat[y * W + x] === WATER)) y++; return y; }; const pools = []; let pool = 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) { pool = null; continue; } - if (!pool) { pool = { x0: x, x1: x, width: 0, min: y, max: y }; pools.push(pool); } + let b = y; while (b < H && mat[b * W + x] === WATER) b++; + if (!pool) { pool = { x0: x, x1: x, width: 0, min: y, max: y, deep: 0, water: 0, clay: true }; pools.push(pool); } pool.x1 = x; pool.width++; pool.min = Math.min(pool.min, y); pool.max = Math.max(pool.max, y); + pool.deep = Math.max(pool.deep, b - y); pool.water += b - y; pool.clay = pool.clay && mat[b * W + x] === CLAY; } - return pools.filter(p => p.width > 20); + return pools.filter(p => p.width > 20).map(p => Object.assign(p, { left: top(p.x0 - 1), right: top(p.x1 + 1) })); })()`); } +// Largest ground step within 40 columns of each pool, away from the dock's foundation. +function bankStep(pools) { + return run(`(() => { const d = machines.find(m => m && m.type === 'dock'); + const top = x => { let y = 0; while (y < H && (mat[y * W + x] === EMPTY || mat[y * W + x] === WATER)) y++; return y; }; + let worst = 0; + for (const p of ${JSON.stringify(pools)}) for (let x = Math.max(1, p.x0 - 40); x < Math.min(W - 1, p.x1 + 40); x++) + if (x < d.x - 2 || x > d.x + 26) worst = Math.max(worst, Math.abs(top(x + 1) - top(x))); + return worst; })()`); +} +function lakeChecks(seed, pools) { + check(`seed ${seed} has a real lake`, pools.some(p => p.width >= 120) && pools.every(p => p.deep >= 12), JSON.stringify(pools)); + check(`seed ${seed} lakes are exactly flat`, pools.every(p => p.max === p.min), JSON.stringify(pools)); + // Contained: the dry column on each side reaches at least the water's surface, so nothing can spill. + check(`seed ${seed} lakes are held by their banks`, pools.every(p => p.left <= p.min && p.right <= p.min), JSON.stringify(pools)); + check(`seed ${seed} lake beds are clay`, pools.every(p => p.clay)); + // Banks follow the land: round 3 left a 15-grain cliff, round 4 a ruler-straight ramp. + check(`seed ${seed} lake banks have no cliff`, bankStep(pools) <= 3, bankStep(pools)); + check(`seed ${seed} keeps its lakes clear of the trade dock`, run(`(() => { const d = machines.find(m => m && m.type === 'dock'); + return ${JSON.stringify(pools)}.every(p => p.x1 < d.x - 8 || p.x0 > d.x + 24 + 8); })()`), JSON.stringify(pools)); + 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')); +} + +// Spill levels on a hand-made surface: each column fills to the lower of its two highest sides. +run('alloc(16, 64); surfY.set([20, 30, 34, 32, 26, 30, 36, 33, 24, 40, 40, 22, 25, 25, 25, 25]); globalThis.levels = new Int16Array(16);'); +// The highest ground right of x=1 is 22 (x=11), lower than the 20 on the left, so x=1..11 fill +// to 22; past x=11 nothing on the right rises above 25. The deepest water is 18 (x=9, 10). +check('spill levels fill each basin to its lower rim', run('spillLevels(0, 15, levels) === 18 && [20,22,22,22,22,22,22,22,22,22,22,22,25,25,25,25].every((v, x) => levels[x] === v)'), run('Array.from(levels)')); + for (const [seed, expected] of [ - [7, '5e230523dc4dda212e7c67ceb7f3b496ff201d826366e1d7c379efd1351d1956'], - [42, 'cc3d150a91caac6ab88dfefbd45d85c5bc5c38fc77d7913b9de157647f4b9d7b'], - [424242, '1339c90f4f7f1d245c2e21aa4b8d23b21c199b8251392256f6de5ed704e2b396'], + [7, '3e97601ea08027e7f21fa3d52ec300fb7e13c630a1ddad0a7be6ca7ab4b07592'], + [42, 'ecf71e4309c0b19654fbc17dbb0b5717023842d331f4f12c010d3312e9f9383f'], + [424242, '235a33a7c85118d05cc9f0508acaa266b61d5f9f100703c0a095d760fe80066d'], [2, null], [1, null], - // Before t96 round 3 these lakes ran over their lower rim up to the trade dock. + // These lakes once ran over their lower rim up to the trade dock. [5, null], [6, null], ]) { - 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;`); + run(`generateWorld(${seed}, 2048, 1024); weather.next = 1e15;`); const hash = createHash('sha256'); for (const name of ['mat', 'temp']) hash.update(Buffer.from(run(name).buffer)); 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)); - // Lakes dug to their lower rim once left a cliff on the high side (seed 766733's 15-grain step). - check(`seed ${seed} lake banks slope with no cliff`, run(`(() => { const d = machines.find(m => m && m.type === 'dock'); - const top = x => { let y = 0; while (y < H && (mat[y * W + x] === EMPTY || mat[y * W + x] === WATER)) y++; return y; }; - return ${JSON.stringify(initial)}.every(p => { for (let x = Math.max(1, p.x0 - 40); x < Math.min(W - 1, p.x1 + 40); x++) - if (x < d.x - 2 || x > d.x + 26) { if (Math.abs(top(x + 1) - top(x)) > 3) return false; } return true; }); })()`), JSON.stringify(initial)); - check(`seed ${seed} keeps its lakes clear of the trade dock`, run(`(() => { const d = machines.find(m => m && m.type === 'dock'); - return ${JSON.stringify(initial)}.every(p => p.x1 < d.x - 8 || p.x0 > d.x + 24 + 8); })()`), JSON.stringify(initial)); + if (expected) check(`seed ${seed} matches its generation hash`, actual === expected, actual); + const pools = surfacePools(); + lakeChecks(seed, pools); 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')); - 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)`)); + // Stable: no leak into a cave and no slump; the surface and the water in each lake stay put. + ticks(run, 3000); + const later = surfacePools(); + check(`seed ${seed} lakes keep their surface and water for 3000 ticks`, later.length === pools.length && + later.every((p, k) => p.x0 === pools[k].x0 && p.x1 === pools[k].x1 && p.min === pools[k].min && p.max === pools[k].max && p.water === pools[k].water), + JSON.stringify({ pools, later })); } -// 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); +// One of the owner's Huge seeds, whose round-3/4 banks showed the cliff and the ramp. +run('generateWorld(766733, 6144, 2048); weather.next = 1e15;'); +lakeChecks(766733, surfacePools()); done(); diff --git a/test/t_world.js b/test/t_world.js index 76873b0..c753ae1 100644 --- a/test/t_world.js +++ b/test/t_world.js @@ -185,8 +185,9 @@ oreNames.forEach((name, j) => { }); const totalOre = oreBalance.counts.reduce((a, b) => a + b, 0); check('total ore is about 4 to 4.5 times the original', totalOre >= 162519 * 4 && totalOre <= 162519 * 4.5, totalOre); -// t96: lake basins moved and their high banks now slope, so ore there changed (only at the two lakes). -check('shallow ore matches the original generator', oreBalance.shallowHash === 1640715415, oreBalance.shallowHash); +// t96: lakes are bowls in the nearest valley with no caves or loose pockets close under them, so ore +// changed there (724 cells, all within the old and new lake spans). +check('shallow ore matches the original generator', oreBalance.shallowHash === 3875519651, oreBalance.shallowHash); run('generateWorld(99, 4096, 1536, null)'); const marks = run('(()=>{let lakes=0,peaks=0,inLake=false;for(let x=1;x= 2 && marks[1] >= 2, marks);