"""Tests for GMCP and MSDP support.""" from unittest.mock import AsyncMock, MagicMock import pytest from mudlib.gmcp import ( send_char_status, send_char_vitals, send_map_data, send_msdp_vitals, send_room_info, ) from mudlib.player import Player, players from mudlib.zone import Zone @pytest.fixture(autouse=True) def clear_state(): """Clear players before and after each test.""" players.clear() yield players.clear() @pytest.fixture def test_zone(): """Create a small test zone with known terrain.""" terrain = [["." for _ in range(25)] for _ in range(25)] # 25x25 simple terrain # Add some impassable tiles for exit testing terrain[5][10] = "^" # mountain north of player zone = Zone( name="testzone", width=25, height=25, toroidal=True, terrain=terrain, impassable={"^", "~"}, ) return zone @pytest.fixture def mock_writer(): writer = MagicMock() writer.write = MagicMock() writer.drain = AsyncMock() writer.send_gmcp = MagicMock() writer.send_msdp = MagicMock() # Option negotiation state (for gmcp_enabled/msdp_enabled checks) writer.local_option = MagicMock() writer.local_option.enabled = MagicMock(return_value=True) writer.remote_option = MagicMock() writer.remote_option.enabled = MagicMock(return_value=True) return writer @pytest.fixture def player(mock_writer, test_zone): p = Player(name="Goku", x=10, y=10, writer=mock_writer) p.location = test_zone p.pl = 85.5 p.max_pl = 100.0 p.stamina = 42.3 p.max_stamina = 100.0 players[p.name] = p return p def test_send_char_vitals(player): """Test Char.Vitals sends correct payload.""" send_char_vitals(player) player.writer.send_gmcp.assert_called_once_with( "Char.Vitals", { "pl": 85.5, "max_pl": 100.0, "stamina": 42.3, "max_stamina": 100.0, }, ) def test_send_char_status_normal_mode(player): """Test Char.Status sends correct payload in normal mode.""" player.flying = False player.resting = False player.mode_stack = ["normal"] send_char_status(player) player.writer.send_gmcp.assert_called_once_with( "Char.Status", { "flying": False, "resting": False, "mode": "normal", "in_combat": False, "is_admin": False, "x": 10, "y": 10, "paint_mode": False, "painting": False, "paint_brush": ".", }, ) def test_send_char_status_in_combat(player): """Test Char.Status detects combat mode.""" player.mode_stack = ["normal", "combat"] send_char_status(player) args = player.writer.send_gmcp.call_args[0] assert args[0] == "Char.Status" assert args[1]["mode"] == "combat" assert args[1]["in_combat"] is True def test_send_char_status_flying_and_resting(player): """Test Char.Status reflects flying and resting states.""" player.flying = True player.resting = True send_char_status(player) args = player.writer.send_gmcp.call_args[0] assert args[1]["flying"] is True assert args[1]["resting"] is True def test_send_room_info(player): """Test Room.Info sends zone, coordinates, terrain, and exits.""" send_room_info(player) player.writer.send_gmcp.assert_called_once() args = player.writer.send_gmcp.call_args[0] assert args[0] == "Room.Info" data = args[1] assert data["zone"] == "testzone" assert data["x"] == 10 assert data["y"] == 10 assert data["terrain"] == "." # All adjacent tiles should be passable (player at 10,10, all tiles are ".") assert "north" in data["exits"] assert "south" in data["exits"] assert "east" in data["exits"] assert "west" in data["exits"] assert "northeast" in data["exits"] assert "northwest" in data["exits"] assert "southeast" in data["exits"] assert "southwest" in data["exits"] def test_send_room_info_blocked_exit(player, test_zone): """Test Room.Info excludes blocked exits.""" # Put player next to mountain at 10,5 player.y = 6 test_zone.terrain[5][10] = "^" # mountain to the north send_room_info(player) args = player.writer.send_gmcp.call_args[0] data = args[1] # North (y-1=5) should be impassable assert "north" not in data["exits"] assert "south" in data["exits"] def test_send_room_info_no_zone(player): """Test Room.Info with no zone does not crash.""" player.location = None send_room_info(player) # Should not have called send_gmcp player.writer.send_gmcp.assert_not_called() def test_send_map_data(player): """Test Room.Map sends terrain grid.""" send_map_data(player) player.writer.send_gmcp.assert_called_once() args = player.writer.send_gmcp.call_args[0] assert args[0] == "Room.Map" data = args[1] assert data["x"] == 10 assert data["y"] == 10 assert data["radius"] == 10 # Grid should be 21x21 (radius 10 = -10 to +10 inclusive) assert len(data["terrain"]) == 21 assert len(data["terrain"][0]) == 21 # Center tile should be "." assert data["terrain"][10][10] == "." def test_send_map_data_toroidal_wrapping(player, test_zone): """Test Room.Map wraps correctly in toroidal zones.""" # Put player near edge to test wrapping player.x = 2 player.y = 2 send_map_data(player) args = player.writer.send_gmcp.call_args[0] data = args[1] # Should still be 21x21 even near edge (wraps around) assert len(data["terrain"]) == 21 assert len(data["terrain"][0]) == 21 def test_send_map_data_no_zone(player): """Test Room.Map with no zone does not crash.""" player.location = None send_map_data(player) player.writer.send_gmcp.assert_not_called() def test_send_msdp_vitals(player): """Test MSDP sends vitals with correct variable names.""" send_msdp_vitals(player) player.writer.send_msdp.assert_called_once_with( { "HEALTH": "85.5", "HEALTH_MAX": "100.0", "STAMINA": "42.3", "STAMINA_MAX": "100.0", } ) def test_send_msdp_vitals_dedup(player): """Test MSDP skips sending when values haven't changed.""" send_msdp_vitals(player) assert player.writer.send_msdp.call_count == 1 # second call with same values — should be suppressed send_msdp_vitals(player) assert player.writer.send_msdp.call_count == 1 # change a value — should send again player.pl = 80.0 send_msdp_vitals(player) assert player.writer.send_msdp.call_count == 2 def test_player_send_gmcp(player): """Test Player.send_gmcp convenience method.""" player.send_gmcp("Test.Package", {"key": "value"}) player.writer.send_gmcp.assert_called_once_with("Test.Package", {"key": "value"}) def test_player_send_gmcp_no_writer(): """Test Player.send_gmcp with no writer does not crash.""" p = Player(name="NoWriter", writer=None) p.send_gmcp("Test.Package", {}) # Should not raise def test_player_send_msdp(player): """Test Player.send_msdp convenience method.""" player.send_msdp({"VAR": "value"}) player.writer.send_msdp.assert_called_once_with({"VAR": "value"}) def test_player_send_msdp_no_writer(): """Test Player.send_msdp with no writer does not crash.""" p = Player(name="NoWriter", writer=None) p.send_msdp({}) # Should not raise @pytest.mark.asyncio async def test_room_info_sent_on_movement(player, test_zone): """Test Room.Info is sent when player moves.""" from mudlib.commands.movement import move_player await move_player(player, 1, 0, "east") # Verify send_gmcp was called with Room.Info gmcp_calls = player.writer.send_gmcp.call_args_list room_info_calls = [call for call in gmcp_calls if call[0][0] == "Room.Info"] assert len(room_info_calls) > 0, "Room.Info should be sent on movement" @pytest.mark.asyncio async def test_map_data_sent_on_movement(player, test_zone): """Test Room.Map is sent when player moves.""" from mudlib.commands.movement import move_player await move_player(player, 1, 0, "east") # Verify send_gmcp was called with Room.Map gmcp_calls = player.writer.send_gmcp.call_args_list room_map_calls = [call for call in gmcp_calls if call[0][0] == "Room.Map"] assert len(room_map_calls) > 0, "Room.Map should be sent on movement" @pytest.mark.asyncio async def test_char_vitals_sent_on_rest_complete(player): """Test Char.Vitals is sent when resting completes.""" from mudlib.resting import process_resting player.resting = True player.stamina = player.max_stamina - 0.1 # Almost full await process_resting() # Should have called send_gmcp with both Char.Status and Char.Vitals assert player.writer.send_gmcp.call_count == 2 # First call should be Char.Status first_call = player.writer.send_gmcp.call_args_list[0] assert first_call[0][0] == "Char.Status" # Second call should be Char.Vitals second_call = player.writer.send_gmcp.call_args_list[1] assert second_call[0][0] == "Char.Vitals" assert second_call[0][1] == { "pl": round(player.pl, 1), "max_pl": round(player.max_pl, 1), "stamina": round(player.max_stamina, 1), "max_stamina": round(player.max_stamina, 1), } @pytest.mark.asyncio async def test_char_vitals_sent_on_combat_resolve(): """Test Char.Vitals is sent to both players on combat resolution.""" import time from mudlib.combat.engine import active_encounters, process_combat, start_encounter from mudlib.combat.moves import CombatMove # Clear encounters active_encounters.clear() # Create two players with mock writers mock_writer_1 = MagicMock() mock_writer_1.write = MagicMock() mock_writer_1.drain = AsyncMock() mock_writer_1.send_gmcp = MagicMock() mock_writer_2 = MagicMock() mock_writer_2.write = MagicMock() mock_writer_2.drain = AsyncMock() mock_writer_2.send_gmcp = MagicMock() attacker = Player( name="Goku", x=0, y=0, pl=100.0, stamina=50.0, writer=mock_writer_1 ) defender = Player( name="Vegeta", x=0, y=0, pl=100.0, stamina=50.0, writer=mock_writer_2 ) attacker.mode_stack.append("combat") defender.mode_stack.append("combat") # Create encounter and attack encounter = start_encounter(attacker, defender) punch = CombatMove( name="punch right", move_type="attack", stamina_cost=5.0, timing_window_ms=800, damage_pct=0.15, countered_by=["dodge left"], ) encounter.attack(punch) # Advance past telegraph and window to trigger resolution time.sleep(0.31) await process_combat() time.sleep(0.85) # Reset mocks before the resolution call mock_writer_1.send_gmcp.reset_mock() mock_writer_2.send_gmcp.reset_mock() await process_combat() # Both players should have received Char.Vitals assert mock_writer_1.send_gmcp.call_count == 1 assert mock_writer_2.send_gmcp.call_count == 1 # Check attacker's call attacker_call = mock_writer_1.send_gmcp.call_args[0] assert attacker_call[0] == "Char.Vitals" assert "pl" in attacker_call[1] assert "max_pl" in attacker_call[1] assert "stamina" in attacker_call[1] assert "max_stamina" in attacker_call[1] # Check defender's call defender_call = mock_writer_2.send_gmcp.call_args[0] assert defender_call[0] == "Char.Vitals" assert "pl" in defender_call[1] assert "max_pl" in defender_call[1] assert "stamina" in defender_call[1] assert "max_stamina" in defender_call[1] # Cleanup active_encounters.clear() @pytest.mark.asyncio async def test_char_status_sent_on_combat_end(): """Test Char.Status is sent when combat ends (victory/defeat).""" import time from mudlib.combat.engine import active_encounters, process_combat, start_encounter from mudlib.combat.moves import CombatMove # Clear encounters active_encounters.clear() # Create two players with mock writers mock_writer_1 = MagicMock() mock_writer_1.write = MagicMock() mock_writer_1.drain = AsyncMock() mock_writer_1.send_gmcp = MagicMock() mock_writer_2 = MagicMock() mock_writer_2.write = MagicMock() mock_writer_2.drain = AsyncMock() mock_writer_2.send_gmcp = MagicMock() attacker = Player( name="Goku", x=0, y=0, pl=100.0, stamina=50.0, writer=mock_writer_1 ) defender = Player( name="Vegeta", x=0, y=0, pl=1.0, stamina=50.0, writer=mock_writer_2 ) attacker.mode_stack.append("combat") defender.mode_stack.append("combat") # Create encounter and attack (will kill defender) encounter = start_encounter(attacker, defender) punch = CombatMove( name="punch right", move_type="attack", stamina_cost=5.0, timing_window_ms=800, damage_pct=0.15, countered_by=["dodge left"], ) encounter.attack(punch) # Advance past telegraph and window to trigger resolution time.sleep(0.31) await process_combat() time.sleep(0.85) # Reset mocks before the resolution call mock_writer_1.send_gmcp.reset_mock() mock_writer_2.send_gmcp.reset_mock() await process_combat() # Both players should have received Char.Status (after Char.Vitals) # Check that Char.Status was called at least once attacker_calls = [ call for call in mock_writer_1.send_gmcp.call_args_list if call[0][0] == "Char.Status" ] defender_calls = [ call for call in mock_writer_2.send_gmcp.call_args_list if call[0][0] == "Char.Status" ] assert len(attacker_calls) >= 1, "Attacker should receive Char.Status on combat end" assert len(defender_calls) >= 1, "Defender should receive Char.Status on combat end" # Verify mode is back to normal for attacker assert attacker_calls[0][0][1]["mode"] == "normal" assert attacker_calls[0][0][1]["in_combat"] is False # Cleanup active_encounters.clear() @pytest.mark.asyncio async def test_char_status_sent_on_rest_start(player): """Test Char.Status is sent when resting begins.""" from mudlib.commands.rest import cmd_rest player.stamina = 50.0 player.resting = False await cmd_rest(player, "") # Check that Char.Status was sent status_calls = [ call for call in player.writer.send_gmcp.call_args_list if call[0][0] == "Char.Status" ] assert len(status_calls) == 1 assert status_calls[0][0][1]["resting"] is True @pytest.mark.asyncio async def test_char_status_sent_on_rest_complete(player): """Test Char.Status is sent when resting completes.""" from mudlib.resting import process_resting player.resting = True player.stamina = player.max_stamina - 0.1 # Almost full await process_resting() # Check that Char.Status was sent status_calls = [ call for call in player.writer.send_gmcp.call_args_list if call[0][0] == "Char.Status" ] assert len(status_calls) == 1 assert status_calls[0][0][1]["resting"] is False def test_msdp_vitals_skipped_when_not_negotiated(player): """Test send_msdp_vitals skips when MSDP is not negotiated.""" player.writer.local_option.enabled.return_value = False player.writer.remote_option.enabled.return_value = False send_msdp_vitals(player) player.writer.send_msdp.assert_not_called() def test_msdp_enabled_property(player): """Test msdp_enabled reflects negotiation state.""" player.writer.local_option.enabled.return_value = False player.writer.remote_option.enabled.return_value = False assert not player.msdp_enabled player.writer.local_option.enabled.return_value = True assert player.msdp_enabled def test_gmcp_enabled_property(player): """Test gmcp_enabled reflects negotiation state.""" player.writer.local_option.enabled.return_value = False player.writer.remote_option.enabled.return_value = False assert not player.gmcp_enabled player.writer.remote_option.enabled.return_value = True assert player.gmcp_enabled def test_msdp_enabled_no_writer(): """Test msdp_enabled with no writer returns False.""" p = Player(name="NoWriter", writer=None) assert not p.msdp_enabled def test_gmcp_enabled_no_writer(): """Test gmcp_enabled with no writer returns False.""" p = Player(name="NoWriter", writer=None) assert not p.gmcp_enabled def test_gmcp_sends_skipped_when_not_negotiated(player): """Test all GMCP send functions skip when GMCP is not negotiated.""" player.writer.local_option.enabled.return_value = False player.writer.remote_option.enabled.return_value = False send_char_vitals(player) send_char_status(player) send_room_info(player) send_map_data(player) player.writer.send_gmcp.assert_not_called() def test_char_status_includes_admin_flag(player): """Test Char.Status includes is_admin field.""" player.is_admin = True send_char_status(player) args = player.writer.send_gmcp.call_args[0] assert args[1]["is_admin"] is True def test_char_status_includes_coordinates(player): """Test Char.Status includes x/y coordinates.""" player.x = 42 player.y = 13 send_char_status(player) args = player.writer.send_gmcp.call_args[0] assert args[1]["x"] == 42 assert args[1]["y"] == 13 def test_char_status_includes_paint_state(player): """Test Char.Status includes paint mode fields.""" player.paint_mode = True player.painting = True player.paint_brush = "#" send_char_status(player) args = player.writer.send_gmcp.call_args[0] assert args[1]["paint_mode"] is True assert args[1]["painting"] is True assert args[1]["paint_brush"] == "#" def test_char_status_paint_mode_off(player): """Test Char.Status includes paint fields even when paint mode is off.""" player.paint_mode = False player.painting = False player.paint_brush = "." send_char_status(player) args = player.writer.send_gmcp.call_args[0] assert args[1]["paint_mode"] is False assert args[1]["painting"] is False assert args[1]["paint_brush"] == "."