diff --git a/engine/src/modules/climate/climate.gd b/engine/src/modules/climate/climate.gd index 087d7812..05741caf 100644 --- a/engine/src/modules/climate/climate.gd +++ b/engine/src/modules/climate/climate.gd @@ -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 diff --git a/engine/src/modules/climate/climate_base.gd b/engine/src/modules/climate/climate_base.gd deleted file mode 100644 index 827b357d..00000000 --- a/engine/src/modules/climate/climate_base.gd +++ /dev/null @@ -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) diff --git a/engine/src/modules/climate/climate_spec_eval.gd b/engine/src/modules/climate/climate_spec_eval.gd index 4fa15f04..f881491a 100644 --- a/engine/src/modules/climate/climate_spec_eval.gd +++ b/engine/src/modules/climate/climate_spec_eval.gd @@ -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)