From d6af1519bbc8d3b0aae3ca7732eacc780feb8a80 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Sun, 29 Mar 2026 05:16:33 -0700 Subject: [PATCH] =?UTF-8?q?feat(ecology-specific):=20=E2=9C=A8=20Update=20?= =?UTF-8?q?flora=20simulation=20logic=20and=20sprite=20generation=20toolin?= =?UTF-8?q?g=20for=20ecology=20module?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- engine/src/modules/ecology/flora.gd | 34 ++++++++----- .../engine-ts/src/EcologyPhysics.generated.ts | 47 +++++++++++++----- tools/sprite-generation/spritegen.db-shm | Bin 32768 -> 32768 bytes tools/sprite-generation/spritegen.db-wal | Bin 9092872 -> 9092872 bytes tools/transpile-engine/ecology_assembly.py | 11 ++++ 5 files changed, 68 insertions(+), 24 deletions(-) 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 4145d466fdabc678206d2503cbd6cb9aa3e6e724..35ce2169ab83e8fe8deee5a057ac92a025876603 100644 GIT binary patch delta 221 zcmZo@U}|V!s+V}A%K!pbmoqRhGB60tVq##pEYH9&F|@DOhhf_!L8mgUf@rmP=I5Dl znXBd+lBynTIFJCD`yUBF#W&W6a!I)gGBAL!I}mF~K`@gv0|S%v=7(GkrknK~nwXiV wGtHQM(Nl5r6=w||mYGbmm^KBJ$uLi0nhF#Z-Q1V+gq3j`kSV#jr>KM%0N8XxbN~PV delta 187 zcmZo@U}|V!s+V}A%K!rBmNPIgGB5~CW@2EtEYHAjx1<`8p)oxB( z!%yz_Bvn1wa3BFP_dgPVif^nB<=Xt2%i44jxBO;3hbHF9Os*=MuQ+S)O#a3tu$d?Z#`tD#*)+tx+MAhuX<4Gle6wXQJ};Ww z-S4^m_unz~Lk}?!BS|18l1P$>nOMjql1x%aDoG=g$zvp)SjiMJm1K}J|*t7Ih{YR2pqFO;3s_4UL+U9;z7?*ieuT|7_2<%l?Q{^DE7)+^NaIfXX(@^_qjNY{cVAFrd`=HCbh4KOH?O zWKh?!ybO9}Yirif=sqhwa7jo`DP%>lxTcNgxASzQ#%ZJBU+mVo{%|-F>Qq|rc5@e7 z!p9$A5l+>du|zp~*~DejE$w2)c&}!lSAtLUIz&%Nu_(oR^&k6R_=x_OJ-7_QAJpReUEFW9(K}z;ZFE~ZetvLFaGN|{w?r=w+B?VN zh(6x!a7!Lh68@fZw*A34pP#*1m3=-_*V8Z9=UMb~N5ne`DVVt#P-k^KZpABN7oC2% zVT7`E{8j46<+A9-O;!e^FV?8@)``MKv^tFKS1?@Rx;^^Kp z{7hQ&ZGH~@;n>1*y5>6}lkTR-;QK*)WPEHcY`KHy1woux@&BQm9OHw%Oc*r%R9Ce6 zR$8i+Fh}I^V^($|PV+pBfs|6Sow=q`S(Xk#g4ZS65di|dc-0|r3 z1nbaN!@8qxX949TUZS?Ko_JN`J$3mcm&4=2a!)KH!#teCO9!hC55CW=h#&6TNW!f5 z7HO}VGy0p!riU;0z{c3+(ogOphn|zjVczfdXzY9^bLF9kDzl-8A_p~GrWWe(M*?ic z^0M+u=dva1D%nsyTOaYa$nH@e`;diz)cPcU_KN=5jemJJP06y3nVB8H+aAcCE$Yvun?YrQdk6wVF@gS zC*dhr22Vp7l*4j(2A+i#uo70mYFGnnp#s)HB~-zBsD=%&5jMeQsDUl86}CYw_+dNL zK>%c^hX!Z_1)hT@XoeODLI}dp3K392gEnY~4(Nm~cpi4ZPS^$Aup4@y7hZrpKwvNI OgFfij2X5}~y!98NJk_57 delta 11867 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 D)m0#B 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)