"""Tests for combat commands.""" import time from pathlib import Path from unittest.mock import AsyncMock, MagicMock import pytest import mudlib.commands.movement as movement_mod from mudlib.combat import commands as combat_commands from mudlib.combat.engine import active_encounters, get_encounter from mudlib.combat.moves import load_moves from mudlib.player import Player, players @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(autouse=True) def mock_world(): """Inject a mock world for send_nearby_message.""" fake_world = MagicMock() fake_world.width = 256 fake_world.height = 256 old = movement_mod.world movement_mod.world = fake_world yield fake_world movement_mod.world = old @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): p = Player(name="Goku", x=0, y=0, reader=mock_reader, writer=mock_writer) players[p.name] = p return p @pytest.fixture def target(mock_reader, mock_writer): t = Player(name="Vegeta", x=0, y=0, reader=mock_reader, writer=mock_writer) 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 punch_left(moves): """Get the punch left move.""" return moves["punch left"] @pytest.fixture def dodge_left(moves): """Get the dodge left move.""" return moves["dodge left"] # --- do_attack tests --- @pytest.mark.asyncio async def test_attack_starts_combat_with_target(player, target, punch_right): """Test do_attack with target starts combat encounter.""" await combat_commands.do_attack(player, "Vegeta", punch_right) encounter = get_encounter(player) assert encounter is not None assert encounter.attacker is player assert encounter.defender is target assert player.mode == "combat" @pytest.mark.asyncio async def test_attack_without_target_when_not_in_combat(player, punch_right): """Test do_attack without target when not in combat gives error.""" await combat_commands.do_attack(player, "", punch_right) player.writer.write.assert_called() message = player.writer.write.call_args[0][0] assert "need a target" in message.lower() @pytest.mark.asyncio async def test_attack_without_target_when_in_combat( player, target, punch_right, punch_left ): """Test do_attack without target when in combat uses implicit target.""" # Start combat first await combat_commands.do_attack(player, "Vegeta", punch_right) # Reset mock to track new calls player.writer.write.reset_mock() # Attack without target should work await combat_commands.do_attack(player, "", punch_left) encounter = get_encounter(player) assert encounter is not None assert encounter.current_move is not None assert encounter.current_move.name == "punch left" @pytest.mark.asyncio async def test_attack_insufficient_stamina(player, target, punch_right): """Test do_attack with insufficient stamina gives error.""" player.stamina = 1.0 # Not enough for punch (costs 5) await combat_commands.do_attack(player, "Vegeta", punch_right) player.writer.write.assert_called() message = player.writer.write.call_args[0][0] assert "stamina" in message.lower() @pytest.mark.asyncio async def test_attack_sends_telegraph_to_defender(player, target, punch_right): """Test do_attack sends telegraph message to defender.""" await combat_commands.do_attack(player, "Vegeta", punch_right) # Check that encounter has the move encounter = get_encounter(player) assert encounter is not None assert encounter.current_move is not None assert encounter.current_move.name == "punch right" # --- do_defend tests --- @pytest.mark.asyncio async def test_defense_works_outside_combat(player, dodge_left): """Test do_defend works outside combat (costs stamina, 'the air' message).""" initial_stamina = player.stamina await combat_commands.do_defend(player, "", dodge_left) assert player.stamina == initial_stamina - dodge_left.stamina_cost messages = [call[0][0] for call in player.writer.write.call_args_list] assert any("dodge the air" in msg.lower() for msg in messages) @pytest.mark.asyncio async def test_defense_records_pending_defense(player, target, punch_right, dodge_left): """Test do_defend queues defense on encounter and costs stamina.""" # Start combat await combat_commands.do_attack(player, "Vegeta", punch_right) player.writer.write.reset_mock() initial_stamina = target.stamina # Defend await combat_commands.do_defend(target, "", dodge_left) encounter = get_encounter(target) assert encounter is not None assert encounter.pending_defense is not None assert encounter.pending_defense.name == "dodge left" assert target.stamina == initial_stamina - dodge_left.stamina_cost @pytest.mark.asyncio async def test_defense_insufficient_stamina(player, dodge_left): """Test do_defend with insufficient stamina gives error.""" player.stamina = 1.0 # Not enough for dodge (costs 3) await combat_commands.do_defend(player, "", dodge_left) player.writer.write.assert_called() messages = [call[0][0] for call in player.writer.write.call_args_list] assert any("stamina" in msg.lower() for msg in messages) # --- variant handler tests --- @pytest.mark.asyncio async def test_variant_handler_parses_direction(player, target, moves): """Test the variant handler parses direction from args.""" variant_moves = { "left": moves["punch left"], "right": moves["punch right"], } handler = combat_commands._make_variant_handler( "punch", variant_moves, combat_commands.do_attack ) await handler(player, "right Vegeta") encounter = get_encounter(player) assert encounter is not None assert encounter.current_move is not None assert encounter.current_move.name == "punch right" @pytest.mark.asyncio async def test_variant_handler_no_direction(player, moves): """Test the variant handler prompts when no direction given.""" variant_moves = { "left": moves["punch left"], "right": moves["punch right"], } handler = combat_commands._make_variant_handler( "punch", variant_moves, combat_commands.do_attack ) await handler(player, "") player.writer.write.assert_called() message = player.writer.write.call_args[0][0] assert "which way" in message.lower() @pytest.mark.asyncio async def test_variant_handler_bad_direction(player, moves): """Test the variant handler rejects invalid direction.""" variant_moves = { "left": moves["punch left"], "right": moves["punch right"], } handler = combat_commands._make_variant_handler( "punch", variant_moves, combat_commands.do_attack ) await handler(player, "up Vegeta") player.writer.write.assert_called() message = player.writer.write.call_args[0][0] assert "unknown" in message.lower() # --- direct handler tests --- @pytest.mark.asyncio async def test_direct_handler_passes_move(player, target, punch_right): """Test the direct handler passes the bound move through.""" handler = combat_commands._make_direct_handler( punch_right, combat_commands.do_attack ) await handler(player, "Vegeta") encounter = get_encounter(player) assert encounter is not None assert encounter.current_move is not None assert encounter.current_move.name == "punch right" @pytest.mark.asyncio async def test_direct_handler_alias_for_variant(player, target, punch_right): """Test direct handler works for variant moves.""" handler = combat_commands._make_direct_handler( punch_right, combat_commands.do_attack ) await handler(player, "Vegeta") encounter = get_encounter(player) assert encounter is not None assert encounter.current_move is not None assert encounter.current_move.name == "punch right" assert encounter.attacker is player assert encounter.defender is target # --- attack switching tests --- @pytest.mark.asyncio async def test_switch_attack_shows_switch_message( player, target, punch_right, punch_left ): """Test switching attack says 'switch to' instead of 'use'.""" await combat_commands.do_attack(player, "Vegeta", punch_right) player.writer.write.reset_mock() await combat_commands.do_attack(player, "", punch_left) messages = [call[0][0] for call in player.writer.write.call_args_list] assert any("switch to" in msg.lower() for msg in messages) @pytest.mark.asyncio async def test_switch_attack_sends_new_telegraph( player, target, punch_right, punch_left ): """Test switching attack sends new telegraph to defender.""" await combat_commands.do_attack(player, "Vegeta", punch_right) target.writer.write.reset_mock() await combat_commands.do_attack(player, "", punch_left) target_msgs = [call[0][0] for call in target.writer.write.call_args_list] # Defender should get a new telegraph with the new move's text assert any("left hook" in msg.lower() for msg in target_msgs) # --- defense commitment tests --- @pytest.mark.asyncio async def test_defense_blocks_for_timing_window(player, dodge_left): """Test defense sleeps for timing_window_ms (commitment via blocking).""" before = time.monotonic() await combat_commands.do_defend(player, "", dodge_left) elapsed = time.monotonic() - before expected = dodge_left.timing_window_ms / 1000.0 assert elapsed >= expected - 0.05 @pytest.mark.asyncio async def test_defense_in_combat_queues_on_encounter( player, target, punch_right, dodge_left ): """Test defense in combat queues on encounter and shows move name.""" await combat_commands.do_attack(player, "Vegeta", punch_right) await combat_commands.do_defend(target, "", dodge_left) encounter = get_encounter(target) assert encounter is not None assert encounter.pending_defense is dodge_left # In combat: full move name (not "the air") messages = [call[0][0] for call in target.writer.write.call_args_list] assert any("dodge left" in msg.lower() for msg in messages) @pytest.mark.asyncio async def test_defense_broadcast_to_nearby(player, target, dodge_left): """Test defense broadcasts to nearby players.""" target.writer.write.reset_mock() await combat_commands.do_defend(player, "", dodge_left) # Target is at same coords, should get broadcast target_msgs = [call[0][0] for call in target.writer.write.call_args_list] assert any(player.name in msg for msg in target_msgs)