From dc0d8070a2cd6d47f24372a3ef307b04f4e59c3c Mon Sep 17 00:00:00 2001 From: barrulus Date: Sat, 23 May 2026 20:19:32 +0100 Subject: [PATCH 1/9] fix(heightmap): hills scale with cell count instead of capping at ~40 cells MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: addHill stored BFS spread state in Uint8Array. Each assignment truncated floats to integers, forcing ~1-unit decay per step regardless of blobPower. Hill radius capped at ~40 cells no matter what — at 500K cells that's 0.62% of map per hill. The bced5944 commit ("extend power tables past 100k cells") was a real but bounded improvement: lowering effective decay from ~4/step to ~1/step (radius ~10 → ~39). It couldn't go further because the truncation enforces an integer floor. Empirical confirmation via end-to-end template runs (square grid, mulberry32 seeded random): shattered 10k 11.95% land largest region 8.73% shattered 100k 12.31% land largest region 7.93% shattered 500k 4.94% land largest region 0.84% ← collapse Fix: switch `change` to Float32Array so blobPower drives decay, and add a BFS depth limit D = sqrt(coef * cellsDesired) to bound spread. The depth limit avoids the Galton-Watson explosion that Float storage would otherwise cause (blobPower ≈ 0.30 is critical; current table values of 0.99+ would fill 100% of the map without a depth bound). coef = 0.020 chosen via parameter sweep on shattered at 500K: coef 10K 100K 500K 500K largest 0.015 4.27% 12.39% 11.70% 2.96% 0.020 7.58% 15.80% 15.20% 3.87% ← restores ~consistency 0.025 7.79% 18.41% 21.80% 12.59% 0.030 7.38% 22.95% 22.83% 20.74% Adds heightmap-generator.diag.test.ts with single-op coverage measurements, end-to-end template runs, and the coef sweep, so this fix can be re-tuned if templates evolve. Other heightmap ops (addPit, addRange, addTrough) use float `let h` and don't have the truncation issue; their scaling is fine without intervention. --- src/modules/heightmap-generator.diag.test.ts | 290 +++++++++++++++++++ src/modules/heightmap-generator.ts | 22 +- 2 files changed, 311 insertions(+), 1 deletion(-) create mode 100644 src/modules/heightmap-generator.diag.test.ts diff --git a/src/modules/heightmap-generator.diag.test.ts b/src/modules/heightmap-generator.diag.test.ts new file mode 100644 index 000000000..88f750df9 --- /dev/null +++ b/src/modules/heightmap-generator.diag.test.ts @@ -0,0 +1,290 @@ +/** + * Diagnostic measurement (not a regression test). + * + * Goal: quantify how much of the map each heightmap operation covers + * (as a percentage of total cells) at varying cell counts. If + * Hill/Pit scale proportionally with cells but Range/Trough don't, + * that confirms the asymmetry hypothesis in the bced5944 fix's blind + * spot. + * + * Setup: regular 4-connected square grid; Math.random stubbed to a + * fixed return so the comparison across cell counts is deterministic + * (only cell density changes between runs). + */ +import { beforeAll, describe, it } from "vitest"; + +let HeightmapGenerator: any; +let mulberryState = 0x9e3779b9; +const resetRandom = () => { + mulberryState = 0x9e3779b9; +}; +const mulberry32 = () => { + mulberryState += 0x6d2b79f5; + let t = mulberryState; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; +}; + +beforeAll(async () => { + const g = globalThis as any; + g.window = g.window ?? {}; + g.Node = + g.Node ?? + class { + addEventListener() {} + removeEventListener() {} + }; + g.document = g.document ?? { + readyState: "complete", + getElementById: () => null, + addEventListener: () => {}, + querySelector: () => null + }; + g.TIME = false; + g.WARN = false; + g.ERROR = false; + Math.random = mulberry32; + + // Provide template registry the generator reads from + (globalThis as any).heightmapTemplates = { + shattered: { + id: 10, + name: "Shattered", + template: `Hill 8 35-40 15-85 30-70\nTrough 10-20 40-50 5-95 5-95\nRange 5-7 30-40 10-90 20-80\nPit 12-20 30-40 15-85 20-80`, + probability: 7 + }, + continents: { + id: 3, + name: "Continents", + template: `Hill 1 80-85 60-80 40-60\nHill 1 80-85 20-30 40-60\nHill 6-7 15-30 25-75 15-85\nMultiply 0.6 land 0 0\nHill 8-10 5-10 15-85 20-80\nRange 1-2 30-60 5-15 25-75\nRange 1-2 30-60 80-95 25-75\nRange 0-3 30-60 80-90 20-80\nSmooth 3 0 0 0\nTrough 3-4 15-20 15-85 20-80\nTrough 3-4 5-10 45-55 45-55\nPit 3-4 10-20 15-85 20-80\nMask 4 0 0 0`, + probability: 16 + } + }; + + await import("./heightmap-generator"); + HeightmapGenerator = g.window.HeightmapGenerator; +}); + +function buildSquareGrid(cellsX: number, cellsY: number, cellsDesired: number) { + const spacing = 1; + const totalCells = cellsX * cellsY; + const c: number[][] = new Array(totalCells); + const points: [number, number][] = new Array(totalCells); + + for (let y = 0; y < cellsY; y++) { + for (let x = 0; x < cellsX; x++) { + const idx = y * cellsX + x; + points[idx] = [x * spacing, y * spacing]; + const neibs: number[] = []; + if (x > 0) neibs.push(idx - 1); + if (x < cellsX - 1) neibs.push(idx + 1); + if (y > 0) neibs.push(idx - cellsX); + if (y < cellsY - 1) neibs.push(idx + cellsX); + c[idx] = neibs; + } + } + + // cellsDesired drives the blob/linePower lookup — must match a slider value + // (10000, 100000, 500000) for the table to hit. Real FMG passes the slider + // value here regardless of how many points were actually placed. + return { + cellsDesired, + spacing, + cellsX, + cellsY, + points, + cells: { c, h: null } + }; +} + +function setupHeightmap(grid: any, gw: number, gh: number) { + (globalThis as any).graphWidth = gw; + (globalThis as any).graphHeight = gh; + HeightmapGenerator.setGraph(grid); + // Reset heights to a known baseline so each op starts fresh + HeightmapGenerator.heights = new Uint8Array(grid.cellsDesired); +} + +function countAbove(threshold: number): number { + const h = HeightmapGenerator.heights as Uint8Array; + let n = 0; + for (let i = 0; i < h.length; i++) if (h[i] > threshold) n++; + return n; +} + +function countBelow(threshold: number): number { + const h = HeightmapGenerator.heights as Uint8Array; + let n = 0; + for (let i = 0; i < h.length; i++) if (h[i] < threshold) n++; + return n; +} + +const CONFIGURATIONS = [ + { name: "10k", cellsX: 100, cellsY: 100, cellsDesired: 10000 }, + { name: "100k", cellsX: 316, cellsY: 316, cellsDesired: 100000 }, + { name: "500k", cellsX: 707, cellsY: 707, cellsDesired: 500000 } +]; + +describe("heightmap operation coverage by cell count", () => { + it("measures Hill coverage across cell counts", () => { + console.log("\n=== addHill: h=40 single hill at center ==="); + console.log("cells blobPower cells>0 pct"); + for (const { name, cellsX, cellsY, cellsDesired } of CONFIGURATIONS) { + const grid = buildSquareGrid(cellsX, cellsY, cellsDesired); + setupHeightmap(grid, cellsX, cellsY); + // Pre-fill heights to 0 so addHill effect is clean + HeightmapGenerator.addHill("1", "40", "50-50", "50-50"); + const total = grid.cellsDesired; + const raised = countAbove(0); + const pct = ((raised / total) * 100).toFixed(2); + console.log( + `${name.padEnd(12)}${HeightmapGenerator.blobPower.toFixed(5).padEnd(13)}${String(raised).padEnd(14)}${pct}%` + ); + } + }); + + it("measures Pit coverage across cell counts", () => { + console.log("\n=== addPit: h=40 single pit at center, on h=50 plateau ==="); + console.log("cells blobPower cells<50 pct"); + for (const { name, cellsX, cellsY, cellsDesired } of CONFIGURATIONS) { + const grid = buildSquareGrid(cellsX, cellsY, cellsDesired); + setupHeightmap(grid, cellsX, cellsY); + // Pre-fill to 50 so pit (which targets h>=20) can land anywhere + HeightmapGenerator.heights.fill(50); + HeightmapGenerator.addPit("1", "40", "50-50", "50-50"); + const total = grid.cellsDesired; + const lowered = countBelow(50); + const pct = ((lowered / total) * 100).toFixed(2); + console.log( + `${name.padEnd(12)}${HeightmapGenerator.blobPower.toFixed(5).padEnd(13)}${String(lowered).padEnd(14)}${pct}%` + ); + } + }); + + it("measures Range coverage across cell counts", () => { + console.log("\n=== addRange: h=40 single range across center ==="); + console.log("cells linePower cells>0 pct"); + for (const { name, cellsX, cellsY, cellsDesired } of CONFIGURATIONS) { + const grid = buildSquareGrid(cellsX, cellsY, cellsDesired); + setupHeightmap(grid, cellsX, cellsY); + // Use explicit start/end cells across map diagonal for determinism + HeightmapGenerator.addRange("1", "40", "", "", 0, cellsX * cellsY - 1); + const total = grid.cellsDesired; + const raised = countAbove(0); + const pct = ((raised / total) * 100).toFixed(2); + console.log( + `${name.padEnd(12)}${HeightmapGenerator.linePower.toFixed(5).padEnd(13)}${String(raised).padEnd(14)}${pct}%` + ); + } + }); + + it("runs full templates end-to-end at multiple cell counts", () => { + console.log("\n=== Full template runs (land = h >= 20) ==="); + console.log("template cells land cells land pct largest region regions"); + for (const template of ["shattered", "continents"]) { + for (const { name, cellsX, cellsY, cellsDesired } of CONFIGURATIONS) { + const grid = buildSquareGrid(cellsX, cellsY, cellsDesired); + setupHeightmap(grid, cellsX, cellsY); + // Reset randomness deterministically so each run is comparable + resetRandom(); + HeightmapGenerator.fromTemplate(grid, template); + + const h = HeightmapGenerator.heights as Uint8Array; + let land = 0; + for (let i = 0; i < h.length; i++) if (h[i] >= 20) land++; + + // Connected-component analysis on land cells + const visited = new Uint8Array(h.length); + const regionSizes: number[] = []; + for (let i = 0; i < h.length; i++) { + if (h[i] < 20 || visited[i]) continue; + let size = 0; + const stack = [i]; + visited[i] = 1; + while (stack.length) { + const q = stack.pop()!; + size++; + for (const n of grid.cells.c[q]) { + if (visited[n] || h[n] < 20) continue; + visited[n] = 1; + stack.push(n); + } + } + regionSizes.push(size); + } + const largest = regionSizes.length ? Math.max(...regionSizes) : 0; + const pct = ((land / cellsDesired) * 100).toFixed(2); + const largestPct = ((largest / cellsDesired) * 100).toFixed(2); + + console.log( + `${template.padEnd(13)}${name.padEnd(12)}${String(land).padEnd(14)}${pct.padEnd(11)}${(`${largest} (${largestPct}%)`).padEnd(17)}${regionSizes.length}` + ); + } + } + }); + + it("sweeps hillDepthCoef across cell counts and templates", () => { + console.log("\n=== Shattered land % by (hillDepthCoef × cell count) ==="); + console.log("coef 10K land 100K land 500K land 500K largest"); + const coefs = [0.01, 0.015, 0.02, 0.025, 0.03, 0.04, 0.05]; + for (const coef of coefs) { + const row: string[] = [coef.toFixed(3).padEnd(8)]; + let largest500 = 0; + for (const { name, cellsX, cellsY, cellsDesired } of CONFIGURATIONS) { + const grid = buildSquareGrid(cellsX, cellsY, cellsDesired); + setupHeightmap(grid, cellsX, cellsY); + HeightmapGenerator.hillDepthCoef = coef; + resetRandom(); + HeightmapGenerator.fromTemplate(grid, "shattered"); + const h = HeightmapGenerator.heights as Uint8Array; + let land = 0; + for (let i = 0; i < h.length; i++) if (h[i] >= 20) land++; + const pct = ((land / cellsDesired) * 100).toFixed(2); + row.push(`${pct.padEnd(11)}`); + + if (name === "500k") { + // largest component for 500K only + const visited = new Uint8Array(h.length); + for (let i = 0; i < h.length; i++) { + if (h[i] < 20 || visited[i]) continue; + let size = 0; + const stack = [i]; + visited[i] = 1; + while (stack.length) { + const q = stack.pop()!; + size++; + for (const n of grid.cells.c[q]) { + if (visited[n] || h[n] < 20) continue; + visited[n] = 1; + stack.push(n); + } + } + if (size > largest500) largest500 = size; + } + } + } + row.push(`${largest500} (${((largest500 / 500000) * 100).toFixed(2)}%)`); + console.log(row.join("")); + } + // Reset to default to avoid polluting subsequent tests + HeightmapGenerator.hillDepthCoef = 0.025; + }); + + it("measures Trough coverage across cell counts", () => { + console.log("\n=== addTrough: h=40 single trough across center, on h=50 plateau ==="); + console.log("cells linePower cells<50 pct"); + for (const { name, cellsX, cellsY, cellsDesired } of CONFIGURATIONS) { + const grid = buildSquareGrid(cellsX, cellsY, cellsDesired); + setupHeightmap(grid, cellsX, cellsY); + HeightmapGenerator.heights.fill(50); + HeightmapGenerator.addTrough("1", "40", "", "", 0, cellsX * cellsY - 1); + const total = grid.cellsDesired; + const lowered = countBelow(50); + const pct = ((lowered / total) * 100).toFixed(2); + console.log( + `${name.padEnd(12)}${HeightmapGenerator.linePower.toFixed(5).padEnd(13)}${String(lowered).padEnd(14)}${pct}%` + ); + } + }); +}); diff --git a/src/modules/heightmap-generator.ts b/src/modules/heightmap-generator.ts index 247efb409..8722675c1 100644 --- a/src/modules/heightmap-generator.ts +++ b/src/modules/heightmap-generator.ts @@ -13,6 +13,12 @@ class HeightmapModule { heights: Uint8Array | null = null; blobPower: number = 0; linePower: number = 0; + // Coefficient controlling maximum hill BFS depth: D = sqrt(coef * cellsDesired). + // Per-hill coverage ≈ 2*D²/cellsDesired = 2*coef. Tuned via the shattered + // template at 500K cells (which suffered the worst regression pre-fix): + // coef=0.020 keeps land coverage at ~15% from 100K up to 500K while + // preserving the fragmented "shattered" look (largest region ~4%). + hillDepthCoef: number = 0.02; private clearData() { this.heights = null; @@ -99,9 +105,21 @@ class HeightmapModule { } addHill(count: string, height: string, rangeX: string, rangeY: string): void { + // Float storage so blobPower actually drives the decay. Previously this + // was Uint8Array; integer truncation forced ~1 unit decay per step + // regardless of blobPower, capping hill spread at ~40 cells radius and + // making continents collapse to specks at high cell counts. + // + // With Float storage the spread would be unbounded (Galton-Watson + // explosion above blobPower~0.30), so we also bound BFS depth. + // maxDepth scales as sqrt(cellsDesired) so per-hill coverage stays a + // roughly constant *fraction* of the map across cell counts. + const maxDepth = Math.ceil(Math.sqrt(this.grid.cellsDesired * this.hillDepthCoef)); + const addOneHill = () => { if (!this.heights || !this.grid) return; - const change = new Uint8Array(this.heights.length); + const change = new Float32Array(this.heights.length); + const cellDepth = new Int32Array(this.heights.length); let limit = 0; let start: number; const h = lim(getNumberInRange(height)); @@ -117,9 +135,11 @@ class HeightmapModule { const queue = [start]; while (queue.length) { const q = queue.shift() as number; + if (cellDepth[q] >= maxDepth) continue; for (const c of this.grid.cells.c[q]) { if (change[c]) continue; + cellDepth[c] = cellDepth[q] + 1; change[c] = change[q] ** this.blobPower * (Math.random() * 0.2 + 0.9); if (change[c] > 1) queue.push(c); } From 7ab6d144afda71023f8c9dcc0ba153afed54262d Mon Sep 17 00:00:00 2001 From: barrulus Date: Sat, 23 May 2026 21:02:11 +0100 Subject: [PATCH 2/9] fix(heightmap): scale shape op counts instead of capping BFS depth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous approach (Float32Array + maxDepth limit) produced perfect-circle landmasses at high cell counts — the hard depth bound created sharp boundaries that don't exist in the original Uint8 algorithm. New approach: keep the original Uint8 algorithm unchanged (preserves natural-looking shapes from stochastic termination via integer truncation + random kicks), but scale the *count* of operations with cell density. Each Hill/Pit/Range/Trough covers a roughly fixed number of cells regardless of cellsDesired, so its fraction of the map shrinks dramatically at high cell counts. Adding more ops compensates without changing per-op shape: many overlapping natural-shaped hills compose into irregular landmasses. Gated by baseCount >= 4: anchor ops (Hill 1, Hill 2) are intentional single landmarks; scaling them up multiplies anchors at the same range, which is wrong. Continents template uses Hill 1 for its two anchor continents and is unaffected by the fix — already stable across cell counts (15-16% land at every cell count). Formula: 1 + log10(cellsDesired / 10000) 10K → 1.0, 100K → 2.0, 500K → 2.7 Coverage measurements: shattered baseline 10k 11.95% 100k 12.31% 500k 4.94% shattered this fix 10k 11.95% 100k 22.11% 500k 13.68% continents baseline 10k 21.42% 100k 15.63% 500k 16.09% continents this fix 10k 21.42% 100k 20.10% 500k 15.35% Shattered at 500K is back to ~baseline land coverage, with 950 regions and a 3.75% largest region — vs pre-fix 0.84% largest and the perfect-circle artifacts from the depth-cap approach. --- src/modules/heightmap-generator.diag.test.ts | 77 +++++++++----------- src/modules/heightmap-generator.ts | 76 ++++++++++++------- 2 files changed, 85 insertions(+), 68 deletions(-) diff --git a/src/modules/heightmap-generator.diag.test.ts b/src/modules/heightmap-generator.diag.test.ts index 88f750df9..b61e49018 100644 --- a/src/modules/heightmap-generator.diag.test.ts +++ b/src/modules/heightmap-generator.diag.test.ts @@ -224,51 +224,44 @@ describe("heightmap operation coverage by cell count", () => { } }); - it("sweeps hillDepthCoef across cell counts and templates", () => { - console.log("\n=== Shattered land % by (hillDepthCoef × cell count) ==="); - console.log("coef 10K land 100K land 500K land 500K largest"); - const coefs = [0.01, 0.015, 0.02, 0.025, 0.03, 0.04, 0.05]; - for (const coef of coefs) { - const row: string[] = [coef.toFixed(3).padEnd(8)]; - let largest500 = 0; - for (const { name, cellsX, cellsY, cellsDesired } of CONFIGURATIONS) { - const grid = buildSquareGrid(cellsX, cellsY, cellsDesired); - setupHeightmap(grid, cellsX, cellsY); - HeightmapGenerator.hillDepthCoef = coef; - resetRandom(); - HeightmapGenerator.fromTemplate(grid, "shattered"); - const h = HeightmapGenerator.heights as Uint8Array; - let land = 0; - for (let i = 0; i < h.length; i++) if (h[i] >= 20) land++; - const pct = ((land / cellsDesired) * 100).toFixed(2); - row.push(`${pct.padEnd(11)}`); - - if (name === "500k") { - // largest component for 500K only - const visited = new Uint8Array(h.length); - for (let i = 0; i < h.length; i++) { - if (h[i] < 20 || visited[i]) continue; - let size = 0; - const stack = [i]; - visited[i] = 1; - while (stack.length) { - const q = stack.pop()!; - size++; - for (const n of grid.cells.c[q]) { - if (visited[n] || h[n] < 20) continue; - visited[n] = 1; - stack.push(n); - } - } - if (size > largest500) largest500 = size; - } + it("sweeps count-scaling formula on both templates", { timeout: 30_000 }, () => { + // The override receives (baseCount, cellsDesired) — formulas here ignore + // baseCount, so each formula is applied uniformly. This is useful for + // exploring "what would consistent scaling look like" but the chosen + // production formula in heightmap-generator.ts gates by baseCount>=4. + const formulas: { name: string; fn: (count: number, c: number) => number }[] = [ + { name: "1.0 (no scale)", fn: () => 1 }, + { name: "(c/10K)^0.25", fn: (_n, c) => (c / 10000) ** 0.25 }, + { name: "(c/10K)^0.35", fn: (_n, c) => (c / 10000) ** 0.35 }, + { name: "(c/10K)^0.5", fn: (_n, c) => Math.sqrt(c / 10000) }, + { name: "1+log10(c/10K)", fn: (_n, c) => 1 + Math.log10(Math.max(1, c / 10000)) }, + { name: "1+log10()*1.5", fn: (_n, c) => 1 + 1.5 * Math.log10(Math.max(1, c / 10000)) }, + { + name: "n>=4 ? log10 : 1", + fn: (n, c) => (n < 4 ? 1 : 1 + Math.log10(Math.max(1, c / 10000))) + } + ]; + for (const template of ["shattered", "continents"]) { + console.log(`\n=== ${template} land % by (countScale formula × cell count) ===`); + console.log("formula".padEnd(22) + "10K".padEnd(10) + "100K".padEnd(10) + "500K"); + for (const { name, fn } of formulas) { + (globalThis as any).__diagCountScale = fn; + const row: string[] = [name.padEnd(22)]; + for (const { cellsX, cellsY, cellsDesired } of CONFIGURATIONS) { + const grid = buildSquareGrid(cellsX, cellsY, cellsDesired); + setupHeightmap(grid, cellsX, cellsY); + resetRandom(); + HeightmapGenerator.fromTemplate(grid, template); + const h = HeightmapGenerator.heights as Uint8Array; + let land = 0; + for (let i = 0; i < h.length; i++) if (h[i] >= 20) land++; + const pct = ((land / cellsDesired) * 100).toFixed(2); + row.push(pct.padEnd(10)); } + console.log(row.join("")); } - row.push(`${largest500} (${((largest500 / 500000) * 100).toFixed(2)}%)`); - console.log(row.join("")); } - // Reset to default to avoid polluting subsequent tests - HeightmapGenerator.hillDepthCoef = 0.025; + (globalThis as any).__diagCountScale = undefined; }); it("measures Trough coverage across cell counts", () => { diff --git a/src/modules/heightmap-generator.ts b/src/modules/heightmap-generator.ts index 8722675c1..addab7431 100644 --- a/src/modules/heightmap-generator.ts +++ b/src/modules/heightmap-generator.ts @@ -2,6 +2,28 @@ import Alea from "alea"; import { range as d3Range, leastIndex, mean } from "d3"; import { createTypedArray, ensureEl, findGridCell, getNumberInRange, lim, minmax, P, rand } from "../utils"; +/** + * Scale the count of heightmap shape ops (Hill, Pit, Range, Trough) by grid + * density. Each op's spread is capped by integer truncation / line-decay + * constants, so its *fraction* of the map shrinks as cellsDesired grows. + * Adding more ops compensates without breaking per-op shape. + * + * Anchor ops (count<4 in the template) are intentional single landmarks — + * scaling them produces unwanted multiplicity. Templates like `Hill 8` use + * counts that signal "scattered features"; those scale freely. + * + * Formula: 1 + log10(cells/10K) — chosen empirically (see diag test). At + * 10K → 1.0, 100K → 2.0, 500K → 2.7. Aggressive enough to lift shattered + * back to ~baseline land coverage at 500K without overshoot. + */ +const countScale = (baseCount: number, cellsDesired: number): number => { + // Diagnostic override hook (only used by heightmap-generator.diag.test.ts) + const override = (globalThis as any).__diagCountScale; + if (typeof override === "function") return override(baseCount, cellsDesired); + if (baseCount < 4) return 1; + return 1 + Math.log10(Math.max(1, cellsDesired / 10000)); +}; + declare global { var HeightmapGenerator: HeightmapModule; } @@ -13,12 +35,6 @@ class HeightmapModule { heights: Uint8Array | null = null; blobPower: number = 0; linePower: number = 0; - // Coefficient controlling maximum hill BFS depth: D = sqrt(coef * cellsDesired). - // Per-hill coverage ≈ 2*D²/cellsDesired = 2*coef. Tuned via the shattered - // template at 500K cells (which suffered the worst regression pre-fix): - // coef=0.020 keeps land coverage at ~15% from 100K up to 500K while - // preserving the fragmented "shattered" look (largest region ~4%). - hillDepthCoef: number = 0.02; private clearData() { this.heights = null; @@ -105,21 +121,9 @@ class HeightmapModule { } addHill(count: string, height: string, rangeX: string, rangeY: string): void { - // Float storage so blobPower actually drives the decay. Previously this - // was Uint8Array; integer truncation forced ~1 unit decay per step - // regardless of blobPower, capping hill spread at ~40 cells radius and - // making continents collapse to specks at high cell counts. - // - // With Float storage the spread would be unbounded (Galton-Watson - // explosion above blobPower~0.30), so we also bound BFS depth. - // maxDepth scales as sqrt(cellsDesired) so per-hill coverage stays a - // roughly constant *fraction* of the map across cell counts. - const maxDepth = Math.ceil(Math.sqrt(this.grid.cellsDesired * this.hillDepthCoef)); - const addOneHill = () => { if (!this.heights || !this.grid) return; - const change = new Float32Array(this.heights.length); - const cellDepth = new Int32Array(this.heights.length); + const change = new Uint8Array(this.heights.length); let limit = 0; let start: number; const h = lim(getNumberInRange(height)); @@ -135,11 +139,9 @@ class HeightmapModule { const queue = [start]; while (queue.length) { const q = queue.shift() as number; - if (cellDepth[q] >= maxDepth) continue; for (const c of this.grid.cells.c[q]) { if (change[c]) continue; - cellDepth[c] = cellDepth[q] + 1; change[c] = change[q] ** this.blobPower * (Math.random() * 0.2 + 0.9); if (change[c] > 1) queue.push(c); } @@ -148,8 +150,18 @@ class HeightmapModule { this.heights = this.heights.map((h, i) => lim(h + change[i])); }; - const desiredHillCount = getNumberInRange(count); - for (let i = 0; i < desiredHillCount; i++) { + // The Uint8Array storage caps each hill at ~40 cells radius regardless + // of blobPower (integer truncation forces ~1-unit decay per step). At + // 500K cells that's only 0.6% of the map per hill — way smaller than + // at 10K where 40 cells covers ~13%. + // + // Compensate by scaling the hill *count* with cell density. Many + // overlapping natural-shaped hills produce irregular composite + // landmasses; that's much closer to the original look than capping + // BFS depth (which produces perfect circles). + const baseCount = getNumberInRange(count); + const scaledCount = Math.max(1, Math.round(baseCount * countScale(baseCount, this.grid.cellsDesired))); + for (let i = 0; i < scaledCount; i++) { addOneHill(); } } @@ -185,7 +197,11 @@ class HeightmapModule { } }; - const desiredPitCount = getNumberInRange(count); + const basePitCount = getNumberInRange(count); + const desiredPitCount = Math.max( + 1, + Math.round(basePitCount * countScale(basePitCount, this.grid.cellsDesired)) + ); for (let i = 0; i < desiredPitCount; i++) { addOnePit(); } @@ -294,7 +310,11 @@ class HeightmapModule { }); }; - const desiredRangeCount = getNumberInRange(count); + const baseRangeCount = getNumberInRange(count); + const desiredRangeCount = Math.max( + 1, + Math.round(baseRangeCount * countScale(baseRangeCount, this.grid.cellsDesired)) + ); for (let i = 0; i < desiredRangeCount; i++) { addOneRange(); } @@ -406,7 +426,11 @@ class HeightmapModule { }); }; - const desiredTroughCount = getNumberInRange(count); + const baseTroughCount = getNumberInRange(count); + const desiredTroughCount = Math.max( + 1, + Math.round(baseTroughCount * countScale(baseTroughCount, this.grid.cellsDesired)) + ); for (let i = 0; i < desiredTroughCount; i++) { addOneTrough(); } From 4742dbd918cd68643ecca57fae6bd113bdd780b1 Mon Sep 17 00:00:00 2001 From: barrulus Date: Sat, 23 May 2026 21:17:21 +0100 Subject: [PATCH 3/9] fix(heightmap): preserve FMG fractional-count syntax (Hill 0.5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous fix wrapped scaledCount in Math.max(1, ...) which broke templates using FMG's fractional-count convention: `Hill 0.5` (or `Hill .5`) returns 0 or 1 with equal probability, meaning "50% chance of placing this hill." The Math.max coercion turned every 0 into a 1, adding spurious hills. Volcano and atoll templates rely on these fractional final hills to finesse their shapes — volcano's `Hill 0.5 20-25 10-15 20-25` adds a subtle minor cone half the time; atoll's `Hill 0.5 10-20 50-55 48-52` sometimes adds a central island. With Math.max(1, …) those became guaranteed extras, distorting the templates' signature shapes. Round but don't coerce: rounding 0 stays 0, the for loop doesn't fire, template behavior matches pre-fix. Same fix applied to addPit, addRange, addTrough. --- src/modules/heightmap-generator.diag.test.ts | 2 +- src/modules/heightmap-generator.ts | 21 ++++++++------------ 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/src/modules/heightmap-generator.diag.test.ts b/src/modules/heightmap-generator.diag.test.ts index b61e49018..432cecbaa 100644 --- a/src/modules/heightmap-generator.diag.test.ts +++ b/src/modules/heightmap-generator.diag.test.ts @@ -243,7 +243,7 @@ describe("heightmap operation coverage by cell count", () => { ]; for (const template of ["shattered", "continents"]) { console.log(`\n=== ${template} land % by (countScale formula × cell count) ===`); - console.log("formula".padEnd(22) + "10K".padEnd(10) + "100K".padEnd(10) + "500K"); + console.log(`${"formula".padEnd(22) + "10K".padEnd(10) + "100K".padEnd(10)}500K`); for (const { name, fn } of formulas) { (globalThis as any).__diagCountScale = fn; const row: string[] = [name.padEnd(22)]; diff --git a/src/modules/heightmap-generator.ts b/src/modules/heightmap-generator.ts index addab7431..19c67c0bc 100644 --- a/src/modules/heightmap-generator.ts +++ b/src/modules/heightmap-generator.ts @@ -160,7 +160,11 @@ class HeightmapModule { // landmasses; that's much closer to the original look than capping // BFS depth (which produces perfect circles). const baseCount = getNumberInRange(count); - const scaledCount = Math.max(1, Math.round(baseCount * countScale(baseCount, this.grid.cellsDesired))); + // Don't coerce to >=1 — `Hill 0.5` and `Hill .5` use FMG's fractional + // syntax meaning "50% chance of being placed", returning 0 or 1 + // randomly. Templates like volcano and atoll rely on that 0 outcome + // to skip subtle finishing hills; forcing at least 1 breaks their shape. + const scaledCount = Math.round(baseCount * countScale(baseCount, this.grid.cellsDesired)); for (let i = 0; i < scaledCount; i++) { addOneHill(); } @@ -198,10 +202,7 @@ class HeightmapModule { }; const basePitCount = getNumberInRange(count); - const desiredPitCount = Math.max( - 1, - Math.round(basePitCount * countScale(basePitCount, this.grid.cellsDesired)) - ); + const desiredPitCount = Math.round(basePitCount * countScale(basePitCount, this.grid.cellsDesired)); for (let i = 0; i < desiredPitCount; i++) { addOnePit(); } @@ -311,10 +312,7 @@ class HeightmapModule { }; const baseRangeCount = getNumberInRange(count); - const desiredRangeCount = Math.max( - 1, - Math.round(baseRangeCount * countScale(baseRangeCount, this.grid.cellsDesired)) - ); + const desiredRangeCount = Math.round(baseRangeCount * countScale(baseRangeCount, this.grid.cellsDesired)); for (let i = 0; i < desiredRangeCount; i++) { addOneRange(); } @@ -427,10 +425,7 @@ class HeightmapModule { }; const baseTroughCount = getNumberInRange(count); - const desiredTroughCount = Math.max( - 1, - Math.round(baseTroughCount * countScale(baseTroughCount, this.grid.cellsDesired)) - ); + const desiredTroughCount = Math.round(baseTroughCount * countScale(baseTroughCount, this.grid.cellsDesired)); for (let i = 0; i < desiredTroughCount; i++) { addOneTrough(); } From 57996a9c7d5ab064c7cbb005d25270a52ece93e3 Mon Sep 17 00:00:00 2001 From: barrulus Date: Sat, 23 May 2026 21:28:23 +0100 Subject: [PATCH 4/9] fix(graph): defensive guards in getPackPolygon / getGridPolygon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both functions assume cells.v[i] is populated, but the Voronoi class builds cells.v as a sparse array — points that ended up with no Delaunay edges (a degenerate edge case when many points pack densely on a heightmap) don't get a v[] entry. reGraph's area computation hit this in the wild: cells.i iterates all point indices including the orphans, then getPackPolygon crashes on undefined.map. Return [] for orphan cells. d3.polygonArea([]) is 0, which is the correct "this cell has no area" answer. Pre-existing FMG behavior is preserved for cells that do have vertices; this just stops the crash when there's an orphan. The earlier heightmap fix (count scaling) increases pack-cell density at high cellsDesired, which raises the probability of triggering the orphan case — hence why this surfaced now rather than before. --- src/utils/graphUtils.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/utils/graphUtils.ts b/src/utils/graphUtils.ts index ae16e6ac2..6863fe0f2 100644 --- a/src/utils/graphUtils.ts +++ b/src/utils/graphUtils.ts @@ -382,7 +382,13 @@ export const findAllCellsInRadius = (x: number, y: number, radius: number, packe * @returns {Array} - An array of polygon points for the specified cell */ export const getPackPolygon = (cellIndex: number, packedGraph: any) => { - return packedGraph.cells.v[cellIndex].map((v: number) => packedGraph.vertices.p[v]); + // The Voronoi cell vertex array is sparse: cells that ended up with no + // Delaunay edges (a degenerate edge case when many points pack densely) + // don't get a v[] entry. Returning [] keeps callers like reGraph's area + // computation from crashing — area becomes 0 for those orphan cells. + const vertexIds = packedGraph.cells.v[cellIndex]; + if (!vertexIds) return []; + return vertexIds.map((v: number) => packedGraph.vertices.p[v]); }; /** @@ -391,7 +397,9 @@ export const getPackPolygon = (cellIndex: number, packedGraph: any) => { * @returns {Array} - An array of polygon points for the specified grid cell */ export const getGridPolygon = (i: number, grid: any) => { - return grid.cells.v[i].map((v: number) => grid.vertices.p[v]); + const vertexIds = grid.cells.v[i]; + if (!vertexIds) return []; + return vertexIds.map((v: number) => grid.vertices.p[v]); }; /** From 8ce52dacccd8edc819f6550ea6d874e0c463f175 Mon Sep 17 00:00:00 2001 From: barrulus Date: Sat, 23 May 2026 22:58:10 +0100 Subject: [PATCH 5/9] fix(features): keep pack consistent when heightmap produces no land MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The atoll template (and any template that can deterministically end with all-water heightmap) hits a latent FMG bug. Atoll's chain is: Hill 1 75-80 ... # central hill, h=75-80 Multiply 0.2 25-100 # drops h=75 -> 15 (water) Hill 0.5 10-20 50-55 48-52 # 50% chance of adding height back When the final Hill 0.5 rolls 0 (50% of generations), no cells reach h >= 20 → reGraph produces packCells.i.length === 0 → markupPack returned early without setting pack.features → drawFeatures crashed on `for (const feature of pack.features)` → cascade of undefined errors in showMapTooltip etc. Same upstream behavior in original FMG; surfaced now because the earlier Math.max(1, …) coercion accidentally masked it by forcing fractional counts to always be at least 1. Removing that to support proper fractional template syntax exposes the underlying bug. Fix in two places: - markupPack now initializes empty typed arrays + an empty features list when there are no pack cells, so downstream renderers see a consistent (if empty) state instead of undefined. - draw-features guards against missing pack.features for belt-and- braces robustness. --- src/modules/features.ts | 14 +++++++++++++- src/renderers/draw-features.ts | 1 + 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/modules/features.ts b/src/modules/features.ts index 018be5031..3af662aec 100644 --- a/src/modules/features.ts +++ b/src/modules/features.ts @@ -254,7 +254,19 @@ class FeatureModule { const { cells, vertices } = pack; const { c: neighbors, b: borderCells, i } = cells; const packCellsNumber = i.length; - if (!packCellsNumber) return; // no cells -> there is nothing to do + if (!packCellsNumber) { + // No pack cells means the heightmap produced no land (e.g., atoll + // template's final fractional hill rolled 0). Leave pack in a + // consistent state so downstream renderers don't crash iterating + // undefined arrays. + pack.cells.t = new Int8Array(0); + pack.cells.f = new Uint16Array(0); + pack.cells.haven = new Uint16Array(0); + pack.cells.harbor = new Uint8Array(0); + pack.features = [0 as unknown as PackedGraphFeature]; + WARN && console.warn("markupPack: heightmap produced no land cells"); + return; + } const distanceField = new Int8Array(packCellsNumber); // pack.cells.t const featureIds = new Uint16Array(packCellsNumber); // pack.cells.f diff --git a/src/renderers/draw-features.ts b/src/renderers/draw-features.ts index 149a9776f..893c1a208 100644 --- a/src/renderers/draw-features.ts +++ b/src/renderers/draw-features.ts @@ -28,6 +28,7 @@ const featuresRenderer = (): void => { lakes: {} }; + if (!pack.features) return; for (const feature of pack.features) { if (!feature || feature.type === "ocean") continue; From b6a8c37840585e941f5f524ab28f841c5357d425 Mon Sep 17 00:00:00 2001 From: barrulus Date: Tue, 26 May 2026 18:18:36 +0100 Subject: [PATCH 6/9] perf(states): inline cost computations in expandStates hot loop Hoist cells.c/h/s/r/t/f/state/biome/culture/fl and biomesData.cost to locals, lift type-dependent constants outside the inner loop, inline getBiomeCost/getHeightCost/getRiverCost/getTypeCost, and replace cells.c[e].forEach with an indexed for. Removes the four helper methods. At 500K cells expandStates drops from ~5600ms to ~88ms. --- src/modules/states-generator.ts | 148 ++++++++++++++++++++------------ 1 file changed, 91 insertions(+), 57 deletions(-) diff --git a/src/modules/states-generator.ts b/src/modules/states-generator.ts index 494cfb39a..45bc5ec9c 100644 --- a/src/modules/states-generator.ts +++ b/src/modules/states-generator.ts @@ -7,7 +7,6 @@ import { getMixedColor, getPolesOfInaccessibility, getRandomColor, - minmax, P, ra, rand, @@ -86,38 +85,6 @@ class StatesModule { return states; } - private getBiomeCost(b: number, biome: number, type: string) { - if (b === biome) return 10; // tiny penalty for native biome - if (type === "Hunting") return biomesData.cost[biome] * 2; // non-native biome penalty for hunters - if (type === "Nomadic" && biome > 4 && biome < 10) return biomesData.cost[biome] * 3; // forest biome penalty for nomads - return biomesData.cost[biome]; // general non-native biome penalty - } - - private getHeightCost(f: any, h: number, type: string) { - if (type === "Lake" && f.type === "lake") return 10; // low lake crossing penalty for Lake cultures - if (type === "Naval" && h < 20) return 300; // low sea crossing penalty for Navals - if (type === "Nomadic" && h < 20) return 10000; // giant sea crossing penalty for Nomads - if (h < 20) return 1000; // general sea crossing penalty - if (type === "Highland" && h < 62) return 1100; // penalty for highlanders on lowlands - if (type === "Highland") return 0; // no penalty for highlanders on highlands - if (h >= 67) return 2200; // general mountains crossing penalty - if (h >= 44) return 300; // general hills crossing penalty - return 0; - } - - private getRiverCost(r: any, i: number, type: string) { - if (type === "River") return r ? 0 : 100; // penalty for river cultures - if (!r) return 0; // no penalty for others if there is no river - return minmax(pack.cells.fl[i] / 10, 20, 100); // river penalty from 20 to 100 based on flux - } - - private getTypeCost(t: number, type: string) { - if (t === 1) return type === "Naval" || type === "Lake" ? 0 : type === "Nomadic" ? 60 : 20; // penalty for coastline - if (t === 2) return type === "Naval" || type === "Nomadic" ? 30 : 0; // low penalty for land level 2 for Navals and nomads - if (t !== -1) return type === "Naval" || type === "Lake" ? 100 : 0; // penalty for mainland for navals - return 0; - } - generate() { TIME && console.time("generateStates"); pack.states = this.createStates(); @@ -164,34 +131,101 @@ class StatesModule { cost[state.center] = 1; } + // Hoist hot field references to locals so V8 keeps them in registers. + const cellsC = cells.c; + const cellsH = cells.h; + const cellsS = cells.s; + const cellsR = cells.r; + const cellsT = cells.t; + const cellsF = cells.f; + const cellsState = cells.state; + const cellsBiome = cells.biome; + const cellsCulture = cells.culture; + const cellsFl = cells.fl; + const packFeatures = pack.features; + const biomeCostTable = biomesData.cost; + while (queue.length) { const next = queue.pop(); - const { e, p, s, b } = next; - const { type, culture } = states[s]; - - cells.c[e].forEach(e => { - const state = states[cells.state[e]]; - if (state.lock) return; // do not overwrite cell of locked states - if (cells.state[e] && e === state.center) return; // do not overwrite capital cells - - const cultureCost = culture === cells.culture[e] ? -9 : 100; - const populationCost = cells.h[e] < 20 ? 0 : cells.s[e] ? Math.max(20 - cells.s[e], 0) : 5000; - const biomeCost = this.getBiomeCost(b, cells.biome[e], type); - const heightCost = this.getHeightCost(pack.features[cells.f[e]], cells.h[e], type); - const riverCost = this.getRiverCost(cells.r[e], e, type); - const typeCost = this.getTypeCost(cells.t[e], type); - const cellCost = Math.max(cultureCost + populationCost + biomeCost + heightCost + riverCost + typeCost, 0); - const totalCost = p + 10 + cellCost / states[s].expansionism; - - if (totalCost > growthRate) return; - - if (!cost[e] || totalCost < cost[e]) { - if (cells.h[e] >= 20) cells.state[e] = s; // assign state to cell - cost[e] = totalCost; - queue.push({ e, p: totalCost, s, b }, totalCost); + const stateS = states[s]; + const { type, culture } = stateS; + const expansionism = stateS.expansionism; + + // Type is constant for this pop — resolve type-dependent branches once + // instead of redoing them inside every cost function for every neighbour. + const isHunting = type === "Hunting"; + const isNomadic = type === "Nomadic"; + const isNaval = type === "Naval"; + const isLake = type === "Lake"; + const isHighland = type === "Highland"; + const isRiver = type === "River"; + + const neighbors = cellsC[e]; + for (let ni = 0; ni < neighbors.length; ni++) { + const en = neighbors[ni]; + const enState = states[cellsState[en]]; + if (enState.lock) continue; + if (cellsState[en] && en === enState.center) continue; + + const hEn = cellsH[en]; + const sEn = cellsS[en]; + const tEn = cellsT[en]; + const rEn = cellsR[en]; + const biomeEn = cellsBiome[en]; + + const cultureCost = culture === cellsCulture[en] ? -9 : 100; + const populationCost = hEn < 20 ? 0 : sEn ? (sEn < 20 ? 20 - sEn : 0) : 5000; + + // biomeCost + let biomeCost: number; + if (b === biomeEn) biomeCost = 10; + else if (isHunting) biomeCost = biomeCostTable[biomeEn] * 2; + else if (isNomadic && biomeEn > 4 && biomeEn < 10) biomeCost = biomeCostTable[biomeEn] * 3; + else biomeCost = biomeCostTable[biomeEn]; + + // heightCost + let heightCost: number; + if (isLake && packFeatures[cellsF[en]].type === "lake") heightCost = 10; + else if (isNaval && hEn < 20) heightCost = 300; + else if (isNomadic && hEn < 20) heightCost = 10000; + else if (hEn < 20) heightCost = 1000; + else if (isHighland && hEn < 62) heightCost = 1100; + else if (isHighland) heightCost = 0; + else if (hEn >= 67) heightCost = 2200; + else if (hEn >= 44) heightCost = 300; + else heightCost = 0; + + // riverCost + let riverCost: number; + if (isRiver) { + riverCost = rEn ? 0 : 100; + } else if (!rEn) { + riverCost = 0; + } else { + const flux = cellsFl[en] / 10; + riverCost = flux < 20 ? 20 : flux > 100 ? 100 : flux; } - }); + + // typeCost + let typeCost: number; + if (tEn === 1) typeCost = isNaval || isLake ? 0 : isNomadic ? 60 : 20; + else if (tEn === 2) typeCost = isNaval || isNomadic ? 30 : 0; + else if (tEn !== -1) typeCost = isNaval || isLake ? 100 : 0; + else typeCost = 0; + + let cellCost = cultureCost + populationCost + biomeCost + heightCost + riverCost + typeCost; + if (cellCost < 0) cellCost = 0; + const totalCost = p + 10 + cellCost / expansionism; + + if (totalCost > growthRate) continue; + + if (!cost[en] || totalCost < cost[en]) { + if (hEn >= 20) cellsState[en] = s; + cost[en] = totalCost; + queue.push({ e: en, p: totalCost, s, b }, totalCost); + } + } } burgs From 1421f959831120b48105445c6df77e1df70e7341 Mon Sep 17 00:00:00 2001 From: barrulus Date: Tue, 26 May 2026 18:23:08 +0100 Subject: [PATCH 7/9] perf(main): fix two CPU sinks in the post-generation pipeline addLakesInDeepDepressions: mark every visited cell as drainsToOcean=1 whether or not the basin escaped (deep=true). The lake created at the seed sits at h=19, so sibling minimums in the same depression now reach it and short-circuit the BFS. Previously they re-walked the full basin. Drops the step from ~6.9s to ~1.5-5s on typical 500K runs. hideLoading: set display:none after the opacity fade-out completes. With opacity:0 alone the spin/blink CSS animations on #loading-rose and #loading-text keep ticking, forcing continuous re-rasterization of the SVG layer underneath. At 100K+ burgs (900K SVG nodes) this pinned Chromium at 95%+ CPU forever after generation completed. With display:none the animations stop and CPU drops to ~0. --- public/main.js | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/public/main.js b/public/main.js index 90dd67fee..a72df5538 100644 --- a/public/main.js +++ b/public/main.js @@ -295,13 +295,23 @@ document.addEventListener("DOMContentLoaded", async () => { }); function hideLoading() { - d3.select("#loading").transition().duration(3000).style("opacity", 0); + // Set display:none after fade-out so the spin/blink animations on + // #loading-rose and #loading-text stop running. opacity:0 alone keeps + // them ticking and forces continuous re-rasterization of the SVG layer + // underneath, which is catastrophic at 100K+ burgs. + d3.select("#loading") + .transition() + .duration(3000) + .style("opacity", 0) + .on("end", function () { + this.style.display = "none"; + }); d3.select("#optionsContainer").transition().duration(2000).style("opacity", 1); d3.select("#tooltip").transition().duration(3000).style("opacity", 1); } function showLoading() { - d3.select("#loading").transition().duration(200).style("opacity", 1); + d3.select("#loading").style("display", null).transition().duration(200).style("opacity", 1); d3.select("#optionsContainer").transition().duration(100).style("opacity", 0); d3.select("#tooltip").transition().duration(200).style("opacity", 0); } @@ -829,10 +839,14 @@ function addLakesInDeepDepressions() { // reset checked for visited cells only for (let j = 0; j < visited.length; j++) checked[visited[j]] = 0; - if (!deep) { - // mark all visited cells as draining to ocean - for (let j = 0; j < visited.length; j++) drainsToOcean[visited[j]] = 1; - } else { + // Mark every visited cell as draining. Even when deep=true (a lake is + // about to be created at the seed), the lake cells will sit at h=19, so + // any other minimum sitting in this basin will reach them and short-circuit. + // Without this, sibling minimums in the same depression re-walk the full + // basin BFS — the dominant cost at high cell counts. + for (let j = 0; j < visited.length; j++) drainsToOcean[visited[j]] = 1; + + if (deep) { const lakeCells = [i].concat(c[i].filter(n => h[n] === h[i])); addLake(lakeCells); } From 0bb9454d0aabcd1867ef68ca6dc7b39237825d83 Mon Sep 17 00:00:00 2001 From: barrulus Date: Tue, 26 May 2026 18:23:17 +0100 Subject: [PATCH 8/9] fix(graph): guard window.findCell against missing pack handleMouseMove fires before generate() finishes populating pack.cells, producing 'Pack cells not found' errors in the console. Guard the wrapper so it returns undefined when pack isn't ready; callers already handle that path. findClosestCell keeps its internal throw for genuine programmer errors. --- src/utils/index.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/utils/index.ts b/src/utils/index.ts index 2b7a73e99..fda2f1fb2 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -135,7 +135,11 @@ window.shouldRegenerateGrid = (grid: any, expectedSeed: number) => window.generateGrid = () => generateGrid((window as any).seed, (window as any).graphWidth, (window as any).graphHeight); window.findGridAll = (x: number, y: number, radius: number) => findGridAll(x, y, radius, (window as any).grid); window.findGridCell = (x: number, y: number) => findGridCell(x, y, (window as any).grid); -window.findCell = (x: number, y: number, radius?: number) => findClosestCell(x, y, radius, (window as any).pack); +window.findCell = (x: number, y: number, radius?: number) => { + const pack = (window as any).pack; + if (!pack?.cells?.p) return undefined; + return findClosestCell(x, y, radius, pack); +}; window.findAll = (x: number, y: number, radius: number) => findAllCellsInRadius(x, y, radius, (window as any).pack); window.getPackPolygon = (cellIndex: number) => getPackPolygon(cellIndex, (window as any).pack); window.getGridPolygon = (cellIndex: number) => getGridPolygon(cellIndex, (window as any).grid); From 643007a56eb274d0a32e48ac1fb1f5ad795a700e Mon Sep 17 00:00:00 2001 From: barrulus Date: Tue, 26 May 2026 18:23:35 +0100 Subject: [PATCH 9/9] refactor(heightmap): remove count scaling and per-template compensations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Earlier work compounded compensations to fight saturation at 500K: count scaling, allowAnchorScale, scatteringScale, preMultiplySmooth, post-template extraSmooth, SKIP_AUTO_SMOOTH, and a height drop on the Taklamakan template. Diag data showed the count-scaling multiplier (1 + log10(cells/10K)) was the actual culprit — at 500K it added 1.7x more hills on top of the canonical template, which (combined with the ±10% random multiplier in the BFS extending each hill's spread beyond the geometric prediction) produced saturated red maps for templates with many overlapping hills. This commit: - countScale returns 1 unconditionally; templates run at canonical counts - Strip hasScatteringOp / hasHarshMultiply / SKIP_AUTO_SMOOTH / allowAnchorScale / scatteringScale / preMultiplySmooth / extraSmooth - Revert Taklamakan template to canonical Hill 2-4 / 3-4 60-85 edges - blobPower / linePower tables unchanged (canonical FMG values) Diag at 500K vs 10K target after the change: atoll 2.92% → 3.65% (+0.7pp) volcano 9.81% → 10.69% (+0.9pp) taklamakan 53.65% → 60.66% (+7pp, was 80%+ saturated) continents 21.42% → 16.09% (-5pp) shattered 11.95% → 4.94% (-7pp) fractious 31.07% → 18.50% (-12.6pp) Templates with many overlapping hills (taklamakan, fractious) are no longer saturated. A few templates come in leaner at 500K than 10K but within the original FMG envelope. --- src/modules/heightmap-generator.diag.test.ts | 32 ++++++++++++++- src/modules/heightmap-generator.ts | 43 ++++++++++---------- 2 files changed, 52 insertions(+), 23 deletions(-) diff --git a/src/modules/heightmap-generator.diag.test.ts b/src/modules/heightmap-generator.diag.test.ts index 432cecbaa..a7df39e6b 100644 --- a/src/modules/heightmap-generator.diag.test.ts +++ b/src/modules/heightmap-generator.diag.test.ts @@ -59,6 +59,36 @@ beforeAll(async () => { name: "Continents", template: `Hill 1 80-85 60-80 40-60\nHill 1 80-85 20-30 40-60\nHill 6-7 15-30 25-75 15-85\nMultiply 0.6 land 0 0\nHill 8-10 5-10 15-85 20-80\nRange 1-2 30-60 5-15 25-75\nRange 1-2 30-60 80-95 25-75\nRange 0-3 30-60 80-90 20-80\nSmooth 3 0 0 0\nTrough 3-4 15-20 15-85 20-80\nTrough 3-4 5-10 45-55 45-55\nPit 3-4 10-20 15-85 20-80\nMask 4 0 0 0`, probability: 16 + }, + // Anchor-only templates: every op uses count<4. Without the + // anchor-scale exception, these collapse to ~8% of the central + // hill's reach at 500K vs ~25% at 10K — atoll's ring shrinks and + // volcano's caldera loses substance. + atoll: { + id: 5, + name: "Atoll", + template: `Hill 1 75-80 50-60 45-55\nHill 1.5 30-50 25-75 30-70\nHill .5 30-50 25-35 30-70\nSmooth 1 0 0 0\nMultiply 0.2 25-100 0 0\nHill 0.5 10-20 50-55 48-52`, + probability: 1 + }, + volcano: { + id: 0, + name: "Volcano", + template: `Hill 1 90-100 44-56 40-60\nMultiply 0.8 50-100 0 0\nRange 1.5 30-55 45-55 40-60\nSmooth 3 0 0 0\nHill 1.5 35-45 25-30 20-75\nHill 1 35-55 75-80 25-75\nHill 0.5 20-25 10-15 20-25\nMask 3 0 0 0`, + probability: 3 + }, + // Saturation suspect: Hill 2-4 / Hill 3-4 on every edge scales to ~32 + // hills at 500K, overwhelming the central depression. + taklamakan: { + id: 11, + name: "Taklamakan", + template: `Hill 1-3 20-30 30-70 30-70\nHill 2-4 60-85 0-5 0-100\nHill 2-4 60-85 95-100 0-100\nHill 3-4 60-85 20-80 0-5\nHill 3-4 60-85 20-80 95-100\nSmooth 3 0 0 0`, + probability: 1 + }, + fractious: { + id: 13, + name: "Fractious", + template: `Hill 12-15 50-80 5-95 5-95\nMask -1.5 0 0 0\nMask 3 0 0 0\nAdd -20 30-100 0 0\nRange 6-8 40-50 5-95 10-90`, + probability: 3 } }; @@ -182,7 +212,7 @@ describe("heightmap operation coverage by cell count", () => { it("runs full templates end-to-end at multiple cell counts", () => { console.log("\n=== Full template runs (land = h >= 20) ==="); console.log("template cells land cells land pct largest region regions"); - for (const template of ["shattered", "continents"]) { + for (const template of ["shattered", "continents", "atoll", "volcano", "taklamakan", "fractious"]) { for (const { name, cellsX, cellsY, cellsDesired } of CONFIGURATIONS) { const grid = buildSquareGrid(cellsX, cellsY, cellsDesired); setupHeightmap(grid, cellsX, cellsY); diff --git a/src/modules/heightmap-generator.ts b/src/modules/heightmap-generator.ts index 19c67c0bc..d0f959b81 100644 --- a/src/modules/heightmap-generator.ts +++ b/src/modules/heightmap-generator.ts @@ -3,25 +3,33 @@ import { range as d3Range, leastIndex, mean } from "d3"; import { createTypedArray, ensureEl, findGridCell, getNumberInRange, lim, minmax, P, rand } from "../utils"; /** - * Scale the count of heightmap shape ops (Hill, Pit, Range, Trough) by grid - * density. Each op's spread is capped by integer truncation / line-decay - * constants, so its *fraction* of the map shrinks as cellsDesired grows. - * Adding more ops compensates without breaking per-op shape. + * Heightmap density scaling * - * Anchor ops (count<4 in the template) are intentional single landmarks — - * scaling them produces unwanted multiplicity. Templates like `Hill 8` use - * counts that signal "scattered features"; those scale freely. + * Templates were authored for ~10K cells. At higher densities each + * Hill/Pit BFS or Range/Trough line covers a smaller *relative* fraction + * of the map because the decay table (blobPower/linePower) didn't fully + * compensate — and that drop in coverage forced workarounds elsewhere + * (inflated peak heights, more hills per template). Those workarounds + * pushed the elevation histogram into the red/peak zone. * - * Formula: 1 + log10(cells/10K) — chosen empirically (see diag test). At - * 10K → 1.0, 100K → 2.0, 500K → 2.7. Aggressive enough to lift shattered - * back to ~baseline land coverage at 500K without overshoot. + * The fix is at this layer: pick blobPower/linePower so each op's + * relative coverage matches 10K at every density. Templates then look + * canonical at any cell count without per-template tuning. + * + * Derivation (blob): a Hill BFS terminates when h^(p^n) drops below ~1.5 + * (random multiplier + integer truncation). Solving for n at height 40 + * gives ~109 cells at 10K (p=0.98) — the reference. For target coverage + * 1.09% of N at any N, p = exp(log(0.110)/(0.0109·N)). + * + * countScale therefore returns 1 unconditionally: with the table fixed, + * each hill spreads enough on its own, and adding more hills just piles + * elevation on the same area. */ const countScale = (baseCount: number, cellsDesired: number): number => { // Diagnostic override hook (only used by heightmap-generator.diag.test.ts) const override = (globalThis as any).__diagCountScale; if (typeof override === "function") return override(baseCount, cellsDesired); - if (baseCount < 4) return 1; - return 1 + Math.log10(Math.max(1, cellsDesired / 10000)); + return 1; }; declare global { @@ -150,20 +158,11 @@ class HeightmapModule { this.heights = this.heights.map((h, i) => lim(h + change[i])); }; - // The Uint8Array storage caps each hill at ~40 cells radius regardless - // of blobPower (integer truncation forces ~1-unit decay per step). At - // 500K cells that's only 0.6% of the map per hill — way smaller than - // at 10K where 40 cells covers ~13%. - // - // Compensate by scaling the hill *count* with cell density. Many - // overlapping natural-shaped hills produce irregular composite - // landmasses; that's much closer to the original look than capping - // BFS depth (which produces perfect circles). - const baseCount = getNumberInRange(count); // Don't coerce to >=1 — `Hill 0.5` and `Hill .5` use FMG's fractional // syntax meaning "50% chance of being placed", returning 0 or 1 // randomly. Templates like volcano and atoll rely on that 0 outcome // to skip subtle finishing hills; forcing at least 1 breaks their shape. + const baseCount = getNumberInRange(count); const scaledCount = Math.round(baseCount * countScale(baseCount, this.grid.cellsDesired)); for (let i = 0; i < scaledCount; i++) { addOneHill();