test(combat-resolver): ✅ Update test cases to validate new combat resolution logic, including edge cases in test_combat_resolver.gd
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
893fd2e524
commit
d3d6dccd22
2 changed files with 355 additions and 0 deletions
354
src/game/engine/tests/unit/test_combat_resolver.gd
Normal file
354
src/game/engine/tests/unit/test_combat_resolver.gd
Normal file
|
|
@ -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")
|
||||
1
src/game/engine/tests/unit/test_combat_resolver.gd.uid
Normal file
1
src/game/engine/tests/unit/test_combat_resolver.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://kck6skn6qy83
|
||||
Loading…
Add table
Reference in a new issue