From 4d6bb486d962790fc26ebd48fde2eb3dc9b76699 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Thu, 26 Mar 2026 04:32:36 -0700 Subject: [PATCH] =?UTF-8?q?refactor(ecology):=20=E2=99=BB=EF=B8=8F=20Simpl?= =?UTF-8?q?ify=20and=20reorganize=20flora=20and=20fauna=20models=20for=20b?= =?UTF-8?q?etter=20maintainability=20and=20simulation=20logic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../modules/ecology/ecosystem_simplified.gd | 284 +++++++++++++++ .../src/modules/ecology/fauna_simplified.gd | 128 +++++++ engine/src/modules/ecology/flora.gd | 335 ++++++++---------- 3 files changed, 566 insertions(+), 181 deletions(-) create mode 100644 engine/src/modules/ecology/ecosystem_simplified.gd create mode 100644 engine/src/modules/ecology/fauna_simplified.gd diff --git a/engine/src/modules/ecology/ecosystem_simplified.gd b/engine/src/modules/ecology/ecosystem_simplified.gd new file mode 100644 index 00000000..4384aa3f --- /dev/null +++ b/engine/src/modules/ecology/ecosystem_simplified.gd @@ -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"] diff --git a/engine/src/modules/ecology/fauna_simplified.gd b/engine/src/modules/ecology/fauna_simplified.gd new file mode 100644 index 00000000..e9843341 --- /dev/null +++ b/engine/src/modules/ecology/fauna_simplified.gd @@ -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]] diff --git a/engine/src/modules/ecology/flora.gd b/engine/src/modules/ecology/flora.gd index e25f05ff..4e4e6b8b 100644 --- a/engine/src/modules/ecology/flora.gd +++ b/engine/src/modules/ecology/flora.gd @@ -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