import math from pathlib import Path import pytest from engine.ai import AIController from engine.character import Character from engine.defs import DefRegistry from engine.entity import EntityPosition from engine.map import Map from engine.tile import Tile, TileLayerInstance from engine.world import World from resources.logic.ai import AI_BEHAVIORS, run_ai_tick from resources.logic.turrets import ( TURRET_BEHAVIOR_ID, find_closest_hostile, is_hostile, rotate_toward, spawn_turret, turret_ai_behavior, ) DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs" @pytest.fixture() def registry(): return DefRegistry.load(DEFS_DIR) class FixedRng: def __init__(self, roll: int = 15): self._roll = roll def randint(self, _lo, _hi): return self._roll def choice(self, seq): return seq[0] def uniform(self, a, b): return a def make_world(registry) -> World: field = Map("field", 20, 20, 1) for x in range(20): for y in range(20): field.set_tile(x, y, 0, Tile(flooring=TileLayerInstance("grass"))) world = World(registry=registry) world.add_map(field) return world def make_human(registry, **kwargs) -> Character: return Character(species_id="human", default_body_parts=registry.new_default_body_parts("human"), **kwargs) def make_turret( registry, position, entity_def_id="turret_pistol", hostile_factions=("hostile",), allied_factions=(), hostile_entity_ids=(), allied_entity_ids=(), ) -> Character: return spawn_turret( registry, entity_def_id, position, hostile_factions=hostile_factions, allied_factions=allied_factions, hostile_entity_ids=hostile_entity_ids, allied_entity_ids=allied_entity_ids, ) # --- rotate_toward -------------------------------------------------------------------------- def test_rotate_toward_snaps_directly_when_within_turn_rate(): assert rotate_toward(0.0, 10.0, 15.0) == 10.0 def test_rotate_toward_is_capped_by_turn_rate(): assert rotate_toward(0.0, 90.0, 15.0) == 15.0 def test_rotate_toward_turns_the_shorter_way_around_the_wrap(): # from 350 to 10 is a 20-degree turn forward through 0, not backward through 180 assert rotate_toward(350.0, 10.0, 30.0) == 10.0 assert rotate_toward(350.0, 10.0, 5.0) == pytest.approx(355.0) def test_rotate_toward_turns_negative_direction_when_shorter(): assert rotate_toward(90.0, 0.0, 15.0) == 75.0 # --- is_hostile ------------------------------------------------------------------------------ def test_is_hostile_defaults_to_false_for_an_untagged_character(registry): turret = make_turret(registry, EntityPosition("field", 0, 0, 0)) bystander = make_human(registry, position=EntityPosition("field", 1, 0, 0)) assert is_hostile(turret, bystander) is False def test_is_hostile_true_for_a_matching_hostile_faction(registry): turret = make_turret(registry, EntityPosition("field", 0, 0, 0), hostile_factions=("raiders",)) enemy = make_human(registry, position=EntityPosition("field", 1, 0, 0), factions=["raiders"]) assert is_hostile(turret, enemy) is True def test_is_hostile_false_for_a_matching_allied_faction(registry): turret = make_turret( registry, EntityPosition("field", 0, 0, 0), hostile_factions=("raiders",), allied_factions=("crew",) ) friendly = make_human(registry, position=EntityPosition("field", 1, 0, 0), factions=["crew"]) assert is_hostile(turret, friendly) is False def test_individual_hostile_override_works_without_any_faction(registry): target = make_human(registry, position=EntityPosition("field", 1, 0, 0)) turret = make_turret( registry, EntityPosition("field", 0, 0, 0), hostile_factions=(), hostile_entity_ids=(target.entity_id,) ) assert is_hostile(turret, target) is True def test_individual_allied_override_beats_a_hostile_faction(registry): target = make_human(registry, position=EntityPosition("field", 1, 0, 0), factions=["raiders"]) turret = make_turret( registry, EntityPosition("field", 0, 0, 0), hostile_factions=("raiders",), allied_entity_ids=(target.entity_id,), ) assert is_hostile(turret, target) is False # --- find_closest_hostile ---------------------------------------------------------------------- def test_find_closest_hostile_picks_the_nearest_one(registry): world = make_world(registry) turret = make_turret(registry, EntityPosition("field", 0, 0, 0)) far = make_human(registry, position=EntityPosition("field", 5, 0, 0), factions=["hostile"]) near = make_human(registry, position=EntityPosition("field", 2, 0, 0), factions=["hostile"]) world.add_entity(turret) world.add_entity(far) world.add_entity(near) assert find_closest_hostile(turret, world, registry) is near def test_find_closest_hostile_ignores_allies(registry): world = make_world(registry) turret = make_turret(registry, EntityPosition("field", 0, 0, 0)) ally = make_human(registry, position=EntityPosition("field", 1, 0, 0), factions=["player"]) world.add_entity(turret) world.add_entity(ally) assert find_closest_hostile(turret, world, registry) is None def test_find_closest_hostile_ignores_targets_out_of_weapon_range(registry): world = make_world(registry) turret = make_turret(registry, EntityPosition("field", 0, 0, 0)) # pistol_basic range = 6 far = make_human(registry, position=EntityPosition("field", 15, 0, 0), factions=["hostile"]) world.add_entity(turret) world.add_entity(far) assert find_closest_hostile(turret, world, registry) is None def test_find_closest_hostile_is_none_without_a_weapon_assigned(registry): world = make_world(registry) turret = make_turret(registry, EntityPosition("field", 0, 0, 0)) turret.ai.state["weapon_item_id"] = None enemy = make_human(registry, position=EntityPosition("field", 1, 0, 0), factions=["hostile"]) world.add_entity(turret) world.add_entity(enemy) assert find_closest_hostile(turret, world, registry) is None # --- turret_ai_behavior ------------------------------------------------------------------------ def test_behavior_is_a_noop_with_no_power(registry): world = make_world(registry) turret = make_turret(registry, EntityPosition("field", 0, 0, 0)) turret.get_or_create_body_part_override("psu").removed = True enemy = make_human(registry, position=EntityPosition("field", 6, 0, 0), factions=["hostile"]) world.add_entity(turret) world.add_entity(enemy) turret_ai_behavior(turret, world, registry, now=0.0, rng=FixedRng()) assert turret.ai.state["facing_degrees"] == 0.0 assert turret.ai.state["target_entity_id"] is None def test_behavior_does_not_acquire_a_target_without_a_camera(registry): world = make_world(registry) turret = make_turret(registry, EntityPosition("field", 0, 0, 0)) turret.get_or_create_body_part_override("camera").removed = True enemy = make_human(registry, position=EntityPosition("field", 0, 6, 0), factions=["hostile"]) world.add_entity(turret) world.add_entity(enemy) turret_ai_behavior(turret, world, registry, now=0.0, rng=FixedRng()) assert turret.ai.state["target_entity_id"] is None assert turret.ai.state["facing_degrees"] == 0.0 def test_behavior_does_not_acquire_a_target_without_a_cpu(registry): world = make_world(registry) turret = make_turret(registry, EntityPosition("field", 0, 0, 0)) turret.get_or_create_body_part_override("cpu").removed = True enemy = make_human(registry, position=EntityPosition("field", 0, 6, 0), factions=["hostile"]) world.add_entity(turret) world.add_entity(enemy) turret_ai_behavior(turret, world, registry, now=0.0, rng=FixedRng()) assert turret.ai.state["target_entity_id"] is None def test_behavior_tracks_but_cannot_turn_without_motors(registry): world = make_world(registry) turret = make_turret(registry, EntityPosition("field", 0, 0, 0)) turret.get_or_create_body_part_override("motors").removed = True enemy = make_human(registry, position=EntityPosition("field", 0, 6, 0), factions=["hostile"]) world.add_entity(turret) world.add_entity(enemy) turret_ai_behavior(turret, world, registry, now=0.0, rng=FixedRng()) assert turret.ai.state["target_entity_id"] == enemy.entity_id # still sees/tracks it assert turret.ai.state["facing_degrees"] == 0.0 # but physically can't turn def test_behavior_tracks_and_turns_but_does_not_fire_without_a_gun(registry): world = make_world(registry) turret = make_turret(registry, EntityPosition("field", 0, 0, 0)) turret.get_or_create_body_part_override("gun").removed = True enemy = make_human(registry, position=EntityPosition("field", 0, 6, 0), factions=["hostile"]) enemy_start_integrity = enemy.get_body_part("torso").integrity world.add_entity(turret) world.add_entity(enemy) turret_ai_behavior(turret, world, registry, now=0.0, rng=FixedRng()) assert turret.ai.state["facing_degrees"] != 0.0 # still turns toward the target torso = enemy.get_body_part("torso") assert torso.integrity == enemy_start_integrity # never actually got shot at def _total_damage_taken(character: Character) -> float: return sum(100.0 - part.integrity for part in character.resolved_body_parts().values() if part is not None) def test_behavior_rotates_toward_and_fires_at_a_fully_functional_turret(registry): world = make_world(registry) turret = make_turret(registry, EntityPosition("field", 0, 0, 0)) enemy = make_human(registry, position=EntityPosition("field", 6, 0, 0), factions=["hostile"]) world.add_entity(turret) world.add_entity(enemy) turret_ai_behavior(turret, world, registry, now=0.0, rng=FixedRng(roll=20)) assert turret.ai.state["target_entity_id"] == enemy.entity_id assert turret.ai.state["facing_degrees"] == 0.0 # enemy due east - already facing that way assert _total_damage_taken(enemy) > 0.0 # a roll of 20 should land a hit somewhere def test_behavior_turn_is_capped_by_the_configured_turn_rate(registry): world = make_world(registry) turret = make_turret(registry, EntityPosition("field", 0, 0, 0)) turret.ai.state["turn_rate_degrees_per_tick"] = 10.0 enemy = make_human(registry, position=EntityPosition("field", 0, 6, 0), factions=["hostile"]) # due "south", 90 degrees world.add_entity(turret) world.add_entity(enemy) turret_ai_behavior(turret, world, registry, now=0.0, rng=FixedRng()) assert turret.ai.state["facing_degrees"] == pytest.approx(10.0) def test_behavior_converges_on_target_over_several_ticks(registry): world = make_world(registry) turret = make_turret(registry, EntityPosition("field", 0, 0, 0)) turret.ai.state["turn_rate_degrees_per_tick"] = 10.0 enemy = make_human(registry, position=EntityPosition("field", 0, 6, 0), factions=["hostile"]) world.add_entity(turret) world.add_entity(enemy) for _ in range(20): turret_ai_behavior(turret, world, registry, now=0.0, rng=FixedRng()) assert turret.ai.state["facing_degrees"] == pytest.approx(90.0) def test_behavior_ignores_a_bystander_with_no_hostile_tag(registry): world = make_world(registry) turret = make_turret(registry, EntityPosition("field", 0, 0, 0)) bystander = make_human(registry, position=EntityPosition("field", 6, 0, 0)) world.add_entity(turret) world.add_entity(bystander) turret_ai_behavior(turret, world, registry, now=0.0, rng=FixedRng()) assert turret.ai.state["target_entity_id"] is None assert turret.ai.state["facing_degrees"] == 0.0 # --- spawn_turret ------------------------------------------------------------------------------ def test_spawn_turret_builds_a_turret_species_character(registry): turret = spawn_turret(registry, "turret_pistol", EntityPosition("field", 1, 1, 0)) assert turret.species_id == "turret" assert turret.name == "Pistol Turret" assert {p.slot for p in turret.default_body_parts} == {"psu", "cpu", "motors", "gun", "camera"} def test_spawn_turret_fills_the_ai_slot_with_the_turret_behavior(registry): turret = spawn_turret(registry, "turret_pistol", EntityPosition("field", 1, 1, 0)) assert turret.ai is not None assert turret.ai.behavior_id == TURRET_BEHAVIOR_ID assert AI_BEHAVIORS[TURRET_BEHAVIOR_ID] is turret_ai_behavior def test_spawn_turret_reads_weapon_and_turn_rate_from_the_entity_template(registry): pistol = spawn_turret(registry, "turret_pistol", EntityPosition("field", 0, 0, 0)) autocannon = spawn_turret(registry, "turret_autocannon", EntityPosition("field", 0, 0, 0)) assert pistol.ai.state["weapon_item_id"] == "pistol_basic" assert pistol.ai.state["turn_rate_degrees_per_tick"] == 30 assert autocannon.ai.state["weapon_item_id"] == "turret_autocannon" assert autocannon.ai.state["turn_rate_degrees_per_tick"] == 12 def test_spawn_turret_stores_the_given_targeting_config(registry): turret = spawn_turret( registry, "turret_pistol", EntityPosition("field", 0, 0, 0), hostile_factions=("raiders",), allied_factions=("crew",), hostile_entity_ids=("npc-1",), allied_entity_ids=("npc-2",), ) assert turret.ai.state["hostile_factions"] == ["raiders"] assert turret.ai.state["allied_factions"] == ["crew"] assert turret.ai.state["hostile_entity_ids"] == ["npc-1"] assert turret.ai.state["allied_entity_ids"] == ["npc-2"] def test_spawn_turret_gives_independent_ai_state_per_instance(registry): a = spawn_turret(registry, "turret_pistol", EntityPosition("field", 0, 0, 0)) b = spawn_turret(registry, "turret_pistol", EntityPosition("field", 1, 1, 0)) a.ai.state["facing_degrees"] = 45.0 assert b.ai.state["facing_degrees"] == 0.0 # --- run_ai_tick dispatch ------------------------------------------------------------------------ def test_run_ai_tick_dispatches_to_the_turret_behavior(registry): world = make_world(registry) turret = make_turret(registry, EntityPosition("field", 0, 0, 0)) enemy = make_human(registry, position=EntityPosition("field", 6, 0, 0), factions=["hostile"]) world.add_entity(turret) world.add_entity(enemy) run_ai_tick(turret, world, registry, now=0.0, rng=FixedRng()) assert turret.ai.state["target_entity_id"] == enemy.entity_id