diff --git a/src/game/engine/tests/integration/test_fauna_emergence_live.gd b/src/game/engine/tests/integration/test_fauna_emergence_live.gd new file mode 100644 index 00000000..4abc3f4f --- /dev/null +++ b/src/game/engine/tests/integration/test_fauna_emergence_live.gd @@ -0,0 +1,69 @@ +extends GutTest +## p2-80 — does the LIVE flora→fauna coupling bootstrap a populated world WITHOUT +## any manual seeding? +## +## 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) +## +## 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. + +const MAP_W: int = 16 +const MAP_H: int = 12 +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 + 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): + var lat: float = 1.0 - absf((float(row) - MAP_H / 2.0) / (MAP_H / 2.0)) + var noise: float = fmod(float(col * 13 + row * 7) * 0.0173, 1.0) + grid.call("set_tile_dict", col, row, { + "temperature": 0.30 + lat * 0.45 + noise * 0.10, + "moisture": 0.40 + noise * 0.40, + "elevation": 0.18 + noise * 0.25, + "habitat_suitability": 0.5 + noise * 0.4, + "quality": 4, + "biome_id": "temperate_forest", + }) + + 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(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"))]) + + 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]) + + assert_eq(start, 0, "no seeding — must start empty") + 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" + ) diff --git a/src/simulator/crates/mc-ecology/src/emergence.rs b/src/simulator/crates/mc-ecology/src/emergence.rs index fb33039b..d096b983 100644 --- a/src/simulator/crates/mc-ecology/src/emergence.rs +++ b/src/simulator/crates/mc-ecology/src/emergence.rs @@ -236,6 +236,80 @@ fn generate_detritivore(seed: u64, col: i32, row: i32) -> Species { ) } +/// World-start bootstrap: force-colonize the base trophic level on a tile so the +/// living world is populated from turn 1. +/// +/// `check_emergence` is a deliberately rare trickle (`emergence_rate_base` = +/// 0.001) gated on flora structure (`undergrowth` / `fungi_network`) that has +/// not formed yet at world start — it **cannot** cold-start a trophic chain from +/// an empty map (that's by design: it models ongoing colonization, not genesis). +/// This seeds a starter **herbivore + detritivore** on a habitable LAND tile and +/// a **filter-feeder** on a WATER tile, reusing the exact library picker + +/// procedural generators emergence uses (so seeded species are identical in kind +/// to emerged ones). **Predators are intentionally NOT seeded** — they colonize +/// via `check_emergence`'s carnivore gate once prey populations exist. Returns +/// the slots to install on this tile (empty for tiles too hostile to support +/// life). Deterministic from `seed`. +#[must_use] +pub fn seed_base_trophic( + tile: &TileState, + species_library: &HashMap, + species_registry: &mut HashMap, + config: &EcologyConfig, + seed: u64, +) -> Vec { + let mut slots = Vec::new(); + if tile.habitat_suitability < config.habitat_abandon_threshold { + return slots; // too hostile to bootstrap any starter population + } + let col = tile.col; + let row = tile.row; + let biome = tile.biome_label_id.as_str(); + // Starter cohort: comfortably above min_viable so the first dynamics tick + // doesn't immediately extinguish it; scaled by habitat quality. + let starter = (8.0 + 10.0 * tile.habitat_suitability).max(config.min_viable_population); + + // Pick a biome-appropriate species from the library; fall back to a + // procedurally-generated one (library picks are filtered on flora structure + // that hasn't formed at turn 1, so the generated fallback is the common case + // at world start — the same fallback emergence itself uses). + let mut colonize = |diet: Diet, + tag: u64, + gen: &dyn Fn(u64, i32, i32) -> Species, + pop: f32, + registry: &mut HashMap| { + let id = pick_library_species(species_library, diet, biome, tile, seed, col, row, tag) + .and_then(|key| ensure_registered(key, species_library, registry)) + .unwrap_or_else(|| { + let sp = gen(seed, col, row); + let id = sp.id; + registry.insert(id, sp); + id + }); + PopulationSlot::new(id, pop) + }; + + if is_deep_ocean(biome) || is_freshwater(biome) || is_coastal_biome(biome) { + slots.push(colonize( + Diet::FilterFeeder, + 40, + &generate_pelagic_filter_feeder, + starter, + species_registry, + )); + } else { + slots.push(colonize(Diet::Herbivore, 41, &generate_herbivore, starter, species_registry)); + slots.push(colonize( + Diet::Detritivore, + 42, + &generate_detritivore, + starter * 0.5, + species_registry, + )); + } + slots +} + // ─── Aerial Generators ─────────────────────────────────────────────────────── /// Canopy seed-disperser / frugivore (bird-like, warm-blooded, colonizes mature forest). diff --git a/src/simulator/crates/mc-ecology/src/engine.rs b/src/simulator/crates/mc-ecology/src/engine.rs index 903e3fe3..9979a8a8 100644 --- a/src/simulator/crates/mc-ecology/src/engine.rs +++ b/src/simulator/crates/mc-ecology/src/engine.rs @@ -1026,6 +1026,40 @@ impl EcologyEngine { .push(slot); } + /// Bootstrap the living world at game start: seed the base trophic level on + /// every habitable tile that is not already populated (herbivore + + /// detritivore on land, filter-feeder in water — never predators; they + /// colonize via emergence once prey exist). This is the genesis step that + /// `check_emergence` deliberately cannot perform (its rate is a slow trickle + /// gated on not-yet-formed flora). Call once when the worldgen grid is first + /// available; `tick_populations` + `check_emergence` then evolve it. + /// Deterministic from `seed`. Returns the number of tiles seeded. + pub fn seed_initial(&mut self, grid: &GridState, seed: u64) -> usize { + let mut seeded = 0; + for row in 0..grid.height { + for col in 0..grid.width { + if self.tile_populations.contains_key(&(col, row)) { + continue; + } + let Some(tile) = grid.tile(col, row) else { + continue; + }; + let slots = emergence::seed_base_trophic( + tile, + &self.species_library, + &mut self.species_registry, + &self.config, + seed, + ); + if !slots.is_empty() { + self.tile_populations.insert((col, row), slots); + seeded += 1; + } + } + } + seeded + } + /// Capture the engine's mutable **continuation state** for save persistence. /// /// This is the complete set of fields that evolve turn-over-turn and must