feat(@projects/@magic-civilization): ✨ improve fauna seeding logic
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
3f866545aa
commit
4bf1d054a9
4 changed files with 75 additions and 33 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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<GdGridState>, 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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue