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); } 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/modules/heightmap-generator.diag.test.ts b/src/modules/heightmap-generator.diag.test.ts new file mode 100644 index 000000000..a7df39e6b --- /dev/null +++ b/src/modules/heightmap-generator.diag.test.ts @@ -0,0 +1,313 @@ +/** + * 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 + }, + // 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 + } + }; + + 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", "atoll", "volcano", "taklamakan", "fractious"]) { + 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 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("")); + } + } + (globalThis as any).__diagCountScale = undefined; + }); + + 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..d0f959b81 100644 --- a/src/modules/heightmap-generator.ts +++ b/src/modules/heightmap-generator.ts @@ -2,6 +2,36 @@ import Alea from "alea"; import { range as d3Range, leastIndex, mean } from "d3"; import { createTypedArray, ensureEl, findGridCell, getNumberInRange, lim, minmax, P, rand } from "../utils"; +/** + * Heightmap density scaling + * + * 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. + * + * 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); + return 1; +}; + declare global { var HeightmapGenerator: HeightmapModule; } @@ -128,8 +158,13 @@ class HeightmapModule { this.heights = this.heights.map((h, i) => lim(h + change[i])); }; - const desiredHillCount = getNumberInRange(count); - for (let i = 0; i < desiredHillCount; i++) { + // 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(); } } @@ -165,7 +200,8 @@ class HeightmapModule { } }; - const desiredPitCount = getNumberInRange(count); + const basePitCount = getNumberInRange(count); + const desiredPitCount = Math.round(basePitCount * countScale(basePitCount, this.grid.cellsDesired)); for (let i = 0; i < desiredPitCount; i++) { addOnePit(); } @@ -274,7 +310,8 @@ class HeightmapModule { }); }; - const desiredRangeCount = getNumberInRange(count); + const baseRangeCount = getNumberInRange(count); + const desiredRangeCount = Math.round(baseRangeCount * countScale(baseRangeCount, this.grid.cellsDesired)); for (let i = 0; i < desiredRangeCount; i++) { addOneRange(); } @@ -386,7 +423,8 @@ class HeightmapModule { }); }; - const desiredTroughCount = getNumberInRange(count); + const baseTroughCount = getNumberInRange(count); + const desiredTroughCount = Math.round(baseTroughCount * countScale(baseTroughCount, this.grid.cellsDesired)); for (let i = 0; i < desiredTroughCount; i++) { addOneTrough(); } 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 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; 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]); }; /** 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);