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:
parent
6879da0964
commit
57afe9a3ce
4 changed files with 179 additions and 39 deletions
|
|
@ -77,7 +77,17 @@ async def cmd_play(player: Player, args: str) -> None:
|
||||||
player.mode_stack.append("if")
|
player.mode_stack.append("if")
|
||||||
|
|
||||||
await player.send("(type ::help for escape commands)\r\n")
|
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")
|
await player.send(intro + "\r\n")
|
||||||
# Broadcast intro to spectators
|
# Broadcast intro to spectators
|
||||||
spectator_msg = f"[{player.name}'s terminal]\r\n{intro}\r\n"
|
spectator_msg = f"[{player.name}'s terminal]\r\n{intro}\r\n"
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,9 @@ class IFSession:
|
||||||
"""Handle player input. Route to dfrotz or handle escape commands."""
|
"""Handle player input. Route to dfrotz or handle escape commands."""
|
||||||
# Check for escape commands (:: prefix)
|
# Check for escape commands (:: prefix)
|
||||||
if text.lower() == "::quit":
|
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":
|
if text.lower() == "::help":
|
||||||
help_text = """escape commands:
|
help_text = """escape commands:
|
||||||
|
|
@ -87,6 +89,8 @@ class IFSession:
|
||||||
async def stop(self):
|
async def stop(self):
|
||||||
"""Terminate dfrotz process."""
|
"""Terminate dfrotz process."""
|
||||||
if self.process and self.process.returncode is None:
|
if self.process and self.process.returncode is None:
|
||||||
|
# Auto-save before terminating
|
||||||
|
await self._do_save()
|
||||||
self.process.terminate()
|
self.process.terminate()
|
||||||
await self.process.wait()
|
await self.process.wait()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -111,24 +111,6 @@ async def test_handle_input_sends_to_dfrotz():
|
||||||
mock_process.stdin.drain.assert_called()
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_handle_input_help_returns_help_text():
|
async def test_handle_input_help_returns_help_text():
|
||||||
"""handle_input('::help') returns help text listing escape commands."""
|
"""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
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_stop_when_no_process():
|
async def test_stop_when_no_process():
|
||||||
"""stop() does nothing if process is None."""
|
"""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()
|
assert "Ok" in response.output or "ok" in response.output.lower()
|
||||||
# Verify save command was sent
|
# Verify save command was sent
|
||||||
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"
|
||||||
|
|
||||||
|
|
||||||
|
@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()
|
||||||
|
|
|
||||||
|
|
@ -57,11 +57,15 @@ async def test_play_unknown_story(player):
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_play_enters_if_mode(player):
|
async def test_play_enters_if_mode(player):
|
||||||
"""Playing a valid story enters IF mode and creates session."""
|
"""Playing a valid story enters IF mode and creates session."""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from mudlib.commands.play import cmd_play
|
from mudlib.commands.play import cmd_play
|
||||||
|
|
||||||
# Mock IFSession
|
# Mock IFSession
|
||||||
mock_session = Mock()
|
mock_session = Mock()
|
||||||
mock_session.start = AsyncMock(return_value="Welcome to Zork!")
|
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:
|
with patch("mudlib.commands.play.IFSession") as MockIFSession:
|
||||||
MockIFSession.return_value = mock_session
|
MockIFSession.return_value = mock_session
|
||||||
|
|
@ -90,12 +94,16 @@ async def test_play_enters_if_mode(player):
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_play_handles_dfrotz_missing(player):
|
async def test_play_handles_dfrotz_missing(player):
|
||||||
"""Playing when dfrotz is missing sends error."""
|
"""Playing when dfrotz is missing sends error."""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from mudlib.commands.play import cmd_play
|
from mudlib.commands.play import cmd_play
|
||||||
|
|
||||||
# Mock IFSession to raise FileNotFoundError on start
|
# Mock IFSession to raise FileNotFoundError on start
|
||||||
mock_session = Mock()
|
mock_session = Mock()
|
||||||
mock_session.start = AsyncMock(side_effect=FileNotFoundError())
|
mock_session.start = AsyncMock(side_effect=FileNotFoundError())
|
||||||
mock_session.stop = AsyncMock()
|
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:
|
with patch("mudlib.commands.play.IFSession") as MockIFSession:
|
||||||
MockIFSession.return_value = mock_session
|
MockIFSession.return_value = mock_session
|
||||||
|
|
@ -118,3 +126,79 @@ async def test_play_handles_dfrotz_missing(player):
|
||||||
|
|
||||||
# Verify session.stop() was called
|
# Verify session.stop() was called
|
||||||
mock_session.stop.assert_called_once()
|
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()
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue