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
This commit is contained in:
Jared Miller 2026-02-07 22:59:37 -05:00
parent 23507d0e70
commit a799b6716c
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
5 changed files with 225 additions and 5 deletions

View file

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

View file

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

View file

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

View file

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

View file

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