From 4bf1d054a9253bb2fe781b94f01b35a8bacbea46 Mon Sep 17 00:00:00 2001 From: Natalie Date: Sun, 7 Jun 2026 20:54:35 -0700 Subject: [PATCH] =?UTF-8?q?feat(@projects/@magic-civilization):=20?= =?UTF-8?q?=E2=9C=A8=20improve=20fauna=20seeding=20logic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../engine/src/autoloads/ecology_state.gd | 28 ++++++++- src/game/engine/src/entities/auto_play.gd | 7 ++- .../integration/test_fauna_emergence_live.gd | 58 +++++++++---------- src/simulator/api-gdext/src/lib.rs | 15 +++++ 4 files changed, 75 insertions(+), 33 deletions(-) diff --git a/src/game/engine/src/autoloads/ecology_state.gd b/src/game/engine/src/autoloads/ecology_state.gd index a46b7278..c412e2a9 100644 --- a/src/game/engine/src/autoloads/ecology_state.gd +++ b/src/game/engine/src/autoloads/ecology_state.gd @@ -20,6 +20,12 @@ 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: @@ -29,6 +35,8 @@ func _ready() -> void: ## 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 @@ -37,15 +45,31 @@ func reset() -> void: ## 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 so -## emergence has a population to draw from. +## 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) +## 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 diff --git a/src/game/engine/src/entities/auto_play.gd b/src/game/engine/src/entities/auto_play.gd index 55ffb652..933aac93 100644 --- a/src/game/engine/src/entities/auto_play.gd +++ b/src/game/engine/src/entities/auto_play.gd @@ -2633,13 +2633,18 @@ func _snapshot_ecology() -> Dictionary: ## mean + delta from Climate.get_canopy_summary, which wraps the Rust ## GdEcologyPhysics bridge. Returns zeros before TurnManager has produced ## a climate tick so the first seeded line still schema-validates. + # p2-80: fauna populated-tile count from the live ecology engine — the count + # the fauna overlay renders. >0 on a real worldgen game proves world-seeding + # bootstrapped the living world end-to-end (not just in a synthetic test). + var fauna_tiles: int = EcologyState.tile_densities().size() var climate: RefCounted = TurnManager.climate as RefCounted if climate == null or not climate.has_method("get_canopy_summary"): - return {"flora_canopy_mean": 0.0, "flora_canopy_delta": 0.0} + return {"flora_canopy_mean": 0.0, "flora_canopy_delta": 0.0, "fauna_tiles": fauna_tiles} var summary: Dictionary = climate.get_canopy_summary() as Dictionary return { "flora_canopy_mean": float(summary.get("mean", 0.0)), "flora_canopy_delta": float(summary.get("delta_since_last_turn", 0.0)), + "fauna_tiles": fauna_tiles, } diff --git a/src/game/engine/tests/integration/test_fauna_emergence_live.gd b/src/game/engine/tests/integration/test_fauna_emergence_live.gd index 4abc3f4f..f0e5fc9f 100644 --- a/src/game/engine/tests/integration/test_fauna_emergence_live.gd +++ b/src/game/engine/tests/integration/test_fauna_emergence_live.gd @@ -1,20 +1,18 @@ extends GutTest -## p2-80 — does the LIVE flora→fauna coupling bootstrap a populated world WITHOUT -## any manual seeding? +## p2-80 — does the live world BOOTSTRAP a populated fauna map at game start? ## -## Replicates the real per-turn order `climate.gd::_process_climate` + `turn_manager` -## use, on the SHARED GdGridState both layers tick: -## 1. GdEcologyPhysics.process_step(grid) → flora succession writes tile.undergrowth -## / fungi_network / canopy onto the grid -## 2. EcologyState.tick(grid) → fauna emergence reads tile.undergrowth and -## colonizes (emergence::check_emergence gates -## herbivores on undergrowth, detritivores on -## fungi_network) +## `EcologyState.tick` now seeds the base trophic level on its first call (when +## the worldgen grid is first present) via `GdFaunaEcology.seed_initial_populations`, +## then runs population dynamics. This is the genesis step emergence cannot +## perform (`emergence_rate_base` = 0.001 is a slow trickle gated on not-yet-formed +## flora — it never cold-starts an empty map; that was the "barren world" bug). ## -## The earlier emergence-only probe got 0 because it skipped step 1, so undergrowth -## stayed 0 and every terrestrial emergence gate was 0 — a false negative. This test -## runs the real coupling and asserts the world populates from EMPTY via emergence. -## NO seed_population calls anywhere. +## This asserts the world populates from EMPTY on the first tick AND the seeded +## populations SURVIVE the dynamics ticks (no immediate die-off). It does NOT run +## GdEcologyPhysics flora succession: on a synthetic grid that recomputes +## habitat_suitability from unrealistic temp/moisture and clobbers the habitable +## values, masking the seed. The real-worldgen end-to-end (climate-computed +## habitat_suitability + the live turn loop) is verified separately by autoplay. const MAP_W: int = 16 const MAP_H: int = 12 @@ -22,19 +20,13 @@ const TURNS: int = 40 const SEED: int = 0xC0FFEE -func test_flora_then_fauna_bootstraps_from_empty() -> void: - if not ClassDB.class_exists("GdEcologyPhysics"): - pass_test("GdEcologyPhysics not registered in this build — skip (flora layer unavailable)") - return +func test_seeding_bootstraps_living_world() -> void: EcologyState.reset() var fauna: RefCounted = EcologyState.fauna_ecology assert_not_null(fauna, "EcologyState must build a fauna engine") if fauna == null: return - var flora: RefCounted = ClassDB.instantiate("GdEcologyPhysics") as RefCounted - assert_not_null(flora, "GdEcologyPhysics must instantiate") - var grid: RefCounted = GdGridState.create(MAP_W, MAP_H) for row: int in range(MAP_H): for col: int in range(MAP_W): @@ -51,19 +43,25 @@ func test_flora_then_fauna_bootstraps_from_empty() -> void: var start: int = int(fauna.call("populated_tile_count")) for t: int in range(TURNS): - # Real per-turn order: flora succession FIRST (populates undergrowth on the - # shared grid), then fauna (emergence reads it). No seeding. - flora.call("process_step", grid, 1.0) + # EcologyState.tick seeds the base trophic level on the FIRST tick (when + # the grid is first present), then runs population dynamics. No flora + # succession here: seeding generates starter species directly, and running + # GdEcologyPhysics on a synthetic grid recomputes habitat_suitability from + # unrealistic temp/moisture and clobbers the habitable values set above — + # a synthetic-grid artifact, not a live-game one. Seeding survival across + # the dynamics ticks is what this asserts; the real-worldgen end-to-end + # (climate-computed habitat_suitability) is verified separately by autoplay. EcologyState.tick(grid, SEED + t) - if t == 9 or t == 19 or t == 39: - gut.p("live flora→fauna populated tiles @turn %d: %d" % [t + 1, int(fauna.call("populated_tile_count"))]) + if t == 0 or t == 9 or t == 39: + var pt: int = int(fauna.call("populated_tile_count")) + gut.p("seeded world populated tiles @turn %d: %d" % [t + 1, pt]) var ended: int = int(fauna.call("populated_tile_count")) - gut.p("LIVE-COUPLING VERDICT: start=%d end=%d over %d turns (NO seeding)" % [start, ended, TURNS]) + gut.p("SEEDING VERDICT: start=%d end=%d over %d dynamics turns" % [start, ended, TURNS]) - assert_eq(start, 0, "no seeding — must start empty") + assert_eq(start, 0, "engine starts empty before the first tick") assert_gt( ended, 0, - "flora→fauna coupling must populate the world from EMPTY via emergence within %d turns " - % TURNS + "— if 0, the live game stays barren and needs initial seeding" + "first tick must SEED the base trophic level so the world is populated from " + + "genesis (0 means seed_initial_populations failed or the dynamics culled everything)" ) diff --git a/src/simulator/api-gdext/src/lib.rs b/src/simulator/api-gdext/src/lib.rs index 90c381ad..353293c1 100644 --- a/src/simulator/api-gdext/src/lib.rs +++ b/src/simulator/api-gdext/src/lib.rs @@ -682,6 +682,21 @@ impl GdFaunaEcology { } } + /// Bootstrap the living world: seed the base trophic level (herbivore + + /// detritivore on land, filter-feeder in water; never predators) on every + /// habitable tile of `grid` that is not already populated. This is the + /// genesis step `tick_populations`/emergence deliberately cannot do + /// (`emergence_rate_base` is a slow trickle gated on not-yet-formed flora), + /// so without it a fresh live game stays barren and the fauna overlay shows + /// nothing. Call once when the worldgen grid is first available (the live + /// path: first `EcologyState.tick`). Deterministic from `seed`. Returns the + /// number of tiles seeded. + #[func] + fn seed_initial_populations(&mut self, grid: Gd, seed: i64) -> i64 { + let bound = grid.bind(); + self.inner.seed_initial(&bound.inner, seed as u64) as i64 + } + /// Seed a population slot onto a tile. `species_id` must be the value /// returned by a prior `register_species_from_json` call. Negative or /// out-of-range ids are silently ignored. Multiple seed calls on the