Mi2dRPGamEng/tests/test_knockback.py

233 lines
8.5 KiB
Python

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