Prevent double-save when quitting IF sessions
This commit is contained in:
parent
8893525647
commit
bb1f9bbbd8
2 changed files with 117 additions and 0 deletions
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue