test(ai): ✅ Add and update test cases for military, player, tactical, and wild creature AI logic to enhance validation coverage
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
43620f7793
commit
2fee788a7f
8 changed files with 707 additions and 0 deletions
202
src/game/engine/tests/unit/test_ai_military.gd
Normal file
202
src/game/engine/tests/unit/test_ai_military.gd
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
extends GutTest
|
||||
## AIMilitary unit tests.
|
||||
## Covers: military strength computation, threat assessment, garrison sizing,
|
||||
## and army composition analysis (state prep for Rust).
|
||||
##
|
||||
## Scoring functions (score_unit_for_army, get_army_composition_advice) now live
|
||||
## in Rust mc-ai evaluator — corresponding tests removed.
|
||||
|
||||
const AIMilitaryScript: GDScript = preload("res://engine/src/modules/ai/ai_military.gd")
|
||||
const PlayerScript: GDScript = preload("res://engine/src/entities/player.gd")
|
||||
const UnitScript: GDScript = preload("res://engine/src/entities/unit.gd")
|
||||
const CityScript: GDScript = preload("res://engine/src/entities/city.gd")
|
||||
|
||||
var _military: AIMilitaryScript = null
|
||||
var _player_a: PlayerScript = null
|
||||
var _player_b: PlayerScript = null
|
||||
|
||||
|
||||
func before_all() -> void:
|
||||
DataLoader.load_theme("age-of-dwarves")
|
||||
|
||||
|
||||
func before_each() -> void:
|
||||
_military = AIMilitaryScript.new()
|
||||
|
||||
_player_a = PlayerScript.new()
|
||||
_player_a.index = 0
|
||||
_player_a.race_id = "dwarf"
|
||||
_player_a.units = []
|
||||
_player_a.cities = []
|
||||
|
||||
_player_b = PlayerScript.new()
|
||||
_player_b.index = 1
|
||||
_player_b.race_id = "terrans"
|
||||
_player_b.units = []
|
||||
_player_b.cities = []
|
||||
|
||||
GameState.players = [_player_a, _player_b]
|
||||
GameState.diplomacy = {}
|
||||
|
||||
|
||||
func _make_military_unit(owner_idx: int, pos: Vector2i, atk: int = 5, dr: int = 3) -> 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 = 10
|
||||
unit.max_hp = 10
|
||||
unit.bonus_attack = atk
|
||||
unit.bonus_defense = dr
|
||||
unit.unit_type = "military"
|
||||
unit.movement_remaining = 2
|
||||
return unit
|
||||
|
||||
|
||||
func _make_city(owner_idx: int, pos: Vector2i, is_capital: bool = false) -> CityScript:
|
||||
var city: CityScript = CityScript.new()
|
||||
city.id = "city_%d" % owner_idx
|
||||
city.owner = owner_idx
|
||||
city.position = pos
|
||||
city.is_capital = is_capital
|
||||
city.population = 1
|
||||
return city
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# compute_military_strength
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_strength_zero_with_no_units_or_cities() -> void:
|
||||
assert_eq(
|
||||
_military.compute_military_strength(_player_a),
|
||||
0.0,
|
||||
"Empty player must have 0 military strength"
|
||||
)
|
||||
|
||||
|
||||
func test_strength_includes_city_bonus() -> void:
|
||||
_player_a.cities.append(_make_city(0, Vector2i(0, 0)))
|
||||
var strength: float = _military.compute_military_strength(_player_a)
|
||||
assert_eq(strength, 10.0, "One city must contribute 10.0 to military strength")
|
||||
|
||||
|
||||
func test_strength_includes_alive_military_units() -> void:
|
||||
var unit: UnitScript = _make_military_unit(0, Vector2i(0, 0))
|
||||
_player_a.units.append(unit)
|
||||
var strength: float = _military.compute_military_strength(_player_a)
|
||||
assert_gt(strength, 0.0, "Alive military unit must add to strength")
|
||||
|
||||
|
||||
func test_strength_excludes_dead_units() -> void:
|
||||
var unit: UnitScript = _make_military_unit(0, Vector2i(0, 0))
|
||||
unit.hp = 0 # dead
|
||||
_player_a.units.append(unit)
|
||||
var strength: float = _military.compute_military_strength(_player_a)
|
||||
assert_eq(strength, 0.0, "Dead units must not contribute to military strength")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# assess_threats
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_assess_threats_sets_meta_on_player() -> void:
|
||||
_player_b.cities.append(_make_city(1, Vector2i(3, 0)))
|
||||
var unit: UnitScript = _make_military_unit(1, Vector2i(3, 0))
|
||||
_player_b.units.append(unit)
|
||||
_player_a.cities.append(_make_city(0, Vector2i(0, 0)))
|
||||
|
||||
_military.assess_threats(_player_a)
|
||||
|
||||
assert_true(
|
||||
_player_a.has_meta("ai_threat_level"),
|
||||
"assess_threats must set ai_threat_level meta on player"
|
||||
)
|
||||
var threat: float = float(_player_a.get_meta("ai_threat_level", 0.0))
|
||||
assert_gte(threat, 0.0, "Threat level must be >= 0.0")
|
||||
assert_lte(threat, 1.0, "Threat level must be clamped to <= 1.0")
|
||||
|
||||
|
||||
func test_assess_threats_zero_when_no_enemies() -> void:
|
||||
## Only one player in game — no opponents to threaten
|
||||
GameState.players = [_player_a]
|
||||
_military.assess_threats(_player_a)
|
||||
var threat: float = float(_player_a.get_meta("ai_threat_level", 0.0))
|
||||
assert_eq(threat, 0.0, "Threat must be 0 when no other players exist")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# rank_opponents_by_threat
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_rank_returns_one_entry_for_one_opponent() -> void:
|
||||
_player_b.cities.append(_make_city(1, Vector2i(5, 0)))
|
||||
var ranked: Array[Dictionary] = _military.rank_opponents_by_threat(_player_a)
|
||||
assert_eq(ranked.size(), 1, "Must return one entry for one opponent")
|
||||
assert_eq(ranked[0].get("player_index"), 1, "Entry must reference opponent's player index")
|
||||
|
||||
|
||||
func test_rank_sorted_descending_by_threat_score() -> void:
|
||||
var player_c: PlayerScript = PlayerScript.new()
|
||||
player_c.index = 2
|
||||
player_c.race_id = "dwarf"
|
||||
player_c.units = []
|
||||
player_c.cities = []
|
||||
|
||||
## Give player_b a large army (high threat) and player_c nothing
|
||||
for i: int in range(5):
|
||||
_player_b.units.append(_make_military_unit(1, Vector2i(2 + i, 0), 10, 8))
|
||||
_player_b.cities.append(_make_city(1, Vector2i(2, 0)))
|
||||
|
||||
GameState.players = [_player_a, _player_b, player_c]
|
||||
var ranked: Array[Dictionary] = _military.rank_opponents_by_threat(_player_a)
|
||||
|
||||
assert_eq(ranked.size(), 2, "Must return two entries for two opponents")
|
||||
assert_gte(
|
||||
float(ranked[0].get("threat_score", 0.0)),
|
||||
float(ranked[1].get("threat_score", 0.0)),
|
||||
"Results must be sorted descending by threat_score"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_garrison_target
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_garrison_target_scales_with_expansion_axis() -> void:
|
||||
var city: CityScript = _make_city(0, Vector2i(0, 0), true)
|
||||
var target: int = _military.get_garrison_target(city, _player_a)
|
||||
assert_gte(target, 1, "Garrison target must be at least 1")
|
||||
assert_lte(target, 8, "Garrison target must be reasonable (≤ 8)")
|
||||
|
||||
|
||||
func test_garrison_capital_adds_bonus() -> void:
|
||||
var capital: CityScript = _make_city(0, Vector2i(0, 0), true)
|
||||
var non_capital: CityScript = _make_city(0, Vector2i(20, 20), false)
|
||||
_player_a.cities = [capital, non_capital]
|
||||
|
||||
var target_cap: int = _military.get_garrison_target(capital, _player_a)
|
||||
var target_nc: int = _military.get_garrison_target(non_capital, _player_a)
|
||||
assert_gte(target_cap, target_nc, "Capital garrison target must be >= non-capital")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# analyze_army_composition
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_composition_counts_melee_units() -> void:
|
||||
for i: int in range(3):
|
||||
_player_a.units.append(_make_military_unit(0, Vector2i(i, 0)))
|
||||
var profile: Dictionary = _military.analyze_army_composition(_player_a.units)
|
||||
assert_eq(profile.get("melee", 0), 3, "3 melee units must produce melee=3")
|
||||
assert_eq(profile.get("ranged", 0), 0, "No ranged units must produce ranged=0")
|
||||
|
||||
|
||||
func test_composition_excludes_dead_units() -> void:
|
||||
var alive: UnitScript = _make_military_unit(0, Vector2i(0, 0))
|
||||
var dead: UnitScript = _make_military_unit(0, Vector2i(1, 0))
|
||||
dead.hp = 0
|
||||
_player_a.units = [alive, dead]
|
||||
var profile: Dictionary = _military.analyze_army_composition(_player_a.units)
|
||||
assert_eq(profile.get("melee", 0), 1, "Dead units must not be counted")
|
||||
1
src/game/engine/tests/unit/test_ai_military.gd.uid
Normal file
1
src/game/engine/tests/unit/test_ai_military.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://dskgl3aim5f4m
|
||||
54
src/game/engine/tests/unit/test_ai_player.gd
Normal file
54
src/game/engine/tests/unit/test_ai_player.gd
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
extends GutTest
|
||||
## AIPlayer unit tests.
|
||||
## Note: difficulty scoring, tech scoring, and tech selection now live in
|
||||
## Rust mc-ai (via GdAiController). Only GDScript-level behaviours are tested here.
|
||||
|
||||
const AIPlayerScript: GDScript = preload("res://engine/src/modules/ai/ai_player.gd")
|
||||
const PlayerScript: GDScript = preload("res://engine/src/entities/player.gd")
|
||||
const CityScript: GDScript = preload("res://engine/src/entities/city.gd")
|
||||
|
||||
var _ai: AIPlayerScript = null
|
||||
var _player: PlayerScript = null
|
||||
|
||||
|
||||
func before_all() -> void:
|
||||
DataLoader.load_theme("age-of-dwarves")
|
||||
|
||||
|
||||
func before_each() -> void:
|
||||
_ai = AIPlayerScript.new()
|
||||
_player = PlayerScript.new()
|
||||
_player.index = 0
|
||||
_player.race_id = "dwarf"
|
||||
_player.is_player_controlled = false
|
||||
_player.researched_techs = []
|
||||
_player.researching = ""
|
||||
_player.research_progress = 0
|
||||
_player.units = []
|
||||
_player.cities = []
|
||||
GameState.players = [_player]
|
||||
GameState.diplomacy = {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Archmage bonus
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_archmage_bonus_skips_controlled_player() -> void:
|
||||
_player.is_player_controlled = true
|
||||
var city: CityScript = CityScript.new()
|
||||
city.is_capital = true
|
||||
city.position = Vector2i(0, 0)
|
||||
city.owner = 0
|
||||
_player.cities.append(city)
|
||||
|
||||
## Player-controlled slot must be skipped without touching unit_manager
|
||||
_ai.apply_archmage_bonus(_player, null)
|
||||
assert_true(true, "apply_archmage_bonus must skip player-controlled slots without error")
|
||||
|
||||
|
||||
func test_archmage_bonus_skips_player_with_no_cities() -> void:
|
||||
_player.is_player_controlled = false
|
||||
_player.cities = []
|
||||
_ai.apply_archmage_bonus(_player, null)
|
||||
assert_true(true, "apply_archmage_bonus must skip players with no cities")
|
||||
1
src/game/engine/tests/unit/test_ai_player.gd.uid
Normal file
1
src/game/engine/tests/unit/test_ai_player.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://bmdqawrij03w8
|
||||
246
src/game/engine/tests/unit/test_ai_tactical.gd
Normal file
246
src/game/engine/tests/unit/test_ai_tactical.gd
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
extends GutTest
|
||||
## AITactical unit tests.
|
||||
## Covers: threat level computation, combat prediction, combat scoring,
|
||||
## nearest-enemy lookup, and adjacent-friendly counting.
|
||||
|
||||
const AITacticalScript: GDScript = preload("res://engine/src/modules/ai/ai_tactical.gd")
|
||||
const PlayerScript: GDScript = preload("res://engine/src/entities/player.gd")
|
||||
const UnitScript: GDScript = preload("res://engine/src/entities/unit.gd")
|
||||
const CityScript: GDScript = preload("res://engine/src/entities/city.gd")
|
||||
const HexUtilsScript: GDScript = preload("res://engine/src/map/hex_utils.gd")
|
||||
|
||||
var _tactical: AITacticalScript = null
|
||||
var _player: PlayerScript = null
|
||||
|
||||
|
||||
func before_all() -> void:
|
||||
DataLoader.load_theme("age-of-dwarves")
|
||||
|
||||
|
||||
func before_each() -> void:
|
||||
_tactical = AITacticalScript.new()
|
||||
|
||||
_player = PlayerScript.new()
|
||||
_player.index = 0
|
||||
_player.race_id = "dwarf"
|
||||
_player.units = []
|
||||
_player.cities = []
|
||||
|
||||
GameState.players = [_player]
|
||||
GameState.diplomacy = {}
|
||||
GameState.layers = [{"units": [], "map": null}]
|
||||
|
||||
|
||||
func _make_unit(owner_idx: int, pos: Vector2i, hp: int = 10) -> UnitScript:
|
||||
var unit: UnitScript = UnitScript.new()
|
||||
unit.id = "u_%d_%d_%d" % [owner_idx, pos.x, pos.y]
|
||||
unit.type_id = "spearmen"
|
||||
unit.owner = owner_idx
|
||||
unit.position = pos
|
||||
unit.hp = hp
|
||||
unit.max_hp = 10
|
||||
unit.bonus_attack = 5
|
||||
unit.bonus_defense = 3
|
||||
unit.unit_type = "military"
|
||||
unit.movement_remaining = 2
|
||||
unit.has_attacked = false
|
||||
return unit
|
||||
|
||||
|
||||
func _make_city(owner_idx: int, pos: Vector2i) -> CityScript:
|
||||
var city: CityScript = CityScript.new()
|
||||
city.owner = owner_idx
|
||||
city.position = pos
|
||||
city.population = 1
|
||||
return city
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# compute_threat_level
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_threat_level_zero_with_no_enemies() -> void:
|
||||
_player.cities.append(_make_city(0, Vector2i(0, 0)))
|
||||
var own_unit: UnitScript = _make_unit(0, Vector2i(0, 0))
|
||||
_player.units.append(own_unit)
|
||||
GameState.layers = [{"units": [own_unit], "map": null}]
|
||||
|
||||
var threat: float = _tactical.compute_threat_level(_player)
|
||||
assert_eq(threat, 0.0, "Threat must be 0 when no enemies are present")
|
||||
|
||||
|
||||
func test_threat_level_positive_with_enemy_near_city() -> void:
|
||||
var capital: CityScript = _make_city(0, Vector2i(0, 0))
|
||||
_player.cities.append(capital)
|
||||
var own_unit: UnitScript = _make_unit(0, Vector2i(0, 0))
|
||||
_player.units.append(own_unit)
|
||||
|
||||
## Enemy unit within 5 hexes of our city
|
||||
var enemy: UnitScript = _make_unit(1, Vector2i(3, 0))
|
||||
|
||||
GameState.layers = [{"units": [own_unit, enemy], "map": null}]
|
||||
|
||||
var threat: float = _tactical.compute_threat_level(_player)
|
||||
assert_gt(threat, 0.0, "Threat must be > 0 when enemy unit is near our city")
|
||||
assert_lte(threat, 1.0, "Threat must be clamped to <= 1.0")
|
||||
|
||||
|
||||
func test_threat_level_one_when_no_military_and_enemies_near() -> void:
|
||||
var capital: CityScript = _make_city(0, Vector2i(0, 0))
|
||||
_player.cities.append(capital)
|
||||
## No military units for player_a
|
||||
var enemy: UnitScript = _make_unit(1, Vector2i(2, 0))
|
||||
GameState.layers = [{"units": [enemy], "map": null}]
|
||||
|
||||
var threat: float = _tactical.compute_threat_level(_player)
|
||||
assert_eq(threat, 1.0, "Threat must be 1.0 when player has no military and enemies are near")
|
||||
|
||||
|
||||
func test_threat_level_zero_when_no_military_and_no_enemies() -> void:
|
||||
var capital: CityScript = _make_city(0, Vector2i(0, 0))
|
||||
_player.cities.append(capital)
|
||||
GameState.layers = [{"units": [], "map": null}]
|
||||
|
||||
var threat: float = _tactical.compute_threat_level(_player)
|
||||
assert_eq(threat, 0.0, "Threat must be 0.0 when no military and no enemies anywhere")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _predict_combat (private but accessible in GDScript)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_predict_combat_deals_damage() -> void:
|
||||
var attacker: UnitScript = _make_unit(0, Vector2i(0, 0))
|
||||
var defender: UnitScript = _make_unit(1, Vector2i(1, 0))
|
||||
var all_units: Array = [attacker, defender]
|
||||
|
||||
var prediction: Dictionary = _tactical._predict_combat(attacker, defender, all_units)
|
||||
assert_true(prediction.has("damage_dealt"), "prediction must have 'damage_dealt'")
|
||||
assert_true(prediction.has("damage_taken"), "prediction must have 'damage_taken'")
|
||||
assert_gte(prediction.get("damage_dealt", 0), 0, "damage_dealt must be >= 0")
|
||||
assert_gte(prediction.get("damage_taken", 0), 0, "damage_taken must be >= 0")
|
||||
|
||||
|
||||
func test_predict_combat_ranged_takes_no_counterattack() -> void:
|
||||
var attacker: UnitScript = _make_unit(0, Vector2i(0, 0))
|
||||
## Simulate ranged by overriding combat type via bonus — note is_ranged() checks data,
|
||||
## but damage_taken formula uses is_ranged(): 0 if ranged, else formula.
|
||||
## spearmen is melee so taken > 0; just verify the formula produces non-negative values.
|
||||
var defender: UnitScript = _make_unit(1, Vector2i(1, 0))
|
||||
var prediction: Dictionary = _tactical._predict_combat(attacker, defender, [attacker, defender])
|
||||
assert_gte(prediction.get("damage_taken", 0), 0, "damage_taken must not be negative")
|
||||
|
||||
|
||||
func test_predict_combat_adjacent_friendlies_boost_damage() -> void:
|
||||
var attacker: UnitScript = _make_unit(0, Vector2i(0, 0))
|
||||
var defender: UnitScript = _make_unit(1, Vector2i(1, 0))
|
||||
var friendly: UnitScript = _make_unit(0, Vector2i(0, 1)) # adjacent to attacker
|
||||
var all_with_friendly: Array = [attacker, defender, friendly]
|
||||
var all_without_friendly: Array = [attacker, defender]
|
||||
|
||||
var with_bonus: Dictionary = _tactical._predict_combat(attacker, defender, all_with_friendly)
|
||||
var no_bonus: Dictionary = _tactical._predict_combat(attacker, defender, all_without_friendly)
|
||||
assert_gte(
|
||||
with_bonus.get("damage_dealt", 0),
|
||||
no_bonus.get("damage_dealt", 0),
|
||||
"Adjacent friendly unit must not reduce damage_dealt"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _combat_score
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_combat_score_negative_when_ratio_below_threshold() -> void:
|
||||
## attack_ratio=1.4 (cautious), dealt=5, taken=10 → ratio=0.5 < 1.4 → score=-1
|
||||
var defender: UnitScript = _make_unit(1, Vector2i(1, 0))
|
||||
var prediction: Dictionary = {"damage_dealt": 5, "damage_taken": 10}
|
||||
var score: float = _tactical._combat_score(prediction, defender, 1.4)
|
||||
assert_eq(score, -1.0, "Combat score must be -1.0 when dealt/taken ratio < attack_ratio")
|
||||
|
||||
|
||||
func test_combat_score_positive_when_ratio_exceeds_threshold() -> void:
|
||||
var defender: UnitScript = _make_unit(1, Vector2i(1, 0))
|
||||
defender.hp = 5
|
||||
var prediction: Dictionary = {"damage_dealt": 20, "damage_taken": 5}
|
||||
var score: float = _tactical._combat_score(prediction, defender, 1.0)
|
||||
assert_gt(score, 0.0, "Combat score must be > 0 when ratio exceeds attack_ratio")
|
||||
|
||||
|
||||
func test_combat_score_kill_bonus_when_damage_exceeds_hp() -> void:
|
||||
var defender: UnitScript = _make_unit(1, Vector2i(1, 0))
|
||||
defender.hp = 5
|
||||
var kill_prediction: Dictionary = {"damage_dealt": 10, "damage_taken": 2}
|
||||
var no_kill_prediction: Dictionary = {"damage_dealt": 4, "damage_taken": 2}
|
||||
var kill_score: float = _tactical._combat_score(kill_prediction, defender, 0.6)
|
||||
var no_kill_score: float = _tactical._combat_score(no_kill_prediction, defender, 0.6)
|
||||
assert_gt(kill_score, no_kill_score, "Killing blow must give higher combat score")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _nearest_enemy_pos
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_nearest_enemy_pos_returns_sentinel_when_no_enemies() -> void:
|
||||
var unit: UnitScript = _make_unit(0, Vector2i(0, 0))
|
||||
var pos: Vector2i = _tactical._nearest_enemy_pos(unit, [])
|
||||
assert_eq(pos, Vector2i(-9999, -9999), "Must return sentinel (-9999,-9999) with no enemies")
|
||||
|
||||
|
||||
func test_nearest_enemy_pos_returns_closest() -> void:
|
||||
var unit: UnitScript = _make_unit(0, Vector2i(0, 0))
|
||||
var close_enemy: UnitScript = _make_unit(1, Vector2i(2, 0))
|
||||
var far_enemy: UnitScript = _make_unit(1, Vector2i(8, 0))
|
||||
var pos: Vector2i = _tactical._nearest_enemy_pos(unit, [far_enemy, close_enemy])
|
||||
assert_eq(pos, close_enemy.position, "Must return position of closest enemy")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _has_nearby_enemy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_has_nearby_enemy_true_within_radius() -> void:
|
||||
var unit: UnitScript = _make_unit(0, Vector2i(0, 0))
|
||||
var enemy: UnitScript = _make_unit(1, Vector2i(3, 0))
|
||||
assert_true(
|
||||
_tactical._has_nearby_enemy(unit, [enemy], 4),
|
||||
"Must return true when enemy is within detection radius"
|
||||
)
|
||||
|
||||
|
||||
func test_has_nearby_enemy_false_outside_radius() -> void:
|
||||
var unit: UnitScript = _make_unit(0, Vector2i(0, 0))
|
||||
var far_enemy: UnitScript = _make_unit(1, Vector2i(10, 0))
|
||||
assert_false(
|
||||
_tactical._has_nearby_enemy(unit, [far_enemy], 4),
|
||||
"Must return false when enemy is outside detection radius"
|
||||
)
|
||||
|
||||
|
||||
func test_has_nearby_enemy_false_when_no_enemies() -> void:
|
||||
var unit: UnitScript = _make_unit(0, Vector2i(0, 0))
|
||||
assert_false(
|
||||
_tactical._has_nearby_enemy(unit, [], 99),
|
||||
"Must return false when enemy list is empty"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _collect_enemy_units
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_collect_enemy_units_excludes_own_player() -> void:
|
||||
var own: UnitScript = _make_unit(0, Vector2i(0, 0))
|
||||
var enemy: UnitScript = _make_unit(1, Vector2i(5, 0))
|
||||
var wild: UnitScript = _make_unit(-1, Vector2i(3, 0)) # owner=-1 excluded
|
||||
var all_units: Array = [own, enemy, wild]
|
||||
|
||||
var enemies: Array = _tactical._collect_enemy_units(0, all_units)
|
||||
assert_eq(enemies.size(), 1, "Must return exactly one enemy for owner index 1")
|
||||
assert_eq((enemies[0] as UnitScript).owner, 1, "Enemy must have owner index 1")
|
||||
|
||||
|
||||
func test_collect_enemy_units_excludes_wild_creatures() -> void:
|
||||
var wild: UnitScript = _make_unit(-1, Vector2i(3, 0))
|
||||
var enemies: Array = _tactical._collect_enemy_units(0, [wild])
|
||||
assert_eq(enemies.size(), 0, "Wild creatures (owner=-1) must not be collected as enemies")
|
||||
1
src/game/engine/tests/unit/test_ai_tactical.gd.uid
Normal file
1
src/game/engine/tests/unit/test_ai_tactical.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://c7wgxqwdchemu
|
||||
201
src/game/engine/tests/unit/test_wild_creature_ai.gd
Normal file
201
src/game/engine/tests/unit/test_wild_creature_ai.gd
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
extends GutTest
|
||||
## WildCreatureAI unit tests.
|
||||
## Covers: attack target detection, lair lookup, leash boundary check,
|
||||
## and spawn validation on null tiles.
|
||||
|
||||
const WildCreatureAIScript: GDScript = preload(
|
||||
"res://engine/src/modules/ai/wild_creature_ai.gd"
|
||||
)
|
||||
const UnitScript: GDScript = preload("res://engine/src/entities/unit.gd")
|
||||
const HexUtilsScript: GDScript = preload("res://engine/src/map/hex_utils.gd")
|
||||
|
||||
## Minimal UnitManager stub — only needs get_units_at() for WildCreatureAI
|
||||
class StubUnitManager extends RefCounted:
|
||||
func get_units_at(_pos: Vector2i) -> Array:
|
||||
return []
|
||||
|
||||
|
||||
## Minimal NPC building with required duck-type properties
|
||||
class FakeBuilding extends RefCounted:
|
||||
var type_id: String = ""
|
||||
var position: Vector2i = Vector2i.ZERO
|
||||
|
||||
|
||||
var _wild_ai: WildCreatureAIScript = null
|
||||
var _stub_mgr: StubUnitManager = null
|
||||
|
||||
|
||||
func before_all() -> void:
|
||||
DataLoader.load_theme("age-of-dwarves")
|
||||
|
||||
|
||||
func before_each() -> void:
|
||||
_stub_mgr = StubUnitManager.new()
|
||||
_wild_ai = WildCreatureAIScript.new(_stub_mgr)
|
||||
|
||||
GameState.players = []
|
||||
GameState.diplomacy = {}
|
||||
GameState.npc_buildings = []
|
||||
GameState.layers = [{"units": [], "map": null}]
|
||||
|
||||
|
||||
func _make_wild_unit(pos: Vector2i) -> UnitScript:
|
||||
var unit: UnitScript = UnitScript.new()
|
||||
unit.id = "wild_%d_%d" % [pos.x, pos.y]
|
||||
unit.type_id = "wolf"
|
||||
unit.owner = -1
|
||||
unit.position = pos
|
||||
unit.hp = 8
|
||||
unit.max_hp = 8
|
||||
unit.movement_remaining = 2
|
||||
unit.has_attacked = false
|
||||
return unit
|
||||
|
||||
|
||||
func _make_player_unit(owner_idx: int, pos: Vector2i) -> UnitScript:
|
||||
var unit: UnitScript = UnitScript.new()
|
||||
unit.id = "p_%d_%d_%d" % [owner_idx, pos.x, pos.y]
|
||||
unit.type_id = "spearmen"
|
||||
unit.owner = owner_idx
|
||||
unit.position = pos
|
||||
unit.hp = 10
|
||||
unit.max_hp = 10
|
||||
unit.movement_remaining = 2
|
||||
unit.has_attacked = false
|
||||
return unit
|
||||
|
||||
|
||||
func _make_lair(lair_type_id: String, pos: Vector2i) -> FakeBuilding:
|
||||
var bld: FakeBuilding = FakeBuilding.new()
|
||||
bld.type_id = lair_type_id
|
||||
bld.position = pos
|
||||
return bld
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _find_attack_target
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_find_attack_target_returns_null_when_no_player_units() -> void:
|
||||
var wild: UnitScript = _make_wild_unit(Vector2i(0, 0))
|
||||
GameState.layers = [{"units": [wild], "map": null}]
|
||||
assert_null(
|
||||
_wild_ai._find_attack_target(wild, 4),
|
||||
"Must return null when no player units are in detection radius"
|
||||
)
|
||||
|
||||
|
||||
func test_find_attack_target_detects_player_unit_in_range() -> void:
|
||||
var wild: UnitScript = _make_wild_unit(Vector2i(0, 0))
|
||||
var player_unit: UnitScript = _make_player_unit(0, Vector2i(3, 0))
|
||||
GameState.layers = [{"units": [wild, player_unit], "map": null}]
|
||||
|
||||
var found: bool = _wild_ai._find_attack_target(wild, 4) != null
|
||||
assert_true(found, "Must detect player unit within detection radius")
|
||||
var target_pos: Vector2i = _wild_ai._find_attack_target(wild, 4) as Vector2i
|
||||
assert_eq(target_pos, player_unit.position, "Must return position of detected player unit")
|
||||
|
||||
|
||||
func test_find_attack_target_ignores_units_outside_radius() -> void:
|
||||
var wild: UnitScript = _make_wild_unit(Vector2i(0, 0))
|
||||
var far_unit: UnitScript = _make_player_unit(0, Vector2i(10, 0))
|
||||
GameState.layers = [{"units": [wild, far_unit], "map": null}]
|
||||
assert_null(
|
||||
_wild_ai._find_attack_target(wild, 4),
|
||||
"Must not detect player unit outside detection radius"
|
||||
)
|
||||
|
||||
|
||||
func test_find_attack_target_ignores_other_wild_creatures() -> void:
|
||||
var wild: UnitScript = _make_wild_unit(Vector2i(0, 0))
|
||||
var other_wild: UnitScript = _make_wild_unit(Vector2i(2, 0))
|
||||
GameState.layers = [{"units": [wild, other_wild], "map": null}]
|
||||
assert_null(
|
||||
_wild_ai._find_attack_target(wild, 4),
|
||||
"Wild creatures must not target other wild creatures (owner=-1)"
|
||||
)
|
||||
|
||||
|
||||
func test_find_attack_target_returns_nearest_when_multiple() -> void:
|
||||
var wild: UnitScript = _make_wild_unit(Vector2i(0, 0))
|
||||
var close_unit: UnitScript = _make_player_unit(0, Vector2i(2, 0))
|
||||
var far_unit: UnitScript = _make_player_unit(0, Vector2i(4, 0))
|
||||
GameState.layers = [{"units": [wild, far_unit, close_unit], "map": null}]
|
||||
|
||||
var target_pos: Vector2i = _wild_ai._find_attack_target(wild, 5) as Vector2i
|
||||
assert_eq(
|
||||
target_pos, close_unit.position,
|
||||
"Must return position of nearest player unit when multiple exist"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _find_nearest_lair
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_find_nearest_lair_returns_current_pos_when_no_buildings() -> void:
|
||||
GameState.npc_buildings = []
|
||||
var pos: Vector2i = Vector2i(5, 5)
|
||||
var result: Vector2i = _wild_ai._find_nearest_lair(pos, 10)
|
||||
assert_eq(result, pos, "Must return current position when no lairs exist")
|
||||
|
||||
|
||||
func test_find_nearest_lair_finds_lair_in_range() -> void:
|
||||
var bld: FakeBuilding = _make_lair("beast_den", Vector2i(3, 3))
|
||||
GameState.npc_buildings = [bld]
|
||||
|
||||
var result: Vector2i = _wild_ai._find_nearest_lair(Vector2i(0, 0), 10)
|
||||
assert_eq(result, bld.position, "Must return lair position when within search radius")
|
||||
|
||||
|
||||
func test_find_nearest_lair_ignores_villages() -> void:
|
||||
var village: FakeBuilding = _make_lair("village", Vector2i(2, 0))
|
||||
var lair: FakeBuilding = _make_lair("beast_den", Vector2i(5, 0))
|
||||
GameState.npc_buildings = [village, lair]
|
||||
|
||||
var result: Vector2i = _wild_ai._find_nearest_lair(Vector2i(0, 0), 10)
|
||||
assert_eq(result, lair.position, "Must skip villages and find the nearest lair")
|
||||
|
||||
|
||||
func test_find_nearest_lair_ignores_ruins() -> void:
|
||||
var ruin: FakeBuilding = _make_lair("ruin", Vector2i(1, 0))
|
||||
GameState.npc_buildings = [ruin]
|
||||
|
||||
var pos: Vector2i = Vector2i(0, 0)
|
||||
var result: Vector2i = _wild_ai._find_nearest_lair(pos, 10)
|
||||
assert_eq(result, pos, "Must skip ruins and return current position when no real lair exists")
|
||||
|
||||
|
||||
func test_find_nearest_lair_returns_closest_of_multiple() -> void:
|
||||
var near_lair: FakeBuilding = _make_lair("spider_nest", Vector2i(3, 0))
|
||||
var far_lair: FakeBuilding = _make_lair("dragon_lair", Vector2i(9, 0))
|
||||
GameState.npc_buildings = [far_lair, near_lair]
|
||||
|
||||
var result: Vector2i = _wild_ai._find_nearest_lair(Vector2i(0, 0), 10)
|
||||
assert_eq(result, near_lair.position, "Must return the closest lair among multiple")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _is_outside_leash
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func test_outside_leash_true_when_beyond_radius() -> void:
|
||||
assert_true(
|
||||
_wild_ai._is_outside_leash(Vector2i(10, 0), Vector2i(0, 0), 5),
|
||||
"Must return true when unit is beyond leash radius"
|
||||
)
|
||||
|
||||
|
||||
func test_outside_leash_false_when_within_radius() -> void:
|
||||
assert_false(
|
||||
_wild_ai._is_outside_leash(Vector2i(3, 0), Vector2i(0, 0), 5),
|
||||
"Must return false when unit is within leash radius"
|
||||
)
|
||||
|
||||
|
||||
func test_outside_leash_false_at_home_position() -> void:
|
||||
var home: Vector2i = Vector2i(5, 5)
|
||||
assert_false(
|
||||
_wild_ai._is_outside_leash(home, home, 3),
|
||||
"Unit at home position must not be considered outside leash"
|
||||
)
|
||||
1
src/game/engine/tests/unit/test_wild_creature_ai.gd.uid
Normal file
1
src/game/engine/tests/unit/test_wild_creature_ai.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://bp7sf73i7787c
|
||||
Loading…
Add table
Reference in a new issue