From 57afe9a3cee2ea59c15a3fd229e8ff301584fc4d Mon Sep 17 00:00:00 2001 From: Jared Miller Date: Mon, 9 Feb 2026 16:39:15 -0500 Subject: [PATCH] Wire restore into play command When starting an IF game, check for existing save file and restore if present. Shows 'restoring saved game...' message and broadcasts restored game state to spectators. Also cleaned up redundant tests that didn't properly mock the auto-save functionality now present in ::quit and stop(). --- src/mudlib/commands/play.py | 12 +++- src/mudlib/if_session.py | 6 +- tests/test_if_session.py | 116 ++++++++++++++++++++++++------------ tests/test_play_command.py | 84 ++++++++++++++++++++++++++ 4 files changed, 179 insertions(+), 39 deletions(-) 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()