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:
Claude Code 2026-04-07 17:50:38 -07:00
parent 43620f7793
commit 2fee788a7f
8 changed files with 707 additions and 0 deletions

View 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")

View file

@ -0,0 +1 @@
uid://dskgl3aim5f4m

View 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")

View file

@ -0,0 +1 @@
uid://bmdqawrij03w8

View 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")

View file

@ -0,0 +1 @@
uid://c7wgxqwdchemu

View 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"
)

View file

@ -0,0 +1 @@
uid://bp7sf73i7787c