magicciv/engine/src/modules/ecology/flora.gd
Claude Code 49e357d033 fix(ecology): 🐛 Refine flora/fauna interaction rules to improve simulation accuracy
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-03-28 21:31:37 -07:00

357 lines
12 KiB
GDScript

# gdlint: disable=max-public-methods
class_name FloraSystem
extends RefCounted
## Per-turn flora dynamics: canopy, undergrowth, fungi, succession,
## 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"
)
const FlavorGeneratorScript = preload(
"res://engine/src/models/world/flavor_generator.gd"
)
# Cached params — loaded once on first process_turn
var _veg: Dictionary = {}
var _suc: Dictionary = {}
var _des: Dictionary = {}
var _params_loaded: bool = false
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: pre-resolve external data, then call transpilable ticks.
if not _params_loaded:
_load_params()
# Collect tiles into flat array for transpiler-friendly iteration
var tiles: Array = game_map.tiles.values()
var o2: float = game_map.o2_fraction if "o2_fraction" in game_map else 0.21
tick_canopy(tiles, _biome_flora, _veg, o2)
tick_undergrowth(tiles, _biome_flora, _veg, o2)
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)
# =========================================================================
# Transpilable tick functions — pure tile array + plain Dictionary params
# No DataLoader, no EventBus, no preload calls inside these.
# =========================================================================
static func tick_canopy(tiles: Array, biome_flora: Dictionary, veg: Dictionary, o2_fraction: float = 0.21) -> void:
## Grow canopy toward biome climax. Decay when outside climate range.
## Requires existing population (dP/dt = r*P) and sufficient atmospheric O2.
var growth_rate: float = veg.get("growth_rate", 0.02)
var decay_rate: float = veg.get("decay_rate", 0.03)
var o2_mult: float = _o2_growth_mult(o2_fraction)
for tile: Variant in tiles:
if BiomeRegistry.has_tag(tile.biome_id, "is_water"):
continue
var bf: Dictionary = biome_flora.get(tile.biome_id, {})
if bf.is_empty():
continue
var climax: float = bf.get("canopy", 0.0)
# Population gate: can't grow from nothing
if tile.canopy_cover <= 0.0:
continue
var match_mult: float = _climate_match_flat(tile, bf)
var q_mult: float = _quality_mult(tile.quality)
if match_mult > 0.0:
var delta: float = growth_rate * match_mult * q_mult * o2_mult
tile.canopy_cover = minf(tile.canopy_cover + delta, climax)
else:
tile.canopy_cover = maxf(tile.canopy_cover - decay_rate, 0.0)
static func tick_undergrowth(tiles: Array, biome_flora: Dictionary, veg: Dictionary, o2_fraction: float = 0.21) -> void:
## Grow undergrowth, capped by canopy shade. Decays faster in drought.
## Requires existing population (dP/dt = r*P) and sufficient atmospheric O2.
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)
var o2_mult: float = _o2_growth_mult(o2_fraction)
for tile: Variant in tiles:
if BiomeRegistry.has_tag(tile.biome_id, "is_water"):
continue
var bf: Dictionary = biome_flora.get(tile.biome_id, {})
if bf.is_empty():
continue
var climax: float = bf.get("undergrowth", 0.0)
# Population gate: can't grow from nothing
if tile.undergrowth <= 0.0:
continue
var match_mult: float = _climate_match_flat(tile, bf)
var q_mult: float = _quality_mult(tile.quality)
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 * o2_mult
tile.undergrowth = minf(tile.undergrowth + delta, effective_cap)
else:
var rate: float = decay_rate
if tile.drought_counter > 0:
rate *= drought_mult
tile.undergrowth = maxf(tile.undergrowth - rate, 0.0)
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 tile: Variant in tiles:
if BiomeRegistry.has_tag(tile.biome_id, "is_water"):
continue
var bf: Dictionary = biome_flora.get(tile.biome_id, {})
if bf.is_empty():
continue
var climax: float = bf.get("fungi", 0.0)
if tile.undergrowth < ug_threshold:
tile.fungi_network = maxf(tile.fungi_network - decay_rate * 0.5, 0.0)
continue
if tile.moisture < 0.15 or tile.temperature < 0.1:
tile.fungi_network = maxf(tile.fungi_network - decay_rate * 0.5, 0.0)
continue
var ug_factor: float = tile.undergrowth
var old_growth: float = 1.0
if (tile.canopy_cover > 0.7
and tile.undergrowth > 0.5
and tile.moisture > 0.4):
old_growth = 1.5
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)
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 tile: Variant in tiles:
if BiomeRegistry.has_tag(tile.biome_id, "is_water"):
continue
if tile.regrowth_stage >= 0:
continue
if tile.canopy_cover >= canopy_threshold:
tile.succession_progress += 1
else:
tile.succession_progress = 0
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 tile: Variant in tiles:
if BiomeRegistry.has_tag(tile.biome_id, "is_water"):
continue
if tile.moisture < moisture_thresh:
tile.drought_counter += 1
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)
else:
tile.drought_counter = maxi(tile.drought_counter - recovery_rate, 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 tile: Variant in tiles:
if tile.regrowth_stage < 0:
continue
tile.regrowth_turns += 1
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)
var fungi_bonus: float = clampf(
1.0 + tile.fungi_network * bonus_cap, 1.0, bonus_cap
)
var effective_turns: int = maxi(
1, roundi(float(base_turns) / fungi_bonus)
)
if tile.regrowth_turns < effective_turns:
continue
var next_stage: int = tile.regrowth_stage + 1
var next_data: Dictionary = _get_stage(next_stage, regrowth_stages)
if next_data.is_empty() or next_stage > 3:
tile.regrowth_stage = -1
tile.regrowth_turns = 0
continue
tile.regrowth_stage = next_stage
tile.regrowth_turns = 0
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)
if next_stage >= 3:
tile.regrowth_stage = -1
tile.regrowth_turns = 0
# =========================================================================
# Non-transpilable post-processing (signals, classifier, naming)
# =========================================================================
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 BiomeRegistry.has_tag(tile.biome_id, "is_water"):
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 {}
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 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 >= 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
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.0
static func _quality_mult(quality: int) -> float:
## Quality growth scaling: Q1=0.6, Q2=0.8, Q3=1.0, Q4=1.2, Q5=1.4
match quality:
1: return 0.6
2: return 0.8
4: return 1.2
5: return 1.4
_: return 1.0
static func _o2_growth_mult(o2: float) -> float:
## Complex plant growth scaling by atmospheric O2.
## Below 2%: no growth. 2-10%: ramp to 0.5. 10-18%: ramp to 1.0. 18%+: full.
if o2 < 0.02:
return 0.0
if o2 < 0.10:
return (o2 - 0.02) / 0.08 * 0.5
if o2 < 0.18:
return 0.5 + (o2 - 0.10) / 0.08 * 0.5
return 1.0