test(ecology): Add/fix test cases for golden vectors to validate edge cases and reference inputs in the ecology module

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-03-26 00:14:19 -07:00
parent 4158267121
commit 2ca21ca5a1

View file

@ -0,0 +1,562 @@
extends GutTest
## M2a ecology golden vector tests.
## Seed 42, 20×20 map, snapshots at turns 0/10/50.
## Verifies: flora growth, quality progression, predator-prey oscillation,
## biome recomputation, habitat suitability.
const GameMapScript = preload("res://engine/src/map/game_map.gd")
const TileScript = preload("res://engine/src/map/tile.gd")
const HexUtilsScript = preload("res://engine/src/map/hex_utils.gd")
const EcosystemScript = preload("res://engine/src/modules/ecology/ecosystem.gd")
const EcologyDBScript = preload("res://engine/src/modules/ecology/ecology_db.gd")
const BiomeModelScript = preload("res://engine/src/models/world/biome_model.gd")
const LandFaunaScript = preload("res://engine/src/models/world/land_fauna.gd")
const MarineFaunaScript = preload("res://engine/src/models/world/marine_fauna.gd")
const MAP_W: int = 20
const MAP_H: int = 20
const SEED: int = 42
var game_map: RefCounted # GameMap
var ecology_db: RefCounted # EcologyDB
var ecosystem: RefCounted # EcosystemOrchestrator
# Snapshot storage
var snap0: Dictionary = {} # "col,row" -> Dictionary
var snap10: Dictionary = {}
var snap50: Dictionary = {}
func _tile_hash(seed: int, col: int, row: int) -> float:
## Deterministic per-tile hash matching the TypeScript test.
var h: int = seed * 374761393 + col * 668265263 + row * 2147483647
h = (h ^ (h >> 13)) * 1274126177
h = h ^ (h >> 16)
return float(h & 0x7FFFFFFF) / float(0x7FFFFFFF)
func _build_test_grid() -> void:
game_map = GameMapScript.new()
# Use WrapMode.NONE (0) for hard-edge test map — no wrapping.
game_map.initialize(MAP_W, MAP_H, 0)
for row in range(MAP_H):
for col in range(MAP_W):
var tile: Resource = TileScript.new()
var axial: Vector2i = HexUtilsScript.offset_to_axial(Vector2i(col, row))
tile.position = axial
var h: float = _tile_hash(SEED, col, row)
var h2: float = _tile_hash(SEED + 1, col, row)
var h3: float = _tile_hash(SEED + 2, col, row)
# Temperature by latitude + noise
var lat_factor: float = 1.0 - absf(float(row) - float(MAP_H) / 2.0) / (float(MAP_H) / 2.0)
tile.temperature = clampf(lat_factor * 0.8 + h * 0.2, 0.0, 1.0)
tile.moisture = clampf(h2 * 0.7 + 0.15, 0.0, 1.0)
# Elevation: edges are water
var edge_dist: int = mini(mini(col, row), mini(MAP_W - 1 - col, MAP_H - 1 - row))
tile.elevation = 0.1 if edge_dist <= 1 else 0.3 + h3 * 0.4
var is_water_tile: bool = edge_dist <= 1
tile.substrate_id = "deep_water" if edge_dist == 0 else ("shallow_water" if edge_dist == 1 else ("highland" if tile.elevation > 0.6 else "lowland"))
tile.biome_id = "ocean" if is_water_tile else "grassland"
tile.quality = 2
tile.wind_direction = int(h * 6.0)
tile.wind_speed = 0.5
if is_water_tile:
tile.water_body_id = 0
tile.depth_from_coast = edge_dist
tile.reef_health = 1.0
else:
tile.water_body_id = -1
tile.depth_from_coast = -1
game_map.set_tile(axial, tile)
func _inject_biome_data() -> void:
## Inject proof biome definitions matching the TS BIOME_DEFS directly
## into DataLoader's internal _biomes dictionary.
var biomes: Array = [
{
"id": "temperate_forest",
"name": "Temperate Forest",
"quality_range": [1, 5],
"temp_range": [0.35, 0.65],
"moisture_range": [0.4, 0.8],
"flora_climax": {"canopy": 0.9, "undergrowth": 0.7, "fungi": 0.5},
"fauna_capacity": 12,
},
{
"id": "tropical_rainforest",
"name": "Tropical Rainforest",
"quality_range": [1, 5],
"temp_range": [0.65, 1.0],
"moisture_range": [0.6, 1.0],
"flora_climax": {"canopy": 1.0, "undergrowth": 0.9, "fungi": 0.8},
"fauna_capacity": 16,
},
{
"id": "grassland",
"name": "Grassland",
"quality_range": [1, 4],
"temp_range": [0.3, 0.7],
"moisture_range": [0.2, 0.5],
"flora_climax": {"canopy": 0.1, "undergrowth": 0.8, "fungi": 0.2},
"fauna_capacity": 8,
},
{
"id": "desert",
"name": "Desert",
"quality_range": [1, 3],
"temp_range": [0.5, 1.0],
"moisture_range": [0.0, 0.2],
"flora_climax": {"canopy": 0.0, "undergrowth": 0.1, "fungi": 0.0},
"fauna_capacity": 3,
},
{
"id": "boreal_forest",
"name": "Boreal Forest",
"quality_range": [1, 5],
"temp_range": [0.15, 0.4],
"moisture_range": [0.3, 0.7],
"flora_climax": {"canopy": 0.7, "undergrowth": 0.4, "fungi": 0.6},
"fauna_capacity": 8,
},
{
"id": "tundra",
"name": "Tundra",
"quality_range": [1, 3],
"temp_range": [0.0, 0.2],
"moisture_range": [0.1, 0.5],
"flora_climax": {"canopy": 0.0, "undergrowth": 0.2, "fungi": 0.1},
"fauna_capacity": 4,
},
# Additional biomes the GDScript classifier produces but TS doesn't have:
# These need at least stub definitions so DataLoader.get_biome() doesn't return null.
{
"id": "temperate_grassland",
"name": "Temperate Grassland",
"quality_range": [1, 4],
"temp_range": [0.25, 0.55],
"moisture_range": [0.3, 0.6],
"flora_climax": {"canopy": 0.05, "undergrowth": 0.8, "fungi": 0.15},
"fauna_capacity": 8,
},
{
"id": "savanna",
"name": "Savanna",
"quality_range": [1, 4],
"temp_range": [0.55, 1.0],
"moisture_range": [0.2, 0.4],
"flora_climax": {"canopy": 0.15, "undergrowth": 0.6, "fungi": 0.1},
"fauna_capacity": 6,
},
{
"id": "chaparral",
"name": "Chaparral",
"quality_range": [1, 3],
"temp_range": [0.25, 0.55],
"moisture_range": [0.1, 0.3],
"flora_climax": {"canopy": 0.05, "undergrowth": 0.4, "fungi": 0.05},
"fauna_capacity": 5,
},
{
"id": "tropical_dry_forest",
"name": "Tropical Dry Forest",
"quality_range": [1, 5],
"temp_range": [0.55, 1.0],
"moisture_range": [0.4, 0.7],
"flora_climax": {"canopy": 0.6, "undergrowth": 0.5, "fungi": 0.3},
"fauna_capacity": 10,
},
{
"id": "polar_desert",
"name": "Polar Desert",
"quality_range": [1, 2],
"temp_range": [0.0, 0.1],
"moisture_range": [0.0, 0.3],
"flora_climax": {"canopy": 0.0, "undergrowth": 0.0, "fungi": 0.0},
"fauna_capacity": 1,
},
{
"id": "swamp",
"name": "Swamp",
"quality_range": [1, 4],
"temp_range": [0.4, 1.0],
"moisture_range": [0.7, 1.0],
"flora_climax": {"canopy": 0.4, "undergrowth": 0.8, "fungi": 0.6},
"fauna_capacity": 10,
},
{
"id": "ocean",
"name": "Ocean",
"quality_range": [1, 3],
"temp_range": [0.0, 1.0],
"moisture_range": [0.0, 1.0],
"flora_climax": {"canopy": 0.0, "undergrowth": 0.0, "fungi": 0.0},
"fauna_capacity": 0,
},
]
for biome_data: Dictionary in biomes:
var biome: RefCounted = BiomeModelScript.from_dict(biome_data)
DataLoader._biomes[biome.id] = biome
# Inject vegetation params (matches TS VEG/SUC/DES defaults)
DataLoader._raw["world_flora"] = {
"vegetation": {
"growth_rate": 0.02,
"decay_rate": 0.03,
"shade_cap": 0.7,
"drought_decay_multiplier": 1.5,
"fungi_undergrowth_threshold": 0.3,
"fungi_regrowth_bonus_cap": 2.0,
},
"succession": {
"stability_turns": 50,
"canopy_threshold": 0.8,
"regrowth_stages": [
{"stage": 0, "turns_to_advance": 10, "canopy_target": 0.0, "undergrowth_target": 0.1, "fungi_target": 0.0},
{"stage": 1, "turns_to_advance": 15, "canopy_target": 0.1, "undergrowth_target": 0.3, "fungi_target": 0.05},
{"stage": 2, "turns_to_advance": 20, "canopy_target": 0.4, "undergrowth_target": 0.5, "fungi_target": 0.2},
{"stage": 3, "turns_to_advance": 25, "canopy_target": 0.7, "undergrowth_target": 0.6, "fungi_target": 0.4},
],
},
"desertification": {
"moisture_threshold": 0.2,
"turns_required": 30,
"decay_multiplier": 2.0,
"recovery_rate": 1,
},
}
# Inject land fauna params
var lf := LandFaunaScript.new()
lf.undergrowth_weight = 0.6
lf.canopy_weight = 0.2
lf.fungi_weight = 0.2
lf.habitat_abandon_threshold = 0.3
lf.habitat_abandon_turns = 10
lf.habitat_thriving_threshold = 0.7
DataLoader._land_fauna = lf
func _setup_ecology_db() -> void:
ecology_db = EcologyDBScript.new()
# Create test species: a grass-like producer, a deer-like herbivore, a wolf-like predator
var grass_id: int = ecology_db.add_species({
"trait_hash": "grass_producer_42",
"name": "Meadow Grass",
"size": "tiny",
"diet": "producer",
"habitat": "terrestrial",
"locomotion": "sessile",
"reproduction": "spore",
"thermal": "mesothermic",
"social": "colonial",
"min_quality": 1,
"max_quality": 5,
"growth_rate": 0.1,
"carrying_capacity": 50,
"migration_range": 0,
"maturity_age": 1,
"max_age": 100,
"quality_up_threshold": 15,
})
var deer_id: int = ecology_db.add_species({
"trait_hash": "deer_herbivore_42",
"name": "Forest Deer",
"size": "medium",
"diet": "herbivore",
"habitat": "terrestrial",
"locomotion": "quadruped",
"reproduction": "viviparous",
"thermal": "endothermic",
"social": "herd",
"min_quality": 1,
"max_quality": 5,
"growth_rate": 0.02,
"carrying_capacity": 10,
"migration_range": 3,
"maturity_age": 5,
"max_age": 40,
"quality_up_threshold": 20,
})
var wolf_id: int = ecology_db.add_species({
"trait_hash": "wolf_predator_42",
"name": "Grey Wolf",
"size": "medium",
"diet": "carnivore",
"habitat": "terrestrial",
"locomotion": "quadruped",
"reproduction": "viviparous",
"thermal": "endothermic",
"social": "pack",
"min_quality": 1,
"max_quality": 5,
"growth_rate": 0.01,
"carrying_capacity": 6,
"migration_range": 4,
"maturity_age": 5,
"max_age": 30,
"quality_up_threshold": 20,
})
# Set up food web: wolf eats deer
ecology_db.add_food_web_edge(wolf_id, deer_id, 0.5)
# Place initial creatures on interior land tiles
# 10 grass producers, 5 deer herbivores, 2 wolf predators
var land_tiles: Array[Vector2i] = []
for axial: Vector2i in game_map.tiles:
var tile: Variant = game_map.tiles[axial]
if tile.substrate_id not in ["deep_water", "shallow_water", "lake_bed"]:
land_tiles.append(axial)
# Place grass on first 10 land tiles
for i in range(mini(10, land_tiles.size())):
var pos: Vector2i = land_tiles[i]
var off: Vector2i = HexUtilsScript.axial_to_offset(pos)
ecology_db.add_creature(off.x, off.y, grass_id, 2, 0, 1.0)
# Place deer on tiles 5-9
for i in range(5, mini(10, land_tiles.size())):
var pos: Vector2i = land_tiles[i]
var off: Vector2i = HexUtilsScript.axial_to_offset(pos)
ecology_db.add_creature(off.x, off.y, deer_id, 2, 0, 1.0)
# Place wolves on tiles 5-6
for i in range(5, mini(7, land_tiles.size())):
var pos: Vector2i = land_tiles[i]
var off: Vector2i = HexUtilsScript.axial_to_offset(pos)
ecology_db.add_creature(off.x, off.y, wolf_id, 2, 0, 1.0)
func _snapshot(label: String) -> Dictionary:
var snap: Dictionary = {}
for axial: Vector2i in game_map.tiles:
var tile: Variant = game_map.tiles[axial]
var off: Vector2i = HexUtilsScript.axial_to_offset(axial)
var key: String = "%d,%d" % [off.x, off.y]
snap[key] = {
"col": off.x,
"row": off.y,
"biome_id": tile.biome_id,
"canopy_cover": tile.canopy_cover,
"undergrowth": tile.undergrowth,
"fungi_network": tile.fungi_network,
"quality": tile.quality,
"habitat_suitability": tile.habitat_suitability,
"drought_counter": tile.drought_counter,
}
return snap
func before_all() -> void:
# Load terrain data for tile.is_water() to work
DataLoader.load_theme("age-of-four")
# Build test grid and inject ecology data
_build_test_grid()
_inject_biome_data()
_setup_ecology_db()
ecosystem = EcosystemScript.new()
# Turn 0 snapshot
snap0 = _snapshot("turn0")
# Run 10 turns
for t in range(10):
ecosystem.process_turn(game_map, ecology_db, SEED + t)
snap10 = _snapshot("turn10")
# Run 40 more turns (total 50)
for t in range(10, 50):
ecosystem.process_turn(game_map, ecology_db, SEED + t)
snap50 = _snapshot("turn50")
# -- Turn 0 --
func test_turn0_all_land_tiles_start_zero_flora() -> void:
for key: String in snap0:
var s: Dictionary = snap0[key]
if s["biome_id"] == "ocean":
continue
assert_eq(s["canopy_cover"], 0.0, "canopy should be 0 at turn 0")
assert_eq(s["undergrowth"], 0.0, "undergrowth should be 0 at turn 0")
assert_eq(s["fungi_network"], 0.0, "fungi should be 0 at turn 0")
func test_turn0_edge_tiles_are_water() -> void:
var s: Dictionary = snap0["0,0"]
assert_eq(s["biome_id"], "ocean", "corner tile should be ocean")
func test_turn0_interior_tiles_have_valid_biome() -> void:
var s: Dictionary = snap0["10,10"]
assert_ne(s["biome_id"], "ocean", "center tile should not be ocean")
assert_ne(s["biome_id"], "", "center tile should have a biome")
# -- Turn 10 --
func test_turn10_undergrowth_growing() -> void:
var total_ug: float = 0.0
var count: int = 0
for key: String in snap10:
var s: Dictionary = snap10[key]
if s["biome_id"] == "ocean":
continue
total_ug += s["undergrowth"]
count += 1
if count > 0:
assert_gt(total_ug / float(count), 0.0, "avg undergrowth should be > 0 after 10 turns")
func test_turn10_habitat_suitability_positive() -> void:
var found: bool = false
for key: String in snap10:
var s: Dictionary = snap10[key]
if s["biome_id"] != "ocean" and s["habitat_suitability"] > 0.0:
found = true
break
assert_true(found, "at least one land tile should have positive habitat suitability")
func test_turn10_quality_not_maxed() -> void:
var s: Dictionary = snap10["10,10"]
assert_lt(s["quality"], 5, "quality should not be maxed at turn 10")
# -- Turn 50 --
func test_turn50_canopy_grown_on_land() -> void:
var max_canopy: float = 0.0
for key: String in snap50:
var s: Dictionary = snap50[key]
if s["biome_id"] == "ocean":
continue
max_canopy = maxf(max_canopy, s["canopy_cover"])
assert_gt(max_canopy, 0.0, "some land tile should have canopy > 0 by turn 50")
func test_turn50_undergrowth_developed() -> void:
var total_ug: float = 0.0
var land_count: int = 0
for key: String in snap50:
var s: Dictionary = snap50[key]
if s["biome_id"] in ["ocean", "desert", "tundra", "polar_desert"]:
continue
total_ug += s["undergrowth"]
land_count += 1
if land_count > 0:
assert_gt(total_ug / float(land_count), 0.05, "avg undergrowth > 0.05 by turn 50")
func test_turn50_quality_differentiated() -> void:
var qualities: Dictionary = {}
for key: String in snap50:
var s: Dictionary = snap50[key]
if s["biome_id"] == "ocean":
continue
qualities[s["quality"]] = true
assert_gte(qualities.size(), 2, "should have at least 2 distinct quality tiers by turn 50")
func test_turn50_at_least_one_q3_or_higher() -> void:
var max_q: int = 0
for key: String in snap50:
var s: Dictionary = snap50[key]
if s["biome_id"] != "ocean":
max_q = maxi(max_q, s["quality"])
assert_gte(max_q, 3, "at least one tile should reach Q3+ by turn 50")
func test_turn50_ecosystem_health_valid() -> void:
assert_gt(ecosystem.global_health, 0.0, "global health should be > 0")
assert_lte(ecosystem.global_health, 1.0, "global health should be <= 1")
func test_turn50_desert_tiles_low_canopy() -> void:
for key: String in snap50:
var s: Dictionary = snap50[key]
if s["biome_id"] == "desert":
assert_lt(s["canopy_cover"], 0.1, "desert tiles should have very low canopy")
# -- Creature lifecycle --
func test_turn50_predator_population_not_zero() -> void:
## Wolf population should oscillate, not crash to zero (Lotka-Volterra).
var wolf_sp: Dictionary = ecology_db.get_species_by_hash("wolf_predator_42")
if wolf_sp.is_empty():
pass_test("wolf species not found — skip (may be unregistered)")
return
var wolves: Array = ecology_db.get_creatures_by_species(wolf_sp.get("id", -1))
# At least some wolves should survive 50 turns if prey exists
var deer_sp: Dictionary = ecology_db.get_species_by_hash("deer_herbivore_42")
var deer: Array = ecology_db.get_creatures_by_species(deer_sp.get("id", -1))
# If deer survive, wolves should too (oscillation, not collapse)
if deer.size() > 0:
assert_gt(wolves.size(), 0, "wolf population should not crash to zero while prey exists")
func test_turn50_herbivore_exists() -> void:
## At least some deer should survive.
var deer_sp: Dictionary = ecology_db.get_species_by_hash("deer_herbivore_42")
if deer_sp.is_empty():
pass_test("deer species not found — skip")
return
var deer: Array = ecology_db.get_creatures_by_species(deer_sp.get("id", -1))
assert_gt(deer.size(), 0, "deer population should persist after 50 turns")
func test_turn50_creature_quality_progression() -> void:
## At least one creature should have progressed from Q2 to Q3 by turn 50.
var all_ids: Array = []
for axial: Vector2i in game_map.tiles:
var off: Vector2i = HexUtilsScript.axial_to_offset(axial)
for c: Dictionary in ecology_db.get_creatures_on_tile(off.x, off.y):
all_ids.append(c.get("id", -1))
var found_q3: bool = false
for cid: int in all_ids:
var cr: Dictionary = ecology_db.get_creature(cid)
if cr.get("quality", 1) >= 3:
found_q3 = true
break
assert_true(found_q3, "at least one creature should reach Q3 by turn 50")
# -- Flora-fauna interaction --
func test_turn50_habitat_correlates_with_flora() -> void:
## Tiles with more flora should generally have higher habitat suitability.
var high_flora: Array[float] = []
var low_flora: Array[float] = []
for key: String in snap50:
var s: Dictionary = snap50[key]
if s["biome_id"] == "ocean":
continue
var flora_sum: float = s["canopy_cover"] + s["undergrowth"] + s["fungi_network"]
if flora_sum > 0.5:
high_flora.append(s["habitat_suitability"])
elif flora_sum < 0.1:
low_flora.append(s["habitat_suitability"])
if high_flora.size() > 0 and low_flora.size() > 0:
var avg_high: float = 0.0
for v: float in high_flora:
avg_high += v
avg_high /= float(high_flora.size())
var avg_low: float = 0.0
for v: float in low_flora:
avg_low += v
avg_low /= float(low_flora.size())
assert_gt(avg_high, avg_low, "high-flora tiles should have higher habitat suitability")