From a799b6716c4770540834a37f998e7926ab5690c6 Mon Sep 17 00:00:00 2001 From: Jared Miller Date: Sat, 7 Feb 2026 22:59:37 -0500 Subject: [PATCH] Add editor mode shell integration and edit command Integrates the Editor class into the MUD server's shell loop, allowing players to enter and use the text editor from the game. Changes: - Add editor field to Player dataclass - Modify shell input loop to check player mode and route to editor - Add edit command to enter editor mode from normal mode - Use inp (not command.strip()) for editor to preserve indentation - Show line-numbered prompt in editor mode - Pop mode and clear editor when done=True - Add comprehensive integration tests - Fix test isolation issue in test_movement_updates_position --- src/mudlib/commands/edit.py | 28 ++++++ src/mudlib/player.py | 8 +- src/mudlib/server.py | 22 +++- tests/test_commands.py | 5 + tests/test_editor_integration.py | 167 +++++++++++++++++++++++++++++++ 5 files changed, 225 insertions(+), 5 deletions(-) create mode 100644 src/mudlib/commands/edit.py create mode 100644 tests/test_editor_integration.py diff --git a/src/mudlib/commands/edit.py b/src/mudlib/commands/edit.py new file mode 100644 index 0000000..c0b3959 --- /dev/null +++ b/src/mudlib/commands/edit.py @@ -0,0 +1,28 @@ +"""Edit command for entering the text editor.""" + +from mudlib.commands import CommandDefinition, register +from mudlib.editor import Editor +from mudlib.player import Player + + +async def cmd_edit(player: Player, args: str) -> None: + """Enter the text editor. + + Args: + player: The player executing the command + args: Command arguments (unused for now) + """ + + async def save_callback(content: str) -> None: + await player.send("Content saved.\r\n") + + player.editor = Editor( + save_callback=save_callback, + content_type="text", + ) + player.mode_stack.append("editor") + await player.send("Entering editor. Type :h for help.\r\n") + + +# Register the edit command +register(CommandDefinition("edit", cmd_edit, mode="normal")) diff --git a/src/mudlib/player.py b/src/mudlib/player.py index 61fcef6..42ca584 100644 --- a/src/mudlib/player.py +++ b/src/mudlib/player.py @@ -1,11 +1,16 @@ """Player state and registry.""" +from __future__ import annotations + from dataclasses import dataclass, field -from typing import Any +from typing import TYPE_CHECKING, Any from mudlib.caps import ClientCaps from mudlib.entity import Entity +if TYPE_CHECKING: + from mudlib.editor import Editor + @dataclass class Player(Entity): @@ -16,6 +21,7 @@ class Player(Entity): flying: bool = False mode_stack: list[str] = field(default_factory=lambda: ["normal"]) caps: ClientCaps = field(default_factory=lambda: ClientCaps(ansi=True)) + editor: Editor | None = None @property def mode(self) -> str: diff --git a/src/mudlib/server.py b/src/mudlib/server.py index a29b4c3..7e7dc24 100644 --- a/src/mudlib/server.py +++ b/src/mudlib/server.py @@ -11,6 +11,7 @@ import telnetlib3 from telnetlib3.server_shell import readline2 import mudlib.commands +import mudlib.commands.edit import mudlib.commands.fly import mudlib.commands.look import mudlib.commands.movement @@ -297,7 +298,11 @@ async def shell( # Command loop try: while not _writer.is_closing(): - _writer.write("mud> ") + # Show appropriate prompt based on mode + if player.mode == "editor" and player.editor: + _writer.write(f" {player.editor.cursor + 1}> ") + else: + _writer.write("mud> ") await _writer.drain() inp = await readline2(_reader, _writer) @@ -305,11 +310,20 @@ async def shell( break command = inp.strip() - if not command: + if not command and player.mode != "editor": continue - # Dispatch command - await mudlib.commands.dispatch(player, command) + # Handle editor mode + if player.mode == "editor" and player.editor: + response = await player.editor.handle_input(inp) + if response.output: + await player.send(response.output) + if response.done: + player.editor = None + player.mode_stack.pop() + else: + # Dispatch normal command + await mudlib.commands.dispatch(player, command) # Check if writer was closed by quit command if _writer.is_closing(): diff --git a/tests/test_commands.py b/tests/test_commands.py index a742e52..0c6d86b 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -138,6 +138,11 @@ async def test_movement_updates_position(player, mock_world): movement.world = mock_world look.world = mock_world + # Clear players registry to avoid test pollution + from mudlib.player import players + + players.clear() + original_x, original_y = player.x, player.y await movement.move_north(player, "") diff --git a/tests/test_editor_integration.py b/tests/test_editor_integration.py new file mode 100644 index 0000000..e9c3e6c --- /dev/null +++ b/tests/test_editor_integration.py @@ -0,0 +1,167 @@ +"""Tests for editor integration with the shell and command system.""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from mudlib import commands +from mudlib.commands.edit import cmd_edit +from mudlib.editor import Editor +from mudlib.player import Player + + +@pytest.fixture +def mock_writer(): + writer = MagicMock() + writer.write = MagicMock() + writer.drain = AsyncMock() + return writer + + +@pytest.fixture +def mock_reader(): + return MagicMock() + + +@pytest.fixture +def player(mock_reader, mock_writer): + return Player(name="TestPlayer", x=5, y=5, reader=mock_reader, writer=mock_writer) + + +@pytest.mark.asyncio +async def test_edit_command_enters_editor_mode(player): + """Test that edit command sets up editor mode.""" + assert player.mode == "normal" + assert player.editor is None + + await cmd_edit(player, "") + + assert player.mode == "editor" + assert player.editor is not None + assert isinstance(player.editor, Editor) + assert player.mode_stack == ["normal", "editor"] + + +@pytest.mark.asyncio +async def test_edit_command_sends_welcome_message(player, mock_writer): + """Test that edit command sends welcome message.""" + await cmd_edit(player, "") + + assert mock_writer.write.called + output = "".join([call[0][0] for call in mock_writer.write.call_args_list]) + assert "editor" in output.lower() + assert ":h" in output + + +@pytest.mark.asyncio +async def test_edit_command_sets_save_callback(player): + """Test that edit command sets up a save callback.""" + await cmd_edit(player, "") + + assert player.editor is not None + assert player.editor.save_callback is not None + + +@pytest.mark.asyncio +async def test_editor_handle_input_preserves_whitespace(player): + """Test that editor receives input with whitespace preserved.""" + await cmd_edit(player, "") + + # Simulate code with indentation + response = await player.editor.handle_input(" def foo():") + + assert player.editor.buffer == [" def foo():"] + assert response.done is False + + +@pytest.mark.asyncio +async def test_editor_done_clears_editor_and_pops_mode(player): + """Test that when editor returns done=True, mode is popped and editor cleared.""" + await cmd_edit(player, "") + + assert player.mode == "editor" + assert player.editor is not None + + # Quit the editor + response = await player.editor.handle_input(":q") + assert response.done is True + + # Simulate what the shell loop should do + player.editor = None + player.mode_stack.pop() + + assert player.mode == "normal" + assert player.editor is None + + +@pytest.mark.asyncio +async def test_editor_save_callback_sends_message(player, mock_writer): + """Test that save callback sends confirmation to player.""" + await cmd_edit(player, "") + mock_writer.reset_mock() + + await player.editor.handle_input("test line") + response = await player.editor.handle_input(":w") + + assert response.saved is True + # Save callback should have sent a message + assert mock_writer.write.called + output = "".join([call[0][0] for call in mock_writer.write.call_args_list]) + assert "saved" in output.lower() + + +@pytest.mark.asyncio +async def test_edit_command_only_works_in_normal_mode(player): + """Test that edit command has mode='normal' restriction.""" + # Push a different mode onto the stack + player.mode_stack.append("combat") + + # Try to invoke edit command through dispatch + await commands.dispatch(player, "edit") + + # Should be blocked by mode check + assert player.mode == "combat" + assert player.editor is None + + +@pytest.mark.asyncio +async def test_mode_stack_push_and_pop(player): + """Test mode stack mechanics for editor mode.""" + assert player.mode_stack == ["normal"] + assert player.mode == "normal" + + # Enter editor mode + player.mode_stack.append("editor") + assert player.mode == "editor" + assert player.mode_stack == ["normal", "editor"] + + # Exit editor mode + player.mode_stack.pop() + assert player.mode == "normal" + assert player.mode_stack == ["normal"] + + +@pytest.mark.asyncio +async def test_empty_input_allowed_in_editor(player): + """Test that empty lines are valid in editor mode (for blank lines in code).""" + await cmd_edit(player, "") + + # Empty line should be appended to buffer + response = await player.editor.handle_input("") + assert response.done is False + assert player.editor.buffer == [""] + + +@pytest.mark.asyncio +async def test_editor_prompt_uses_cursor(player): + """Test that editor prompt should show line number from cursor.""" + await cmd_edit(player, "") + + # Add some lines + await player.editor.handle_input("line 1") + await player.editor.handle_input("line 2") + + # Cursor starts at 0. The prompt shows "cursor + 1" for 1-indexed display + # This test verifies the cursor field exists and can be used for prompts + assert player.editor.cursor == 0 + # Shell loop prompt: f" {player.editor.cursor + 1}> " = " 1> "