from pathlib import Path import pytest from engine.character import Character from engine.defs import DefRegistry from engine.entity import Entity, EntityPosition from engine.inventory import Inventory from engine.item import ItemInstance from engine.map import Map from engine.tile import Tile, TileLayerInstance from engine.world import World from resources.logic.actions import activate_hand from resources.logic.knockback import ( apply_knockback, apply_recoil, direction_between, melee_knockback_distance, resolve_explosion_knockback, resolve_melee_knockback, resolve_ranged_knockback, ) from resources.logic.ranged_combat import fire_held_weapon 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", 15, 15, 1) for x in range(15): for y in range(15): field.set_tile(x, y, 0, Tile(flooring=TileLayerInstance("grass"))) world = World(registry=registry) world.add_map(field) return world, field class FixedRng: def __init__(self, roll=15, choice_value="torso"): self._roll = roll self._choice_value = choice_value def randint(self, _lo, _hi): return self._roll def choice(self, seq): return self._choice_value if self._choice_value is not None else seq[0] def uniform(self, _a, _b): return 0.0 # --- primitives ---------------------------------------------------------------------------- def test_direction_between_computes_signed_unit_steps(): a = EntityPosition("field", 5, 5, 0) b = EntityPosition("field", 8, 3, 0) assert direction_between(a, b) == (1, -1, 0) def test_apply_knockback_moves_entity_up_to_distance(): world, _field = make_world(None) entity = Entity(position=EntityPosition("field", 5, 5, 0)) world.registry = None world.add_entity(entity) world.registry = _FreeRegistry() moved = apply_knockback(world, entity, (1, 0, 0), 3) assert moved == 3 assert (entity.position.x, entity.position.y) == (8, 5) class _FreeRegistry: def get_tile_layer_def(self, _layer, _def_id): return None def test_apply_knockback_stops_at_a_wall(): world, field = make_world(None) world.registry = _FreeRegistry() field.get_tile(7, 5, 0).room = TileLayerInstance("wood_wall") class WallAwareRegistry: def get_tile_layer_def(self, layer, def_id): if layer == "room" and def_id == "wood_wall": from engine.tile import TileLayerDef return TileLayerDef(id="wood_wall", layer="room", walkable=False) return None world.registry = WallAwareRegistry() entity = Entity(position=EntityPosition("field", 5, 5, 0)) world.add_entity(entity) moved = apply_knockback(world, entity, (1, 0, 0), 5) assert moved == 1 # stopped just before the wall at x=7 assert entity.position.x == 6 def test_melee_knockback_distance_scales_with_strength(registry): weak = make_human(registry, strength=5) strong = make_human(registry, strength=20) staff = registry.get_item_def("staff_oak") assert melee_knockback_distance(strong, staff, registry) > melee_knockback_distance(weak, staff, registry) def test_melee_knockback_distance_unarmed_uses_default_base(registry): character = make_human(registry, strength=10) assert melee_knockback_distance(character, None, registry) == round(0.3 * 1.0) def test_apply_recoil_skips_rockets(): world, _field = make_world(None) world.registry = _FreeRegistry() shooter = Entity(position=EntityPosition("field", 5, 5, 0)) world.add_entity(shooter) moved = apply_recoil(world, shooter, (1, 0, 0), knockback_dealt=10, is_rocket=True) assert moved == 0 assert shooter.position.x == 5 def test_apply_recoil_pushes_shooter_backward_proportional_to_knockback(): world, _field = make_world(None) world.registry = _FreeRegistry() shooter = Entity(position=EntityPosition("field", 5, 5, 0)) world.add_entity(shooter) moved = apply_recoil(world, shooter, (1, 0, 0), knockback_dealt=10, is_rocket=False) assert moved == round(10 * 0.3) assert shooter.position.x == 5 - moved # --- resolve_melee_knockback ---------------------------------------------------------------- def test_resolve_melee_knockback_pushes_defender_and_recoils_attacker(registry): world, _field = make_world(registry) attacker = make_human(registry, position=EntityPosition("field", 5, 5, 0), strength=20) defender = make_human(registry, position=EntityPosition("field", 6, 5, 0)) world.add_entity(attacker) world.add_entity(defender) staff = registry.get_item_def("staff_oak") moved = resolve_melee_knockback(world, attacker, defender, staff, hit=True, registry=registry) assert moved > 0 assert defender.position.x > 6 # pushed away from the attacker def test_resolve_melee_knockback_no_op_on_miss(registry): world, _field = make_world(registry) attacker = make_human(registry, position=EntityPosition("field", 5, 5, 0), strength=20) defender = make_human(registry, position=EntityPosition("field", 6, 5, 0)) world.add_entity(attacker) world.add_entity(defender) staff = registry.get_item_def("staff_oak") moved = resolve_melee_knockback(world, attacker, defender, staff, hit=False, registry=registry) assert moved == 0 assert defender.position.x == 6 # --- resolve_ranged_knockback / resolve_explosion_knockback ------------------------------- def test_resolve_ranged_knockback_pushes_target_and_recoils_shooter(registry): world, _field = make_world(registry) 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) heavy = registry.get_item_def("ammo_heavy_caliber") moved = resolve_ranged_knockback(world, shooter, target, heavy, (1, 0, 0)) assert moved == round(heavy.ranged_knockback) assert target.position.x == 5 + moved assert shooter.position.x < 0.0 + 1 # shooter recoiled backward from x=0 def test_resolve_explosion_knockback_pushes_entities_radially(registry): world, _field = make_world(registry) east = make_human(registry, position=EntityPosition("field", 7, 5, 0)) west = make_human(registry, position=EntityPosition("field", 3, 5, 0)) world.add_entity(east) world.add_entity(west) rocket = registry.get_item_def("ammo_rocket") resolve_explosion_knockback(world, rocket, center=(5, 5, 0), hit_entities=[(east, 20.0), (west, 20.0)]) assert east.position.x > 7 # pushed further east, away from center assert west.position.x < 3 # pushed further west, away from center # --- end-to-end through activate_hand / fire_held_weapon ----------------------------------- def test_activate_hand_melee_knocks_back_target(registry): world, _field = make_world(registry) attacker = make_human(registry, position=EntityPosition("field", 5, 5, 0), strength=20) attacker.bio["athletics"] = 15 attacker.wield_item("right_hand", "staff_oak", registry) defender = make_human(registry, position=EntityPosition("field", 6, 5, 0)) world.add_entity(attacker) world.add_entity(defender) activate_hand(attacker, world, registry, "right_hand", (1, 0, 0), rng=FixedRng(roll=18)) assert defender.position.x > 6 def test_fire_held_weapon_heavy_caliber_knocks_back_even_when_blocked(registry): world, _field = make_world(registry) shooter = make_human(registry, position=EntityPosition("field", 0, 5, 0)) shooter.wield_item("right_hand", "rifle_heavy", registry) shooter.inventory = Inventory(4, 4) ammo_def = registry.get_item_def("ammo_heavy_caliber") shooter.inventory.place(ItemInstance("ammo-1", "ammo_heavy_caliber", quantity=5), ammo_def, 0, 0) shooter.reload("right_hand", registry) # heavy_shield doesn't cover heavy_caliber... use one that does, and set is_blocking so the # opposed-check path runs (knockback should still land regardless of that outcome) defender = make_human(registry, position=EntityPosition("field", 3, 5, 0), is_blocking=True) world.add_entity(shooter) world.add_entity(defender) original_x = defender.position.x fire_held_weapon(shooter, world, registry, "right_hand", (1, 0, 0), rng=FixedRng(roll=15)) assert defender.position.x > original_x