feat(climate): Introduce climate modeling logic, base utilities, and evaluation specifications in climate.gd, climate_base.gd, and climate_spec_eval.gd

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-03-31 04:28:00 -07:00
parent d18bacda78
commit 13a1e659d0
3 changed files with 168 additions and 888 deletions

View file

@ -1,399 +1,173 @@
class_name Climate
extends "res://engine/src/modules/climate/climate_base.gd"
## Per-tile climate physics simulation. Called once per turn by TurnManager.
extends RefCounted
## Per-tile climate physics — thin wrapper delegating to Rust GDExtension (GdClimatePhysics).
##
## Processing order each turn:
## 1. Collect magic forcing from active spells (hook; weather.gd writes deltas)
## 2. Update temperatures — double-buffered (reads old, writes new, swaps)
## 3. Lake thermal moderation on adjacent tiles
## 4. Wind moisture transport — double-buffered decay + transport + relaxation + atmospheric loss
## 5. River moisture transport (gravity along river_edges, downhill only)
## 6. Lake/ocean/coast evaporation into neighbours (multi-hop for ocean/coast)
## 6b. Deep Earth Water injection (volcanoes, hot springs, high elevation)
## 7. Precipitation (clamp excess moisture to threshold)
## 8. Terrain quality evolution (classify_terrain + quality ladder)
## 9. Corruption spreading and terrain flip
## 9b. Ley residue decay
## 9c. Ecological events (seeded stochastic perturbations)
## 9d. Anchor decay (event-created anchors fade over time)
## 10. Compute global stats (avg temp for UI phase label)
## 11. Clear transient magic deltas
##
## Double-buffering: temperature and moisture wind use read-old/write-new
## dictionaries to avoid order-dependent bugs when reading neighbour state.
## The heavy simulation (temperature, moisture, wind, precipitation, terrain evolution,
## aerosol forcing, orbital cycles, reef health, global stats) runs in native Rust.
## This GDScript class handles Godot-side orchestration: loading params from DataLoader,
## calling the Rust engine, and running subsystems that remain in GDScript (ley network,
## ecological events via EventBus, magic delta clearing).
const EcologicalEventsScript: GDScript = preload(
"res://engine/src/modules/climate/ecological_events.gd"
)
const AnchorDecayScript: GDScript = preload("res://engine/src/modules/climate/anchor_decay.gd")
const LeyResidueScript: GDScript = preload("res://engine/src/modules/ley/ley_residue.gd")
const LeyNetworkScript: GDScript = preload("res://engine/src/modules/ley/ley_network.gd")
## Global average temperature — updated each turn from Rust grid state, readable by UI.
var global_avg_temp: float = 0.5
## Fraction of coast tiles permanently dead from marine extinction [0.0, 1.0].
## Set by TurnManager from marine_harvest.ocean_dead_fraction before process_turn().
var ocean_dead_fraction: float = 0.0
var _rust: GdClimatePhysics
var _grid: GdGridState
var _spec_json: String = ""
var _spec: Dictionary = {}
var _params_loaded: bool = false
## Physics capability flags from world manifest (physics_features block).
var _physics_flags: Dictionary = {
"water_cycle": true,
"weather_events": true,
"ecology": true,
"volcanism": true,
}
func _ensure_rust() -> void:
if _params_loaded:
return
var params: Dictionary = DataLoader.get_climate_params()
if params.is_empty():
push_warning("Climate: climate_params.json missing — using built-in defaults")
_spec = DataLoader.get_climate_spec()
if _spec.is_empty():
push_warning("Climate: climate_spec.json missing — terrain transitions use built-in defaults")
var world_flags: Dictionary = DataLoader.get_physics_features()
if not world_flags.is_empty():
for key: String in world_flags:
_physics_flags[key] = world_flags[key]
# Collect terrain data for the Rust engine's terrain cache
var terrain_array: Array = DataLoader.get_all_terrains()
var params_json: String = JSON.stringify(params)
var terrain_json: String = JSON.stringify(terrain_array)
_spec_json = JSON.stringify(_spec)
_rust = GdClimatePhysics.new()
_rust.initialize(params_json, terrain_json, _spec_json)
_params_loaded = true
func process_turn(game_map: RefCounted, turn: int = 0, seed: int = 42) -> void:
## Run the full climate simulation step for one game turn.
_seed = seed
_ensure_params()
_ensure_ocean_dist(game_map)
_ensure_rust()
_collect_magic_forcing(game_map)
_apply_orbital_forcing(game_map, turn)
_apply_aerosol_forcing(game_map)
_update_temperatures(game_map)
_update_lake_thermal_effects(game_map)
if _physics_flags.get("water_cycle", true):
_update_moisture_wind(game_map)
_update_moisture_rivers(game_map)
_update_lake_evaporation(game_map)
if _physics_flags.get("volcanism", true):
_update_deep_earth_water(game_map)
_update_precipitation(game_map)
_update_surface_runoff(game_map)
_check_terrain_evolution(game_map)
_tick_ley_residue(game_map)
# Sync game_map tiles into a GdGridState for the Rust engine.
var w: int = game_map.width
var h: int = game_map.height
if _grid == null or _grid.get_width() != w or _grid.get_height() != h:
_grid = GdGridState.create(w, h)
_sync_tiles_to_grid(game_map)
_grid.set_ocean_dead_fraction(ocean_dead_fraction)
# Run the core physics in Rust
_rust.process_step(_grid, turn, seed)
# Sync results back to GDScript game_map tiles
_sync_grid_to_tiles(game_map)
# Update global stats from Rust output
global_avg_temp = _grid.get_global_avg_temp()
# GDScript-side subsystems that depend on Godot autoloads
LeyResidueScript.tick_residue(game_map)
if _physics_flags.get("ecology", true):
EcologicalEventsScript.process_events(
game_map, turn, seed, _spec, DataLoader.get_ecological_events(),
GameState.get_max_event_tier()
)
AnchorDecayScript.process_decay(game_map, _spec)
_update_ley_network(game_map)
_compute_global_stats(game_map)
_clear_magic_deltas(game_map)
# -- Step 1: Magic forcing hook --
func _collect_magic_forcing(_game_map: RefCounted) -> void:
# magic_heat_delta and magic_moisture_delta are written directly by weather.gd
# each turn before process_turn() is called. This step is a pre-processing hook.
pass
# -- Step 1a: Orbital forcing (Milankovitch-like cycles) --
func _apply_orbital_forcing(game_map: RefCounted, turn: int) -> void:
## Superimpose deterministic orbital cycles onto the heat budget.
## Three overlapping sinusoidal cycles model obliquity, precession,
## and eccentricity at game timescale (1 turn = 10 years).
var t: float = float(turn)
var total_delta: float = 0.0
for i: int in range(1, 4):
var prefix: String = "orbital_cycle_%d_" % i
var period: float = _params.get(prefix + "period", 0.0)
if period <= 0.0:
continue
var amplitude: float = _params.get(prefix + "amplitude", 0.0)
var phase: float = _params.get(prefix + "phase", 0.0)
total_delta += amplitude * sin(TAU * (t / period) + phase)
if absf(total_delta) < 0.0001:
return
# Warm cycles increase evaporation → more moisture. The coupling factor
# controls how strongly temperature oscillation drives moisture oscillation.
var moisture_coupling: float = _params.get("orbital_moisture_coupling", 0.5)
for axial: Vector2i in game_map.tiles:
game_map.tiles[axial].magic_heat_delta += total_delta
game_map.tiles[axial].magic_moisture_delta += total_delta * moisture_coupling
# -- Step 1b: Aerosol forcing (persistent stratospheric sulfate from volcanic/impact events) --
func _apply_aerosol_forcing(game_map: RefCounted) -> void:
## Phase 0: Natural aerosol generation — desert dust, volcanic outgassing, baseline.
## Phase 1: apply cooling and drying to magic deltas (solar blocking + evaporation loss).
## Phase 2: transport aerosol downwind (double-buffered snapshot).
## Phase 3: decay aerosol each turn.
var aerosol_cfg: Dictionary = _spec.get("aerosol", {})
if aerosol_cfg.is_empty():
return
# Phase 0: Natural sources inject small aerosol each turn
var bg: float = _params.get("aerosol_background", 0.002)
var desert_rate: float = _params.get("aerosol_desert_dust", 0.005)
var volcano_rate: float = _params.get("aerosol_volcanic_outgas", 0.015)
for axial: Vector2i in game_map.tiles:
var tile: Variant = game_map.tiles[axial]
var inject: float = bg
if BiomeRegistry.has_tag(tile.biome_id, "is_dry"):
inject += desert_rate
elif BiomeRegistry.has_tag(tile.biome_id, "is_volcanic"):
inject += volcano_rate
tile.sulfate_aerosol = maxf(0.0, tile.get("sulfate_aerosol", 0.0) + inject)
# Short-circuit: skip processing if no aerosol exists anywhere
var any_aerosol: bool = false
for axial: Vector2i in game_map.tiles:
if (game_map.tiles[axial] as Object).get("sulfate_aerosol") > 0.1:
any_aerosol = true
break
if not any_aerosol:
return
var cooling_rate: float = aerosol_cfg.get("cooling_rate", 0.06)
var drying_rate: float = aerosol_cfg.get("drying_rate", 0.03)
var transport_rate: float = aerosol_cfg.get("wind_transport_rate", 0.12)
var decay_rate: float = aerosol_cfg.get("decay_rate", 0.05)
# Phase 1: Apply solar-blocking cooling and drying to magic deltas.
# Effect is capped at 100% solar blocking (aerosol > 1.0 extends duration, not intensity).
# City protection buildings write tile.aerosol_mitigation to scale down the effect.
for axial: Vector2i in game_map.tiles:
var tile: Variant = game_map.tiles[axial]
if tile.sulfate_aerosol <= 0.0:
continue
var effective: float = minf(1.0, tile.sulfate_aerosol)
var mitigation: float = tile.aerosol_mitigation
if mitigation > 0.0:
effective *= (1.0 - clampf(mitigation, 0.0, 1.0))
tile.magic_heat_delta -= effective * cooling_rate
tile.magic_moisture_delta -= effective * drying_rate
# Phase 2: Wind transport — double-buffered snapshot to avoid order-dependency.
# No cap on destination — aerosol stacks above 1.0 (more particulates = longer winter).
var snapshot: Dictionary = {}
for axial: Vector2i in game_map.tiles:
var tile: Variant = game_map.tiles[axial]
if tile.sulfate_aerosol > 0.1:
snapshot[axial] = tile.sulfate_aerosol
for axial: Vector2i in snapshot:
var tile: Variant = game_map.tiles[axial]
var transported: float = snapshot[axial] * transport_rate
var downwind: Vector2i = _downwind_pos(axial, tile.wind_direction)
if game_map.tiles.has(downwind):
game_map.tiles[downwind].sulfate_aerosol += transported
tile.sulfate_aerosol = maxf(0.0, tile.sulfate_aerosol - transported)
# Phase 3: Exponential decay
for axial: Vector2i in game_map.tiles:
var tile: Variant = game_map.tiles[axial]
if tile.sulfate_aerosol > 0.0:
tile.sulfate_aerosol = maxf(0.0, tile.sulfate_aerosol - decay_rate)
# -- Step 5: River moisture transport --
func _update_moisture_rivers(game_map: RefCounted) -> void:
# River transport is inherently sequential (water flows from source to mouth),
# so in-place update is correct — each river tile drains to its lower neighbour.
var transport_rate: float = _params.get(
"river_moisture_transport", _DEFAULTS["river_moisture_transport"]
)
for axial: Vector2i in game_map.tiles:
var tile: Variant = game_map.tiles[axial]
if tile.river_edges.is_empty():
continue
for edge: int in tile.river_edges:
var nb: Variant = game_map.tiles.get(axial + HexUtilsScript.AXIAL_DIRECTIONS[edge])
if nb == null or tile.elevation <= nb.elevation:
continue
var amount: float = tile.moisture * transport_rate
tile.moisture = clampf(tile.moisture - amount, 0.0, 1.0)
nb.moisture = clampf(nb.moisture + amount, 0.0, 1.0)
# -- Step 6: Lake/ocean evaporation --
func _update_lake_evaporation(game_map: RefCounted) -> void:
var base_evap: float = _params.get("evaporation_rate", _DEFAULTS["evaporation_rate"])
var max_hops: int = int(
_params.get("ocean_evaporation_hops", _DEFAULTS["ocean_evaporation_hops"])
)
var hop_decay: float = _params.get(
"ocean_evaporation_hop_decay", _DEFAULTS["ocean_evaporation_hop_decay"]
)
var rain_shadow_block: float = _params.get(
"mountain_rain_shadow_block", _DEFAULTS["mountain_rain_shadow_block"]
)
# Biological pump failure: dead ocean reduces evaporation → inland forests dry out.
# Formula from OCEAN.md: evap *= (1 - (fraction - 0.25) * 2) when fraction > 0.25.
var evap_rate: float = base_evap
if ocean_dead_fraction > 0.25:
evap_rate = base_evap * maxf(0.0, 1.0 - (ocean_dead_fraction - 0.25) * 2.0)
for axial: Vector2i in game_map.tiles:
var tile: Variant = game_map.tiles[axial]
var tid: String = tile.biome_id
if not BiomeRegistry.has_tag(tid, "is_water"):
continue
# Only evaporate if liquid water is actually present
if tile.surface_water <= 0.0:
continue
var evap: float = tile.temperature * evap_rate * minf(1.0, tile.surface_water)
if tid == "lake":
# Lakes inject to all 6 neighbours equally
var share: float = evap / 6.0
for nb_pos: Vector2i in HexUtilsScript.get_neighbors(axial):
var nb: Variant = game_map.tiles.get(nb_pos)
if nb != null:
nb.moisture = clampf(nb.moisture + share, 0.0, 1.0)
else:
# Ocean/coast: inject along downwind chain (multi-hop with decay).
# Hop 1 = full strength, hop 2 = hop_decay, hop 3 = hop_decay^2, etc.
# Stop at mountains (rain shadow), water tiles (no double-injection), or map edge.
var hop_pos: Vector2i = axial
var hop_strength: float = 1.0
var wind_dir: int = tile.wind_direction
for _hop_i: int in max_hops:
hop_pos = hop_pos + HexUtilsScript.AXIAL_DIRECTIONS[wind_dir]
var hop_tile: Variant = game_map.tiles.get(hop_pos)
if hop_tile == null:
break
var hop_tid: String = hop_tile.biome_id
# Stop if we hit another water tile (no double-injection)
if BiomeRegistry.has_tag(hop_tid, "is_water"):
break
# Mountains block most moisture (rain shadow)
if BiomeRegistry.has_tag(hop_tid, "is_elevated"):
hop_strength *= (1.0 - rain_shadow_block)
hop_tile.moisture = clampf(hop_tile.moisture + evap * hop_strength, 0.0, 1.0)
hop_strength *= hop_decay
# Follow the hop tile's own wind direction for subsequent hops
wind_dir = hop_tile.wind_direction
# -- Step 6b: Deep Earth Water (volcanic outgassing) --
func _update_deep_earth_water(game_map: RefCounted) -> void:
## Mantle degassing: volcanoes, hot springs, and high-elevation tiles inject moisture.
## Models ringwoodite water release through the crust — the mechanism that maintains
## Earth's ocean volume over geological time despite atmospheric hydrogen escape.
var dew: Dictionary = _params.get("deep_earth_water", _DEW_DEFAULTS)
var vol_self: float = dew.get("volcano_self", _DEW_DEFAULTS["volcano_self"])
var vol_nb: float = dew.get("volcano_neighbor", _DEW_DEFAULTS["volcano_neighbor"])
var hs_self: float = dew.get("hot_spring_self", _DEW_DEFAULTS["hot_spring_self"])
var hs_nb: float = dew.get("hot_spring_neighbor", _DEW_DEFAULTS["hot_spring_neighbor"])
var hs_max_temp: float = dew.get("hot_spring_max_temperature", _DEW_DEFAULTS["hot_spring_max_temperature"])
var elev_self: float = dew.get("high_elevation_self", _DEW_DEFAULTS["high_elevation_self"])
var elev_thresh: float = dew.get(
"high_elevation_threshold", _DEW_DEFAULTS["high_elevation_threshold"]
)
for axial: Vector2i in game_map.tiles:
var tile: Variant = game_map.tiles[axial]
if BiomeRegistry.has_tag(tile.biome_id, "is_volcanic"):
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)
if nb != null:
nb.moisture = clampf(nb.moisture + vol_nb, 0.0, 1.0)
elif tile.river_source_type == "hot_spring" and tile.temperature <= hs_max_temp:
tile.moisture = clampf(tile.moisture + hs_self, 0.0, 1.0)
for nb_pos: Vector2i in HexUtilsScript.get_neighbors(axial):
var nb: Variant = game_map.tiles.get(nb_pos)
if nb != null:
nb.moisture = clampf(nb.moisture + hs_nb, 0.0, 1.0)
elif tile.elevation >= elev_thresh:
tile.moisture = clampf(tile.moisture + elev_self, 0.0, 1.0)
# -- Step 7: Precipitation --
func _update_precipitation(game_map: RefCounted) -> void:
var threshold: float = _params.get(
"precipitation_threshold", _DEFAULTS["precipitation_threshold"]
)
var h: int = game_map.height
var lat_spec: Dictionary = _spec.get("precipitation_latitude", {})
var polar_band: float = lat_spec.get("polar_drying", {}).get("band_pct", 0.15)
var polar_rate: float = lat_spec.get("polar_drying", {}).get("rate_per_turn", 0.008)
var sub_center: float = lat_spec.get("subtropical_drying", {}).get("center_pct", 0.30)
var sub_half: float = lat_spec.get("subtropical_drying", {}).get("half_width", 0.15)
var sub_rate: float = lat_spec.get("subtropical_drying", {}).get("rate_per_turn", 0.013)
for axial: Vector2i in game_map.tiles:
var tile: Variant = game_map.tiles[axial]
if tile.moisture > threshold:
var runoff: float = tile.moisture - threshold
tile.moisture = threshold
tile.surface_water += runoff # excess moisture precipitates as surface water
# Latitude-dependent moisture adjustment — models Hadley cell circulation.
# Polar regions are dry (cold air holds less moisture).
# Subtropical high-pressure belt (~30° latitude) suppresses moisture.
var offset: Vector2i = HexUtilsScript.axial_to_offset(axial)
var row: int = clampi(offset.y, 0, h - 1)
var frac: float = float(row) / float(h)
var norm_frac: float = frac if frac <= 0.5 else 1.0 - frac # 0=pole, 0.5=equator
if norm_frac < polar_band:
tile.moisture = maxf(0.0, tile.moisture - polar_rate)
if norm_frac > polar_band and norm_frac < sub_center + sub_half:
var dry_strength: float = maxf(0.0, 1.0 - absf(norm_frac - sub_center) / sub_half)
tile.moisture = maxf(0.05, tile.moisture - sub_rate * dry_strength)
# -- Step 9b: Ley residue decay --
func _tick_ley_residue(game_map: RefCounted) -> void:
LeyResidueScript.tick_residue(game_map)
func _update_ley_network(game_map: RefCounted) -> void:
## Build resonance/disruption edges between wonder anchors and apply effects.
var edges: Array = LeyNetworkScript.build_network(game_map, _spec)
LeyNetworkScript.apply_ley_effects(game_map, edges, _spec)
EventBus.ley_network_updated.emit(edges)
# -- Step 10: Global stats --
# Tile access uses Variant because game_map.tiles is a Dictionary[Vector2i, RefCounted]
# and tile objects are class_name types that cannot be typed across autoload boundary.
# See CLAUDE.md "Signal Parameters" — Variant is required here.
func _compute_global_stats(game_map: RefCounted) -> void:
var total: float = 0.0
var count: int = 0
func _sync_tiles_to_grid(game_map: RefCounted) -> void:
## Push GDScript tile state into the Rust GdGridState before physics.
for axial: Vector2i in game_map.tiles:
var tile: Variant = game_map.tiles[axial]
if tile.biome_id != "ocean":
total += tile.temperature
count += 1
global_avg_temp = total / float(count) if count > 0 else 0.5
# Tile access returns RefCounted subclass — Variant required at autoload boundary
var tile: RefCounted = game_map.tiles[axial] as RefCounted
var col: int = tile.get("col") as int
var row: int = tile.get("row") as int
var d: Dictionary = {
"temperature": tile.get("temperature"),
"moisture": tile.get("moisture"),
"elevation": tile.get("elevation"),
"biome_id": tile.get("biome_id"),
"wind_direction": tile.get("wind_direction"),
"wind_speed": tile.get("wind_speed"),
"sulfate_aerosol": tile.get("sulfate_aerosol"),
"quality": tile.get("quality"),
"quality_progress": tile.get("quality_progress"),
"original_biome_id": tile.get("original_biome_id"),
"ley_line_count": tile.get("ley_line_count"),
"ley_school": tile.get("ley_school"),
"reef_health": tile.get("reef_health"),
"magic_heat_delta": tile.get("magic_heat_delta"),
"magic_moisture_delta": tile.get("magic_moisture_delta"),
"is_natural_wonder": tile.get("is_natural_wonder"),
"wonder_anchor_strength": tile.get("wonder_anchor_strength"),
"wonder_tier": tile.get("wonder_tier"),
"canopy_cover": tile.get("canopy_cover"),
"undergrowth": tile.get("undergrowth"),
"fungi_network": tile.get("fungi_network"),
"surface_water": tile.get("surface_water"),
"river_source_type": tile.get("river_source_type"),
"is_coastal": tile.get("is_coastal"),
"aerosol_mitigation": tile.get("aerosol_mitigation"),
}
_grid.set_tile_dict(col, row, d)
# Reef health on coast tiles — coral bleaching from high SST
var reef_spec: Dictionary = _spec.get("reef_bleaching", {})
var bleach_thresh: float = reef_spec.get("bleach_temp_threshold", 0.75)
var damage_rate: float = reef_spec.get("damage_rate_per_turn", 0.01)
var recovery_rate: float = reef_spec.get("recovery_rate_per_turn", 0.005)
var dead_thresh: float = reef_spec.get("dead_threshold", 0.3)
var coast_count: int = 0
var dead_count: int = 0
func _sync_grid_to_tiles(game_map: RefCounted) -> void:
## Pull Rust physics results back into GDScript tiles after physics.
for axial: Vector2i in game_map.tiles:
var tile: Variant = game_map.tiles[axial]
if tile.biome_id != "coast":
var tile: RefCounted = game_map.tiles[axial] as RefCounted
var col: int = tile.get("col") as int
var row: int = tile.get("row") as int
var d: Dictionary = _grid.get_tile_dict(col, row)
if d.is_empty():
continue
coast_count += 1
if tile.temperature > bleach_thresh:
tile.reef_health = maxf(0.0, tile.reef_health - damage_rate)
else:
tile.reef_health = minf(1.0, tile.reef_health + recovery_rate)
if tile.reef_health < dead_thresh:
dead_count += 1
if coast_count > 0:
ocean_dead_fraction = float(dead_count) / float(coast_count)
# -- Step 11: Clear transient magic deltas --
func _clear_magic_deltas(game_map: RefCounted) -> void:
for axial: Vector2i in game_map.tiles:
var tile: Variant = game_map.tiles[axial]
tile.magic_heat_delta = 0.0
tile.magic_moisture_delta = 0.0
# -- UI helper --
tile.set("temperature", d.get("temperature", tile.get("temperature")))
tile.set("moisture", d.get("moisture", tile.get("moisture")))
tile.set("biome_id", d.get("biome_id", tile.get("biome_id")))
tile.set("sulfate_aerosol", d.get("sulfate_aerosol", 0.0))
tile.set("quality", d.get("quality", tile.get("quality")))
tile.set("quality_progress", d.get("quality_progress", tile.get("quality_progress")))
tile.set("original_biome_id", d.get("original_biome_id", tile.get("original_biome_id")))
tile.set("reef_health", d.get("reef_health", tile.get("reef_health")))
tile.set("magic_heat_delta", d.get("magic_heat_delta", 0.0))
tile.set("magic_moisture_delta", d.get("magic_moisture_delta", 0.0))
tile.set("surface_water", d.get("surface_water", tile.get("surface_water")))
tile.set("canopy_cover", d.get("canopy_cover", 0.0))
tile.set("undergrowth", d.get("undergrowth", 0.0))
tile.set("fungi_network", d.get("fungi_network", 0.0))
tile.set("habitat_suitability", d.get("habitat_suitability", 0.0))
func get_phase_label() -> String:
@ -404,68 +178,3 @@ func get_phase_label() -> String:
if global_avg_temp < THRESHOLDS[i]:
return LABELS[i]
return LABELS[LABELS.size() - 1]
# -- Internal helpers --
func _ensure_params() -> void:
if _params_loaded:
return
_params = DataLoader.get_climate_params()
if _params.is_empty():
push_warning("Climate: climate_params.json missing — using built-in defaults")
_params = _DEFAULTS.duplicate()
_spec = DataLoader.get_climate_spec()
if _spec.is_empty():
push_warning(
"Climate: climate_spec.json missing — terrain transitions use built-in defaults"
)
var world_flags: Dictionary = DataLoader.get_physics_features()
if not world_flags.is_empty():
for key: String in world_flags:
_physics_flags[key] = world_flags[key]
_params_loaded = true
func _ensure_ocean_dist(game_map: RefCounted) -> void:
## Build ocean proximity cache via BFS if not yet computed.
## Invalidated by terrain_transformed signal (terrain flips can create/remove water).
if _ocean_dist_valid:
return
_ocean_dist_cache.clear()
# 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].biome_id
if BiomeRegistry.has_tag(tid, "is_water"):
_ocean_dist_cache[axial] = 0
queue.append(axial)
# BFS outward — cap at 20 hexes (beyond this, factor bottoms out at floor anyway)
var head: int = 0
while head < queue.size():
var pos: Vector2i = queue[head]
head += 1
var dist: int = _ocean_dist_cache[pos]
if dist >= 20:
continue
for nb_pos: Vector2i in HexUtilsScript.get_neighbors(pos):
if not game_map.tiles.has(nb_pos):
continue
if _ocean_dist_cache.has(nb_pos):
continue
_ocean_dist_cache[nb_pos] = dist + 1
queue.append(nb_pos)
_ocean_dist_valid = true
# Listen for terrain changes that might invalidate the cache
if not EventBus.terrain_transformed.is_connected(_on_terrain_transformed):
EventBus.terrain_transformed.connect(_on_terrain_transformed)
func _on_terrain_transformed(_tile: Variant, _old_type: String, _new_type: String) -> void:
_ocean_dist_valid = false

