diff --git a/engine/tests/unit/test_ecology_golden_vectors.gd b/engine/tests/unit/test_ecology_golden_vectors.gd new file mode 100644 index 00000000..5b7d7c9a --- /dev/null +++ b/engine/tests/unit/test_ecology_golden_vectors.gd @@ -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")