Prevent double-save when quitting IF sessions

This commit is contained in:
Jared Miller 2026-02-09 16:44:13 -05:00
parent 8893525647
commit bb1f9bbbd8
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
2 changed files with 117 additions and 0 deletions

View file

@ -28,6 +28,8 @@ class IFSession:
self.process: asyncio.subprocess.Process | None = None self.process: asyncio.subprocess.Process | None = None
# data/ directory is at project root (2 levels up from src/mudlib/) # data/ directory is at project root (2 levels up from src/mudlib/)
self._data_dir = Path(__file__).resolve().parents[2] / "data" self._data_dir = Path(__file__).resolve().parents[2] / "data"
# Track whether we've saved (prevents double-save on quit+stop)
self._saved = False
@property @property
def save_path(self) -> Path: def save_path(self) -> Path:
@ -78,6 +80,8 @@ class IFSession:
return IFResponse(output=confirmation, done=False) return IFResponse(output=confirmation, done=False)
# Regular game input - send to dfrotz # Regular game input - send to dfrotz
# Reset saved flag since game state has changed
self._saved = False
if self.process and self.process.stdin: if self.process and self.process.stdin:
stripped = text.strip() stripped = text.strip()
self.process.stdin.write(f"{stripped}\n".encode()) self.process.stdin.write(f"{stripped}\n".encode())
@ -100,6 +104,10 @@ class IFSession:
async def _do_save(self) -> str: async def _do_save(self) -> str:
"""Save game state to disk. Returns confirmation message.""" """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: if not self.process or not self.process.stdin:
return "error: game not running" return "error: game not running"
@ -120,6 +128,9 @@ class IFSession:
# Read confirmation # Read confirmation
confirmation = await self._read_response() confirmation = await self._read_response()
# Mark as saved
self._saved = True
return confirmation return confirmation
async def _do_restore(self) -> str: async def _do_restore(self) -> str:

View file

@ -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" assert mock_process.stdin.write.call_args_list[0][0][0] == b"save\n"
mock_process.terminate.assert_called_once() mock_process.terminate.assert_called_once()
mock_process.wait.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"