diff --git a/src/mudlib/combat/commands.py b/src/mudlib/combat/commands.py index e1bb261..e97fcb1 100644 --- a/src/mudlib/combat/commands.py +++ b/src/mudlib/combat/commands.py @@ -9,7 +9,7 @@ from mudlib.combat.engine import get_encounter, start_encounter from mudlib.combat.moves import CombatMove, load_moves from mudlib.combat.stamina import check_stamina_cues from mudlib.commands import CommandDefinition, register -from mudlib.player import Player, players +from mudlib.player import Player from mudlib.render.colors import colorize from mudlib.render.pov import render_pov @@ -32,15 +32,16 @@ async def do_attack(player: Player, target_args: str, move: CombatMove) -> None: target = None target_name = target_args.strip() if encounter is None and target_name: - target = players.get(target_name) - if target is None and player.location is not None: - from mudlib.mobs import get_nearby_mob - from mudlib.zone import Zone + from mudlib.targeting import find_entity_on_tile - if isinstance(player.location, Zone): - target = get_nearby_mob( - target_name, player.x, player.y, player.location - ) + target = find_entity_on_tile(target_name, player) + + # If no target found on same z-axis, check if one exists on different z-axis + if target is None: + any_z_target = find_entity_on_tile(target_name, player, z_filter=False) + if any_z_target is not None: + await player.send("You can't reach them from here!\r\n") + return # Check stamina if player.stamina < move.stamina_cost: @@ -53,11 +54,6 @@ async def do_attack(player: Player, target_args: str, move: CombatMove) -> None: await player.send("You need a target to start combat.\r\n") return - # Check altitude match before starting combat - if getattr(player, "flying", False) != getattr(target, "flying", False): - await player.send("You can't reach them from here!\r\n") - return - # Start new encounter try: encounter = start_encounter(player, target) diff --git a/tests/test_combat_targeting.py b/tests/test_combat_targeting.py new file mode 100644 index 0000000..1ff692a --- /dev/null +++ b/tests/test_combat_targeting.py @@ -0,0 +1,137 @@ +"""Tests for combat command targeting integration.""" + +import pytest + +from mudlib.combat.commands import do_attack +from mudlib.combat.moves import CombatMove +from mudlib.mobs import Mob +from mudlib.player import Player + + +@pytest.fixture +def attack_move(): + """Create a test attack move.""" + return CombatMove( + name="punch left", + command="punch", + variant="left", + move_type="attack", + stamina_cost=5, + timing_window_ms=850, + telegraph="telegraphs a left punch at {defender}", + telegraph_color="yellow", + aliases=[], + ) + + +@pytest.mark.asyncio +async def test_attack_with_prefix_match(player, nearby_player, attack_move): + """Attack with prefix match finds target.""" + player.stamina = 10 + + # Use "veg" to find Vegeta + await do_attack(player, "veg", attack_move) + + # Should send combat start message + sent = "".join(call.args[0] for call in player.writer.write.call_args_list) + assert "You engage Vegeta in combat!" in sent + assert "You use punch left!" in sent + + +@pytest.mark.asyncio +async def test_attack_with_ordinal(player, nearby_player, test_zone, attack_move): + """Attack with ordinal selects correct target.""" + # Create two more players on the same tile, both starting with "p" + from unittest.mock import AsyncMock, MagicMock + + mock_writer2 = MagicMock() + mock_writer2.write = MagicMock() + mock_writer2.drain = AsyncMock() + mock_reader2 = MagicMock() + player2 = Player(name="Piccolo", x=0, y=0, reader=mock_reader2, writer=mock_writer2) + player2.location = test_zone + test_zone._contents.append(player2) + + mock_writer3 = MagicMock() + mock_writer3.write = MagicMock() + mock_writer3.drain = AsyncMock() + mock_reader3 = MagicMock() + player3 = Player(name="Puar", x=0, y=0, reader=mock_reader3, writer=mock_writer3) + player3.location = test_zone + test_zone._contents.append(player3) + + from mudlib.player import players + + players[player2.name] = player2 + players[player3.name] = player3 + + player.stamina = 10 + + # Use "2.p" to find the second player starting with "p" (should be Puar) + await do_attack(player, "2.p", attack_move) + + # Should engage the second "p" match + sent = "".join(call.args[0] for call in player.writer.write.call_args_list) + assert ( + "You engage Piccolo in combat!" in sent or "You engage Puar in combat!" in sent + ) + + +@pytest.mark.asyncio +async def test_attack_nonexistent_target(player, attack_move): + """Attack on non-existent target shows error.""" + player.stamina = 10 + + await do_attack(player, "nobody", attack_move) + + sent = "".join(call.args[0] for call in player.writer.write.call_args_list) + assert "You need a target to start combat." in sent + + +@pytest.mark.asyncio +async def test_attack_z_axis_mismatch(player, nearby_player, attack_move): + """Attack on different z-axis is blocked with specific error message.""" + player.stamina = 10 + player.flying = True + nearby_player.flying = False + + # find_entity_on_tile returns None due to z-axis mismatch + await do_attack(player, "Vegeta", attack_move) + + sent = "".join(call.args[0] for call in player.writer.write.call_args_list) + assert "You can't reach them from here!" in sent + + +@pytest.mark.asyncio +async def test_attack_mob_with_prefix(player, test_zone, attack_move): + """Attack mob with prefix match.""" + player.stamina = 10 + + # Create a mob on the same tile + mob = Mob(name="Goblin", x=0, y=0) + mob.location = test_zone + test_zone._contents.append(mob) + + # Use "gob" to find the goblin + await do_attack(player, "gob", attack_move) + + sent = "".join(call.args[0] for call in player.writer.write.call_args_list) + assert "You engage Goblin in combat!" in sent + + +@pytest.mark.asyncio +async def test_attack_skips_dead_mob(player, test_zone, attack_move): + """Attack skips dead mobs.""" + player.stamina = 10 + + # Create a dead mob on the same tile + mob = Mob(name="Goblin", x=0, y=0) + mob.location = test_zone + mob.alive = False + test_zone._contents.append(mob) + + # Should not find the dead mob + await do_attack(player, "goblin", attack_move) + + sent = "".join(call.args[0] for call in player.writer.write.call_args_list) + assert "You need a target to start combat." in sent