feat(climate): Add ecological event handlers and evaluation utilities for enhanced climate simulation

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-03-25 23:53:21 -07:00
parent a289fd2e65
commit ddf41217ca
7 changed files with 91 additions and 250 deletions

View file

@ -209,7 +209,7 @@ func _step4_humidity(game_map: RefCounted) -> void:
hum += ocean_evap * tile.temperature
else:
hum += soil_rate * tile.moisture
match tile.terrain_id:
match tile.biome_id:
"forest", "enchanted_forest":
hum += forest_et
"jungle":
@ -251,7 +251,7 @@ func _is_windward_of_mountain(tile: Variant, game_map: RefCounted) -> bool:
var downwind_tile: Variant = game_map.get_tile(downwind_pos)
if downwind_tile == null:
return false
return downwind_tile.terrain_id == "mountains" or downwind_tile.elevation > 0.8
return downwind_tile.biome_id == "mountains" or downwind_tile.elevation > 0.8
func _is_leeward_of_mountain(tile: Variant, game_map: RefCounted) -> bool:
@ -261,7 +261,7 @@ func _is_leeward_of_mountain(tile: Variant, game_map: RefCounted) -> bool:
var upwind_tile: Variant = game_map.get_tile(upwind_pos)
if upwind_tile == null:
return false
return upwind_tile.terrain_id == "mountains" or upwind_tile.elevation > 0.8
return upwind_tile.biome_id == "mountains" or upwind_tile.elevation > 0.8
# -----------------------------------------------------------------------

View file

