diff --git a/src/mudlib/if_session.py b/src/mudlib/if_session.py index 14dec5b..628f70c 100644 --- a/src/mudlib/if_session.py +++ b/src/mudlib/if_session.py @@ -28,6 +28,8 @@ class IFSession: self.process: asyncio.subprocess.Process | None = None # data/ directory is at project root (2 levels up from src/mudlib/) self._data_dir = Path(__file__).resolve().parents[2] / "data" + # Track whether we've saved (prevents double-save on quit+stop) + self._saved = False @property def save_path(self) -> Path: @@ -78,6 +80,8 @@ class IFSession: return IFResponse(output=confirmation, done=False) # Regular game input - send to dfrotz + # Reset saved flag since game state has changed + self._saved = False if self.process and self.process.stdin: stripped = text.strip() self.process.stdin.write(f"{stripped}\n".encode()) @@ -100,6 +104,10 @@ class IFSession: async def _do_save(self) -> str: """Save game state to disk. Returns confirmation message.""" + # Skip if already saved (prevents double-save on quit+stop) + if self._saved: + return "already saved" + if not self.process or not self.process.stdin: return "error: game not running" @@ -120,6 +128,9 @@ class IFSession: # Read confirmation confirmation = await self._read_response() + + # Mark as saved + self._saved = True return confirmation async def _do_restore(self) -> str: diff --git a/tests/test_if_session.py b/tests/test_if_session.py index db27b9c..2d89106 100644 --- a/tests/test_if_session.py +++ b/tests/test_if_session.py @@ -577,3 +577,109 @@ async def test_stop_saves_before_terminating(tmp_path): 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() + + +@pytest.mark.asyncio +async def test_quit_then_stop_does_not_double_save(tmp_path): + """stop() after ::quit doesn't save again (prevents double-save).""" + 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 (only expect one save) + 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) + + # First: handle ::quit (should save) + await session.handle_input("::quit") + + # Verify save was called once + save_calls = [ + call for call in mock_process.stdin.write.call_args_list if call[0][0] == b"save\n" + ] + assert len(save_calls) == 1 + + # Second: call stop() (should NOT save again) + await session.stop() + + # Verify save was still only called once + save_calls = [ + call for call in mock_process.stdin.write.call_args_list if call[0][0] == b"save\n" + ] + assert len(save_calls) == 1, "stop() should not save again after ::quit" + mock_process.terminate.assert_called_once() + + +@pytest.mark.asyncio +async def test_regular_input_resets_save_flag(tmp_path): + """Regular input resets the saved flag so subsequent saves work.""" + 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() + session.process = mock_process + + # Simulate dfrotz responses + responses = [ + b"Enter saved game: \n>", + b"Ok.\n>", # first save + b"You move north.\n>", # regular input + b"Enter saved game: \n>", + b"Ok.\n>", # second save + ] + 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) + + # First: save + await session.handle_input("::save") + + # Second: regular input (resets saved flag) + await session.handle_input("north") + + # Third: save again (should work) + await session.handle_input("::save") + + # Verify save was called twice + save_calls = [ + call for call in mock_process.stdin.write.call_args_list if call[0][0] == b"save\n" + ] + assert len(save_calls) == 2, "should be able to save again after regular input"