From 9034d117f705538b6287bb68e61cd0ff6ff3dfd4 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Wed, 25 Mar 2026 22:48:52 -0700 Subject: [PATCH] =?UTF-8?q?deps-add(packages-directly):=20=E2=9E=95=20Inst?= =?UTF-8?q?all=20and=20pin=20critical=20security=20packages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- packages/engine-ts/package.json | 15 + .../engine-ts/src/ClimatePhysics.generated.ts | 1115 ++++++++++++ packages/engine-ts/src/HexGrid.ts | 154 ++ .../engine-ts/src/MapGenerator.generated.ts | 1543 +++++++++++++++++ packages/engine-ts/src/configs/grid.ts | 6 + packages/engine-ts/src/configs/index.ts | 3 + packages/engine-ts/src/configs/rendering.ts | 48 + packages/engine-ts/src/configs/simulation.ts | 17 + packages/engine-ts/src/index.ts | 8 + packages/engine-ts/src/runner.ts | 297 ++++ packages/engine-ts/src/scenarios.ts | 141 ++ packages/engine-ts/src/types.ts | 141 ++ packages/engine-ts/src/worker-protocol.ts | 118 ++ packages/engine-ts/tsconfig.json | 14 + 14 files changed, 3620 insertions(+) create mode 100644 packages/engine-ts/package.json create mode 100644 packages/engine-ts/src/ClimatePhysics.generated.ts create mode 100644 packages/engine-ts/src/HexGrid.ts create mode 100644 packages/engine-ts/src/MapGenerator.generated.ts create mode 100644 packages/engine-ts/src/configs/grid.ts create mode 100644 packages/engine-ts/src/configs/index.ts create mode 100644 packages/engine-ts/src/configs/rendering.ts create mode 100644 packages/engine-ts/src/configs/simulation.ts create mode 100644 packages/engine-ts/src/index.ts create mode 100644 packages/engine-ts/src/runner.ts create mode 100644 packages/engine-ts/src/scenarios.ts create mode 100644 packages/engine-ts/src/types.ts create mode 100644 packages/engine-ts/src/worker-protocol.ts create mode 100644 packages/engine-ts/tsconfig.json diff --git a/packages/engine-ts/package.json b/packages/engine-ts/package.json new file mode 100644 index 00000000..abdff7e2 --- /dev/null +++ b/packages/engine-ts/package.json @@ -0,0 +1,15 @@ +{ + "name": "@magic-civ/engine-ts", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "typescript": "^5.7.0" + } +} diff --git a/packages/engine-ts/src/ClimatePhysics.generated.ts b/packages/engine-ts/src/ClimatePhysics.generated.ts new file mode 100644 index 00000000..3cb81516 --- /dev/null +++ b/packages/engine-ts/src/ClimatePhysics.generated.ts @@ -0,0 +1,1115 @@ +// 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' + +// --------------------------------------------------------------------------- +// Defaults (mirrors climate.gd _DEFAULTS — used when params JSON absent) +// --------------------------------------------------------------------------- + +const CLIMATE_DEFAULTS: Record = { + wind_conductivity: 0.1, + energy_scale: 0.01, + equilibrium_relaxation: 0.05, + evaporation_rate: 0.05, + moisture_transport: 0.15, + precipitation_threshold: 0.7, + moisture_decay: 0.98, + moisture_relaxation: 0.02, + ocean_evaporation_hops: 3, + ocean_evaporation_hop_decay: 0.5, + atmospheric_loss_rate: 0.001, + quality_up_threshold: 10, + quality_down_threshold: 5, + corruption_spread_rate: 0.02, + corruption_flip_threshold: 0.5, + corruption_decay_rate: 0.004, + corruption_heal_rate: 0.008, + corruption_heal_threshold: 0.15, + lake_thermal_conductivity: 0.05, + river_moisture_transport: 0.075, + mountain_rain_shadow_block: 0.9, +} + +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.terrain_id + const temp = tile.temperature + const moist = tile.moisture + const elev = tile.elevation + + 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)) { + 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): boolean { + if (cond.includes(' OR ')) { + return cond.split(' OR ').some(part => evalCondition(part.trim(), temp, moist, elev)) + } + if (cond.includes(' AND ')) { + return cond.split(' AND ').every(part => evalCondition(part.trim(), temp, moist, elev)) + } + let clean = cond.trim() + if (clean.startsWith('(') && clean.endsWith(')')) clean = clean.slice(1, -1) + const tokens = clean.trim().split(' ') + if (tokens.length < 3) { console.warn(`ClimateSpecEval: bad condition: '${cond}'`); 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 + default: console.warn(`ClimateSpecEval: unknown field '${field}'`); 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].terrain_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.stepAerosolForcing(grid) + this.stepTemperature(grid) + this.stepLakeThermal(grid) + this.stepMoistureWind(grid) + this.stepMoistureRivers(grid) + this.stepLakeEvaporation(grid) + this.stepDeepEarthWater(grid) + this.stepPrecipitation(grid) + this.stepTerrainEvolution(grid) + this.stepCorruption(grid) + const events = this.stepEcologicalEvents(grid, turn, seed) + this.stepAnchorDecay(grid) + this.stepGlobalStats(grid) + this.stepClearDeltas(grid) + return events + } + + 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 + let any_aerosol = false + for (let i = 0; i < tiles.length; i++) { + if (((tiles[i] as any).sulfate_aerosol ?? 0) > 0.001) { 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.01 + let relaxation = (this.params as any)["equilibrium_relaxation"] ?? 0.05 + + // 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) + let current_temp = oldTemp[i] + + let terrain_data = (this.terrainCache.get(tile.terrain_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.terrain_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.98 + let rain_shadow_block = (this.params as any)["mountain_rain_shadow_block"] ?? 0.9 + let relaxation = (this.params as any)["moisture_relaxation"] ?? 0.02 + let atmo_loss = (this.params as any)["atmospheric_loss_rate"] ?? 0.001 + + // 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 + relaxation. + // 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 && upwind_tile.terrain_id === "mountains" ) + 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.terrain_id) ?? {}) + let evapotrans = (terrain_data as any)["evapotranspiration"] ?? 0.0 + + // Moisture equilibrium relaxation — pull toward climate baseline (like temperature) + let baseline = this.moistureBaseline(row, h, i) + let relax_delta = (baseline - current) * relaxation + + // Atmospheric loss to space — Jeans escape (temperature-scaled) + let space_loss = current * atmo_loss * tile.temperature + + newMoisture[i] = Math.min(1.0, Math.max(0.0, ( current + transported + evapotrans + relax_delta - 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"] ?? 3 ) + 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.terrain_id + if (tid !== "lake" && tid !== "ocean" && tid !== "coast") { + continue + + } + let evap = tile.temperature * evap_rate + + 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 = 0; _hop < max_hops; _hop++) { + 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.terrain_id + // Stop if we hit another water tile (no double-injection) + if (hop_tid === "ocean" || hop_tid === "coast" || hop_tid === "lake") { + break + } + // Mountains block most moisture (rain shadow) + if (hop_tid === "mountains") { + 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 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 (tile.terrain_id === "volcano") { + 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.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) { + tile.moisture = threshold + + } + // 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 stepTerrainEvolution(grid: GridState): void { + const { tiles, width: w, height: h } = grid + let up_thresh = (this.params as any)["quality_up_threshold"] ?? 10 + let down_thresh = (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.terrain_id + + // Natural wonders are geological formations — quality evolves but terrain doesn't flip + if (tile.is_natural_wonder) { + continue + + } + // Water, corrupted, and fixed-form terrains don't evolve via climate + if (( tid === "ocean" || tid === "coast" || tid === "lake" || tid === "corrupted_land" || tid === "volcano" )) { + 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) { + let old_q = tile.quality + tile.quality += 1 + } else { + tile.quality_progress -= 1 + if (tile.quality_progress <= -down_thresh) { + tile.quality_progress = 0 + if (tile.quality > 1) { + let old_q = tile.quality + tile.quality -= 1 + } else { + // Quality at floor — terrain flips one step along its chain + let old_type = tid + tile.terrain_id = ideal + tile.quality = 1 + tile.quality_progress = 0 + + } + } + } + } + } + } + } + + private stepCorruption(grid: GridState): void { + const { tiles, width: w, height: h } = grid + let spread_rate = (this.params as any)["corruption_spread_rate"] ?? 0.02 + let flip_threshold = (this.params as any)["corruption_flip_threshold"] ?? 0.5 + let decay_rate = (this.params as any)["corruption_decay_rate"] ?? 0.004 + let heal_rate = (this.params as any)["corruption_heal_rate"] ?? 0.008 + let heal_threshold = (this.params as any)["corruption_heal_threshold"] ?? 0.15 + + // Accumulate pressure increments into a separate dict — one O(n) read pass, + // then one O(n) write pass. Avoids double-counting and in-place mutation. + const pressureDeltas = new Float32Array(tiles.length) + + for (let i = 0; i < tiles.length; i++) { + const tile = tiles[i] + const { col, row } = tile + if (tile.corruption_pressure <= 0.0) { + continue + } + // terrain_power corruption_spread_modifier on the SOURCE tile scales spread rate. + // e.g. jungle = 0.5x (resists spread), swamp = 1.5x (accelerates spread). + let source_modifier = 1.0 + let base_spread = spread_rate * source_modifier + for (const nb_pos of neighbors(col, row, w, h)) { + if (!true) { + continue + } + let nb = tiles[idx(nb_pos.col, nb_pos.row, w)] + if (nb.terrain_id === "corrupted_land") { + continue + } + // Ley line channeling: spread rate is modified by the ley properties of the + // receiving tile. Death ley = 3x, generic ley edge = 2x, + // Nature/Life ley = 0.5x (resists). Off-ley = no channeling modifier. + let ley_mult = leyChannelingMult(nb, this.spec) + // Moisture resistance: high-moisture terrain (jungle, swamp, water) resists + // corruption biologically. Marine life (fish, reefs, algae) can still carry + // corruption through water — so water isn't immune, just dampened. + let moisture_resist = 1.0 - nb.moisture * 0.4 + // City protection buildings write corruption_resistance_pct to scale down spread + let corruption_resist = 1.0 - Math.min(1.0, Math.max(0.0, (nb.corruption_resistance_pct ?? 0))) + pressureDeltas[idx(nb_pos.col, nb_pos.row, w)] += base_spread * ley_mult * moisture_resist * corruption_resist + + } + } + // Apply pressure deltas + natural decay + Life/Nature ley active healing + for (let i = 0; i < tiles.length; i++) { + const tile = tiles[i] + const { col, row } = tile + let incoming = pressureDeltas[i] + + // Natural pressure decay — corruption dissipates without active feeding sources + let drain = decay_rate + + // Life/Nature ley lines actively heal (bonus drain, capped at 3 stacks) + if (tile.ley_line_count > 0 && (tile.ley_school === "life" || tile.ley_school === "nature")) { + drain += heal_rate * Math.min(tile.ley_line_count, 3.0) + + } + if (incoming === 0.0 && tile.corruption_pressure === 0.0) { + continue + } + tile.corruption_pressure = Math.min(1.0, Math.max(0.0, tile.corruption_pressure + incoming - drain)) + + } + // Terrain flip: pressure crosses threshold → corrupted_land + // Water tiles (ocean, lake, inland_sea, coast) never flip terrain — corruption + // in water expresses through the marine ecosystem (reef_health, fish_stock) instead. + for (let i = 0; i < tiles.length; i++) { + const tile = tiles[i] + const { col, row } = tile + let tid = tile.terrain_id + if (( tid === "corrupted_land" || tid === "ocean" || tid === "lake" || tid === "inland_sea" || tid === "coast" )) { + continue + } + if (tile.corruption_pressure > flip_threshold) { + if (tile.original_terrain_id === "") { + tile.original_terrain_id = tile.terrain_id + } + tile.terrain_id = "corrupted_land" + + } + } + // Marine corruption: coast tiles with elevated pressure degrade reef and fish stock + // (corrupted fish and algae carry corruption through the marine ecosystem) + for (let i = 0; i < tiles.length; i++) { + const tile = tiles[i] + const { col, row } = tile + if (tile.terrain_id !== "coast") { + continue + } + if (tile.corruption_pressure > 0.2) { + tile.reef_health = Math.max(0.0, tile.reef_health - tile.corruption_pressure * 0.015) + if ((tile.fish_stock ?? 0) > 0) { + tile.fish_stock = Math.max(0.0, (tile.fish_stock ?? 0) - tile.corruption_pressure * 0.01) + + } + } + } + // Terrain healing: corrupted tile's pressure falls below heal threshold → restore + for (let i = 0; i < tiles.length; i++) { + const tile = tiles[i] + const { col, row } = tile + if (tile.terrain_id !== "corrupted_land" || tile.corruption_pressure > heal_threshold) { + continue + } + let original = tile.original_terrain_id + let restore_to = (original !== "" ? original : "grassland") + tile.terrain_id = restore_to + tile.original_terrain_id = "" + + } + } + + 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.terrain_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.terrain_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 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.terrain_id === 'ocean' || tile.terrain_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.terrain_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.terrain_id)) { + t.terrain_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.terrain_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.terrain_id !== 'ocean' && t.terrain_id !== 'coast') { + t.terrain_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.terrain_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.terrain_id !== 'ocean' && t.terrain_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.terrain_id === 'coast' || center.terrain_id === 'ocean')) { + for (const t of tilesInRadius(col, row, 2)) { + if (t.terrain_id === 'coast') t.reef_health = Math.min(1.0, t.reef_health + 0.1) + if (t.terrain_id !== 'ocean' && t.terrain_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.terrain_id === 'forest' || center.terrain_id === 'jungle' || + center.terrain_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.terrain_id === 'enchanted_forest') { t.terrain_id = 'forest'; t.quality = Math.max(1, t.quality - 1) } + else if (t.terrain_id === 'jungle' || t.terrain_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.terrain_id === 'hills' || center.terrain_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.terrain_id === 'mountains' || center.terrain_id === 'hills' || + center.terrain_id === 'volcano') && !center.is_natural_wonder) { + center.elevation = Math.max(0.0, center.elevation - 0.03) + const isVolcano = center.terrain_id === 'volcano' + if (isVolcano) center.terrain_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.terrain_id === 'ocean' || tile.terrain_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.terrain_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.terrain_id)) { + t.terrain_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.terrain_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.terrain_id !== 'ocean' && t.terrain_id !== 'coast') { + t.terrain_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.terrain_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.terrain_id !== 'ocean' && t.terrain_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.terrain_id === 'coast' || center.terrain_id === 'ocean')) { + for (const t of tilesInRadius(col, row, 2)) { + if (t.terrain_id === 'coast') t.reef_health = Math.min(1.0, t.reef_health + 0.1) + if (t.terrain_id !== 'ocean' && t.terrain_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.terrain_id)) { + for (const t of tilesInRadius(center.col, center.row, 3)) { + if (t.is_natural_wonder) continue + if (t.terrain_id === 'enchanted_forest') { t.terrain_id = 'forest'; t.quality = Math.max(1, t.quality - 1) } + else if (t.terrain_id === 'jungle' || t.terrain_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.terrain_id === 'hills' || center.terrain_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.terrain_id) && !center.is_natural_wonder) { + center.elevation = Math.max(0.0, center.elevation - 0.03) + const isVolcano = center.terrain_id === 'volcano' + if (isVolcano) center.terrain_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 + } + } + } + +} diff --git a/packages/engine-ts/src/HexGrid.ts b/packages/engine-ts/src/HexGrid.ts new file mode 100644 index 00000000..d8f6c7c9 --- /dev/null +++ b/packages/engine-ts/src/HexGrid.ts @@ -0,0 +1,154 @@ +import { GRID_WIDTH, GRID_HEIGHT } from './configs' + +export { GRID_WIDTH, GRID_HEIGHT } + +// Odd-q offset neighbor deltas: [even_col_offsets, odd_col_offsets] +// Each entry is [dcol, drow] for directions E, NE, NW, W, SW, SE +const ODD_Q_NEIGHBORS: readonly [readonly [number, number][], readonly [number, number][]] = [ + // even col + [[1, 0], [1, -1], [0, -1], [-1, 0], [-1, 1], [0, 1]], + // odd col + [[1, 0], [1, 1], [0, 1], [-1, 0], [-1, -1], [0, -1]], +] as const + +// Axial direction vectors: E(0), NE(1), NW(2), W(3), SW(4), SE(5) +// These match the GDScript HexUtils.AXIAL_DIRECTIONS order +export const AXIAL_DIRECTIONS: readonly [number, number][] = [ + [1, 0], [1, -1], [0, -1], [-1, 0], [-1, 1], [0, 1], +] as const + +// --------------------------------------------------------------------------- +// Core grid helpers +// --------------------------------------------------------------------------- + +export function idx(col: number, row: number, w: number): number { + return row * w + col +} + +export function neighbors( + col: number, + row: number, + w: number, + h: number, +): Array<{ col: number; row: number }> { + const parity = col & 1 + const deltas = ODD_Q_NEIGHBORS[parity] + const result: Array<{ col: number; row: number }> = [] + for (const [dc, dr] of deltas) { + const nc = col + dc + const nr = row + dr + if (nc >= 0 && nc < w && nr >= 0 && nr < h) { + result.push({ col: nc, row: nr }) + } + } + return result +} + +export function axialToOffset(q: number, r: number): { col: number; row: number } { + const col = q + const row = r + (q - (q & 1)) / 2 + return { col, row } +} + +/** The tile that wind arrives FROM (opposite direction, clamped to grid). */ +export function upwindPos( + col: number, + row: number, + windDir: number, + w: number, + h: number, +): { col: number; row: number } | null { + const oppositeDir = (windDir + 3) % 6 + const parity = col & 1 + const [dc, dr] = ODD_Q_NEIGHBORS[parity][oppositeDir] + const nc = col + dc + const nr = row + dr + if (nc < 0 || nc >= w || nr < 0 || nr >= h) return null + return { col: nc, row: nr } +} + +/** Step one hex in direction dir (0-5), returning null if out of bounds. */ +export function neighborInDir( + col: number, + row: number, + dir: number, + w: number, + h: number, +): { col: number; row: number } | null { + const parity = col & 1 + const [dc, dr] = ODD_Q_NEIGHBORS[parity][dir % 6] + const nc = col + dc + const nr = row + dr + if (nc < 0 || nc >= w || nr < 0 || nr >= h) return null + return { col: nc, row: nr } +} + +/** Solar insolation at a row: 1.0 at equator (center), 0.0 at poles. */ +export function solarByRow(row: number, height: number): number { + const centerRow = height / 2.0 + return 1.0 - Math.abs((row - centerRow) / centerRow) +} + +// --------------------------------------------------------------------------- +// Terrain classification +// --------------------------------------------------------------------------- + +export function classifyTerrain( + temp: number, + moisture: number, + elevation: number, +): string { + // Only the highest peaks are locked terrain — elevation interacts with + // climate for hills (cold hills→tundra, wet hills→forest). + if (elevation > 0.82) return 'mountains' + if (elevation > 0.70) { + if (temp < 0.25) return 'tundra' + if (moisture > 0.65 && temp > 0.55) return 'forest' + return 'hills' + } + + // Polar / cold zones + if (temp < 0.10) return 'snow' + if (temp < 0.20) return moisture >= 0.30 ? 'boreal_forest' : 'tundra' + if (temp < 0.35) { + if (moisture >= 0.50) return 'boreal_forest' + if (moisture >= 0.30) return 'grassland' + return 'tundra' + } + + // Temperate zone (0.35–0.55) — this is where most diversity should live + if (temp < 0.55) { + if (moisture < 0.25) return 'desert' + if (moisture < 0.40) return 'plains' + if (moisture < 0.55) return 'grassland' + if (moisture < 0.70) return 'forest' + return 'swamp' + } + + // Warm zone (0.55–0.80) + if (temp < 0.80) { + if (moisture < 0.25) return 'desert' + if (moisture < 0.40) return 'plains' + if (moisture < 0.55) return 'grassland' + if (moisture < 0.68) return 'forest' + return 'jungle' + } + + // Hot zone (>0.80) — true tropics, equatorial only + if (moisture < 0.30) return 'desert' + if (moisture < 0.45) return 'plains' + if (moisture < 0.60) return 'grassland' + if (moisture < 0.68) return 'forest' + return 'jungle' +} + +// --------------------------------------------------------------------------- +// Seeded noise +// --------------------------------------------------------------------------- + +export function hashNoise(x: number, y: number, seed: number): number { + // Returns a deterministic pseudo-random float in [0, 1) + const v = Math.sin(x * 127.1 + y * 311.7 + seed * 74.3) * 43758.5453 + return v - Math.floor(v) +} + diff --git a/packages/engine-ts/src/MapGenerator.generated.ts b/packages/engine-ts/src/MapGenerator.generated.ts new file mode 100644 index 00000000..26f8961f --- /dev/null +++ b/packages/engine-ts/src/MapGenerator.generated.ts @@ -0,0 +1,1543 @@ +// 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' + +/** + * 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, + corrupted_land: 0.04, 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 + terrain_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, + terrain_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() + + 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.terrain_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, + terrain_id: gt?.terrain_id ?? 'ocean', + wind_direction: gt?.wind_direction ?? 0, + wind_speed: gt?.wind_speed ?? 0.5, + quality: gt?.quality ?? 2, + quality_progress: gt?.quality_progress ?? 0, + river_edges: gt?.river_edges ?? [], + flow_accumulation: gt?.flow_accumulation ?? 0.0, + corruption_pressure: 0.0, + original_terrain_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, + } + } + } + return { + tiles, + width: this.width, + height: this.height, + global_avg_temp: 0.5, + ocean_dead_fraction: 0.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] + + for (const tile of gm.tiles.values()) { + const elev = elevation.get(axialKey(tile.axial)) ?? 0.0 + tile.terrain_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.terrain_id)) landCount++ + } + const waterCount = neighborCount - landCount + if (isWaterTerrain(tile.terrain_id) && landCount >= 5) { + changes.push({ pos: tile.axial, terrain: 'grassland' }) + } else if (!isWaterTerrain(tile.terrain_id) && waterCount >= 5) { + changes.push({ pos: tile.axial, terrain: 'ocean' }) + } + } + for (const change of changes) { + const tile = gm.getTile(change.pos) + if (tile) tile.terrain_id = change.terrain + } + } +} + +function assignCoastTiles(gm: GenMap): void { + for (const tile of gm.tiles.values()) { + if (tile.terrain_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.terrain_id)) { hasLandNeighbor = true; break } + } + if (hasLandNeighbor) tile.terrain_id = 'coast' + } else if (!isWaterTerrain(tile.terrain_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.terrain_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.terrain_id === 'ocean' || tile.terrain_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.terrain_id !== 'ocean' && tile.terrain_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.terrain_id === 'ocean' || nbTile.terrain_id === 'coast')) { + adjOcean = true; break + } + } + + if (!adjOcean && elev > avg * 1.20) { + tile.terrain_id = 'mountains' + mountainTiles.push(axial) + } else if (!adjOcean && elev > avg * 1.10) { + tile.terrain_id = 'hills' + hillTiles.push(axial) + } else if (rng.randf() < 0.40) { + tile.terrain_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.terrain_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.terrain_id === 'ocean' || tile.terrain_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.terrain_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, + corrupted_land: 0.04, volcano: 0.02, + } + const fractions = (typeData['terrain_fractions'] ?? defaultFractions) as Record + + let landCount = 0 + for (const tile of gm.tiles.values()) { + if (tile.terrain_id === 'land') landCount++ + } + + const order: string[] = [ + 'volcano', 'jungle', 'forest', 'boreal_forest', 'enchanted_forest', + 'desert', 'swamp', 'corrupted_land', '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.terrain_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.terrain_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.terrain_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.terrain_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.terrain_id === 'land') tile.terrain_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.terrain_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.terrain_id !== src) { + eligible.splice(ei, 1); continue + } + tile.terrain_id = terrainId + placed++ + + for (const nb of axialNeighbors(seedPos)) { + if (placed >= targetCount) break + const nbTile = gm.getTile(nb) + if (!nbTile || nbTile.terrain_id !== src) continue + if (!isEligible(nb, terrainId, gm, elevation, moisture, temperature)) continue + if (rng.randf() < 0.65) { + nbTile.terrain_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.terrain_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 'corrupted_land': return true + 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.terrain_id !== 'ocean' && tile.terrain_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.terrain_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.terrain_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.terrain_id === 'ocean' || tile.terrain_id === 'coast' || tile.terrain_id === 'land') continue + let same = 0 + for (const nb of axialNeighbors(tile.axial)) { + const nbTile = gm.getTile(nb) + if (nbTile && nbTile.terrain_id === tile.terrain_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.terrain_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.terrain_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.terrain_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.terrain_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.terrain_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.terrain_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.terrain_id)) continue + const a = acc.get(key) ?? 0.0 + const temp = temperature.get(key) ?? tile.temperature + if (a < riverThresh(temp, tile.terrain_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.terrain_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.terrain_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.terrain_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.terrain_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.terrain_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/configs/grid.ts b/packages/engine-ts/src/configs/grid.ts new file mode 100644 index 00000000..19ee6aa0 --- /dev/null +++ b/packages/engine-ts/src/configs/grid.ts @@ -0,0 +1,6 @@ +/** Hex grid dimensions — 40 columns × 24 rows, odd-q offset layout. */ +export const GRID_WIDTH = 40 +export const GRID_HEIGHT = 24 + +/** Rows at the very top and bottom forced to ocean (no land at poles). */ +export const POLAR_ROWS = 2 diff --git a/packages/engine-ts/src/configs/index.ts b/packages/engine-ts/src/configs/index.ts new file mode 100644 index 00000000..ff80e533 --- /dev/null +++ b/packages/engine-ts/src/configs/index.ts @@ -0,0 +1,3 @@ +export * from './grid' +export * from './rendering' +export * from './simulation' diff --git a/packages/engine-ts/src/configs/rendering.ts b/packages/engine-ts/src/configs/rendering.ts new file mode 100644 index 00000000..d03ddce2 --- /dev/null +++ b/packages/engine-ts/src/configs/rendering.ts @@ -0,0 +1,48 @@ +import type { LeySchool } from '../types' +import { GRID_WIDTH, GRID_HEIGHT } from './grid' + +/** Hex tile size in the WebGL renderer (pixels). */ +export const HEX_W = 24 +export const HEX_H = 20 + +/** Canvas dimensions — derived from grid + hex size so the canvas exactly matches the camera frustum. */ +export const CANVAS_W = GRID_WIDTH * HEX_W * 0.75 + HEX_W * 0.25 // 726 +export const CANVAS_H = GRID_HEIGHT * HEX_H + HEX_H * 0.5 // 490 + +/** Ley line glow colors per school (Three.js hex). */ +export const LEY_COLORS: Record = { + death: 0x8B00FF, + life: 0xFFD700, + nature: 0x00FF88, + aether: 0x00CFFF, + chaos: 0xFF4400, +} + +/** School colors for wonder markers (RGB 0-1 for shaders). */ +export const SCHOOL_COLORS: Record = { + death: [0.54, 0.00, 1.00], + life: [1.00, 0.85, 0.00], + nature: [0.00, 1.00, 0.53], + aether: [0.00, 0.81, 1.00], + chaos: [1.00, 0.27, 0.00], + generic: [0.85, 0.85, 0.85], + all: [1.00, 1.00, 1.00], + none: [0.60, 0.60, 0.60], +} + +/** + * Diamond-glow offsets for ley line rendering. + * [dx, dy, opacity_weight] — center + 4 cardinal + 4 diagonal. + * Additive blending accumulates to full brightness at center, fades at edges. + */ +export const GLOW_OFFSETS: ReadonlyArray<[number, number, number]> = [ + [0, 0, 1.00], + [-1, 0, 0.45], [1, 0, 0.45], [0, -1, 0.45], [0, 1, 0.45], + [-1, -1, 0.20], [1, -1, 0.20], [-1, 1, 0.20], [1, 1, 0.20], +] + +/** Maximum ley edges rendered (sorted by strength, weakest culled). */ +export const MAX_VISIBLE_LEY_EDGES = 8 + +/** Wind particle count for the wind layer overlay. */ +export const WIND_PARTICLE_COUNT = 300 diff --git a/packages/engine-ts/src/configs/simulation.ts b/packages/engine-ts/src/configs/simulation.ts new file mode 100644 index 00000000..eb280a37 --- /dev/null +++ b/packages/engine-ts/src/configs/simulation.ts @@ -0,0 +1,17 @@ +/** Default number of scenario turns to simulate (after worldAge geological history). */ +export const DEFAULT_SCENARIO_TURNS = 2000 + +/** Number of additional turns when the user clicks "Extend". */ +export const EXTEND_TURNS = 500 + +/** Milliseconds per animation frame in the playback loop (~12.5 fps). */ +export const FRAME_MS = 80 + +/** Default layer mask: terrain only (bit 0). */ +export const DEFAULT_LAYER_MASK = (1 << 0) + +/** Fixed world seed used by the simulation. Same seed = same world. */ +export const WORLD_SEED = 42 + +/** Default playback buffer duration in seconds (used when ?buffer= is absent). */ +export const DEFAULT_BUFFER_SECONDS = 10 diff --git a/packages/engine-ts/src/index.ts b/packages/engine-ts/src/index.ts new file mode 100644 index 00000000..da67cfb3 --- /dev/null +++ b/packages/engine-ts/src/index.ts @@ -0,0 +1,8 @@ +export * from './types' +export * from './HexGrid' +export * from './ClimatePhysics.generated' +export * from './MapGenerator.generated' +export * from './runner' +export * from './scenarios' +export * from './worker-protocol' +export * from './configs' diff --git a/packages/engine-ts/src/runner.ts b/packages/engine-ts/src/runner.ts new file mode 100644 index 00000000..2cf0b456 --- /dev/null +++ b/packages/engine-ts/src/runner.ts @@ -0,0 +1,297 @@ +import type { + GridState, + GridSnapshot, + TurnStats, + EcologicalEvent, + ScenarioConfig, + TerrainData, + LeySchool, + SimulationResult, +} from './types' +import { GRID_WIDTH, GRID_HEIGHT } from './HexGrid' +import { ClimatePhysics } from './ClimatePhysics.generated' +import { generate as generateMap } from './MapGenerator.generated' +import { WORLD_SEED, DEFAULT_SCENARIO_TURNS } from './configs' + +// --------------------------------------------------------------------------- +// Terrain order — fixed for consistent encoding across frames +// --------------------------------------------------------------------------- + +export const TERRAIN_ORDER: readonly string[] = [ + 'ocean', 'coast', 'lake', 'inland_sea', 'ice', 'snow', 'tundra', 'desert', + 'plains', 'grassland', 'forest', 'boreal_forest', 'jungle', 'enchanted_forest', + 'hills', 'mountains', 'swamp', 'corrupted_land', 'volcano', + // Natural wonders (geological/biological formations) + 'mana_node', 'ley_nexus', 'lodestone_spire', 'crystal_cavern', + 'worldroot', 'primordial_spring', 'abyssal_vortex', +] as const + +const TERRAIN_INDEX = new Map( + TERRAIN_ORDER.map((id, i) => [id, i]), +) + +function encodeTerrainId(terrainId: string): number { + const i = TERRAIN_INDEX.get(terrainId) ?? 0 + return i / (TERRAIN_ORDER.length - 1) +} + +// --------------------------------------------------------------------------- +// Terrain cache +// --------------------------------------------------------------------------- + +export function buildTerrainCache(): Map { + const terrainMods = import.meta.glob( + '@data/terrain/*.json', + { eager: true, import: 'default' }, + ) as Record + + const cache = new Map() + for (const mod of Object.values(terrainMods)) { + if (!mod?.terrains) continue + for (const terrain of mod.terrains) { + cache.set(terrain.id, terrain) + } + } + return cache +} + +// --------------------------------------------------------------------------- +// Params loading +// --------------------------------------------------------------------------- + +type ClimateParamsRaw = Record> + +function flattenParams(raw: ClimateParamsRaw): Record { + const out: Record = {} + for (const [k, v] of Object.entries(raw)) { + if (typeof v === 'number') out[k] = v + } + return out +} + +export function loadClimateParams(): Record { + const mods = import.meta.glob( + '@data/climate_params.json', + { eager: true, import: 'default' }, + ) as Record + const raw = Object.values(mods)[0] + if (!raw) throw new Error('climate_params.json not found via @data/ alias') + return flattenParams(raw) +} + +// --------------------------------------------------------------------------- +// Snapshot encoding +// --------------------------------------------------------------------------- + +export function encodeSnapshot( + grid: GridState, + turn: number, + events: EcologicalEvent[] = [], +): GridSnapshot { + const n = grid.tiles.length + const texA = new Float32Array(n * 4) + const texB = new Float32Array(n * 4) + + for (let i = 0; i < n; i++) { + const tile = grid.tiles[i] + const base = i * 4 + + texA[base + 0] = tile.temperature + texA[base + 1] = tile.moisture + texA[base + 2] = tile.corruption_pressure + texA[base + 3] = tile.reef_health + + texB[base + 0] = tile.wind_direction / 5 + texB[base + 1] = tile.wind_speed + texB[base + 2] = encodeTerrainId(tile.terrain_id) + let riverMask = 0 + for (const e of tile.river_edges) riverMask |= (1 << e) + texB[base + 3] = riverMask / 63 + } + + const stats = computeTurnStats(grid) + + return { + texA, texB, + width: grid.width, + height: grid.height, + turn, + global_avg_temp: grid.global_avg_temp, + ocean_dead_fraction: grid.ocean_dead_fraction, + ley_edges: [], + wonder_positions: [], + stats, + events, + } +} + +const EMPTY_SCHOOL_RECORD: Record = { death: 0, life: 0, nature: 0, aether: 0, chaos: 0 } + +export function computeTurnStats(grid: GridState): TurnStats { + const { tiles } = grid + let tempSum = 0 + let moistSum = 0 + let landCount = 0 + let corruptedCount = 0 + const terrain_counts: Record = {} + + for (const tile of tiles) { + terrain_counts[tile.terrain_id] = (terrain_counts[tile.terrain_id] ?? 0) + 1 + const isWater = tile.terrain_id === 'ocean' || tile.terrain_id === 'coast' || + tile.terrain_id === 'lake' || tile.terrain_id === 'inland_sea' + if (!isWater) { + landCount++ + tempSum += tile.temperature + moistSum += tile.moisture + if (tile.terrain_id === 'corrupted_land') corruptedCount++ + } + } + + return { + avg_temp: landCount > 0 ? tempSum / landCount : 0.5, + avg_moisture: landCount > 0 ? moistSum / landCount : 0.5, + corrupted_pct: landCount > 0 ? corruptedCount / landCount : 0, + total_ley_strength: 0, + dominant_ley_school: '', + ley_school_strengths: { ...EMPTY_SCHOOL_RECORD }, + ley_land_coverage: { ...EMPTY_SCHOOL_RECORD }, + ocean_dead_pct: grid.ocean_dead_fraction, + terrain_counts, + } +} + +// --------------------------------------------------------------------------- +// Terrain cache from pre-loaded data (worker-compatible, no import.meta.glob) +// --------------------------------------------------------------------------- + +export function buildTerrainCacheFromData(data: Record): Map { + return new Map(Object.entries(data)) +} + +// --------------------------------------------------------------------------- +// Grid state cloning (for checkpoints) +// --------------------------------------------------------------------------- + +export function cloneGridState(grid: GridState): GridState { + return { + width: grid.width, + height: grid.height, + global_avg_temp: grid.global_avg_temp, + ocean_dead_fraction: grid.ocean_dead_fraction, + tiles: grid.tiles.map((t) => ({ + ...t, + river_edges: [...t.river_edges], + })), + } +} + +// --------------------------------------------------------------------------- +// Volcanic winter per-turn forcing +// --------------------------------------------------------------------------- + +export function applyVolcanicWinterForcing(grid: GridState): void { + const { tiles } = grid + const RADIUS = 3 + + const volcanos = tiles.filter((t) => t.terrain_id === 'volcano') + for (const volcano of volcanos) { + const q1 = volcano.col + const s1 = volcano.row - (volcano.col - (volcano.col & 1)) / 2 + for (const tile of tiles) { + const q2 = tile.col + const s2 = tile.row - (tile.col - (tile.col & 1)) / 2 + const dq = q2 - q1 + const ds = s2 - s1 + const dist = (Math.abs(dq) + Math.abs(ds) + Math.abs(dq + ds)) / 2 + if (dist <= RADIUS) { + tile.magic_heat_delta -= 0.008 + } + } + } +} + +// --------------------------------------------------------------------------- +// Core simulation loop (synchronous) +// --------------------------------------------------------------------------- + +export function runScenarioSync( + config: ScenarioConfig, + terrainCache: Map, + params: Record, + scenarioTurns = DEFAULT_SCENARIO_TURNS, + seed = WORLD_SEED, +): SimulationResult { + const worldSeed = seed + 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 physics = new ClimatePhysics(params, terrainCache) + const snapshots: GridSnapshot[] = [] + + const isVolcanicWinter = config.id === 'volcanic_winter' + + // Phase 1: Geological history (worldAge turns) — pure physics + ecology, no scenario forcing. + for (let turn = 0; turn < worldAge; turn++) { + physics.processStep(grid, turn, worldSeed) + } + + // Phase 2: Apply scenario initMap overrides on the geologically mature world + config.initMap(grid) + + // Phase 3: Scenario simulation — volcanic forcing, full recording + for (let turn = worldAge; turn < totalTurns; turn++) { + const scenarioTurn = turn - worldAge + + if (isVolcanicWinter) applyVolcanicWinterForcing(grid) + + const events = physics.processStep(grid, turn, worldSeed) + snapshots.push(encodeSnapshot(grid, scenarioTurn, events)) + } + + return { + snapshots, + continuation: { + grid, physics, config, + nextAbsoluteTurn: totalTurns, + worldSeed, isVolcanicWinter, + }, + } +} + +// --------------------------------------------------------------------------- +// Extend an existing simulation by additional turns +// --------------------------------------------------------------------------- + +export function extendSimulation( + prev: SimulationResult, + additionalTurns: number, +): SimulationResult { + const { continuation } = prev + const { grid, config, nextAbsoluteTurn, worldSeed, isVolcanicWinter } = continuation + const physics = continuation.physics as ClimatePhysics + const worldAge = config.worldAge + + const snapshots = [...prev.snapshots] + const endTurn = nextAbsoluteTurn + additionalTurns + + for (let turn = nextAbsoluteTurn; turn < endTurn; turn++) { + const scenarioTurn = turn - worldAge + + if (isVolcanicWinter) applyVolcanicWinterForcing(grid) + + const events = physics.processStep(grid, turn, worldSeed) + snapshots.push(encodeSnapshot(grid, scenarioTurn, events)) + } + + return { + snapshots, + continuation: { + grid, physics, config, + nextAbsoluteTurn: endTurn, + worldSeed, isVolcanicWinter, + }, + } +} + diff --git a/packages/engine-ts/src/scenarios.ts b/packages/engine-ts/src/scenarios.ts new file mode 100644 index 00000000..b4c133a5 --- /dev/null +++ b/packages/engine-ts/src/scenarios.ts @@ -0,0 +1,141 @@ +import type { ScenarioConfig, GridState } from './types' +import { DEFAULT_WORLD_AGE } from './types' +import { idx } from './HexGrid' + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function noInit(_grid: GridState): void { /* no overrides */ } + +// --------------------------------------------------------------------------- +// 1a. Base — No Magic +// --------------------------------------------------------------------------- + +const baseNoMagic: ScenarioConfig = { + id: 'base_no_magic', + worldAge: 0, + name: 'Baseline', + description: + 'Reference climate scenario. Temperature, moisture, pressure, and terrain evolve under solar forcing alone. Should reach equilibrium — persistent drift indicates a physics bug.', + initMap: noInit, +} + +// --------------------------------------------------------------------------- +// 2. Ice Age Spiral +// --------------------------------------------------------------------------- + +const iceAge: ScenarioConfig = { + worldAge: DEFAULT_WORLD_AGE, + id: 'ice_age', + name: 'Ice Age Spiral', + description: + 'Polar temperatures drop sharply, cascading inward as the cold front tightens over 150 turns.', + initMap: (grid) => { + for (const tile of grid.tiles) { + if (tile.row < 5 || tile.row > 19) { + tile.temperature = 0.05 + } + } + }, +} + +// --------------------------------------------------------------------------- +// 3. Desertification +// --------------------------------------------------------------------------- + +const desertification: ScenarioConfig = { + worldAge: DEFAULT_WORLD_AGE, + id: 'desertification', + name: 'Desertification', + description: + 'The subtropical band dries out. Moisture collapses, plains turn to desert, and the effect slowly reverses as equilibrium restores.', + initMap: (grid) => { + for (const tile of grid.tiles) { + if (tile.row >= 9 && tile.row <= 14) { + tile.moisture = Math.max(0, tile.moisture - 0.2) + } + } + }, +} + +// --------------------------------------------------------------------------- +// 4. Monsoon Cycle +// --------------------------------------------------------------------------- + +const monsoonCycle: ScenarioConfig = { + worldAge: DEFAULT_WORLD_AGE, + id: 'monsoon', + name: 'Monsoon Cycle', + description: + 'High moisture in the equatorial band recreates the wet/dry rhythm of tropical monsoon seasons as the climate system oscillates toward equilibrium.', + initMap: (grid) => { + for (const tile of grid.tiles) { + if (tile.row >= 10 && tile.row <= 14) { + tile.moisture = 0.7 + } + } + }, +} + +// --------------------------------------------------------------------------- +// 5. Flooding +// --------------------------------------------------------------------------- + +const flooding: ScenarioConfig = { + worldAge: DEFAULT_WORLD_AGE, + id: 'flooding', + name: 'Flooding', + description: + 'Excessive moisture drives relentless precipitation across the globe. Rivers overflow, coasts surge, and the land drowns in an oscillating cycle of inundation.', + initMap: (grid) => { + for (const tile of grid.tiles) { + tile.moisture = Math.min(1.0, tile.moisture + 0.3) + tile.flow_accumulation = Math.min(10.0, tile.flow_accumulation * 1.5) + } + }, +} + +// --------------------------------------------------------------------------- +// 6. Volcanic Winter +// --------------------------------------------------------------------------- + +const volcanicWinter: ScenarioConfig = { + worldAge: DEFAULT_WORLD_AGE, + id: 'volcanic_winter', + name: 'Volcanic Winter', + description: + 'Six volcanoes erupt simultaneously, blasting ash clouds that cool the entire globe. Pure physical forcing from volcanic particulates.', + initMap: (grid) => { + const { width: w } = grid + const volcanicSites = [ + { col: 5, row: 4 }, { col: 15, row: 6 }, { col: 25, row: 3 }, + { col: 35, row: 5 }, { col: 10, row: 18 }, { col: 30, row: 20 }, + ] + for (const { col, row } of volcanicSites) { + if (col >= 0 && col < w && row >= 0 && row < grid.height) { + const tile = grid.tiles[idx(col, row, w)] + if (tile) tile.terrain_id = 'volcano' + } + } + // Seed ash cooling delta on volcano-adjacent tiles + for (const tile of grid.tiles) { + if (tile.terrain_id === 'volcano') { + tile.magic_heat_delta = -0.005 + } + } + }, +} + +// --------------------------------------------------------------------------- +// Export +// --------------------------------------------------------------------------- + +export const SCENARIOS: ScenarioConfig[] = [ + baseNoMagic, + iceAge, + desertification, + monsoonCycle, + flooding, + volcanicWinter, +] diff --git a/packages/engine-ts/src/types.ts b/packages/engine-ts/src/types.ts new file mode 100644 index 00000000..8b66a792 --- /dev/null +++ b/packages/engine-ts/src/types.ts @@ -0,0 +1,141 @@ +export type School = 'death' | 'life' | 'nature' | 'aether' | 'chaos' | 'generic' | 'all' | 'none' +export type LeySchool = 'death' | 'life' | 'nature' | 'aether' | 'chaos' + +export interface TileState { + col: number + row: number + temperature: number // [0, 1] + moisture: number // [0, 1] + elevation: number // [0, 1] + terrain_id: string + wind_direction: number // [0, 5] axial direction index + wind_speed: number // [0, 1] + quality: number // 1 | 2 | 3 + quality_progress: number // counter toward next quality change + river_edges: number[] // edge indices [0-5] where rivers flow + flow_accumulation: number + corruption_pressure: number // [0, 1] + original_terrain_id: string + ley_line_count: number + ley_school: LeySchool | '' + reef_health: number // [0, 1], relevant for coast tiles + magic_heat_delta: number + magic_moisture_delta: number + is_natural_wonder: boolean + wonder_anchor_strength: number // from terrain data ley_anchor_strength + wonder_anchor_school: School // LEGACY — single school (kept for anchor decay compat) + wonder_anchor_schools: LeySchool[] // multi-school affinities for ley network + wonder_tier: number // 1-5, aligned with eras (separate from terrain quality) + // Optional fields written by subsystems (not present on all tiles) + river_source_type?: string // 'snowmelt' | 'spring' | 'hot_spring' | 'glacial' | undefined + fish_stock?: number // marine ecosystem fish population [0, 1] + corruption_resistance_pct?: number // city building protection [0, 1] +} + +export interface GridState { + tiles: TileState[] + width: number + height: number + global_avg_temp: number + ocean_dead_fraction: number +} + +export interface TurnStats { + avg_temp: number + avg_moisture: number + corrupted_pct: number + total_ley_strength: number + dominant_ley_school: LeySchool | '' + ley_school_strengths: Record + ley_land_coverage: Record // fraction of land tiles affiliated with each school + ocean_dead_pct: number + terrain_counts: Record +} + +// Packed per-tile data for rendering: 2 RGBA float textures +// texA: [temperature, moisture, corruption_pressure, reef_health] +// texB: [wind_direction/5, wind_speed, terrain_encoded, river_bitmask/63] +export interface GridSnapshot { + texA: Float32Array // width * height * 4 + texB: Float32Array // width * height * 4 + width: number + height: number + turn: number + global_avg_temp: number + ocean_dead_fraction: number + ley_edges: LeyEdge[] + wonder_positions: WonderMarker[] + stats: TurnStats + events: EcologicalEvent[] // events that occurred this turn +} + +export interface LeyEdge { + fromCol: number + fromRow: number + toCol: number + toRow: number + school: LeySchool + strength: number // [0, 5] +} + +export interface WonderMarker { + col: number + row: number + schools: LeySchool[] + tier: number // 1-5 + strength: number // = tier +} + +export interface EcologicalEvent { + turn: number + type: string + col: number + row: number + description: string +} + +/** Valid world age values — increments of 500 geological turns from 0. */ +export const WORLD_AGE_OPTIONS = [0, 500, 1000, 1500, 2000, 2500, 3000] as const +export type WorldAge = (typeof WORLD_AGE_OPTIONS)[number] +export const DEFAULT_WORLD_AGE: WorldAge = 2000 + +export interface ScenarioConfig { + id: string + name: string + description: string + initMap: (grid: GridState) => void + worldAge: WorldAge // turns of geological history before scenario forcing begins +} + +/** Opaque handle for continuing a simulation beyond its initial run. */ +export interface ContinuationState { + grid: GridState + config: ScenarioConfig + nextAbsoluteTurn: number + worldSeed: number + isVolcanicWinter: boolean + // ClimatePhysics is stored as `unknown` here to avoid + // importing the class type into the shared types file. The runner casts it. + physics: unknown +} + +/** Result of running or extending a simulation. */ +export interface SimulationResult { + snapshots: GridSnapshot[] + continuation: ContinuationState +} + +// Terrain data shape from games/age-of-four/data/terrain/*.json +export interface TerrainData { + id: string + name: string + albedo: number + evapotranspiration: number + color: [number, number, number] + flags: string[] + climate_zone: string + terrain_power?: { spell_school?: string; corruption_spread_modifier?: number } + mana_major?: { school: string; amount: number } + ley_anchor_strength?: number + ley_anchor_school?: string +} diff --git a/packages/engine-ts/src/worker-protocol.ts b/packages/engine-ts/src/worker-protocol.ts new file mode 100644 index 00000000..b478c179 --- /dev/null +++ b/packages/engine-ts/src/worker-protocol.ts @@ -0,0 +1,118 @@ +import type { TurnStats, EcologicalEvent, GridSnapshot, TerrainData, LeyEdge, WonderMarker } from './types' + +// --------------------------------------------------------------------------- +// Commands: main thread → worker +// --------------------------------------------------------------------------- + +export type WorkerCommand = + | InitCommand + | RunCommand + | ExtendCommand + | FrameCommand + | CancelCommand + +export interface InitCommand { + type: 'init' + terrainData: Record + params: Record +} + +export interface RunCommand { + type: 'run' + scenarioId: string + turns: number + /** How many frames to pre-encode after simulation completes. */ + prebufferFrames: number + /** World seed (default 42). */ + seed?: number +} + +export interface ExtendCommand { + type: 'extend' + scenarioId: string + turns: number +} + +export interface FrameCommand { + type: 'frame' + scenarioId: string + turn: number + lookahead: number +} + +export interface CancelCommand { + type: 'cancel' + scenarioId: string +} + +// --------------------------------------------------------------------------- +// Responses: worker → main thread +// --------------------------------------------------------------------------- + +export type WorkerResponse = + | ReadyResponse + | ProgressResponse + | StatsResponse + | FramesResponse + | DoneResponse + | PrebufferDoneResponse + | ErrorResponse + +export interface ReadyResponse { + type: 'ready' +} + +export interface ProgressResponse { + type: 'progress' + scenarioId: string + turn: number + total: number + phase: 'geology' | 'scenario' | 'buffering' +} + +/** Lightweight per-turn data sent once after simulation completes. */ +export interface StatsResponse { + type: 'stats' + scenarioId: string + allStats: TurnStats[] + allEvents: EcologicalEvent[][] +} + +/** Rendered frames with Float32Array textures — sent on demand via 'frame' command. */ +export interface FramesResponse { + type: 'frames' + scenarioId: string + startTurn: number + snapshots: FramePayload[] +} + +/** Minimal snapshot for rendering — no stats/events (main thread has those already). */ +export interface FramePayload { + texA: Float32Array + texB: Float32Array + width: number + height: number + turn: number + global_avg_temp: number + ocean_dead_fraction: number + ley_edges: LeyEdge[] + wonder_positions: WonderMarker[] +} + +export interface DoneResponse { + type: 'done' + scenarioId: string + totalTurns: number +} + +/** Posted after the initial playback buffer frames have all been sent. */ +export interface PrebufferDoneResponse { + type: 'prebuffer_done' + scenarioId: string +} + +export interface ErrorResponse { + type: 'error' + scenarioId: string + message: string +} diff --git a/packages/engine-ts/tsconfig.json b/packages/engine-ts/tsconfig.json new file mode 100644 index 00000000..27811a60 --- /dev/null +++ b/packages/engine-ts/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["ES2020"], + "module": "ESNext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "strict": true + }, + "include": ["src"] +}