diff --git a/src/mudlib/combat/commands.py b/src/mudlib/combat/commands.py index c87b40c..5de2f41 100644 --- a/src/mudlib/combat/commands.py +++ b/src/mudlib/combat/commands.py @@ -52,6 +52,11 @@ 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_zaxis.py b/tests/test_combat_zaxis.py new file mode 100644 index 0000000..f8eceb0 --- /dev/null +++ b/tests/test_combat_zaxis.py @@ -0,0 +1,239 @@ +"""Tests for z-axis altitude checks in combat.""" + +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from mudlib.combat import commands as combat_commands +from mudlib.combat.encounter import CombatState +from mudlib.combat.engine import active_encounters, get_encounter, start_encounter +from mudlib.combat.moves import load_moves +from mudlib.player import Player, players +from mudlib.zone import Zone + + +@pytest.fixture(autouse=True) +def clear_state(): + """Clear encounters and players before and after each test.""" + active_encounters.clear() + players.clear() + yield + active_encounters.clear() + players.clear() + + +@pytest.fixture +def test_zone(): + """Create a test zone for players.""" + terrain = [["." for _ in range(256)] for _ in range(256)] + zone = Zone( + name="testzone", + width=256, + height=256, + toroidal=True, + terrain=terrain, + impassable=set(), + ) + return zone + + +@pytest.fixture +def mock_writer(): + writer = MagicMock() + writer.write = MagicMock() + writer.drain = AsyncMock() + return writer + + +@pytest.fixture +def mock_reader(): + return MagicMock() + + +@pytest.fixture +def player(mock_reader, mock_writer, test_zone): + p = Player(name="Goku", x=0, y=0, reader=mock_reader, writer=mock_writer) + p.location = test_zone + test_zone._contents.append(p) + players[p.name] = p + return p + + +@pytest.fixture +def target(mock_reader, mock_writer, test_zone): + t = Player(name="Vegeta", x=0, y=0, reader=mock_reader, writer=mock_writer) + t.location = test_zone + test_zone._contents.append(t) + players[t.name] = t + return t + + +@pytest.fixture +def moves(): + """Load combat moves from content directory.""" + content_dir = Path(__file__).parent.parent / "content" / "combat" + return load_moves(content_dir) + + +@pytest.fixture(autouse=True) +def inject_moves(moves): + """Inject loaded moves into combat commands module.""" + combat_commands.combat_moves = moves + yield + combat_commands.combat_moves = {} + + +@pytest.fixture +def punch_right(moves): + """Get the punch right move.""" + return moves["punch right"] + + +@pytest.fixture +def dodge_left(moves): + """Get the dodge left move.""" + return moves["dodge left"] + + +# --- Z-axis altitude check tests for starting combat --- + + +@pytest.mark.asyncio +async def test_attack_fails_when_attacker_flying_defender_grounded( + player, target, punch_right +): + """Test attack fails when attacker is flying and defender is grounded.""" + player.flying = True + target.flying = False + + await combat_commands.do_attack(player, "Vegeta", punch_right) + + # Combat should not start + encounter = get_encounter(player) + assert encounter is None + + # Player should get error message + player.writer.write.assert_called() + messages = [call[0][0] for call in player.writer.write.call_args_list] + assert any("can't reach" in msg.lower() for msg in messages) + + +@pytest.mark.asyncio +async def test_attack_fails_when_attacker_grounded_defender_flying( + player, target, punch_right +): + """Test attack fails when attacker is grounded and defender is flying.""" + player.flying = False + target.flying = True + + await combat_commands.do_attack(player, "Vegeta", punch_right) + + # Combat should not start + encounter = get_encounter(player) + assert encounter is None + + # Player should get error message + player.writer.write.assert_called() + messages = [call[0][0] for call in player.writer.write.call_args_list] + assert any("can't reach" in msg.lower() for msg in messages) + + +@pytest.mark.asyncio +async def test_attack_succeeds_when_both_flying(player, target, punch_right): + """Test attack succeeds when both are flying.""" + player.flying = True + target.flying = True + + await combat_commands.do_attack(player, "Vegeta", punch_right) + + # Combat should start + encounter = get_encounter(player) + assert encounter is not None + assert encounter.attacker is player + assert encounter.defender is target + + +@pytest.mark.asyncio +async def test_attack_succeeds_when_both_grounded(player, target, punch_right): + """Test attack succeeds when both are grounded.""" + player.flying = False + target.flying = False + + await combat_commands.do_attack(player, "Vegeta", punch_right) + + # Combat should start + encounter = get_encounter(player) + assert encounter is not None + assert encounter.attacker is player + assert encounter.defender is target + + +# --- Flying dodge tests (altitude mismatch at resolve time) --- + + +@pytest.mark.asyncio +async def test_flying_during_window_causes_miss(player, target, punch_right): + """Test flying during window phase causes attack to miss at resolve time.""" + # Both grounded, start combat + player.flying = False + target.flying = False + + encounter = start_encounter(player, target) + encounter.attack(punch_right) + + # Advance to WINDOW phase + encounter.state = CombatState.WINDOW + + # Defender flies during window + target.flying = True + + # Resolve + result = encounter.resolve() + + # Attack should miss (countered or zero damage) + assert result.countered is True or result.damage == 0.0 + + +@pytest.mark.asyncio +async def test_both_flying_at_resolve_attack_lands(player, target, punch_right): + """Test both flying at resolve time — attack lands normally.""" + # Both flying, start combat + player.flying = True + target.flying = True + + encounter = start_encounter(player, target) + encounter.attack(punch_right) + + # Advance to WINDOW phase (no altitude change) + encounter.state = CombatState.WINDOW + + # Resolve + result = encounter.resolve() + + # Attack should land (damage > 0 unless defended) + # Since there's no defense, damage should be > 0 + assert result.damage > 0.0 + + +@pytest.mark.asyncio +async def test_attacker_flies_during_window_causes_miss(player, target, punch_right): + """Test attacker flying during window phase causes attack to miss.""" + # Both grounded, start combat + player.flying = False + target.flying = False + + encounter = start_encounter(player, target) + encounter.attack(punch_right) + + # Advance to WINDOW phase + encounter.state = CombatState.WINDOW + + # Attacker flies during window + player.flying = True + + # Resolve + result = encounter.resolve() + + # Attack should miss + assert result.countered is True or result.damage == 0.0