diff --git a/engine/tests/unit/test_species_generation.gd b/engine/tests/unit/test_species_generation.gd new file mode 100644 index 00000000..ae190f8c --- /dev/null +++ b/engine/tests/unit/test_species_generation.gd @@ -0,0 +1,178 @@ +extends GutTest +## M2b Block 1 verification — species generation diversity and constraint validation. +## Tests 1.5 (diversity) and 1.6 (constraint validation) from the M2b task list. + +const SpeciesGeneratorScript = preload("res://engine/src/models/world/species_generator.gd") +const TraitSetScript = preload("res://engine/src/models/world/species_traits.gd") + +const ALL_BIOMES: Array = [ + "deep_ocean", "shallow_ocean", "coral_reef", "estuary", "lake", "pond", + "river", "mangrove", "tropical_rainforest", "tropical_dry_forest", + "savanna", "desert", "temperate_forest", "temperate_grassland", + "chaparral", "swamp", "bog", "boreal_forest", "tundra", "polar_desert", + "montane_forest", "cloud_forest", "alpine_meadow", "alpine_tundra", + "permanent_ice", "subterranean", +] + +const TEST_SEEDS: Array = [42, 137, 256, 404, 512, 777, 999, 1234, 2048, 3141] +const MIN_PRODUCERS: int = 3 +const MIN_HERBIVORES: int = 3 +const MIN_PREDATORS: int = 2 + + +func before_all() -> void: + DataLoader.load_theme("age-of-four") + + +## 1.5: Each biome generates at least 3 producers + 3 herbivores + 2 predators +## across 10 seeds. +func test_species_diversity_per_biome() -> void: + var report: Array[String] = [] + var failures: Array[String] = [] + + for biome_id: String in ALL_BIOMES: + var weights: Dictionary = DataLoader.get_biome_trait_weights(biome_id) + var total_producers: int = 0 + var total_herbivores: int = 0 + var total_predators: int = 0 + var total_species: int = 0 + + for seed_val: int in TEST_SEEDS: + var species: Array = SpeciesGeneratorScript.generate( + biome_id, 3, seed_val, weights + ) + total_species += species.size() + for ts: Variant in species: + match ts.diet: + "producer": + total_producers += 1 + "herbivore": + total_herbivores += 1 + "carnivore", "omnivore": + total_predators += 1 + + var line: String = ( + "%s: %d species, %d producers, %d herbivores, %d predators" + % [biome_id, total_species, total_producers, total_herbivores, total_predators] + ) + report.append(line) + + if total_producers < MIN_PRODUCERS: + failures.append("%s: only %d producers (need %d)" % [biome_id, total_producers, MIN_PRODUCERS]) + if total_herbivores < MIN_HERBIVORES: + failures.append("%s: only %d herbivores (need %d)" % [biome_id, total_herbivores, MIN_HERBIVORES]) + if total_predators < MIN_PREDATORS: + failures.append("%s: only %d predators (need %d)" % [biome_id, total_predators, MIN_PREDATORS]) + + # Print diversity report + gut.p("=== Species Diversity Report (10 seeds, quality 3) ===") + for line: String in report: + gut.p(line) + + if failures.size() > 0: + gut.p("=== FAILURES ===") + for f: String in failures: + gut.p(f) + fail_test("Diversity check failed for %d biomes" % failures.size()) + else: + pass_test("All biomes meet diversity minimums") + + +## 1.6: Zero invalid species generated across all biomes on 10 seeds. +func test_zero_invalid_species() -> void: + var invalid_count: int = 0 + var total_count: int = 0 + + for biome_id: String in ALL_BIOMES: + var weights: Dictionary = DataLoader.get_biome_trait_weights(biome_id) + + for seed_val: int in TEST_SEEDS: + var species: Array = SpeciesGeneratorScript.generate( + biome_id, 3, seed_val, weights + ) + for ts: Variant in species: + total_count += 1 + if not ts.validate(): + invalid_count += 1 + gut.p( + "INVALID: %s in %s (seed %d)" % [ts.trait_hash, biome_id, seed_val] + ) + + gut.p("Total species generated: %d, invalid: %d" % [total_count, invalid_count]) + assert_eq(invalid_count, 0, "All generated species must pass validate()") + + +## Verify amphibious adjacency boost produces amphibious species. +func test_amphibious_adjacency_boost() -> void: + var weights: Dictionary = DataLoader.get_biome_trait_weights("temperate_forest") + var amphibious_without: int = 0 + var amphibious_with: int = 0 + var trials: int = 10 + + for seed_val: int in TEST_SEEDS: + var normal: Array = SpeciesGeneratorScript.generate( + "temperate_forest", 3, seed_val, weights, false + ) + var boosted: Array = SpeciesGeneratorScript.generate( + "temperate_forest", 3, seed_val, weights, true + ) + for ts: Variant in normal: + if ts.habitat == "amphibious": + amphibious_without += 1 + for ts: Variant in boosted: + if ts.habitat == "amphibious": + amphibious_with += 1 + + gut.p( + "Amphibious species: without boost=%d, with boost=%d (across %d seeds)" + % [amphibious_without, amphibious_with, trials] + ) + assert_true( + amphibious_with >= amphibious_without, + "Adjacency boost should not reduce amphibious species count" + ) + + +## Verify migration pattern detection. +func test_migration_pattern_detection() -> void: + var flying_herd := TraitSetScript.new() + flying_herd.size = "medium" + flying_herd.diet = "herbivore" + flying_herd.habitat = "aerial" + flying_herd.locomotion = "flying" + flying_herd.reproduction = "k_strategy" + flying_herd.thermal = "warm_blooded" + flying_herd.social = "herd" + assert_eq( + SpeciesGeneratorScript.get_migration_pattern(flying_herd), + "seasonal", + "Aerial herd should get seasonal migration" + ) + + var walking_pack := TraitSetScript.new() + walking_pack.size = "medium" + walking_pack.diet = "carnivore" + walking_pack.habitat = "terrestrial" + walking_pack.locomotion = "walking" + walking_pack.reproduction = "k_strategy" + walking_pack.thermal = "warm_blooded" + walking_pack.social = "pack" + assert_eq( + SpeciesGeneratorScript.get_migration_pattern(walking_pack), + null, + "Walking pack should not get seasonal migration" + ) + + var flying_swarm := TraitSetScript.new() + flying_swarm.size = "tiny" + flying_swarm.diet = "herbivore" + flying_swarm.habitat = "terrestrial" + flying_swarm.locomotion = "flying" + flying_swarm.reproduction = "r_strategy" + flying_swarm.thermal = "cold_blooded" + flying_swarm.social = "swarm" + assert_eq( + SpeciesGeneratorScript.get_migration_pattern(flying_swarm), + "seasonal", + "Flying swarm should get seasonal migration" + )