@ -38,8 +38,7 @@ func process_turn(game_map: RefCounted, turn: int = 0, seed: int = 42) -> void:
_update_lake_evaporation(game_map)
_update_deep_earth_water(game_map)
_update_precipitation(game_map)
_check_terrain_evolution(game_map)
_update_corruption(game_map)
# Quality evolution removed — now owned by ecosystem.gd
_tick_ley_residue(game_map)
EcologicalEventsScript.process_events(
game_map, turn, seed, _spec, DataLoader.get_ecological_events()
@ -167,7 +166,7 @@ func _update_lake_evaporation(game_map: RefCounted) -> void:
for axial: Vector2i in game_map.tiles:
var tile: Variant = game_map.tiles[axial]
var tid: String = tile.terrain_id
var tid: String = tile.biome_id
if tid != "lake" and tid != "ocean" and tid != "coast":
continue
@ -192,7 +191,7 @@ func _update_lake_evaporation(game_map: RefCounted) -> void:
var hop_tile: Variant = game_map.tiles.get(hop_pos)
if hop_tile == null:
break
var hop_tid: String = hop_tile.terrain_id
var hop_tid: String = hop_tile.biome_id
# Stop if we hit another water tile (no double-injection)
if hop_tid == "ocean" or hop_tid == "coast" or hop_tid == "lake":
break
@ -225,7 +224,7 @@ func _update_deep_earth_water(game_map: RefCounted) -> void:
for axial: Vector2i in game_map.tiles:
var tile: Variant = game_map.tiles[axial]
if tile.terrain_id == "volcano":
if tile.biome_id == "volcano":
tile.moisture = clampf(tile.moisture + vol_self, 0.0, 1.0)
for nb_pos: Vector2i in HexUtilsScript.get_neighbors(axial):
var nb: Variant = game_map.tiles.get(nb_pos)
@ -301,7 +300,7 @@ func _compute_global_stats(game_map: RefCounted) -> void:
var count: int = 0
for axial: Vector2i in game_map.tiles:
var tile: Variant = game_map.tiles[axial]
if tile.terrain_id != "ocean":
if tile.biome_id != "ocean":
total += tile.temperature
count += 1
global_avg_temp = total / float(count) if count > 0 else 0.5
@ -316,7 +315,7 @@ func _compute_global_stats(game_map: RefCounted) -> void:
var dead_count: int = 0
for axial: Vector2i in game_map.tiles:
var tile: Variant = game_map.tiles[axial]
if tile.terrain_id != "coast":
if tile.biome_id != "coast":
continue
coast_count += 1
if tile.temperature > bleach_thresh:
@ -381,7 +380,7 @@ func _ensure_ocean_dist(game_map: RefCounted) -> void:
# Seed BFS from all water tiles (distance 0)
var queue: Array[Vector2i] = []
for axial: Vector2i in game_map.tiles:
var tid: String = game_map.tiles[axial].terrain_id
var tid: String = game_map.tiles[axial].biome_id
if tid == "ocean" or tid == "coast" or tid == "lake" or tid == "inland_sea":
_ocean_dist_cache[axial] = 0
queue.append(axial)

View file

@ -19,26 +19,23 @@ const LeyNetworkScript: GDScript = preload("res://engine/src/modules/ley/ley_net
# Fallback defaults — used only when climate_params.json absent (logs warning)
const _DEFAULTS: Dictionary = {
"wind_conductivity": 0.1,
"energy_scale": 0.01,
"equilibrium_relaxation": 0.05,
"energy_scale": 0.005,
"equilibrium_relaxation": 0.08,
"evaporation_rate": 0.05,
"moisture_transport": 0.15,
"precipitation_threshold": 0.7,
"moisture_decay": 0.98,
"moisture_relaxation": 0.02,
"ocean_evaporation_hops": 3,
"moisture_decay": 0.995,
"moisture_relaxation": 0.04,
"ocean_evaporation_hops": 4,
"ocean_evaporation_hop_decay": 0.5,
"atmospheric_loss_rate": 0.001,
"atmospheric_loss_rate": 0.0003,
"quality_up_threshold": 10,
"quality_down_threshold": 5,
"corruption_spread_rate": 0.02,
"corruption_flip_threshold": 0.5,
"corruption_decay_rate": 0.004,
"corruption_heal_rate": 0.008,
"corruption_heal_threshold": 0.15,
"lake_thermal_conductivity": 0.05,
"river_moisture_transport": 0.075,
"mountain_rain_shadow_block": 0.9,
"solar_min": 0.15,
"solar_max": 0.70,
}
const _DEW_DEFAULTS: Dictionary = {
@ -66,7 +63,7 @@ var _params: Dictionary = {}
var _spec: Dictionary = {}
var _params_loaded: bool = false
# Per-terrain data cache (albedo, evapotranspiration) keyed by terrain_id string
# Per-terrain data cache (albedo, evapotranspiration) keyed by biome_id string
var _terrain_cache: Dictionary = {}
# Ocean proximity cache: axial → int (hex distance to nearest water tile).
@ -106,7 +103,7 @@ func _update_temperatures(game_map: RefCounted) -> void:
var solar: float = solar_by_row[clampi(offset.y, 0, h - 1)]
var current_temp: float = old_temp[axial]
var terrain_data: Dictionary = _get_terrain_data(tile.terrain_id)
var terrain_data: Dictionary = _get_terrain_data(tile.biome_id)
var albedo: float = terrain_data.get("albedo", 0.4)
var net_solar: float = solar * (1.0 - albedo) * energy_scale
@ -141,7 +138,7 @@ func _update_lake_thermal_effects(game_map: RefCounted) -> void:
for axial: Vector2i in game_map.tiles:
var tile: Variant = game_map.tiles[axial]
if tile.terrain_id != "lake":
if tile.biome_id != "lake":
continue
var lake_temp: float = tile.temperature
for nb_pos: Vector2i in HexUtilsScript.get_neighbors(axial):
@ -184,7 +181,7 @@ func _update_moisture_wind(game_map: RefCounted) -> void:
if old_moisture.has(upwind_pos):
var upwind_tile: Variant = game_map.tiles.get(upwind_pos)
var upwind_is_mountain: bool = (
upwind_tile != null and upwind_tile.terrain_id == "mountains"
upwind_tile != null and upwind_tile.biome_id == "mountains"
)
var block: float = rain_shadow_block if upwind_is_mountain else 0.0
transported = (
@ -192,7 +189,7 @@ func _update_moisture_wind(game_map: RefCounted) -> void:
)
# Evapotranspiration and magic forcing are per-tile local effects — safe to add here
var terrain_data: Dictionary = _get_terrain_data(tile.terrain_id)
var terrain_data: Dictionary = _get_terrain_data(tile.biome_id)
var evapotrans: float = terrain_data.get("evapotranspiration", 0.0)
# Moisture equilibrium relaxation — pull toward climate baseline (like temperature)
@ -223,179 +220,26 @@ func _update_moisture_wind(game_map: RefCounted) -> void:
# -- Step 8: Terrain quality evolution --
func _check_terrain_evolution(game_map: RefCounted) -> void:
var up_thresh: int = _params.get("quality_up_threshold", _DEFAULTS["quality_up_threshold"])
var down_thresh: int = _params.get(
"quality_down_threshold", _DEFAULTS["quality_down_threshold"]
)
for axial: Vector2i in game_map.tiles:
var tile: Variant = game_map.tiles[axial]
var tid: String = tile.terrain_id
# Natural wonders are geological formations — quality evolves but terrain doesn't flip
if tile.is_natural_wonder():
continue
# Water, corrupted, and fixed-form terrains don't evolve via climate
if (
tid == "ocean"
or tid == "coast"
or tid == "lake"
or tid == "corrupted_land"
or tid == "volcano"
):
continue
var ideal: String = _ideal_terrain(tile)
if ideal == tid:
tile.quality_progress += 1
if tile.quality_progress >= up_thresh:
tile.quality_progress = 0
if tile.quality < 5:
var old_q: int = tile.quality
tile.quality += 1
EventBus.quality_changed.emit(tile, old_q, tile.quality)
else:
tile.quality_progress -= 1
if tile.quality_progress <= -down_thresh:
tile.quality_progress = 0
if tile.quality > 1:
var old_q: int = tile.quality
tile.quality -= 1
EventBus.quality_changed.emit(tile, old_q, tile.quality)
else:
# Quality at floor — terrain flips one step along its chain
var old_type: String = tid
tile.terrain_id = ideal
tile.quality = 1
tile.quality_progress = 0
EventBus.terrain_transformed.emit(tile, old_type, ideal)
# -- Step 9: Corruption spreading --
func _update_corruption(game_map: RefCounted) -> void:
var spread_rate: float = _params.get(
"corruption_spread_rate", _DEFAULTS["corruption_spread_rate"]
)
var flip_threshold: float = _params.get(
"corruption_flip_threshold", _DEFAULTS["corruption_flip_threshold"]
)
var decay_rate: float = _params.get("corruption_decay_rate", _DEFAULTS["corruption_decay_rate"])
var heal_rate: float = _params.get("corruption_heal_rate", _DEFAULTS["corruption_heal_rate"])
var heal_threshold: float = _params.get(
"corruption_heal_threshold", _DEFAULTS["corruption_heal_threshold"]
)
# Accumulate pressure increments into a separate dict — one O(n) read pass,
# then one O(n) write pass. Avoids double-counting and in-place mutation.
var pressure_deltas: Dictionary = {} # axial → float
for axial: Vector2i in game_map.tiles:
var tile: Variant = game_map.tiles[axial]
if tile.corruption_pressure <= 0.0:
continue
# terrain_power corruption_spread_modifier on the SOURCE tile scales spread rate.
# e.g. jungle = 0.5x (resists spread), swamp = 1.5x (accelerates spread).
var source_modifier: float = TerrainAffinityScript.get_corruption_spread_modifier(
tile.terrain_id
)
var base_spread: float = spread_rate * source_modifier
for nb_pos: Vector2i in HexUtilsScript.get_neighbors(axial):
if not game_map.tiles.has(nb_pos):
continue
var nb: Variant = game_map.tiles[nb_pos]
if nb.terrain_id == "corrupted_land":
continue
# Ley line channeling: spread rate is modified by the ley properties of the
# receiving tile. Death ley = 3x, generic ley edge = 2x,
# Nature/Life ley = 0.5x (resists). Off-ley = no channeling modifier.
var ley_mult: float = _ley_channeling_mult(nb)
# Moisture resistance: high-moisture terrain (jungle, swamp, water) resists
# corruption biologically. Marine life (fish, reefs, algae) can still carry
# corruption through water — so water isn't immune, just dampened.
var moisture_resist: float = 1.0 - nb.moisture * 0.4
# City protection buildings write corruption_resistance_pct to scale down spread
var corruption_resist: float = 1.0 - clampf(nb.corruption_resistance_pct, 0.0, 1.0)
pressure_deltas[nb_pos] = (
pressure_deltas.get(nb_pos, 0.0)
+ base_spread * ley_mult * moisture_resist * corruption_resist
)
# Apply pressure deltas + natural decay + Life/Nature ley active healing
for axial: Vector2i in game_map.tiles:
var tile: Variant = game_map.tiles[axial]
var incoming: float = pressure_deltas.get(axial, 0.0)
# Natural pressure decay — corruption dissipates without active feeding sources
var drain: float = decay_rate
# Life/Nature ley lines actively heal (bonus drain, capped at 3 stacks)
if tile.ley_line_count > 0 and (tile.ley_school == "life" or tile.ley_school == "nature"):
drain += heal_rate * minf(float(tile.ley_line_count), 3.0)
if incoming == 0.0 and tile.corruption_pressure == 0.0:
continue
tile.corruption_pressure = clampf(tile.corruption_pressure + incoming - drain, 0.0, 1.0)
# Terrain flip: pressure crosses threshold → corrupted_land
# Water tiles (ocean, lake, inland_sea, coast) never flip terrain — corruption
# in water expresses through the marine ecosystem (reef_health, fish_stock) instead.
for axial: Vector2i in game_map.tiles:
var tile: Variant = game_map.tiles[axial]
var tid: String = tile.terrain_id
if (
tid == "corrupted_land"
or tid == "ocean"
or tid == "lake"
or tid == "inland_sea"
or tid == "coast"
):
continue
if tile.corruption_pressure > flip_threshold:
if tile.original_terrain_id == "":
tile.original_terrain_id = tile.terrain_id
tile.terrain_id = "corrupted_land"
EventBus.corruption_spread.emit(tile)
# Marine corruption: coast tiles with elevated pressure degrade reef and fish stock
# (corrupted fish and algae carry corruption through the marine ecosystem)
for axial: Vector2i in game_map.tiles:
var tile: Variant = game_map.tiles[axial]
if tile.terrain_id != "coast":
continue
if tile.corruption_pressure > 0.2:
tile.reef_health = maxf(0.0, tile.reef_health - tile.corruption_pressure * 0.015)
if tile.fish_stock > 0:
tile.fish_stock = maxf(0.0, tile.fish_stock - tile.corruption_pressure * 0.01)
# Terrain healing: corrupted tile's pressure falls below heal threshold → restore
for axial: Vector2i in game_map.tiles:
var tile: Variant = game_map.tiles[axial]
if tile.terrain_id != "corrupted_land" or tile.corruption_pressure > heal_threshold:
continue
var original: String = tile.original_terrain_id
var restore_to: String = original if original != "" else "grassland"
tile.terrain_id = restore_to
tile.original_terrain_id = ""
EventBus.corruption_healed.emit(tile)
# -- Internal helpers --
func _get_terrain_data(terrain_id: String) -> Dictionary:
if not _terrain_cache.has(terrain_id):
_terrain_cache[terrain_id] = DataLoader.get_terrain(terrain_id)
return _terrain_cache[terrain_id]
func _get_terrain_data(biome_id: String) -> Dictionary:
if not _terrain_cache.has(biome_id):
_terrain_cache[biome_id] = DataLoader.get_terrain(biome_id)
return _terrain_cache[biome_id]
func _solar_by_latitude(row: int, center_row: float) -> float:
## Solar input at a given map row: 1.0 at equator (center), 0.0 at poles.
return 1.0 - absf((float(row) - center_row) / center_row)
## Solar input at a given map row, clamped to habitable range.
## Raw latitude factor: 1.0 at equator, 0.0 at poles.
## Mapped via solar_min/solar_max params to produce stable equilibrium
## temperatures that support reef survival and biome diversity.
var raw: float = 1.0 - absf((float(row) - center_row) / center_row)
var solar_min: float = _params.get("solar_min", _DEFAULTS.get("solar_min", 0.15))
var solar_max: float = _params.get("solar_max", _DEFAULTS.get("solar_max", 0.70))
return solar_min + (solar_max - solar_min) * raw
func _upwind_pos(axial: Vector2i, wind_dir: int) -> Vector2i:

View file

@ -4,20 +4,21 @@ extends RefCounted
## Pure logic — no side effects, no state mutation. Used by Climate and the transpiler.
const MapGeneratorScript: GDScript = preload("res://engine/src/generation/map_generator.gd")
const BiomeClassifierScript = preload("res://engine/src/models/world/biome_classifier.gd")
static func ideal_terrain(tile: Variant, spec: Dictionary) -> String:
## Determine the climate-ideal terrain type for a tile. Reads transition rules
## from climate_spec.json terrain_transitions. If a terrain has no spec entry,
## falls back to classify_terrain.
var tid: String = tile.terrain_id
## falls back to BiomeClassifier.classify().
var tid: String = tile.biome_id
var temp: float = tile.temperature
var moist: float = tile.moisture
var transitions: Dictionary = spec.get("terrain_transitions", {})
var rules: Array = transitions.get(tid, [])
if rules.is_empty():
return MapGeneratorScript.classify_terrain(temp, moist, tile.elevation)
return BiomeClassifierScript.classify(tile)
for rule: Variant in rules:
if not rule is Dictionary:
@ -25,7 +26,7 @@ static func ideal_terrain(tile: Variant, spec: Dictionary) -> String:
if eval_condition(rule.get("condition", ""), temp, moist, tile.elevation):
var becomes: String = rule.get("becomes", "")
if becomes == "classify":
return MapGeneratorScript.classify_terrain(temp, moist, tile.elevation)
return BiomeClassifierScript.classify(tile)
return becomes
return tid

View file

@ -27,7 +27,7 @@ static func process_volcanic(
var center: Variant = EcoUtils.pick_land(game_map, w, h, turn_seed, channel)
if center == null or center.is_natural_wonder():
return
center.terrain_id = "volcano"
center.biome_id = "volcano"
center.quality = 1
center.quality_progress = 0
@ -41,8 +41,8 @@ static func process_volcanic(
var t: Variant = game_map.tiles[axial]
if t == center or t.is_natural_wonder():
continue
if t.terrain_id != "ocean" and t.terrain_id != "coast":
t.terrain_id = scorched_terrain
if t.biome_id != "ocean" and t.biome_id != "coast":
t.biome_id = scorched_terrain
t.moisture = maxf(0.0, t.moisture - moisture_loss)
t.quality = 1
scorched += 1
@ -105,13 +105,12 @@ static func process_impact(
var t: Variant = game_map.tiles[naxial]
if t.is_natural_wonder():
continue
if t.terrain_id != "ocean" and t.terrain_id != "coast":
t.terrain_id = "desert"
if t.biome_id != "ocean" and t.biome_id != "coast":
t.biome_id = "desert"
t.elevation = maxf(0.0, t.elevation - elev_loss)
t.moisture = 0.0
t.quality = 1
t.resource_id = ""
t.corruption_pressure = 1.0
# Phase 2: Global aerosol
for naxial: Vector2i in game_map.tiles:
game_map.tiles[naxial].sulfate_aerosol += aerosol_strength
@ -120,18 +119,18 @@ static func process_impact(
var t: Variant = game_map.tiles[naxial]
if t.is_natural_wonder():
continue
if t.terrain_id in living_terrains:
if t.biome_id in living_terrains:
t.quality = maxi(1, t.quality - biome_kill_quality)
if t.terrain_id == "jungle":
t.terrain_id = "grassland"
if t.biome_id == "jungle":
t.biome_id = "grassland"
t.moisture = maxf(0.0, t.moisture - 0.15)
elif t.terrain_id == "enchanted_forest":
t.terrain_id = "forest"
elif t.terrain_id == "swamp":
t.terrain_id = "desert"
elif t.biome_id == "enchanted_forest":
t.biome_id = "forest"
elif t.biome_id == "swamp":
t.biome_id = "desert"
t.moisture = 0.0
# Phase 4: Impact site becomes mana node
center.terrain_id = "mana_node"
center.biome_id = "mana_node"
center.quality = 5
EcoUtils.set_wonder_anchor(center, tier_cfg, 5, ["death", "chaos"])
EcoUtils.spawn_resource(center, tier_cfg)
@ -159,13 +158,13 @@ static func process_impact(
if elev_delta > 0.0:
# Raises terrain (e.g. mountain ridge)
var crater_terrain: String = tier_cfg.get("crater_terrain", "mountains")
center.terrain_id = crater_terrain
center.biome_id = crater_terrain
center.elevation = minf(1.0, center.elevation + elev_delta)
center.moisture = maxf(0.0, center.moisture - 0.1)
center.quality = 3
else:
# Depresses terrain (crater lake or barren)
center.terrain_id = "lake" if center.elevation < 0.15 else "desert"
center.biome_id = "lake" if center.elevation < 0.15 else "desert"
center.elevation = maxf(0.0, center.elevation + elev_delta)
center.quality = 1
@ -194,7 +193,7 @@ static func process_impact(
var t: Variant = game_map.tiles[naxial]
if t.is_natural_wonder() or t.resource_id != "":
continue
if t.terrain_id == "ocean" or t.terrain_id == "coast":
if t.biome_id == "ocean" or t.biome_id == "coast":
continue
var tile_roll: float = EcoUtils.hash_noise(
float(scatter_idx), float(naxial.x + naxial.y), turn_seed + 7.0
@ -282,7 +281,7 @@ static func process_wildfire(
var forest_types: Array = cat_cfg.get(
"target_terrain", ["forest", "jungle", "boreal_forest", "enchanted_forest"]
)
if not (center.terrain_id in forest_types):
if not (center.biome_id in forest_types):
return
var radius: int = tier_cfg.get("radius", 1)
@ -296,9 +295,9 @@ static func process_wildfire(
var t: Variant = game_map.tiles[axial]
if t.is_natural_wonder():
continue
if t.terrain_id in forest_types:
if t.biome_id in forest_types:
if becomes != null and str(becomes) != "null":
t.terrain_id = str(becomes)
t.biome_id = str(becomes)
if quality_loss > 0:
t.quality = maxi(1, t.quality - quality_loss)
t.quality_progress = 0
@ -343,7 +342,7 @@ static func process_drought(
if not game_map.tiles.has(axial):
continue
var t: Variant = game_map.tiles[axial]
if t.terrain_id != "ocean" and t.terrain_id != "coast":
if t.biome_id != "ocean" and t.biome_id != "coast":
t.moisture = maxf(0.0, t.moisture - moisture_loss)
var tier_name: String = tier_cfg.get("name", "Drought")
@ -373,7 +372,7 @@ static func process_plague(
var target_terrain: Array = tier_cfg.get(
"target_terrain", ["grassland", "plains", "forest", "jungle", "enchanted_forest"]
)
if not (center.terrain_id in target_terrain):
if not (center.biome_id in target_terrain):
return
var radius: int = tier_cfg.get("radius", 2)
@ -386,11 +385,11 @@ static func process_plague(
var t: Variant = game_map.tiles[axial]
if t.is_natural_wonder():
continue
if not (t.terrain_id in target_terrain):
if not (t.biome_id in target_terrain):
continue
t.quality = maxi(1, t.quality - quality_loss)
if terrain_downgrade.has(t.terrain_id):
t.terrain_id = str(terrain_downgrade[t.terrain_id])
if terrain_downgrade.has(t.biome_id):
t.biome_id = str(terrain_downgrade[t.biome_id])
affected += 1
if affected == 0:

View file

@ -56,7 +56,7 @@ static func _apply_magical_positive(
) -> void:
var center_terrain: Variant = tier_cfg.get("center_terrain", null)
if center_terrain != null and str(center_terrain) != "null":
center.terrain_id = str(center_terrain)
center.biome_id = str(center_terrain)
var center_quality: int = tier_cfg.get("center_quality", 3)
center.quality = center_quality
@ -70,13 +70,13 @@ static func _apply_magical_positive(
var t: Variant = game_map.tiles[axial]
if t == center or t.is_natural_wonder():
continue
if t.terrain_id == "ocean" or t.terrain_id == "coast":
if t.biome_id == "ocean" or t.biome_id == "coast":
continue
if moisture_gain > 0.0:
t.moisture = minf(1.0, t.moisture + moisture_gain)
if grassland_upgrade != null and str(grassland_upgrade) != "null":
if t.terrain_id == "grassland" or t.terrain_id == "plains":
t.terrain_id = str(grassland_upgrade)
if t.biome_id == "grassland" or t.biome_id == "plains":
t.biome_id = str(grassland_upgrade)
# Location-based school selection for wellspring
var schools: Array[String] = []
@ -122,26 +122,22 @@ static func _apply_magical_negative(
) -> void:
var center_terrain: Variant = tier_cfg.get("center_terrain", null)
if center_terrain != null and str(center_terrain) != "null":
center.terrain_id = str(center_terrain)
center.biome_id = str(center_terrain)
var center_quality: int = tier_cfg.get("center_quality", 1)
center.quality = center_quality
var radius: int = tier_cfg.get("radius", 0)
var corruption_gain: float = tier_cfg.get("corruption_gain", 0.0)
var heat_delta: float = tier_cfg.get("heat_delta", 0.0)
if radius > 0:
if radius > 0 and heat_delta != 0.0:
for axial: Vector2i in EcoUtils.tiles_in_radius(center, radius):
if not game_map.tiles.has(axial):
continue
var t: Variant = game_map.tiles[axial]
if t == center or t.is_natural_wonder():
continue
if t.terrain_id == "ocean" or t.terrain_id == "coast":
if t.biome_id == "ocean" or t.biome_id == "coast":
continue
if corruption_gain > 0.0:
t.corruption_pressure = minf(1.0, t.corruption_pressure + corruption_gain)
if heat_delta != 0.0:
t.magic_heat_delta += heat_delta
t.magic_heat_delta += heat_delta
var raw_schools: Array = tier_cfg.get("anchor_schools", [])
var default_schools: Array = []
@ -156,7 +152,7 @@ static func _apply_magical_negative(
turn,
event_type,
center,
"%s — T%d anchor, corruption spreads" % [tier_name, center.wonder_tier]
"%s — T%d anchor" % [tier_name, center.wonder_tier]
)
)
@ -179,7 +175,7 @@ static func process_marine(
if not game_map.tiles.has(axial):
return
var center: Variant = game_map.tiles[axial]
if center.terrain_id != "coast" and center.terrain_id != "ocean":
if center.biome_id != "coast" and center.biome_id != "ocean":
return
var radius: int = tier_cfg.get("radius", 2)
@ -192,19 +188,16 @@ static func process_marine(
var fish_stock_loss: int = tier_cfg.get("fish_stock_loss", 0)
var fish_stock_kill: bool = tier_cfg.get("fish_stock_kill", false)
var moisture_gain: float = tier_cfg.get("moisture_gain", 0.0)
var coast_corruption: float = tier_cfg.get("coast_corruption", 0.0)
for naxial: Vector2i in EcoUtils.tiles_in_radius(center, radius):
if not game_map.tiles.has(naxial):
continue
var t: Variant = game_map.tiles[naxial]
if t.terrain_id == "coast":
if t.biome_id == "coast":
if reef_health_gain > 0.0:
t.reef_health = minf(1.0, t.reef_health + reef_health_gain)
if reef_health_loss > 0.0:
t.reef_health = maxf(0.0, t.reef_health - reef_health_loss)
if coast_corruption > 0.0:
t.corruption_pressure = minf(1.0, t.corruption_pressure + coast_corruption)
if t.fish_stock >= 0:
if fish_stock_gain > 0:
t.fish_stock = mini(100, t.fish_stock + fish_stock_gain)
@ -212,7 +205,7 @@ static func process_marine(
t.fish_stock = maxi(0, t.fish_stock - fish_stock_loss)
elif fish_stock_kill:
t.fish_stock = 0
elif t.terrain_id == "ocean":
elif t.biome_id == "ocean":
if moisture_gain > 0.0:
t.moisture = minf(1.0, t.moisture + moisture_gain)
if t.fish_stock >= 0:
@ -222,7 +215,7 @@ static func process_marine(
t.fish_stock = maxi(0, t.fish_stock - fish_stock_loss)
elif fish_stock_kill:
t.fish_stock = 0
elif moisture_gain > 0.0 and t.terrain_id != "ocean":
elif moisture_gain > 0.0 and t.biome_id != "ocean":
t.moisture = minf(1.0, t.moisture + moisture_gain)
var polarity: String = "enriches" if positive else "harms"
@ -295,7 +288,7 @@ static func process_glacial(
if warm_delta != 0.0:
t.magic_heat_delta += warm_delta
t.glacial_forcing += warm_delta
if moisture_surge > 0.0 and t.terrain_id == "coast":
if moisture_surge > 0.0 and t.biome_id == "coast":
t.moisture = minf(1.0, t.moisture + moisture_surge)
var coastal_reach: int = tier_cfg.get("coastal_flood_reach", 2)
events.append(
@ -341,8 +334,8 @@ static func process_glacial(
for edge: int in t.river_edges:
if t.river_flow.has(edge):
t.river_flow[edge] = -abs(t.river_flow[edge])
if tundra_expansion and (t.terrain_id == "grassland" or t.terrain_id == "plains"):
t.terrain_id = "tundra"
if tundra_expansion and (t.biome_id == "grassland" or t.biome_id == "plains"):
t.biome_id = "tundra"
if quality_loss > 0:
t.quality = maxi(1, t.quality - quality_loss)
if moisture_loss > 0.0:
@ -377,7 +370,7 @@ static func process_tsunami(
if not game_map.tiles.has(axial):
return
var coast_tile: Variant = game_map.tiles[axial]
if coast_tile.terrain_id != "coast":
if coast_tile.biome_id != "coast":
return
var inland_reach: int = tier_cfg.get("inland_reach", 1)
@ -392,7 +385,7 @@ static func process_tsunami(
if not game_map.tiles.has(naxial):
continue
var t: Variant = game_map.tiles[naxial]
if t.terrain_id == "ocean" or t.terrain_id == "coast":
if t.biome_id == "ocean" or t.biome_id == "coast":
if reef_destruction:
t.reef_health = maxf(0.0, t.reef_health - 0.5)
continue
@ -449,6 +442,11 @@ static func process_pandemic(
)
if lair_roll < lair_kill_chance:
t.lair_type = ""
# Convert NPC building to ruin
for b: Variant in GameState.get_npc_buildings_at(axial):
if b.type_id != "village" and b.type_id != "ruin":
b.type_id = "ruin"
b.name = "Ruin"
killed_lairs += 1
if fish_stock_loss > 0 and t.fish_stock >= 0:
t.fish_stock = maxi(0, t.fish_stock - fish_stock_loss)

View file

@ -53,7 +53,7 @@ static func pick_land(
if not game_map.tiles.has(axial):
return null
var tile: Variant = game_map.tiles[axial]
if tile.terrain_id == "ocean" or tile.terrain_id == "coast":
if tile.biome_id == "ocean" or tile.biome_id == "coast":
return null
return tile
@ -68,7 +68,7 @@ static func spawn_resource(tile: Variant, cfg: Dictionary) -> void:
## Handles two config shapes:
## spawns_resource + resource_terrain → always spawn that resource
## resource_table [{ resource, weight, resource_terrain }] → weighted random pick
## resource_terrain: if non-null, the tile's terrain_id is set to that value first.
## resource_terrain: if non-null, the tile's biome_id is set to that value first.
var table: Array = cfg.get("resource_table", [])
if not table.is_empty():
spawn_resource_weighted(tile, table)
@ -79,7 +79,7 @@ static func spawn_resource(tile: Variant, cfg: Dictionary) -> void:
return
var resource_terrain: Variant = cfg.get("resource_terrain", null)
if resource_terrain != null and str(resource_terrain) != "null":
tile.terrain_id = str(resource_terrain)
tile.biome_id = str(resource_terrain)
tile.resource_id = resource_id
@ -102,7 +102,7 @@ static func spawn_resource_weighted(tile: Variant, table: Array) -> void:
var resource_id: String = str(entry.get("resource", ""))
var resource_terrain: Variant = entry.get("resource_terrain", null)
if resource_terrain != null and str(resource_terrain) != "null":
tile.terrain_id = str(resource_terrain)
tile.biome_id = str(resource_terrain)
if resource_id != "":
tile.resource_id = resource_id
return