diff --git a/src/mudlib/commands/play.py b/src/mudlib/commands/play.py index 180933d..96d018a 100644 --- a/src/mudlib/commands/play.py +++ b/src/mudlib/commands/play.py @@ -3,6 +3,7 @@ import pathlib from mudlib.commands import CommandDefinition, register +from mudlib.embedded_if_session import EmbeddedIFSession from mudlib.if_session import IFSession, broadcast_to_spectators from mudlib.player import Player @@ -60,8 +61,20 @@ async def cmd_play(player: Player, args: str) -> None: await player.send(msg) return - # Create and start IF session - session = IFSession(player, str(story_path), game_name) + # Ensure story_path is a Path object (for mocking compatibility) + if not isinstance(story_path, pathlib.Path): + story_path = pathlib.Path(story_path) + + # Use embedded interpreter for z3 files, dfrotz for others + if story_path.suffix == ".z3": + try: + session = EmbeddedIFSession(player, str(story_path), game_name) + except (FileNotFoundError, OSError) as e: + await player.send(f"error starting game: {e}\r\n") + return + else: + session = IFSession(player, str(story_path), game_name) + try: intro = await session.start() except FileNotFoundError: @@ -78,8 +91,8 @@ async def cmd_play(player: Player, args: str) -> None: await player.send("(type ::help for escape commands)\r\n") - # Check for saved game - if session.save_path.exists(): + # Check for saved game (both session types now support _do_restore) + if hasattr(session, "_do_restore") and session.save_path.exists(): await player.send("restoring saved game...\r\n") restored_text = await session._do_restore() if restored_text: diff --git a/src/mudlib/embedded_if_session.py b/src/mudlib/embedded_if_session.py new file mode 100644 index 0000000..575ac49 --- /dev/null +++ b/src/mudlib/embedded_if_session.py @@ -0,0 +1,166 @@ +import asyncio +import logging +import re +import threading +from pathlib import Path +from typing import TYPE_CHECKING + +from mudlib.if_session import IFResponse +from mudlib.zmachine.mud_ui import create_mud_ui +from mudlib.zmachine.quetzal import QuetzalParser, QuetzalWriter +from mudlib.zmachine.zcpu import ZCpuQuit, ZCpuRestart +from mudlib.zmachine.zmachine import ZMachine + +if TYPE_CHECKING: + from mudlib.player import Player + +logger = logging.getLogger(__name__) + + +class EmbeddedIFSession: + """Wraps z-machine interpreter for MUD integration.""" + + def __init__(self, player: "Player", story_path: str, game_name: str = ""): + self.player = player + self.story_path = story_path + self.game_name = game_name or Path(story_path).stem + self._data_dir = Path(__file__).resolve().parents[2] / "data" + self._thread: threading.Thread | None = None + self._done = False + self._error: str | None = None + + story_bytes = Path(story_path).read_bytes() + save_path = self.save_path + self._ui, self._screen, self._keyboard = create_mud_ui(save_path) + self._zmachine = ZMachine(story_bytes, self._ui) + self._filesystem = self._ui.filesystem + + @property + def save_path(self) -> Path: + safe_name = re.sub(r"[^a-zA-Z0-9_-]", "_", self.player.name) + return self._data_dir / "if_saves" / safe_name / f"{self.game_name}.qzl" + + async def start(self) -> str: + self._thread = threading.Thread(target=self._run_interpreter, daemon=True) + self._thread.start() + + loop = asyncio.get_running_loop() + await loop.run_in_executor(None, self._keyboard._waiting.wait) + + intro = self._screen.flush() + return intro + + async def handle_input(self, text: str) -> IFResponse: + if text.lower() == "::quit": + await self._do_save() + return IFResponse(output="game saved.", done=True) + + if text.lower() == "::help": + help_text = """escape commands: +::quit - exit the game +::save - save game progress +::help - show this help""" + return IFResponse(output=help_text, done=False) + + if text.lower() == "::save": + confirmation = await self._do_save() + return IFResponse(output=confirmation, done=False) + + self._keyboard._waiting.clear() + self._keyboard.feed(text) + + loop = asyncio.get_running_loop() + + def wait_for_next_input(): + while not self._done and not self._keyboard._waiting.is_set(): + self._keyboard._waiting.wait(timeout=0.1) + + await loop.run_in_executor(None, wait_for_next_input) + + output = self._screen.flush() + if self._done and self._error: + output = f"{output}\r\n{self._error}" if output else self._error + return IFResponse(output=output, done=self._done) + + async def stop(self): + self._done = True + if self._keyboard._waiting.is_set(): + self._keyboard.feed("") + + def _run_interpreter(self): + try: + self._zmachine.run() + except ZCpuQuit: + logger.debug("Interpreter quit normally") + except ZCpuRestart: + logger.debug("Interpreter restart requested") + except Exception as e: + logger.error(f"Interpreter crashed: {e}") + self._error = f"interpreter error: {e}" + finally: + self._done = True + self._keyboard._waiting.set() + + async def _do_save(self) -> str: + try: + writer = QuetzalWriter(self._zmachine) + save_data = writer.generate_save_data() + success = self._filesystem.save_game(save_data) + if success: + return "saved." + return "error: save failed" + except Exception as e: + return f"error: save failed ({e})" + + async def _do_restore(self) -> str: + """Restore game state from disk. Returns status message.""" + if not self.save_path.exists(): + return "" + try: + save_data = self.save_path.read_bytes() + parser = QuetzalParser(self._zmachine) + parser.load_from_bytes(save_data) + # Flush stale intro text from screen buffer + self._screen.flush() + # Feed a blank line to continue execution from restored state + self._keyboard._waiting.clear() + self._keyboard.feed("") + # Wait for interpreter to process and generate output + loop = asyncio.get_running_loop() + + def wait_for_output(): + while not self._done and not self._keyboard._waiting.is_set(): + self._keyboard._waiting.wait(timeout=0.1) + + await loop.run_in_executor(None, wait_for_output) + # Get the output from restored state + output = self._screen.flush() + return f"restored.\r\n{output}" if output else "restored." + except Exception as e: + logger.debug(f"Restore failed: {e}") + return "" + + def get_location_name(self) -> str | None: + try: + location_obj = self._zmachine._mem.read_global(0) + if location_obj == 0: + return None + return self._zmachine._objectparser.get_shortname(location_obj) + except Exception: + return None + + def get_room_objects(self) -> list[str]: + try: + location_obj = self._zmachine._mem.read_global(0) + if location_obj == 0: + return [] + + objects = [] + child = self._zmachine._objectparser.get_child(location_obj) + while child != 0: + name = self._zmachine._objectparser.get_shortname(child) + objects.append(name) + child = self._zmachine._objectparser.get_sibling(child) + return objects + except Exception: + return [] diff --git a/src/mudlib/player.py b/src/mudlib/player.py index 7806250..814d48a 100644 --- a/src/mudlib/player.py +++ b/src/mudlib/player.py @@ -10,6 +10,7 @@ from mudlib.entity import Entity if TYPE_CHECKING: from mudlib.editor import Editor + from mudlib.embedded_if_session import EmbeddedIFSession from mudlib.if_session import IFSession @@ -23,7 +24,7 @@ class Player(Entity): mode_stack: list[str] = field(default_factory=lambda: ["normal"]) caps: ClientCaps = field(default_factory=lambda: ClientCaps(ansi=True)) editor: Editor | None = None - if_session: IFSession | None = None + if_session: IFSession | EmbeddedIFSession | None = None @property def mode(self) -> str: diff --git a/src/mudlib/zmachine/mud_ui.py b/src/mudlib/zmachine/mud_ui.py new file mode 100644 index 0000000..a569a54 --- /dev/null +++ b/src/mudlib/zmachine/mud_ui.py @@ -0,0 +1,150 @@ +import logging +import queue +import threading +from pathlib import Path + +from . import zaudio, zfilesystem, zscreen, zstream, zui + +logger = logging.getLogger(__name__) + + +class NullAudio(zaudio.ZAudio): + def __init__(self): + super().__init__() + self.features = {"has_more_than_a_bleep": False} + + def play_bleep(self, bleep_type): + pass + + +class MudScreen(zscreen.ZScreen): + def __init__(self): + super().__init__() + self._buffer = [] + self._columns = 80 + self._rows = zscreen.INFINITE_ROWS + self.features = { + "has_status_line": False, + "has_upper_window": False, + "has_graphics_font": False, + "has_text_colors": False, + } + + def write(self, string): + self._buffer.append(string) + + def flush(self) -> str: + result = "".join(self._buffer) + self._buffer.clear() + return result + + def split_window(self, height): + logger.debug(f"split_window({height}) - no-op") + + def select_window(self, window): + logger.debug(f"select_window({window}) - no-op") + + def set_cursor_position(self, x, y): + logger.debug(f"set_cursor_position({x}, {y}) - no-op") + + def erase_window(self, window=zscreen.WINDOW_LOWER, color=zscreen.COLOR_CURRENT): + logger.debug(f"erase_window({window}, {color}) - no-op") + + def erase_line(self): + logger.debug("erase_line() - no-op") + + def print_status_score_turns(self, text, score, turns): + pass + + def print_status_time(self, hours, minutes): + pass + + def set_font(self, font_number): + if font_number == zscreen.FONT_NORMAL: + return font_number + return None + + def set_text_style(self, style): + pass + + def set_text_color(self, foreground_color, background_color): + pass + + +class MudInputStream(zstream.ZInputStream): + def __init__(self): + super().__init__() + self._input_queue = queue.Queue() + self._waiting = threading.Event() + self._ready = threading.Event() + self._done = False + self.features = {"has_timed_input": False} + + def read_line( + self, + original_text=None, + max_length=0, + terminating_characters=None, + timed_input_routine=None, + timed_input_interval=0, + ): + self._waiting.set() + self._ready.wait() + self._ready.clear() + self._waiting.clear() + text = self._input_queue.get() + if max_length > 0: + text = text[:max_length] + return text + + def read_char(self, timed_input_routine=None, timed_input_interval=0): + self._waiting.set() + self._ready.wait() + self._ready.clear() + self._waiting.clear() + text = self._input_queue.get() + if text: + return ord(text[0]) + return 0 + + def feed(self, text: str): + self._input_queue.put(text) + self._ready.set() + + +class MudFilesystem(zfilesystem.ZFilesystem): + def __init__(self, save_path: Path): + self.save_path = save_path + + def save_game(self, data, suggested_filename=None): + try: + self.save_path.parent.mkdir(parents=True, exist_ok=True) + self.save_path.write_bytes(data) + return True + except Exception as e: + logger.error(f"Failed to save game: {e}") + return False + + def restore_game(self): + if self.save_path.exists(): + try: + return self.save_path.read_bytes() + except Exception as e: + logger.error(f"Failed to restore game: {e}") + return None + return None + + def open_transcript_file_for_writing(self): + return None + + def open_transcript_file_for_reading(self): + return None + + +def create_mud_ui(save_path: Path) -> tuple[zui.ZUI, MudScreen, MudInputStream]: + audio = NullAudio() + screen = MudScreen() + keyboard = MudInputStream() + filesystem = MudFilesystem(save_path) + ui = zui.ZUI(audio, screen, keyboard, filesystem) + return ui, screen, keyboard diff --git a/src/mudlib/zmachine/quetzal.py b/src/mudlib/zmachine/quetzal.py index 711079d..927e25e 100644 --- a/src/mudlib/zmachine/quetzal.py +++ b/src/mudlib/zmachine/quetzal.py @@ -216,7 +216,7 @@ class QuetzalParser: # read anywhere from 0 to 15 local vars local_vars = [] - for _i in range(flags_bitfield[0:3]): + for _i in range(flags_bitfield[0:4]): var = (bytes[ptr] << 8) + bytes[ptr + 1] ptr += 2 local_vars.append(var)