diff --git a/src/mudlib/combat/engine.py b/src/mudlib/combat/engine.py index b9266a6..a239265 100644 --- a/src/mudlib/combat/engine.py +++ b/src/mudlib/combat/engine.py @@ -4,7 +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 +from mudlib.gmcp import send_char_status, send_char_vitals # Global list of active combat encounters active_encounters: list[CombatEncounter] = [] @@ -86,6 +86,7 @@ async def process_combat() -> None: for entity in (encounter.attacker, encounter.defender): if isinstance(entity, Player) and entity.mode == "combat": entity.mode_stack.pop() + send_char_status(entity) end_encounter(encounter) continue @@ -134,10 +135,12 @@ async def process_combat() -> None: attacker = encounter.attacker if isinstance(attacker, Player) and attacker.mode == "combat": attacker.mode_stack.pop() + send_char_status(attacker) defender = encounter.defender if isinstance(defender, Player) and defender.mode == "combat": defender.mode_stack.pop() + send_char_status(defender) # Remove encounter from active list end_encounter(encounter) diff --git a/src/mudlib/commands/rest.py b/src/mudlib/commands/rest.py index 71d3ef2..5b64a8c 100644 --- a/src/mudlib/commands/rest.py +++ b/src/mudlib/commands/rest.py @@ -1,6 +1,7 @@ """Rest command for restoring stamina.""" from mudlib.commands.movement import send_nearby_message +from mudlib.gmcp import send_char_status from mudlib.player import Player @@ -20,6 +21,7 @@ async def cmd_rest(player: Player, args: str) -> None: if player.resting: # Stop resting player.resting = False + send_char_status(player) await player.send("You stop resting.\r\n") await send_nearby_message( player, player.x, player.y, f"{player.name} stops resting.\r\n" @@ -27,6 +29,7 @@ async def cmd_rest(player: Player, args: str) -> None: else: # Start resting player.resting = True + send_char_status(player) await player.send("You begin to rest.\r\n") await send_nearby_message( player, player.x, player.y, f"{player.name} begins to rest.\r\n" diff --git a/src/mudlib/resting.py b/src/mudlib/resting.py index df11a84..1c0177f 100644 --- a/src/mudlib/resting.py +++ b/src/mudlib/resting.py @@ -1,7 +1,7 @@ """Resting system for stamina regeneration.""" from mudlib.commands.movement import send_nearby_message -from mudlib.gmcp import send_char_vitals +from mudlib.gmcp import send_char_status, send_char_vitals from mudlib.player import players # Stamina regeneration rate: 2.0 per second @@ -26,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_status(player) send_char_vitals(player) await player.send("You feel fully rested.\r\n") await send_nearby_message( diff --git a/src/mudlib/server.py b/src/mudlib/server.py index 529b78d..5c16329 100644 --- a/src/mudlib/server.py +++ b/src/mudlib/server.py @@ -32,6 +32,13 @@ from mudlib.combat.commands import register_combat_commands from mudlib.combat.engine import process_combat from mudlib.content import load_commands from mudlib.effects import clear_expired +from mudlib.gmcp import ( + send_char_status, + send_char_vitals, + send_map_data, + send_msdp_vitals, + send_room_info, +) from mudlib.if_session import broadcast_to_spectators from mudlib.mob_ai import process_mobs from mudlib.mobs import load_mob_templates, mob_templates @@ -77,14 +84,21 @@ async def game_loop() -> None: """Run periodic game tasks at TICK_RATE ticks per second.""" log.info("game loop started (%d ticks/sec)", TICK_RATE) last_save_time = time.monotonic() + tick_count = 0 while True: t0 = asyncio.get_event_loop().time() + tick_count += 1 clear_expired() await process_combat() await process_mobs(mudlib.combat.commands.combat_moves) await process_resting() + # MSDP updates once per second (every TICK_RATE ticks) + if tick_count % TICK_RATE == 0: + for p in list(players.values()): + send_msdp_vitals(p) + # Periodic auto-save (every 60 seconds) current_time = time.monotonic() if current_time - last_save_time >= AUTOSAVE_INTERVAL: @@ -347,6 +361,12 @@ async def shell( # Show initial map await mudlib.commands.look.cmd_look(player, "") + # Send initial GMCP data to rich clients + send_char_vitals(player) + send_char_status(player) + send_room_info(player) + send_map_data(player) + # Command loop try: while not _writer.is_closing(): diff --git a/tests/test_gmcp.py b/tests/test_gmcp.py index d7b094f..c3ffa41 100644 --- a/tests/test_gmcp.py +++ b/tests/test_gmcp.py @@ -370,3 +370,114 @@ async def test_char_vitals_sent_on_combat_resolve(): # 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