View file

@ -1,351 +0,0 @@
class_name ClimateBase
extends RefCounted
## Per-tile climate physics — base class containing heavy simulation step functions.
## Inherited by Climate which adds the orchestration layer.
const MapGeneratorScript: GDScript = preload("res://engine/src/generation/map_generator.gd")
const HexUtilsScript: GDScript = preload("res://engine/src/map/hex_utils.gd")
const TerrainAffinityScript: GDScript = preload("res://engine/src/core/terrain_affinity.gd")
const LeyResidueScript: GDScript = preload("res://engine/src/modules/ley/ley_residue.gd")
const ClimateSpecEvalScript: GDScript = preload(
"res://engine/src/modules/climate/climate_spec_eval.gd"
)
const EcologicalEventsScript: GDScript = preload(
"res://engine/src/modules/climate/ecological_events.gd"
)
const AnchorDecayScript: GDScript = preload("res://engine/src/modules/climate/anchor_decay.gd")
const LeyNetworkScript: GDScript = preload("res://engine/src/modules/ley/ley_network.gd")
# Fallback defaults — used only when climate_params.json absent (logs warning)
const _DEFAULTS: Dictionary = {
"wind_conductivity": 0.1,
"energy_scale": 0.005,
"equilibrium_relaxation": 0.08,
"evaporation_rate": 0.05,
"moisture_transport": 0.15,
"precipitation_threshold": 0.7,
"moisture_decay": 0.995,
"ocean_evaporation_hops": 4,
"ocean_evaporation_hop_decay": 0.5,
"atmospheric_loss_rate": 0.0003,
"quality_up_threshold": 10,
"quality_down_threshold": 5,
"lake_thermal_conductivity": 0.05,
"river_moisture_transport": 0.075,
"mountain_rain_shadow_block": 0.9,
"solar_min": 0.05,
"solar_max": 0.70,
}
const _DEW_DEFAULTS: Dictionary = {
"volcano_self": 0.008,
"volcano_neighbor": 0.004,
"hot_spring_self": 0.005,
"hot_spring_neighbor": 0.002,
"hot_spring_max_temperature": 0.60,
"high_elevation_self": 0.002,
"high_elevation_threshold": 0.8,
}
## Global average temperature — updated each turn, readable by UI
var global_avg_temp: float = 0.5
## Fraction of coast tiles permanently dead from marine extinction [0.0, 1.0].
## Set by TurnManager from marine_harvest.ocean_dead_fraction before process_turn().
## Scales ocean evaporation down — biological pump failure reduces moisture transport inland.
var ocean_dead_fraction: float = 0.0
## Random seed passed from TurnManager — used by stochastic subsystems.
var _seed: int = 42
# Climate params from DataLoader — loaded once, then cached
var _params: Dictionary = {}
var _spec: Dictionary = {}
var _params_loaded: bool = false
# Physics capability flags from world manifest (physics_features block).
# Defaults to all-enabled so Earth worlds without the field keep working.
var _physics_flags: Dictionary = {
"water_cycle": true,
"weather_events": true,
"ecology": true,
"volcanism": true,
}
# 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).
# Computed once via BFS on first use, invalidated when terrain transforms.
var _ocean_dist_cache: Dictionary = {} # axial → int
var _ocean_dist_valid: bool = false
# -- Step 2: Temperature update (double-buffered) --
func _update_temperatures(game_map: RefCounted) -> void:
var conductivity: float = _params.get("wind_conductivity", _DEFAULTS["wind_conductivity"])
var energy_scale: float = _params.get("energy_scale", _DEFAULTS["energy_scale"])
var relaxation: float = _params.get(
"equilibrium_relaxation", _DEFAULTS["equilibrium_relaxation"]
)
var h: int = game_map.height
var center_row: float = float(h) / 2.0
# Pre-compute solar baseline per row — same value for every tile in a row
var solar_by_row: Dictionary = {}
for row: int in h:
solar_by_row[row] = _solar_by_latitude(row, center_row)
# Snapshot current temperatures into a read buffer — prevents stale reads
# when upwind tiles have already been updated in this same pass.
var old_temp: Dictionary = {} # axial → float
for axial: Vector2i in game_map.tiles:
old_temp[axial] = game_map.tiles[axial].temperature
# Compute new temperatures from old values only, write into new_temp buffer
var new_temp: Dictionary = {} # axial → float
for axial: Vector2i in game_map.tiles:
var tile: Variant = game_map.tiles[axial]
var offset: Vector2i = HexUtilsScript.axial_to_offset(axial)
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.biome_id)
var albedo: float = terrain_data.get("albedo", 0.4)
var net_solar: float = solar * (1.0 - albedo) * energy_scale
# Wind transport: read upwind tile temperature from old snapshot
var wind_transport: float = 0.0
var upwind_pos: Vector2i = _upwind_pos(axial, tile.wind_direction)
if old_temp.has(upwind_pos):
wind_transport = (
(old_temp[upwind_pos] - current_temp) * tile.wind_speed * conductivity
)
var relax: float = (solar - current_temp) * relaxation
new_temp[axial] = clampf(
current_temp + net_solar + wind_transport + relax + tile.magic_heat_delta, 0.0, 1.0
)
# Swap: write buffered results back to tiles
for axial: Vector2i in new_temp:
game_map.tiles[axial].temperature = new_temp[axial]
# -- Step 3: Lake thermal moderation --
func _update_lake_thermal_effects(game_map: RefCounted) -> void:
# Lakes moderate adjacent land tiles toward lake temperature.
# Read lake temps from post-step-2 values — moderation is a secondary effect
# and doesn't require double-buffering (lakes don't read each other here).
var conductivity: float = _params.get(
"lake_thermal_conductivity", _DEFAULTS["lake_thermal_conductivity"]
)
for axial: Vector2i in game_map.tiles:
var tile: Variant = game_map.tiles[axial]
if tile.biome_id != "lake":
continue
var lake_temp: float = tile.temperature
for nb_pos: Vector2i in HexUtilsScript.get_neighbors(axial):
var nb: Variant = game_map.tiles.get(nb_pos)
if nb == null:
continue
var diff: float = lake_temp - nb.temperature
nb.temperature = clampf(nb.temperature + diff * conductivity, 0.0, 1.0)
# -- Step 4: Wind moisture transport (double-buffered) --
func _update_moisture_wind(game_map: RefCounted) -> void:
var transport_rate: float = _params.get("moisture_transport", _DEFAULTS["moisture_transport"])
var decay: float = _params.get("moisture_decay", _DEFAULTS["moisture_decay"])
var rain_shadow_block: float = _params.get(
"mountain_rain_shadow_block", _DEFAULTS["mountain_rain_shadow_block"]
)
var atmo_loss: float = _params.get("atmospheric_loss_rate", _DEFAULTS["atmospheric_loss_rate"])
# Snapshot moisture before decay so all tiles decay from the same baseline
var old_moisture: Dictionary = {} # axial → float
for axial: Vector2i in game_map.tiles:
old_moisture[axial] = game_map.tiles[axial].moisture * decay
# Compute new moisture from decayed old values + upwind transport.
# Reading old_moisture[upwind] avoids stale-write order dependence.
var new_moisture: Dictionary = {} # axial → float
for axial: Vector2i in game_map.tiles:
var tile: Variant = game_map.tiles[axial]
var current: float = old_moisture[axial]
# Upwind moisture transport
var upwind_pos: Vector2i = _upwind_pos(axial, tile.wind_direction)
var transported: float = 0.0
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 BiomeRegistry.has_tag(upwind_tile.biome_id, "is_elevated")
)
var block: float = rain_shadow_block if upwind_is_mountain else 0.0
transported = (
old_moisture[upwind_pos] * tile.wind_speed * transport_rate * (1.0 - block)
)
# Evapotranspiration and magic forcing are per-tile local effects — safe to add here
var terrain_data: Dictionary = _get_terrain_data(tile.biome_id)
var evapotrans: float = terrain_data.get("evapotranspiration", 0.0)
# Atmospheric loss to space — Jeans escape (temperature-scaled).
# Balanced by volcanic outgassing (deep_earth_water step). No baseline injection needed.
var space_loss: float = current * atmo_loss * tile.temperature
new_moisture[axial] = clampf(
(
current
+ transported
+ evapotrans
- space_loss
+ tile.magic_moisture_delta
),
0.0,
1.0
)
# Swap
for axial: Vector2i in new_moisture:
game_map.tiles[axial].moisture = new_moisture[axial]
# -- Step 7b: Surface runoff — move surface water downhill into ocean --
func _update_surface_runoff(game_map: RefCounted) -> void:
## Surface water flows downhill along river_edges into ocean-substrate tiles.
## Draining into ocean substrate adds to the planetary water reservoir (drives sea_level).
## No downhill neighbour → water pools (lake formation).
var runoff_rate: float = _params.get("surface_runoff_rate", 0.3)
var ocean_substrates: Array[String] = ["deep_water", "shallow_water"]
for axial: Vector2i in game_map.tiles:
var tile: Variant = game_map.tiles[axial]
if tile.surface_water <= 0.0:
continue
# Find the steepest downhill river_edge neighbor
var best_nb: Variant = null
var best_drop: float = 0.0
for edge: int in tile.river_edges:
var nb_pos: Vector2i = axial + HexUtilsScript.AXIAL_DIRECTIONS[edge]
var nb: Variant = game_map.tiles.get(nb_pos)
if nb == null:
continue
var drop: float = tile.elevation - nb.elevation
if drop > best_drop:
best_drop = drop
best_nb = nb
if best_nb == null:
continue # no downhill neighbor — water pools here
var flow: float = tile.surface_water * runoff_rate
tile.surface_water -= flow
if best_nb.substrate_id in ocean_substrates:
game_map.total_ocean_water += flow
else:
best_nb.surface_water += flow
# -- Step 8: Terrain quality evolution --
func _check_terrain_evolution(game_map: RefCounted) -> void:
var up_thresh: int = int(_params.get("quality_up_threshold", _DEFAULTS["quality_up_threshold"]))
var down_thresh: int = 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.biome_id
if tile.is_natural_wonder:
continue
# Water freezing/thawing
var freeze_temp: float = _params.get("water_freeze_threshold", 0.12)
var thaw_temp: float = freeze_temp + 0.03
var is_water: bool = BiomeRegistry.has_tag(tid, "is_water")
if is_water:
if tile.temperature < freeze_temp:
tile.original_biome_id = tid
tile.biome_id = "ice"
tile.quality = 1
tile.quality_progress = 0
continue
if BiomeRegistry.has_tag(tid, "is_frozen"):
if tile.temperature > thaw_temp and tile.original_biome_id != "":
tile.biome_id = tile.original_biome_id
tile.original_biome_id = ""
tile.quality = 1
tile.quality_progress = 0
continue
if BiomeRegistry.has_tag(tid, "is_volcanic"):
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:
tile.quality += 1
else:
tile.quality_progress -= 1
if tile.quality_progress <= -down_thresh:
tile.quality_progress = 0
if tile.quality > 1:
tile.quality -= 1
else:
tile.biome_id = ideal
tile.quality = 1
tile.quality_progress = 0
# -- Internal helpers --
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, 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.05))
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:
## Return the axial position that wind blows FROM (opposite to wind_direction).
return axial + HexUtilsScript.AXIAL_DIRECTIONS[(wind_dir + 3) % 6]
func _downwind_pos(axial: Vector2i, wind_dir: int) -> Vector2i:
## Return the axial position that wind blows TOWARD (same as wind_direction).
return axial + HexUtilsScript.AXIAL_DIRECTIONS[wind_dir % 6]
func _ideal_terrain(tile: Variant) -> String:
return ClimateSpecEvalScript.ideal_terrain(tile, _spec)
func _ley_channeling_mult(nb: Variant) -> float:
return ClimateSpecEvalScript.ley_channeling_mult(nb, _spec)

