From 2fee788a7f795ab15329cab5549f81da2d4bec3b Mon Sep 17 00:00:00 2001 From: Claude Code Date: Tue, 7 Apr 2026 17:50:38 -0700 Subject: [PATCH] =?UTF-8?q?test(ai):=20=E2=9C=85=20Add=20and=20update=20te?= =?UTF-8?q?st=20cases=20for=20military,=20player,=20tactical,=20and=20wild?= =?UTF-8?q?=20creature=20AI=20logic=20to=20enhance=20validation=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../engine/tests/unit/test_ai_military.gd | 202 ++++++++++++++ .../engine/tests/unit/test_ai_military.gd.uid | 1 + src/game/engine/tests/unit/test_ai_player.gd | 54 ++++ .../engine/tests/unit/test_ai_player.gd.uid | 1 + .../engine/tests/unit/test_ai_tactical.gd | 246 ++++++++++++++++++ .../engine/tests/unit/test_ai_tactical.gd.uid | 1 + .../tests/unit/test_wild_creature_ai.gd | 201 ++++++++++++++ .../tests/unit/test_wild_creature_ai.gd.uid | 1 + 8 files changed, 707 insertions(+) create mode 100644 src/game/engine/tests/unit/test_ai_military.gd create mode 100644 src/game/engine/tests/unit/test_ai_military.gd.uid create mode 100644 src/game/engine/tests/unit/test_ai_player.gd create mode 100644 src/game/engine/tests/unit/test_ai_player.gd.uid create mode 100644 src/game/engine/tests/unit/test_ai_tactical.gd create mode 100644 src/game/engine/tests/unit/test_ai_tactical.gd.uid create mode 100644 src/game/engine/tests/unit/test_wild_creature_ai.gd create mode 100644 src/game/engine/tests/unit/test_wild_creature_ai.gd.uid diff --git a/src/game/engine/tests/unit/test_ai_military.gd b/src/game/engine/tests/unit/test_ai_military.gd new file mode 100644 index 00000000..d5f4b015 --- /dev/null +++ b/src/game/engine/tests/unit/test_ai_military.gd @@ -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") diff --git a/src/game/engine/tests/unit/test_ai_military.gd.uid b/src/game/engine/tests/unit/test_ai_military.gd.uid new file mode 100644 index 00000000..50b35418 --- /dev/null +++ b/src/game/engine/tests/unit/test_ai_military.gd.uid @@ -0,0 +1 @@ +uid://dskgl3aim5f4m diff --git a/src/game/engine/tests/unit/test_ai_player.gd b/src/game/engine/tests/unit/test_ai_player.gd new file mode 100644 index 00000000..06cc8e63 --- /dev/null +++ b/src/game/engine/tests/unit/test_ai_player.gd @@ -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") diff --git a/src/game/engine/tests/unit/test_ai_player.gd.uid b/src/game/engine/tests/unit/test_ai_player.gd.uid new file mode 100644 index 00000000..23e2f229 --- /dev/null +++ b/src/game/engine/tests/unit/test_ai_player.gd.uid @@ -0,0 +1 @@ +uid://bmdqawrij03w8 diff --git a/src/game/engine/tests/unit/test_ai_tactical.gd b/src/game/engine/tests/unit/test_ai_tactical.gd new file mode 100644 index 00000000..6fd08c6f --- /dev/null +++ b/src/game/engine/tests/unit/test_ai_tactical.gd @@ -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") diff --git a/src/game/engine/tests/unit/test_ai_tactical.gd.uid b/src/game/engine/tests/unit/test_ai_tactical.gd.uid new file mode 100644 index 00000000..b72eef79 --- /dev/null +++ b/src/game/engine/tests/unit/test_ai_tactical.gd.uid @@ -0,0 +1 @@ +uid://c7wgxqwdchemu diff --git a/src/game/engine/tests/unit/test_wild_creature_ai.gd b/src/game/engine/tests/unit/test_wild_creature_ai.gd new file mode 100644 index 00000000..a0627702 --- /dev/null +++ b/src/game/engine/tests/unit/test_wild_creature_ai.gd @@ -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" + ) diff --git a/src/game/engine/tests/unit/test_wild_creature_ai.gd.uid b/src/game/engine/tests/unit/test_wild_creature_ai.gd.uid new file mode 100644 index 00000000..9e6c2bf8 --- /dev/null +++ b/src/game/engine/tests/unit/test_wild_creature_ai.gd.uid @@ -0,0 +1 @@ +uid://bp7sf73i7787c