diff --git a/content/commands/motd.toml b/content/commands/motd.toml new file mode 100644 index 0000000..29a4c8e --- /dev/null +++ b/content/commands/motd.toml @@ -0,0 +1,17 @@ +name = "motd" +aliases = ["message"] +help = "display the message of the day" +mode = "*" +message = """ +=== Message of the Day === + +Welcome to the MUD! + +This is a procedurally generated world with seamless terrain wrapping. +Use cardinal directions (n, s, e, w) or diagonals (ne, nw, se, sw) to move. + +Type 'help' for a list of commands. +Type 'fly' to toggle flying mode and soar over mountains and water. + +Have fun exploring! +""" diff --git a/src/mudlib/content.py b/src/mudlib/content.py new file mode 100644 index 0000000..85c108e --- /dev/null +++ b/src/mudlib/content.py @@ -0,0 +1,141 @@ +"""Content loading from TOML files.""" + +import importlib +import logging +import tomllib +from pathlib import Path +from typing import cast + +from mudlib.commands import CommandDefinition, CommandHandler +from mudlib.player import Player + +log = logging.getLogger(__name__) + + +def _resolve_handler(handler_ref: str) -> CommandHandler: + """Resolve a handler reference string to a callable. + + Args: + handler_ref: String in format "module.path:function_name" + + Returns: + The resolved callable + + Raises: + ImportError: If the module cannot be imported + AttributeError: If the function doesn't exist in the module + """ + if ":" not in handler_ref: + msg = ( + f"Handler reference must be in format 'module:function', got: {handler_ref}" + ) + raise ValueError(msg) + + module_path, func_name = handler_ref.split(":", 1) + module = importlib.import_module(module_path) + handler = getattr(module, func_name) + + if not callable(handler): + msg = f"Handler reference {handler_ref} is not callable" + raise TypeError(msg) + + return cast(CommandHandler, handler) + + +def _make_message_handler(message: str) -> CommandHandler: + """Create a handler that sends a fixed message to the player. + + Args: + message: The message text to send + + Returns: + An async handler function + """ + + async def handler(player: Player, args: str) -> None: + player.writer.write(message.strip() + "\r\n") + await player.writer.drain() + + return handler + + +def load_command(path: Path) -> CommandDefinition: + """Load a command definition from a TOML file. + + Args: + path: Path to the .toml file + + Returns: + CommandDefinition instance + + Raises: + KeyError: If required fields are missing + ValueError: If neither handler nor message is specified + TypeError: If handler or message fields have wrong types + ImportError: If handler reference cannot be resolved + """ + with open(path, "rb") as f: + data = tomllib.load(f) + + name = data["name"] + + # Determine the handler + handler_ref = data.get("handler") + message = data.get("message") + + if handler_ref and message: + msg = "Command definition cannot specify both 'handler' and 'message'" + raise ValueError(msg) + + if not handler_ref and not message: + msg = "Command definition must specify either 'handler' or 'message'" + raise ValueError(msg) + + # Validate types + if handler_ref and not isinstance(handler_ref, str): + msg = f"'handler' must be a string in {path}" + raise TypeError(msg) + + if message and not isinstance(message, str): + msg = f"'message' must be a string in {path}" + raise TypeError(msg) + + handler: CommandHandler + if handler_ref: + handler = _resolve_handler(handler_ref) + else: + # message must be a string here since we checked it's not None + handler = _make_message_handler(cast(str, message)) + + # Optional fields with defaults + aliases = data.get("aliases", []) + mode = data.get("mode", "normal") + help_text = data.get("help", "") + + return CommandDefinition( + name=name, + handler=handler, + aliases=aliases, + mode=mode, + help=help_text, + ) + + +def load_commands(directory: Path) -> list[CommandDefinition]: + """Load all command definitions from a directory. + + Args: + directory: Path to directory containing .toml files + + Returns: + List of CommandDefinition instances + """ + commands = [] + + for path in directory.glob("*.toml"): + try: + commands.append(load_command(path)) + except Exception as e: + log.warning("failed to load command from %s: %s", path, e) + + return commands diff --git a/src/mudlib/server.py b/src/mudlib/server.py index 27a3c24..8459f3c 100644 --- a/src/mudlib/server.py +++ b/src/mudlib/server.py @@ -15,6 +15,7 @@ import mudlib.commands.fly import mudlib.commands.look import mudlib.commands.movement import mudlib.commands.quit +from mudlib.content import load_commands from mudlib.effects import clear_expired from mudlib.player import Player, players from mudlib.world.terrain import World @@ -189,6 +190,15 @@ async def run_server() -> None: mudlib.commands.look.world = _world mudlib.commands.movement.world = _world + # Load content-defined commands from TOML files + content_dir = pathlib.Path(__file__).resolve().parents[2] / "content" / "commands" + if content_dir.exists(): + log.info("loading content from %s", content_dir) + content_commands = load_commands(content_dir) + for cmd_def in content_commands: + mudlib.commands.register(cmd_def) + log.debug("registered content command: %s", cmd_def.name) + # connect_maxwait: how long to wait for telnet option negotiation (CHARSET # etc) before starting the shell. default is 4.0s which is painful. # MUD clients like tintin++ reject CHARSET immediately via MTTS, but diff --git a/tests/test_content_loader.py b/tests/test_content_loader.py new file mode 100644 index 0000000..59591e4 --- /dev/null +++ b/tests/test_content_loader.py @@ -0,0 +1,314 @@ +"""Tests for content loading from TOML files.""" + +import logging +import tempfile +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from mudlib.content import load_command, load_commands +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) + + +def test_load_command_with_handler_reference(): + """Test loading a command with a Python handler reference.""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f: + f.write(""" +name = "shout" +aliases = ["yell"] +help = "shout a message to nearby players" +mode = "normal" +handler = "mudlib.commands.look:cmd_look" +""") + f.flush() + tmp_path = Path(f.name) + + try: + defn = load_command(tmp_path) + assert defn.name == "shout" + assert defn.aliases == ["yell"] + assert defn.help == "shout a message to nearby players" + assert defn.mode == "normal" + assert callable(defn.handler) + finally: + tmp_path.unlink() + + +def test_load_command_with_message(): + """Test loading a command that just sends a message.""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f: + f.write(""" +name = "rules" +aliases = ["info"] +help = "display the server rules" +mode = "*" +message = \"\"\" +Welcome to the MUD! +1. Be respectful +2. No cheating +\"\"\" +""") + f.flush() + tmp_path = Path(f.name) + + try: + defn = load_command(tmp_path) + assert defn.name == "rules" + assert defn.aliases == ["info"] + assert defn.help == "display the server rules" + assert defn.mode == "*" + assert callable(defn.handler) + finally: + tmp_path.unlink() + + +@pytest.mark.asyncio +async def test_message_command_sends_text(player, mock_writer): + """Test that a message command actually sends the text to the player.""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f: + f.write(""" +name = "motd" +message = "Welcome to the server!\\nEnjoy your stay." +""") + f.flush() + tmp_path = Path(f.name) + + try: + defn = load_command(tmp_path) + await defn.handler(player, "") + + assert mock_writer.write.called + written_text = mock_writer.write.call_args[0][0] + assert "Welcome to the server!" in written_text + assert "Enjoy your stay." in written_text + finally: + tmp_path.unlink() + + +def test_load_commands_from_directory(): + """Test loading multiple command definitions from a directory.""" + with tempfile.TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + + # Create two command files + (tmp_path / "cmd1.toml").write_text(""" +name = "cmd1" +message = "Command 1" +""") + (tmp_path / "cmd2.toml").write_text(""" +name = "cmd2" +message = "Command 2" +""") + + # Create a non-toml file that should be ignored + (tmp_path / "readme.txt").write_text("This is not a command") + + commands = load_commands(tmp_path) + + assert len(commands) == 2 + names = {cmd.name for cmd in commands} + assert names == {"cmd1", "cmd2"} + + +def test_load_command_missing_name(): + """Test that missing name field raises an error.""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f: + f.write(""" +message = "Hello" +""") + f.flush() + tmp_path = Path(f.name) + + try: + with pytest.raises(KeyError): + load_command(tmp_path) + finally: + tmp_path.unlink() + + +def test_load_command_missing_handler_and_message(): + """Test that a command without handler or message raises an error.""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f: + f.write(""" +name = "broken" +""") + f.flush() + tmp_path = Path(f.name) + + try: + match_msg = "must specify either 'handler' or 'message'" + with pytest.raises(ValueError, match=match_msg): + load_command(tmp_path) + finally: + tmp_path.unlink() + + +def test_load_command_bad_handler_reference(): + """Test that an invalid handler reference raises an error.""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f: + f.write(""" +name = "broken" +handler = "nonexistent.module:function" +""") + f.flush() + tmp_path = Path(f.name) + + try: + with pytest.raises((ImportError, ModuleNotFoundError)): + load_command(tmp_path) + finally: + tmp_path.unlink() + + +def test_load_command_defaults(): + """Test that optional fields have sensible defaults.""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f: + f.write(""" +name = "simple" +message = "Hello" +""") + f.flush() + tmp_path = Path(f.name) + + try: + defn = load_command(tmp_path) + assert defn.name == "simple" + assert defn.aliases == [] + assert defn.help == "" + assert defn.mode == "normal" + finally: + tmp_path.unlink() + + +def test_load_command_both_handler_and_message(): + """Test that specifying both handler and message raises an error.""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f: + f.write(""" +name = "broken" +handler = "mudlib.commands.look:cmd_look" +message = "Hello" +""") + f.flush() + tmp_path = Path(f.name) + + try: + match_msg = "cannot specify both 'handler' and 'message'" + with pytest.raises(ValueError, match=match_msg): + load_command(tmp_path) + finally: + tmp_path.unlink() + + +def test_load_command_handler_wrong_type(): + """Test that non-string handler field raises TypeError.""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f: + f.write(""" +name = "broken" +handler = 123 +""") + f.flush() + tmp_path = Path(f.name) + + try: + match_msg = "'handler' must be a string" + with pytest.raises(TypeError, match=match_msg): + load_command(tmp_path) + finally: + tmp_path.unlink() + + +def test_load_command_message_wrong_type(): + """Test that non-string message field raises TypeError.""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f: + f.write(""" +name = "broken" +message = 456 +""") + f.flush() + tmp_path = Path(f.name) + + try: + match_msg = "'message' must be a string" + with pytest.raises(TypeError, match=match_msg): + load_command(tmp_path) + finally: + tmp_path.unlink() + + +@pytest.mark.asyncio +async def test_message_whitespace_handling(player, mock_writer): + """Test message stripping removes outer whitespace, keeps internal formatting.""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f: + f.write(""" +name = "formatted" +message = \"\"\" + +First line + Indented line +Last line + +\"\"\" +""") + f.flush() + tmp_path = Path(f.name) + + try: + defn = load_command(tmp_path) + await defn.handler(player, "") + + assert mock_writer.write.called + written_text = mock_writer.write.call_args[0][0] + # Outer whitespace should be stripped + assert not written_text.startswith("\n") + # But internal formatting should be preserved + assert "First line\n Indented line\nLast line" in written_text + finally: + tmp_path.unlink() + + +def test_load_commands_logs_broken_files(caplog): + """Test that load_commands logs warnings for broken TOML files.""" + with tempfile.TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + + # Create a valid command file + (tmp_path / "valid.toml").write_text(""" +name = "valid" +message = "OK" +""") + + # Create a broken command file (missing required field) + (tmp_path / "broken.toml").write_text(""" +name = "broken" +""") + + with caplog.at_level(logging.WARNING): + commands = load_commands(tmp_path) + + # Should load the valid one only + assert len(commands) == 1 + assert commands[0].name == "valid" + + # Should log warning about the broken file + warnings = [r.message for r in caplog.records] + assert any("failed to load command from" in msg for msg in warnings) + assert any("broken.toml" in msg for msg in warnings)