diff --git a/src/game/engine/tests/unit/test_population_stability.gd.uid b/src/game/engine/tests/unit/test_population_stability.gd.uid new file mode 100644 index 00000000..b86cd3fd --- /dev/null +++ b/src/game/engine/tests/unit/test_population_stability.gd.uid @@ -0,0 +1 @@ +uid://42sl40oguaro diff --git a/src/game/engine/tests/unit/test_save_manager.gd b/src/game/engine/tests/unit/test_save_manager.gd new file mode 100644 index 00000000..72a7567d --- /dev/null +++ b/src/game/engine/tests/unit/test_save_manager.gd @@ -0,0 +1,234 @@ +extends GutTest +## SaveManager unit tests. +## Verifies save/load round-trip, slot listing, and edge cases. + +const SaveManagerScript: GDScript = preload("res://engine/src/core/save_manager.gd") +const PlayerScript: GDScript = preload("res://engine/src/entities/player.gd") +const CityScript: GDScript = preload("res://engine/src/entities/city.gd") +const UnitScript: GDScript = preload("res://engine/src/entities/unit.gd") +const GameMapScript: GDScript = preload("res://engine/src/map/game_map.gd") +const TileScript: GDScript = preload("res://engine/src/map/tile.gd") + +const TEST_SLOT: String = "_gut_test_slot" + + +func before_all() -> void: + DataLoader.load_theme("age-of-dwarves") + + +func after_each() -> void: + ## Clean up any test save files after each test. + SaveManagerScript.delete_save(TEST_SLOT) + GameState.players = [] + GameState.layers = [] + GameState.turn_number = 1 + GameState.current_player_index = 0 + + +func _make_player(idx: int, gold_amount: int, race: String) -> RefCounted: + var player: PlayerScript = PlayerScript.new() + player.index = idx + player.player_name = "Test Player %d" % idx + player.race_id = race + player.is_player_controlled = true + player.gold = gold_amount + player.gold_per_turn = 10 + player.science_per_turn = 5 + player.culture_per_turn = 3 + player.culture_total = 42 + player.happiness = 7 + return player + + +func _make_city( + owner_idx: int, pos: Vector2i, city_name: String, pop: int, +) -> CityScript: + var city: CityScript = CityScript.new() + city.id = "%d_%d_%d" % [owner_idx, pos.x, pos.y] + city.city_name = city_name + city.owner = owner_idx + city.position = pos + city.population = pop + city.owned_tiles = [pos] + return city + + +func _make_unit(owner_idx: int, type_id: String, pos: Vector2i) -> UnitScript: + var unit: UnitScript = UnitScript.new() + unit.id = "u_%d_%d_%d" % [owner_idx, pos.x, pos.y] + unit.type_id = type_id + unit.owner = owner_idx + unit.position = pos + unit.hp = 10 + unit.max_hp = 10 + return unit + + +func _make_game_map() -> GameMapScript: + var game_map: GameMapScript = GameMapScript.new() + game_map.initialize(10, 10, 0) + for col: int in range(10): + for row: int in range(10): + var tile: TileScript = TileScript.new() + tile.position = Vector2i(col, row) + tile.biome_id = "plains" + game_map.set_tile(tile.position, tile) + return game_map + + +func _setup_game_state_with_map() -> void: + ## Set up GameState with a single layer containing a 10x10 map. + var game_map: GameMapScript = _make_game_map() + GameState.layers = [{"game_map": game_map, "cities": [], "units": []}] + + +func test_save_game_writes_file() -> void: + ## save_game() must write a file and return true. + var player: RefCounted = _make_player(0, 100, "dwarf") + GameState.players = [player] + _setup_game_state_with_map() + + var success: bool = SaveManagerScript.save_game(TEST_SLOT) + assert_true(success, "save_game must return true on success") + + var path: String = SaveManagerScript.SAVE_DIR + TEST_SLOT + SaveManagerScript.SAVE_EXTENSION + assert_true( + FileAccess.file_exists(path), + "Save file must exist at '%s'" % path, + ) + + +func test_load_game_reads_back() -> void: + ## load_game() must restore GameState from a previously saved file. + var player: RefCounted = _make_player(0, 250, "dwarf") + GameState.players = [player] + _setup_game_state_with_map() + GameState.turn_number = 15 + + SaveManagerScript.save_game(TEST_SLOT) + + ## Wipe state to verify load restores it. + GameState.players = [] + GameState.turn_number = 1 + GameState.layers = [] + + var success: bool = SaveManagerScript.load_game(TEST_SLOT) + assert_true(success, "load_game must return true") + assert_eq(GameState.turn_number, 15, "Turn number must be restored") + assert_eq( + GameState.players.size(), 1, + "Must restore exactly one player", + ) + + var restored: RefCounted = GameState.players[0] + assert_eq(restored.gold, 250, "Player gold must round-trip") + assert_eq(restored.race_id, "dwarf", "Player race_id must round-trip") + + +func test_round_trip_gold_cities_units() -> void: + ## Full round-trip: Player with gold=500, 2 cities, 3 units. + var player: RefCounted = _make_player(0, 500, "terrans") + var city_a: CityScript = _make_city(0, Vector2i(2, 3), "Alpha", 5) + var city_b: CityScript = _make_city(0, Vector2i(5, 6), "Beta", 3) + player.cities = [city_a, city_b] + + var unit_a: UnitScript = _make_unit(0, "dwarf_warrior", Vector2i(1, 1)) + var unit_b: UnitScript = _make_unit(0, "dwarf_warrior", Vector2i(2, 2)) + var unit_c: UnitScript = _make_unit(0, "dwarf_warrior", Vector2i(3, 3)) + player.units = [unit_a, unit_b, unit_c] + + GameState.players = [player] + GameState.turn_number = 42 + _setup_game_state_with_map() + + SaveManagerScript.save_game(TEST_SLOT) + + ## Wipe and reload. + GameState.players = [] + GameState.turn_number = 1 + GameState.layers = [] + + SaveManagerScript.load_game(TEST_SLOT) + + var restored: RefCounted = GameState.players[0] + assert_eq(restored.gold, 500, "Gold must be 500 after round-trip") + assert_eq(restored.units.size(), 3, "Must restore 3 units") + assert_eq(restored.player_name, "Test Player 0", "Player name must round-trip") + + +func test_get_save_slots_returns_saved_slot() -> void: + ## After saving, get_save_slots() must include our test slot. + var player: RefCounted = _make_player(0, 100, "dwarf") + GameState.players = [player] + _setup_game_state_with_map() + + SaveManagerScript.save_game(TEST_SLOT) + + var slots: Array[Dictionary] = SaveManagerScript.get_save_slots() + var found: bool = false + for slot: Dictionary in slots: + if slot.get("slot", "") == TEST_SLOT: + found = true + break + assert_true(found, "get_save_slots must include the test slot") + + +func test_save_with_no_players_succeeds() -> void: + ## Saving with empty game state must not crash. + GameState.players = [] + _setup_game_state_with_map() + + var success: bool = SaveManagerScript.save_game(TEST_SLOT) + assert_true(success, "Saving with empty player list must succeed") + + +func test_load_nonexistent_slot_fails() -> void: + ## Loading a slot that was never saved must return false. + var success: bool = SaveManagerScript.load_game("_gut_nonexistent_slot") + assert_false(success, "Loading a nonexistent save must return false") + + +func test_delete_save_removes_file() -> void: + ## delete_save() must remove the file, and subsequent load must fail. + var player: RefCounted = _make_player(0, 100, "dwarf") + GameState.players = [player] + _setup_game_state_with_map() + + SaveManagerScript.save_game(TEST_SLOT) + var deleted: bool = SaveManagerScript.delete_save(TEST_SLOT) + assert_true(deleted, "delete_save must return true for existing slot") + + var load_result: bool = SaveManagerScript.load_game(TEST_SLOT) + assert_false(load_result, "Loading deleted save must fail") + + +func test_slot_number_convenience() -> void: + ## save_slot() / load_slot() must work with numeric slot indices. + var player: RefCounted = _make_player(0, 777, "orc") + GameState.players = [player] + _setup_game_state_with_map() + + var save_ok: bool = SaveManagerScript.save_slot(0) + assert_true(save_ok, "save_slot(0) must succeed") + + GameState.players = [] + GameState.layers = [] + + var load_ok: bool = SaveManagerScript.load_slot(0) + assert_true(load_ok, "load_slot(0) must succeed") + assert_eq(GameState.players[0].gold, 777, "Gold must round-trip through slot 0") + + ## Cleanup numbered slot. + SaveManagerScript.delete_save("slot_0") + + +func test_invalid_slot_number_rejected() -> void: + ## Slot numbers outside [0, MAX_SLOTS) must fail. + assert_false( + SaveManagerScript.save_slot(-1), + "Negative slot index must be rejected", + ) + assert_false( + SaveManagerScript.save_slot(SaveManagerScript.MAX_SLOTS), + "Slot index >= MAX_SLOTS must be rejected", + ) diff --git a/src/game/engine/tests/unit/test_save_manager.gd.uid b/src/game/engine/tests/unit/test_save_manager.gd.uid new file mode 100644 index 00000000..d9a44b8d --- /dev/null +++ b/src/game/engine/tests/unit/test_save_manager.gd.uid @@ -0,0 +1 @@ +uid://b8yye3lgwolqv diff --git a/src/game/engine/tests/unit/test_species_generation.gd.uid b/src/game/engine/tests/unit/test_species_generation.gd.uid new file mode 100644 index 00000000..dea182a6 --- /dev/null +++ b/src/game/engine/tests/unit/test_species_generation.gd.uid @@ -0,0 +1 @@ +uid://ctgosphycgfwn diff --git a/src/game/engine/tests/unit/test_tech_web.gd b/src/game/engine/tests/unit/test_tech_web.gd new file mode 100644 index 00000000..f63303c9 --- /dev/null +++ b/src/game/engine/tests/unit/test_tech_web.gd @@ -0,0 +1,151 @@ +extends GutTest +## Tech Web unit tests. +## Verifies prerequisite graph construction, availability logic, and the +## masonry → walls unlock path. +## +## Acceptance criterion: "Research 'masonry' → city Walls becomes buildable." + +const TechWebScript: GDScript = preload("res://engine/src/modules/tech/tech_web.gd") +const PlayerScript: GDScript = preload("res://engine/src/entities/player.gd") +const CityScript: GDScript = preload("res://engine/src/entities/city.gd") + +var _tech_web: TechWebScript = null +var _player: PlayerScript = null + + +func before_all() -> void: + DataLoader.load_theme("age-of-dwarves") + + +func before_each() -> void: + _tech_web = TechWebScript.new() + _tech_web.initialize() + _player = PlayerScript.new() + _player.index = 0 + _player.race_id = "dwarf" + _player.researched_techs = [] + _player.researching = "" + _player.research_progress = 0 + _player.schools = [] + GameState.players = [_player] + + +func test_tech_web_loads_techs() -> void: + assert_gt(_tech_web.get_tech_count(), 0, "TechWeb must load at least one tech") + + +func test_can_research_starter_techs() -> void: + ## Tier-1 techs with no prerequisites must be available at game start + ## regardless of what other techs exist. + var available: Array[String] = _tech_web.get_available_techs(0) + assert_false(available.is_empty(), "At least one tech must be available at game start") + + ## Every available tech must have all prerequisites satisfied (vacuously for + ## starter techs, which have an empty requires list). + for tech_id: String in available: + var prereqs: Array[String] = _tech_web.get_prereqs(tech_id) + for prereq_id: String in prereqs: + assert_true( + _player.has_tech(prereq_id), + "Available tech '%s' has unmet prereq '%s'" % [tech_id, prereq_id] + ) + + ## Stonecutting is the masonry pillar's tier-1 tech — must be available. + assert_true( + _tech_web.can_research("stonecutting", 0), + "'stonecutting' must be available with no techs researched" + ) + + +func test_locked_tech_not_available_without_prereq() -> void: + ## 'masonry' requires 'stonecutting' — must not be researchable before stonecutting. + assert_false( + _tech_web.can_research("masonry", 0), + "'masonry' must be locked until 'stonecutting' is researched" + ) + + +func test_masonry_available_after_stonecutting() -> void: + _player.add_tech("stonecutting") + assert_true( + _tech_web.can_research("masonry", 0), + "'masonry' must be available once 'stonecutting' is researched" + ) + + +func test_masonry_unlocks_walls() -> void: + ## Core acceptance criterion: Research 'masonry' → city Walls becomes buildable. + var city: CityScript = CityScript.new() + city.owner = 0 + city.buildings = [] + + ## Before masonry: walls must not be buildable. + assert_false( + city.can_build("walls", _player), + "'walls' must not be buildable before 'masonry' is researched" + ) + + ## Research both stonecutting (prerequisite) and masonry. + _player.add_tech("stonecutting") + _player.add_tech("masonry") + + ## After masonry: walls must be buildable. + assert_true( + city.can_build("walls", _player), + "'walls' must be buildable after 'masonry' is researched" + ) + + +func test_start_research_sets_current() -> void: + var success: bool = _tech_web.start_research("stonecutting", 0) + assert_true(success, "start_research must succeed for an available tech") + assert_eq(_player.researching, "stonecutting", "researching field must be set") + assert_eq(_player.research_progress, 0, "research_progress must reset to 0") + + +func test_add_science_completes_research() -> void: + _tech_web.start_research("stonecutting", 0) + var tech_data: Dictionary = _tech_web.get_tech_data("stonecutting") + var cost: int = tech_data.get("cost", 0) + assert_gt(cost, 0, "stonecutting must have a positive cost") + + _tech_web.add_science(cost, 0) + + assert_true( + _player.has_tech("stonecutting"), + "Player must have stonecutting after full science input" + ) + assert_eq(_player.researching, "", "researching must be cleared after completion") + + +func test_progress_fraction_reflects_input() -> void: + _tech_web.start_research("stonecutting", 0) + var tech_data: Dictionary = _tech_web.get_tech_data("stonecutting") + var cost: int = tech_data.get("cost", 1) + + _tech_web.add_science(cost / 2, 0) + var fraction: float = _tech_web.get_progress_fraction(0) + assert_gt(fraction, 0.0, "Progress fraction must be above 0 after partial science") + assert_lt(fraction, 1.0, "Progress fraction must be below 1 when research is incomplete") + + +func test_get_turns_remaining() -> void: + _tech_web.start_research("stonecutting", 0) + var tech_data: Dictionary = _tech_web.get_tech_data("stonecutting") + var cost: int = tech_data.get("cost", 1) + var science_per_turn: int = 5 + + var turns: int = _tech_web.get_turns_remaining(0, science_per_turn) + var expected: int = ceili(float(cost) / float(science_per_turn)) + assert_eq(turns, expected, "get_turns_remaining must match ceil(cost / spt) formula") + + +func test_overflow_carries_to_next() -> void: + ## Research stonecutting with excess science — overflow must carry to research_progress. + _tech_web.start_research("stonecutting", 0) + var cost: int = _tech_web.get_tech_data("stonecutting").get("cost", 20) + var overflow: int = 7 + + _tech_web.add_science(cost + overflow, 0) + assert_true(_player.has_tech("stonecutting"), "stonecutting must complete") + assert_eq(_player.research_progress, overflow, "Overflow science must carry forward") diff --git a/src/game/engine/tests/unit/test_tech_web.gd.uid b/src/game/engine/tests/unit/test_tech_web.gd.uid new file mode 100644 index 00000000..b6ba6c02 --- /dev/null +++ b/src/game/engine/tests/unit/test_tech_web.gd.uid @@ -0,0 +1 @@ +uid://c3r835g0aik5w diff --git a/src/game/engine/tests/unit/test_victory_manager.gd b/src/game/engine/tests/unit/test_victory_manager.gd new file mode 100644 index 00000000..bf6b9237 --- /dev/null +++ b/src/game/engine/tests/unit/test_victory_manager.gd @@ -0,0 +1,222 @@ +extends GutTest +## VictoryManager unit tests. +## Verifies score calculation, domination detection, and score-leader logic. + +const VictoryManagerScript: GDScript = preload( + "res://engine/src/modules/victory/victory_manager.gd" +) +const PlayerScript: GDScript = preload("res://engine/src/entities/player.gd") +const CityScript: GDScript = preload("res://engine/src/entities/city.gd") +const UnitScript: GDScript = preload("res://engine/src/entities/unit.gd") +const GameMapScript: GDScript = preload("res://engine/src/map/game_map.gd") +const TileScript: GDScript = preload("res://engine/src/map/tile.gd") + +var _vm: VictoryManagerScript = null +var _game_map: GameMapScript = null + + +func before_all() -> void: + DataLoader.load_theme("age-of-dwarves") + + +func before_each() -> void: + _vm = VictoryManagerScript.new() + _game_map = GameMapScript.new() + _game_map.initialize(10, 10, 0) + for col: int in range(10): + for row: int in range(10): + var tile: TileScript = TileScript.new() + tile.position = Vector2i(col, row) + tile.biome_id = "plains" + _game_map.set_tile(tile.position, tile) + + GameState.players = [] + GameState.game_settings = { + "victory_domination": true, + "victory_score": true, + "turn_limit_enabled": false, + "turn_limit": 300, + } + GameState.turn_number = 1 + + +func after_each() -> void: + GameState.players = [] + GameState.turn_number = 1 + + +func _make_player(idx: int) -> PlayerScript: + var player: PlayerScript = PlayerScript.new() + player.index = idx + player.player_name = "Player %d" % idx + player.race_id = "dwarf" + player.is_player_controlled = (idx == 0) + player.researched_techs = [] + return player + + +func _make_city(owner_idx: int, pos: Vector2i, pop: int) -> CityScript: + var city: CityScript = CityScript.new() + city.id = "%d_%d_%d" % [owner_idx, pos.x, pos.y] + city.city_name = "City_%d" % owner_idx + city.owner = owner_idx + city.position = pos + city.population = pop + city.owned_tiles = [pos] + return city + + +func _make_unit(owner_idx: int, pos: Vector2i) -> UnitScript: + var unit: UnitScript = UnitScript.new() + unit.id = "u_%d_%d_%d" % [owner_idx, pos.x, pos.y] + unit.type_id = "dwarf_warrior" + unit.owner = owner_idx + unit.position = pos + return unit + + +func test_score_positive_for_player_with_cities_and_units() -> void: + ## get_score() must return a positive value for a player with cities + units. + var p: PlayerScript = _make_player(0) + var city: CityScript = _make_city(0, Vector2i(2, 2), 3) + p.cities = [city] + var unit: UnitScript = _make_unit(0, Vector2i(1, 1)) + p.units = [unit] + p.researched_techs = ["stonecutting"] + GameState.players = [p] + + var score: int = _vm.get_score(0) + assert_gt(score, 0, "Score must be positive for player with cities, units, and techs") + + ## Verify formula: cities*10 + pop*2 + techs*5 + units*1 + var expected: int = ( + 1 * VictoryManagerScript.SCORE_CITY + + 3 * VictoryManagerScript.SCORE_POP + + 1 * VictoryManagerScript.SCORE_TECH + + 1 * VictoryManagerScript.SCORE_UNIT + ) + assert_eq(score, expected, "Score must match formula: %d" % expected) + + +func test_score_zero_for_empty_player() -> void: + ## A player with no cities, units, or techs must score 0. + var p: PlayerScript = _make_player(0) + GameState.players = [p] + + var score: int = _vm.get_score(0) + assert_eq(score, 0, "Empty player must score 0") + + +func test_check_victory_returns_empty_when_game_ongoing() -> void: + ## With two active players both holding cities, no victory condition is met. + var p0: PlayerScript = _make_player(0) + var p1: PlayerScript = _make_player(1) + p0.cities = [_make_city(0, Vector2i(1, 1), 2)] + p1.cities = [_make_city(1, Vector2i(5, 5), 2)] + GameState.players = [p0, p1] + + var result: String = _vm.check_victory(0, _game_map) + assert_eq(result, "", "No victory condition should be met during ongoing game") + + +func test_domination_all_cities() -> void: + ## Domination: player 0 controls all cities (player 1 has none left). + var p0: PlayerScript = _make_player(0) + var p1: PlayerScript = _make_player(1) + p0.cities = [_make_city(0, Vector2i(2, 2), 5)] + p1.cities = [] ## All cities captured + GameState.players = [p0, p1] + + var result: String = _vm.check_victory(0, _game_map) + assert_eq(result, "domination", "Player controlling all cities must trigger domination") + + +func test_domination_land_majority() -> void: + ## Domination: player 0 owns > 80% of all land tiles. + var p0: PlayerScript = _make_player(0) + var p1: PlayerScript = _make_player(1) + p0.cities = [_make_city(0, Vector2i(0, 0), 1)] + p1.cities = [_make_city(1, Vector2i(9, 9), 1)] + GameState.players = [p0, p1] + + ## Assign > 80% of tiles to player 0. + var total_tiles: int = _game_map.tiles.size() + var threshold_count: int = int(total_tiles * 0.85) ## well above 80% + var assigned: int = 0 + for tile: Variant in _game_map.tiles.values(): + if assigned < threshold_count: + tile.owner = 0 + assigned += 1 + else: + tile.owner = 1 + + var result: String = _vm.check_victory(0, _game_map) + assert_eq(result, "domination", "Player owning >80% land must trigger domination") + + +func test_no_domination_below_threshold() -> void: + ## Owning exactly 50% of tiles must NOT trigger domination. + var p0: PlayerScript = _make_player(0) + var p1: PlayerScript = _make_player(1) + p0.cities = [_make_city(0, Vector2i(0, 0), 1)] + p1.cities = [_make_city(1, Vector2i(9, 9), 1)] + GameState.players = [p0, p1] + + var total_tiles: int = _game_map.tiles.size() + var half: int = total_tiles / 2 + var assigned: int = 0 + for tile: Variant in _game_map.tiles.values(): + if assigned < half: + tile.owner = 0 + else: + tile.owner = 1 + assigned += 1 + + var result: String = _vm.check_victory(0, _game_map) + assert_eq(result, "", "50% land ownership must not trigger domination") + + +func test_score_leader_wins_at_turn_limit() -> void: + ## When turn limit is reached, the highest-scoring player wins via "score". + var p0: PlayerScript = _make_player(0) + var p1: PlayerScript = _make_player(1) + p0.cities = [_make_city(0, Vector2i(1, 1), 10)] + p0.researched_techs = ["stonecutting", "masonry"] + p0.units = [_make_unit(0, Vector2i(2, 2))] + + p1.cities = [_make_city(1, Vector2i(5, 5), 1)] + p1.researched_techs = [] + p1.units = [] + + GameState.players = [p0, p1] + GameState.game_settings["turn_limit_enabled"] = true + GameState.game_settings["turn_limit"] = 100 + GameState.turn_number = 101 ## past the limit + + var result_p0: String = _vm.check_victory(0, _game_map) + assert_eq(result_p0, "score", "Score leader must win when turn limit is exceeded") + + var result_p1: String = _vm.check_victory(1, _game_map) + assert_eq(result_p1, "", "Non-leader must not win via score") + + +func test_get_scores_multiple_players() -> void: + ## get_scores() must return a dictionary keyed by player index. + var p0: PlayerScript = _make_player(0) + var p1: PlayerScript = _make_player(1) + p0.cities = [_make_city(0, Vector2i(1, 1), 5)] + p0.units = [_make_unit(0, Vector2i(2, 2))] + p1.cities = [_make_city(1, Vector2i(5, 5), 2)] + GameState.players = [p0, p1] + + var scores: Dictionary = _vm.get_scores() + assert_has(scores, 0, "Scores dict must have key for player 0") + assert_has(scores, 1, "Scores dict must have key for player 1") + assert_gt(scores[0], scores[1], "Player 0 must outscore player 1") + + +func test_score_invalid_player_returns_zero() -> void: + ## get_score() for a non-existent player index must return 0. + GameState.players = [] + var score: int = _vm.get_score(99) + assert_eq(score, 0, "Score for non-existent player must be 0") diff --git a/src/game/engine/tests/unit/test_victory_manager.gd.uid b/src/game/engine/tests/unit/test_victory_manager.gd.uid new file mode 100644 index 00000000..01f6443b --- /dev/null +++ b/src/game/engine/tests/unit/test_victory_manager.gd.uid @@ -0,0 +1 @@ +uid://vbi0o87pegfy