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>
179 lines
8.1 KiB
GDScript
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")
|