magicciv/src/game/engine/src/autoloads/ecology_state.gd
Natalie bb38d5db0e feat(@projects/@magic-civilization): 🌲 p3-19 DONE — flora half: deforestation depletes live flora populations
Completes player→ecology feedback. EcologyEngine::deplete_flora_at(col,row,amount)
depletes a tile's Producer-diet (flora) populations (registry-identified);
GdFaunaEcology.deplete_flora_at exposes it; EcologyState._on_tile_improved fires
it when a flora-clearing improvement (deforestation) completes — so clear-cutting
a forest removes its flora (the terrain→grassland change also drives the gradual
die-off). With the fauna half (over-hunting → extinction), the living world now
reacts to player pressure both ways. Logic stays in mc_ecology (Rail 1).

Test: deplete_flora_at_targets_producer_species_only (mc-ecology green); dylib
rebuilt + deployed; canonical GUT 745/0 (wiring loads, no regression).

p3-19 → done. Next: p3-20 (weather→scouting).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 15:16:18 -04:00

179 lines
8.1 KiB
GDScript

extends Node
## Session-scoped GdFaunaEcology singleton.
##
## p1-38 Phase B item #7 lifecycle wiring: a single fauna engine survives the
## whole game so populations evolve turn-over-turn and
## `GdFaunaEcology::fauna_product_supply` returns non-zero in a live game.
##
## Lifecycle:
## _ready() → reset() builds a fresh engine
## tick(grid, seed) → ensure species registry is populated, then run one
## mc-ecology process_step (emergence + Lotka-Volterra
## dynamics + tier advancement + feedback)
##
## Save/load: not yet round-tripped — `mc_ecology::EcologyEngine` lacks a
## serde derive. Reseeding from disk on `reset()` is the documented recovery
## path; populations rebuild via emergence over a few turns.
const _SPECIES_DIR: String = "res://public/resources/ecology/fauna/species"
var fauna_ecology: RefCounted = null
var _registered_already: bool = false
## World-genesis seeding runs once, on the first tick when the worldgen grid is
## available. Reset on new game / load. Safe for loaded games: the underlying
## `seed_initial_populations` only fills tiles that are not already populated, so
## restored populations are never double-seeded — it just bootstraps any empty
## world (the current EcologyState save path rebuilds from scratch anyway).
var _seeded_already: bool = false
func _ready() -> void:
reset()
# p3-19 — clearing a forest removes its flora. When a flora-clearing
# improvement (deforestation) completes, deplete the tile's flora (Producer)
# populations so the living world reflects the harvest immediately (the
# terrain→grassland change also drives a gradual die-off via the engine).
if not EventBus.tile_improved.is_connected(_on_tile_improved):
EventBus.tile_improved.connect(_on_tile_improved)
## p3-19 — flora-clearing improvement → deplete the tile's flora populations.
## The depletion logic lives in Rust (`GdFaunaEcology.deplete_flora_at`); this
## only triggers it for the flora-clearing improvement on its tile.
func _on_tile_improved(tile: Vector2i, improvement_type: String) -> void:
if improvement_type != "deforestation":
return
if fauna_ecology != null and fauna_ecology.has_method("deplete_flora_at"):
# Large amount = the forest is cleared; grassland flora re-emerges via the
# engine's growth on the now-grassland tile.
fauna_ecology.call("deplete_flora_at", tile.x, tile.y, 1.0e9)
## Discard the current engine and create a fresh one. Called on new game /
## load to avoid carrying stale species or tile populations across sessions.
func reset() -> void:
_registered_already = false
_seeded_already = false
if not ClassDB.class_exists("GdFaunaEcology"):
fauna_ecology = null
return
fauna_ecology = ClassDB.instantiate("GdFaunaEcology") as RefCounted
## Run one tick of population dynamics on the shared GdGridState. Caller is
## typically `turn_manager.gd::next_player()` after the climate step.
## Lazy-registers the full species library from disk on first call, then
## bootstraps the world with starter populations so emergence + dynamics have a
## living base to evolve (emergence alone cannot cold-start — its rate is a slow
## trickle gated on not-yet-formed flora).
func tick(grid: RefCounted, seed: int) -> void:
if fauna_ecology == null or grid == null:
return
_ensure_species_registered()
_ensure_world_seeded(grid, seed)
fauna_ecology.call("tick_populations", grid, seed)
## g2-07: drain the flora succession transitions captured by the most recent
## `tick`. Each entry is `{ col, row, species_id, from_tier, to_tier }`. Empty
## array when no flora crossed a tier last tick or no engine is present.
## Draining clears the Rust-side buffer so a transition is reported exactly once.
func take_flora_transitions() -> Array:
if fauna_ecology == null:
return []
return fauna_ecology.call("take_flora_transitions") as Array
## Seed starter populations once, when the live worldgen grid is first present.
## `seed_initial_populations` skips already-populated tiles, so this is harmless
## on a loaded game (no double-seeding) and bootstraps an otherwise-empty world.
## Logged so a barren world is diagnosable.
func _ensure_world_seeded(grid: RefCounted, seed: int) -> void:
if _seeded_already:
return
_seeded_already = true
var n: int = int(fauna_ecology.call("seed_initial_populations", grid, seed))
if n <= 0:
push_warning("[EcologyState] world seeding produced 0 populated tiles — fauna will be absent")
## Per-tile total fauna population for the live-map fauna overlay
## (`fauna_overlay_renderer.gd`). One bulk bridge read per refresh: a Dictionary
## keyed by `Vector2i(col, row)` → total population (float). Empty when no
## engine or no populations yet (emergence has not fired). The overlay reads
## this on each `EventBus.worldsim_updated` and normalizes for its heatmap.
func tile_densities() -> Dictionary:
if fauna_ecology == null:
return {}
return fauna_ecology.call("populated_tile_densities")
## Serialize the live fauna populations (`tile_populations`) for the save
## envelope, mirroring `WorldsimState.to_save_json` (eco_map). Empty string when
## no engine is active — the loader then rebuilds the world via seeding. The
## bridge serializes byte-stably (ordered `(col,row)` pairs), so re-saving an
## unchanged engine is deterministic.
func to_save_json() -> String:
if fauna_ecology == null:
return ""
return String(fauna_ecology.call("continuation_state_to_json"))
## Restore the live fauna populations from a save envelope payload. No-op on
## empty input (old saves / pre-seeding saves) — the world then re-seeds on the
## first tick. Mirrors `WorldsimState.restore_from_save_json`. Call AFTER
## `reset()` (save_manager does), so this injects into a fresh engine.
func restore_from_save_json(json: String) -> void:
if fauna_ecology == null or json.is_empty():
return
var ok: bool = bool(fauna_ecology.call("restore_continuation_state_from_json", json))
# Suppress world-genesis re-seeding ONLY when the restore actually brought a
# living world back — an empty/pre-seeding save must fall through to fresh
# seeding, or it loads barren forever. `_registered_already` stays false so
# the first tick reloads the species registry + emergence library that the
# restored population slots reference (restore overwrites tile_populations
# only, not the registry).
if ok and int(fauna_ecology.call("populated_tile_count")) > 0:
_seeded_already = true
func _ensure_species_registered() -> void:
## Idempotent: a non-empty registry short-circuits via the engine's
## `population_slot_count` proxy on subsequent ticks (slots only appear
## after emergence, but registration itself is a one-shot).
if _registered_already:
return
_registered_already = true
var dir: DirAccess = DirAccess.open(_SPECIES_DIR)
if dir == null:
push_warning("[EcologyState] species dir not found: %s" % _SPECIES_DIR)
return
dir.list_dir_begin()
var registered_count: int = 0
# Accumulate raw JSON so we can ALSO populate the engine's emergence library
# (the pool procedural emergence draws colonizers from). Registry-only
# registration left the library empty, so a never-seeded live engine never
# emerged anything — the world stayed barren. Both are required: the registry
# for known/active species, the library for emergence candidates.
var species_jsons: Array[String] = []
var name: String = dir.get_next()
while name != "":
if not dir.current_is_dir() and name.ends_with(".json"):
var path: String = "%s/%s" % [_SPECIES_DIR, name]
var raw: String = FileAccess.get_file_as_string(path)
if raw != "":
species_jsons.append(raw)
var numeric_id: int = int(fauna_ecology.call(
"register_species_from_json", raw
))
if numeric_id >= 0:
registered_count += 1
name = dir.get_next()
dir.list_dir_end()
if registered_count == 0:
push_warning("[EcologyState] registered 0 species from %s" % _SPECIES_DIR)
# Load the emergence library so emergence can populate the world over turns.
var lib_count: int = int(fauna_ecology.call("load_species_library_from_json", species_jsons))
if lib_count <= 0:
push_warning("[EcologyState] emergence library empty — world will not populate via emergence")