refactor(ecology): ♻️ Simplify and reorganize flora and fauna models for better maintainability and simulation logic

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-03-26 04:32:36 -07:00
parent 8ed89541e4
commit 4d6bb486d9
3 changed files with 566 additions and 181 deletions

View file

@ -0,0 +1,284 @@
class_name EcosystemSimplified
extends RefCounted
## Guide-compatible tile quality (Q1-Q5) and global health computation.
##
## Transpiler-friendly: static functions accept flat tile arrays + plain
## Dictionary params. No DataLoader, EventBus, preload, or BiomeClassifier
## calls inside loops. Biome data pre-resolved by orchestrator.
# Quality component weights (sum to 1.0)
const W_FLORA: float = 0.30
const W_FAUNA: float = 0.25
const W_STABILITY: float = 0.25
const W_BALANCE: float = 0.20
# Quality tier thresholds
const Q2_THRESHOLD: float = 0.2
const Q3_THRESHOLD: float = 0.4
const Q4_THRESHOLD: float = 0.6
const Q5_THRESHOLD: float = 0.8
# Biome reclassification deltas
const BIOME_CANOPY_DELTA: float = 0.05
const BIOME_TEMP_DELTA: float = 0.02
const BIOME_MOISTURE_DELTA: float = 0.03
# =========================================================================
# Transpilable functions
# =========================================================================
static func compute_tile_quality(tiles: Array, biome_data: Dictionary, w: int, h: int) -> void:
## Per-tile ecology composite -> Q1-Q5.
## biome_data: 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 tile: Variant in tiles:
var bd: Dictionary = biome_data.get(tile.biome_id, {})
var flora_score: float = 0.0
var fauna_score: float = 0.0
var stability_score: float = 0.0
var balance_score: float = 0.5
if _is_water(tile):
# Water tiles: quality from fish stock and reef health
balance_score = _water_balance(tile)
stability_score = _water_stability(tile)
fauna_score = balance_score
flora_score = tile.reef_health if "reef_health" in tile else 0.0
else:
flora_score = _flora_health(tile, bd)
fauna_score = _fauna_proxy(tile)
stability_score = _biome_stability(tile, bd)
balance_score = _land_balance(tile)
var score: float = (
flora_score * W_FLORA
+ fauna_score * W_FAUNA
+ stability_score * W_STABILITY
+ balance_score * W_BALANCE
)
var new_q: int = _score_to_tier(score)
# Cap by biome quality range
var q_min: int = bd.get("quality_min", 1)
var q_max: int = bd.get("quality_max", 5)
new_q = clampi(new_q, q_min, q_max)
tile.quality = new_q
static func compute_global_health(tiles: Array) -> float:
## Average of tile qualities / 5.0 across all tiles.
var total: float = 0.0
var count: int = 0
for tile: Variant in tiles:
total += float(tile.quality) / 5.0
count += 1
if count == 0:
return 0.0
return total / float(count)
# =========================================================================
# Scoring helpers
# =========================================================================
static func _flora_health(tile: Variant, bd: Dictionary) -> float:
## Average of canopy/undergrowth/fungi vs biome climax values.
var canopy_max: float = maxf(bd.get("canopy", 0.0), 0.001)
var ug_max: float = maxf(bd.get("undergrowth", 0.0), 0.001)
var fungi_max: float = maxf(bd.get("fungi", 0.0), 0.001)
var c: float = clampf(tile.canopy_cover / canopy_max, 0.0, 1.0)
var u: float = clampf(tile.undergrowth / ug_max, 0.0, 1.0)
var f: float = clampf(tile.fungi_network / fungi_max, 0.0, 1.0)
return (c + u + f) / 3.0
static func _fauna_proxy(tile: Variant) -> float:
## Use habitat_suitability as fauna diversity proxy (no creature DB).
if "habitat_suitability" in tile:
return clampf(tile.habitat_suitability, 0.0, 1.0)
return 0.3
static func _biome_stability(tile: Variant, bd: Dictionary) -> float:
## Score how well the tile's climate matches its assigned biome range.
## 1.0 = perfect match, 0.5 = edge, 0.0 = far outside.
if bd.is_empty():
return 0.5
var temp: float = tile.temperature
var moist: float = tile.moisture
var t_min: float = bd.get("temp_min", 0.0)
var t_max: float = bd.get("temp_max", 1.0)
var m_min: float = bd.get("moist_min", 0.0)
var m_max: float = bd.get("moist_max", 1.0)
var temp_ok: bool = temp >= t_min and temp <= t_max
var moist_ok: bool = moist >= m_min and moist <= m_max
if temp_ok and moist_ok:
return 1.0
# Partial credit for edge cases
var temp_edge: bool = temp >= t_min - 0.1 and temp <= t_max + 0.1
var moist_edge: bool = moist >= m_min - 0.1 and moist <= m_max + 0.1
if temp_edge and moist_edge:
return 0.5
return 0.2
static func _land_balance(tile: Variant) -> float:
## Land population balance proxy: high habitat + moderate flora = balanced.
var hab: float = 0.0
if "habitat_suitability" in tile:
hab = tile.habitat_suitability
# Well-vegetated tiles with good habitat are balanced
var veg: float = (tile.undergrowth + tile.canopy_cover) * 0.5
return clampf((hab + veg) * 0.5, 0.0, 1.0)
static func _water_balance(tile: Variant) -> float:
## Water population balance: fish stock ratio vs nominal capacity.
var stock: float = float(tile.fish_stock) if "fish_stock" in tile else 0.0
var cap: float = 100.0
if stock <= 0.0:
return 0.1
var ratio: float = clampf(stock / cap, 0.0, 1.0)
# 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
static func _water_stability(tile: Variant) -> float:
## Water stability from reef health and temperature range.
var reef: float = tile.reef_health if "reef_health" in tile else 0.0
var temp: float = tile.temperature if "temperature" in tile else 0.5
# Tropical/temperate water is more stable
var temp_score: float = 0.5
if temp > 0.25 and temp < 0.75:
temp_score = 1.0
elif temp > 0.15:
temp_score = 0.7
return (reef * 0.5 + temp_score * 0.5)
static func _score_to_tier(score: float) -> int:
## 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
static func recompute_biomes(tiles: Array, w: int, h: int, last_canopy: PackedFloat32Array, last_temp: PackedFloat32Array, last_moisture: PackedFloat32Array) -> void:
## Reclassify biomes where canopy/temp/moisture changed significantly.
## Updates last_* arrays in-place for next turn's comparison.
var n: int = tiles.size()
if last_canopy.size() != n:
last_canopy.resize(n)
last_temp.resize(n)
last_moisture.resize(n)
for i: int in n:
last_canopy[i] = tiles[i].canopy_cover
last_temp[i] = tiles[i].temperature
last_moisture[i] = tiles[i].moisture
return
for i: int in n:
var tile: Variant = tiles[i]
if _is_water(tile):
continue
var d_canopy: float = absf(tile.canopy_cover - last_canopy[i])
var d_temp: float = absf(tile.temperature - last_temp[i])
var d_moisture: float = absf(tile.moisture - last_moisture[i])
last_canopy[i] = tile.canopy_cover
last_temp[i] = tile.temperature
last_moisture[i] = tile.moisture
if d_canopy > BIOME_CANOPY_DELTA or d_temp > BIOME_TEMP_DELTA or d_moisture > BIOME_MOISTURE_DELTA:
# Inline classifier call — will be transpiled to classifyBiome(tile)
var new_biome: String = _classify_biome(tile)
if new_biome != tile.biome_id:
tile.biome_id = new_biome
static func _classify_biome(tile: Variant) -> String:
## Minimal inline classifier for recomputation. Mirrors biome_classifier.gd logic.
if _is_water(tile):
return tile.biome_id
var temp: float = tile.temperature
var moist: float = tile.moisture
var elev: float = tile.elevation
var canopy: float = tile.canopy_cover
if moist > 0.7 and 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 and temp > 0.3:
return "cloud_forest"
return "alpine_meadow"
if temp > 0.55:
if moist > 0.7 and 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"
static func get_ecology_food_modifier(tile: Variant) -> float:
## Food yield modifier based on ecology quality tier.
var mult: Dictionary = {1: 0.5, 2: 1.0, 3: 1.5, 4: 2.0, 5: 2.5}
var base: float = mult.get(tile.quality, 1.0)
if not _is_water(tile):
base *= 0.8 + 0.4 * tile.undergrowth
return base
static func _is_water(tile: Variant) -> bool:
if "substrate_id" in tile:
var sub: String = tile.substrate_id
return sub in ["deep_water", "shallow_water", "lake_bed"]
return tile.biome_id in ["ocean", "coast"]

