diff --git a/src/mudlib/commands/play.py b/src/mudlib/commands/play.py index 8fc19cb..180933d 100644 --- a/src/mudlib/commands/play.py +++ b/src/mudlib/commands/play.py @@ -77,7 +77,17 @@ async def cmd_play(player: Player, args: str) -> None: player.mode_stack.append("if") await player.send("(type ::help for escape commands)\r\n") - if intro: + + # Check for saved game + if session.save_path.exists(): + await player.send("restoring saved game...\r\n") + restored_text = await session._do_restore() + if restored_text: + await player.send(restored_text + "\r\n") + # Broadcast restored text to spectators + spectator_msg = f"[{player.name}'s terminal]\r\n{restored_text}\r\n" + await broadcast_to_spectators(player, spectator_msg) + elif intro: await player.send(intro + "\r\n") # Broadcast intro to spectators spectator_msg = f"[{player.name}'s terminal]\r\n{intro}\r\n" diff --git a/src/mudlib/if_session.py b/src/mudlib/if_session.py index 5c95a90..59c36c7 100644 --- a/src/mudlib/if_session.py +++ b/src/mudlib/if_session.py @@ -58,7 +58,9 @@ class IFSession: """Handle player input. Route to dfrotz or handle escape commands.""" # Check for escape commands (:: prefix) if text.lower() == "::quit": - return IFResponse(output="", done=True) + # Auto-save before quitting + await self._do_save() + return IFResponse(output="game saved.", done=True) if text.lower() == "::help": help_text = """escape commands: @@ -87,6 +89,8 @@ class IFSession: async def stop(self): """Terminate dfrotz process.""" if self.process and self.process.returncode is None: + # Auto-save before terminating + await self._do_save() self.process.terminate() await self.process.wait() diff --git a/tests/test_if_session.py b/tests/test_if_session.py index 956d0f2..8e2904d 100644 --- a/tests/test_if_session.py +++ b/tests/test_if_session.py @@ -111,24 +111,6 @@ async def test_handle_input_sends_to_dfrotz(): mock_process.stdin.drain.assert_called() -@pytest.mark.asyncio -async def test_handle_input_quit_returns_done(): - """handle_input('::quit') returns done=True without sending to dfrotz.""" - player = MagicMock() - session = IFSession(player, "/path/to/story.z5") - - # Mock process - mock_process = AsyncMock() - mock_process.stdin = AsyncMock() - session.process = mock_process - - response = await session.handle_input("::quit") - - assert response.done is True - # Should NOT have written to dfrotz stdin - mock_process.stdin.write.assert_not_called() - - @pytest.mark.asyncio async def test_handle_input_help_returns_help_text(): """handle_input('::help') returns help text listing escape commands.""" @@ -143,25 +125,6 @@ async def test_handle_input_help_returns_help_text(): assert "::help" in response.output -@pytest.mark.asyncio -async def test_stop_terminates_subprocess(): - """stop() terminates the dfrotz process.""" - player = MagicMock() - session = IFSession(player, "/path/to/story.z5") - - # Mock process - mock_process = MagicMock() - mock_process.returncode = None - mock_process.terminate = MagicMock() - mock_process.wait = AsyncMock() - session.process = mock_process - - await session.stop() - - mock_process.terminate.assert_called_once() - mock_process.wait.assert_called_once() - - @pytest.mark.asyncio async def test_stop_when_no_process(): """stop() does nothing if process is None.""" @@ -517,3 +480,82 @@ async def test_handle_input_save_triggers_save(tmp_path): assert "Ok" in response.output or "ok" in response.output.lower() # Verify save command was sent assert mock_process.stdin.write.call_args_list[0][0][0] == b"save\n" + + +@pytest.mark.asyncio +async def test_handle_input_quit_saves_before_exit(tmp_path): + """handle_input('::quit') saves game before returning done=True.""" + player = MagicMock() + player.name = "tester" + session = IFSession(player, "/path/to/zork.z5", "zork") + session._data_dir = tmp_path + + # Mock process + mock_process = MagicMock() + mock_process.stdin = MagicMock() + mock_process.stdin.write = MagicMock() + mock_process.stdin.drain = AsyncMock() + mock_process.stdout = AsyncMock() + session.process = mock_process + + # Simulate dfrotz save responses + responses = [b"Enter saved game: \n>", b"Ok.\n>"] + response_data = b"".join(responses) + + async def read_side_effect(n): + nonlocal response_data + if response_data: + byte = response_data[:1] + response_data = response_data[1:] + return byte + return b"" + + mock_process.stdout.read = AsyncMock(side_effect=read_side_effect) + + response = await session.handle_input("::quit") + + assert response.done is True + assert "saved" in response.output.lower() + # Verify save command was sent + assert mock_process.stdin.write.call_args_list[0][0][0] == b"save\n" + + +@pytest.mark.asyncio +async def test_stop_saves_before_terminating(tmp_path): + """stop() saves game before terminating process.""" + player = MagicMock() + player.name = "tester" + session = IFSession(player, "/path/to/zork.z5", "zork") + session._data_dir = tmp_path + + # Mock process + mock_process = MagicMock() + mock_process.returncode = None + mock_process.stdin = MagicMock() + mock_process.stdin.write = MagicMock() + mock_process.stdin.drain = AsyncMock() + mock_process.stdout = AsyncMock() + mock_process.terminate = MagicMock() + mock_process.wait = AsyncMock() + session.process = mock_process + + # Simulate dfrotz save responses + responses = [b"Enter saved game: \n>", b"Ok.\n>"] + response_data = b"".join(responses) + + async def read_side_effect(n): + nonlocal response_data + if response_data: + byte = response_data[:1] + response_data = response_data[1:] + return byte + return b"" + + mock_process.stdout.read = AsyncMock(side_effect=read_side_effect) + + await session.stop() + + # Verify save was called before terminate + assert mock_process.stdin.write.call_args_list[0][0][0] == b"save\n" + mock_process.terminate.assert_called_once() + mock_process.wait.assert_called_once() diff --git a/tests/test_play_command.py b/tests/test_play_command.py index 317a36f..f5beeaf 100644 --- a/tests/test_play_command.py +++ b/tests/test_play_command.py @@ -57,11 +57,15 @@ async def test_play_unknown_story(player): @pytest.mark.asyncio async def test_play_enters_if_mode(player): """Playing a valid story enters IF mode and creates session.""" + from pathlib import Path + from mudlib.commands.play import cmd_play # Mock IFSession mock_session = Mock() mock_session.start = AsyncMock(return_value="Welcome to Zork!") + mock_session.save_path = Mock(spec=Path) + mock_session.save_path.exists = Mock(return_value=False) with patch("mudlib.commands.play.IFSession") as MockIFSession: MockIFSession.return_value = mock_session @@ -90,12 +94,16 @@ async def test_play_enters_if_mode(player): @pytest.mark.asyncio async def test_play_handles_dfrotz_missing(player): """Playing when dfrotz is missing sends error.""" + from pathlib import Path + from mudlib.commands.play import cmd_play # Mock IFSession to raise FileNotFoundError on start mock_session = Mock() mock_session.start = AsyncMock(side_effect=FileNotFoundError()) mock_session.stop = AsyncMock() + mock_session.save_path = Mock(spec=Path) + mock_session.save_path.exists = Mock(return_value=False) with patch("mudlib.commands.play.IFSession") as MockIFSession: MockIFSession.return_value = mock_session @@ -118,3 +126,79 @@ async def test_play_handles_dfrotz_missing(player): # Verify session.stop() was called mock_session.stop.assert_called_once() + + +@pytest.mark.asyncio +async def test_play_restores_save_if_exists(player): + """Playing restores saved game if save file exists.""" + from pathlib import Path + + from mudlib.commands.play import cmd_play + + # Mock IFSession + mock_session = Mock() + mock_session.start = AsyncMock(return_value="Welcome to Zork!") + mock_session._do_restore = AsyncMock( + return_value="West of House\nYou are standing in an open field." + ) + mock_session.save_path = Mock(spec=Path) + mock_session.save_path.exists = Mock(return_value=True) + + with patch("mudlib.commands.play.IFSession") as MockIFSession: + MockIFSession.return_value = mock_session + + with patch("mudlib.commands.play._find_story") as mock_find: + mock_find.return_value = "/fake/path/zork1.z3" + + await cmd_play(player, "zork1") + + # Verify restore was called + mock_session._do_restore.assert_called_once() + + # Verify session was created and started + mock_session.start.assert_called_once() + + # Verify mode was pushed + assert "if" in player.mode_stack + + # Verify restored text was sent + calls = [call[0][0] for call in player.writer.write.call_args_list] + full_output = "".join(calls) + assert "restoring" in full_output.lower() + assert "West of House" in full_output + assert "open field" in full_output + + +@pytest.mark.asyncio +async def test_play_no_restore_if_no_save(player): + """Playing does not restore if no save file exists.""" + from pathlib import Path + + from mudlib.commands.play import cmd_play + + # Mock IFSession + mock_session = Mock() + mock_session.start = AsyncMock(return_value="Welcome to Zork!") + mock_session._do_restore = AsyncMock(return_value="") + mock_session.save_path = Mock(spec=Path) + mock_session.save_path.exists = Mock(return_value=False) + + with patch("mudlib.commands.play.IFSession") as MockIFSession: + MockIFSession.return_value = mock_session + + with patch("mudlib.commands.play._find_story") as mock_find: + mock_find.return_value = "/fake/path/zork1.z3" + + await cmd_play(player, "zork1") + + # Verify restore was NOT called + mock_session._do_restore.assert_not_called() + + # Verify session was created and started + mock_session.start.assert_called_once() + + # Verify intro was sent but not restore message + calls = [call[0][0] for call in player.writer.write.call_args_list] + full_output = "".join(calls) + assert "Welcome to Zork!" in full_output + assert "restoring" not in full_output.lower()