233 lines
8.5 KiB
Python
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
|