From 4e670f38c96ae35d7c8719ab747b9c2f4dd0de28 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Mon, 30 Mar 2026 22:27:12 -0700 Subject: [PATCH] =?UTF-8?q?feat(engine):=20=E2=9C=A8=20Regenerate=20climat?= =?UTF-8?q?e/ecology=20physics,=20update=20biome=20classification,=20and?= =?UTF-8?q?=20enhance=20engine=20runner/exports=20for=20improved=20simulat?= =?UTF-8?q?ion=20and=20performance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../engine-ts/src/ClimatePhysics.generated.ts | 1354 -------------- .../engine-ts/src/EcologyPhysics.generated.ts | 1205 ------------- .../engine-ts/src/MapGenerator.generated.ts | 1578 ----------------- packages/engine-ts/src/biomeClassifier.ts | 151 ++ packages/engine-ts/src/index.ts | 4 +- packages/engine-ts/src/runner.ts | 96 +- 6 files changed, 221 insertions(+), 4167 deletions(-) delete mode 100644 packages/engine-ts/src/ClimatePhysics.generated.ts delete mode 100644 packages/engine-ts/src/EcologyPhysics.generated.ts delete mode 100644 packages/engine-ts/src/MapGenerator.generated.ts create mode 100644 packages/engine-ts/src/biomeClassifier.ts diff --git a/packages/engine-ts/src/ClimatePhysics.generated.ts b/packages/engine-ts/src/ClimatePhysics.generated.ts deleted file mode 100644 index 198d57e1..00000000 --- a/packages/engine-ts/src/ClimatePhysics.generated.ts +++ /dev/null @@ -1,1354 +0,0 @@ -// AUTO-GENERATED from GDScript climate engine — do not edit manually. -// Source: engine/src/modules/climate/climate.gd + climate_base.gd + ecological_events.gd + anchor_decay.gd + climate_spec_eval.gd -// Regenerate: pnpm transpile (in guide/) -// To update golden regression vectors after physics change: pnpm gen-vectors - -import type { GridState, TileState, TerrainData, EcologicalEvent } from './types' -import { idx, neighbors, upwindPos, solarByRow, classifyTerrain, hashNoise, neighborInDir } from './HexGrid' -import { hasTag } from './biomeRegistry' - -// --------------------------------------------------------------------------- -// Defaults (mirrors climate.gd _DEFAULTS — used when params JSON absent) -// --------------------------------------------------------------------------- - -const CLIMATE_DEFAULTS: Record = { - wind_conductivity: 0.1, - energy_scale: 0.005, - equilibrium_relaxation: 0.08, - evaporation_rate: 0.05, - moisture_transport: 0.15, - precipitation_threshold: 0.7, - moisture_decay: 0.995, - moisture_relaxation: 0.04, - ocean_evaporation_hops: 4, - ocean_evaporation_hop_decay: 0.5, - atmospheric_loss_rate: 0.0003, - quality_up_threshold: 10, - quality_down_threshold: 5, - lake_thermal_conductivity: 0.05, - river_moisture_transport: 0.075, - mountain_rain_shadow_block: 0.9, - solar_min: 0.05, - solar_max: 0.70, -} - -const DEW_DEFAULTS: Record = { - volcano_self: 0.008, - volcano_neighbor: 0.004, - hot_spring_self: 0.005, - hot_spring_neighbor: 0.002, - high_elevation_self: 0.002, - high_elevation_threshold: 0.8, -} - -// --------------------------------------------------------------------------- -// ODD_Q neighbor offsets — odd-q offset grid, directions 0-5 (E,NE,NW,W,SW,SE) -// --------------------------------------------------------------------------- -const ODD_Q: readonly [readonly [number, number][], readonly [number, number][]] = [ - [[1, 0], [1, -1], [0, -1], [-1, 0], [-1, 1], [0, 1]], - [[1, 0], [1, 1], [0, 1], [-1, 0], [-1, -1], [0, -1]], -] as const - -// --------------------------------------------------------------------------- -// Spec evaluation helpers (from climate_spec_eval.gd) -// --------------------------------------------------------------------------- - -export function idealTerrain( - tile: TileState, - spec: Record, -): string { - const tid = tile.biome_id - const temp = tile.temperature - const moist = tile.moisture - const elev = tile.elevation - const canopy = tile.canopy_cover ?? 0 - - const transitions = (spec['terrain_transitions'] ?? {}) as Record>> - const rules: Array> = transitions[tid] ?? [] - if (rules.length === 0) return classifyTerrain(temp, moist, elev) - - for (const rule of rules) { - const cond = rule['condition'] as string ?? '' - if (evalCondition(cond, temp, moist, elev, canopy)) { - const becomes = rule['becomes'] as string ?? '' - if (becomes === 'classify') return classifyTerrain(temp, moist, elev) - return becomes - } - } - return tid -} - -export function leyChannelingMult( - tile: TileState, - spec: Record, -): number { - if (tile.ley_line_count <= 0) return 1.0 - const leySpec = (spec['ley_channeling'] ?? {}) as Record - const school = tile.ley_school - if (school === 'death') return leySpec['death_ley'] ?? 3.0 - if (school === 'nature' || school === 'life') return leySpec['nature_life_ley'] ?? 0.5 - return leySpec['on_ley_generic'] ?? 2.0 -} - -export function evalCondition(cond: string, temp: number, moist: number, elev: number, canopy = 0): boolean { - if (cond.includes(' OR ')) { - return cond.split(' OR ').some(part => evalCondition(part.trim(), temp, moist, elev, canopy)) - } - if (cond.includes(' AND ')) { - return cond.split(' AND ').every(part => evalCondition(part.trim(), temp, moist, elev, canopy)) - } - let clean = cond.trim() - if (clean.startsWith('(') && clean.endsWith(')')) clean = clean.slice(1, -1) - const tokens = clean.trim().split(' ') - if (tokens.length < 3) return false - const [field, op, valStr] = tokens - const value = parseFloat(valStr) - let actual: number - switch (field) { - case 'temperature': actual = temp; break - case 'moisture': actual = moist; break - case 'elevation': actual = elev; break - case 'canopy': actual = canopy; break - default: return false - } - switch (op) { - case '<': return actual < value - case '<=': return actual <= value - case '>': return actual > value - case '>=': return actual >= value - default: console.warn(`ClimateSpecEval: unknown op '${op}'`); return false - } -} - -// --------------------------------------------------------------------------- -// ClimatePhysics class (transpiled from climate.gd) -// --------------------------------------------------------------------------- - -export class ClimatePhysics { - private readonly params: Record - private readonly terrainCache: Map - readonly spec: Record - private oceanDistCache: Int32Array | null = null - private oceanDistGridId = -1 - - constructor( - params: Record, - terrainCache: Map, - spec: Record = {}, - ) { - this.params = params - this.terrainCache = terrainCache - this.spec = spec - } - - private p(key: string, fallback: number): number { - return this.params[key] ?? CLIMATE_DEFAULTS[key] ?? fallback - } - - /** BFS ocean distance cache. */ - private ensureOceanDist(grid: GridState): void { - const { tiles, width: w, height: h } = grid - const n = tiles.length - if (this.oceanDistCache && this.oceanDistCache.length === n && this.oceanDistGridId === n) return - this.oceanDistCache = new Int32Array(n).fill(20) - const queue: number[] = [] - for (let i = 0; i < n; i++) { - const tid = tiles[i].biome_id - if (tid === 'ocean' || tid === 'coast' || tid === 'lake' || tid === 'inland_sea') { - this.oceanDistCache[i] = 0 - queue.push(i) - } - } - let head = 0 - while (head < queue.length) { - const ci = queue[head++] - const tile = tiles[ci] - const dist = this.oceanDistCache[ci] - if (dist >= 20) continue - for (const { col, row } of neighbors(tile.col, tile.row, w, h)) { - const ni = idx(col, row, w) - if (this.oceanDistCache[ni] <= dist + 1) continue - this.oceanDistCache[ni] = dist + 1 - queue.push(ni) - } - } - this.oceanDistGridId = n - } - - /** Climate moisture baseline for equilibrium relaxation. */ - private moistureBaseline(row: number, h: number, tileIdx: number): number { - const centerRow = h / 2.0 - const latNorm = Math.abs((row - centerRow) / centerRow) - const latMoisture = 0.5 + (0.15 - 0.5) * latNorm - const dist = this.oceanDistCache ? this.oceanDistCache[tileIdx] : 20 - let proxFactor = 1.0 - if (dist > 3) proxFactor = Math.max(0.3, 1.0 - (dist - 3) * 0.15) - return latMoisture * proxFactor - } - - getPhaseLabel(grid: GridState): string { - const t = grid.global_avg_temp - if (t < 0.15) return 'Ice Age' - if (t < 0.30) return 'Deep Winter' - if (t < 0.45) return 'Cold Cycle' - if (t < 0.75) return 'Temperate' - return 'Inferno' - } - - processStep(grid: GridState, turn = 0, seed = 42): EcologicalEvent[] { - this.ensureOceanDist(grid) - this.stepCollectMagicForcing(grid) - this.stepOrbitalForcing(grid, turn) - this.stepAerosolForcing(grid) - // Atmosphere: pressure → anomalies → wind → humidity (before temperature) - this.stepBaselinePressure(grid) - this.stepAnomalies(grid, turn) - this.stepWindFromPressure(grid) - this.stepHumidity(grid) - this.stepTemperature(grid) - this.stepLakeThermal(grid) - this.stepMoistureWind(grid) - this.stepMoistureRivers(grid) - this.stepLakeEvaporation(grid) - this.stepDeepEarthWater(grid) - this.stepPrecipitation(grid) - this.stepSurfaceRunoff(grid) - this.stepTerrainEvolution(grid) - this.stepSeaLevel(grid) - const events = this.stepEcologicalEvents(grid, turn, seed) - this.stepAnchorDecay(grid) - this.stepGlobalStats(grid) - this.stepClearDeltas(grid) - return events - } - - private stepOrbitalForcing(grid: GridState, turn: number): void { - const t = turn - let totalDelta = 0.0 - for (let i = 1; i <= 3; i++) { - const prefix = `orbital_cycle_${i}_` - const period = this.p(prefix + 'period', 0.0) - if (period <= 0.0) continue - const amplitude = this.p(prefix + 'amplitude', 0.0) - const phase = this.p(prefix + 'phase', 0.0) - totalDelta += amplitude * Math.sin(2 * Math.PI * (t / period) + phase) - } - - if (Math.abs(totalDelta) < 0.0001) return - - const moistureCoupling = this.p('orbital_moisture_coupling', 0.5) - - for (const tile of grid.tiles) { - tile.magic_heat_delta += totalDelta - tile.magic_moisture_delta += totalDelta * moistureCoupling - } - } - - private stepCollectMagicForcing(grid: GridState): void { - // magic_heat_delta and magic_moisture_delta are written directly by weather.gd - // each turn before process_turn() is called. This step is a pre-processing hook. - - } - - private stepAerosolForcing(grid: GridState): void { - const aerosol_cfg = ((this.spec['aerosol'] ?? {}) as Record) - if (Object.keys(aerosol_cfg).length === 0) return - const { tiles, width: w, height: h } = grid - - // Phase 0: Natural aerosol sources — background, desert dust, volcanic outgassing - const bg = this.p('aerosol_background', 0.002) - const dustRate = this.p('aerosol_desert_dust', 0.005) - const volcanicRate = this.p('aerosol_volcanic_outgas', 0.015) - for (let i = 0; i < tiles.length; i++) { - const tile = tiles[i] - let inject = bg - if (tile.biome_id === 'desert') inject += dustRate - else if (tile.biome_id === 'volcano') inject += volcanicRate - ;(tile as any).sulfate_aerosol = Math.max(0.0, ((tile as any).sulfate_aerosol ?? 0) + inject) - } - - let any_aerosol = false - for (let i = 0; i < tiles.length; i++) { - if (((tiles[i] as any).sulfate_aerosol ?? 0) > 0.1) { any_aerosol = true; break } - } - if (!any_aerosol) return - const cooling_rate = aerosol_cfg['cooling_rate'] ?? 0.06 - const drying_rate = aerosol_cfg['drying_rate'] ?? 0.03 - const transport_rate = aerosol_cfg['wind_transport_rate'] ?? 0.12 - const decay_rate = aerosol_cfg['decay_rate'] ?? 0.05 - // Phase 1: Apply solar-blocking cooling and drying - for (let i = 0; i < tiles.length; i++) { - const tile = tiles[i] - const sa: number = (tile as any).sulfate_aerosol ?? 0 - if (sa <= 0.0) continue - let effective = Math.min(1.0, sa) - const mitigation: number = (tile as any).aerosol_mitigation ?? 0 - if (mitigation > 0.0) effective *= (1.0 - Math.min(1.0, Math.max(0.0, mitigation))) - tile.magic_heat_delta -= effective * cooling_rate - tile.magic_moisture_delta -= effective * drying_rate - } - // Phase 2: Wind transport — double-buffered snapshot to avoid order-dependency - const snapshot = new Map() - for (let i = 0; i < tiles.length; i++) { - const sa: number = (tiles[i] as any).sulfate_aerosol ?? 0 - if (sa > 0.001) snapshot.set(i, sa) - } - for (const [i, sa] of snapshot) { - const tile = tiles[i] - const transported = sa * transport_rate - const dw = neighborInDir(tile.col, tile.row, tile.wind_direction, w, h) - if (dw !== null) { - const dwTile = tiles[idx(dw.col, dw.row, w)] as any - dwTile.sulfate_aerosol = (dwTile.sulfate_aerosol ?? 0) + transported - } - ;(tile as any).sulfate_aerosol = Math.max(0.0, sa - transported) - } - // Phase 3: Exponential decay - for (let i = 0; i < tiles.length; i++) { - const sa: number = (tiles[i] as any).sulfate_aerosol ?? 0 - if (sa > 0.0) (tiles[i] as any).sulfate_aerosol = Math.max(0.0, sa - decay_rate) - } - } - - private stepTemperature(grid: GridState): void { - const { tiles, width: w, height: h } = grid - let conductivity = (this.params as any)["wind_conductivity"] ?? 0.1 - let energy_scale = (this.params as any)["energy_scale"] ?? 0.005 - let relaxation = (this.params as any)["equilibrium_relaxation"] ?? 0.08 - - // Pre-compute solar baseline per row — same value for every tile in a row - for (let row = 0; row < h; row++) { - - } - // Snapshot current temperatures into a read buffer — prevents stale reads - // when upwind tiles have already been updated in this same pass. - const oldTemp = new Float32Array(tiles.length) - for (let i = 0; i < tiles.length; i++) { - oldTemp[i] = tiles[i].temperature - - } - // Compute new temperatures from old values only, write into new_temp buffer - const newTemp = new Float32Array(tiles.length) - for (let i = 0; i < tiles.length; i++) { - const tile = tiles[i] - const { col, row } = tile - let solar = solarByRow(row, h, this.p('solar_min', 0.05), this.p('solar_max', 0.70)) - let current_temp = oldTemp[i] - - let terrain_data = (this.terrainCache.get(tile.biome_id) ?? {}) - let albedo = (terrain_data as any)["albedo"] ?? 0.4 - let net_solar = solar * (1.0 - albedo) * energy_scale - - // Wind transport: read upwind tile temperature from old snapshot - let wind_transport = 0.0 - let upwind_pos = upwindPos(col, row, tile.wind_direction, w, h) - if (upwind_pos !== null) { - wind_transport = ( (oldTemp[idx(upwind_pos.col, upwind_pos.row, w)] - current_temp) * tile.wind_speed * conductivity ) - - } - let relax = (solar - current_temp) * relaxation - newTemp[i] = Math.min(1.0, Math.max(0.0, current_temp + net_solar + wind_transport + relax + tile.magic_heat_delta)) - - } - // Swap: write buffered results back to tiles - for (let i = 0; i < tiles.length; i++) { - tiles[i].temperature = newTemp[i] - - } - } - - private stepLakeThermal(grid: GridState): void { - // Lakes moderate adjacent land tiles toward lake temperature. - // Read lake temps from post-step-2 values — moderation is a secondary effect - // and doesn't require double-buffering (lakes don't read each other here). - const { tiles, width: w, height: h } = grid - let conductivity = (this.params as any)["lake_thermal_conductivity"] ?? 0.05 - - for (let i = 0; i < tiles.length; i++) { - const tile = tiles[i] - const { col, row } = tile - if (tile.biome_id !== "lake") { - continue - } - let lake_temp = tile.temperature - for (const nb_pos of neighbors(col, row, w, h)) { - const nb = tiles[idx(nb_pos.col, nb_pos.row, w)] - if (nb === null) { - continue - } - let diff = lake_temp - nb.temperature - nb.temperature = Math.min(1.0, Math.max(0.0, nb.temperature + diff * conductivity)) - - } - } - } - - private stepMoistureWind(grid: GridState): void { - const { tiles, width: w, height: h } = grid - let transport_rate = (this.params as any)["moisture_transport"] ?? 0.15 - let decay = (this.params as any)["moisture_decay"] ?? 0.995 - let rain_shadow_block = (this.params as any)["mountain_rain_shadow_block"] ?? 0.9 - let atmo_loss = (this.params as any)["atmospheric_loss_rate"] ?? 0.0003 - - // Snapshot moisture before decay so all tiles decay from the same baseline - const oldMoisture = new Float32Array(tiles.length) - for (let i = 0; i < tiles.length; i++) { - oldMoisture[i] = tiles[i].moisture * decay - - } - // Compute new moisture from decayed old values + upwind transport. - // Reading old_moisture[upwind] avoids stale-write order dependence. - const newMoisture = new Float32Array(tiles.length) - for (let i = 0; i < tiles.length; i++) { - const tile = tiles[i] - const { col, row } = tile - let current = oldMoisture[i] - - // Upwind moisture transport - let upwind_pos = upwindPos(col, row, tile.wind_direction, w, h) - let transported = 0.0 - if (upwind_pos !== null) { - const upwind_tile = tiles[idx(upwind_pos.col, upwind_pos.row, w)] - let upwind_is_mountain = ( upwind_tile !== null && hasTag(upwind_tile.biome_id, "is_elevated") ) - let block = (upwind_is_mountain ? rain_shadow_block : 0.0) - transported = ( oldMoisture[idx(upwind_pos.col, upwind_pos.row, w)] * tile.wind_speed * transport_rate * (1.0 - block) ) - - } - // Evapotranspiration and magic forcing are per-tile local effects — safe to add here - let terrain_data = (this.terrainCache.get(tile.biome_id) ?? {}) - let evapotrans = (terrain_data as any)["evapotranspiration"] ?? 0.0 - - // Atmospheric loss to space — Jeans escape (temperature-scaled). - // Balanced by volcanic outgassing (deep_earth_water step). No baseline injection needed. - let space_loss = current * atmo_loss * tile.temperature - - newMoisture[i] = Math.min(1.0, Math.max(0.0, ( current + transported + evapotrans - space_loss + tile.magic_moisture_delta ))) - - } - // Swap - for (let i = 0; i < tiles.length; i++) { - tiles[i].moisture = newMoisture[i] - - } - } - - private stepMoistureRivers(grid: GridState): void { - // River transport is inherently sequential (water flows from source to mouth), - // so in-place update is correct — each river tile drains to its lower neighbour. - const { tiles, width: w, height: h } = grid - let transport_rate = (this.params as any)["river_moisture_transport"] ?? 0.075 - - for (let i = 0; i < tiles.length; i++) { - const tile = tiles[i] - const { col, row } = tile - if (tile.river_edges.length === 0) { - continue - } - for (const edge of tile.river_edges) { - const _nbCoord = neighborInDir(col, row, edge, w, h) - const nb = _nbCoord !== null ? tiles[idx(_nbCoord.col, _nbCoord.row, w)] : null - if (nb === null || tile.elevation <= nb.elevation) { - continue - } - let amount = tile.moisture * transport_rate - tile.moisture = Math.min(1.0, Math.max(0.0, tile.moisture - amount)) - nb.moisture = Math.min(1.0, Math.max(0.0, nb.moisture + amount)) - - } - } - } - - private stepLakeEvaporation(grid: GridState): void { - const { tiles, width: w, height: h } = grid - let base_evap = (this.params as any)["evaporation_rate"] ?? 0.05 - let max_hops = Math.floor( (this.params as any)["ocean_evaporation_hops"] ?? 4 ) - let hop_decay = (this.params as any)["ocean_evaporation_hop_decay"] ?? 0.5 - let rain_shadow_block = (this.params as any)["mountain_rain_shadow_block"] ?? 0.9 - // Biological pump failure: dead ocean reduces evaporation → inland forests dry out. - // Formula from OCEAN.md: evap *= (1 - (fraction - 0.25) * 2) when fraction > 0.25. - let evap_rate = base_evap - if (grid.ocean_dead_fraction > 0.25) { - evap_rate = base_evap * Math.max(0.0, 1.0 - (grid.ocean_dead_fraction - 0.25) * 2.0) - - } - for (let i = 0; i < tiles.length; i++) { - const tile = tiles[i] - const { col, row } = tile - let tid = tile.biome_id - if (!hasTag(tid, "is_water")) { - continue - } - // Only evaporate if liquid water is actually present - if (tile.surface_water <= 0.0) { - continue - - } - let evap = tile.temperature * evap_rate * Math.min(1.0, tile.surface_water) - - if (tid === "lake") { - // Lakes inject to all 6 neighbours equally - let share = evap / 6.0 - for (const nb_pos of neighbors(col, row, w, h)) { - const nb = tiles[idx(nb_pos.col, nb_pos.row, w)] - if (nb !== null) { - nb.moisture = Math.min(1.0, Math.max(0.0, nb.moisture + share)) - } - } - } else { - // Ocean/coast: inject along downwind chain (multi-hop with decay). - // Hop 1 = full strength, hop 2 = hop_decay, hop 3 = hop_decay^2, etc. - // Stop at mountains (rain shadow), water tiles (no double-injection), or map edge. - let hopCol = col - let hopRow = row - let hop_strength = 1.0 - let wind_dir = tile.wind_direction - for (let _hop_i = 0; _hop_i < max_hops; _hop_i++) { - const _hop = neighborInDir(hopCol, hopRow, wind_dir, w, h) - if (_hop === null) break - hopCol = _hop.col - hopRow = _hop.row - const hop_tile = tiles[idx(hopCol, hopRow, w)] - if (hop_tile === undefined) break - if (hop_tile === null) { - break - } - let hop_tid = hop_tile.biome_id - // Stop if we hit another water tile (no double-injection) - if (hasTag(hop_tid, "is_water")) { - break - } - // Mountains block most moisture (rain shadow) - if (hasTag(hop_tid, "is_elevated")) { - hop_strength *= (1.0 - rain_shadow_block) - } - hop_tile.moisture = Math.min(1.0, Math.max(0.0, hop_tile.moisture + evap * hop_strength)) - hop_strength *= hop_decay - // Follow the hop tile's own wind direction for subsequent hops - wind_dir = hop_tile.wind_direction - - } - } - } - } - - private stepDeepEarthWater(grid: GridState): void { - // Mantle degassing: volcanoes, hot springs, and high-elevation tiles inject moisture. - // Models ringwoodite water release through the crust — the mechanism that maintains - // Earth's ocean volume over geological time despite atmospheric hydrogen escape. - const { tiles, width: w, height: h } = grid - let dew = (this.params as any)["deep_earth_water"] ?? DEW_DEFAULTS - let vol_self = (dew as any)["volcano_self"] ?? DEW_DEFAULTS["volcano_self"] - let vol_nb = (dew as any)["volcano_neighbor"] ?? DEW_DEFAULTS["volcano_neighbor"] - let hs_self = (dew as any)["hot_spring_self"] ?? DEW_DEFAULTS["hot_spring_self"] - let hs_nb = (dew as any)["hot_spring_neighbor"] ?? DEW_DEFAULTS["hot_spring_neighbor"] - let hs_max_temp = (dew as any)["hot_spring_max_temperature"] ?? DEW_DEFAULTS["hot_spring_max_temperature"] - let elev_self = (dew as any)["high_elevation_self"] ?? DEW_DEFAULTS["high_elevation_self"] - let elev_thresh = (dew as any)["high_elevation_threshold"] ?? DEW_DEFAULTS["high_elevation_threshold"] - - for (let i = 0; i < tiles.length; i++) { - const tile = tiles[i] - const { col, row } = tile - - if (hasTag(tile.biome_id, "is_volcanic")) { - tile.moisture = Math.min(1.0, Math.max(0.0, tile.moisture + vol_self)) - for (const nb_pos of neighbors(col, row, w, h)) { - const nb = tiles[idx(nb_pos.col, nb_pos.row, w)] - if (nb !== null) { - nb.moisture = Math.min(1.0, Math.max(0.0, nb.moisture + vol_nb)) - - } - } - } else if (tile.river_source_type === "hot_spring" && tile.temperature <= hs_max_temp) { - tile.moisture = Math.min(1.0, Math.max(0.0, tile.moisture + hs_self)) - for (const nb_pos of neighbors(col, row, w, h)) { - const nb = tiles[idx(nb_pos.col, nb_pos.row, w)] - if (nb !== null) { - nb.moisture = Math.min(1.0, Math.max(0.0, nb.moisture + hs_nb)) - - } - } - } else if (tile.elevation >= elev_thresh) { - tile.moisture = Math.min(1.0, Math.max(0.0, tile.moisture + elev_self)) - - } - } - } - - private stepPrecipitation(grid: GridState): void { - const { tiles, width: w, height: h } = grid - let threshold = (this.params as any)["precipitation_threshold"] ?? 0.7 - let lat_spec = (this.spec as any)["precipitation_latitude"] ?? {} - let polar_band = (((lat_spec as any)["polar_drying"] ?? {}) as any)["band_pct"] ?? 0.15 - let polar_rate = (((lat_spec as any)["polar_drying"] ?? {}) as any)["rate_per_turn"] ?? 0.008 - let sub_center = (((lat_spec as any)["subtropical_drying"] ?? {}) as any)["center_pct"] ?? 0.30 - let sub_half = (((lat_spec as any)["subtropical_drying"] ?? {}) as any)["half_width"] ?? 0.15 - let sub_rate = (((lat_spec as any)["subtropical_drying"] ?? {}) as any)["rate_per_turn"] ?? 0.013 - - for (let i = 0; i < tiles.length; i++) { - const tile = tiles[i] - const { col, row } = tile - if (tile.moisture > threshold) { - let runoff = tile.moisture - threshold - tile.moisture = threshold - tile.surface_water += runoff - - } - // Latitude-dependent moisture adjustment — models Hadley cell circulation. - // Polar regions are dry (cold air holds less moisture). - // Subtropical high-pressure belt (~30° latitude) suppresses moisture. - let frac = row / h - let norm_frac = (frac <= 0.5 ? frac : 1.0 - frac) - - if (norm_frac < polar_band) { - tile.moisture = Math.max(0.0, tile.moisture - polar_rate) - - } - if (norm_frac > polar_band && norm_frac < sub_center + sub_half) { - let dry_strength = Math.max(0.0, 1.0 - Math.abs(norm_frac - sub_center) / sub_half) - tile.moisture = Math.max(0.05, tile.moisture - sub_rate * dry_strength) - - } - } - } - - private stepSurfaceRunoff(grid: GridState): void { - // Surface water flows downhill along river_edges into ocean-substrate tiles. - // Draining into ocean substrate adds to the planetary reservoir (drives sea_level). - // No downhill neighbour → water pools (lake formation). - const runoffRate = this.p('surface_runoff_rate', 0.3) - const oceanSubstrates = new Set(['deep_water', 'shallow_water']) - const { tiles, width: w, height: h } = grid - - for (let i = 0; i < tiles.length; i++) { - const tile = tiles[i] - if ((tile.surface_water ?? 0) <= 0.0) continue - - // Find the steepest downhill river_edge neighbor - let bestNb: TileState | null = null - let bestDrop = 0.0 - for (const edge of tile.river_edges) { - const nbPos = neighborInDir(tile.col, tile.row, edge, w, h) - if (!nbPos) continue - const nb = tiles[idx(nbPos.col, nbPos.row, w)] - if (!nb) continue - const drop = tile.elevation - nb.elevation - if (drop > bestDrop) { - bestDrop = drop - bestNb = nb - } - } - if (!bestNb) continue // no downhill neighbor — water pools here - - const flow = tile.surface_water * runoffRate - tile.surface_water -= flow - if (oceanSubstrates.has(bestNb.substrate_id)) { - grid.total_ocean_water += flow - } else { - bestNb.surface_water = (bestNb.surface_water ?? 0) + flow - } - } - } - - private stepTerrainEvolution(grid: GridState): void { - const { tiles, width: w, height: h } = grid - let up_thresh = Math.floor((this.params as any)["quality_up_threshold"] ?? 10) - let down_thresh = Math.floor((this.params as any)["quality_down_threshold"] ?? 5) - - for (let i = 0; i < tiles.length; i++) { - const tile = tiles[i] - const { col, row } = tile - let tid = tile.biome_id - - if (tile.is_natural_wonder) { - continue - - } - // Water freezing/thawing - let freeze_temp = (this.params as any)["water_freeze_threshold"] ?? 0.12 - let thaw_temp = freeze_temp + 0.03 - let is_water = hasTag(tid, "is_water") - if (is_water) { - if (tile.temperature < freeze_temp) { - tile.original_biome_id = tid - tile.biome_id = "ice" - tile.quality = 1 - tile.quality_progress = 0 - } - continue - } - if (hasTag(tid, "is_frozen")) { - if (tile.temperature > thaw_temp && tile.original_biome_id !== "") { - tile.biome_id = tile.original_biome_id - tile.original_biome_id = "" - tile.quality = 1 - tile.quality_progress = 0 - } - continue - - } - if (hasTag(tid, "is_volcanic")) { - continue - - } - let ideal = idealTerrain(tile, this.spec) - - if (ideal === tid) { - tile.quality_progress += 1 - if (tile.quality_progress >= up_thresh) { - tile.quality_progress = 0 - if (tile.quality < 5) { - tile.quality += 1 - } - } - } else { - tile.quality_progress -= 1 - if (tile.quality_progress <= -down_thresh) { - tile.quality_progress = 0 - if (tile.quality > 1) { - tile.quality -= 1 - } else { - tile.biome_id = ideal - tile.quality = 1 - tile.quality_progress = 0 - - } - } - } - } - } - - private stepGlobalStats(grid: GridState): void { - const { tiles, width: w, height: h } = grid - let total = 0.0 - let count = 0 - for (let i = 0; i < tiles.length; i++) { - const tile = tiles[i] - const { col, row } = tile - if (tile.biome_id !== "ocean") { - total += tile.temperature - count += 1 - } - } - grid.global_avg_temp = (count > 0 ? total / count : 0.5) - - // Reef health on coast tiles — coral bleaching from high SST - let reef_spec = (this.spec as any)["reef_bleaching"] ?? {} - let bleach_thresh = (reef_spec as any)["bleach_temp_threshold"] ?? 0.75 - let damage_rate = (reef_spec as any)["damage_rate_per_turn"] ?? 0.01 - let recovery_rate = (reef_spec as any)["recovery_rate_per_turn"] ?? 0.005 - let dead_thresh = (reef_spec as any)["dead_threshold"] ?? 0.3 - let coast_count = 0 - let dead_count = 0 - for (let i = 0; i < tiles.length; i++) { - const tile = tiles[i] - const { col, row } = tile - if (tile.biome_id !== "coast") { - continue - } - coast_count += 1 - if (tile.temperature > bleach_thresh) { - tile.reef_health = Math.max(0.0, tile.reef_health - damage_rate) - } else { - tile.reef_health = Math.min(1.0, tile.reef_health + recovery_rate) - } - if (tile.reef_health < dead_thresh) { - dead_count += 1 - } - } - if (coast_count > 0) { - grid.ocean_dead_fraction = dead_count / coast_count - - } - } - - private stepClearDeltas(grid: GridState): void { - const { tiles, width: w, height: h } = grid - for (let i = 0; i < tiles.length; i++) { - const tile = tiles[i] - const { col, row } = tile - tile.magic_heat_delta = 0.0 - tile.magic_moisture_delta = 0.0 - - } - } - - private stepSeaLevel(grid: GridState): void { - // Sea level is derived from total ocean water volume divided by basin area. - // As surface runoff drains into ocean-substrate tiles, total_ocean_water rises. - // As water boils/freezes/is consumed, total_ocean_water falls and sea_level drops. - const basinArea = grid.ocean_basin_area > 0 ? grid.ocean_basin_area : 300 - grid.sea_level = grid.total_ocean_water / basinArea - - // Flood low land / expose high water - const { tiles } = grid - let changed = false - for (let i = 0; i < tiles.length; i++) { - const tile = tiles[i] - const isWater = tile.biome_id === 'ocean' || tile.biome_id === 'coast' || - tile.biome_id === 'lake' || tile.biome_id === 'inland_sea' - - if (!isWater && tile.elevation < grid.sea_level) { - // Land tile floods — becomes coast - if (tile.is_natural_wonder) continue - tile.original_biome_id = tile.biome_id - tile.biome_id = 'coast' - tile.quality = 1 - tile.quality_progress = 0 - changed = true - } else if (tile.biome_id === 'coast' && tile.elevation >= grid.sea_level + 0.02) { - // Coast tile exposed — reclassify as land based on climate - // Small hysteresis buffer (0.02) prevents oscillation at the boundary - tile.biome_id = classifyTerrain(tile.temperature, tile.moisture, tile.elevation) - tile.quality = 1 - tile.quality_progress = 0 - changed = true - } - } - - // Invalidate ocean distance cache if coastline changed - if (changed) this.oceanDistGridId = -1 - } - - private stepEcologicalEvents(grid: GridState, turn: number, seed: number): EcologicalEvent[] { - const { tiles, width: w, height: h } = grid - const cfg = (this.spec['ecological_events'] ?? {}) as Record> - if (Object.keys(cfg).length === 0) { - return this._stepEcologicalEventsDefault(grid, turn, seed) - } - const turnSeed = seed * 1000.0 + turn - const events: EcologicalEvent[] = [] - - const roll = (ch: number): number => hashNoise(ch, 0, turnSeed) - const pickLand = (ch: number): TileState | null => { - const col = Math.floor(hashNoise(ch, 1, turnSeed) * w) - const row = Math.floor(hashNoise(ch, 2, turnSeed) * (h - 4)) + 2 - const tile = tiles[idx(col, row, w)] - if (!tile || tile.biome_id === 'ocean' || tile.biome_id === 'coast') return null - return tile - } - const hexDist = (c1: number, r1: number, c2: number, r2: number): number => { - const q1 = c1, s1 = r1 - (c1 - (c1 & 1)) / 2 - const q2 = c2, s2 = r2 - (c2 - (c2 & 1)) / 2 - const dq = q2 - q1, ds = s2 - s1 - return (Math.abs(dq) + Math.abs(ds) + Math.abs(dq + ds)) / 2 - } - const tilesInRadius = (col: number, row: number, radius: number): TileState[] => - tiles.filter(t => hexDist(col, row, t.col, t.row) <= radius) - - // -- Wildfire -- - const wf = cfg['wildfire'] ?? {} - const wfFreq = (wf['frequency'] as number) ?? 0.125 - if (roll(10) < wfFreq) { - const center = pickLand(11) - const forestTypes = (wf['target_terrain'] as string[]) ?? - ['forest', 'jungle', 'boreal_forest', 'enchanted_forest'] - if (center && forestTypes.includes(center.biome_id)) { - const radius = (wf['radius'] as number) ?? 2 - const moistLoss = (wf['moisture_loss'] as number) ?? 0.15 - const becomes = (wf['becomes'] as string) ?? 'grassland' - let burned = 0 - for (const t of tilesInRadius(center.col, center.row, radius)) { - if (t.is_natural_wonder) continue - if (forestTypes.includes(t.biome_id)) { - t.biome_id = becomes - t.quality = 1 - t.quality_progress = 0 - t.moisture = Math.max(0.0, t.moisture - moistLoss) - burned++ - } - } - if (burned > 0) events.push({ turn, type: 'wildfire', col: center.col, row: center.row, - description: `Wildfire burns ${burned} tiles of forest` }) - } - } - - // -- Supervolcano -- - const sv = cfg['supervolcano'] ?? {} - if (roll(20) < ((sv['frequency'] as number) ?? 0.00667)) { - const center = pickLand(21) - if (center && !center.is_natural_wonder) { - center.biome_id = 'volcano' - center.quality = 1 - center.quality_progress = 0 - const svRadius = (sv['radius'] as number) ?? 3 - const svMoistLoss = (sv['scorched_moisture_loss'] as number) ?? 0.2 - const globalCooling = (sv['global_cooling'] as number) ?? -0.002 - const aerosol = (sv['aerosol_strength'] as number) ?? 0.3 - const aerosolRadius = (sv['aerosol_radius'] as number) ?? 6 - let scorched = 0 - for (const t of tilesInRadius(center.col, center.row, svRadius)) { - if (t === center || t.is_natural_wonder) continue - if (t.biome_id !== 'ocean' && t.biome_id !== 'coast') { - t.biome_id = (sv['scorched_terrain'] as string) ?? 'desert' - t.moisture = Math.max(0.0, t.moisture - svMoistLoss) - t.quality = 1 - scorched++ - } - } - for (const t of tilesInRadius(center.col, center.row, aerosolRadius)) { - if ('sulfate_aerosol' in t) (t as any).sulfate_aerosol += aerosol - } - for (const t of tiles) t.magic_heat_delta += globalCooling - center.wonder_anchor_strength = (sv['anchor_strength'] as number) ?? 2 - center.wonder_anchor_school = ((sv['anchor_schools'] as string[]) ?? ['chaos'])[0] as any - events.push({ turn, type: 'supervolcano', col: center.col, row: center.row, - description: `Supervolcano erupts, scorching ${scorched} tiles` }) - } - } - - // -- Meteorite -- - const mt = cfg['meteorite'] ?? {} - if (roll(30) < ((mt['frequency'] as number) ?? 0.005)) { - const col = Math.floor(hashNoise(31, 1, turnSeed) * w) - const row = Math.floor(hashNoise(31, 2, turnSeed) * (h - 4)) + 2 - const center = tiles[idx(col, row, w)] - if (center && !center.is_natural_wonder) { - const mtRadius = (mt['heat_radius'] as number) ?? 2 - const heatDelta = (mt['heat_delta'] as number) ?? 0.03 - center.biome_id = center.elevation < 0.15 ? 'lake' : 'desert' - center.elevation = Math.max(0.0, center.elevation - 0.15) - center.quality = 1 - for (const t of tilesInRadius(col, row, mtRadius)) t.magic_heat_delta += heatDelta - center.wonder_anchor_strength = (mt['anchor_strength'] as number) ?? 2 - center.wonder_anchor_school = ((mt['anchor_schools'] as string[]) ?? ['aether'])[0] as any - events.push({ turn, type: 'meteorite', col, row, - description: 'Meteorite impact creates crater' }) - } - } - - // -- Drought -- - const dr = cfg['drought'] ?? {} - if (roll(40) < ((dr['frequency'] as number) ?? 0.067)) { - const center = pickLand(41) - if (center) { - const drRadius = (dr['radius'] as number) ?? 4 - const moistLoss = (dr['moisture_loss'] as number) ?? 0.10 - for (const t of tilesInRadius(center.col, center.row, drRadius)) { - if (t.biome_id !== 'ocean' && t.biome_id !== 'coast') - t.moisture = Math.max(0.0, t.moisture - moistLoss) - } - events.push({ turn, type: 'drought', col: center.col, row: center.row, - description: 'Regional drought' }) - } - } - - // -- Algal Bloom -- - const ab = cfg['algal_bloom'] ?? {} - if (roll(50) < ((ab['frequency'] as number) ?? 0.05)) { - const col = Math.floor(hashNoise(51, 1, turnSeed) * w) - const row = Math.floor(hashNoise(51, 2, turnSeed) * h) - const center = tiles[idx(col, row, w)] - if (center && (center.biome_id === 'coast' || center.biome_id === 'ocean')) { - for (const t of tilesInRadius(col, row, 2)) { - if (t.biome_id === 'coast') t.reef_health = Math.min(1.0, t.reef_health + 0.1) - if (t.biome_id !== 'ocean' && t.biome_id !== 'coast') - t.moisture = Math.min(1.0, t.moisture + 0.05) - } - events.push({ turn, type: 'algal_bloom', col, row, description: 'Algal bloom' }) - } - } - - // -- Insect Plague -- - const ip = cfg['insect_plague'] ?? {} - if (roll(60) < ((ip['frequency'] as number) ?? 0.04)) { - const center = pickLand(61) - if (center && (center.biome_id === 'forest' || center.biome_id === 'jungle' || - center.biome_id === 'enchanted_forest')) { - const ipRadius = (ip['radius'] as number) ?? 3 - for (const t of tilesInRadius(center.col, center.row, ipRadius)) { - if (t.is_natural_wonder) continue - if (t.biome_id === 'enchanted_forest') { t.biome_id = 'forest'; t.quality = Math.max(1, t.quality - 1) } - else if (t.biome_id === 'jungle' || t.biome_id === 'forest') t.quality = Math.max(1, t.quality - 1) - } - events.push({ turn, type: 'insect_plague', col: center.col, row: center.row, - description: 'Insect plague degrades forest quality' }) - } - } - - // -- Mountain Growth -- - const mg = cfg['mountain_growth'] ?? {} - if (roll(70) < ((mg['frequency'] as number) ?? 0.0167)) { - const center = pickLand(71) - if (center && (center.biome_id === 'hills' || center.biome_id === 'mountains')) { - center.elevation = Math.min(1.0, center.elevation + 0.05) - for (const nb of neighbors(center.col, center.row, w, h)) - tiles[idx(nb.col, nb.row, w)].elevation = Math.min(1.0, tiles[idx(nb.col, nb.row, w)].elevation + 0.02) - events.push({ turn, type: 'mountain_growth', col: center.col, row: center.row, - description: 'Tectonic uplift raises elevation' }) - } - } - - // -- Erosion -- - const er = cfg['erosion'] ?? {} - if (roll(80) < ((er['frequency'] as number) ?? 0.033)) { - const center = pickLand(81) - if (center && (center.biome_id === 'mountains' || center.biome_id === 'hills' || - center.biome_id === 'volcano') && !center.is_natural_wonder) { - center.elevation = Math.max(0.0, center.elevation - 0.03) - const isVolcano = center.biome_id === 'volcano' - if (isVolcano) center.biome_id = 'hills' - for (const nb of neighbors(center.col, center.row, w, h)) { - const t = tiles[idx(nb.col, nb.row, w)] - if (t.elevation < center.elevation) t.moisture = Math.min(1.0, t.moisture + 0.03) - } - events.push({ turn, type: 'erosion', col: center.col, row: center.row, - description: isVolcano ? 'Extinct volcano weathers into hills' : 'Erosion lowers elevation' }) - } - } - - return events - } - - /** Fallback when no spec provided. */ - private _stepEcologicalEventsDefault(grid: GridState, turn: number, seed: number): EcologicalEvent[] { - const { tiles, width: w, height: h } = grid - const turnSeed = seed * 1000 + turn - const events: EcologicalEvent[] = [] - const roll = (ch: number): number => hashNoise(ch, 0, turnSeed) - const pickLand = (ch: number): TileState | null => { - const col = Math.floor(hashNoise(ch, 1, turnSeed) * w) - const row = Math.floor(hashNoise(ch, 2, turnSeed) * (h - 4)) + 2 - const tile = tiles[idx(col, row, w)] - if (!tile || tile.biome_id === 'ocean' || tile.biome_id === 'coast') return null - return tile - } - const hexDist = (c1: number, r1: number, c2: number, r2: number): number => { - const q1 = c1; const s1 = r1 - (c1 - (c1 & 1)) / 2 - const q2 = c2; const s2 = r2 - (c2 - (c2 & 1)) / 2 - const dq = q2 - q1; const ds = s2 - s1 - return (Math.abs(dq) + Math.abs(ds) + Math.abs(dq + ds)) / 2 - } - const tilesInRadius = (col: number, row: number, radius: number): TileState[] => - tiles.filter(t => hexDist(col, row, t.col, t.row) <= radius) - if (roll(10) < 0.125) { - const center = pickLand(11) - if (center && ['forest','jungle','boreal_forest','enchanted_forest'].includes(center.biome_id)) { - let burned = 0 - for (const t of tilesInRadius(center.col, center.row, 2)) { - if (t.is_natural_wonder) continue - if (['forest','jungle','enchanted_forest','boreal_forest'].includes(t.biome_id)) { - t.biome_id = 'grassland'; t.quality = 1; t.quality_progress = 0 - t.moisture = Math.max(0.0, t.moisture - 0.15); burned++ - } - } - if (burned > 0) events.push({ turn, type: 'wildfire', col: center.col, row: center.row, - description: `Wildfire burns ${burned} tiles of forest` }) - } - } - if (roll(20) < 0.00667) { - const center = pickLand(21) - if (center && !center.is_natural_wonder) { - center.biome_id = 'volcano'; center.quality = 1; center.quality_progress = 0 - let scorched = 0 - for (const t of tilesInRadius(center.col, center.row, 3)) { - if (t === center || t.is_natural_wonder) continue - if (t.biome_id !== 'ocean' && t.biome_id !== 'coast') { - t.biome_id = 'desert'; t.moisture = Math.max(0.0, t.moisture - 0.2); t.quality = 1; scorched++ - } - } - for (const t of tiles) t.magic_heat_delta -= 0.002 - center.wonder_anchor_strength = 3; center.wonder_anchor_school = 'chaos' - events.push({ turn, type: 'supervolcano', col: center.col, row: center.row, - description: `Supervolcano erupts, scorching ${scorched} tiles` }) - } - } - if (roll(30) < 0.005) { - const col = Math.floor(hashNoise(31, 1, turnSeed) * w) - const row = Math.floor(hashNoise(31, 2, turnSeed) * (h - 4)) + 2 - const center = tiles[idx(col, row, w)] - if (center && !center.is_natural_wonder) { - center.biome_id = center.elevation < 0.15 ? 'lake' : 'desert' - center.elevation = Math.max(0.0, center.elevation - 0.15); center.quality = 1 - for (const t of tilesInRadius(col, row, 2)) t.magic_heat_delta += 0.03 - center.wonder_anchor_strength = 2; center.wonder_anchor_school = 'aether' - events.push({ turn, type: 'meteorite', col, row, description: 'Meteorite impact creates crater' }) - } - } - if (roll(40) < 0.067) { - const center = pickLand(41) - if (center) { - for (const t of tilesInRadius(center.col, center.row, 4)) { - if (t.biome_id !== 'ocean' && t.biome_id !== 'coast') - t.moisture = Math.max(0.0, t.moisture - 0.10) - } - events.push({ turn, type: 'drought', col: center.col, row: center.row, description: 'Regional drought' }) - } - } - if (roll(50) < 0.05) { - const col = Math.floor(hashNoise(51, 1, turnSeed) * w) - const row = Math.floor(hashNoise(51, 2, turnSeed) * h) - const center = tiles[idx(col, row, w)] - if (center && (center.biome_id === 'coast' || center.biome_id === 'ocean')) { - for (const t of tilesInRadius(col, row, 2)) { - if (t.biome_id === 'coast') t.reef_health = Math.min(1.0, t.reef_health + 0.1) - if (t.biome_id !== 'ocean' && t.biome_id !== 'coast') - t.moisture = Math.min(1.0, t.moisture + 0.05) - } - events.push({ turn, type: 'algal_bloom', col, row, description: 'Algal bloom' }) - } - } - if (roll(60) < 0.04) { - const center = pickLand(61) - if (center && ['forest','jungle','enchanted_forest'].includes(center.biome_id)) { - for (const t of tilesInRadius(center.col, center.row, 3)) { - if (t.is_natural_wonder) continue - if (t.biome_id === 'enchanted_forest') { t.biome_id = 'forest'; t.quality = Math.max(1, t.quality - 1) } - else if (t.biome_id === 'jungle' || t.biome_id === 'forest') t.quality = Math.max(1, t.quality - 1) - } - events.push({ turn, type: 'insect_plague', col: center.col, row: center.row, description: 'Insect plague' }) - } - } - if (roll(70) < 0.0167) { - const center = pickLand(71) - if (center && (center.biome_id === 'hills' || center.biome_id === 'mountains')) { - center.elevation = Math.min(1.0, center.elevation + 0.05) - for (const nb of neighbors(center.col, center.row, w, h)) - tiles[idx(nb.col, nb.row, w)].elevation = Math.min(1.0, tiles[idx(nb.col, nb.row, w)].elevation + 0.02) - events.push({ turn, type: 'mountain_growth', col: center.col, row: center.row, description: 'Tectonic uplift' }) - } - } - if (roll(80) < 0.033) { - const center = pickLand(81) - if (center && ['mountains','hills','volcano'].includes(center.biome_id) && !center.is_natural_wonder) { - center.elevation = Math.max(0.0, center.elevation - 0.03) - const isVolcano = center.biome_id === 'volcano' - if (isVolcano) center.biome_id = 'hills' - for (const nb of neighbors(center.col, center.row, w, h)) { - const t = tiles[idx(nb.col, nb.row, w)] - if (t.elevation < center.elevation) t.moisture = Math.min(1.0, t.moisture + 0.03) - } - events.push({ turn, type: 'erosion', col: center.col, row: center.row, - description: isVolcano ? 'Extinct volcano weathers into hills' : 'Erosion lowers elevation' }) - } - } - return events - } - - private stepAnchorDecay(grid: GridState): void { - const decayCfg = ((this.spec['anchor_decay'] ?? {}) as Record) - const rate = decayCfg['rate_per_turn'] ?? 0.003 - for (const tile of grid.tiles) { - if (tile.wonder_anchor_strength <= 0 || tile.is_natural_wonder) continue - tile.wonder_anchor_strength = Math.max(0.0, tile.wonder_anchor_strength - rate) - if (tile.wonder_anchor_strength <= 0) { - tile.wonder_anchor_school = '' as any - } - } - } - - - // -- Atmosphere state -- - private anomalyAge = new Map() - private thermalSustain = new Map() - - - private stepBaselinePressure(grid: GridState): void { - const { tiles, width: w, height: h } = grid - const polarHi = this.p('polar_high_pressure', 1030.0) - const subpolarLo = this.p('subpolar_low_pressure', 995.0) - const subtropHi = this.p('subtropical_high_pressure', 1025.0) - const itczLo = this.p('itcz_low_pressure', 1005.0) - const mtnBoost = this.p('mountain_pressure_boost', 5.0) - const oceanOff = this.p('ocean_pressure_offset', -2.0) - const heatOff = this.p('heated_land_pressure_offset', -3.0) - const heatThresh = this.p('heated_land_temp_threshold', 0.6) - - for (const tile of tiles) { - const latFrac = h > 1 ? tile.row / (h - 1) : 0.5 - - let baseline: number - if (latFrac < 0.15) baseline = polarHi + (subpolarLo - polarHi) * (latFrac / 0.15) - else if (latFrac < 0.30) baseline = subpolarLo - else if (latFrac < 0.45) baseline = subpolarLo + (subtropHi - subpolarLo) * ((latFrac - 0.30) / 0.15) - else if (latFrac < 0.60) baseline = subtropHi - else if (latFrac < 0.85) baseline = subtropHi + (itczLo - subtropHi) * ((latFrac - 0.60) / 0.25) - else baseline = itczLo - - const isW = this.atmoIsWater(tile.biome_id) - if (isW) { - baseline += oceanOff - } else { - if (tile.elevation > 0.5) baseline += ((tile.elevation - 0.5) / 0.1) * mtnBoost - if (tile.temperature > heatThresh) baseline += heatOff - } - - tile.pressure = baseline + (tile.pressure_anomaly ?? 0) - } - } - - private atmoIsWater(biome: string): boolean { - return biome === 'ocean' || biome === 'coast' || biome === 'lake' || - biome === 'deep_ocean' || biome === 'shallow_ocean' || biome === 'coral_reef' || - biome === 'estuary' || biome === 'pond' || biome === 'river' || biome === 'mangrove' || - biome === 'inland_sea' - } - - - private stepAnomalies(grid: GridState, turn: number): void { - const { tiles } = grid - const thermalLowTemp = this.p('thermal_low_temp_threshold', 0.65) - const coldHighTemp = this.p('cold_high_temp_threshold', 0.2) - const coldHighHum = this.p('cold_high_humidity_threshold', 0.3) - const sustainNeeded = this.p('thermal_low_sustain_turns', 3) - const thermalLowVal = this.p('thermal_low_anomaly', -15.0) - const coldHighVal = this.p('cold_high_anomaly', 15.0) - const decayRate = this.p('anomaly_decay_rate', 3.0) - const reinforceRate = this.p('anomaly_reinforce_rate', 2.0) - const maxAnomaly = this.p('anomaly_max', 35.0) - - for (let i = 0; i < tiles.length; i++) { - const tile = tiles[i] - let pa = tile.pressure_anomaly ?? 0 - - const isHot = tile.temperature > thermalLowTemp - const isCold = tile.temperature < coldHighTemp && - (tile.humidity ?? 0) < coldHighHum && - !this.atmoIsWater(tile.biome_id) - - if (isHot || isCold) { - this.thermalSustain.set(i, (this.thermalSustain.get(i) ?? 0) + 1) - } else { - this.thermalSustain.delete(i) - } - - const sustain = this.thermalSustain.get(i) ?? 0 - - // Spawn - if (pa === 0 && sustain >= sustainNeeded) { - if (isHot) pa = thermalLowVal - else if (isCold) pa = coldHighVal - } - - // Decay / reinforce - if (pa !== 0) { - const condMet = (pa < 0 && tile.temperature > thermalLowTemp) || - (pa > 0 && tile.temperature < coldHighTemp) - if (condMet && sustain > 0) { - pa = pa < 0 ? Math.max(pa - reinforceRate, -maxAnomaly) - : Math.min(pa + reinforceRate, maxAnomaly) - } else { - pa = pa < 0 ? Math.min(pa + decayRate, 0) : Math.max(pa - decayRate, 0) - } - } - - tile.pressure_anomaly = pa - tile.pressure = (tile.pressure ?? 1013) + pa - } - } - - - private stepWindFromPressure(grid: GridState): void { - const { tiles, width: w, height: h } = grid - const speedScale = this.p('wind_speed_scale', 0.08) - const coriolisScale = this.p('coriolis_scale', 0.3) - - for (const tile of tiles) { - const latFrac = h > 1 ? tile.row / (h - 1) : 0.5 - const hemiSign = latFrac < 0.5 ? 1 : -1 - - // Find neighbor with steepest pressure gradient - let bestDir = tile.wind_direction - let bestGrad = 0 - const nbs = neighbors(tile.col, tile.row, w, h) - for (let d = 0; d < nbs.length; d++) { - const nb = nbs[d] - const ni = idx(nb.col, nb.row, w) - const grad = (tile.pressure ?? 1013) - (tiles[ni].pressure ?? 1013) - if (grad > bestGrad) { - bestGrad = grad - bestDir = d - } - } - - // Coriolis deflection - let deflect = 0 - if (coriolisScale > 0.5) { - deflect = hemiSign > 0 ? 1 : -1 - } else if (coriolisScale > 0.15 && hemiSign > 0 && bestGrad > 5) { - deflect = 1 - } - bestDir = ((bestDir + deflect) % 6 + 6) % 6 - - tile.wind_direction = bestDir - tile.wind_speed = Math.min(Math.max(bestGrad * speedScale, 0), 1) - } - } - - - private stepHumidity(grid: GridState): void { - const { tiles, width: w, height: h } = grid - const oceanEvap = this.p('ocean_evap_rate', 0.03) - const soilRate = this.p('soil_evap_rate', 0.01) - const forestEt = this.p('forest_et_rate', 0.01) - const transport = this.p('wind_humidity_transport', 0.1) - const precRh = this.p('precipitation_rh_threshold', 0.95) - const precHumLoss = this.p('precipitation_humidity_loss', 0.1) - const precMoistG = this.p('precipitation_moisture_gain', 0.05) - const humDecay = this.p('humidity_decay_rate', 0.02) - - // Snapshot for double-buffering - const oldHum = new Float32Array(tiles.length) - for (let i = 0; i < tiles.length; i++) oldHum[i] = tiles[i].humidity ?? 0 - - for (let i = 0; i < tiles.length; i++) { - const tile = tiles[i] - let hum = oldHum[i] - - // Sources - if (this.atmoIsWater(tile.biome_id)) { - hum += oceanEvap * tile.temperature - } else { - hum += soilRate * tile.moisture - const canopy = tile.canopy_cover ?? 0 - if (canopy > 0.3) hum += forestEt * canopy - } - - // Wind transport from upwind neighbor - const upwindDir = (tile.wind_direction + 3) % 6 - const nbs = neighbors(tile.col, tile.row, w, h) - if (upwindDir < nbs.length) { - const upNb = nbs[upwindDir] - if (upNb) { - const upIdx = idx(upNb.col, upNb.row, w) - hum += transport * oldHum[upIdx] * tile.wind_speed - } - } - - // Sinks - hum -= humDecay - - // Precipitation - const satCap = 0.2 + tile.temperature * 0.8 - const rh = Math.min(hum / Math.max(satCap, 0.001), 1.0) - if (rh >= precRh) { - hum -= precHumLoss - tile.moisture = Math.min(Math.max(tile.moisture + precMoistG, 0), 1) - } - - tile.humidity = Math.min(Math.max(hum, 0), 1) - } - } - - - // Step 5: Derived fields — relative_humidity, dew_point, CAPE - private stepDerived(grid: GridState): void { - const { tiles } = grid - for (const tile of tiles) { - const sat = 0.2 + tile.temperature * 0.8 - const rh = Math.min(Math.max(tile.humidity / Math.max(sat, 0.001), 0), 1) - tile.relative_humidity = rh - - // Simplified Magnus formula (normalized scale) - tile.dew_point = tile.temperature - ((1 - rh) * 0.2) - - // CAPE: convective available potential energy [0,1] - const lapse = 0.006 * tile.elevation - const upperTemp = tile.temperature - lapse - const tempDiff = tile.temperature - upperTemp - tile.cape = Math.min(Math.max( - (tempDiff * 2 + tile.humidity * 1.5 - 0.3) * (1 + tile.moisture * 0.5), - 0), 1) - } - } - -} diff --git a/packages/engine-ts/src/EcologyPhysics.generated.ts b/packages/engine-ts/src/EcologyPhysics.generated.ts deleted file mode 100644 index 47664018..00000000 --- a/packages/engine-ts/src/EcologyPhysics.generated.ts +++ /dev/null @@ -1,1205 +0,0 @@ -// AUTO-GENERATED from GDScript ecology engine — do not edit manually. -// Source: engine/src/modules/ecology/flora.gd + fauna_simplified.gd + ecosystem_simplified.gd -// Regenerate: uv run tools/transpile-engine/transpile.py - -import type { GridState, TileState } from './types' -import { idx, neighbors } from './HexGrid' -import { hasTag } from './biomeRegistry' - -// --------------------------------------------------------------------------- -// Biome definitions (auto-generated from biomes.json at transpile time) -// --------------------------------------------------------------------------- - -interface BiomeDef { - id: string - temp_range: [number, number] - moisture_range: [number, number] - flora_climax: { canopy: number; undergrowth: number; fungi: number } - fauna_capacity: number - quality_range: [number, number] -} - -const BIOME_DEFS: Record = { - deep_ocean: { id: 'deep_ocean', temp_range: [0.0, 1.0], moisture_range: [0.0, 1.0], flora_climax: { canopy: 0.0, undergrowth: 0.0, fungi: 0.0 }, fauna_capacity: 8, quality_range: [1, 4] }, - shallow_ocean: { id: 'shallow_ocean', temp_range: [0.0, 1.0], moisture_range: [0.0, 1.0], flora_climax: { canopy: 0.0, undergrowth: 0.3, fungi: 0.0 }, fauna_capacity: 12, quality_range: [1, 5] }, - coral_reef: { id: 'coral_reef', temp_range: [0.55, 1.0], moisture_range: [0.0, 1.0], flora_climax: { canopy: 0.0, undergrowth: 0.6, fungi: 0.1 }, fauna_capacity: 20, quality_range: [1, 5] }, - estuary: { id: 'estuary', temp_range: [0.2, 0.8], moisture_range: [0.6, 1.0], flora_climax: { canopy: 0.0, undergrowth: 0.5, fungi: 0.05 }, fauna_capacity: 14, quality_range: [1, 4] }, - lake: { id: 'lake', temp_range: [0.0, 1.0], moisture_range: [0.0, 1.0], flora_climax: { canopy: 0.0, undergrowth: 0.3, fungi: 0.02 }, fauna_capacity: 10, quality_range: [1, 4] }, - pond: { id: 'pond', temp_range: [0.0, 1.0], moisture_range: [0.0, 1.0], flora_climax: { canopy: 0.0, undergrowth: 0.15, fungi: 0.01 }, fauna_capacity: 3, quality_range: [1, 2] }, - river: { id: 'river', temp_range: [0.0, 1.0], moisture_range: [0.0, 1.0], flora_climax: { canopy: 0.0, undergrowth: 0.2, fungi: 0.01 }, fauna_capacity: 6, quality_range: [1, 3] }, - mangrove: { id: 'mangrove', temp_range: [0.55, 1.0], moisture_range: [0.7, 1.0], flora_climax: { canopy: 0.6, undergrowth: 0.5, fungi: 0.15 }, fauna_capacity: 14, quality_range: [1, 4] }, - tropical_rainforest: { id: 'tropical_rainforest', temp_range: [0.65, 1.0], moisture_range: [0.7, 1.0], flora_climax: { canopy: 0.95, undergrowth: 0.7, fungi: 0.4 }, fauna_capacity: 25, quality_range: [1, 5] }, - tropical_dry_forest: { id: 'tropical_dry_forest', temp_range: [0.55, 1.0], moisture_range: [0.4, 0.7], flora_climax: { canopy: 0.65, undergrowth: 0.5, fungi: 0.2 }, fauna_capacity: 16, quality_range: [1, 4] }, - savanna: { id: 'savanna', temp_range: [0.55, 1.0], moisture_range: [0.2, 0.4], flora_climax: { canopy: 0.15, undergrowth: 0.45, fungi: 0.05 }, fauna_capacity: 12, quality_range: [1, 3] }, - desert: { id: 'desert', temp_range: [0.55, 1.0], moisture_range: [0.0, 0.15], flora_climax: { canopy: 0.0, undergrowth: 0.08, fungi: 0.01 }, fauna_capacity: 4, quality_range: [1, 3] }, - temperate_forest: { id: 'temperate_forest', temp_range: [0.25, 0.55], moisture_range: [0.5, 1.0], flora_climax: { canopy: 0.85, undergrowth: 0.6, fungi: 0.35 }, fauna_capacity: 18, quality_range: [1, 5] }, - temperate_grassland: { id: 'temperate_grassland', temp_range: [0.25, 0.55], moisture_range: [0.3, 0.5], flora_climax: { canopy: 0.05, undergrowth: 0.55, fungi: 0.1 }, fauna_capacity: 14, quality_range: [1, 4] }, - chaparral: { id: 'chaparral', temp_range: [0.25, 0.55], moisture_range: [0.15, 0.35], flora_climax: { canopy: 0.1, undergrowth: 0.35, fungi: 0.05 }, fauna_capacity: 8, quality_range: [1, 3] }, - swamp: { id: 'swamp', temp_range: [0.35, 0.7], moisture_range: [0.8, 1.0], flora_climax: { canopy: 0.5, undergrowth: 0.6, fungi: 0.45 }, fauna_capacity: 15, quality_range: [1, 4] }, - bog: { id: 'bog', temp_range: [0.1, 0.4], moisture_range: [0.7, 1.0], flora_climax: { canopy: 0.05, undergrowth: 0.3, fungi: 0.2 }, fauna_capacity: 6, quality_range: [1, 3] }, - boreal_forest: { id: 'boreal_forest', temp_range: [0.1, 0.3], moisture_range: [0.35, 1.0], flora_climax: { canopy: 0.7, undergrowth: 0.35, fungi: 0.3 }, fauna_capacity: 12, quality_range: [1, 4] }, - tundra: { id: 'tundra', temp_range: [0.05, 0.15], moisture_range: [0.0, 0.5], flora_climax: { canopy: 0.0, undergrowth: 0.15, fungi: 0.05 }, fauna_capacity: 5, quality_range: [1, 3] }, - polar_desert: { id: 'polar_desert', temp_range: [0.0, 0.05], moisture_range: [0.0, 0.2], flora_climax: { canopy: 0.0, undergrowth: 0.02, fungi: 0.0 }, fauna_capacity: 2, quality_range: [1, 2] }, - montane_forest: { id: 'montane_forest', temp_range: [0.15, 0.45], moisture_range: [0.4, 1.0], flora_climax: { canopy: 0.75, undergrowth: 0.45, fungi: 0.25 }, fauna_capacity: 14, quality_range: [1, 4] }, - cloud_forest: { id: 'cloud_forest', temp_range: [0.2, 0.45], moisture_range: [0.7, 1.0], flora_climax: { canopy: 0.8, undergrowth: 0.65, fungi: 0.5 }, fauna_capacity: 20, quality_range: [1, 5] }, - alpine_meadow: { id: 'alpine_meadow', temp_range: [0.05, 0.25], moisture_range: [0.3, 0.7], flora_climax: { canopy: 0.0, undergrowth: 0.3, fungi: 0.08 }, fauna_capacity: 6, quality_range: [1, 3] }, - alpine_tundra: { id: 'alpine_tundra', temp_range: [0.0, 0.15], moisture_range: [0.0, 0.4], flora_climax: { canopy: 0.0, undergrowth: 0.08, fungi: 0.02 }, fauna_capacity: 3, quality_range: [1, 2] }, - sea_ice: { id: 'sea_ice', temp_range: [0.0, 0.08], moisture_range: [0.0, 1.0], flora_climax: { canopy: 0.0, undergrowth: 0.0, fungi: 0.0 }, fauna_capacity: 1, quality_range: [1, 1] }, - glacial: { id: 'glacial', temp_range: [0.0, 0.1], moisture_range: [0.0, 1.0], flora_climax: { canopy: 0.0, undergrowth: 0.01, fungi: 0.0 }, fauna_capacity: 1, quality_range: [1, 1] }, - subterranean: { id: 'subterranean', temp_range: [0.1, 0.5], moisture_range: [0.2, 0.8], flora_climax: { canopy: 0.0, undergrowth: 0.1, fungi: 0.6 }, fauna_capacity: 8, quality_range: [1, 4] }, - ocean: { id: 'ocean', temp_range: [0.0, 1.0], moisture_range: [0.0, 1.0], flora_climax: { canopy: 0.0, undergrowth: 0.0, fungi: 0.0 }, fauna_capacity: 8, quality_range: [1, 4] }, - coast: { id: 'coast', temp_range: [0.0, 1.0], moisture_range: [0.0, 1.0], flora_climax: { canopy: 0.0, undergrowth: 0.15, fungi: 0.0 }, fauna_capacity: 10, quality_range: [1, 4] }, - inland_sea: { id: 'inland_sea', temp_range: [0.0, 1.0], moisture_range: [0.0, 1.0], flora_climax: { canopy: 0.0, undergrowth: 0.0, fungi: 0.0 }, fauna_capacity: 6, quality_range: [1, 3] }, - mountains: { id: 'mountains', temp_range: [0.0, 0.3], moisture_range: [0.0, 1.0], flora_climax: { canopy: 0.0, undergrowth: 0.05, fungi: 0.02 }, fauna_capacity: 3, quality_range: [1, 3] }, - hills: { id: 'hills', temp_range: [0.0, 1.0], moisture_range: [0.0, 1.0], flora_climax: { canopy: 0.2, undergrowth: 0.3, fungi: 0.1 }, fauna_capacity: 8, quality_range: [1, 4] }, - volcano: { id: 'volcano', temp_range: [0.3, 1.0], moisture_range: [0.0, 0.5], flora_climax: { canopy: 0.0, undergrowth: 0.0, fungi: 0.0 }, fauna_capacity: 1, quality_range: [1, 2] }, - grassland: { id: 'grassland', temp_range: [0.15, 0.65], moisture_range: [0.25, 0.6], flora_climax: { canopy: 0.05, undergrowth: 0.5, fungi: 0.08 }, fauna_capacity: 12, quality_range: [1, 4] }, - plains: { id: 'plains', temp_range: [0.15, 0.65], moisture_range: [0.2, 0.5], flora_climax: { canopy: 0.02, undergrowth: 0.4, fungi: 0.05 }, fauna_capacity: 10, quality_range: [1, 4] }, - forest: { id: 'forest', temp_range: [0.25, 0.65], moisture_range: [0.4, 1.0], flora_climax: { canopy: 0.8, undergrowth: 0.55, fungi: 0.3 }, fauna_capacity: 16, quality_range: [1, 5] }, - jungle: { id: 'jungle', temp_range: [0.55, 1.0], moisture_range: [0.6, 1.0], flora_climax: { canopy: 0.9, undergrowth: 0.65, fungi: 0.35 }, fauna_capacity: 22, quality_range: [1, 5] }, - enchanted_forest: { id: 'enchanted_forest', temp_range: [0.2, 0.7], moisture_range: [0.4, 1.0], flora_climax: { canopy: 0.85, undergrowth: 0.6, fungi: 0.4 }, fauna_capacity: 18, quality_range: [1, 5] }, - snow: { id: 'snow', temp_range: [0.0, 0.1], moisture_range: [0.0, 1.0], flora_climax: { canopy: 0.0, undergrowth: 0.0, fungi: 0.0 }, fauna_capacity: 1, quality_range: [1, 1] }, - ice: { id: 'ice', temp_range: [0.0, 0.12], moisture_range: [0.0, 1.0], flora_climax: { canopy: 0.0, undergrowth: 0.0, fungi: 0.0 }, fauna_capacity: 0, quality_range: [1, 1] }, - mana_node: { id: 'mana_node', temp_range: [0.0, 1.0], moisture_range: [0.0, 1.0], flora_climax: { canopy: 0.0, undergrowth: 0.0, fungi: 0.0 }, fauna_capacity: 0, quality_range: [1, 5] }, -} - -function getBiome(biomeId: string): BiomeDef | null { - return BIOME_DEFS[biomeId] ?? null -} - -// --------------------------------------------------------------------------- -// BiomeClassifier (auto-transpiled from biome_classifier.gd) -// --------------------------------------------------------------------------- - -function _getSubstrate(tile: TileState): string { - if (tile.substrate_id !== "") { - return tile.substrate_id - } - return "" - -} - -function _getWaterBodyType(tile: TileState): string { - if (true) { - return tile.water_body_type - } - return "" - -} - -function _getDepth(tile: TileState): number { - if (true) { - return tile.depth_from_coast - } - return 0 - -} - -function _getTemperature(tile: TileState): number { - if (true) { - return tile.temperature - } - return 0.5 - -} - -function _getMoisture(tile: TileState): number { - if (true) { - return tile.moisture - } - return 0.5 - -} - -function _getElevation(tile: TileState): number { - if (true) { - return tile.elevation - } - return 0.3 - -} - -function _getCanopy(tile: TileState): number { - if (true) { - return tile.canopy_cover - } - return 0.0 - -} - -function _isRiverMouth(tile: TileState): boolean { - if (true) { - return tile.is_river_mouth - } - return false - -} - -function _hasCave(tile: TileState): boolean { - if (true) { - return tile.has_cave - } - return false} - -function _isCoastalTile(tile: TileState): boolean { - // True if tile is land adjacent to ocean. - // In Godot: is_coastal is computed at map gen from neighbor water check. - // In TS guide: set by runner post-map-gen from biome_id == "coast". - if (true) { - return tile.is_coastal - } - return false - -} - -function _getBiomeId(tile: TileState): string { - if (true) { - return tile.biome_id - } - return "" - -} - -function _classifyAquatic(tile: TileState): string { - // Boil threshold: water-substrate tile too hot for liquid water → volcanic basin. - // Normalized temperature 0.82 ≈ 800°C surface temp — pre-ocean Hadean conditions. - const WATER_BOIL_TEMP = 0.82 - if (_getTemperature(tile) >= WATER_BOIL_TEMP) { - return "volcanic" - - } - let wb_type = _getWaterBodyType(tile) - let biome = _getBiomeId(tile) - - // Freshwater bodies — identified by water_body_type or biome_id fallback - if (wb_type === "pond" || biome === "pond") { - return "pond" - } - if (wb_type === "river" || biome === "river") { - return "river" - } - if (wb_type === "lake" || wb_type === "large_lake" || biome === "lake" || biome === "inland_sea") { - return "lake" - - } - // Saltwater — ocean tiles - let depth = _getDepth(tile) - let temp = _getTemperature(tile) - - // Estuary: river mouth meeting ocean - if (_isRiverMouth(tile) && depth <= 1) { - return "estuary" - - } - // Coral reef: tropical shallow ocean (temp > 0.55, near coast) - if (temp > 0.55 && depth <= 2) { - // In TS guide: depth=0 for all ocean, so tropical coast = coral_reef - // In Godot: depth properly computed, so only truly shallow tiles qualify - return "coral_reef" - - } - // Deep vs shallow ocean - if (depth > 3) { - return "deep_ocean" - } - return "shallow_ocean" - -} - -function _classifyLand(tile: TileState): string { - let temp = _getTemperature(tile) - let moisture = _getMoisture(tile) - let elevation = _getElevation(tile) - let canopy = _getCanopy(tile) - - // Wetland override: saturated moisture on lowland - // (mangrove already handled in classify() before water/land split) - if (moisture > 0.7 && elevation < 0.4 && canopy > 0) { - if (temp > 0.4) { - return "swamp" - } - return "bog" - - } - // Elevation-driven - if (elevation > 0.85) { - if (temp < 0.1) { - return "glacial" - } - return "alpine_tundra" - - } - if (elevation > 0.70) { - if (canopy > 0 && moisture > 0.3) { - return "alpine_meadow" - } - return "alpine_tundra" - - } - if (elevation > 0.55) { - if (canopy > 0.4) { - return "montane_forest" - } - if (canopy > 0 && moisture > 0.7 && temp > 0.3) { - return "cloud_forest" - } - if (canopy > 0 && moisture > 0.3) { - return "alpine_meadow" - } - return "alpine_tundra" - - } - // Temperature-driven (lowland/midland) - if (temp > 0.55) { - if (moisture > 0.7 && canopy > 0.6) { - return "tropical_rainforest" - } - // Without biology, warm moist land is bare rock — not savanna or dry forest - if (canopy > 0) { - if (moisture > 0.4) { - return "tropical_dry_forest" - } - if (moisture > 0.2) { - return "savanna" - } - } - return "desert" - - } - // Temperate - if (temp > 0.25) { - if (canopy > 0.5) { - return "temperate_forest" - } - // Without biology, temperate moist land is bare rock — not grassland - if (moisture > 0.3 && canopy > 0) { - return "temperate_grassland" - } - return "chaparral" - - } - // Cold - if (temp > 0.1) { - if (canopy > 0.3) { - return "boreal_forest" - } - if (canopy > 0) { - return "tundra" - } - return "polar_desert" - - } - return "polar_desert" - -} - -function classifyBiome(tile: TileState): string { - // Mangrove: tropical wetland at coast — checked before water/land split - // because mangrove tiles are land (wetland substrate) adjacent to ocean. - if (_getSubstrate(tile) === "wetland" && _getTemperature(tile) > 0.55) { - if (_isCoastalTile(tile)) { - return "mangrove" - - } - } - if (_isWater(tile)) { - return _classifyAquatic(tile) - } - if (_getSubstrate(tile) === "volcanic") { - return "volcanic" - } - if (_hasCave(tile)) { - return "subterranean" - } - return _classifyLand(tile) - -} - -// --------------------------------------------------------------------------- -// Flora helpers (auto-transpiled from flora.gd) -// --------------------------------------------------------------------------- - -function _isWater(tile: TileState): boolean { - return hasTag(tile.biome_id ?? '', 'is_water') -} - -function _climateMatchFlat(tile: TileState, bf: Record): number { - // Climate match using pre-resolved flat biome data (no BiomeModel). - let temp = tile.temperature - let moist = tile.moisture - let t_min = bf["temp_min"] ?? 0.0 - let t_max = bf["temp_max"] ?? 1.0 - let m_min = bf["moist_min"] ?? 0.0 - let m_max = bf["moist_max"] ?? 1.0 - - let temp_ok = temp >= t_min && temp <= t_max - let moist_ok = moist >= m_min && moist <= m_max - - if (temp_ok && moist_ok) { - return 1.0 - - } - let temp_edge = temp >= t_min - 0.1 && temp <= t_max + 0.1 - let moist_edge = moist >= m_min - 0.1 && moist <= m_max + 0.1 - - if (temp_edge && moist_edge) { - return 0.5 - - } - return 0.0 - -} - -function _qualityMult(quality: number): number { - // Quality growth scaling: Q1=0.6, Q2=0.8, Q3=1.0, Q4=1.2, Q5=1.4 - switch (quality) { - case 1: return 0.6 - case 2: return 0.8 - case 4: return 1.2 - case 5: return 1.4 - default: return 1.0 - - - }} - -function _o2GrowthMult(o2: number): number { - // Complex plant growth scaling by atmospheric O2. - // Below 2%: no growth. 2-10%: ramp to 0.5. 10-18%: ramp to 1.0. 18%+: full. - if (o2 < 0.02) { - return 0.0 - } - if (o2 < 0.10) { - return (o2 - 0.02) / 0.08 * 0.5 - } - if (o2 < 0.18) { - return 0.5 + (o2 - 0.10) / 0.08 * 0.5 - } - return 1.0} - -function _getStage(stage_index: number, stages: Record[]): Record { - for (const entry of stages) { - if (typeof entry === 'object' && entry !== null && ((entry as any)["stage"] ?? -1) === stage_index) { - return entry - } - } - return {} - - -} - -// --------------------------------------------------------------------------- -// Flora tick functions (auto-transpiled from flora.gd) -// --------------------------------------------------------------------------- - -function tickPioneer(tiles: TileState[], biomeFlora: Record>, veg: Record, o2_fraction: number = 0.21): void { - // Pioneer colonization: seeds bare ground with initial flora using raw climate values. - // Uses temperature/moisture directly (not biome climate range) because abiotic-classified - // tiles may have narrow climate ranges that don't match their actual climate conditions - // (e.g., a temperate tile classified as polar_desert before biology establishes). - // Does NOT run for abiotic worlds (ecology is disabled when abioticWorld = true). - // Respects the O2 minimum threshold — no pioneer seeding in anoxic atmospheres. - let o2_mult = _o2GrowthMult(o2_fraction) - if (o2_mult <= 0.0) { - return - } - let pioneer_rate = (veg as any)["pioneer_rate"] ?? 0.002 - - for (const tile of tiles) { - if (hasTag(tile.biome_id, "is_water")) { - continue - } - // Only seed completely bare tiles - if (tile.undergrowth > 0.0 && tile.canopy_cover > 0.0) { - continue - } - // Raw habitability: minimum conditions for pioneer life - if (tile.temperature < 0.10 || tile.moisture < 0.15) { - continue - } - let bf = biomeFlora[tile.biome_id] ?? {} - if (Object.keys(bf).length === 0) { - continue - } - // Habitat quality: scale by how far above survival thresholds, capped at 1 - let hab_temp = Math.min(1.0, (tile.temperature - 0.10) / 0.40) - let hab_moist = Math.min(1.0, (tile.moisture - 0.15) / 0.40) - let hab = hab_temp * hab_moist - if (hab <= 0.0) { - continue - } - // Seed undergrowth on bare tiles - if (tile.undergrowth <= 0.0) { - tile.undergrowth = pioneer_rate * hab - } - // Seed canopy on tiles with tree/shrub potential (climax canopy >= 5%) - let climax_ca = bf["canopy"] ?? 0.0 - if (climax_ca >= 0.05 && tile.canopy_cover <= 0.0 && tile.undergrowth >= pioneer_rate * 0.5) { - tile.canopy_cover = pioneer_rate * 0.5 * hab - - - } - }} - -function tickCanopy(tiles: TileState[], biomeFlora: Record>, veg: Record, o2_fraction: number = 0.21): void { - // Grow canopy toward biome climax. Decay when outside climate range. - // Requires existing population (dP/dt = r*P) and sufficient atmospheric O2. - let growth_rate = (veg as any)["growth_rate"] ?? 0.02 - let decay_rate = (veg as any)["decay_rate"] ?? 0.03 - let o2_mult = _o2GrowthMult(o2_fraction) - - for (const tile of tiles) { - if (hasTag(tile.biome_id, "is_water")) { - continue - } - let bf = biomeFlora[tile.biome_id] ?? {} - if (Object.keys(bf).length === 0) { - continue - } - let climax = bf["canopy"] ?? 0.0 - - // Population gate: can't grow from nothing - if (tile.canopy_cover <= 0.0) { - continue - - } - let match_mult = _climateMatchFlat(tile, bf) - let q_mult = _qualityMult(tile.quality) - - if (match_mult > 0.0) { - let delta = growth_rate * match_mult * q_mult * o2_mult - tile.canopy_cover = Math.min(tile.canopy_cover + delta, climax) - } else { - tile.canopy_cover = Math.max(tile.canopy_cover - decay_rate, 0.0) - - - } - }} - -function tickUndergrowth(tiles: TileState[], biomeFlora: Record>, veg: Record, o2_fraction: number = 0.21): void { - // Grow undergrowth, capped by canopy shade. Decays faster in drought. - // Requires existing population (dP/dt = r*P) and sufficient atmospheric O2. - let growth_rate = (veg as any)["growth_rate"] ?? 0.02 - let decay_rate = (veg as any)["decay_rate"] ?? 0.03 - let shade_cap = (veg as any)["shade_cap"] ?? 0.7 - let drought_mult = (veg as any)["drought_decay_multiplier"] ?? 1.5 - let o2_mult = _o2GrowthMult(o2_fraction) - - for (const tile of tiles) { - if (hasTag(tile.biome_id, "is_water")) { - continue - } - let bf = biomeFlora[tile.biome_id] ?? {} - if (Object.keys(bf).length === 0) { - continue - } - let climax = bf["undergrowth"] ?? 0.0 - - // Population gate: can't grow from nothing - if (tile.undergrowth <= 0.0) { - continue - - } - let match_mult = _climateMatchFlat(tile, bf) - let q_mult = _qualityMult(tile.quality) - - let effective_cap = climax - if (tile.canopy_cover > shade_cap) { - effective_cap = Math.min(climax, shade_cap) - - } - if (match_mult > 0.0) { - let delta = growth_rate * match_mult * q_mult * o2_mult - tile.undergrowth = Math.min(tile.undergrowth + delta, effective_cap) - } else { - let rate = decay_rate - if (tile.drought_counter > 0) { - rate *= drought_mult - } - tile.undergrowth = Math.max(tile.undergrowth - rate, 0.0) - - - } - }} - -function tickFungi(tiles: TileState[], biomeFlora: Record>, veg: Record): void { - // Fungi grows where undergrowth > threshold. Old-growth bonus. - let growth_rate = (veg as any)["growth_rate"] ?? 0.02 - let decay_rate = (veg as any)["decay_rate"] ?? 0.03 - let ug_threshold = (veg as any)["fungi_undergrowth_threshold"] ?? 0.3 - - for (const tile of tiles) { - if (hasTag(tile.biome_id, "is_water")) { - continue - } - let bf = biomeFlora[tile.biome_id] ?? {} - if (Object.keys(bf).length === 0) { - continue - } - let climax = bf["fungi"] ?? 0.0 - - if (tile.undergrowth < ug_threshold) { - tile.fungi_network = Math.max(tile.fungi_network - decay_rate * 0.5, 0.0) - continue - - } - if (tile.moisture < 0.15 || tile.temperature < 0.1) { - tile.fungi_network = Math.max(tile.fungi_network - decay_rate * 0.5, 0.0) - continue - - } - let ug_factor = tile.undergrowth - let old_growth = 1.0 - if ((tile.canopy_cover > 0.7 && tile.undergrowth > 0.5 && tile.moisture > 0.4)) { - old_growth = 1.5 - - } - let q_mult = _qualityMult(tile.quality) - let delta = growth_rate * ug_factor * old_growth * q_mult - tile.fungi_network = Math.min(tile.fungi_network + delta, climax) - - - }} - -function tickSuccession(tiles: TileState[], suc: Record): void { - // Track canopy stability for biome succession. - // Reclassification and signals handled by _post_succession (non-transpilable). - let stability_turns = (suc as any)["stability_turns"] ?? 50 - let canopy_threshold = (suc as any)["canopy_threshold"] ?? 0.8 - - for (const tile of tiles) { - if (hasTag(tile.biome_id, "is_water")) { - continue - } - if (tile.regrowth_stage >= 0) { - continue - - } - if (tile.canopy_cover >= canopy_threshold) { - tile.succession_progress += 1 - } else { - tile.succession_progress = 0 - - - } - }} - -function tickDesertification(tiles: TileState[], veg: Record, des: Record): void { - // Drought tracking and accelerated flora decay. - let moisture_thresh = (des as any)["moisture_threshold"] ?? 0.2 - let decay_mult = (des as any)["decay_multiplier"] ?? 2.0 - let recovery_rate = (des as any)["recovery_rate"] ?? 1 - let base_decay = (veg as any)["decay_rate"] ?? 0.03 - - for (const tile of tiles) { - if (hasTag(tile.biome_id, "is_water")) { - continue - - } - if (tile.moisture < moisture_thresh) { - tile.drought_counter += 1 - let rate = base_decay * decay_mult - tile.canopy_cover = Math.max(tile.canopy_cover - rate, 0.0) - tile.undergrowth = Math.max(tile.undergrowth - rate * 1.5, 0.0) - tile.fungi_network = Math.max(tile.fungi_network - rate, 0.0) - } else { - tile.drought_counter = Math.max(tile.drought_counter - recovery_rate, 0) - - - } - }} - -function tickRegrowth(tiles: TileState[], regrowthStages: Record[], veg: Record): void { - // Advance tiles through regrowth stages. Fungi accelerates (capped). - let bonus_cap = (veg as any)["fungi_regrowth_bonus_cap"] ?? 2.0 - - for (const tile of tiles) { - if (tile.regrowth_stage < 0) { - continue - - } - tile.regrowth_turns += 1 - - let stage_data = _getStage(tile.regrowth_stage, regrowthStages) - if (Object.keys(stage_data).length === 0) { - continue - - } - let base_turns = (stage_data as any)["turns_to_advance"] ?? 10 - let fungi_bonus = Math.min(bonus_cap, Math.max(1.0, 1.0 + tile.fungi_network * bonus_cap)) - let effective_turns = Math.max( 1, Math.round(base_turns / fungi_bonus) ) - - if (tile.regrowth_turns < effective_turns) { - continue - - } - let next_stage = tile.regrowth_stage + 1 - let next_data = _getStage(next_stage, regrowthStages) - - if (Object.keys(next_data).length === 0 || next_stage > 3) { - tile.regrowth_stage = -1 - tile.regrowth_turns = 0 - continue - - } - tile.regrowth_stage = next_stage - tile.regrowth_turns = 0 - tile.canopy_cover = (next_data as any)["canopy_target"] ?? 0.0 - tile.undergrowth = (next_data as any)["undergrowth_target"] ?? 0.0 - tile.fungi_network = (next_data as any)["fungi_target"] ?? 0.0 - - if (next_stage >= 3) { - tile.regrowth_stage = -1 - tile.regrowth_turns = 0 - - - } - }} - -// --------------------------------------------------------------------------- -// Fauna (auto-transpiled from fauna_simplified.gd) -// --------------------------------------------------------------------------- - -function _tempMult(temperature: number): number { - // Temperature multiplier for fish: tropical=1.0, temperate=0.8, polar=0.5. - if (temperature > 0.55) { - return 1.0 - } - if (temperature > 0.25) { - return 0.8 - } - return 0.5 - - -} - -function _getNeighborOffsets(col: number): number[][] { - // Even-q offset hex neighbor deltas as [dc, dr] arrays. - let parity = col & 1 - if (parity === 0) { - return [[1, 0], [1, -1], [0, -1], [-1, -1], [-1, 0], [0, 1]] - } else { - return [[1, 1], [1, 0], [0, -1], [-1, 0], [-1, 1], [0, 1]] - }} - -function tickFishStock(tiles: TileState[], marine_params: Record): void { - // Logistic fish reproduction on water tiles. - // Requires existing population — no spontaneous generation. - let repro_rate = marine_params["reproduction_rate"] ?? 0.05 - let cap_base = marine_params["fish_capacity"] ?? 100.0 - let reef_bonus = marine_params["reef_bonus"] ?? 0.5 - let reef_penalty = marine_params["reef_penalty"] ?? -0.5 - - for (const tile of tiles) { - if (!hasTag(tile.biome_id, "is_water")) { - continue - - } - let temp_mult = _tempMult(tile.temperature) - let cap = cap_base - if (tile.reef_health > 0.5) { - cap *= (1.0 + reef_bonus) - } else if (tile.reef_health < 0.1) { - cap *= Math.max(0.1, 1.0 + reef_penalty) - - } - let stock = (tile.fish_stock ?? 0) - - // Population gate: no spontaneous generation - if (stock <= 0.0) { - continue - - } - let growth = repro_rate * temp_mult * stock * (1.0 - stock / cap) - tile.fish_stock = Math.min(Math.floor(cap), Math.max(0, Math.floor(stock + growth))) - - - }} - -function tickHabitatSuitability(tiles: TileState[], w: number, h: number): void { - // Per land tile: average flora in radius-1 neighbors. - // undergrowth x 0.6 + canopy x 0.2 + fungi x 0.2. - for (let i = 0; i < tiles.length; i++) { - let tile = tiles[i] - if (hasTag(tile.biome_id, "is_water")) { - continue - - } - let col = tile.col - let row = tile.row - let total_ug = tile.undergrowth - let total_ca = tile.canopy_cover - let total_fn = tile.fungi_network - let count = 1 - - // Radius-1 neighbors via even-q offset - let nbOffsets = _getNeighborOffsets(col) - for (const off of nbOffsets) { - let nc = col + off[0] - let nr = row + off[1] - if (nc < 0 || nc >= w || nr < 0 || nr >= h) { - continue - } - let ni = nr * w + nc - if (ni < 0 || ni >= tiles.length) { - continue - } - let ntile = tiles[ni] - if (hasTag(ntile.biome_id, "is_water")) { - continue - } - total_ug += ntile.undergrowth - total_ca += ntile.canopy_cover - total_fn += ntile.fungi_network - count += 1 - - } - if (count > 0) { - let avg_ug = total_ug / count - let avg_ca = total_ca / count - let avg_fn = total_fn / count - tile.habitat_suitability = avg_ug * 0.6 + avg_ca * 0.2 + avg_fn * 0.2 - } else { - tile.habitat_suitability = 0.0 - - - } - }} - -function tickReefHealth(tiles: TileState[], marine_params: Record): void { - // Reef growth in ideal temperature range. - // Requires existing population — reef can't grow from nothing. - let growth_rate = marine_params["reef_growth_rate"] ?? 0.02 - let ideal_min = marine_params["reef_ideal_min"] ?? 0.55 - let ideal_max = marine_params["reef_ideal_max"] ?? 0.75 - - for (const tile of tiles) { - if (!hasTag(tile.biome_id, "is_water")) { - continue - } - // Population gate: reef can't grow from nothing - if (tile.reef_health <= 0.0) { - continue - } - if (tile.temperature >= ideal_min && tile.temperature <= ideal_max) { - tile.reef_health = Math.min(1.0, tile.reef_health + growth_rate) - - - } - }} - -// --------------------------------------------------------------------------- -// Ecosystem quality (auto-transpiled from ecosystem_simplified.gd) -// --------------------------------------------------------------------------- - -// Ecosystem constants (extracted from ecosystem_simplified.gd) -const W_FLORA = 0.30 -const W_FAUNA = 0.25 -const W_STABILITY = 0.25 -const W_BALANCE = 0.20 -const Q2_THRESHOLD = 0.2 -const Q3_THRESHOLD = 0.4 -const Q4_THRESHOLD = 0.6 -const Q5_THRESHOLD = 0.8 -const BIOME_CANOPY_DELTA = 0.05 -const BIOME_TEMP_DELTA = 0.02 -const BIOME_MOISTURE_DELTA = 0.03 - -function _floraHealth(tile: TileState, bd: Record): number { - // Average of canopy/undergrowth/fungi vs biome climax values. - let canopy_max = Math.max(bd["canopy"] ?? 0.0, 0.001) - let ug_max = Math.max(bd["undergrowth"] ?? 0.0, 0.001) - let fungi_max = Math.max(bd["fungi"] ?? 0.0, 0.001) - - let c = Math.min(1.0, Math.max(0.0, tile.canopy_cover / canopy_max)) - let u = Math.min(1.0, Math.max(0.0, tile.undergrowth / ug_max)) - let f = Math.min(1.0, Math.max(0.0, tile.fungi_network / fungi_max)) - return (c + u + f) / 3.0 - -} - -function _faunaProxy(tile: TileState): number { - // Use habitat_suitability as fauna diversity proxy (no creature DB). - if (true) { - return Math.min(1.0, Math.max(0.0, tile.habitat_suitability)) - } - return 0.3 - -} - -function _biomeStability(tile: TileState, bd: Record): number { - // Score how well the tile's climate matches its assigned biome range. - // 1.0 = perfect match, 0.5 = edge, 0.0 = far outside. - if (Object.keys(bd).length === 0) { - return 0.5 - - } - let temp = tile.temperature - let moist = tile.moisture - let t_min = bd["temp_min"] ?? 0.0 - let t_max = bd["temp_max"] ?? 1.0 - let m_min = bd["moist_min"] ?? 0.0 - let m_max = bd["moist_max"] ?? 1.0 - - let temp_ok = temp >= t_min && temp <= t_max - let moist_ok = moist >= m_min && moist <= m_max - - if (temp_ok && moist_ok) { - return 1.0 - - } - // Partial credit for edge cases - let temp_edge = temp >= t_min - 0.1 && temp <= t_max + 0.1 - let moist_edge = moist >= m_min - 0.1 && moist <= m_max + 0.1 - - if (temp_edge && moist_edge) { - return 0.5 - - } - return 0.2 - -} - -function _landBalance(tile: TileState): number { - // Land population balance proxy: high habitat + moderate flora = balanced. - let hab = 0.0 - if (true) { - hab = tile.habitat_suitability - } - // Well-vegetated tiles with good habitat are balanced - let veg = (tile.undergrowth + tile.canopy_cover) * 0.5 - return Math.min(1.0, Math.max(0.0, (hab + veg) * 0.5)) - -} - -function _waterBalance(tile: TileState): number { - // Water population balance: fish stock ratio vs nominal capacity. - let stock = (true ? (tile.fish_stock ?? 0) : 0.0) - let cap = 100.0 - if (stock <= 0.0) { - return 0.1 - } - let ratio = Math.min(1.0, Math.max(0.0, stock / cap)) - // Score peaks near 60-80% capacity (not overfished, not overcrowded) - if (ratio > 0.8) { - return 0.8 - } - if (ratio > 0.4) { - return 1.0 - } - return ratio / 0.4 - -} - -function _waterStability(tile: TileState): number { - // Water stability from reef health and temperature range. - let reef = (true ? tile.reef_health : 0.0) - let temp = (true ? tile.temperature : 0.5) - // Tropical/temperate water is more stable - let temp_score = 0.5 - if (temp > 0.25 && temp < 0.75) { - temp_score = 1.0 - } else if (temp > 0.15) { - temp_score = 0.7 - } - return (reef * 0.5 + temp_score * 0.5) - -} - -function _scoreToTier(score: number): number { - // Map [0,1] score to Q1-Q5 tier. - if (score >= Q5_THRESHOLD) { - return 5 - } - if (score >= Q4_THRESHOLD) { - return 4 - } - if (score >= Q3_THRESHOLD) { - return 3 - } - if (score >= Q2_THRESHOLD) { - return 2 - } - return 1 - -} - -function recomputeBiomes(tiles: TileState[], w: number, h: number, lastCanopy: Float32Array, lastTemp: Float32Array, lastMoisture: Float32Array): void { - // Reclassify biomes where canopy/temp/moisture changed significantly. - // Updates last_* arrays in-place for next turn's comparison. - let n = tiles.length - if (lastCanopy.length !== n) { - lastCanopy = new Float32Array(n) - lastTemp = new Float32Array(n) - lastMoisture = new Float32Array(n) - for (let i = 0; i < n; i++) { - lastCanopy[i] = tiles[i].canopy_cover - lastTemp[i] = tiles[i].temperature - lastMoisture[i] = tiles[i].moisture - } - return - - } - for (let i = 0; i < n; i++) { - let tile = tiles[i] - if (hasTag(tile.biome_id, "is_water")) { - continue - } - let dCanopy = Math.abs(tile.canopy_cover - lastCanopy[i]) - let dTemp = Math.abs(tile.temperature - lastTemp[i]) - let dMoisture = Math.abs(tile.moisture - lastMoisture[i]) - lastCanopy[i] = tile.canopy_cover - lastTemp[i] = tile.temperature - lastMoisture[i] = tile.moisture - if (dCanopy > BIOME_CANOPY_DELTA || dTemp > BIOME_TEMP_DELTA || dMoisture > BIOME_MOISTURE_DELTA) { - // Inline classifier call — will be transpiled to classifyBiome(tile) - let newBiome = _classifyBiomeInline(tile) - if (newBiome !== tile.biome_id) { - tile.biome_id = newBiome - - - } - } - }} - -function _classifyBiomeInline(tile: TileState): string { - // Minimal inline classifier for recomputation. Mirrors biome_classifier.gd logic. - if (hasTag(tile.biome_id, "is_water")) { - return tile.biome_id - } - let temp = tile.temperature - let moist = tile.moisture - let elev = tile.elevation - let canopy = tile.canopy_cover - if (moist > 0.7 && elev < 0.4 && canopy > 0) { - if (temp > 0.4) { - return "swamp" - } - return "bog" - } - if (elev > 0.85) { - if (temp < 0.1) { - return "glacial" - } - return "alpine_tundra" - } - if (elev > 0.70) { - if (canopy > 0 && moist > 0.3) { - return "alpine_meadow" - } - return "alpine_tundra" - } - if (elev > 0.55) { - if (canopy > 0.4) { - return "montane_forest" - } - if (canopy > 0 && moist > 0.7 && temp > 0.3) { - return "cloud_forest" - } - if (canopy > 0 && moist > 0.3) { - return "alpine_meadow" - } - return "alpine_tundra" - } - if (temp > 0.55) { - if (moist > 0.7 && canopy > 0.6) { - return "tropical_rainforest" - } - if (canopy > 0) { - if (moist > 0.4) { - return "tropical_dry_forest" - } - if (moist > 0.2) { - return "savanna" - } - } - return "desert" - } - if (temp > 0.25) { - if (canopy > 0.5) { - return "temperate_forest" - } - if (moist > 0.3 && canopy > 0) { - return "temperate_grassland" - } - return "chaparral" - } - if (temp > 0.1) { - if (canopy > 0.3) { - return "boreal_forest" - } - if (canopy > 0) { - return "tundra" - } - return "polar_desert" - } - return "polar_desert" - -} - -function computeTileQuality(tiles: TileState[], biomeFlora: Record>, w: number, h: number): void { - // Per-tile ecology composite -> Q1-Q5. - // biomeFlora: biome_id -> {canopy, undergrowth, fungi, quality_min, quality_max, - // temp_min, temp_max, moist_min, moist_max} - // Land tiles: flora_health x 0.30 + fauna_diversity x 0.25 - // + biome_stability x 0.25 + population_balance x 0.20 - // Water tiles: fish_stock ratio used for population_balance. - - for (const tile of tiles) { - let bd = biomeFlora[tile.biome_id] ?? {} - - let flora_score = 0.0 - let fauna_score = 0.0 - let stability_score = 0.0 - let balance_score = 0.5 - - if (hasTag(tile.biome_id, "is_water")) { - // Water tiles: quality from fish stock and reef health - balance_score = _waterBalance(tile) - stability_score = _waterStability(tile) - fauna_score = balance_score - flora_score = (true ? tile.reef_health : 0.0) - } else { - flora_score = _floraHealth(tile, bd) - fauna_score = _faunaProxy(tile) - stability_score = _biomeStability(tile, bd) - balance_score = _landBalance(tile) - - } - let score = ( flora_score * W_FLORA + fauna_score * W_FAUNA + stability_score * W_STABILITY + balance_score * W_BALANCE ) - - let new_q = _scoreToTier(score) - - // Cap by biome quality range - let q_min = bd["quality_min"] ?? 1 - let q_max = bd["quality_max"] ?? 5 - new_q = Math.min(q_max, Math.max(q_min, new_q)) - - tile.quality = new_q - - - }} - -function computeGlobalHealth(grid: GridState): number { - // Average of tile qualities / 5.0 across all tiles. - let total = 0.0 - let count = 0 - for (const tile of grid.tiles) { - total += tile.quality / 5.0 - count += 1 - } - if (count === 0) { - return 0.0 - } - return total / count - -} - -export function getEcologyFoodModifier(tile: TileState): number { - // Food yield modifier based on ecology quality tier. - let mult = {1: 0.5, 2: 1.0, 3: 1.5, 4: 2.0, 5: 2.5} - let base = (mult as any)[tile.quality] ?? 1.0 - if (!hasTag(tile.biome_id, "is_water")) { - base *= 0.8 + 0.4 * tile.undergrowth - } - return base - -} - -// --------------------------------------------------------------------------- -// EcologyPhysics class — orchestrates flora + fauna + quality per turn -// --------------------------------------------------------------------------- - -export class EcologyPhysics { - private lastCanopy: Float32Array = new Float32Array(0) - private lastTemp: Float32Array = new Float32Array(0) - private lastMoisture: Float32Array = new Float32Array(0) - - // Pre-resolved biome flora data keyed by biome_id. - // In the game this is loaded by DataLoader; in the guide it's built from BIOME_DEFS. - private readonly biomeFlora: Record> - private readonly marineParams: Record = { - reproduction_rate: 0.05, fish_capacity: 100, reef_bonus: 0.5, - reef_penalty: -0.5, seed_fraction: 0.1, reef_growth_rate: 0.02, - reef_ideal_min: 0.55, reef_ideal_max: 0.75, - } - - constructor(biomeFlora?: Record>) { - if (biomeFlora) { - this.biomeFlora = biomeFlora - } else { - // Build from inline BIOME_DEFS (guide default) - this.biomeFlora = Object.fromEntries( - Object.entries(BIOME_DEFS).map(([id, def]) => [id, { - canopy: def.flora_climax.canopy, - undergrowth: def.flora_climax.undergrowth, - fungi: def.flora_climax.fungi, - temp_min: def.temp_range[0], - temp_max: def.temp_range[1], - moist_min: def.moisture_range[0], - moist_max: def.moisture_range[1], - }]), - ) - } - } - - /** - * Process one turn of ecology dynamics. - * Call after ClimatePhysics.processStep(). - */ - processStep(grid: GridState): void { - const { tiles, width: w, height: h } = grid - const bf = this.biomeFlora - - // Vegetation params (guide uses hardcoded defaults matching flora.gd _DEFAULTS) - const veg = { growth_rate: 0.02, decay_rate: 0.03, shade_cap: 0.7, - drought_decay_multiplier: 1.5, fungi_undergrowth_threshold: 0.3, - fungi_regrowth_bonus_cap: 2.0, pioneer_rate: 0.002 } - const suc = { stability_turns: 50, canopy_threshold: 0.8 } - const des = { moisture_threshold: 0.2, decay_multiplier: 2.0, recovery_rate: 1 } - const regrowthStages = [ - { stage: 0, turns_to_advance: 10, canopy_target: 0.0, undergrowth_target: 0.1, fungi_target: 0.0 }, - { stage: 1, turns_to_advance: 15, canopy_target: 0.1, undergrowth_target: 0.3, fungi_target: 0.05 }, - { stage: 2, turns_to_advance: 20, canopy_target: 0.4, undergrowth_target: 0.5, fungi_target: 0.2 }, - { stage: 3, turns_to_advance: 25, canopy_target: 0.7, undergrowth_target: 0.6, fungi_target: 0.4 }, - ] - - // Flora dynamics (order matches flora.gd process_turn) - const o2 = grid.o2_fraction ?? 0.21 - tickPioneer(tiles, bf, veg, o2) - // Reclassify pioneer-seeded tiles immediately so subsequent ticks use the correct biome. - // A tile seeded from canopy=0 to canopy>0 may be in an abiotic biome (polar_desert, - // chaparral) that won't match its actual climate, causing tick_canopy/tick_undergrowth - // to decay the pioneer growth. Reclassifying here lets the same-turn tick functions - // use the correct biotic biome and grow instead of decay. - for (const tile of tiles) { - if (!hasTag(tile.biome_id, 'is_water') && (tile.undergrowth > 0 || tile.canopy_cover > 0)) { - const _newBiome = _classifyBiomeInline(tile) - if (_newBiome !== tile.biome_id) tile.biome_id = _newBiome - } - } - tickCanopy(tiles, bf, veg, o2) - tickUndergrowth(tiles, bf, veg, o2) - tickFungi(tiles, bf, veg) - tickSuccession(tiles, suc) - tickDesertification(tiles, veg, des) - tickRegrowth(tiles, regrowthStages, veg) - - // Biome recomputation (auto-transpiled from ecosystem_simplified.gd) - recomputeBiomes(tiles, w, h, this.lastCanopy, this.lastTemp, this.lastMoisture) - - // Fauna - tickHabitatSuitability(tiles, w, h) - tickFishStock(tiles, this.marineParams) - tickReefHealth(tiles, this.marineParams) - - // Quality scoring - computeTileQuality(tiles, bf, w, h) - - // Global health - grid.ecosystem_health = computeGlobalHealth(grid) - } -} - -// Re-export helpers for guide lenses -export { _isWater as isWater, getBiome, classifyBiome, BIOME_DEFS } -export type { BiomeDef } diff --git a/packages/engine-ts/src/MapGenerator.generated.ts b/packages/engine-ts/src/MapGenerator.generated.ts deleted file mode 100644 index 9a87ab33..00000000 --- a/packages/engine-ts/src/MapGenerator.generated.ts +++ /dev/null @@ -1,1578 +0,0 @@ -// AUTO-GENERATED from GDScript map generation pipeline — do not edit manually. -// Sources: engine/src/generation/map_generator.gd + map_shape_seeds.gd + -// terrain_refiner.gd + wind_calculator.gd + hydrology.gd + hydrology_rivers.gd -// engine/src/map/hex_utils.gd -// Regenerate: uv run tools/transpile-engine/transpile.py - -import type { GridState, TileState, TerrainData } from './types' -import { idx, neighbors, axialToOffset, hashNoise, AXIAL_DIRECTIONS } from './HexGrid' -import { hasTag } from './biomeRegistry' - -/** - * PCG32 PRNG — bit-identical to Godot 4's RandomNumberGenerator. - * Uses BigInt for 64-bit state math. Apache 2.0 (pcg-random.org). - */ -class PCG32 { - private state = 0n; - private inc = 2885390081777926815n; // (1442695040888963407 << 1) | 1 - - /** Seed the generator (matches Godot's pcg32_srandom_r with default stream). */ - seed(initstate: number | bigint): void { - const s = BigInt(initstate) & 0xFFFFFFFFFFFFFFFFn; - const initseq = 1442695040888963407n; - this.state = 0n; - this.inc = ((initseq << 1n) | 1n) & 0xFFFFFFFFFFFFFFFFn; - this._next(); - this.state = (this.state + s) & 0xFFFFFFFFFFFFFFFFn; - this._next(); - } - - /** Raw 32-bit output (pcg32_random_r). */ - private _next(): number { - const old = this.state; - this.state = (old * 6364136223846793005n + this.inc) & 0xFFFFFFFFFFFFFFFFn; - const xorshifted = Number(((old >> 18n) ^ old) >> 27n) >>> 0; - const rot = Number(old >> 59n); - return ((xorshifted >>> rot) | (xorshifted << ((-rot) & 31))) >>> 0; - } - - /** Unsigned 32-bit integer. */ - randi(): number { - return this._next(); - } - - /** Float in [0, 1) — matches Godot's `randf()` fallback path. */ - randf(): number { - return (this._next() & 0xFFFFFF) / 0x1000000; - } - - /** Integer in [from, to] inclusive — matches Godot's `randi_range`. */ - randiRange(from: number, to: number): number { - if (from > to) [from, to] = [to, from]; - const range = to - from + 1; - return from + (this._next() % range); - } - - /** Float in [from, to) — matches Godot's `randf_range`. */ - randfRange(from: number, to: number): number { - return from + this.randf() * (to - from); - } -} - - -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - -const SIZE_TABLE: Record = { - duel: { width: 40, height: 24, default_players: 2, natural_wonders: 1 }, - tiny: { width: 56, height: 36, default_players: 4, natural_wonders: 2 }, - small: { width: 66, height: 42, default_players: 4, natural_wonders: 2 }, - standard: { width: 80, height: 52, default_players: 8, natural_wonders: 3 }, - large: { width: 104, height: 64, default_players: 10, natural_wonders: 4 }, - huge: { width: 128, height: 80, default_players: 12, natural_wonders: 5 }, -} - -const DEFAULT_TYPE_DATA: Record = { - ocean_percentage: { target: 0.40, variance: 0.05 }, - continent_count: { min: 2, max: 4 }, - terrain_fractions: { - enchanted_forest: 0.01, desert: 0.10, swamp: 0.07, - volcano: 0.02, - }, - generation_params: { - num_landmass: 35, steepness: 0.20, prevailing_wind_direction: 0, - coastline_smoothing_iterations: 2, river_count_per_continent: 2, - river_source_min_distance: 4, start_position_min_distance: 10, - }, -} - -const WIND_DEFAULTS: Record = { - wind_band_polar_cut: 0.15, - wind_band_polar_front_cut: 0.30, - wind_band_ferrel_cut: 0.50, - wind_band_subtropical_cut: 0.60, - wind_band_hadley_cut: 0.85, - wind_speed_polar: 0.6, - wind_speed_polar_front: 0.3, - wind_speed_ferrel: 0.8, - wind_speed_subtropical: 0.3, - wind_speed_hadley: 0.7, - wind_speed_itcz: 0.3, - wind_friction_land: 0.7, - wind_friction_mountain_downwind: 0.1, -} - -const OPPOSITE_DIR: readonly number[] = [3, 4, 5, 0, 1, 2] -const WATER_TERRAINS: readonly string[] = ['ocean', 'coast', 'lake', 'inland_sea'] - -// --------------------------------------------------------------------------- -// Hex coordinate helpers (from hex_utils.gd) -// --------------------------------------------------------------------------- - -interface Vec2i { x: number; y: number } - -function offsetToAxial(col: number, row: number): Vec2i { - const q = col - const r = row - Math.trunc((col - (col & 1)) / 2) - return { x: q, y: r } -} - -function axialToOffsetCoords(q: number, r: number): { col: number; row: number } { - const col = q - const row = r + Math.trunc((q - (q & 1)) / 2) - return { col, row } -} - -function axialNeighbors(ax: Vec2i): Vec2i[] { - return AXIAL_DIRECTIONS.map(([dq, dr]) => ({ x: ax.x + dq, y: ax.y + dr })) -} - -function axialKey(ax: Vec2i): string { - return `${ax.x},${ax.y}` -} - -function hexRing(center: Vec2i, radius: number): Vec2i[] { - if (radius <= 0) return [] - const results: Vec2i[] = [] - let current: Vec2i = { - x: center.x + AXIAL_DIRECTIONS[4][0] * radius, - y: center.y + AXIAL_DIRECTIONS[4][1] * radius, - } - for (let d = 0; d < 6; d++) { - for (let s = 0; s < radius; s++) { - results.push(current) - current = { x: current.x + AXIAL_DIRECTIONS[d][0], y: current.y + AXIAL_DIRECTIONS[d][1] } - } - } - return results -} - -function hexSpiral(center: Vec2i, radius: number): Vec2i[] { - const results: Vec2i[] = [center] - for (let r = 1; r <= radius; r++) { - for (const pos of hexRing(center, r)) results.push(pos) - } - return results -} - -// --------------------------------------------------------------------------- -// Internal generation tile + map (converts to GridState at the end) -// --------------------------------------------------------------------------- - -interface GenTile { - axial: Vec2i - col: number - row: number - biome_id: string - elevation: number - moisture: number - temperature: number - is_coastal: boolean - quality: number - quality_progress: number - wind_direction: number - wind_speed: number - river_edges: number[] - river_flow: Record - flow_accumulation: number - lake_id: number - river_source_type: string - variation_index: number -} - -function newGenTile(axial: Vec2i, col: number, row: number): GenTile { - return { - axial, col, row, - biome_id: '', - elevation: 0.0, - moisture: 0.0, - temperature: 0.0, - is_coastal: false, - quality: 2, - quality_progress: 0, - wind_direction: 0, - wind_speed: 0.5, - river_edges: [], - river_flow: {}, - flow_accumulation: 0.0, - lake_id: -1, - river_source_type: '', - variation_index: 0, - } -} - -class GenMap { - readonly width: number - readonly height: number - readonly tiles: Map = new Map() - sea_level = 0.0 - - constructor(width: number, height: number) { - this.width = width - this.height = height - } - - setTile(ax: Vec2i, tile: GenTile): void { - this.tiles.set(axialKey(ax), tile) - } - - getTile(ax: Vec2i): GenTile | undefined { - return this.tiles.get(axialKey(ax)) - } - - hasTile(ax: Vec2i): boolean { - return this.tiles.has(axialKey(ax)) - } - - getTilesByTerrain(terrainId: string): Vec2i[] { - const result: Vec2i[] = [] - for (const tile of this.tiles.values()) { - if (tile.biome_id === terrainId) result.push(tile.axial) - } - return result - } - - toGridState(): GridState { - const n = this.width * this.height - const tiles: TileState[] = new Array(n) - for (let row = 0; row < this.height; row++) { - for (let col = 0; col < this.width; col++) { - const ax = offsetToAxial(col, row) - const gt = this.getTile(ax) - const i = idx(col, row, this.width) - tiles[i] = { - col, row, - temperature: gt?.temperature ?? 0.0, - moisture: gt?.moisture ?? 0.0, - elevation: gt?.elevation ?? 0.0, - biome_id: gt?.biome_id ?? 'ocean', - wind_direction: gt?.wind_direction ?? 0, - wind_speed: gt?.wind_speed ?? 0.5, - quality: gt?.quality ?? 2, - quality_progress: gt?.quality_progress ?? (((col * 7 + row * 13) % 11) - 5), // stagger -5..+5 to prevent synchronized biome flips - river_edges: gt?.river_edges ?? [], - river_flow: gt?.river_flow ?? {}, - flow_accumulation: gt?.flow_accumulation ?? 0.0, - original_biome_id: '', - ley_line_count: 0, - ley_school: '', - reef_health: 1.0, - magic_heat_delta: 0.0, - magic_moisture_delta: 0.0, - is_natural_wonder: false, - wonder_anchor_strength: 0.0, - wonder_anchor_school: 'none', - wonder_anchor_schools: [], - wonder_tier: 0, - river_source_type: gt?.river_source_type || undefined, - // Classifier fields (populated by water body finder + map gen) - water_body_type: '', - is_river_mouth: false, - has_cave: false, - is_coastal: false, - // Atmosphere fields (populated by ClimatePhysics atmosphere steps) - pressure: 1013.0, - pressure_anomaly: 0.0, - humidity: 0.0, - sulfate_aerosol: 0.0, - // Ecology fields (populated by EcologyPhysics) - canopy_cover: 0.0, - undergrowth: 0.0, - fungi_network: 0.0, - drought_counter: 0, - succession_progress: 0, - regrowth_stage: -1, - regrowth_turns: 0, - habitat_suitability: 0.0, - habitat_low_turns: 0, - landmark_name: '', - substrate_id: '', - water_body_id: -1, - depth_from_coast: -1, - fish_stock: 0, - surface_water: 0.0, - relative_humidity: 0.5, - dew_point: 0.0, - cape: 0.0, - } - } - } - return { - tiles, - width: this.width, - height: this.height, - global_avg_temp: 0.5, - ocean_dead_fraction: 0.0, - ecosystem_health: 1.0, - sea_level: this.sea_level, - total_ocean_water: 0.0, - ocean_basin_area: 0, - } - } -} - -// --------------------------------------------------------------------------- -// Shared helpers -// --------------------------------------------------------------------------- - -function isWaterTerrain(terrainId: string): boolean { - return terrainId === 'ocean' || terrainId === 'coast' -} - -function isWaterTerrainHydro(terrainId: string): boolean { - return WATER_TERRAINS.includes(terrainId) -} - -// --------------------------------------------------------------------------- -// Stage 1: Region seed placement (map_generator.gd + map_shape_seeds.gd) -// --------------------------------------------------------------------------- - -interface Region { - center: Vec2i - elevation: number - edge: boolean -} - -function placeRegionSeedsShaped( - gm: GenMap, genParams: Record, typeId: string, rng: PCG32, -): Region[] { - const regions: Region[] = [] - const w = gm.width - const h = gm.height - - // Edge seeds every 5 tiles along all four borders (always ocean frame) - for (let col = 0; col < w; col += 5) { - for (const rowVal of [0, h - 1]) { - regions.push({ center: offsetToAxial(col, rowVal), elevation: 0, edge: true }) - } - } - for (let row = 0; row < h; row += 5) { - for (const colVal of [0, w - 1]) { - regions.push({ center: offsetToAxial(colVal, row), elevation: 0, edge: true }) - } - } - - const numInterior = genParams['num_landmass'] ?? (20 + Math.floor(15.0 * Math.sqrt((w * h) / 2772.0))) - const cx = w / 2.0 - const cy = h / 2.0 - - placeSeedsForShape(regions, w, h, cx, cy, numInterior, typeId, genParams, rng) - return regions -} - -function placeSeedsForShape( - regions: Region[], w: number, h: number, cx: number, cy: number, - numInterior: number, typeId: string, genParams: Record, rng: PCG32, -): void { - const seedShape = String(genParams['seed_shape'] ?? '') - switch (seedShape) { - case 'two_clusters': - placeSeedsTwoClusters(regions, w, h, cx, cy, numInterior, genParams, rng); break - case 'cross': - placeSeedsCross(regions, w, h, cx, cy, numInterior, genParams, rng); break - case 'plus': - placeSeedsPlus(regions, w, h, cx, cy, numInterior, genParams, rng); break - default: - placeSeedsDefault(regions, w, h, cx, cy, numInterior, typeId, rng); break - } -} - -function placeSeedsDefault( - regions: Region[], w: number, h: number, cx: number, cy: number, - numInterior: number, typeId: string, rng: PCG32, -): void { - const isPangaea = typeId === 'pangaea' - const stdDev = Math.min(w, h) * 0.25 - for (let i = 0; i < numInterior; i++) { - let col: number, row: number - if (isPangaea) { - col = Math.min(Math.max(cx + randfn(rng, 0.0, stdDev), 1.0), w - 2.0) - row = Math.min(Math.max(cy + randfn(rng, 0.0, stdDev), 1.0), h - 2.0) - } else { - col = rng.randfRange(1.0, w - 2.0) - row = rng.randfRange(1.0, h - 2.0) - } - regions.push({ - center: offsetToAxial(Math.round(col), Math.round(row)), - elevation: rng.randiRange(0, 1000), - edge: false, - }) - } -} - -/** Gaussian normal distribution via Box-Muller (matches Godot's randfn). */ -function randfn(rng: PCG32, mean: number, deviation: number): number { - const u1 = Math.max(1e-10, rng.randf()) - const u2 = rng.randf() - const z0 = Math.sqrt(-2.0 * Math.log(u1)) * Math.cos(2.0 * Math.PI * u2) - return mean + z0 * deviation -} - -function placeSeedsTwoClusters( - regions: Region[], w: number, h: number, _cx: number, cy: number, - numInterior: number, genParams: Record, rng: PCG32, -): void { - const clusterStd = Math.min(w, h) * (genParams['cluster_std_dev'] ?? 0.18) - const leftCx = w * 0.25 - const rightCx = w * 0.75 - for (let i = 0; i < numInterior; i++) { - const targetCx = i % 2 === 0 ? leftCx : rightCx - const col = Math.min(Math.max(targetCx + randfn(rng, 0.0, clusterStd), 1.0), w - 2.0) - const row = Math.min(Math.max(cy + randfn(rng, 0.0, clusterStd), 1.0), h - 2.0) - regions.push({ - center: offsetToAxial(Math.round(col), Math.round(row)), - elevation: rng.randiRange(0, 1000), - edge: false, - }) - } -} - -function placeSeedsCross( - regions: Region[], w: number, h: number, cx: number, cy: number, - numInterior: number, genParams: Record, rng: PCG32, -): void { - const armW = genParams['arm_width_fraction'] ?? 0.18 - const centerR = genParams['center_radius_fraction'] ?? 0.15 - const centerRadius = Math.min(w, h) * centerR - let placed = 0 - const maxAttempts = numInterior * 10 - for (let attempt = 0; attempt < maxAttempts; attempt++) { - if (placed >= numInterior) break - const col = rng.randfRange(1.0, w - 2.0) - const row = rng.randfRange(1.0, h - 2.0) - const nx = col / w - const ny = row / h - const dcx = Math.abs(col - cx) - const dcy = Math.abs(row - cy) - const distCenter = Math.sqrt(dcx * dcx + dcy * dcy) - const onDiag1 = Math.abs(nx - ny) - const onDiag2 = Math.abs(nx - (1.0 - ny)) - if (distCenter < centerRadius || onDiag1 < armW || onDiag2 < armW) { - regions.push({ - center: offsetToAxial(Math.round(col), Math.round(row)), - elevation: rng.randiRange(0, 1000), - edge: false, - }) - placed++ - } - } -} - -function placeSeedsPlus( - regions: Region[], w: number, h: number, cx: number, cy: number, - numInterior: number, genParams: Record, rng: PCG32, -): void { - const armW = genParams['arm_width_fraction'] ?? 0.18 - const centerR = genParams['center_radius_fraction'] ?? 0.15 - const centerRadius = Math.min(w, h) * centerR - const armHalfWPx = w * armW * 0.5 - const armHalfHPx = h * armW * 0.5 - let placed = 0 - const maxAttempts = numInterior * 10 - for (let attempt = 0; attempt < maxAttempts; attempt++) { - if (placed >= numInterior) break - const col = rng.randfRange(1.0, w - 2.0) - const row = rng.randfRange(1.0, h - 2.0) - const dcx = Math.abs(col - cx) - const dcy = Math.abs(row - cy) - const distCenter = Math.sqrt(dcx * dcx + dcy * dcy) - const onHorizontal = dcy < armHalfHPx - const onVertical = dcx < armHalfWPx - if (distCenter < centerRadius || onHorizontal || onVertical) { - regions.push({ - center: offsetToAxial(Math.round(col), Math.round(row)), - elevation: rng.randiRange(0, 1000), - edge: false, - }) - placed++ - } - } -} - -// --------------------------------------------------------------------------- -// Stage 2: Region growth — hex Voronoi BFS (map_generator.gd) -// --------------------------------------------------------------------------- - -function growRegions( - gm: GenMap, regions: Region[], elevation: Map, rng: PCG32, -): void { - const claimed = new Set() - const queue: Array<{ pos: Vec2i; ridx: number }> = [] - - for (let i = 0; i < regions.length; i++) { - const center = regions[i].center - const key = axialKey(center) - if (!gm.hasTile(center) || claimed.has(key)) continue - claimed.add(key) - setRegionElevation(gm, center, regions[i].elevation, elevation) - queue.push({ pos: center, ridx: i }) - } - - let head = 0 - while (head < queue.length) { - const entry = queue[head++] - const ridx = entry.ridx - for (const nb of axialNeighbors(entry.pos)) { - const key = axialKey(nb) - if (!gm.hasTile(nb) || claimed.has(key)) continue - claimed.add(key) - setRegionElevation(gm, nb, regions[ridx].elevation, elevation) - queue.push({ pos: nb, ridx }) - } - } - - // Elevation fuzz +/-2 to break hard region boundaries - for (const tile of gm.tiles.values()) { - const key = axialKey(tile.axial) - const fuzz = rng.randiRange(-2, 2) - const prev = elevation.get(key) ?? 0.0 - elevation.set(key, prev + fuzz) - tile.elevation = prev + fuzz - } -} - -function setRegionElevation( - gm: GenMap, axial: Vec2i, baseElevation: number, elevation: Map, -): void { - const tile = gm.getTile(axial) - if (!tile) return - tile.elevation = baseElevation - elevation.set(axialKey(axial), baseElevation) -} - -// --------------------------------------------------------------------------- -// Stage 3: Normalise elevation to [0, 1] (map_generator.gd) -// --------------------------------------------------------------------------- - -function normalizeElevation(gm: GenMap, elevation: Map): void { - const allElevs: number[] = [] - for (const tile of gm.tiles.values()) { - allElevs.push(elevation.get(axialKey(tile.axial)) ?? 0.0) - } - allElevs.sort((a, b) => a - b) - - const n = allElevs.length - if (n <= 1) return - - for (const tile of gm.tiles.values()) { - const key = axialKey(tile.axial) - const elev = elevation.get(key) ?? 0.0 - const rank = bsearch(allElevs, elev) - const normalised = rank / (n - 1) - elevation.set(key, normalised) - tile.elevation = normalised - } -} - -function bsearch(sorted: number[], value: number): number { - let lo = 0, hi = sorted.length - while (lo < hi) { - const mid = (lo + hi) >>> 1 - if (sorted[mid] < value) lo = mid + 1 - else hi = mid - } - return lo -} - -// --------------------------------------------------------------------------- -// Stage 4: Sea level assignment + coastline smoothing (map_generator.gd + terrain_refiner.gd) -// --------------------------------------------------------------------------- - -function assignSeaLevel( - gm: GenMap, oceanTarget: number, genParams: Record, - elevation: Map, -): void { - const allElevs: number[] = [] - for (const tile of gm.tiles.values()) { - allElevs.push(elevation.get(axialKey(tile.axial)) ?? 0.0) - } - allElevs.sort((a, b) => a - b) - - const seaIdx = Math.min( - Math.max(Math.round(oceanTarget * allElevs.length), 0), allElevs.length - 1, - ) - const seaLevel = allElevs[seaIdx] - gm.sea_level = seaLevel - - for (const tile of gm.tiles.values()) { - const elev = elevation.get(axialKey(tile.axial)) ?? 0.0 - tile.biome_id = elev < seaLevel ? 'ocean' : 'land' - } - - smoothCoastlines(gm, genParams) - assignCoastTiles(gm) -} - -function smoothCoastlines(gm: GenMap, params: Record): void { - const iterations = params['coastline_smoothing_iterations'] ?? 2 - for (let pass = 0; pass < iterations; pass++) { - const changes: Array<{ pos: Vec2i; terrain: string }> = [] - for (const tile of gm.tiles.values()) { - let landCount = 0 - let neighborCount = 0 - for (const [dq, dr] of AXIAL_DIRECTIONS) { - const nb = gm.getTile({ x: tile.axial.x + dq, y: tile.axial.y + dr }) - if (!nb) continue - neighborCount++ - if (!isWaterTerrain(nb.biome_id)) landCount++ - } - const waterCount = neighborCount - landCount - if (isWaterTerrain(tile.biome_id) && landCount >= 5) { - changes.push({ pos: tile.axial, terrain: 'grassland' }) - } else if (!isWaterTerrain(tile.biome_id) && waterCount >= 5) { - changes.push({ pos: tile.axial, terrain: 'ocean' }) - } - } - for (const change of changes) { - const tile = gm.getTile(change.pos) - if (tile) tile.biome_id = change.terrain - } - } -} - -function assignCoastTiles(gm: GenMap): void { - for (const tile of gm.tiles.values()) { - if (tile.biome_id === 'ocean') { - let hasLandNeighbor = false - for (const [dq, dr] of AXIAL_DIRECTIONS) { - const nb = gm.getTile({ x: tile.axial.x + dq, y: tile.axial.y + dr }) - if (nb && !isWaterTerrain(nb.biome_id)) { hasLandNeighbor = true; break } - } - if (hasLandNeighbor) tile.biome_id = 'coast' - } else if (!isWaterTerrain(tile.biome_id)) { - for (const [dq, dr] of AXIAL_DIRECTIONS) { - const nb = gm.getTile({ x: tile.axial.x + dq, y: tile.axial.y + dr }) - if (nb && isWaterTerrain(nb.biome_id)) { tile.is_coastal = true; break } - } - } - } -} - -// --------------------------------------------------------------------------- -// Stage 5: Tectonic relief (terrain_refiner.gd) -// --------------------------------------------------------------------------- - -function placeTectonicRelief( - gm: GenMap, elevation: Map, - genParams: Record, rng: PCG32, -): void { - const steepness = genParams['steepness'] ?? 0.20 - - // Compute local average elevation (radius 3) for each land tile - const localAvg = new Map() - for (const tile of gm.tiles.values()) { - if (tile.biome_id === 'ocean' || tile.biome_id === 'coast') continue - const nearby = hexSpiral(tile.axial, 3) - let total = 0.0, count = 0 - for (const nb of nearby) { - const e = elevation.get(axialKey(nb)) - if (e !== undefined) { total += e; count++ } - } - localAvg.set(axialKey(tile.axial), count > 0 ? total / count : 0.5) - } - - const landTiles: Vec2i[] = [] - for (const tile of gm.tiles.values()) { - if (tile.biome_id !== 'ocean' && tile.biome_id !== 'coast') { - landTiles.push(tile.axial) - } - } - if (landTiles.length === 0) return - - const mountainTiles: Vec2i[] = [] - const hillTiles: Vec2i[] = [] - - for (const axial of landTiles) { - const tile = gm.getTile(axial)! - const elev = elevation.get(axialKey(axial)) ?? 0.0 - const avg = localAvg.get(axialKey(axial)) ?? 0.5 - - let adjOcean = false - for (const nb of axialNeighbors(axial)) { - const nbTile = gm.getTile(nb) - if (nbTile && (nbTile.biome_id === 'ocean' || nbTile.biome_id === 'coast')) { - adjOcean = true; break - } - } - - if (!adjOcean && elev > avg * 1.20) { - tile.biome_id = 'mountains' - mountainTiles.push(axial) - } else if (!adjOcean && elev > avg * 1.10) { - tile.biome_id = 'hills' - hillTiles.push(axial) - } else if (rng.randf() < 0.40) { - tile.biome_id = 'hills' - hillTiles.push(axial) - } - } - - // Enforce steepness cap - const totalRelief = mountainTiles.length + hillTiles.length - const cap = Math.round(landTiles.length * steepness) - if (totalRelief > cap) { - hillTiles.sort((a, b) => - (elevation.get(axialKey(a)) ?? 0) - (elevation.get(axialKey(b)) ?? 0), - ) - const excess = totalRelief - cap - for (let i = 0; i < Math.min(excess, hillTiles.length); i++) { - const tile = gm.getTile(hillTiles[i]) - if (tile) tile.biome_id = 'land' - } - } -} - -// --------------------------------------------------------------------------- -// Stage 6: Temperature map (map_generator.gd) -// --------------------------------------------------------------------------- - -function computeTemperature( - gm: GenMap, elevation: Map, temperature: Map, -): void { - const centerY = gm.height / 2.0 - for (const tile of gm.tiles.values()) { - const row = tile.row - const baseTemp = 1.0 - Math.abs((row - centerY) / centerY) - const elev = elevation.get(axialKey(tile.axial)) ?? 0.0 - const temp = Math.min(1.0, Math.max(0.0, - baseTemp - elev * 0.3 + (tile.is_coastal ? 0.15 : 0.0), - )) - temperature.set(axialKey(tile.axial), temp) - tile.temperature = temp - } -} - -// --------------------------------------------------------------------------- -// Stage 7: Moisture map (map_generator.gd) -// --------------------------------------------------------------------------- - -function computeMoisture( - gm: GenMap, elevation: Map, - moisture: Map, rng: PCG32, -): void { - // BFS from all ocean/coast tiles simultaneously - const dist = new Map() - const queue: Vec2i[] = [] - for (const tile of gm.tiles.values()) { - if (tile.biome_id === 'ocean' || tile.biome_id === 'coast') { - dist.set(axialKey(tile.axial), 0) - queue.push(tile.axial) - } - } - - let head = 0 - while (head < queue.length) { - const pos = queue[head++] - const d = dist.get(axialKey(pos))! - if (d >= 10) continue - for (const nb of axialNeighbors(pos)) { - const key = axialKey(nb) - if (gm.hasTile(nb) && !dist.has(key)) { - dist.set(key, d + 1) - queue.push(nb) - } - } - } - - // Noise via hash (replaces FastNoiseLite) - const noiseSeed = rng.randi() - for (const tile of gm.tiles.values()) { - const key = axialKey(tile.axial) - const base = 1.0 - (dist.get(key) ?? 10) / 10.0 - const localV = (hashNoise(tile.axial.x * 0.08, tile.axial.y * 0.08, noiseSeed) + 1.0) / 2.0 - const moist = Math.min(1.0, Math.max(0.0, base + localV * 0.2)) - moisture.set(key, moist) - tile.moisture = moist - } - - // Rain shadow from mountains - for (const tile of gm.tiles.values()) { - if (tile.biome_id !== 'mountains') continue - const wind = 0 // base direction before quality pass - const [dq, dr] = AXIAL_DIRECTIONS[wind] - for (let r = 1; r < 3; r++) { - const shadow: Vec2i = { x: tile.axial.x + dq * r, y: tile.axial.y + dr * r } - const sKey = axialKey(shadow) - if (!moisture.has(sKey)) continue - const val = Math.min(1.0, Math.max(0.0, (moisture.get(sKey) ?? 0) - 0.3)) - moisture.set(sKey, val) - const st = gm.getTile(shadow) - if (st) st.moisture = val - } - } -} - -// --------------------------------------------------------------------------- -// Stage 8: Terrain patch expansion (terrain_refiner.gd) -// --------------------------------------------------------------------------- - -function assignTerrainPatches( - gm: GenMap, typeData: Record, - elevation: Map, moisture: Map, - temperature: Map, rng: PCG32, -): void { - const defaultFractions: Record = { - forest: 0.12, jungle: 0.06, boreal_forest: 0.05, - enchanted_forest: 0.01, desert: 0.10, swamp: 0.07, - volcano: 0.02, - } - const fractions = (typeData['terrain_fractions'] ?? defaultFractions) as Record - - let landCount = 0 - for (const tile of gm.tiles.values()) { - if (tile.biome_id === 'land') landCount++ - } - - const order: string[] = [ - 'volcano', 'jungle', 'forest', 'boreal_forest', 'enchanted_forest', - 'desert', 'swamp', 'tundra', 'snow', 'grassland', - ] - - for (const terrainId of order) { - let targetCount = 0 - if (terrainId === 'tundra' || terrainId === 'snow') { - const isFrozen = terrainId === 'snow' - for (const tile of gm.tiles.values()) { - if (tile.biome_id !== 'land') continue - const t = temperature.get(axialKey(tile.axial)) ?? 0.5 - if (isFrozen && t < 0.10) targetCount++ - else if (!isFrozen && t >= 0.10 && t < 0.25) targetCount++ - } - } else if (terrainId === 'jungle' || terrainId === 'forest' || terrainId === 'boreal_forest') { - for (const tile of gm.tiles.values()) { - if (tile.biome_id !== 'land') continue - const t = temperature.get(axialKey(tile.axial)) ?? 0.5 - const m = moisture.get(axialKey(tile.axial)) ?? 0.5 - if (terrainId === 'jungle' && t > 0.65 && m >= 0.35) targetCount++ - else if (terrainId === 'forest' && t >= 0.25 && t <= 0.65 && m >= 0.35) targetCount++ - else if (terrainId === 'boreal_forest' && t >= 0.10 && t < 0.25 && m >= 0.25) targetCount++ - } - targetCount = Math.round(targetCount * (fractions[terrainId] ?? 0.0)) - } else if (terrainId === 'enchanted_forest') { - let forestFamilyCount = 0 - for (const tile of gm.tiles.values()) { - const tid = tile.biome_id - if (tid === 'forest' || tid === 'jungle' || tid === 'boreal_forest') forestFamilyCount++ - } - const baseCount = forestFamilyCount > 0 ? forestFamilyCount : landCount - targetCount = Math.round(baseCount * (fractions['enchanted_forest'] ?? 0.01)) - } else if (terrainId === 'grassland') { - for (const tile of gm.tiles.values()) { - if (tile.biome_id === 'land') targetCount++ - } - } else if (terrainId === 'volcano') { - const mtCount = gm.getTilesByTerrain('mountains').length - targetCount = Math.round(mtCount * (fractions['volcano'] ?? 0.02)) - } else { - targetCount = Math.round(landCount * (fractions[terrainId] ?? 0.0)) - } - if (targetCount <= 0) continue - expandPatch(gm, terrainId, targetCount, elevation, moisture, temperature, rng) - } - - // Convert any remaining unclassified 'land' tiles to 'plains' - for (const tile of gm.tiles.values()) { - if (tile.biome_id === 'land') tile.biome_id = 'plains' - } -} - -function expandPatch( - gm: GenMap, terrainId: string, targetCount: number, - elevation: Map, moisture: Map, - temperature: Map, rng: PCG32, -): void { - const eligible: Vec2i[] = [] - const src = terrainId === 'volcano' ? 'mountains' : 'land' - for (const tile of gm.tiles.values()) { - if (tile.biome_id === src && isEligible(tile.axial, terrainId, gm, elevation, moisture, temperature)) { - eligible.push(tile.axial) - } - } - if (eligible.length === 0) return - - let placed = 0, attempts = 0 - const maxAttempts = targetCount * 10 - while (placed < targetCount && attempts < maxAttempts && eligible.length > 0) { - attempts++ - const ei = rng.randiRange(0, eligible.length - 1) - const seedPos = eligible[ei] - const tile = gm.getTile(seedPos) - if (!tile || tile.biome_id !== src) { - eligible.splice(ei, 1); continue - } - tile.biome_id = terrainId - placed++ - - for (const nb of axialNeighbors(seedPos)) { - if (placed >= targetCount) break - const nbTile = gm.getTile(nb) - if (!nbTile || nbTile.biome_id !== src) continue - if (!isEligible(nb, terrainId, gm, elevation, moisture, temperature)) continue - if (rng.randf() < 0.65) { - nbTile.biome_id = terrainId - placed++ - eligible.push(nb) - } - } - eligible.splice(ei, 1) - } -} - -function isEligible( - axial: Vec2i, terrainId: string, gm: GenMap, - elevation: Map, moisture: Map, - temperature: Map, -): boolean { - const t = temperature.get(axialKey(axial)) ?? 0.5 - const m = moisture.get(axialKey(axial)) ?? 0.5 - const e = elevation.get(axialKey(axial)) ?? 0.5 - switch (terrainId) { - case 'volcano': - for (const nb of axialNeighbors(axial)) { - const nbTile = gm.getTile(nb) - if (nbTile && nbTile.biome_id === 'mountains') return false - } - return true - case 'jungle': return t > 0.65 && m >= 0.35 - case 'forest': return t >= 0.25 && t <= 0.65 && m >= 0.35 - case 'boreal_forest': return t >= 0.10 && t < 0.25 && m >= 0.25 - case 'enchanted_forest': return m >= 0.40 - case 'desert': return t >= 0.25 && m < 0.25 - case 'swamp': return t > 0.25 && m > 0.75 && e < 0.48 - case 'tundra': return t >= 0.10 && t < 0.25 - case 'snow': return t < 0.10 - case 'grassland': return true - } - return true -} - -// --------------------------------------------------------------------------- -// Stage 9a: Wind map — 3-cell atmospheric model (wind_calculator.gd) -// --------------------------------------------------------------------------- - -function computeWindMap(gm: GenMap, windParams: Record): void { - const params = { ...WIND_DEFAULTS, ...windParams } - const h = gm.height - const centerRow = h / 2.0 - - // Pre-compute per-row wind - const rowWind = new Map() - for (let row = 0; row < h; row++) { - rowWind.set(row, bandWindForRow(row, h, centerRow, params)) - } - - // Pass 1: assign base direction and speed - for (const tile of gm.tiles.values()) { - const entry = rowWind.get(tile.row)! - tile.wind_direction = entry[0] - tile.wind_speed = entry[1] - } - - // Pass 2: landmass friction - const frictionLand = params['wind_friction_land'] ?? 0.7 - for (const tile of gm.tiles.values()) { - if (tile.biome_id !== 'ocean' && tile.biome_id !== 'coast') { - tile.wind_speed *= frictionLand - } - } - - // Pass 3: mountain blocking - const mtCap = params['wind_friction_mountain_downwind'] ?? 0.1 - for (const tile of gm.tiles.values()) { - if (tile.biome_id !== 'mountains') continue - const mtDir = tile.wind_direction - const [dwQ, dwR] = AXIAL_DIRECTIONS[mtDir] - for (let step = 1; step <= 2; step++) { - const target = gm.getTile({ - x: tile.axial.x + dwQ * step, - y: tile.axial.y + dwR * step, - }) - if (target) target.wind_speed = Math.min(target.wind_speed, mtCap) - } - const faceA = (mtDir + 1) % 6 - const faceB = (mtDir + 5) % 6 - for (const faceDir of [faceA, faceB]) { - const [fq, fr] = AXIAL_DIRECTIONS[faceDir] - const faceTile = gm.getTile({ x: tile.axial.x + fq, y: tile.axial.y + fr }) - if (faceTile && faceTile.biome_id !== 'mountains') { - faceTile.wind_direction = faceDir - } - } - } -} - -const DIR_VARIABLE = -1 -const CORIOLIS_NORTH = 1 -const CORIOLIS_SOUTH = -1 - -function bandWindForRow( - row: number, h: number, centerRow: number, params: Record, -): [number, number] { - const poleDist = Math.min(row, (h - 1) - row) - const poleDistFrac = poleDist / centerRow - const isNorth = row < centerRow - - const polarCut = params['wind_band_polar_cut'] ?? 0.15 - const polarFrontCut = params['wind_band_polar_front_cut'] ?? 0.30 - const ferrelCut = params['wind_band_ferrel_cut'] ?? 0.50 - const subtropicalCut = params['wind_band_subtropical_cut'] ?? 0.60 - const hadleyCut = params['wind_band_hadley_cut'] ?? 0.85 - - let baseDir: number, baseSpeed: number - if (poleDistFrac < polarCut) { - baseDir = 0; baseSpeed = params['wind_speed_polar'] ?? 0.6 - } else if (poleDistFrac < polarFrontCut) { - baseDir = DIR_VARIABLE; baseSpeed = params['wind_speed_polar_front'] ?? 0.3 - } else if (poleDistFrac < ferrelCut) { - baseDir = 3; baseSpeed = params['wind_speed_ferrel'] ?? 0.8 - } else if (poleDistFrac < subtropicalCut) { - baseDir = DIR_VARIABLE; baseSpeed = params['wind_speed_subtropical'] ?? 0.3 - } else if (poleDistFrac < hadleyCut) { - baseDir = 0; baseSpeed = params['wind_speed_hadley'] ?? 0.7 - } else { - baseDir = DIR_VARIABLE; baseSpeed = params['wind_speed_itcz'] ?? 0.3 - } - - let finalDir: number - if (baseDir === DIR_VARIABLE) { - finalDir = 0 - } else { - const coriolis = isNorth ? CORIOLIS_NORTH : CORIOLIS_SOUTH - finalDir = ((baseDir + coriolis) % 6 + 6) % 6 - } - return [finalDir, baseSpeed] -} - -// --------------------------------------------------------------------------- -// Quality assignment (terrain_refiner.gd) -// --------------------------------------------------------------------------- - -function assignQuality(gm: GenMap): void { - for (const tile of gm.tiles.values()) { - if (tile.biome_id === 'ocean' || tile.biome_id === 'coast' || tile.biome_id === 'land') continue - let same = 0 - for (const nb of axialNeighbors(tile.axial)) { - const nbTile = gm.getTile(nb) - if (nbTile && nbTile.biome_id === tile.biome_id) same++ - } - if (same >= 3) tile.quality = 4 - else if (same >= 1) tile.quality = 2 - else tile.quality = 3 - } -} - -// --------------------------------------------------------------------------- -// Hydrology: drainage, rivers, lakes, deltas (hydrology.gd + hydrology_rivers.gd) -// --------------------------------------------------------------------------- - -function generateDrainage( - gm: GenMap, elevation: Map, moisture: Map, - temperature: Map, params: Record, _rng: PCG32, -): void { - const rainfall = computeRainfall(gm, moisture, temperature, elevation, params) - const fill = depressionFill(gm, elevation, params) - const acc = accumulateFlow(gm, rainfall, fill.flowDir, fill.topoOrder) - detectLakes(gm, elevation, fill.filledElev, params) - markRivers(gm, acc, temperature, fill.flowDir, fill.topoOrder, params) - markDeltas(gm, acc, fill.flowDir, params) - classifySources(gm, elevation, moisture, temperature, fill.flowDir, params) - for (const [key, val] of acc) { - const tile = gm.tiles.get(key) - if (tile) tile.flow_accumulation = val - } -} - -function computeRainfall( - gm: GenMap, moisture: Map, temperature: Map, - elevation: Map, params: Record, -): Map { - const out = new Map() - for (const tile of gm.tiles.values()) { - const key = axialKey(tile.axial) - if (isWaterTerrainHydro(tile.biome_id)) { out.set(key, 0.0); continue } - const m = moisture.get(key) ?? tile.moisture - const t = temperature.get(key) ?? tile.temperature - let base = m * climateMult(t, params) - base = applyRainfallBonuses(base, tile, key, elevation, temperature, gm, params) - out.set(key, Math.max(0.0, base)) - } - const globalMult = (params as Record)['rainfall_global_multiplier'] ?? 1.0 - if (globalMult !== 1.0) { - for (const [k, v] of out) out.set(k, v * globalMult) - } - return out -} - -function climateMult(temp: number, params: Record): number { - const rf = (params['rainfall'] ?? {}) as Record - if (temp > 0.65) return rf['multiplier_tropical'] ?? 1.4 - if (temp >= 0.25) return rf['multiplier_temperate'] ?? 1.0 - if (temp >= 0.10) return rf['multiplier_cold'] ?? 0.5 - return rf['multiplier_frozen'] ?? 0.2 -} - -function applyRainfallBonuses( - base: number, tile: GenTile, key: string, - elevation: Map, temperature: Map, - gm: GenMap, params: Record, -): number { - const sb = (params['source_bonuses'] ?? {}) as Record> - const terrain = tile.biome_id - const elev = elevation.get(key) ?? tile.elevation - const temp = temperature.get(key) ?? tile.temperature - - if (terrain === 'desert') { - const rf = (params['rainfall'] ?? {}) as Record - base *= rf['multiplier_desert_terrain'] ?? 0.15 - } - - const snow = sb['snowmelt'] ?? {} - const snowTerrains = (snow['terrain'] ?? []) as string[] - if (snowTerrains.includes(terrain) - && elev >= ((snow['min_elevation'] as number) ?? 0.65) - && temp < ((snow['max_temperature'] as number) ?? 0.5)) { - base += (snow['bonus'] as number) ?? 0.8 - } - - const spr = sb['spring'] ?? {} - const sprTerrains = (spr['terrain'] ?? []) as string[] - if (sprTerrains.includes(terrain) && tile.moisture >= ((spr['min_moisture'] as number) ?? 0.6)) { - base += (spr['bonus'] as number) ?? 0.3 - } - - const hs = sb['hot_spring'] ?? {} - const hsTerrains = (hs['terrain'] ?? []) as string[] - if (hsTerrains.includes(terrain) - && temp < ((hs['max_temperature'] as number) ?? 0.25) - && adjTerrain(tile.axial, (hs['adjacent_terrain'] ?? []) as string[], gm)) { - base += (hs['bonus'] as number) ?? 0.5 - } - - const gl = sb['glacial'] ?? {} - const glTerrains = (gl['terrain'] ?? []) as string[] - if (glTerrains.includes(terrain) && elev >= ((gl['min_elevation'] as number) ?? 0.5)) { - base += (gl['bonus'] as number) ?? 0.4 - } - - return base -} - -function adjTerrain(axial: Vec2i, terrains: string[], gm: GenMap): boolean { - for (const [dq, dr] of AXIAL_DIRECTIONS) { - const nb = gm.getTile({ x: axial.x + dq, y: axial.y + dr }) - if (nb && terrains.includes(nb.biome_id)) return true - } - return false -} - -interface FillResult { - flowDir: Map - filledElev: Map - topoOrder: Vec2i[] -} - -function depressionFill( - gm: GenMap, elevation: Map, params: Record, -): FillResult { - const eps = ((params['depression_fill'] ?? {}) as Record)['epsilon'] ?? 0.0001 - const filled = new Map() - const flowDir = new Map() - const done = new Set() - const topoOrder: Vec2i[] = [] - const heap: [number, number, number][] = [] - - for (const tile of gm.tiles.values()) { - const key = axialKey(tile.axial) - if (isWaterTerrainHydro(tile.biome_id)) { - filled.set(key, 0.0) - flowDir.set(key, -1) - heapPush(heap, [0.0, tile.axial.x, tile.axial.y]) - } else { - filled.set(key, 1e9) - flowDir.set(key, -1) - } - } - - while (heap.length > 0) { - const e = heapPop(heap)! - const axial: Vec2i = { x: e[1], y: e[2] } - const key = axialKey(axial) - if (done.has(key)) continue - done.add(key) - topoOrder.push(axial) - for (let di = 0; di < 6; di++) { - const nb: Vec2i = { - x: axial.x + AXIAL_DIRECTIONS[di][0], - y: axial.y + AXIAL_DIRECTIONS[di][1], - } - const nbKey = axialKey(nb) - if (!gm.hasTile(nb) || done.has(nbKey)) continue - const nbTile = gm.getTile(nb)! - const nbReal = elevation.get(nbKey) ?? nbTile.elevation - const cand = Math.max(nbReal, e[0] + eps) - if (cand < (filled.get(nbKey) ?? 1e9)) { - filled.set(nbKey, cand) - flowDir.set(nbKey, OPPOSITE_DIR[di]) - heapPush(heap, [cand, nb.x, nb.y]) - } - } - } - - return { flowDir, filledElev: filled, topoOrder } -} - -function heapPush(h: [number, number, number][], e: [number, number, number]): void { - h.push(e) - let i = h.length - 1 - while (i > 0) { - const p = (i - 1) >>> 1 - if (h[p][0] <= h[i][0]) break - const tmp = h[p]; h[p] = h[i]; h[i] = tmp - i = p - } -} - -function heapPop(h: [number, number, number][]): [number, number, number] | undefined { - if (h.length === 0) return undefined - const top = h[0] - const last = h.pop()! - if (h.length === 0) return top - h[0] = last - let i = 0 - const n = h.length - while (true) { - const l = 2 * i + 1 - const r = 2 * i + 2 - let s = i - if (l < n && h[l][0] < h[s][0]) s = l - if (r < n && h[r][0] < h[s][0]) s = r - if (s === i) break - const tmp = h[i]; h[i] = h[s]; h[s] = tmp - i = s - } - return top -} - -function accumulateFlow( - gm: GenMap, rainfall: Map, - flowDir: Map, topoOrder: Vec2i[], -): Map { - const acc = new Map() - for (const tile of gm.tiles.values()) { - const key = axialKey(tile.axial) - acc.set(key, rainfall.get(key) ?? 0.0) - } - for (let i = topoOrder.length - 1; i >= 0; i--) { - const axial = topoOrder[i] - const key = axialKey(axial) - const d = flowDir.get(key) ?? -1 - if (d === -1) continue - const ds: Vec2i = { - x: axial.x + AXIAL_DIRECTIONS[d][0], - y: axial.y + AXIAL_DIRECTIONS[d][1], - } - const dsKey = axialKey(ds) - if (acc.has(dsKey)) { - // No terrain infiltration lookup in guide context — pass full flow through - acc.set(dsKey, acc.get(dsKey)! + (acc.get(key) ?? 0)) - } - } - return acc -} - -// -- Lake detection (hydrology_rivers.gd) -- - -function detectLakes( - gm: GenMap, elevation: Map, - filledElev: Map, params: Record, -): void { - const cfg = (params['lakes'] ?? {}) as Record - const depth = cfg['depth_threshold'] ?? 0.02 - const seaMin = cfg['inland_sea_min_tiles'] ?? 12 - const lakeTiles = new Set() - for (const tile of gm.tiles.values()) { - if (isWaterTerrainHydro(tile.biome_id)) continue - const key = axialKey(tile.axial) - const re = elevation.get(key) ?? tile.elevation - if ((filledElev.get(key) ?? re) - re > depth) lakeTiles.add(key) - } - const visited = new Set() - let nextId = 0 - for (const startKey of lakeTiles) { - if (visited.has(startKey)) continue - const group: string[] = [] - const q = [startKey] - while (q.length > 0) { - const cur = q.shift()! - if (visited.has(cur)) continue - visited.add(cur) - group.push(cur) - const parts = cur.split(',') - const ax: Vec2i = { x: parseInt(parts[0]), y: parseInt(parts[1]) } - for (const [dq, dr] of AXIAL_DIRECTIONS) { - const nb: Vec2i = { x: ax.x + dq, y: ax.y + dr } - const nbKey = axialKey(nb) - if (lakeTiles.has(nbKey) && !visited.has(nbKey)) q.push(nbKey) - } - } - const tid = group.length < seaMin ? 'lake' : 'inland_sea' - for (const key of group) { - const tile = gm.tiles.get(key) - if (tile) { tile.biome_id = tid; tile.lake_id = nextId } - } - nextId++ - } -} - -// -- River marking (hydrology_rivers.gd) -- - -function markRivers( - gm: GenMap, acc: Map, temperature: Map, - flowDir: Map, topoOrder: Vec2i[], params: Record, -): void { - const rcfg = (params['rivers'] ?? {}) as Record - const density = (params as Record)['hydrology_river_density_multiplier'] ?? 1.0 - const frozenT = (params as Record)['frozen_river_temperature'] ?? 0.10 - for (const axial of topoOrder) { - const key = axialKey(axial) - const tile = gm.getTile(axial) - if (!tile || isWaterTerrainHydro(tile.biome_id)) continue - const a = acc.get(key) ?? 0.0 - const temp = temperature.get(key) ?? tile.temperature - if (a < riverThresh(temp, tile.biome_id, rcfg) / density) continue - const d = flowDir.get(key) ?? -1 - if (d === -1) continue - const dsPos: Vec2i = { - x: axial.x + AXIAL_DIRECTIONS[d][0], - y: axial.y + AXIAL_DIRECTIONS[d][1], - } - const ds = gm.getTile(dsPos) - if (!ds) continue - const fv = temp <= frozenT ? -a : a - if (!tile.river_edges.includes(d)) tile.river_edges.push(d) - tile.river_flow[String(d)] = fv - tile.river_flow['_flow_dir'] = d - const opp = OPPOSITE_DIR[d] - if (!isWaterTerrainHydro(ds.biome_id)) { - if (!ds.river_edges.includes(opp)) ds.river_edges.push(opp) - ds.river_flow[String(opp)] = fv - } - } -} - -function riverThresh(temp: number, terrainId: string, cfg: Record): number { - if (terrainId === 'desert') return cfg['threshold_desert'] ?? 20.0 - if (temp > 0.65) return cfg['threshold_tropical'] ?? 4.0 - if (temp >= 0.25) return cfg['threshold_temperate'] ?? 6.0 - if (temp >= 0.10) return cfg['threshold_cold'] ?? 10.0 - return cfg['threshold_frozen'] ?? 15.0 -} - -// -- Delta formation (hydrology_rivers.gd) -- - -function markDeltas( - gm: GenMap, acc: Map, - flowDir: Map, params: Record, -): void { - const cfg = (params['deltas'] ?? {}) as Record - const thresh = cfg['accumulation_threshold'] ?? 15.0 - const maxBr = cfg['max_branches'] ?? 3 - const frozenT = (params as Record)['frozen_river_temperature'] ?? 0.10 - for (const tile of gm.tiles.values()) { - const key = axialKey(tile.axial) - if (isWaterTerrainHydro(tile.biome_id) || (acc.get(key) ?? 0) < thresh) continue - const waterDirs: number[] = [] - for (let di = 0; di < 6; di++) { - const nb = gm.getTile({ - x: tile.axial.x + AXIAL_DIRECTIONS[di][0], - y: tile.axial.y + AXIAL_DIRECTIONS[di][1], - }) - if (nb && isWaterTerrainHydro(nb.biome_id)) waterDirs.push(di) - } - if (waterDirs.length === 0) continue - const br = Math.min(maxBr - 1, waterDirs.length) - let fv = acc.get(key) ?? 0.0 - if (tile.temperature <= frozenT) fv = -fv - for (let i = 0; i < br; i++) { - const d = waterDirs[i] - if (!tile.river_edges.includes(d)) tile.river_edges.push(d) - tile.river_flow[String(d)] = fv - } - if (br < waterDirs.length) { - for (let di = 0; di < 6; di++) { - const nbPos: Vec2i = { - x: tile.axial.x + AXIAL_DIRECTIONS[di][0], - y: tile.axial.y + AXIAL_DIRECTIONS[di][1], - } - const nbKey = axialKey(nbPos) - if ((flowDir.get(nbKey) ?? -1) === OPPOSITE_DIR[di]) { - const up = gm.getTile(nbPos) - if (up && !isWaterTerrainHydro(up.biome_id)) { - const d = waterDirs[br] - let upFv = acc.get(nbKey) ?? 0.0 - if (up.temperature <= frozenT) upFv = -upFv - if (!up.river_edges.includes(d)) up.river_edges.push(d) - up.river_flow[String(d)] = upFv - } - break - } - } - } - } -} - -// -- Source classification (hydrology_rivers.gd) -- - -function classifySources( - gm: GenMap, elevation: Map, moisture: Map, - temperature: Map, flowDir: Map, - params: Record, -): void { - const sb = (params['source_bonuses'] ?? {}) as Record> - for (const tile of gm.tiles.values()) { - if (tile.river_edges.length === 0) continue - const key = axialKey(tile.axial) - let hasUp = false - for (let di = 0; di < 6; di++) { - const nb: Vec2i = { - x: tile.axial.x + AXIAL_DIRECTIONS[di][0], - y: tile.axial.y + AXIAL_DIRECTIONS[di][1], - } - const nbKey = axialKey(nb) - if ((flowDir.get(nbKey) ?? -1) === OPPOSITE_DIR[di]) { - const nbt = gm.getTile(nb) - if (nbt && nbt.river_edges.length > 0) { hasUp = true; break } - } - } - if (hasUp) continue - - const terrain = tile.biome_id - const elev = elevation.get(key) ?? tile.elevation - const temp = temperature.get(key) ?? tile.temperature - const moist = moisture.get(key) ?? tile.moisture - - const snow = sb['snowmelt'] ?? {} - const spr = sb['spring'] ?? {} - const hs = sb['hot_spring'] ?? {} - const gl = sb['glacial'] ?? {} - - if (((snow['terrain'] ?? []) as string[]).includes(terrain) - && elev >= ((snow['min_elevation'] as number) ?? 0.65) - && temp < ((snow['max_temperature'] as number) ?? 0.5)) { - tile.river_source_type = 'snowmelt' - } else if (((spr['terrain'] ?? []) as string[]).includes(terrain) - && moist >= ((spr['min_moisture'] as number) ?? 0.6)) { - tile.river_source_type = 'spring' - } else if (((hs['terrain'] ?? []) as string[]).includes(terrain) - && temp < ((hs['max_temperature'] as number) ?? 0.25) - && adjTerrain(tile.axial, (hs['adjacent_terrain'] ?? []) as string[], gm)) { - tile.river_source_type = 'hot_spring' - } else if (((gl['terrain'] ?? []) as string[]).includes(terrain) - && elev >= ((gl['min_elevation'] as number) ?? 0.5)) { - tile.river_source_type = 'glacial' - } else { - tile.river_source_type = 'snowmelt' - } - } -} - -// --------------------------------------------------------------------------- -// Render metadata storage (terrain_refiner.gd) -// --------------------------------------------------------------------------- - -function storeRenderMetadata( - gm: GenMap, elevation: Map, - moisture: Map, temperature: Map, -): void { - for (const tile of gm.tiles.values()) { - const key = axialKey(tile.axial) - tile.elevation = elevation.get(key) ?? 0.0 - tile.moisture = moisture.get(key) ?? 0.0 - tile.temperature = temperature.get(key) ?? 0.0 - tile.variation_index = tile.quality - 1 - } -} - -// --------------------------------------------------------------------------- -// Public API: generate() -// --------------------------------------------------------------------------- - -export function generate( - seed: number, - width: number, - height: number, - terrainCache: Map, - params: Record, - mapType: string, -): GridState { - const rng = new PCG32() - rng.seed(seed) - - const w = width - const h = height - - const typeData: Record = { - ...DEFAULT_TYPE_DATA, - id: mapType, - } - - const oceanPct = (typeData['ocean_percentage'] as Record) ?? { target: 0.40, variance: 0.05 } - const oceanTarget = (oceanPct['target'] ?? 0.40) + rng.randfRange( - -(oceanPct['variance'] ?? 0.05), oceanPct['variance'] ?? 0.05, - ) - - const genParams: Record = { - ...((typeData['generation_params'] ?? {}) as Record), - ...params, - } - - // Create map and fill tile grid - const gm = new GenMap(w, h) - for (let col = 0; col < w; col++) { - for (let row = 0; row < h; row++) { - const ax = offsetToAxial(col, row) - gm.setTile(ax, newGenTile(ax, col, row)) - } - } - - // Per-tile float caches (axial-keyed) - const elevation = new Map() - const moisture = new Map() - const temperature = new Map() - - // Stage 1-2: Seed placement and region growth - const regions = placeRegionSeedsShaped(gm, genParams, mapType, rng) - growRegions(gm, regions, elevation, rng) - - // Stage 3: Normalise elevation - normalizeElevation(gm, elevation) - - // Stage 4: Sea level, ocean/land, coastline - assignSeaLevel(gm, oceanTarget, genParams, elevation) - - // Stage 5: Tectonic relief - placeTectonicRelief(gm, elevation, genParams, rng) - - // Stage 6: Temperature - computeTemperature(gm, elevation, temperature) - - // Stage 7: Moisture - computeMoisture(gm, elevation, moisture, rng) - - // Stage 8: Terrain patch expansion - assignTerrainPatches(gm, typeData, elevation, moisture, temperature, rng) - - // Stage 9: Wind map + quality - computeWindMap(gm, genParams) - assignQuality(gm) - - // Hydrology (rivers, lakes, deltas) - const hydroParams: Record = { ...genParams } - generateDrainage(gm, elevation, moisture, temperature, hydroParams, rng) - - // Render metadata - storeRenderMetadata(gm, elevation, moisture, temperature) - - return gm.toGridState() -} diff --git a/packages/engine-ts/src/biomeClassifier.ts b/packages/engine-ts/src/biomeClassifier.ts new file mode 100644 index 00000000..7dd8898c --- /dev/null +++ b/packages/engine-ts/src/biomeClassifier.ts @@ -0,0 +1,151 @@ +// Biome classification utilities — extracted from auto-generated EcologyPhysics. +// These are pure functions that classify tiles into biome IDs based on climate/terrain state. +// The heavy physics (flora/fauna simulation) is now in Rust WASM; these utilities remain +// in TS for runner.ts, worker, and test consumption. + +import type { TileState } from './types' +import { hasTag } from './biomeRegistry' + +// --------------------------------------------------------------------------- +// Biome definitions +// --------------------------------------------------------------------------- + +export interface BiomeDef { + id: string + temp_range: [number, number] + moisture_range: [number, number] + flora_climax: { canopy: number; undergrowth: number; fungi: number } + fauna_capacity: number + quality_range: [number, number] +} + +export const BIOME_DEFS: Record = { + deep_ocean: { id: 'deep_ocean', temp_range: [0.0, 1.0], moisture_range: [0.0, 1.0], flora_climax: { canopy: 0.0, undergrowth: 0.0, fungi: 0.0 }, fauna_capacity: 8, quality_range: [1, 4] }, + shallow_ocean: { id: 'shallow_ocean', temp_range: [0.0, 1.0], moisture_range: [0.0, 1.0], flora_climax: { canopy: 0.0, undergrowth: 0.3, fungi: 0.0 }, fauna_capacity: 12, quality_range: [1, 5] }, + coral_reef: { id: 'coral_reef', temp_range: [0.55, 1.0], moisture_range: [0.0, 1.0], flora_climax: { canopy: 0.0, undergrowth: 0.6, fungi: 0.1 }, fauna_capacity: 20, quality_range: [1, 5] }, + estuary: { id: 'estuary', temp_range: [0.2, 0.8], moisture_range: [0.6, 1.0], flora_climax: { canopy: 0.0, undergrowth: 0.5, fungi: 0.05 }, fauna_capacity: 14, quality_range: [1, 4] }, + lake: { id: 'lake', temp_range: [0.0, 1.0], moisture_range: [0.0, 1.0], flora_climax: { canopy: 0.0, undergrowth: 0.3, fungi: 0.02 }, fauna_capacity: 10, quality_range: [1, 4] }, + pond: { id: 'pond', temp_range: [0.0, 1.0], moisture_range: [0.0, 1.0], flora_climax: { canopy: 0.0, undergrowth: 0.15, fungi: 0.01 }, fauna_capacity: 3, quality_range: [1, 2] }, + river: { id: 'river', temp_range: [0.0, 1.0], moisture_range: [0.0, 1.0], flora_climax: { canopy: 0.0, undergrowth: 0.2, fungi: 0.01 }, fauna_capacity: 6, quality_range: [1, 3] }, + mangrove: { id: 'mangrove', temp_range: [0.55, 1.0], moisture_range: [0.7, 1.0], flora_climax: { canopy: 0.6, undergrowth: 0.5, fungi: 0.15 }, fauna_capacity: 14, quality_range: [1, 4] }, + tropical_rainforest: { id: 'tropical_rainforest', temp_range: [0.65, 1.0], moisture_range: [0.7, 1.0], flora_climax: { canopy: 0.95, undergrowth: 0.7, fungi: 0.4 }, fauna_capacity: 25, quality_range: [1, 5] }, + tropical_dry_forest: { id: 'tropical_dry_forest', temp_range: [0.55, 1.0], moisture_range: [0.4, 0.7], flora_climax: { canopy: 0.65, undergrowth: 0.5, fungi: 0.2 }, fauna_capacity: 16, quality_range: [1, 4] }, + savanna: { id: 'savanna', temp_range: [0.55, 1.0], moisture_range: [0.2, 0.4], flora_climax: { canopy: 0.15, undergrowth: 0.45, fungi: 0.05 }, fauna_capacity: 12, quality_range: [1, 3] }, + desert: { id: 'desert', temp_range: [0.55, 1.0], moisture_range: [0.0, 0.15], flora_climax: { canopy: 0.0, undergrowth: 0.08, fungi: 0.01 }, fauna_capacity: 4, quality_range: [1, 3] }, + temperate_forest: { id: 'temperate_forest', temp_range: [0.25, 0.55], moisture_range: [0.5, 1.0], flora_climax: { canopy: 0.85, undergrowth: 0.6, fungi: 0.35 }, fauna_capacity: 18, quality_range: [1, 5] }, + temperate_grassland: { id: 'temperate_grassland', temp_range: [0.25, 0.55], moisture_range: [0.3, 0.5], flora_climax: { canopy: 0.05, undergrowth: 0.55, fungi: 0.1 }, fauna_capacity: 14, quality_range: [1, 4] }, + chaparral: { id: 'chaparral', temp_range: [0.25, 0.55], moisture_range: [0.15, 0.35], flora_climax: { canopy: 0.1, undergrowth: 0.35, fungi: 0.05 }, fauna_capacity: 8, quality_range: [1, 3] }, + swamp: { id: 'swamp', temp_range: [0.35, 0.7], moisture_range: [0.8, 1.0], flora_climax: { canopy: 0.5, undergrowth: 0.6, fungi: 0.45 }, fauna_capacity: 15, quality_range: [1, 4] }, + bog: { id: 'bog', temp_range: [0.1, 0.4], moisture_range: [0.7, 1.0], flora_climax: { canopy: 0.05, undergrowth: 0.3, fungi: 0.2 }, fauna_capacity: 6, quality_range: [1, 3] }, + boreal_forest: { id: 'boreal_forest', temp_range: [0.1, 0.3], moisture_range: [0.35, 1.0], flora_climax: { canopy: 0.7, undergrowth: 0.35, fungi: 0.3 }, fauna_capacity: 12, quality_range: [1, 4] }, + tundra: { id: 'tundra', temp_range: [0.05, 0.15], moisture_range: [0.0, 0.5], flora_climax: { canopy: 0.0, undergrowth: 0.15, fungi: 0.05 }, fauna_capacity: 5, quality_range: [1, 3] }, + polar_desert: { id: 'polar_desert', temp_range: [0.0, 0.05], moisture_range: [0.0, 0.2], flora_climax: { canopy: 0.0, undergrowth: 0.02, fungi: 0.0 }, fauna_capacity: 2, quality_range: [1, 2] }, + montane_forest: { id: 'montane_forest', temp_range: [0.15, 0.45], moisture_range: [0.4, 1.0], flora_climax: { canopy: 0.75, undergrowth: 0.45, fungi: 0.25 }, fauna_capacity: 14, quality_range: [1, 4] }, + cloud_forest: { id: 'cloud_forest', temp_range: [0.2, 0.45], moisture_range: [0.7, 1.0], flora_climax: { canopy: 0.8, undergrowth: 0.65, fungi: 0.5 }, fauna_capacity: 20, quality_range: [1, 5] }, + alpine_meadow: { id: 'alpine_meadow', temp_range: [0.05, 0.25], moisture_range: [0.3, 0.7], flora_climax: { canopy: 0.0, undergrowth: 0.3, fungi: 0.08 }, fauna_capacity: 6, quality_range: [1, 3] }, + alpine_tundra: { id: 'alpine_tundra', temp_range: [0.0, 0.15], moisture_range: [0.0, 0.4], flora_climax: { canopy: 0.0, undergrowth: 0.08, fungi: 0.02 }, fauna_capacity: 3, quality_range: [1, 2] }, + sea_ice: { id: 'sea_ice', temp_range: [0.0, 0.08], moisture_range: [0.0, 1.0], flora_climax: { canopy: 0.0, undergrowth: 0.0, fungi: 0.0 }, fauna_capacity: 1, quality_range: [1, 1] }, + glacial: { id: 'glacial', temp_range: [0.0, 0.1], moisture_range: [0.0, 1.0], flora_climax: { canopy: 0.0, undergrowth: 0.01, fungi: 0.0 }, fauna_capacity: 1, quality_range: [1, 1] }, + subterranean: { id: 'subterranean', temp_range: [0.1, 0.5], moisture_range: [0.2, 0.8], flora_climax: { canopy: 0.0, undergrowth: 0.1, fungi: 0.6 }, fauna_capacity: 8, quality_range: [1, 4] }, + ocean: { id: 'ocean', temp_range: [0.0, 1.0], moisture_range: [0.0, 1.0], flora_climax: { canopy: 0.0, undergrowth: 0.0, fungi: 0.0 }, fauna_capacity: 8, quality_range: [1, 4] }, + coast: { id: 'coast', temp_range: [0.0, 1.0], moisture_range: [0.0, 1.0], flora_climax: { canopy: 0.0, undergrowth: 0.1, fungi: 0.0 }, fauna_capacity: 6, quality_range: [1, 3] }, + volcanic: { id: 'volcanic', temp_range: [0.0, 1.0], moisture_range: [0.0, 1.0], flora_climax: { canopy: 0.0, undergrowth: 0.0, fungi: 0.0 }, fauna_capacity: 0, quality_range: [1, 2] }, + enchanted_forest: { id: 'enchanted_forest', temp_range: [0.2, 0.7], moisture_range: [0.4, 1.0], flora_climax: { canopy: 0.85, undergrowth: 0.6, fungi: 0.4 }, fauna_capacity: 18, quality_range: [1, 5] }, + snow: { id: 'snow', temp_range: [0.0, 0.1], moisture_range: [0.0, 1.0], flora_climax: { canopy: 0.0, undergrowth: 0.0, fungi: 0.0 }, fauna_capacity: 1, quality_range: [1, 1] }, + ice: { id: 'ice', temp_range: [0.0, 0.12], moisture_range: [0.0, 1.0], flora_climax: { canopy: 0.0, undergrowth: 0.0, fungi: 0.0 }, fauna_capacity: 0, quality_range: [1, 1] }, + mana_node: { id: 'mana_node', temp_range: [0.0, 1.0], moisture_range: [0.0, 1.0], flora_climax: { canopy: 0.0, undergrowth: 0.0, fungi: 0.0 }, fauna_capacity: 0, quality_range: [1, 5] }, +} + +export function getBiome(biomeId: string): BiomeDef | null { + return BIOME_DEFS[biomeId] ?? null +} + +export function isWater(tile: TileState): boolean { + return hasTag(tile.biome_id ?? '', 'is_water') +} + +// --------------------------------------------------------------------------- +// Biome classifier helpers +// --------------------------------------------------------------------------- + +function classifyAquatic(tile: TileState): string { + const WATER_BOIL_TEMP = 0.82 + if (tile.temperature >= WATER_BOIL_TEMP) return 'volcanic' + + const wbType = tile.water_body_type + const biome = tile.biome_id + + if (wbType === 'pond' || biome === 'pond') return 'pond' + if (wbType === 'river' || biome === 'river') return 'river' + if (wbType === 'lake' || wbType === 'large_lake' || biome === 'lake' || biome === 'inland_sea') return 'lake' + + const depth = tile.depth_from_coast + const temp = tile.temperature + + if (tile.is_river_mouth && depth <= 1) return 'estuary' + if (temp > 0.55 && depth <= 2) return 'coral_reef' + if (depth > 3) return 'deep_ocean' + return 'shallow_ocean' +} + +function classifyLand(tile: TileState): string { + const temp = tile.temperature + const moisture = tile.moisture + const elevation = tile.elevation + const canopy = tile.canopy_cover + + if (moisture > 0.7 && elevation < 0.4 && canopy > 0) { + return temp > 0.4 ? 'swamp' : 'bog' + } + + if (elevation > 0.85) return temp < 0.1 ? 'glacial' : 'alpine_tundra' + if (elevation > 0.70) return (canopy > 0 && moisture > 0.3) ? 'alpine_meadow' : 'alpine_tundra' + if (elevation > 0.55) { + if (canopy > 0.4) return 'montane_forest' + if (canopy > 0 && moisture > 0.7 && temp > 0.3) return 'cloud_forest' + if (canopy > 0 && moisture > 0.3) return 'alpine_meadow' + return 'alpine_tundra' + } + + if (temp > 0.55) { + if (moisture > 0.7 && canopy > 0.6) return 'tropical_rainforest' + if (canopy > 0) { + if (moisture > 0.4) return 'tropical_dry_forest' + if (moisture > 0.2) return 'savanna' + } + return 'desert' + } + + if (temp > 0.25) { + if (canopy > 0.5) return 'temperate_forest' + if (moisture > 0.3 && canopy > 0) return 'temperate_grassland' + return 'chaparral' + } + + if (temp > 0.1) { + if (canopy > 0.3) return 'boreal_forest' + if (canopy > 0) return 'tundra' + return 'polar_desert' + } + + return 'polar_desert' +} + +export function classifyBiome(tile: TileState): string { + if (tile.substrate_id === 'wetland' && tile.temperature > 0.55 && tile.is_coastal) { + return 'mangrove' + } + if (isWater(tile)) return classifyAquatic(tile) + if (tile.substrate_id === 'volcanic') return 'volcanic' + if (tile.has_cave) return 'subterranean' + return classifyLand(tile) +} + +export function getEcologyFoodModifier(tile: TileState): number { + const mult: Record = { 1: 0.5, 2: 1.0, 3: 1.5, 4: 2.0, 5: 2.5 } + let base = mult[tile.quality] ?? 1.0 + if (!hasTag(tile.biome_id, 'is_water')) { + base *= 0.8 + 0.4 * tile.undergrowth + } + return base +} diff --git a/packages/engine-ts/src/index.ts b/packages/engine-ts/src/index.ts index 3967262f..fa8fa826 100644 --- a/packages/engine-ts/src/index.ts +++ b/packages/engine-ts/src/index.ts @@ -1,8 +1,6 @@ export * from './types' export * from './HexGrid' -export * from './ClimatePhysics.generated' -export * from './MapGenerator.generated' -export * from './EcologyPhysics.generated' +export * from './biomeClassifier' export * from './biomeRegistry' export * from './runner' export * from './scenarios' diff --git a/packages/engine-ts/src/runner.ts b/packages/engine-ts/src/runner.ts index ea66bf13..d88f043f 100644 --- a/packages/engine-ts/src/runner.ts +++ b/packages/engine-ts/src/runner.ts @@ -13,9 +13,8 @@ import type { WindArrow, } from './types' import { solarByRow } from './HexGrid' -import { ClimatePhysics } from './ClimatePhysics.generated' -import { EcologyPhysics, classifyBiome } from './EcologyPhysics.generated' -import { generate as generateMap } from './MapGenerator.generated' +import { WasmClimatePhysics, WasmEcologyPhysics, WasmMapGenerator, WasmGrid } from '@magic-civ/physics-rs' +import { classifyBiome } from './biomeClassifier' import { WORLD_SEED, DEFAULT_SCENARIO_TURNS, GRID_WIDTH, GRID_HEIGHT } from './configs' // --------------------------------------------------------------------------- @@ -625,8 +624,16 @@ export function runScenarioSync( const worldAge = config.worldAge const totalTurns = worldAge + scenarioTurns - // Generate map from seed using the transpiled GDScript pipeline - const grid = generateMap(worldSeed, GRID_WIDTH, GRID_HEIGHT, terrainCache, params, 'continents') + const paramsJson = JSON.stringify(params) + const terrainJson = JSON.stringify(Object.fromEntries(terrainCache)) + const specJson = '{}' + + // Generate map from seed using WASM map generator + const mapGen = new WasmMapGenerator(paramsJson) + const wasmMapGrid = mapGen.generate(worldSeed, 'continents') + const grid: GridState = wasmMapGrid.toJSON() as GridState + wasmMapGrid.free() + mapGen.free() // Derive is_coastal: land tiles adjacent to ocean/coast. const waterBiomeIds = new Set(['ocean', 'coast', 'lake', 'inland_sea']) @@ -653,8 +660,8 @@ export function runScenarioSync( tile.biome_id = classifyBiome(tile) } - const physics = new ClimatePhysics(params, terrainCache) - const ecology = new EcologyPhysics() + const physics = new WasmClimatePhysics(paramsJson, terrainJson, specJson) + const ecology = config.abioticWorld ? null : new WasmEcologyPhysics() const snapshots: GridSnapshot[] = [] const isVolcanicWinter = config.id === 'volcanic_winter' @@ -670,14 +677,16 @@ export function runScenarioSync( grid.global_fish_stock = 1.0 grid.photosynthesisMultiplier = 1.0 - const runEcology = !config.abioticWorld - - // Phase 1: Geological history (worldAge turns) — climate + ecology, no scenario forcing. + // Phase 1: Geological history — run entirely in WASM + let wasmGrid = WasmGrid.fromJSON(grid) for (let turn = 0; turn < worldAge; turn++) { - physics.processStep(grid, turn, worldSeed) - if (runEcology) ecology.processStep(grid) - stepAtmosphericChemistry(grid) + physics.processStep(wasmGrid, turn, worldSeed) + if (ecology) ecology.processStep(wasmGrid) } + // Sync back to JS + const geoGrid = wasmGrid.toJSON() as GridState + Object.assign(grid, geoGrid) + wasmGrid.free() // Phase 2: Apply scenario initMap overrides on the geologically mature world config.initMap(grid) @@ -690,22 +699,38 @@ export function runScenarioSync( } // Phase 3: Scenario simulation — volcanic forcing, full recording + wasmGrid = WasmGrid.fromJSON(grid) for (let turn = worldAge; turn < totalTurns; turn++) { const scenarioTurn = turn - worldAge - if (isVolcanicWinter) applyVolcanicWinterForcing(grid) + if (isVolcanicWinter) { + const jsGrid = wasmGrid.toJSON() as GridState + applyVolcanicWinterForcing(jsGrid) + wasmGrid.free() + wasmGrid = WasmGrid.fromJSON(jsGrid) + } - const events = physics.processStep(grid, turn, worldSeed) - if (runEcology) ecology.processStep(grid) + physics.processStep(wasmGrid, turn, worldSeed) + if (ecology) ecology.processStep(wasmGrid) + // Sync to JS for snapshot encoding + const jsGrid = wasmGrid.toJSON() as GridState + Object.assign(grid, jsGrid) stepAtmosphericChemistry(grid) + // Sync atmo changes back to WASM + wasmGrid.free() + wasmGrid = WasmGrid.fromJSON(grid) const prev = snapshots.length > 0 ? snapshots[snapshots.length - 1].stats : undefined - snapshots.push(encodeSnapshot(grid, scenarioTurn, events, prev)) + snapshots.push(encodeSnapshot(grid, scenarioTurn, [], prev)) } + wasmGrid.free() + physics.free() + if (ecology) ecology.free() + return { snapshots, continuation: { - grid, physics, ecology, config, + grid, physics: null, ecology: null, config, nextAbsoluteTurn: totalTurns, worldSeed, isVolcanicWinter, }, @@ -722,30 +747,47 @@ export function extendSimulation( ): SimulationResult { const { continuation } = prev const { grid, config, nextAbsoluteTurn, worldSeed, isVolcanicWinter } = continuation - const physics = continuation.physics as ClimatePhysics - const ecology = continuation.ecology as EcologyPhysics const worldAge = config.worldAge + const paramsJson = '{}' + const terrainJson = '{}' + const specJson = '{}' + const physics = new WasmClimatePhysics(paramsJson, terrainJson, specJson) + const ecology = config.abioticWorld ? null : new WasmEcologyPhysics() + const snapshots = [...prev.snapshots] const endTurn = nextAbsoluteTurn + additionalTurns - const runEcology = !config.abioticWorld + let wasmGrid = WasmGrid.fromJSON(grid) for (let turn = nextAbsoluteTurn; turn < endTurn; turn++) { const scenarioTurn = turn - worldAge - if (isVolcanicWinter) applyVolcanicWinterForcing(grid) + if (isVolcanicWinter) { + const jsGrid = wasmGrid.toJSON() as GridState + applyVolcanicWinterForcing(jsGrid) + wasmGrid.free() + wasmGrid = WasmGrid.fromJSON(jsGrid) + } - const events = physics.processStep(grid, turn, worldSeed) - if (runEcology) ecology.processStep(grid) + physics.processStep(wasmGrid, turn, worldSeed) + if (ecology) ecology.processStep(wasmGrid) + const jsGrid = wasmGrid.toJSON() as GridState + Object.assign(grid, jsGrid) stepAtmosphericChemistry(grid) - const prev = snapshots.length > 0 ? snapshots[snapshots.length - 1].stats : undefined - snapshots.push(encodeSnapshot(grid, scenarioTurn, events, prev)) + wasmGrid.free() + wasmGrid = WasmGrid.fromJSON(grid) + const prevSnap = snapshots.length > 0 ? snapshots[snapshots.length - 1].stats : undefined + snapshots.push(encodeSnapshot(grid, scenarioTurn, [], prevSnap)) } + wasmGrid.free() + physics.free() + if (ecology) ecology.free() + return { snapshots, continuation: { - grid, physics, ecology, config, + grid, physics: null, ecology: null, config, nextAbsoluteTurn: endTurn, worldSeed, isVolcanicWinter, },