diff --git a/src/game/engine/tests/unit/test_combat_resolver.gd b/src/game/engine/tests/unit/test_combat_resolver.gd new file mode 100644 index 00000000..4b6cd432 --- /dev/null +++ b/src/game/engine/tests/unit/test_combat_resolver.gd @@ -0,0 +1,354 @@ +extends GutTest +## Combat resolver unit tests. +## Covers: melee attack, defender death, ranged no-retaliation, flanking, +## fortification, XP/promotion, city siege, combat preview. + +const CombatResolverScript: GDScript = preload( + "res://engine/src/modules/combat/combat_resolver.gd" +) +const UnitScript: GDScript = preload("res://engine/src/entities/unit.gd") +const CityScript: GDScript = preload("res://engine/src/entities/city.gd") +const PlayerScript: GDScript = preload("res://engine/src/entities/player.gd") +const TileScript: GDScript = preload("res://engine/src/map/tile.gd") +const GameMapScript: GDScript = preload("res://engine/src/map/game_map.gd") + +var _resolver: CombatResolverScript = null +var _player_a: PlayerScript = null +var _player_b: PlayerScript = null +var _game_map: GameMapScript = null + + +func before_all() -> void: + DataLoader.load_theme("age-of-dwarves") + + +func before_each() -> void: + _resolver = CombatResolverScript.new() + + _player_a = PlayerScript.new() + _player_a.index = 0 + _player_a.race_id = "dwarf" + _player_a.units = [] + _player_a.cities = [] + _player_a.researched_techs = [] + _player_a.pending_improvements = [] + + _player_b = PlayerScript.new() + _player_b.index = 1 + _player_b.race_id = "terrans" + _player_b.units = [] + _player_b.cities = [] + _player_b.researched_techs = [] + _player_b.pending_improvements = [] + + GameState.players = [_player_a, _player_b] + + _game_map = GameMapScript.new() + _game_map.initialize(20, 20, 1) + for x: int in range(20): + for y: int in range(20): + var tile: TileScript = TileScript.new() + tile.position = Vector2i(x, y) + tile.biome_id = "grassland" + tile.quality = 3 + _game_map.set_tile(Vector2i(x, y), tile) + + +func _make_unit( + owner_idx: int, + pos: Vector2i, + atk: int = 10, + dr: int = 2, + hp_val: int = 30, +) -> UnitScript: + var unit: UnitScript = UnitScript.new() + unit.id = "u_%d_%d_%d" % [owner_idx, pos.x, pos.y] + unit.type_id = "dwarf_spearman" + unit.owner = owner_idx + unit.position = pos + unit.hp = hp_val + unit.max_hp = hp_val + unit.bonus_attack = atk + unit.bonus_defense = dr + unit.unit_type = "military" + unit.movement_remaining = 2 + return unit + + +func _make_ranged_unit( + owner_idx: int, + pos: Vector2i, + atk: int = 10, + dr: int = 2, + hp_val: int = 30, +) -> UnitScript: + var unit: UnitScript = _make_unit(owner_idx, pos, atk, dr, hp_val) + unit.type_id = "dwarf_crossbowman" + return unit + + +func _make_city( + owner_idx: int, + pos: Vector2i, + city_hp_val: int = 200, +) -> CityScript: + var city: CityScript = CityScript.new() + city.id = "city_%d" % owner_idx + city.owner = owner_idx + city.position = pos + city.population = 1 + city.city_hp = city_hp_val + city.max_city_hp = city_hp_val + city.buildings = [] + city.is_capital = false + return city + + +# --------------------------------------------------------------------------- +# Basic melee attack +# --------------------------------------------------------------------------- + +func test_melee_attack_deals_damage() -> void: + var attacker: UnitScript = _make_unit(0, Vector2i(1, 0), 15, 2, 50) + var defender: UnitScript = _make_unit(1, Vector2i(2, 0), 10, 2, 50) + + var result: Dictionary = _resolver.resolve( + attacker, defender, _game_map, [attacker, defender] + ) + + assert_gt( + result.get("damage_dealt", 0), 0, + "Melee attack must deal positive damage" + ) + assert_lt(defender.hp, 50, "Defender HP must decrease after melee attack") + + +func test_melee_attacker_takes_retaliation() -> void: + var attacker: UnitScript = _make_unit(0, Vector2i(1, 0), 10, 2, 50) + var defender: UnitScript = _make_unit(1, Vector2i(2, 0), 10, 2, 50) + + var result: Dictionary = _resolver.resolve( + attacker, defender, _game_map, [attacker, defender] + ) + + assert_gt( + result.get("damage_taken", 0), 0, + "Melee attacker must take retaliation damage" + ) + assert_lt(attacker.hp, 50, "Attacker HP must decrease from retaliation") + + +# --------------------------------------------------------------------------- +# Defender / attacker death +# --------------------------------------------------------------------------- + +func test_defender_dies_when_hp_depleted() -> void: + var attacker: UnitScript = _make_unit(0, Vector2i(1, 0), 80, 5, 50) + var defender: UnitScript = _make_unit(1, Vector2i(2, 0), 3, 0, 5) + + var result: Dictionary = _resolver.resolve( + attacker, defender, _game_map, [attacker, defender] + ) + + assert_false( + result.get("defender_alive", true), + "Defender must be dead when HP depleted" + ) + assert_eq(defender.hp, 0, "Dead defender HP must be 0") + + +func test_attacker_dies_when_overpowered() -> void: + var attacker: UnitScript = _make_unit(0, Vector2i(1, 0), 1, 0, 3) + var defender: UnitScript = _make_unit(1, Vector2i(2, 0), 80, 5, 100) + + var result: Dictionary = _resolver.resolve( + attacker, defender, _game_map, [attacker, defender] + ) + + assert_false( + result.get("attacker_alive", true), + "Weak attacker must die from retaliation by strong defender" + ) + assert_eq(attacker.hp, 0, "Dead attacker HP must be 0") + + +# --------------------------------------------------------------------------- +# Ranged attack — no retaliation +# --------------------------------------------------------------------------- + +func test_ranged_attack_no_retaliation() -> void: + var attacker: UnitScript = _make_ranged_unit(0, Vector2i(1, 0), 15, 2, 50) + var defender: UnitScript = _make_unit(1, Vector2i(2, 0), 10, 2, 50) + + var result: Dictionary = _resolver.resolve( + attacker, defender, _game_map, [attacker, defender] + ) + + assert_gt(result.get("damage_dealt", 0), 0, "Ranged attack must deal damage") + assert_eq(attacker.hp, 50, "Ranged attacker must take no retaliation damage") + + +# --------------------------------------------------------------------------- +# Flanking bonus +# --------------------------------------------------------------------------- + +func test_flanking_ally_increases_damage() -> void: + var attacker: UnitScript = _make_unit(0, Vector2i(1, 1), 10, 2, 50) + var defender: UnitScript = _make_unit(1, Vector2i(2, 1), 5, 2, 100) + var flanker: UnitScript = _make_unit(0, Vector2i(3, 1), 10, 2, 50) + + var result: Dictionary = _resolver.resolve( + attacker, defender, _game_map, [attacker, defender, flanker] + ) + + assert_gt( + result.get("damage_dealt", 0), 0, + "Flanking attack must deal positive damage" + ) + + +# --------------------------------------------------------------------------- +# Fortification +# --------------------------------------------------------------------------- + +func test_fortification_bonus_values() -> void: + var unit: UnitScript = _make_unit(0, Vector2i(1, 0)) + assert_eq(unit.get_fortification_bonus(), 0, "Unfortified must be 0 DR") + unit.fortified_turns = 1 + assert_eq(unit.get_fortification_bonus(), 3, "1-turn fortified must be +3 DR") + unit.fortified_turns = 2 + assert_eq(unit.get_fortification_bonus(), 5, "2+ turn fortified must be +5 DR") + + +# --------------------------------------------------------------------------- +# XP and promotion +# --------------------------------------------------------------------------- + +func test_combat_awards_xp() -> void: + var attacker: UnitScript = _make_unit(0, Vector2i(1, 0), 15, 2, 50) + var defender: UnitScript = _make_unit(1, Vector2i(2, 0), 10, 2, 50) + + _resolver.resolve(attacker, defender, _game_map, [attacker, defender]) + + assert_true( + attacker.xp > 0 or defender.xp > 0, + "At least one surviving combatant must gain XP" + ) + + +func test_xp_kill_bonus_on_defender_death() -> void: + var attacker: UnitScript = _make_unit(0, Vector2i(1, 0), 80, 5, 50) + var defender: UnitScript = _make_unit(1, Vector2i(2, 0), 3, 0, 5) + + var result: Dictionary = _resolver.resolve( + attacker, defender, _game_map, [attacker, defender] + ) + + assert_false(result.get("defender_alive", true), "Defender must die") + assert_gte( + result.get("attacker_xp", 0), + CombatResolverScript.XP_ATTACKER_BASE, + "Attacker must earn at least base XP from killing" + ) + + +func test_promotion_threshold() -> void: + var unit: UnitScript = _make_unit(0, Vector2i(1, 0)) + unit.xp = 14 + assert_false(unit.can_promote(), "14 XP must not reach threshold 15") + unit.xp = 15 + assert_true(unit.can_promote(), "15 XP must reach promotion threshold") + + +# --------------------------------------------------------------------------- +# City siege +# --------------------------------------------------------------------------- + +func test_siege_deals_city_hp_damage() -> void: + var attacker: UnitScript = _make_unit(0, Vector2i(1, 0), 20, 5, 50) + var city: CityScript = _make_city(1, Vector2i(2, 0), 200) + + var result: Dictionary = _resolver.resolve( + attacker, city, _game_map, [attacker] + ) + + assert_gt(result.get("city_damage", 0), 0, "Siege must deal city HP damage") + assert_lt(city.city_hp, 200, "City HP must decrease after siege") + + +func test_city_captured_when_hp_zero_melee() -> void: + var attacker: UnitScript = _make_unit(0, Vector2i(1, 0), 80, 10, 100) + var city: CityScript = _make_city(1, Vector2i(2, 0), 1) + _player_b.cities = [city] + + var result: Dictionary = _resolver.resolve( + attacker, city, _game_map, [attacker] + ) + + assert_true( + result.get("captured", false), + "City must be captured when HP=0 and melee unit attacks" + ) + + +func test_city_not_captured_by_ranged() -> void: + var attacker: UnitScript = _make_ranged_unit(0, Vector2i(1, 0), 80, 10, 100) + var city: CityScript = _make_city(1, Vector2i(2, 0), 1) + _player_b.cities = [city] + + var result: Dictionary = _resolver.resolve( + attacker, city, _game_map, [attacker] + ) + + assert_false( + result.get("captured", false), + "Ranged unit must not capture a city" + ) + + +func test_walls_reduce_city_damage() -> void: + var atk_a: UnitScript = _make_unit(0, Vector2i(1, 0), 15, 2, 50) + var city_bare: CityScript = _make_city(1, Vector2i(2, 0), 200) + var result_bare: Dictionary = _resolver.resolve( + atk_a, city_bare, _game_map, [atk_a] + ) + + var atk_b: UnitScript = _make_unit(0, Vector2i(1, 1), 15, 2, 50) + var city_walls: CityScript = _make_city(1, Vector2i(2, 1), 200) + city_walls.buildings = ["walls"] + var result_walls: Dictionary = _resolver.resolve( + atk_b, city_walls, _game_map, [atk_b] + ) + + assert_lte( + result_walls.get("city_damage", 0), + result_bare.get("city_damage", 0), + "Walled city must take <= damage than unwalled" + ) + + +# --------------------------------------------------------------------------- +# Combat preview +# --------------------------------------------------------------------------- + +func test_preview_returns_expected_keys() -> void: + var attacker: UnitScript = _make_unit(0, Vector2i(1, 0), 15, 2, 50) + var defender: UnitScript = _make_unit(1, Vector2i(2, 0), 10, 2, 50) + + var preview: Dictionary = _resolver.preview( + attacker, defender, _game_map, [attacker, defender] + ) + + assert_true(preview.has("avg_damage_dealt"), "Must have avg_damage_dealt") + assert_true(preview.has("avg_damage_taken"), "Must have avg_damage_taken") + assert_true(preview.has("defender_survives"), "Must have defender_survives") + + +func test_preview_does_not_modify_units() -> void: + var attacker: UnitScript = _make_unit(0, Vector2i(1, 0), 15, 2, 50) + var defender: UnitScript = _make_unit(1, Vector2i(2, 0), 10, 2, 50) + + _resolver.preview(attacker, defender, _game_map, [attacker, defender]) + + assert_eq(attacker.hp, 50, "Preview must not modify attacker HP") + assert_eq(defender.hp, 50, "Preview must not modify defender HP") diff --git a/src/game/engine/tests/unit/test_combat_resolver.gd.uid b/src/game/engine/tests/unit/test_combat_resolver.gd.uid new file mode 100644 index 00000000..ff294b9a --- /dev/null +++ b/src/game/engine/tests/unit/test_combat_resolver.gd.uid @@ -0,0 +1 @@ +uid://kck6skn6qy83