From fb6b66894cb51476bd23dbec183897892233d8a9 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Thu, 26 Mar 2026 04:32:37 -0700 Subject: [PATCH] =?UTF-8?q?feat(engine):=20=E2=9C=A8=20Update=20ecology/ph?= =?UTF-8?q?ysics=20simulation,=20hex=20grid=20traversal,=20and=20procedura?= =?UTF-8?q?l=20map=20generation=20logic=20with=20auto-generated=20code=20a?= =?UTF-8?q?nd=20new=20simulation=20runner=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../engine-ts/src/EcologyPhysics.generated.ts | 1314 +++++++++++------ packages/engine-ts/src/HexGrid.ts | 77 +- .../engine-ts/src/MapGenerator.generated.ts | 23 + packages/engine-ts/src/runner.ts | 22 +- packages/engine-ts/src/types.ts | 7 + 5 files changed, 983 insertions(+), 460 deletions(-) diff --git a/packages/engine-ts/src/EcologyPhysics.generated.ts b/packages/engine-ts/src/EcologyPhysics.generated.ts index a5057ad7..0bc803f2 100644 --- a/packages/engine-ts/src/EcologyPhysics.generated.ts +++ b/packages/engine-ts/src/EcologyPhysics.generated.ts @@ -1,12 +1,12 @@ // AUTO-GENERATED from GDScript ecology engine — do not edit manually. -// Source: engine/src/modules/ecology/flora.gd + fauna.gd + ecosystem.gd +// Source: engine/src/modules/ecology/flora.gd + fauna_simplified.gd + ecosystem_simplified.gd // Regenerate: uv run tools/transpile-engine/transpile.py import type { GridState, TileState } from './types' import { idx, neighbors } from './HexGrid' // --------------------------------------------------------------------------- -// Biome definitions (proof set — matches games/age-of-dwarves/data/world/) +// Biome definitions (auto-generated from biomes.json at transpile time) // --------------------------------------------------------------------------- interface BiomeDef { @@ -19,54 +19,32 @@ interface BiomeDef { } const BIOME_DEFS: Record = { - temperate_forest: { - id: 'temperate_forest', - temp_range: [0.35, 0.65], - moisture_range: [0.4, 0.8], - flora_climax: { canopy: 0.9, undergrowth: 0.7, fungi: 0.5 }, - fauna_capacity: 12, - quality_range: [1, 5], - }, - tropical_rainforest: { - id: 'tropical_rainforest', - temp_range: [0.65, 1.0], - moisture_range: [0.6, 1.0], - flora_climax: { canopy: 1.0, undergrowth: 0.9, fungi: 0.8 }, - fauna_capacity: 16, - quality_range: [1, 5], - }, - grassland: { - id: 'grassland', - temp_range: [0.3, 0.7], - moisture_range: [0.2, 0.5], - flora_climax: { canopy: 0.1, undergrowth: 0.8, fungi: 0.2 }, - fauna_capacity: 8, - quality_range: [1, 4], - }, - desert: { - id: 'desert', - temp_range: [0.5, 1.0], - moisture_range: [0.0, 0.2], - flora_climax: { canopy: 0.0, undergrowth: 0.1, fungi: 0.0 }, - fauna_capacity: 3, - quality_range: [1, 3], - }, - boreal_forest: { - id: 'boreal_forest', - temp_range: [0.15, 0.4], - moisture_range: [0.3, 0.7], - flora_climax: { canopy: 0.7, undergrowth: 0.4, fungi: 0.6 }, - fauna_capacity: 8, - quality_range: [1, 5], - }, - tundra: { - id: 'tundra', - temp_range: [0.0, 0.2], - moisture_range: [0.1, 0.5], - flora_climax: { canopy: 0.0, undergrowth: 0.2, fungi: 0.1 }, - fauna_capacity: 4, - quality_range: [1, 3], - }, + deep_ocean: { id: 'deep_ocean', temp_range: [0.0, 1.0], moisture_range: [0.0, 1.0], flora_climax: { canopy: 0.0, undergrowth: 0.0, fungi: 0.0 }, fauna_capacity: 8, quality_range: [1, 4] }, + shallow_ocean: { id: 'shallow_ocean', temp_range: [0.0, 1.0], moisture_range: [0.0, 1.0], flora_climax: { canopy: 0.0, undergrowth: 0.3, fungi: 0.0 }, fauna_capacity: 12, quality_range: [1, 5] }, + coral_reef: { id: 'coral_reef', temp_range: [0.55, 1.0], moisture_range: [0.0, 1.0], flora_climax: { canopy: 0.0, undergrowth: 0.6, fungi: 0.1 }, fauna_capacity: 20, quality_range: [1, 5] }, + estuary: { id: 'estuary', temp_range: [0.2, 0.8], moisture_range: [0.6, 1.0], flora_climax: { canopy: 0.0, undergrowth: 0.5, fungi: 0.05 }, fauna_capacity: 14, quality_range: [1, 4] }, + lake: { id: 'lake', temp_range: [0.0, 1.0], moisture_range: [0.0, 1.0], flora_climax: { canopy: 0.0, undergrowth: 0.3, fungi: 0.02 }, fauna_capacity: 10, quality_range: [1, 4] }, + pond: { id: 'pond', temp_range: [0.0, 1.0], moisture_range: [0.0, 1.0], flora_climax: { canopy: 0.0, undergrowth: 0.15, fungi: 0.01 }, fauna_capacity: 3, quality_range: [1, 2] }, + river: { id: 'river', temp_range: [0.0, 1.0], moisture_range: [0.0, 1.0], flora_climax: { canopy: 0.0, undergrowth: 0.2, fungi: 0.01 }, fauna_capacity: 6, quality_range: [1, 3] }, + mangrove: { id: 'mangrove', temp_range: [0.55, 1.0], moisture_range: [0.7, 1.0], flora_climax: { canopy: 0.6, undergrowth: 0.5, fungi: 0.15 }, fauna_capacity: 14, quality_range: [1, 4] }, + tropical_rainforest: { id: 'tropical_rainforest', temp_range: [0.65, 1.0], moisture_range: [0.7, 1.0], flora_climax: { canopy: 0.95, undergrowth: 0.7, fungi: 0.4 }, fauna_capacity: 25, quality_range: [1, 5] }, + tropical_dry_forest: { id: 'tropical_dry_forest', temp_range: [0.55, 1.0], moisture_range: [0.4, 0.7], flora_climax: { canopy: 0.65, undergrowth: 0.5, fungi: 0.2 }, fauna_capacity: 16, quality_range: [1, 4] }, + savanna: { id: 'savanna', temp_range: [0.55, 1.0], moisture_range: [0.2, 0.4], flora_climax: { canopy: 0.15, undergrowth: 0.45, fungi: 0.05 }, fauna_capacity: 12, quality_range: [1, 3] }, + desert: { id: 'desert', temp_range: [0.55, 1.0], moisture_range: [0.0, 0.15], flora_climax: { canopy: 0.0, undergrowth: 0.08, fungi: 0.01 }, fauna_capacity: 4, quality_range: [1, 3] }, + temperate_forest: { id: 'temperate_forest', temp_range: [0.25, 0.55], moisture_range: [0.5, 1.0], flora_climax: { canopy: 0.85, undergrowth: 0.6, fungi: 0.35 }, fauna_capacity: 18, quality_range: [1, 5] }, + temperate_grassland: { id: 'temperate_grassland', temp_range: [0.25, 0.55], moisture_range: [0.3, 0.5], flora_climax: { canopy: 0.05, undergrowth: 0.55, fungi: 0.1 }, fauna_capacity: 14, quality_range: [1, 4] }, + chaparral: { id: 'chaparral', temp_range: [0.25, 0.55], moisture_range: [0.15, 0.35], flora_climax: { canopy: 0.1, undergrowth: 0.35, fungi: 0.05 }, fauna_capacity: 8, quality_range: [1, 3] }, + swamp: { id: 'swamp', temp_range: [0.35, 0.7], moisture_range: [0.8, 1.0], flora_climax: { canopy: 0.5, undergrowth: 0.6, fungi: 0.45 }, fauna_capacity: 15, quality_range: [1, 4] }, + bog: { id: 'bog', temp_range: [0.1, 0.4], moisture_range: [0.7, 1.0], flora_climax: { canopy: 0.05, undergrowth: 0.3, fungi: 0.2 }, fauna_capacity: 6, quality_range: [1, 3] }, + boreal_forest: { id: 'boreal_forest', temp_range: [0.1, 0.3], moisture_range: [0.35, 1.0], flora_climax: { canopy: 0.7, undergrowth: 0.35, fungi: 0.3 }, fauna_capacity: 12, quality_range: [1, 4] }, + tundra: { id: 'tundra', temp_range: [0.05, 0.15], moisture_range: [0.0, 0.5], flora_climax: { canopy: 0.0, undergrowth: 0.15, fungi: 0.05 }, fauna_capacity: 5, quality_range: [1, 3] }, + polar_desert: { id: 'polar_desert', temp_range: [0.0, 0.05], moisture_range: [0.0, 0.2], flora_climax: { canopy: 0.0, undergrowth: 0.02, fungi: 0.0 }, fauna_capacity: 2, quality_range: [1, 2] }, + montane_forest: { id: 'montane_forest', temp_range: [0.15, 0.45], moisture_range: [0.4, 1.0], flora_climax: { canopy: 0.75, undergrowth: 0.45, fungi: 0.25 }, fauna_capacity: 14, quality_range: [1, 4] }, + cloud_forest: { id: 'cloud_forest', temp_range: [0.2, 0.45], moisture_range: [0.7, 1.0], flora_climax: { canopy: 0.8, undergrowth: 0.65, fungi: 0.5 }, fauna_capacity: 20, quality_range: [1, 5] }, + alpine_meadow: { id: 'alpine_meadow', temp_range: [0.05, 0.25], moisture_range: [0.3, 0.7], flora_climax: { canopy: 0.0, undergrowth: 0.3, fungi: 0.08 }, fauna_capacity: 6, quality_range: [1, 3] }, + alpine_tundra: { id: 'alpine_tundra', temp_range: [0.0, 0.15], moisture_range: [0.0, 0.4], flora_climax: { canopy: 0.0, undergrowth: 0.08, fungi: 0.02 }, fauna_capacity: 3, quality_range: [1, 2] }, + permanent_ice: { id: 'permanent_ice', temp_range: [0.0, 0.1], moisture_range: [0.0, 1.0], flora_climax: { canopy: 0.0, undergrowth: 0.01, fungi: 0.0 }, fauna_capacity: 1, quality_range: [1, 1] }, + subterranean: { id: 'subterranean', temp_range: [0.1, 0.5], moisture_range: [0.2, 0.8], flora_climax: { canopy: 0.0, undergrowth: 0.1, fungi: 0.6 }, fauna_capacity: 8, quality_range: [1, 4] }, } function getBiome(biomeId: string): BiomeDef | null { @@ -74,72 +52,253 @@ function getBiome(biomeId: string): BiomeDef | null { } // --------------------------------------------------------------------------- -// BiomeClassifier — substrate + climate + flora → biome_id -// Faithfully ports engine/src/models/world/biome_classifier.gd +// BiomeClassifier (auto-transpiled from biome_classifier.gd) // --------------------------------------------------------------------------- +function _getSubstrate(tile: TileState): string { + if (true) { + return tile.substrate_id + } + return "" + +} + +function _getWaterBodyType(tile: TileState): string { + if (true) { + return tile.water_body_type + } + return "" + +} + +function _getDepth(tile: TileState): number { + if (true) { + return tile.depth_from_coast + } + return 0 + +} + +function _getTemperature(tile: TileState): number { + if (true) { + return tile.temperature + } + return 0.5 + +} + +function _getMoisture(tile: TileState): number { + if (true) { + return tile.moisture + } + return 0.5 + +} + +function _getElevation(tile: TileState): number { + if (true) { + return tile.elevation + } + return 0.3 + +} + +function _getCanopy(tile: TileState): number { + if (true) { + return tile.canopy_cover + } + return 0.0 + +} + +function _isRiverMouth(tile: TileState): boolean { + if (true) { + return tile.is_river_mouth + } + return false + +} + +function _hasCave(tile: TileState): boolean { + if (true) { + return tile.has_cave + } + return false} + +function _classifyAquatic(tile: TileState): string { + let wb_type = _getWaterBodyType(tile) + + if (wb_type === "pond") { + return "pond" + } + if (wb_type === "river") { + return "river" + } + if (wb_type === "lake" || wb_type === "large_lake") { + return "lake" + + } + // Ocean/sea (edge-connected) + let depth = _getDepth(tile) + let temp = _getTemperature(tile) + + // Estuary: river mouth meeting ocean — pre-computed flag on tile + if (_isRiverMouth(tile) && depth <= 1) { + return "estuary" + + } + // Mangrove: tropical + coastal wetland substrate — check substrate override + // Mangrove tiles have wetland substrate adjacent to ocean with warm temp + if (_getSubstrate(tile) === "wetland" && temp > 0.55) { + return "mangrove" + + } + // Coral reef: tropical shallow ocean + if (temp > 0.55 && depth <= 2) { + return "coral_reef" + + } + // Shallow vs deep ocean by depth from coast + if (depth <= 3) { + return "shallow_ocean" + } + return "deep_ocean" + +} + +function _classifyLand(tile: TileState): string { + let temp = _getTemperature(tile) + let moisture = _getMoisture(tile) + let elevation = _getElevation(tile) + let canopy = _getCanopy(tile) + + // Wetland override: saturated moisture on lowland + if (moisture > 0.7 && elevation < 0.4) { + if (temp > 0.4) { + return "swamp" + } + return "bog" + + } + // Elevation-driven + if (elevation > 0.85) { + if (temp < 0.1) { + return "permanent_ice" + } + return "alpine_tundra" + + } + if (elevation > 0.70) { + if (moisture > 0.3) { + return "alpine_meadow" + } + return "alpine_tundra" + + } + if (elevation > 0.55) { + if (canopy > 0.4) { + return "montane_forest" + } + if (moisture > 0.7 && temp > 0.3) { + return "cloud_forest" + } + return "alpine_meadow" + + } + // Temperature-driven (lowland/midland) + if (temp > 0.55) { + if (moisture > 0.7 && canopy > 0.6) { + return "tropical_rainforest" + } + if (moisture > 0.4) { + return "tropical_dry_forest" + } + if (moisture > 0.2) { + return "savanna" + } + return "desert" + + } + // Temperate + if (temp > 0.25) { + if (canopy > 0.5) { + return "temperate_forest" + } + if (moisture > 0.3) { + return "temperate_grassland" + } + return "chaparral" + + } + // Cold + if (temp > 0.1) { + if (canopy > 0.3) { + return "boreal_forest" + } + return "tundra" + + } + return "polar_desert" + +} + function classifyBiome(tile: TileState): string { - const sub = tile.substrate_id - // Aquatic tiles keep their biome_id - if (sub === 'deep_water' || sub === 'shallow_water' || sub === 'lake_bed') { - return tile.biome_id + if (_isWater(tile)) { + return _classifyAquatic(tile) } - - const temp = tile.temperature - const moist = tile.moisture - - // Wetland override - if (sub === 'wetland') return 'swamp' - - // Temperature-driven classification - if (temp < 0.15) return 'tundra' - if (temp < 0.4) { - if (moist > 0.3 && tile.canopy_cover > 0.3) return 'boreal_forest' - if (moist > 0.3) return 'grassland' - return 'tundra' + if (_getSubstrate(tile) === "volcanic") { + return "volcanic" } - if (temp < 0.65) { - if (moist > 0.4 && tile.canopy_cover > 0.5) return 'temperate_forest' - if (moist > 0.2) return 'grassland' - return 'desert' + // Subterranean check before land — cave tiles get cave biome regardless of surface climate + if (_hasCave(tile)) { + return "subterranean" } - // Hot - if (moist > 0.6 && tile.canopy_cover > 0.6) return 'tropical_rainforest' - if (moist > 0.3) return 'grassland' - return 'desert' + return _classifyLand(tile) + } // --------------------------------------------------------------------------- -// Flora helpers +// Flora helpers (auto-transpiled from flora.gd) // --------------------------------------------------------------------------- -function isWater(tile: TileState): boolean { - const sub = tile.substrate_id - if (sub) { - return sub === 'deep_water' || sub === 'shallow_water' || sub === 'lake_bed' +function _isWater(tile: TileState): boolean { + if (true) { + let sub = tile.substrate_id + return ["deep_water", "shallow_water", "lake_bed"].includes(sub) } - return tile.biome_id === 'ocean' || tile.biome_id === 'coast' + return ["ocean", "coast"].includes(tile.biome_id) + } -function climateMatch(tile: TileState, biome: BiomeDef): number { - const temp = tile.temperature - const moist = tile.moisture - const [tMin, tMax] = biome.temp_range - const [mMin, mMax] = biome.moisture_range +function _climateMatchFlat(tile: TileState, bf: Record): number { + // Climate match using pre-resolved flat biome data (no BiomeModel). + let temp = tile.temperature + let moist = tile.moisture + let t_min = bf["temp_min"] ?? 0.0 + let t_max = bf["temp_max"] ?? 1.0 + let m_min = bf["moist_min"] ?? 0.0 + let m_max = bf["moist_max"] ?? 1.0 - const tempOk = temp >= tMin && temp <= tMax - const moistOk = moist >= mMin && moist <= mMax + let temp_ok = temp >= t_min && temp <= t_max + let moist_ok = moist >= m_min && moist <= m_max - if (tempOk && moistOk) return 1.0 + if (temp_ok && moist_ok) { + return 1.0 - const tempEdge = temp >= tMin - 0.1 && temp <= tMax + 0.1 - const moistEdge = moist >= mMin - 0.1 && moist <= mMax + 0.1 + } + let temp_edge = temp >= t_min - 0.1 && temp <= t_max + 0.1 + let moist_edge = moist >= m_min - 0.1 && moist <= m_max + 0.1 - if (tempEdge && moistEdge) return 0.5 + if (temp_edge && moist_edge) { + return 0.5 + + } return 0.0 + } -function qualityMult(quality: number): number { +function _qualityMult(quality: number): number { + // Quality growth scaling: Q1=0.6, Q2=0.8, Q3=1.0, Q4=1.2, Q5=1.4 switch (quality) { case 1: return 0.6 case 2: return 0.8 @@ -149,376 +308,693 @@ function qualityMult(quality: number): number { } } -// --------------------------------------------------------------------------- -// Vegetation defaults (from DataLoader fallbacks in flora.gd) -// --------------------------------------------------------------------------- - -const VEG = { - growth_rate: 0.02, - decay_rate: 0.03, - shade_cap: 0.7, - drought_decay_multiplier: 1.5, - fungi_undergrowth_threshold: 0.3, - fungi_regrowth_bonus_cap: 2.0, -} as const - -const SUC = { - stability_turns: 50, - canopy_threshold: 0.8, - regrowth_stages: [ - { stage: 0, turns_to_advance: 10, canopy_target: 0.0, undergrowth_target: 0.1, fungi_target: 0.0 }, - { stage: 1, turns_to_advance: 15, canopy_target: 0.1, undergrowth_target: 0.3, fungi_target: 0.05 }, - { stage: 2, turns_to_advance: 20, canopy_target: 0.4, undergrowth_target: 0.5, fungi_target: 0.2 }, - { stage: 3, turns_to_advance: 25, canopy_target: 0.7, undergrowth_target: 0.6, fungi_target: 0.4 }, - ], -} as const - -const DES = { - moisture_threshold: 0.2, - turns_required: 30, - decay_multiplier: 2.0, - recovery_rate: 1, -} as const - -function getRegrowthStage(stageIdx: number): typeof SUC.regrowth_stages[number] | null { - for (const s of SUC.regrowth_stages) { - if (s.stage === stageIdx) return s +function _getStage(stage_index: number, stages: Record[]): Record { + for (const entry of stages) { + if (typeof entry === 'object' && entry !== null && ((entry as any)["stage"] ?? -1) === stage_index) { + return entry + } } - return null + return {} + } // --------------------------------------------------------------------------- -// Flora tick methods (from flora.gd process_turn) +// Flora tick functions (auto-transpiled from flora.gd) // --------------------------------------------------------------------------- -function tickCanopy(tiles: TileState[], w: number, h: number): void { - for (let i = 0; i < tiles.length; i++) { - const tile = tiles[i] - if (isWater(tile)) continue - const biome = getBiome(tile.biome_id) - if (!biome) continue - const climax = biome.flora_climax.canopy - const match = climateMatch(tile, biome) - const qm = qualityMult(tile.quality) - if (match > 0.0) { - const delta = VEG.growth_rate * match * qm +function tickCanopy(tiles: TileState[], biomeFlora: Record>, veg: Record): void { + // Grow canopy toward biome climax. Decay when outside climate range. + let growth_rate = (veg as any)["growth_rate"] ?? 0.02 + let decay_rate = (veg as any)["decay_rate"] ?? 0.03 + + for (const tile of tiles) { + if (_isWater(tile)) { + continue + } + let bf = biomeFlora[tile.biome_id] ?? {} + if (Object.keys(bf).length === 0) { + continue + } + let climax = bf["canopy"] ?? 0.0 + let match_mult = _climateMatchFlat(tile, bf) + let q_mult = _qualityMult(tile.quality) + + if (match_mult > 0.0) { + let delta = growth_rate * match_mult * q_mult tile.canopy_cover = Math.min(tile.canopy_cover + delta, climax) } else { - tile.canopy_cover = Math.max(tile.canopy_cover - VEG.decay_rate, 0.0) - } - } -} + tile.canopy_cover = Math.max(tile.canopy_cover - decay_rate, 0.0) -function tickUndergrowth(tiles: TileState[], w: number, h: number): void { - for (let i = 0; i < tiles.length; i++) { - const tile = tiles[i] - if (isWater(tile)) continue - const biome = getBiome(tile.biome_id) - if (!biome) continue - const climax = biome.flora_climax.undergrowth - const match = climateMatch(tile, biome) - const qm = qualityMult(tile.quality) - let effectiveCap = climax - if (tile.canopy_cover > VEG.shade_cap) { - effectiveCap = Math.min(climax, VEG.shade_cap) - } - if (match > 0.0) { - const delta = VEG.growth_rate * match * qm - tile.undergrowth = Math.min(tile.undergrowth + delta, effectiveCap) - } else { - let rate = VEG.decay_rate - if (tile.drought_counter > 0) rate *= VEG.drought_decay_multiplier - tile.undergrowth = Math.max(tile.undergrowth - rate, 0.0) - } - } -} -function tickFungi(tiles: TileState[], w: number, h: number): void { - for (let i = 0; i < tiles.length; i++) { - const tile = tiles[i] - if (isWater(tile)) continue - const biome = getBiome(tile.biome_id) - if (!biome) continue - const climax = biome.flora_climax.fungi + } + }} - if (tile.undergrowth < VEG.fungi_undergrowth_threshold) { - tile.fungi_network = Math.max(tile.fungi_network - VEG.decay_rate * 0.5, 0.0) +function tickUndergrowth(tiles: TileState[], biomeFlora: Record>, veg: Record): void { + // Grow undergrowth, capped by canopy shade. Decays faster in drought. + let growth_rate = (veg as any)["growth_rate"] ?? 0.02 + let decay_rate = (veg as any)["decay_rate"] ?? 0.03 + let shade_cap = (veg as any)["shade_cap"] ?? 0.7 + let drought_mult = (veg as any)["drought_decay_multiplier"] ?? 1.5 + + for (const tile of tiles) { + if (_isWater(tile)) { continue } + let bf = biomeFlora[tile.biome_id] ?? {} + if (Object.keys(bf).length === 0) { + continue + } + let climax = bf["undergrowth"] ?? 0.0 + let match_mult = _climateMatchFlat(tile, bf) + let q_mult = _qualityMult(tile.quality) + + let effective_cap = climax + if (tile.canopy_cover > shade_cap) { + effective_cap = Math.min(climax, shade_cap) + + } + if (match_mult > 0.0) { + let delta = growth_rate * match_mult * q_mult + tile.undergrowth = Math.min(tile.undergrowth + delta, effective_cap) + } else { + let rate = decay_rate + if (tile.drought_counter > 0) { + rate *= drought_mult + } + tile.undergrowth = Math.max(tile.undergrowth - rate, 0.0) + + + } + }} + +function tickFungi(tiles: TileState[], biomeFlora: Record>, veg: Record): void { + // Fungi grows where undergrowth > threshold. Old-growth bonus. + let growth_rate = (veg as any)["growth_rate"] ?? 0.02 + let decay_rate = (veg as any)["decay_rate"] ?? 0.03 + let ug_threshold = (veg as any)["fungi_undergrowth_threshold"] ?? 0.3 + + for (const tile of tiles) { + if (_isWater(tile)) { + continue + } + let bf = biomeFlora[tile.biome_id] ?? {} + if (Object.keys(bf).length === 0) { + continue + } + let climax = bf["fungi"] ?? 0.0 + + if (tile.undergrowth < ug_threshold) { + tile.fungi_network = Math.max(tile.fungi_network - decay_rate * 0.5, 0.0) + continue + + } if (tile.moisture < 0.15 || tile.temperature < 0.1) { - tile.fungi_network = Math.max(tile.fungi_network - VEG.decay_rate * 0.5, 0.0) + tile.fungi_network = Math.max(tile.fungi_network - decay_rate * 0.5, 0.0) + continue + + } + let ug_factor = tile.undergrowth + let old_growth = 1.0 + if ((tile.canopy_cover > 0.7 && tile.undergrowth > 0.5 && tile.moisture > 0.4)) { + old_growth = 1.5 + + } + let q_mult = _qualityMult(tile.quality) + let delta = growth_rate * ug_factor * old_growth * q_mult + tile.fungi_network = Math.min(tile.fungi_network + delta, climax) + + + }} + +function tickSuccession(tiles: TileState[], suc: Record): void { + // Track canopy stability for biome succession. + // Reclassification and signals handled by _post_succession (non-transpilable). + let stability_turns = (suc as any)["stability_turns"] ?? 50 + let canopy_threshold = (suc as any)["canopy_threshold"] ?? 0.8 + + for (const tile of tiles) { + if (_isWater(tile)) { continue } - const ugFactor = tile.undergrowth - let oldGrowth = 1.0 - if (tile.canopy_cover > 0.7 && tile.undergrowth > 0.5 && tile.moisture > 0.4) { - oldGrowth = 1.5 + if (tile.regrowth_stage >= 0) { + continue + } - const qm = qualityMult(tile.quality) - const delta = VEG.growth_rate * ugFactor * oldGrowth * qm - tile.fungi_network = Math.min(tile.fungi_network + delta, climax) - } -} - -function tickSuccession(tiles: TileState[], w: number, h: number): void { - for (let i = 0; i < tiles.length; i++) { - const tile = tiles[i] - if (isWater(tile)) continue - if (tile.regrowth_stage >= 0) continue - - if (tile.canopy_cover >= SUC.canopy_threshold) { + if (tile.canopy_cover >= canopy_threshold) { tile.succession_progress += 1 } else { tile.succession_progress = 0 + + + } + }} + +function tickDesertification(tiles: TileState[], veg: Record, des: Record): void { + // Drought tracking and accelerated flora decay. + let moisture_thresh = (des as any)["moisture_threshold"] ?? 0.2 + let decay_mult = (des as any)["decay_multiplier"] ?? 2.0 + let recovery_rate = (des as any)["recovery_rate"] ?? 1 + let base_decay = (veg as any)["decay_rate"] ?? 0.03 + + for (const tile of tiles) { + if (_isWater(tile)) { continue - } - if (tile.succession_progress < SUC.stability_turns) continue - // Succession triggered — reclassify - const oldBiome = tile.biome_id - const newBiome = classifyBiome(tile) - tile.succession_progress = 0 - if (newBiome !== oldBiome) { - tile.biome_id = newBiome } - if (tile.quality >= 4 && tile.landmark_name === '') { - tile.landmark_name = `Ancient ${tile.biome_id.replace(/_/g, ' ')}` - } - } -} - -function tickDesertification(tiles: TileState[], w: number, h: number): void { - const baseDecay = VEG.decay_rate - for (let i = 0; i < tiles.length; i++) { - const tile = tiles[i] - if (isWater(tile)) continue - if (tile.moisture < DES.moisture_threshold) { + if (tile.moisture < moisture_thresh) { tile.drought_counter += 1 - const rate = baseDecay * DES.decay_multiplier + let rate = base_decay * decay_mult tile.canopy_cover = Math.max(tile.canopy_cover - rate, 0.0) tile.undergrowth = Math.max(tile.undergrowth - rate * 1.5, 0.0) tile.fungi_network = Math.max(tile.fungi_network - rate, 0.0) - if (tile.drought_counter >= DES.turns_required) { - const oldBiome = tile.biome_id - const newBiome = classifyBiome(tile) - if (newBiome !== oldBiome) tile.biome_id = newBiome - } } else { - tile.drought_counter = Math.max(tile.drought_counter - DES.recovery_rate, 0) + tile.drought_counter = Math.max(tile.drought_counter - recovery_rate, 0) + + } - } -} + }} -function tickRegrowth(tiles: TileState[], w: number, h: number): void { - for (let i = 0; i < tiles.length; i++) { - const tile = tiles[i] - if (tile.regrowth_stage < 0) continue +function tickRegrowth(tiles: TileState[], regrowthStages: Record[], veg: Record): void { + // Advance tiles through regrowth stages. Fungi accelerates (capped). + let bonus_cap = (veg as any)["fungi_regrowth_bonus_cap"] ?? 2.0 + for (const tile of tiles) { + if (tile.regrowth_stage < 0) { + continue + + } tile.regrowth_turns += 1 - const stageData = getRegrowthStage(tile.regrowth_stage) - if (!stageData) continue - const baseTurns = stageData.turns_to_advance - const fungiBonus = Math.min( - Math.max(1.0 + tile.fungi_network * VEG.fungi_regrowth_bonus_cap, 1.0), - VEG.fungi_regrowth_bonus_cap, - ) - const effectiveTurns = Math.max(1, Math.round(baseTurns / fungiBonus)) - if (tile.regrowth_turns < effectiveTurns) continue + let stage_data = _getStage(tile.regrowth_stage, regrowthStages) + if (Object.keys(stage_data).length === 0) { + continue - const nextStage = tile.regrowth_stage + 1 - const nextData = getRegrowthStage(nextStage) - if (!nextData || nextStage > 3) { + } + let base_turns = (stage_data as any)["turns_to_advance"] ?? 10 + let fungi_bonus = Math.min(bonus_cap, Math.max(1.0, 1.0 + tile.fungi_network * bonus_cap)) + let effective_turns = Math.max( 1, Math.round(base_turns / fungi_bonus) ) + + if (tile.regrowth_turns < effective_turns) { + continue + + } + let next_stage = tile.regrowth_stage + 1 + let next_data = _getStage(next_stage, regrowthStages) + + if (Object.keys(next_data).length === 0 || next_stage > 3) { tile.regrowth_stage = -1 tile.regrowth_turns = 0 continue + } - tile.regrowth_stage = nextStage + tile.regrowth_stage = next_stage tile.regrowth_turns = 0 - tile.canopy_cover = nextData.canopy_target - tile.undergrowth = nextData.undergrowth_target - tile.fungi_network = nextData.fungi_target - if (nextStage >= 3) { + tile.canopy_cover = (next_data as any)["canopy_target"] ?? 0.0 + tile.undergrowth = (next_data as any)["undergrowth_target"] ?? 0.0 + tile.fungi_network = (next_data as any)["fungi_target"] ?? 0.0 + + if (next_stage >= 3) { tile.regrowth_stage = -1 tile.regrowth_turns = 0 + + } + }} + +// --------------------------------------------------------------------------- +// Fauna (auto-transpiled from fauna_simplified.gd) +// --------------------------------------------------------------------------- + +function _tempMult(temperature: number): number { + // Temperature multiplier for fish: tropical=1.0, temperate=0.8, polar=0.5. + if (temperature > 0.55) { + return 1.0 } + if (temperature > 0.25) { + return 0.8 + } + return 0.5 + } -// --------------------------------------------------------------------------- -// Fauna — simplified for guide (no SQLite creature DB) -// Uses tile-level habitat suitability + fish stock approximation. -// --------------------------------------------------------------------------- +function _getNeighborOffsets(col: number): number[][] { + // Even-q offset hex neighbor deltas as [dc, dr] arrays. + let parity = col & 1 + if (parity === 0) { + return [[1, 0], [1, -1], [0, -1], [-1, -1], [-1, 0], [0, 1]] + } else { + return [[1, 1], [1, 0], [0, -1], [-1, 0], [-1, 1], [0, 1]] + }} -const FAUNA_WEIGHTS = { - undergrowth_weight: 0.6, - canopy_weight: 0.2, - fungi_weight: 0.2, -} as const +function tickFishStock(tiles: TileState[], marine_params: Record): void { + // Logistic fish reproduction on water tiles. + // Seed empty water tiles at 10% capacity x tempMult. + let repro_rate = marine_params["reproduction_rate"] ?? 0.05 + let cap_base = marine_params["fish_capacity"] ?? 100.0 + let reef_bonus = marine_params["reef_bonus"] ?? 0.5 + let reef_penalty = marine_params["reef_penalty"] ?? -0.5 + let seed_fraction = marine_params["seed_fraction"] ?? 0.1 + + for (const tile of tiles) { + if (!_isWater(tile)) { + continue -function updateHabitatSuitability( - tiles: TileState[], w: number, h: number, -): void { - for (let i = 0; i < tiles.length; i++) { - const tile = tiles[i] - if (isWater(tile)) continue - let tu = 0, tc = 0, tf = 0, n = 0 - // Radius-2 neighborhood average - const nbs = neighbors(tile.col, tile.row, w, h) - for (const nb of nbs) { - const nt = tiles[idx(nb.col, nb.row, w)] - if (isWater(nt)) continue - tu += nt.undergrowth - tc += nt.canopy_cover - tf += nt.fungi_network - n++ } - // Include self - tu += tile.undergrowth; tc += tile.canopy_cover; tf += tile.fungi_network; n++ - if (n > 0) { - tile.habitat_suitability = ( - (tu / n) * FAUNA_WEIGHTS.undergrowth_weight + - (tc / n) * FAUNA_WEIGHTS.canopy_weight + - (tf / n) * FAUNA_WEIGHTS.fungi_weight - ) + let temp_mult = _tempMult(tile.temperature) + let cap = cap_base + if (tile.reef_health > 0.5) { + cap *= (1.0 + reef_bonus) + } else if (tile.reef_health < 0.1) { + cap *= Math.max(0.1, 1.0 + reef_penalty) + + } + let stock = (tile.fish_stock ?? 0) + + // Seed empty water tiles + if (stock <= 0.0) { + tile.fish_stock = Math.floor(cap * seed_fraction * temp_mult) + continue + + } + let growth = repro_rate * temp_mult * stock * (1.0 - stock / cap) + tile.fish_stock = Math.min(Math.floor(cap), Math.max(0, Math.floor(stock + growth))) + + + }} + +function tickHabitatSuitability(tiles: TileState[], w: number, h: number): void { + // Per land tile: average flora in radius-1 neighbors. + // undergrowth x 0.6 + canopy x 0.2 + fungi x 0.2. + for (let i = 0; i < tiles.length; i++) { + let tile = tiles[i] + if (_isWater(tile)) { + continue + + } + let col = tile.col + let row = tile.row + let total_ug = tile.undergrowth + let total_ca = tile.canopy_cover + let total_fn = tile.fungi_network + let count = 1 + + // Radius-1 neighbors via even-q offset + let nbOffsets = _getNeighborOffsets(col) + for (const off of nbOffsets) { + let nc = col + off[0] + let nr = row + off[1] + if (nc < 0 || nc >= w || nr < 0 || nr >= h) { + continue + } + let ni = nr * w + nc + if (ni < 0 || ni >= tiles.length) { + continue + } + let ntile = tiles[ni] + if (_isWater(ntile)) { + continue + } + total_ug += ntile.undergrowth + total_ca += ntile.canopy_cover + total_fn += ntile.fungi_network + count += 1 + + } + if (count > 0) { + let avg_ug = total_ug / count + let avg_ca = total_ca / count + let avg_fn = total_fn / count + tile.habitat_suitability = avg_ug * 0.6 + avg_ca * 0.2 + avg_fn * 0.2 } else { tile.habitat_suitability = 0.0 + + } - } -} + }} -function updateFishStock(tiles: TileState[], w: number, h: number): void { - for (let i = 0; i < tiles.length; i++) { - const tile = tiles[i] - if (!isWater(tile) || (tile.fish_stock ?? 0) <= 0) continue - let tempMult = 0.5 // polar - if (tile.temperature > 0.55) tempMult = 1.0 // tropical - else if (tile.temperature > 0.25) tempMult = 0.8 // temperate - let cap = 100.0 - if (tile.reef_health > 0.5) cap *= 1.5 - else if (tile.reef_health < 0.1) cap *= 0.5 - const stock = tile.fish_stock ?? 0 - const growth = 0.05 * tempMult * stock * (1.0 - stock / cap) - tile.fish_stock = Math.max(0, Math.min(Math.round(stock + growth), cap)) - } -} +function tickReefHealth(tiles: TileState[], marine_params: Record): void { + // Reef growth in ideal temperature range. + let growth_rate = marine_params["reef_growth_rate"] ?? 0.02 + let ideal_min = marine_params["reef_ideal_min"] ?? 0.55 + let ideal_max = marine_params["reef_ideal_max"] ?? 0.75 + + for (const tile of tiles) { + if (!_isWater(tile)) { + continue + } + if (tile.temperature >= ideal_min && tile.temperature <= ideal_max) { + tile.reef_health = Math.min(1.0, tile.reef_health + growth_rate) + + + } + }} // --------------------------------------------------------------------------- -// Ecosystem quality computation (from ecosystem.gd) +// Ecosystem quality (auto-transpiled from ecosystem_simplified.gd) // --------------------------------------------------------------------------- -const QUALITY_THRESHOLDS = [0.2, 0.4, 0.6, 0.8] as const +// Ecosystem constants (extracted from ecosystem_simplified.gd) const W_FLORA = 0.30 const W_FAUNA = 0.25 const W_STABILITY = 0.25 const W_BALANCE = 0.20 +const Q2_THRESHOLD = 0.2 +const Q3_THRESHOLD = 0.4 +const Q4_THRESHOLD = 0.6 +const Q5_THRESHOLD = 0.8 +const BIOME_CANOPY_DELTA = 0.05 +const BIOME_TEMP_DELTA = 0.02 +const BIOME_MOISTURE_DELTA = 0.03 -const FOOD_YIELD_MULT: Record = { - 1: 0.5, 2: 1.0, 3: 1.5, 4: 2.0, 5: 2.5, -} +function _floraHealth(tile: TileState, bd: Record): number { + // Average of canopy/undergrowth/fungi vs biome climax values. + let canopy_max = Math.max(bd["canopy"] ?? 0.0, 0.001) + let ug_max = Math.max(bd["undergrowth"] ?? 0.0, 0.001) + let fungi_max = Math.max(bd["fungi"] ?? 0.0, 0.001) -function scoreToTier(score: number): number { - if (score >= 0.8) return 5 - if (score >= 0.6) return 4 - if (score >= 0.4) return 3 - if (score >= 0.2) return 2 - return 1 -} - -function floraHealth(tile: TileState, biome: BiomeDef | null): number { - if (!biome) return 0.5 - const { canopy, undergrowth, fungi } = biome.flora_climax - const cMax = Math.max(canopy, 0.001) - const uMax = Math.max(undergrowth, 0.001) - const fMax = Math.max(fungi, 0.001) - const c = Math.min(tile.canopy_cover / cMax, 1.0) - const u = Math.min(tile.undergrowth / uMax, 1.0) - const f = Math.min(tile.fungi_network / fMax, 1.0) + let c = Math.min(1.0, Math.max(0.0, tile.canopy_cover / canopy_max)) + let u = Math.min(1.0, Math.max(0.0, tile.undergrowth / ug_max)) + let f = Math.min(1.0, Math.max(0.0, tile.fungi_network / fungi_max)) return (c + u + f) / 3.0 + } -function biomeStability(tile: TileState): number { - const classified = classifyBiome(tile) - if (classified === tile.biome_id) return 1.0 - // Partial credit for same family - if (classified.startsWith('temperate') && tile.biome_id.startsWith('temperate')) return 0.6 - if (classified.startsWith('tropical') && tile.biome_id.startsWith('tropical')) return 0.6 - return 0.2 -} - -/** Approximate fauna diversity from habitat suitability (no creature DB in guide). */ -function faunaDiversity(tile: TileState, biome: BiomeDef | null): number { - if (!biome) return 0.5 - // Habitat suitability as proxy for species diversity - return Math.min(tile.habitat_suitability / 0.7, 1.0) -} - -/** Approximate population balance from flora ratios (no creature DB in guide). */ -function populationBalance(tile: TileState): number { - // Healthy undergrowth implies herbivore support, balanced canopy implies - // predator-prey equilibrium. In the full game this uses SQLite creature counts. - if (tile.undergrowth < 0.1) return 0.3 - const ratio = tile.canopy_cover / Math.max(tile.undergrowth, 0.01) - // Ideal ratio around 1.0-2.0 - if (ratio >= 0.5 && ratio <= 3.0) return 1.0 - return 0.5 -} - -function computeTileQuality(tiles: TileState[], w: number, h: number): void { - for (let i = 0; i < tiles.length; i++) { - const tile = tiles[i] - if (isWater(tile)) continue - const biome = getBiome(tile.biome_id) - const flora = floraHealth(tile, biome) - const fauna = faunaDiversity(tile, biome) - const stability = biomeStability(tile) - const balance = populationBalance(tile) - const score = flora * W_FLORA + fauna * W_FAUNA + - stability * W_STABILITY + balance * W_BALANCE - let newQ = scoreToTier(score) - if (biome) { - const [qMin, qMax] = biome.quality_range - newQ = Math.max(qMin, Math.min(qMax, newQ)) - } - if (newQ >= 4 && tile.quality < 4 && tile.landmark_name === '') { - tile.landmark_name = `Ancient ${tile.biome_id.replace(/_/g, ' ')}` - } - tile.quality = newQ +function _faunaProxy(tile: TileState): number { + // Use habitat_suitability as fauna diversity proxy (no creature DB). + if (true) { + return Math.min(1.0, Math.max(0.0, tile.habitat_suitability)) } + return 0.3 + } +function _biomeStability(tile: TileState, bd: Record): number { + // Score how well the tile's climate matches its assigned biome range. + // 1.0 = perfect match, 0.5 = edge, 0.0 = far outside. + if (Object.keys(bd).length === 0) { + return 0.5 + + } + let temp = tile.temperature + let moist = tile.moisture + let t_min = bd["temp_min"] ?? 0.0 + let t_max = bd["temp_max"] ?? 1.0 + let m_min = bd["moist_min"] ?? 0.0 + let m_max = bd["moist_max"] ?? 1.0 + + let temp_ok = temp >= t_min && temp <= t_max + let moist_ok = moist >= m_min && moist <= m_max + + if (temp_ok && moist_ok) { + return 1.0 + + } + // Partial credit for edge cases + let temp_edge = temp >= t_min - 0.1 && temp <= t_max + 0.1 + let moist_edge = moist >= m_min - 0.1 && moist <= m_max + 0.1 + + if (temp_edge && moist_edge) { + return 0.5 + + } + return 0.2 + +} + +function _landBalance(tile: TileState): number { + // Land population balance proxy: high habitat + moderate flora = balanced. + let hab = 0.0 + if (true) { + hab = tile.habitat_suitability + } + // Well-vegetated tiles with good habitat are balanced + let veg = (tile.undergrowth + tile.canopy_cover) * 0.5 + return Math.min(1.0, Math.max(0.0, (hab + veg) * 0.5)) + +} + +function _waterBalance(tile: TileState): number { + // Water population balance: fish stock ratio vs nominal capacity. + let stock = (true ? (tile.fish_stock ?? 0) : 0.0) + let cap = 100.0 + if (stock <= 0.0) { + return 0.1 + } + let ratio = Math.min(1.0, Math.max(0.0, stock / cap)) + // Score peaks near 60-80% capacity (not overfished, not overcrowded) + if (ratio > 0.8) { + return 0.8 + } + if (ratio > 0.4) { + return 1.0 + } + return ratio / 0.4 + +} + +function _waterStability(tile: TileState): number { + // Water stability from reef health and temperature range. + let reef = (true ? tile.reef_health : 0.0) + let temp = (true ? tile.temperature : 0.5) + // Tropical/temperate water is more stable + let temp_score = 0.5 + if (temp > 0.25 && temp < 0.75) { + temp_score = 1.0 + } else if (temp > 0.15) { + temp_score = 0.7 + } + return (reef * 0.5 + temp_score * 0.5) + +} + +function _scoreToTier(score: number): number { + // Map [0,1] score to Q1-Q5 tier. + if (score >= Q5_THRESHOLD) { + return 5 + } + if (score >= Q4_THRESHOLD) { + return 4 + } + if (score >= Q3_THRESHOLD) { + return 3 + } + if (score >= Q2_THRESHOLD) { + return 2 + } + return 1 + +} + +function recomputeBiomes(tiles: TileState[], w: number, h: number, lastCanopy: Float32Array, lastTemp: Float32Array, lastMoisture: Float32Array): void { + // Reclassify biomes where canopy/temp/moisture changed significantly. + // Updates last_* arrays in-place for next turn's comparison. + let n = tiles.length + if (lastCanopy.length !== n) { + lastCanopy = new Float32Array(n) + lastTemp = new Float32Array(n) + lastMoisture = new Float32Array(n) + for (let i = 0; i < n; i++) { + lastCanopy[i] = tiles[i].canopy_cover + lastTemp[i] = tiles[i].temperature + lastMoisture[i] = tiles[i].moisture + } + return + + } + for (let i = 0; i < n; i++) { + let tile = tiles[i] + if (_isWater(tile)) { + continue + } + let dCanopy = Math.abs(tile.canopy_cover - lastCanopy[i]) + let dTemp = Math.abs(tile.temperature - lastTemp[i]) + let dMoisture = Math.abs(tile.moisture - lastMoisture[i]) + lastCanopy[i] = tile.canopy_cover + lastTemp[i] = tile.temperature + lastMoisture[i] = tile.moisture + if (dCanopy > BIOME_CANOPY_DELTA || dTemp > BIOME_TEMP_DELTA || dMoisture > BIOME_MOISTURE_DELTA) { + // Inline classifier call — will be transpiled to classifyBiome(tile) + let newBiome = _classifyBiomeInline(tile) + if (newBiome !== tile.biome_id) { + tile.biome_id = newBiome + + + } + } + }} + +function _classifyBiomeInline(tile: TileState): string { + // Minimal inline classifier for recomputation. Mirrors biome_classifier.gd logic. + if (_isWater(tile)) { + return tile.biome_id + } + let temp = tile.temperature + let moist = tile.moisture + let elev = tile.elevation + let canopy = tile.canopy_cover + if (moist > 0.7 && elev < 0.4) { + if (temp > 0.4) { + return "swamp" + } + return "bog" + } + if (elev > 0.85) { + if (temp < 0.1) { + return "permanent_ice" + } + return "alpine_tundra" + } + if (elev > 0.70) { + if (moist > 0.3) { + return "alpine_meadow" + } + return "alpine_tundra" + } + if (elev > 0.55) { + if (canopy > 0.4) { + return "montane_forest" + } + if (moist > 0.7 && temp > 0.3) { + return "cloud_forest" + } + return "alpine_meadow" + } + if (temp > 0.55) { + if (moist > 0.7 && canopy > 0.6) { + return "tropical_rainforest" + } + if (moist > 0.4) { + return "tropical_dry_forest" + } + if (moist > 0.2) { + return "savanna" + } + return "desert" + } + if (temp > 0.25) { + if (canopy > 0.5) { + return "temperate_forest" + } + if (moist > 0.3) { + return "temperate_grassland" + } + return "chaparral" + } + if (temp > 0.1) { + if (canopy > 0.3) { + return "boreal_forest" + } + return "tundra" + } + return "polar_desert" + +} + +function computeTileQuality(tiles: TileState[], biomeFlora: Record>, w: number, h: number): void { + // Per-tile ecology composite -> Q1-Q5. + // biomeFlora: biome_id -> {canopy, undergrowth, fungi, quality_min, quality_max, + // temp_min, temp_max, moist_min, moist_max} + // Land tiles: flora_health x 0.30 + fauna_diversity x 0.25 + // + biome_stability x 0.25 + population_balance x 0.20 + // Water tiles: fish_stock ratio used for population_balance. + + for (const tile of tiles) { + let bd = biomeFlora[tile.biome_id] ?? {} + + let flora_score = 0.0 + let fauna_score = 0.0 + let stability_score = 0.0 + let balance_score = 0.5 + + if (_isWater(tile)) { + // Water tiles: quality from fish stock and reef health + balance_score = _waterBalance(tile) + stability_score = _waterStability(tile) + fauna_score = balance_score + flora_score = (true ? tile.reef_health : 0.0) + } else { + flora_score = _floraHealth(tile, bd) + fauna_score = _faunaProxy(tile) + stability_score = _biomeStability(tile, bd) + balance_score = _landBalance(tile) + + } + let score = ( flora_score * W_FLORA + fauna_score * W_FAUNA + stability_score * W_STABILITY + balance_score * W_BALANCE ) + + let new_q = _scoreToTier(score) + + // Cap by biome quality range + let q_min = bd["quality_min"] ?? 1 + let q_max = bd["quality_max"] ?? 5 + new_q = Math.min(q_max, Math.max(q_min, new_q)) + + tile.quality = new_q + + + }} + function computeGlobalHealth(grid: GridState): number { - let total = 0, count = 0 + // Average of tile qualities / 5.0 across all tiles. + let total = 0.0 + let count = 0 for (const tile of grid.tiles) { - if (isWater(tile)) continue total += tile.quality / 5.0 - count++ + count += 1 } - return count > 0 ? total / count : 0.5 + if (count === 0) { + return 0.0 + } + return total / count + } -/** Food yield modifier for a tile based on quality. */ export function getEcologyFoodModifier(tile: TileState): number { - let base = FOOD_YIELD_MULT[tile.quality] ?? 1.0 - if (!isWater(tile)) { - base *= 0.8 + 0.4 * tile.undergrowth // lerp(0.8, 1.2, undergrowth) + // Food yield modifier based on ecology quality tier. + let mult = {1: 0.5, 2: 1.0, 3: 1.5, 4: 2.0, 5: 2.5} + let base = (mult as any)[tile.quality] ?? 1.0 + if (!_isWater(tile)) { + base *= 0.8 + 0.4 * tile.undergrowth } return base + } // --------------------------------------------------------------------------- // EcologyPhysics class — orchestrates flora + fauna + quality per turn // --------------------------------------------------------------------------- -// Biome recomputation deltas -const CANOPY_DELTA = 0.05 -const TEMP_DELTA = 0.02 -const MOISTURE_DELTA = 0.03 - export class EcologyPhysics { - private lastCanopy: Float32Array | null = null - private lastTemp: Float32Array | null = null - private lastMoisture: Float32Array | null = null + private lastCanopy: Float32Array = new Float32Array(0) + private lastTemp: Float32Array = new Float32Array(0) + private lastMoisture: Float32Array = new Float32Array(0) + + // Pre-resolved biome flora data keyed by biome_id. + // In the game this is loaded by DataLoader; in the guide it's built from BIOME_DEFS. + private readonly biomeFlora: Record> + private readonly marineParams: Record = { + reproduction_rate: 0.05, fish_capacity: 100, reef_bonus: 0.5, + reef_penalty: -0.5, seed_fraction: 0.1, reef_growth_rate: 0.02, + reef_ideal_min: 0.55, reef_ideal_max: 0.75, + } + + constructor(biomeFlora?: Record>) { + if (biomeFlora) { + this.biomeFlora = biomeFlora + } else { + // Build from inline BIOME_DEFS (guide default) + this.biomeFlora = Object.fromEntries( + Object.entries(BIOME_DEFS).map(([id, def]) => [id, { + canopy: def.flora_climax.canopy, + undergrowth: def.flora_climax.undergrowth, + fungi: def.flora_climax.fungi, + temp_min: def.temp_range[0], + temp_max: def.temp_range[1], + moist_min: def.moisture_range[0], + moist_max: def.moisture_range[1], + }]), + ) + } + } /** * Process one turn of ecology dynamics. @@ -526,63 +1002,45 @@ export class EcologyPhysics { */ processStep(grid: GridState): void { const { tiles, width: w, height: h } = grid + const bf = this.biomeFlora - // Flora dynamics (6 ticks in order) - tickCanopy(tiles, w, h) - tickUndergrowth(tiles, w, h) - tickFungi(tiles, w, h) - tickSuccession(tiles, w, h) - tickDesertification(tiles, w, h) - tickRegrowth(tiles, w, h) + // Vegetation params (guide uses hardcoded defaults matching flora.gd _DEFAULTS) + const veg = { growth_rate: 0.02, decay_rate: 0.03, shade_cap: 0.7, + drought_decay_multiplier: 1.5, fungi_undergrowth_threshold: 0.3, + fungi_regrowth_bonus_cap: 2.0 } + const suc = { stability_turns: 50, canopy_threshold: 0.8 } + const des = { moisture_threshold: 0.2, decay_multiplier: 2.0, recovery_rate: 1 } + const regrowthStages = [ + { stage: 0, turns_to_advance: 10, canopy_target: 0.0, undergrowth_target: 0.1, fungi_target: 0.0 }, + { stage: 1, turns_to_advance: 15, canopy_target: 0.1, undergrowth_target: 0.3, fungi_target: 0.05 }, + { stage: 2, turns_to_advance: 20, canopy_target: 0.4, undergrowth_target: 0.5, fungi_target: 0.2 }, + { stage: 3, turns_to_advance: 25, canopy_target: 0.7, undergrowth_target: 0.6, fungi_target: 0.4 }, + ] - // Fauna (simplified for guide) - updateHabitatSuitability(tiles, w, h) - updateFishStock(tiles, w, h) + // Flora dynamics (order matches flora.gd process_turn) + tickCanopy(tiles, bf, veg) + tickUndergrowth(tiles, bf, veg) + tickFungi(tiles, bf, veg) + tickSuccession(tiles, suc) + tickDesertification(tiles, veg, des) + tickRegrowth(tiles, regrowthStages, veg) - // Biome recomputation on significant changes - this.recomputeBiomes(tiles, w, h) + // Biome recomputation (auto-transpiled from ecosystem_simplified.gd) + recomputeBiomes(tiles, w, h, this.lastCanopy, this.lastTemp, this.lastMoisture) + + // Fauna + tickHabitatSuitability(tiles, w, h) + tickFishStock(tiles, this.marineParams) + tickReefHealth(tiles, this.marineParams) // Quality scoring - computeTileQuality(tiles, w, h) + computeTileQuality(tiles, bf, w, h) // Global health grid.ecosystem_health = computeGlobalHealth(grid) } - - private recomputeBiomes(tiles: TileState[], w: number, h: number): void { - const n = tiles.length - if (!this.lastCanopy || this.lastCanopy.length !== n) { - this.lastCanopy = new Float32Array(n) - this.lastTemp = new Float32Array(n) - this.lastMoisture = new Float32Array(n) - for (let i = 0; i < n; i++) { - this.lastCanopy[i] = tiles[i].canopy_cover - this.lastTemp![i] = tiles[i].temperature - this.lastMoisture![i] = tiles[i].moisture - } - return - } - for (let i = 0; i < n; i++) { - const tile = tiles[i] - if (isWater(tile)) continue - const canopyD = Math.abs(tile.canopy_cover - this.lastCanopy[i]) - const tempD = Math.abs(tile.temperature - this.lastTemp![i]) - const moistD = Math.abs(tile.moisture - this.lastMoisture![i]) - - this.lastCanopy[i] = tile.canopy_cover - this.lastTemp![i] = tile.temperature - this.lastMoisture![i] = tile.moisture - - if (canopyD > CANOPY_DELTA || tempD > TEMP_DELTA || moistD > MOISTURE_DELTA) { - const newBiome = classifyBiome(tile) - if (newBiome !== tile.biome_id) { - tile.biome_id = newBiome - } - } - } - } } // Re-export helpers for guide lenses -export { isWater, getBiome, classifyBiome, BIOME_DEFS } +export { _isWater as isWater, getBiome, classifyBiome, BIOME_DEFS } export type { BiomeDef } diff --git a/packages/engine-ts/src/HexGrid.ts b/packages/engine-ts/src/HexGrid.ts index 3e1d1e88..84de886e 100644 --- a/packages/engine-ts/src/HexGrid.ts +++ b/packages/engine-ts/src/HexGrid.ts @@ -99,48 +99,69 @@ export function classifyTerrain( moisture: number, elevation: number, ): string { - // Only the highest peaks are locked terrain — elevation interacts with - // climate for hills (cold hills→tundra, wet hills→forest). + // ── Extreme elevation ───────────────────────────────────────────────────── 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' + if (temp < 0.20) return 'alpine_tundra' + if (temp < 0.40 && moisture > 0.50) return 'cloud_forest' + if (moisture > 0.45 && temp > 0.35) return 'alpine_meadow' + return 'hills' + } + if (elevation > 0.55) { + if (moisture > 0.40 && temp > 0.30) return 'montane_forest' + if (moisture > 0.70 && temp > 0.30) return 'cloud_forest' + if (moisture > 0.30) return 'alpine_meadow' 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' + // ── Polar / ice (temp < 0.10) ───────────────────────────────────────────── + if (temp < 0.05) return 'ice' + if (temp < 0.10) { + if (moisture < 0.15) return 'polar_desert' + return 'snow' + } + + // ── Subpolar (0.10–0.20) ────────────────────────────────────────────────── + if (temp < 0.20) { + if (moisture < 0.20) return 'polar_desert' + if (moisture >= 0.30) return 'boreal_forest' return 'tundra' } - // Temperate zone (0.35–0.55) — this is where most diversity should live + // ── Cool (0.20–0.35) ────────────────────────────────────────────────────── + if (temp < 0.35) { + if (moisture >= 0.55) return 'boreal_forest' + if (moisture >= 0.35) return 'grassland' + return 'tundra' + } + + // ── Temperate (0.35–0.55) — most biome diversity ────────────────────────── if (temp < 0.55) { - if (moisture < 0.25) return 'desert' - if (moisture < 0.40) return 'plains' + if (moisture < 0.20) return 'desert' + if (moisture < 0.30) return 'chaparral' + if (moisture < 0.42) return 'plains' if (moisture < 0.55) return 'grassland' if (moisture < 0.70) return 'forest' + if (moisture < 0.80) return 'temperate_rainforest' + return 'bog' + } + + // ── Warm (0.55–0.75) ────────────────────────────────────────────────────── + if (temp < 0.75) { + if (moisture < 0.20) return 'desert' + if (moisture < 0.35) return 'savanna' + if (moisture < 0.50) return 'plains' + if (moisture < 0.62) return 'tropical_dry_forest' + if (moisture < 0.75) return 'jungle' 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' + // ── Hot (>0.75) — true tropics ──────────────────────────────────────────── + if (moisture < 0.20) return 'desert' + if (moisture < 0.38) return 'savanna' + if (moisture < 0.52) return 'tropical_dry_forest' + if (moisture < 0.70) return 'jungle' + return 'tropical_rainforest' } // --------------------------------------------------------------------------- diff --git a/packages/engine-ts/src/MapGenerator.generated.ts b/packages/engine-ts/src/MapGenerator.generated.ts index 9c5bda93..17b2aba9 100644 --- a/packages/engine-ts/src/MapGenerator.generated.ts +++ b/packages/engine-ts/src/MapGenerator.generated.ts @@ -264,6 +264,29 @@ class GenMap { wonder_anchor_schools: [], wonder_tier: 0, river_source_type: gt?.river_source_type || undefined, + // Classifier fields (populated by water body finder + map gen) + water_body_type: '', + is_river_mouth: false, + has_cave: false, + // Atmosphere fields (populated by ClimatePhysics atmosphere steps) + pressure: 1013.0, + pressure_anomaly: 0.0, + humidity: 0.0, + // Ecology fields (populated by EcologyPhysics) + canopy_cover: 0.0, + undergrowth: 0.0, + fungi_network: 0.0, + drought_counter: 0, + succession_progress: 0, + regrowth_stage: -1, + regrowth_turns: 0, + habitat_suitability: 0.0, + habitat_low_turns: 0, + landmark_name: '', + substrate_id: '', + water_body_id: -1, + depth_from_coast: -1, + fish_stock: 0, } } } diff --git a/packages/engine-ts/src/runner.ts b/packages/engine-ts/src/runner.ts index a0e613b9..c36c215a 100644 --- a/packages/engine-ts/src/runner.ts +++ b/packages/engine-ts/src/runner.ts @@ -50,10 +50,24 @@ function computeTileAlbedo(tile: TileState, isWater: boolean): number { // --------------------------------------------------------------------------- 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', 'volcano', - // Natural wonders (geological/biological formations) + // Water + 'ocean', 'deep_ocean', 'coast', 'coral_reef', 'lake', 'inland_sea', 'estuary', + // Ice / Polar + 'ice', 'snow', 'polar_desert', + // Cold + 'tundra', 'alpine_tundra', 'boreal_forest', + // Temperate + 'chaparral', 'plains', 'grassland', 'forest', 'temperate_rainforest', + // Warm / Tropical + 'desert', 'savanna', 'tropical_dry_forest', 'tropical_rainforest', 'jungle', 'mangrove', + // Elevation + 'hills', 'mountains', 'alpine_meadow', 'cloud_forest', 'montane_forest', + // Wetland + 'swamp', 'bog', + // Special + 'volcano', + // Magic (hidden from legend — kept for ClimatePhysics.generated.ts compat) + 'enchanted_forest', 'mana_node', 'ley_nexus', 'lodestone_spire', 'crystal_cavern', 'worldroot', 'primordial_spring', 'abyssal_vortex', ] as const diff --git a/packages/engine-ts/src/types.ts b/packages/engine-ts/src/types.ts index 7e88e018..0f766fc7 100644 --- a/packages/engine-ts/src/types.ts +++ b/packages/engine-ts/src/types.ts @@ -10,6 +10,9 @@ export interface TileState { biome_id: string // computed biome from substrate + climate + flora wind_direction: number // [0, 5] axial direction index wind_speed: number // [0, 1] + pressure: number // atmospheric pressure (hPa, ~995-1030) + pressure_anomaly: number // dynamic anomaly offset from baseline + humidity: number // atmospheric humidity [0, 1] quality: number // 1-5 (Q1 prolific .. Q5 epic) quality_progress: number // counter toward next quality change river_edges: number[] // edge indices [0-5] where rivers flow @@ -39,7 +42,11 @@ export interface TileState { regrowth_turns: number // turns in current regrowth stage // Fauna fields (updated per turn by ecology system) habitat_suitability: number // [0, 1] weighted flora average + habitat_low_turns: number // consecutive turns habitat < abandon threshold landmark_name: string // Q4+ tile name from FlavorGenerator + water_body_type: string // pond, lake, large_lake, ocean, river (from water body finder) + is_river_mouth: boolean // true if ocean tile adjacent to river + has_cave: boolean // true if cave system present (subterranean biome) // 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]