diff --git a/src/mudlib/combat/engine.py b/src/mudlib/combat/engine.py index 1cc2384..b9266a6 100644 --- a/src/mudlib/combat/engine.py +++ b/src/mudlib/combat/engine.py @@ -4,6 +4,7 @@ import time from mudlib.combat.encounter import IDLE_TIMEOUT, CombatEncounter, CombatState from mudlib.entity import Entity, Mob +from mudlib.gmcp import send_char_vitals # Global list of active combat encounters active_encounters: list[CombatEncounter] = [] @@ -100,6 +101,13 @@ async def process_combat() -> None: await encounter.attacker.send(result.attacker_msg + "\r\n") await encounter.defender.send(result.defender_msg + "\r\n") + # Send vitals update after damage resolution + from mudlib.player import Player + + for entity in (encounter.attacker, encounter.defender): + if isinstance(entity, Player): + send_char_vitals(entity) + if result.combat_ended: # Determine winner/loser if encounter.defender.pl <= 0: diff --git a/src/mudlib/commands/movement.py b/src/mudlib/commands/movement.py index dec0001..876611a 100644 --- a/src/mudlib/commands/movement.py +++ b/src/mudlib/commands/movement.py @@ -2,6 +2,7 @@ from mudlib.commands import CommandDefinition, register from mudlib.entity import Entity +from mudlib.gmcp import send_map_data, send_room_info from mudlib.player import Player from mudlib.portal import Portal from mudlib.zone import Zone @@ -106,6 +107,8 @@ async def move_player(player: Player, dx: int, dy: int, direction_name: str) -> from mudlib.commands.look import cmd_look await cmd_look(player, "") + send_room_info(player) + send_map_data(player) return # Don't do normal arrival+look else: await player.send("The portal doesn't lead anywhere.\r\n") @@ -124,6 +127,8 @@ async def move_player(player: Player, dx: int, dy: int, direction_name: str) -> from mudlib.commands.look import cmd_look await cmd_look(player, "") + send_room_info(player) + send_map_data(player) async def send_nearby_message(entity: Entity, x: int, y: int, message: str) -> None: diff --git a/src/mudlib/resting.py b/src/mudlib/resting.py index df618d6..df11a84 100644 --- a/src/mudlib/resting.py +++ b/src/mudlib/resting.py @@ -1,6 +1,7 @@ """Resting system for stamina regeneration.""" from mudlib.commands.movement import send_nearby_message +from mudlib.gmcp import send_char_vitals from mudlib.player import players # Stamina regeneration rate: 2.0 per second @@ -25,6 +26,7 @@ async def process_resting() -> None: # Check if we reached max stamina if player.stamina >= player.max_stamina: player.resting = False + send_char_vitals(player) await player.send("You feel fully rested.\r\n") await send_nearby_message( player, player.x, player.y, f"{player.name} stops resting.\r\n" diff --git a/tests/test_gmcp.py b/tests/test_gmcp.py index 340d492..d7b094f 100644 --- a/tests/test_gmcp.py +++ b/tests/test_gmcp.py @@ -246,3 +246,127 @@ 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 Char.Vitals + player.writer.send_gmcp.assert_called_once_with( + "Char.Vitals", + { + "pl": round(player.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 "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 "stamina" in defender_call[1] + assert "max_stamina" in defender_call[1] + + # Cleanup + active_encounters.clear() diff --git a/tests/test_paint_mode.py b/tests/test_paint_mode.py index 714416f..1b7e3eb 100644 --- a/tests/test_paint_mode.py +++ b/tests/test_paint_mode.py @@ -42,6 +42,14 @@ class MockWriter: async def drain(self): pass + def send_gmcp(self, package: str, data: dict): + """Mock GMCP send (no-op for paint mode tests).""" + pass + + def send_msdp(self, data: dict): + """Mock MSDP send (no-op for paint mode tests).""" + pass + def get_output(self) -> str: return "".join(self.messages)