from pathlib import Path import pytest 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.combat import find_ranged_target, perform_shoot DEFS_DIR = Path(__file__).resolve().parent.parent / "resources" / "defs" @pytest.fixture() def registry(): return DefRegistry.load(DEFS_DIR) def make_human(registry, **kwargs) -> Character: return Character(species_id="human", default_body_parts=registry.new_default_body_parts("human"), **kwargs) def make_world(registry): field = Map("field", 10, 10, 1) for x in range(10): for y in range(10): field.set_tile(x, y, 0, Tile(flooring=TileLayerInstance("grass"))) world = World(registry=registry) world.add_map(field) return world, field class ScriptedRng: """Deterministic stand-in: independent, ordered queues for randint() and choice() calls.""" def __init__(self, randints=None, choices=None): self._randints = list(randints or []) self._choices = list(choices or []) def randint(self, _lo, _hi): return self._randints.pop(0) def choice(self, seq): if self._choices: return self._choices.pop(0) return seq[0] def test_find_ranged_target_hits_entity_in_line(registry): world, field = make_world(registry) shooter = make_human(registry, position=EntityPosition("field", 0, 5, 0)) target = make_human(registry, position=EntityPosition("field", 3, 5, 0)) world.add_entity(shooter) world.add_entity(target) found, impact, steps = find_ranged_target(world, "field", 0, 5, 0, 1, 0, 0, max_range=6) assert found is target assert impact == (3, 5, 0) assert steps == 3 def test_find_ranged_target_stops_at_a_wall(registry): world, field = make_world(registry) field.get_tile(2, 5, 0).room = TileLayerInstance("wood_wall") shooter = make_human(registry, position=EntityPosition("field", 0, 5, 0)) target = make_human(registry, position=EntityPosition("field", 5, 5, 0)) world.add_entity(shooter) world.add_entity(target) found, impact, _steps = find_ranged_target(world, "field", 0, 5, 0, 1, 0, 0, max_range=6) assert found is None assert impact == (2, 5, 0) def test_find_ranged_target_respects_max_range(registry): world, _field = make_world(registry) shooter = make_human(registry, position=EntityPosition("field", 0, 5, 0)) target = make_human(registry, position=EntityPosition("field", 9, 5, 0)) world.add_entity(shooter) world.add_entity(target) found, _impact, _steps = find_ranged_target(world, "field", 0, 5, 0, 1, 0, 0, max_range=3) assert found is None def test_perform_shoot_hits_and_damages_target(registry): world, _field = make_world(registry) shooter = make_human(registry, position=EntityPosition("field", 0, 5, 0)) target = make_human(registry, position=EntityPosition("field", 3, 5, 0)) world.add_entity(shooter) world.add_entity(target) pistol = registry.get_item_def("pistol_basic") result = perform_shoot(shooter, world, registry, pistol, (1, 0, 0), rng=ScriptedRng(randints=[15])) assert result.hit is True assert result.blocked is False assert result.target_entity_id == target.entity_id assert result.attack.target_slot is not None assert target.get_body_part(result.attack.target_slot).integrity < 100.0 def test_shield_blocks_matching_projectile_type_without_ricochet_target(registry): world, _field = make_world(registry) shooter = make_human(registry, position=EntityPosition("field", 0, 5, 0)) blocker = make_human(registry, position=EntityPosition("field", 3, 5, 0), is_blocking=True) blocker.wield_item("left_hand", "wooden_shield", registry) world.add_entity(shooter) world.add_entity(blocker) bow = registry.get_item_def("bow_basic") # projectile_type "arrow", which wooden_shield blocks # ricochet direction (choice=1 -> deflect south) finds nothing there, so the bounce fizzles result = perform_shoot(shooter, world, registry, bow, (1, 0, 0), rng=ScriptedRng(choices=[1])) assert result.blocked is True assert result.ricocheted is True assert result.hit is False assert result.target_entity_id is None # nothing found along the ricochet path def test_shield_blocks_and_ricochet_hits_a_second_target(registry): world, _field = make_world(registry) shooter = make_human(registry, position=EntityPosition("field", 0, 5, 0)) blocker = make_human(registry, position=EntityPosition("field", 3, 5, 0), is_blocking=True) blocker.wield_item("left_hand", "wooden_shield", registry) bystander = make_human(registry, position=EntityPosition("field", 3, 7, 0)) # 2 tiles south of the blocker world.add_entity(shooter) world.add_entity(blocker) world.add_entity(bystander) bow = registry.get_item_def("bow_basic") result = perform_shoot(shooter, world, registry, bow, (1, 0, 0), rng=ScriptedRng(randints=[15], choices=[1])) assert result.blocked is True assert result.ricocheted is True assert result.target_entity_id == bystander.entity_id assert result.hit is True assert result.attack.target_slot is not None assert bystander.get_body_part(result.attack.target_slot).integrity < 100.0 def test_non_matching_projectile_type_breaks_through_shield(registry): world, _field = make_world(registry) shooter = make_human(registry, position=EntityPosition("field", 0, 5, 0)) blocker = make_human(registry, position=EntityPosition("field", 3, 5, 0), is_blocking=True) blocker.wield_item("left_hand", "wooden_shield", registry) # only blocks "arrow" world.add_entity(shooter) world.add_entity(blocker) pistol = registry.get_item_def("pistol_basic") # projectile_type "bullet" # blocker is still in a blocking stance, so the opposed check still applies even though the # shield doesn't cover "bullet" - it just can't hard-block/ricochet this projectile type result = perform_shoot( shooter, world, registry, pistol, (1, 0, 0), rng=ScriptedRng(randints=[15, 1], choices=["torso"]) ) assert result.blocked is False assert result.ricocheted is False assert result.target_entity_id == blocker.entity_id assert result.attack.blocked_check is True