View file

@ -0,0 +1,128 @@
class_name FaunaSimplified
extends RefCounted
## Tile-level fauna approximation for the guide (no individual creatures, no SQLite).
##
## Transpiler-friendly: static tick functions accept flat tile arrays + plain
## Dictionary params. No DataLoader, EventBus, preload, or HexUtils calls
## inside tick loops. Tiles accessed by col/row (not position Vector2i).
# =========================================================================
# Transpilable tick functions
# =========================================================================
static func tick_fish_stock(tiles: Array, marine_params: Dictionary) -> void:
## Logistic fish reproduction on water tiles.
## Seed empty water tiles at 10% capacity x tempMult.
var repro_rate: float = marine_params.get("reproduction_rate", 0.05)
var cap_base: float = marine_params.get("fish_capacity", 100.0)
var reef_bonus: float = marine_params.get("reef_bonus", 0.5)
var reef_penalty: float = marine_params.get("reef_penalty", -0.5)
var seed_fraction: float = marine_params.get("seed_fraction", 0.1)
for tile: Variant in tiles:
if not _is_water(tile):
continue
var temp_mult: float = _temp_mult(tile.temperature)
var cap: float = cap_base
if tile.reef_health > 0.5:
cap *= (1.0 + reef_bonus)
elif tile.reef_health < 0.1:
cap *= maxf(0.1, 1.0 + reef_penalty)
var stock: float = float(tile.fish_stock)
# Seed empty water tiles
if stock <= 0.0:
tile.fish_stock = int(cap * seed_fraction * temp_mult)
continue
var growth: float = repro_rate * temp_mult * stock * (1.0 - stock / cap)
tile.fish_stock = clampi(int(stock + growth), 0, int(cap))
static func tick_habitat_suitability(tiles: Array, w: int, h: int) -> void:
## Per land tile: average flora in radius-1 neighbors.
## undergrowth x 0.6 + canopy x 0.2 + fungi x 0.2.
for i: int in tiles.size():
var tile: Variant = tiles[i]
if _is_water(tile):
continue
var col: int = tile.col
var row: int = tile.row
var total_ug: float = tile.undergrowth
var total_ca: float = tile.canopy_cover
var total_fn: float = tile.fungi_network
var count: int = 1
# Radius-1 neighbors via even-q offset
var nb_offsets: Array = _get_neighbor_offsets(col)
for off: Variant in nb_offsets:
var nc: int = col + off[0]
var nr: int = row + off[1]
if nc < 0 or nc >= w or nr < 0 or nr >= h:
continue
var ni: int = nr * w + nc
if ni < 0 or ni >= tiles.size():
continue
var ntile: Variant = tiles[ni]
if _is_water(ntile):
continue
total_ug += ntile.undergrowth
total_ca += ntile.canopy_cover
total_fn += ntile.fungi_network
count += 1
if count > 0:
var avg_ug: float = total_ug / float(count)
var avg_ca: float = total_ca / float(count)
var avg_fn: float = total_fn / float(count)
tile.habitat_suitability = avg_ug * 0.6 + avg_ca * 0.2 + avg_fn * 0.2
else:
tile.habitat_suitability = 0.0
static func tick_reef_health(tiles: Array, marine_params: Dictionary) -> void:
## Reef growth in ideal temperature range.
var growth_rate: float = marine_params.get("reef_growth_rate", 0.02)
var ideal_min: float = marine_params.get("reef_ideal_min", 0.55)
var ideal_max: float = marine_params.get("reef_ideal_max", 0.75)
for tile: Variant in tiles:
if not _is_water(tile):
continue
if tile.temperature >= ideal_min and tile.temperature <= ideal_max:
tile.reef_health = minf(1.0, tile.reef_health + growth_rate)
# =========================================================================
# Pure static helpers
# =========================================================================
static func _temp_mult(temperature: float) -> float:
## 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
static func _is_water(tile: Variant) -> bool:
if "substrate_id" in tile:
var sub: String = tile.substrate_id
return sub in ["deep_water", "shallow_water", "lake_bed"]
return tile.biome_id in ["ocean", "coast"]
static func _get_neighbor_offsets(col: int) -> Array:
## Even-q offset hex neighbor deltas as [dc, dr] arrays.
var parity: int = 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]]

