From e2bd260538b031e7f30de846c33af24d93b9caeb Mon Sep 17 00:00:00 2001 From: Jared Miller Date: Mon, 9 Feb 2026 16:31:47 -0500 Subject: [PATCH] Add save functionality to IF sessions --- src/mudlib/if_session.py | 24 +++++++++++ tests/test_if_session.py | 86 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+) diff --git a/src/mudlib/if_session.py b/src/mudlib/if_session.py index 04f5c58..7eec8bb 100644 --- a/src/mudlib/if_session.py +++ b/src/mudlib/if_session.py @@ -85,6 +85,30 @@ class IFSession: self.process.terminate() await self.process.wait() + async def _do_save(self) -> str: + """Save game state to disk. Returns confirmation message.""" + if not self.process or not self.process.stdin: + return "error: game not running" + + # Ensure save directory exists + self._ensure_save_dir() + + # Send "save" command to dfrotz + self.process.stdin.write(b"save\n") + await self.process.stdin.drain() + + # Read filename prompt + await self._read_response() + + # Send save file path + save_path_str = str(self.save_path) + self.process.stdin.write(f"{save_path_str}\n".encode()) + await self.process.stdin.drain() + + # Read confirmation + confirmation = await self._read_response() + return confirmation + async def _read_response(self) -> str: """Read dfrotz output until the '>' prompt appears.""" if not self.process or not self.process.stdout: diff --git a/tests/test_if_session.py b/tests/test_if_session.py index 1b13441..dc3062e 100644 --- a/tests/test_if_session.py +++ b/tests/test_if_session.py @@ -324,3 +324,89 @@ def test_ensure_save_dir_creates_directories(tmp_path): # Now it should exist assert expected_dir.exists() assert expected_dir.is_dir() + + +@pytest.mark.asyncio +async def test_do_save_sends_save_command(tmp_path): + """_do_save() sends save command and filepath to dfrotz.""" + 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 responses: first the filename prompt, then confirmation + responses = [ + b"Enter saved game to store: \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) + + result = await session._do_save() + + # Should have written "save\n" then the filepath + calls = mock_process.stdin.write.call_args_list + assert len(calls) == 2 + assert calls[0][0][0] == b"save\n" + assert str(session.save_path) in calls[1][0][0].decode() + assert b"\n" in calls[1][0][0] + + # Result should contain confirmation + assert "Ok" in result or "ok" in result.lower() + + +@pytest.mark.asyncio +async def test_do_save_creates_save_directory(tmp_path): + """_do_save() ensures save directory exists.""" + player = MagicMock() + player.name = "alice" + session = IFSession(player, "/path/to/story.z5", "story") + 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 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) + + # Directory shouldn't exist yet + expected_dir = tmp_path / "if_saves" / "alice" + assert not expected_dir.exists() + + await session._do_save() + + # Now it should exist + assert expected_dir.exists()