diff --git a/engine/src/modules/ecology/flora.gd b/engine/src/modules/ecology/flora.gd index 601e34ad..11ad50ff 100644 --- a/engine/src/modules/ecology/flora.gd +++ b/engine/src/modules/ecology/flora.gd @@ -82,28 +82,38 @@ func process_turn(game_map: RefCounted) -> void: static func tick_pioneer(tiles: Array, biome_flora: Dictionary, veg: Dictionary) -> void: - ## Pioneer colonization: allows bare ground to acquire initial flora. - ## Applies a small spontaneous seeding rate (additive, not multiplicative) to - ## overcome the population gate in tick_undergrowth. Models bacteria/lichen/spore - ## colonization of virgin substrate. Does NOT run for abiotic worlds (ecology - ## is disabled entirely when abioticWorld = true in the scenario config). + ## Pioneer colonization: seeds bare ground with initial flora using raw climate values. + ## Uses temperature/moisture directly (not biome climate range) because abiotic-classified + ## tiles may have narrow climate ranges that don't match their actual climate conditions + ## (e.g., a temperate tile classified as polar_desert before biology establishes). + ## Does NOT run for abiotic worlds (ecology is disabled when abioticWorld = true). var pioneer_rate: float = veg.get("pioneer_rate", 0.002) for tile: Variant in tiles: if BiomeRegistry.has_tag(tile.biome_id, "is_water"): continue - if tile.undergrowth > 0.0: + # Only seed completely bare tiles + if tile.undergrowth > 0.0 and tile.canopy_cover > 0.0: + continue + # Raw habitability: minimum conditions for pioneer life + if tile.temperature < 0.10 or tile.moisture < 0.15: continue var bf: Dictionary = biome_flora.get(tile.biome_id, {}) if bf.is_empty(): continue - var climax_ug: float = bf.get("undergrowth", 0.0) - # No seeding for biomes with negligible undergrowth potential (desert, ice) - if climax_ug <= 0.01: + # Habitat quality: scale by how far above survival thresholds, capped at 1 + var hab_temp: float = minf(1.0, (tile.temperature - 0.10) / 0.40) + var hab_moist: float = minf(1.0, (tile.moisture - 0.15) / 0.40) + var hab: float = hab_temp * hab_moist + if hab <= 0.0: continue - var match_mult: float = _climate_match_flat(tile, bf) - if match_mult > 0.0: - tile.undergrowth = pioneer_rate * match_mult + # Seed undergrowth on bare tiles + if tile.undergrowth <= 0.0: + tile.undergrowth = pioneer_rate * hab + # Seed canopy on tiles with tree/shrub potential (climax canopy >= 5%) + var climax_ca: float = bf.get("canopy", 0.0) + if climax_ca >= 0.05 and tile.canopy_cover <= 0.0 and tile.undergrowth >= pioneer_rate * 0.5: + tile.canopy_cover = pioneer_rate * 0.5 * hab static func tick_canopy(tiles: Array, biome_flora: Dictionary, veg: Dictionary, o2_fraction: float = 0.21) -> void: diff --git a/packages/engine-ts/src/EcologyPhysics.generated.ts b/packages/engine-ts/src/EcologyPhysics.generated.ts index 5ba24fd5..17973a5d 100644 --- a/packages/engine-ts/src/EcologyPhysics.generated.ts +++ b/packages/engine-ts/src/EcologyPhysics.generated.ts @@ -392,32 +392,44 @@ function _getStage(stage_index: number, stages: Record[]): Recor // --------------------------------------------------------------------------- function tickPioneer(tiles: TileState[], biomeFlora: Record>, veg: Record): void { - // Pioneer colonization: allows bare ground to acquire initial flora. - // Applies a small spontaneous seeding rate (additive, not multiplicative) to - // overcome the population gate in tick_undergrowth. Models bacteria/lichen/spore - // colonization of virgin substrate. Does NOT run for abiotic worlds (ecology - // is disabled entirely when abioticWorld = true in the scenario config). + // Pioneer colonization: seeds bare ground with initial flora using raw climate values. + // Uses temperature/moisture directly (not biome climate range) because abiotic-classified + // tiles may have narrow climate ranges that don't match their actual climate conditions + // (e.g., a temperate tile classified as polar_desert before biology establishes). + // Does NOT run for abiotic worlds (ecology is disabled when abioticWorld = true). let pioneer_rate = (veg as any)["pioneer_rate"] ?? 0.002 for (const tile of tiles) { if (hasTag(tile.biome_id, "is_water")) { continue } - if (tile.undergrowth > 0.0) { + // Only seed completely bare tiles + if (tile.undergrowth > 0.0 && tile.canopy_cover > 0.0) { + continue + } + // Raw habitability: minimum conditions for pioneer life + if (tile.temperature < 0.10 || tile.moisture < 0.15) { continue } let bf = biomeFlora[tile.biome_id] ?? {} if (Object.keys(bf).length === 0) { continue } - let climax_ug = bf["undergrowth"] ?? 0.0 - // No seeding for biomes with negligible undergrowth potential (desert, ice) - if (climax_ug <= 0.01) { + // Habitat quality: scale by how far above survival thresholds, capped at 1 + let hab_temp = Math.min(1.0, (tile.temperature - 0.10) / 0.40) + let hab_moist = Math.min(1.0, (tile.moisture - 0.15) / 0.40) + let hab = hab_temp * hab_moist + if (hab <= 0.0) { continue } - let match_mult = _climateMatchFlat(tile, bf) - if (match_mult > 0.0) { - tile.undergrowth = pioneer_rate * match_mult + // Seed undergrowth on bare tiles + if (tile.undergrowth <= 0.0) { + tile.undergrowth = pioneer_rate * hab + } + // Seed canopy on tiles with tree/shrub potential (climax canopy >= 5%) + let climax_ca = bf["canopy"] ?? 0.0 + if (climax_ca >= 0.05 && tile.canopy_cover <= 0.0 && tile.undergrowth >= pioneer_rate * 0.5) { + tile.canopy_cover = pioneer_rate * 0.5 * hab } @@ -1149,6 +1161,17 @@ export class EcologyPhysics { // Flora dynamics (order matches flora.gd process_turn) const o2 = grid.o2_fraction ?? 0.21 tickPioneer(tiles, bf, veg) + // Reclassify pioneer-seeded tiles immediately so subsequent ticks use the correct biome. + // A tile seeded from canopy=0 to canopy>0 may be in an abiotic biome (polar_desert, + // chaparral) that won't match its actual climate, causing tick_canopy/tick_undergrowth + // to decay the pioneer growth. Reclassifying here lets the same-turn tick functions + // use the correct biotic biome and grow instead of decay. + for (const tile of tiles) { + if (!hasTag(tile.biome_id, 'is_water') && (tile.undergrowth > 0 || tile.canopy_cover > 0)) { + const _newBiome = _classifyBiomeInline(tile) + if (_newBiome !== tile.biome_id) tile.biome_id = _newBiome + } + } tickCanopy(tiles, bf, veg, o2) tickUndergrowth(tiles, bf, veg, o2) tickFungi(tiles, bf, veg) diff --git a/tools/sprite-generation/spritegen.db-shm b/tools/sprite-generation/spritegen.db-shm index 4145d466..35ce2169 100644 Binary files a/tools/sprite-generation/spritegen.db-shm and b/tools/sprite-generation/spritegen.db-shm differ diff --git a/tools/sprite-generation/spritegen.db-wal b/tools/sprite-generation/spritegen.db-wal index c7fdbd4e..4470d33c 100644 Binary files a/tools/sprite-generation/spritegen.db-wal and b/tools/sprite-generation/spritegen.db-wal differ diff --git a/tools/transpile-engine/ecology_assembly.py b/tools/transpile-engine/ecology_assembly.py index 2bc367e2..c46126ad 100644 --- a/tools/transpile-engine/ecology_assembly.py +++ b/tools/transpile-engine/ecology_assembly.py @@ -658,6 +658,17 @@ export class EcologyPhysics {{ // Flora dynamics (order matches flora.gd process_turn) const o2 = grid.o2_fraction ?? 0.21 tickPioneer(tiles, bf, veg) + // Reclassify pioneer-seeded tiles immediately so subsequent ticks use the correct biome. + // A tile seeded from canopy=0 to canopy>0 may be in an abiotic biome (polar_desert, + // chaparral) that won't match its actual climate, causing tick_canopy/tick_undergrowth + // to decay the pioneer growth. Reclassifying here lets the same-turn tick functions + // use the correct biotic biome and grow instead of decay. + for (const tile of tiles) {{ + if (!hasTag(tile.biome_id, 'is_water') && (tile.undergrowth > 0 || tile.canopy_cover > 0)) {{ + const _newBiome = _classifyBiomeInline(tile) + if (_newBiome !== tile.biome_id) tile.biome_id = _newBiome + }} + }} tickCanopy(tiles, bf, veg, o2) tickUndergrowth(tiles, bf, veg, o2) tickFungi(tiles, bf, veg)