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().
This commit is contained in:
Jared Miller 2026-02-09 16:39:15 -05:00
parent 6879da0964
commit 57afe9a3ce
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
4 changed files with 179 additions and 39 deletions

View file

@ -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"

View file

@ -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()

View file

@ -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()

View file

@ -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()