View file

@ -1,112 +1,34 @@
class_name ClimateSpecEval
extends RefCounted
## Evaluates terrain transition rules and ley channeling multipliers from climate_spec.json.
## 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")
## Thin wrapper delegating to Rust GDExtension (GdClimateSpecEval).
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 BiomeClassifier.classify().
var tid: String = tile.biome_id
var temp: float = tile.temperature
var moist: float = tile.moisture
var canopy: float = tile.get("canopy_cover", 0.0)
var transitions: Dictionary = spec.get("terrain_transitions", {})
var rules: Array = transitions.get(tid, [])
if rules.is_empty():
return BiomeClassifierScript.classify(tile)
for rule: Variant in rules:
if not rule is Dictionary:
continue
if eval_condition(rule.get("condition", ""), temp, moist, tile.elevation, canopy):
var becomes: String = rule.get("becomes", "")
if becomes == "classify":
return BiomeClassifierScript.classify(tile)
return becomes
return tid
static func ideal_terrain(tile: RefCounted, spec: Dictionary) -> String:
## Determine the climate-ideal terrain type for a tile.
var tile_dict: Dictionary = {
"biome_id": tile.get("biome_id"),
"temperature": tile.get("temperature"),
"moisture": tile.get("moisture"),
"elevation": tile.get("elevation"),
"canopy_cover": tile.get("canopy_cover"),
}
var spec_json: String = JSON.stringify(spec)
return GdClimateSpecEval.ideal_terrain(tile_dict, spec_json)
static func eval_condition(cond: String, temp: float, moist: float, elev: float, canopy: float = 0.0) -> bool:
static func eval_condition(
cond: String, temp: float, moist: float, elev: float, canopy: float = 0.0
) -> bool:
## Evaluate a condition string from climate_spec.json.
## Supports: "field op value", "cond1 AND cond2", "cond1 OR cond2".
## Fields: temperature, moisture, elevation, canopy.
## Operators: <, <=, >, >=.
if " OR " in cond:
var parts: PackedStringArray = cond.split(" OR ")
for part: String in parts:
if eval_condition(part.strip_edges(), temp, moist, elev, canopy):
return true
return false
if " AND " in cond:
var parts: PackedStringArray = cond.split(" AND ")
for part: String in parts:
if not eval_condition(part.strip_edges(), temp, moist, elev, canopy):
return false
return true
var clean: String = cond.strip_edges().trim_prefix("(").trim_suffix(")")
var tokens: PackedStringArray = clean.split(" ")
if tokens.size() < 3:
push_warning("ClimateSpecEval: bad condition: '%s'" % cond)
return false
var field: String = tokens[0]
var op: String = tokens[1]
var value: float = float(tokens[2])
var actual: float = _field_value(field, temp, moist, elev, canopy)
if actual == -INF:
return false
return _check_op(actual, op, value)
return GdClimateSpecEval.eval_condition(cond, temp, moist, elev, canopy)
static func ley_channeling_mult(tile: Variant, spec: Dictionary) -> float:
static func ley_channeling_mult(tile: RefCounted, spec: Dictionary) -> float:
## Corruption spread multiplier based on receiving tile's ley state.
## Reads multiplier values from climate_spec.json ley_channeling section.
if tile.ley_line_count <= 0:
return 1.0
var ley_spec: Dictionary = spec.get("ley_channeling", {})
var school: String = tile.ley_school
if school == "death":
return ley_spec.get("death_ley", 3.0)
if school in ["nature", "life"]:
return ley_spec.get("nature_life_ley", 0.5)
return ley_spec.get("on_ley_generic", 2.0)
static func _field_value(field: String, temp: float, moist: float, elev: float, canopy: float = 0.0) -> float:
match field:
"temperature":
return temp
"moisture":
return moist
"elevation":
return elev
"canopy":
return canopy
push_warning("ClimateSpecEval: unknown field '%s'" % field)
return -INF
static func _check_op(actual: float, op: String, value: float) -> bool:
match op:
"<":
return actual < value
"<=":
return actual <= value
">":
return actual > value
">=":
return actual >= value
push_warning("ClimateSpecEval: unknown op '%s'" % op)
return false
var tile_dict: Dictionary = {
"ley_line_count": tile.get("ley_line_count"),
"ley_school": tile.get("ley_school"),
}
var spec_json: String = JSON.stringify(spec)
return GdClimateSpecEval.ley_channeling_mult(tile_dict, spec_json)