View file

@ -1,7 +1,18 @@
# gdlint: disable=max-public-methods
class_name FloraSystem
extends RefCounted
## Per-turn flora dynamics: canopy, undergrowth, fungi, succession,
## desertification, and regrowth. Reads params from DataLoader.
## desertification, and regrowth.
##
## The tick functions are written to be transpiler-friendly:
## - Iterate flat tile arrays (not game_map.tiles Dictionary)
## - Accept pre-resolved biome data as plain Dictionaries (not BiomeModel objects)
## - No DataLoader, EventBus, or preload calls inside tick loops
## - The orchestrator (process_turn) resolves all external dependencies
## and passes plain data to the ticks
##
## This means `uv run tools/transpile-engine/transpile.py` can auto-generate
## EcologyPhysics.generated.ts from these functions — single source of truth.
const BiomeClassifierScript = preload(
"res://engine/src/models/world/biome_classifier.gd"
@ -15,144 +26,135 @@ var _veg: Dictionary = {}
var _suc: Dictionary = {}
var _des: Dictionary = {}
var _params_loaded: bool = false
# Regrowth stage targets from succession.json
var _regrowth_stages: Array = []
# Pre-resolved biome data: biome_id -> {canopy, undergrowth, fungi, temp_min, temp_max, moist_min, moist_max}
var _biome_flora: Dictionary = {}
func _load_params() -> void:
_veg = DataLoader.get_vegetation_params()
_suc = DataLoader.get_succession_params()
_des = DataLoader.get_desertification_params()
_regrowth_stages = _suc.get("regrowth_stages", [])
# Pre-resolve biome data into plain Dictionary for transpiler-friendly ticks
_biome_flora = {}
for biome: Variant in DataLoader.get_all_biomes():
var fc: Dictionary = biome.flora_climax if biome.flora_climax is Dictionary else {}
_biome_flora[biome.id] = {
"canopy": fc.get("canopy", 0.0),
"undergrowth": fc.get("undergrowth", 0.0),
"fungi": fc.get("fungi", 0.0),
"temp_min": biome.temp_range.x if biome.temp_range is Vector2 else 0.0,
"temp_max": biome.temp_range.y if biome.temp_range is Vector2 else 1.0,
"moist_min": biome.moisture_range.x if biome.moisture_range is Vector2 else 0.0,
"moist_max": biome.moisture_range.y if biome.moisture_range is Vector2 else 1.0,
}
_params_loaded = true
func process_turn(game_map: RefCounted) -> void:
## Orchestrator: tick flora components in fixed order.
## Orchestrator: pre-resolve external data, then call transpilable ticks.
if not _params_loaded:
_load_params()
_tick_canopy(game_map)
_tick_undergrowth(game_map)
_tick_fungi(game_map)
_tick_succession(game_map)
_tick_desertification(game_map)
_tick_regrowth(game_map)
# Collect tiles into flat array for transpiler-friendly iteration
var tiles: Array = game_map.tiles.values()
tick_canopy(tiles, _biome_flora, _veg)
tick_undergrowth(tiles, _biome_flora, _veg)
tick_fungi(tiles, _biome_flora, _veg)
tick_succession(tiles, _suc)
tick_desertification(tiles, _veg, _des)
tick_regrowth(tiles, _regrowth_stages, _veg)
# Post-tick: handle non-transpilable effects (signals, classifier, naming)
_post_succession(tiles)
# -- Tick methods --
# =========================================================================
# Transpilable tick functions — pure tile array + plain Dictionary params
# No DataLoader, no EventBus, no preload calls inside these.
# =========================================================================
func _tick_canopy(game_map: RefCounted) -> void:
static func tick_canopy(tiles: Array, biome_flora: Dictionary, veg: Dictionary) -> void:
## Grow canopy toward biome climax. Decay when outside climate range.
var growth_rate: float = _veg.get("growth_rate", 0.02)
var decay_rate: float = _veg.get("decay_rate", 0.03)
var growth_rate: float = veg.get("growth_rate", 0.02)
var decay_rate: float = veg.get("decay_rate", 0.03)
for axial: Vector2i in game_map.tiles:
var tile: Variant = game_map.tiles[axial]
for tile: Variant in tiles:
if _is_water(tile):
continue
var biome: Variant = DataLoader.get_biome(tile.biome_id)
if biome == null:
var bf: Dictionary = biome_flora.get(tile.biome_id, {})
if bf.is_empty():
continue
var climax: float = biome.flora_climax.get("canopy", 0.0)
var match_mult: float = _climate_match(tile, biome)
var climax: float = bf.get("canopy", 0.0)
var match_mult: float = _climate_match_flat(tile, bf)
var q_mult: float = _quality_mult(tile.quality)
if match_mult > 0.0:
# Grow toward climax
var delta: float = growth_rate * match_mult * q_mult
tile.canopy_cover = minf(
tile.canopy_cover + delta, climax
)
tile.canopy_cover = minf(tile.canopy_cover + delta, climax)
else:
# Decay
tile.canopy_cover = maxf(
tile.canopy_cover - decay_rate, 0.0
)
tile.canopy_cover = maxf(tile.canopy_cover - decay_rate, 0.0)
func _tick_undergrowth(game_map: RefCounted) -> void:
## Grow undergrowth toward climax, capped by canopy shade.
## Open biomes (canopy < 0.2): grows independently.
## Decays 1.5x faster during drought.
var growth_rate: float = _veg.get("growth_rate", 0.02)
var decay_rate: float = _veg.get("decay_rate", 0.03)
var shade_cap: float = _veg.get("shade_cap", 0.7)
var drought_mult: float = _veg.get("drought_decay_multiplier", 1.5)
static func tick_undergrowth(tiles: Array, biome_flora: Dictionary, veg: Dictionary) -> void:
## Grow undergrowth, capped by canopy shade. Decays faster in drought.
var growth_rate: float = veg.get("growth_rate", 0.02)
var decay_rate: float = veg.get("decay_rate", 0.03)
var shade_cap: float = veg.get("shade_cap", 0.7)
var drought_mult: float = veg.get("drought_decay_multiplier", 1.5)
for axial: Vector2i in game_map.tiles:
var tile: Variant = game_map.tiles[axial]
for tile: Variant in tiles:
if _is_water(tile):
continue
var biome: Variant = DataLoader.get_biome(tile.biome_id)
if biome == null:
var bf: Dictionary = biome_flora.get(tile.biome_id, {})
if bf.is_empty():
continue
var climax: float = biome.flora_climax.get("undergrowth", 0.0)
var match_mult: float = _climate_match(tile, biome)
var climax: float = bf.get("undergrowth", 0.0)
var match_mult: float = _climate_match_flat(tile, bf)
var q_mult: float = _quality_mult(tile.quality)
# Shade cap: dense canopy limits undergrowth
var effective_cap: float = climax
if tile.canopy_cover > shade_cap:
effective_cap = minf(climax, shade_cap)
if match_mult > 0.0:
var delta: float = growth_rate * match_mult * q_mult
tile.undergrowth = minf(
tile.undergrowth + delta, effective_cap
)
tile.undergrowth = minf(tile.undergrowth + delta, effective_cap)
else:
# Drought: undergrowth decays faster
var rate: float = decay_rate
if tile.drought_counter > 0:
rate *= drought_mult
tile.undergrowth = maxf(
tile.undergrowth - rate, 0.0
)
tile.undergrowth = maxf(tile.undergrowth - rate, 0.0)
func _tick_fungi(game_map: RefCounted) -> void:
## Fungi grows only where undergrowth > threshold.
## Highest in old-growth. Near-zero in desert/tundra.
var growth_rate: float = _veg.get("growth_rate", 0.02)
var decay_rate: float = _veg.get("decay_rate", 0.03)
var ug_threshold: float = _veg.get(
"fungi_undergrowth_threshold", 0.3
)
static func tick_fungi(tiles: Array, biome_flora: Dictionary, veg: Dictionary) -> void:
## Fungi grows where undergrowth > threshold. Old-growth bonus.
var growth_rate: float = veg.get("growth_rate", 0.02)
var decay_rate: float = veg.get("decay_rate", 0.03)
var ug_threshold: float = veg.get("fungi_undergrowth_threshold", 0.3)
for axial: Vector2i in game_map.tiles:
var tile: Variant = game_map.tiles[axial]
for tile: Variant in tiles:
if _is_water(tile):
continue
var biome: Variant = DataLoader.get_biome(tile.biome_id)
if biome == null:
var bf: Dictionary = biome_flora.get(tile.biome_id, {})
if bf.is_empty():
continue
var climax: float = bf.get("fungi", 0.0)
var climax: float = biome.flora_climax.get("fungi", 0.0)
# Fungi requires undergrowth above threshold
if tile.undergrowth < ug_threshold:
tile.fungi_network = maxf(
tile.fungi_network - decay_rate * 0.5, 0.0
)
tile.fungi_network = maxf(tile.fungi_network - decay_rate * 0.5, 0.0)
continue
# Near-zero in desert and tundra
if tile.moisture < 0.15 or tile.temperature < 0.1:
tile.fungi_network = maxf(
tile.fungi_network - decay_rate * 0.5, 0.0
)
tile.fungi_network = maxf(tile.fungi_network - decay_rate * 0.5, 0.0)
continue
# Growth rate scales with undergrowth density
var ug_factor: float = tile.undergrowth
# Old-growth bonus: canopy > 0.7 AND undergrowth > 0.5 AND moisture > 0.4
var old_growth: float = 1.0
if (tile.canopy_cover > 0.7
and tile.undergrowth > 0.5
@ -161,117 +163,63 @@ func _tick_fungi(game_map: RefCounted) -> void:
var q_mult: float = _quality_mult(tile.quality)
var delta: float = growth_rate * ug_factor * old_growth * q_mult
tile.fungi_network = minf(
tile.fungi_network + delta, climax
)
tile.fungi_network = minf(tile.fungi_network + delta, climax)
func _tick_succession(game_map: RefCounted) -> void:
## Track canopy stability. When threshold met for enough turns,
## reclassify biome. Q4+ tiles get landmark names.
var stability_turns: int = _suc.get("stability_turns", 50)
var canopy_threshold: float = _suc.get("canopy_threshold", 0.8)
static func tick_succession(tiles: Array, suc: Dictionary) -> void:
## Track canopy stability for biome succession.
## Reclassification and signals handled by _post_succession (non-transpilable).
var stability_turns: int = suc.get("stability_turns", 50)
var canopy_threshold: float = suc.get("canopy_threshold", 0.8)
for axial: Vector2i in game_map.tiles:
var tile: Variant = game_map.tiles[axial]
for tile: Variant in tiles:
if _is_water(tile):
continue
# Skip tiles in active regrowth
if tile.regrowth_stage >= 0:
continue
if tile.canopy_cover >= canopy_threshold:
tile.succession_progress += 1
else:
# Reset if canopy drops below threshold
tile.succession_progress = 0
continue
if tile.succession_progress < stability_turns:
continue
# Succession triggered — reclassify
var old_biome: String = tile.biome_id
var new_biome: String = BiomeClassifierScript.classify(tile)
tile.succession_progress = 0
if new_biome != old_biome:
tile.biome_id = new_biome
EventBus.biome_changed.emit(axial, old_biome, new_biome)
# Landmark naming for Q4+ tiles
if tile.quality >= 4 and tile.landmark_name == "":
var name: String = FlavorGeneratorScript.generate_landmark_name(
tile.biome_id, tile.quality,
hash(axial) + tile.quality,
)
tile.landmark_name = name
EventBus.landmark_formed.emit(
axial, name, tile.quality
)
func _tick_desertification(game_map: RefCounted) -> void:
## Drought tracking. Prolonged low moisture decays flora at 2x rate.
var moisture_thresh: float = _des.get("moisture_threshold", 0.2)
var turns_req: int = _des.get("turns_required", 30)
var decay_mult: float = _des.get("decay_multiplier", 2.0)
var recovery_rate: int = _des.get("recovery_rate", 1)
var base_decay: float = _veg.get("decay_rate", 0.03)
static func tick_desertification(tiles: Array, veg: Dictionary, des: Dictionary) -> void:
## Drought tracking and accelerated flora decay.
var moisture_thresh: float = des.get("moisture_threshold", 0.2)
var decay_mult: float = des.get("decay_multiplier", 2.0)
var recovery_rate: int = des.get("recovery_rate", 1)
var base_decay: float = veg.get("decay_rate", 0.03)
for axial: Vector2i in game_map.tiles:
var tile: Variant = game_map.tiles[axial]
for tile: Variant in tiles:
if _is_water(tile):
continue
if tile.moisture < moisture_thresh:
tile.drought_counter += 1
# Accelerated decay on all three components
var rate: float = base_decay * decay_mult
tile.canopy_cover = maxf(tile.canopy_cover - rate, 0.0)
tile.undergrowth = maxf(
tile.undergrowth - rate * 1.5, 0.0
)
tile.fungi_network = maxf(
tile.fungi_network - rate, 0.0
)
# Full desertification after enough drought turns
if tile.drought_counter >= turns_req:
var old_biome: String = tile.biome_id
var new_biome: String = BiomeClassifierScript.classify(
tile
)
if new_biome != old_biome:
tile.biome_id = new_biome
EventBus.biome_changed.emit(
axial, old_biome, new_biome
)
tile.undergrowth = maxf(tile.undergrowth - rate * 1.5, 0.0)
tile.fungi_network = maxf(tile.fungi_network - rate, 0.0)
else:
# Moisture recovered — decrement counter
tile.drought_counter = maxi(
tile.drought_counter - recovery_rate, 0
)
tile.drought_counter = maxi(tile.drought_counter - recovery_rate, 0)
func _tick_regrowth(game_map: RefCounted) -> void:
## Advance tiles through regrowth stages after clearing.
## Fungi network reduces turn threshold (capped at 2.0x).
var bonus_cap: float = _veg.get("fungi_regrowth_bonus_cap", 2.0)
static func tick_regrowth(tiles: Array, regrowth_stages: Array, veg: Dictionary) -> void:
## Advance tiles through regrowth stages. Fungi accelerates (capped).
var bonus_cap: float = veg.get("fungi_regrowth_bonus_cap", 2.0)
for axial: Vector2i in game_map.tiles:
var tile: Variant = game_map.tiles[axial]
for tile: Variant in tiles:
if tile.regrowth_stage < 0:
continue
tile.regrowth_turns += 1
var stage_data: Dictionary = _get_stage(tile.regrowth_stage)
var stage_data: Dictionary = _get_stage(tile.regrowth_stage, regrowth_stages)
if stage_data.is_empty():
continue
var base_turns: int = stage_data.get("turns_to_advance", 10)
# Fungi acceleration: divisor clamped to [1.0, bonus_cap]
var fungi_bonus: float = clampf(
1.0 + tile.fungi_network * bonus_cap, 1.0, bonus_cap
)
@ -282,36 +230,66 @@ func _tick_regrowth(game_map: RefCounted) -> void:
if tile.regrowth_turns < effective_turns:
continue
# Advance to next stage
var next_stage: int = tile.regrowth_stage + 1
var next_data: Dictionary = _get_stage(next_stage)
var next_data: Dictionary = _get_stage(next_stage, regrowth_stages)
if next_data.is_empty() or next_stage > 3:
# Regrowth complete — clear state
tile.regrowth_stage = -1
tile.regrowth_turns = 0
continue
tile.regrowth_stage = next_stage
tile.regrowth_turns = 0
# Set flora to stage targets
tile.canopy_cover = next_data.get("canopy_target", 0.0)
tile.undergrowth = next_data.get("undergrowth_target", 0.0)
tile.fungi_network = next_data.get("fungi_target", 0.0)
# At stage 3 (forest): clear regrowth, tile is normal
if next_stage >= 3:
tile.regrowth_stage = -1
tile.regrowth_turns = 0
# -- Helpers --
# =========================================================================
# Non-transpilable post-processing (signals, classifier, naming)
# =========================================================================
func _get_stage(stage_index: int) -> Dictionary:
## Get regrowth stage data by index.
for entry: Variant in _regrowth_stages:
func _post_succession(tiles: Array) -> void:
## Handle biome reclassification + signals + landmark naming after succession tick.
var stability_turns: int = _suc.get("stability_turns", 50)
for tile: Variant in tiles:
if _is_water(tile):
continue
if tile.succession_progress < stability_turns:
continue
# Succession triggered
var old_biome: String = tile.biome_id
var new_biome: String = BiomeClassifierScript.classify(tile)
tile.succession_progress = 0
if new_biome != old_biome:
tile.biome_id = new_biome
EventBus.biome_changed.emit(tile.position, old_biome, new_biome)
# Landmark naming for Q4+ tiles
if tile.quality >= 4 and tile.landmark_name == "":
var lname: String = FlavorGeneratorScript.generate_landmark_name(
tile.biome_id, tile.quality,
hash(tile.position) + tile.quality,
)
tile.landmark_name = lname
EventBus.landmark_formed.emit(tile.position, lname, tile.quality)
# =========================================================================
# Pure static helpers — transpilable
# =========================================================================
static func _get_stage(stage_index: int, stages: Array) -> Dictionary:
for entry: Variant in stages:
if entry is Dictionary and entry.get("stage", -1) == stage_index:
return entry
return {}
@ -320,32 +298,27 @@ func _get_stage(stage_index: int) -> Dictionary:
static func _is_water(tile: Variant) -> bool:
if "substrate_id" in tile:
var sub: String = tile.substrate_id
return sub in [
"deep_water", "shallow_water", "lake_bed",
]
return sub in ["deep_water", "shallow_water", "lake_bed"]
return tile.biome_id in ["ocean", "coast"]
static func _climate_match(tile: Variant, biome: Variant) -> float:
## Return climate match multiplier: 1.0 ideal, 0.5 edge, 0.0 outside.
static func _climate_match_flat(tile: Variant, bf: Dictionary) -> float:
## Climate match using pre-resolved flat biome data (no BiomeModel).
var temp: float = tile.temperature
var moist: float = tile.moisture
var tr: Vector2 = biome.temp_range
var mr: Vector2 = biome.moisture_range
var t_min: float = bf.get("temp_min", 0.0)
var t_max: float = bf.get("temp_max", 1.0)
var m_min: float = bf.get("moist_min", 0.0)
var m_max: float = bf.get("moist_max", 1.0)
var temp_ok: bool = temp >= tr.x and temp <= tr.y
var moist_ok: bool = moist >= mr.x and moist <= mr.y
var temp_ok: bool = temp >= t_min and temp <= t_max
var moist_ok: bool = moist >= m_min and moist <= m_max
if temp_ok and moist_ok:
return 1.0
# Edge: within 0.1 of range boundary
var temp_edge: bool = (
temp >= tr.x - 0.1 and temp <= tr.y + 0.1
)
var moist_edge: bool = (
moist >= mr.x - 0.1 and moist <= mr.y + 0.1
)
var temp_edge: bool = temp >= t_min - 0.1 and temp <= t_max + 0.1
var moist_edge: bool = moist >= m_min - 0.1 and moist <= m_max + 0.1
if temp_edge and moist_edge:
return 0.5