feat(@projects/@magic-civilization): ✨ add live fauna emergence test suite
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
fba776c936
commit
3f866545aa
3 changed files with 177 additions and 0 deletions
|
|
@ -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"
|
||||
)
|
||||
|
|
@ -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<String, Species>,
|
||||
species_registry: &mut HashMap<u32, Species>,
|
||||
config: &EcologyConfig,
|
||||
seed: u64,
|
||||
) -> Vec<PopulationSlot> {
|
||||
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<u32, Species>| {
|
||||
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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue