Rebuild lake generation: bowls in valleys filled to their spill level (t96)

Lakes no longer pour water to a guessed lip and repair it afterwards. Each lake lowers the land
in the nearest valley with a smoothstep bowl, and water fills it to its spill level (the lower of
the highest ground on each side, Priority-Flood in one dimension), so it is flat and contained by
construction. Clay beds, no caves or loose pockets close under lakes, trees kept off the shore.
Removes the levelling pass, the lower-rim lip and the bank-slope cap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cj2RPXdPykKMEz5BXXczsj
This commit is contained in:
2026-10-29 20:55:52 -04:00
co-authored by Claude Opus 5.5
parent e3e01a84b1
commit daeb3b2238
9 changed files with 165 additions and 206 deletions
+20 -21
View File
@@ -590,27 +590,26 @@ 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.
- **Level lakes during world generation (t96, 2026-10-29).** The owner wants new worlds - **Lakes are bowls filled to their spill level (t96, 2026-10-29).** Three patches the same
to start with flat lakes instead of waiting 80–125 seconds for them to flow level. Changed day (a levelling pass, a lower-rim lip, a 0.7-grain bank cap) each fixed one symptom and
generated worlds are accepted: seeds no longer reproduce the old water layout. After the exposed the next: lakes started tilted, then flooded to the dock, then had a cliff, then a
existing settling loop, level four-connected water bodies of at least 50 grains, preserving straight ramp. The real cause was pouring water to a guessed `lip` (which was the highest
their exact volume. Fill reachable air/water from the bottom upward, respecting solid/powder ground, since y grows downward) so it ran out over the lower rim during settling. Banks and
walls and dry rims; place a partial top row from the middle outward. Establish the dock's beds are solid and never moved. Owner decisions: fix it properly, planned lakes only (no
existing foundation first so it cannot erase newly levelled water. Keep the pinned settling extra ponds), clay lake beds. The method, from depression filling (Priority-Flood, Barnes et
values, startup water wakes, and round-1 wet/water-connected-bank tree filtering. Existing al.): every basin fills to its spill level, the lowest point of its rim, so water is flat and
load wakes and later snowmelt/flooding rules stay as they are. contained by construction. The generated surface is one height per column with no overhangs
- **Lakes fill only to their lower rim and keep clear of spawn (t96 round 3, 2026-10-29).** (caves start 10+ rows down), so this is the 1D form: each column's water top is the lower of
The generator took the basin's `lip` as `Math.min(surfY)`, the highest ground (y grows the highest ground to its left and to its right (`spillLevels` in worldgen-helpers.js).
downward), so water was poured above the lower rim and ran out toward low ground; once Generation: each lake goes to the lowest smoothed ground within 60 grains of its planned spot,
levelled it reached the trade dock on 4 of 12 seeds. The owner chose option 2: the lip is keeping 80 grains from spawn and the dock; the land is lowered by a smoothstep bowl (flat floor
now the lower of the two rim edges, minus one, so a lake is filled to just under that rim, in the middle 40%, banks easing in and out, a little noise) on top of its own shape, so banks
and each lake's half-width is capped so its edge stays 80 grains from spawn (and the dock). keep the land's contours and meet it with no step; on sloping ground the bowl deepens (up to
Lakes are smaller and lower: about 180-250 grains wide instead of 250-330, 5-15 grains 3x) until the water is at least 12 deep. Water fills only to the spill level within the bowl.
lower. The random draws keep their order. The bed (top 3 rows under water) is clay and grass stops at the waterline. No caves or loose
Dug to the lower rim, the high side ended in a vertical cliff as tall as the gap between the sand/gravel pockets within 40 rows under a lake or 12 columns of its edge, so nothing can open
rims (15-18 grains on seeds 766733, 7, 42; owner screenshots). From each edge inward the a hole in its bed. Trees keep 6 columns from the water. The levelling pass, the lip, the bank
ground may now fall at most 0.7 grains a column and is never raised above the natural cap and the post-settle tree re-check are gone. Generation time is unchanged (within 5%).
surface, so the high side is a sloped bank down to the water.
## Ideas not built yet ## Ideas not built yet
+5 -6
View File
@@ -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 (131 lines) ### src/sim/worldgen-helpers.js (74 lines)
World generation helpers: noise and removal of tiny hard rock specks. World generation helpers: noise, hard rock specks and lake spill levels.
- functions: makeNoise, fbm, clearHardSpecks, levelWorldWater - functions: makeNoise, fbm, clearHardSpecks, spillLevels
- variables: SPECK_MIN - 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. World generation: staged terrain, ore, caves and placement, with synchronous and async entry points.
- functions: generateWorld, generateWorldAsync - 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_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.py`: Pixel startup and fresh/reloaded flat lakes after 600 ticks; shots/t96-level.png. - `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.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_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.
+8 -7
View File
@@ -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. 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. t96 round 2 re-baselines only for the lake-levelling pass; 7, 42 and 424242 at 2048x1024; `t_corner_seal.js` pins three smaller worlds and `t_world.js`
`t_corner_seal.js` also pins three smaller worlds with the same intentional change. The lake the shallow-ore hash, all re-baselined for the t96 lake rebuild (changes only inside lake spans).
suite checks all five seeds (also 2 and 1): surface pools wider than 20 grains start within For seeds 7, 42, 424242, 2, 1, 5 and 6 (and Huge seed 766733 without ticks) every lake is at
one grain of level and stay so after 600 ticks, exact WATER count from the pre-pass 0.99 least 12 deep with one at least 120 wide, exactly flat, held by a dry bank at or above its
progress callback, dry tree bases and startup wakes. Small fixtures cover solid/powder rims, surface on both sides, clay-bedded, with no ground step over 3 grains within 40 columns, clear
volume, centered partial rows, the 50-grain cutoff and diagonal separation. 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 `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, `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,
+22
View File
@@ -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 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 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) ## 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 - Owner chose option 2. src/sim/worldgen.js: lake `lip` is the lower rim edge minus one (was
+11 -68
View File
@@ -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 ---------- // ---------- 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);
@@ -60,72 +60,15 @@ function clearHardSpecks(live) {
return n; return n;
} }
// Grow each basin upward from its bottom: dry walls/rims are crossed only if filled to them. // Water top for each column of [x0, x1] on the 1D side-view surface: the lower of the highest
function levelWorldWater() { // ground to its left and to its right (Priority-Flood reduced to one dimension; y grows
const seen = new Uint8Array(N), region = new Uint32Array(N), cells = new Int32Array(N), stack = new Int32Array(N); // downward). Writes out[x0..x1] and returns the deepest water.
let tag = 0; function spillLevels(x0, x1, out) {
for (let start = 0; start < N; start++) { for (let x = x0, top = surfY[x0]; x <= x1; x++) { top = Math.min(top, surfY[x]); out[x] = top; }
if (mat[start] !== WATER || seen[start]) continue; let deepest = 0;
let size = 0, sp = 1, bottom = start; for (let x = x1, top = surfY[x1]; x >= x0; x--) {
stack[0] = start; seen[start] = 1; top = Math.min(top, surfY[x]); out[x] = Math.max(out[x], top);
// Measure the original four-connected body before moving any of its water. deepest = Math.max(deepest, surfY[x] - out[x]);
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;
}
} }
return deepest;
} }
+36 -44
View File
@@ -67,31 +67,38 @@ function* worldSteps(seedIn, w, h) {
else dirtY[x] = Math.min(dirtY[x], surfY[x] + 20); 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 // Lakes: lower the land in the nearest valley with a smooth bump (no slope at its edges, so the
// downward, so the lower rim is the larger surfY); kept 80 grains clear of spawn and the dock // banks keep the land's own shape), then fill the basin to its spill level. Water is flat and
const lakeTop = new Int16Array(w).fill(h), noTree = new Uint8Array(w), desAt = new Uint8Array(w); // 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) { for (const f of feats) {
if (f.k !== 'lake') continue; 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 lwDraw = Math.round(90 + rng() * 40), deep = 22 + rng() * 8;
const lip = Math.max(surfY[Math.max(0, f.x - lw)], surfY[Math.min(w - 1, f.x + lw)]) - 1; // water gathers in valleys: take the lowest smoothed ground within 60 grains of the plan
const x0 = Math.max(0, f.x - lw), x1 = Math.min(w - 1, f.x + lw), nat = surfY.slice(x0, x1 + 1); 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; }
}
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++) { for (let x = x0; x <= x1; x++) {
const u = (x - f.x) / lw, depth = Math.max(0, 1 - u * u) * deep; // a bowl: flat floor in the middle 40%, banks easing in and out along a smoothstep
if (depth > 0) surfY[x] = Math.max(surfY[x], Math.round(lip + depth)); const t = Math.min(1, Math.max(0, (1 - Math.abs(x - c) / lw) / 0.6)), b = t * t * (3 - 2 * t);
lakeTop[x] = lip + 2; surfY[x] = nat[x - x0] + Math.round(deep * scale * b * (1 + 0.15 * nDetail(x / 25, 6.6)));
} }
// Dug to the lower rim, the high side would end in a cliff as tall as the gap between the if (spillLevels(x0, x1, lakeTop) >= 12) break;
// 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]);
} }
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 = x0; x <= x1; x++) dirtY[x] = Math.max(dirtY[x], surfY[x] + 8); 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, f.x - lw - 6); x <= Math.min(w - 1, f.x + lw + 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) { for (const f of feats) {
if (f.k !== 'desert') continue; if (f.k !== 'desert') continue;
@@ -110,8 +117,9 @@ function* worldSteps(seedIn, w, h) {
if (y >= lakeTop[x]) m = WATER; if (y >= lakeTop[x]) m = WATER;
} else { } else {
const d = y - sy; const d = y - sy;
const inDes = desAt[x] === 1; const inDes = desAt[x] === 1, bed = lakeBed[x] === 1;
if (y < dirtY[x]) m = d === 0 && !inDes ? GRASS : DIRT; // 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 < 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 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; else m = BASALT;
@@ -126,8 +134,9 @@ function* worldSteps(seedIn, w, h) {
else if (m === STONE) { else if (m === STONE) {
if (d < 150 && pk > 0.52) m = DIRT; if (d < 150 && pk > 0.52) m = DIRT;
else if (d < 110 && pk2 > 0.5) m = CLAY; else if (d < 110 && pk2 > 0.5) m = CLAY;
else if (pk < -0.56) m = GRAVEL; // no loose pockets close under a lake: one falling into a cave would open a hole in its bed
else if (pk2 < -0.6 && d < 220) m = SAND; else if (pk < -0.56 && (!bed || d > 40)) m = GRAVEL;
else if (pk2 < -0.6 && d < 220 && (!bed || d > 40)) m = SAND;
} }
// ores // ores
if (m === STONE || m === DIRT || m === GRANITE || m === BASALT || m === CLAY) { 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 // 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; if (m !== host && (IGN[m] ? AMB[y] > IGN[m] - 120 : MELT[m] && AMB[y] > MELT[m] - 200)) m = host;
} }
// caves // caves (kept deeper under lakes so none opens into one)
if (d > 9) { if (d > (bed ? 40 : 9)) {
const t1 = Math.abs(fbm(nCave, x / 130, y / 80, 2)); 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 big = fy > 0.2 ? fbm(nCave2, x / 95, y / 64, 3) : -1;
const small = nCave3(x / 26, y / 20); const small = nCave3(x / 26, y / 20);
@@ -202,14 +211,12 @@ function* worldSteps(seedIn, w, h) {
} }
yield [0.72, 'Snow and trees']; yield [0.72, 'Snow and trees'];
// trees // trees
const trees = [];
for (let x = 12; x < w - 12;) { for (let x = 12; x < w - 12;) {
const sy = surfY[x]; 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 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) { if (ok && rng() < 0.7) {
const tree = { x, y: sy - 1, cells: [] }; trees.push(tree);
const th = 9 + Math.floor(rng() * 8); 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); for (let k = 1; k <= th; k++) for (let dx = 0; dx < 2; dx++) setW(x + dx, sy - k);
// two branches // 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)); } 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++) { 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; if (xx < 0 || yy < 1 || xx >= w) continue;
const ex = (xx - cx) / r, ey = (yy - cy) / (r * 0.85); 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); 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']; 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); 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;
// 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(); findTreeBases();
hot.fill(0); awake.fill(0); updateRoof(); hot.fill(0); awake.fill(0); updateRoof();
// Wake generated water so it responds from the first tick. // Wake generated water so it responds from the first tick.
+4 -4
View File
@@ -4,11 +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 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 [ for (const [seed, expected] of [
[777, 'dc0a19b076f341bcd944e1692651446198f57c7353db5fdea36d6bdd886ab6ff'], [777, '6c75cd15278a0d8c952bfb0b0f7f6185c597bd8f607d112d5970f8bdff4b1f2f'],
[77, 'aa979290d81a758e6ff68fa22b06513fb546aa73a80d09e69b35899a46758a0b'], [77, 'c1f3b0ac9b6d0a566e0051fa3f52388fa626ee714c3e18feed235721569ba056'],
[4242, '25737574612fe0395a2b0304a1ac698f817aa68bcd9e2b13a24ba794201ba6dc'], [4242, '63f6d90358f3e53be577a724cc5265a6e54380c431196cd3ed24cfd064285924'],
]) { ]) {
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);
+55 -53
View File
@@ -1,80 +1,82 @@
// Generated lakes start flat, conserve water, and have dry tree bases. // Generated lakes sit in a smooth bowl filled to its spill level: flat, contained, clay-bedded,
const { load, suite, ticks, count, rect } = require('./harness'); // stable, with natural banks, dry tree bases and the dock clear.
const { load, suite, ticks } = 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();
// Surface pools with their depth, water count, and the ground top of the dry column on each side.
function surfacePools() { function surfacePools() {
return run(`(() => { 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; const pools = []; let pool = null;
for (let x = 0; x < W; x++) { for (let x = 0; x < W; x++) {
let y = 0; while (y < H && mat[y * W + x] === EMPTY) y++; let y = 0; while (y < H && mat[y * W + x] === EMPTY) y++;
if (mat[y * W + x] !== WATER) { pool = null; continue; } 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.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 [ for (const [seed, expected] of [
[7, '5e230523dc4dda212e7c67ceb7f3b496ff201d826366e1d7c379efd1351d1956'], [7, '3e97601ea08027e7f21fa3d52ec300fb7e13c630a1ddad0a7be6ca7ab4b07592'],
[42, 'cc3d150a91caac6ab88dfefbd45d85c5bc5c38fc77d7913b9de157647f4b9d7b'], [42, 'ecf71e4309c0b19654fbc17dbb0b5717023842d331f4f12c010d3312e9f9383f'],
[424242, '1339c90f4f7f1d245c2e21aa4b8d23b21c199b8251392256f6de5ed704e2b396'], [424242, '235a33a7c85118d05cc9f0508acaa266b61d5f9f100703c0a095d760fe80066d'],
[2, null], [2, null],
[1, 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], [5, null],
[6, null], [6, null],
]) { ]) {
run(`generateWorld(${seed}, 2048, 1024, (f, label) => { run(`generateWorld(${seed}, 2048, 1024); weather.next = 1e15;`);
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));
const actual = hash.digest('hex'); const actual = hash.digest('hex');
if (expected) check(`seed ${seed} matches levelled generation hash`, actual === expected, actual); if (expected) check(`seed ${seed} matches its generation hash`, actual === expected, actual);
check(`seed ${seed} conserves water through levelling`, run('mat.reduce((n, m) => n + (m === WATER), 0) === waterBeforeLevel')); const pools = surfacePools();
const initial = surfacePools(); lakeChecks(seed, pools);
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));
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)')); // Stable: no leak into a cave and no slump; the surface and the water in each lake stay put.
check(`seed ${seed} retains dry trees`, run('treeBases.filter(i => treeHeld(i)).length > 20')); ticks(run, 3000);
ticks(run, 600); const later = surfacePools();
const settled = surfacePools(); check(`seed ${seed} lakes keep their surface and water for 3000 ticks`, later.length === pools.length &&
check(`seed ${seed} stays flat after 600 ticks`, settled.length > 0 && settled.every(p => p.max - p.min <= 1), JSON.stringify(settled)); 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),
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)`)); JSON.stringify({ pools, later }));
} }
// A low rim with a much deeper empty basin beyond it must not steal the water. // One of the owner's Huge seeds, whose round-3/4 banks showed the cliff and the ramp.
for (const wall of ['STONE', 'SAND']) { run('generateWorld(766733, 6144, 2048); weather.next = 1e15;');
run('alloc(64, 64); mat.fill(STONE);'); lakeChecks(766733, surfacePools());
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();
+3 -2
View File
@@ -185,8 +185,9 @@ oreNames.forEach((name, j) => {
}); });
const totalOre = oreBalance.counts.reduce((a, b) => a + b, 0); 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); 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). // t96: lakes are bowls in the nearest valley with no caves or loose pockets close under them, so ore
check('shallow ore matches the original generator', oreBalance.shallowHash === 1640715415, oreBalance.shallowHash); // 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)'); run('generateWorld(99, 4096, 1536, null)');
const marks = run('(()=>{let lakes=0,peaks=0,inLake=false;for(let x=1;x<W-1;x++){const l=mat[(surfY[x]-1)*W+x]===WATER;if(l&&!inLake)lakes++;inLake=l;if(surfY[x]<surfBase-100&&surfY[x]<=surfY[x-1]&&surfY[x]<surfY[x+1])peaks++;}return [lakes,peaks]})()'); const marks = run('(()=>{let lakes=0,peaks=0,inLake=false;for(let x=1;x<W-1;x++){const l=mat[(surfY[x]-1)*W+x]===WATER;if(l&&!inLake)lakes++;inLake=l;if(surfY[x]<surfBase-100&&surfY[x]<=surfY[x-1]&&surfY[x]<surfY[x+1])peaks++;}return [lakes,peaks]})()');
check('a large world has several lakes and mountains across it', marks[0] >= 2 && marks[1] >= 2, marks); check('a large world has several lakes and mountains across it', marks[0] >= 2 && marks[1] >= 2, marks);