diff --git a/justfile b/justfile index 9f673d3..4215387 100644 --- a/justfile +++ b/justfile @@ -9,3 +9,6 @@ test: uv run pytest check: lint typecheck test + +run: + uv run python -m mudlib diff --git a/mud.tin b/mud.tin new file mode 100644 index 0000000..69548d0 --- /dev/null +++ b/mud.tin @@ -0,0 +1,3 @@ +#NOP TinTin++ config for connecting to the MUD server +#split 0 1 +#session mud localhost 6789 diff --git a/pyproject.toml b/pyproject.toml index 59144e2..261a80d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ dev = [ "ruff", "pyright", "pytest", + "pytest-asyncio", ] [build-system] diff --git a/src/mudlib/__main__.py b/src/mudlib/__main__.py new file mode 100644 index 0000000..e79394b --- /dev/null +++ b/src/mudlib/__main__.py @@ -0,0 +1,8 @@ +"""Main entry point for running the MUD server.""" + +import asyncio + +from mudlib.server import run_server + +if __name__ == "__main__": + asyncio.run(run_server()) diff --git a/src/mudlib/server.py b/src/mudlib/server.py new file mode 100644 index 0000000..07f0c9b --- /dev/null +++ b/src/mudlib/server.py @@ -0,0 +1,54 @@ +"""Telnet server for the MUD.""" + +import asyncio +from typing import cast + +import telnetlib3 + +PORT = 6789 + + +async def shell( + reader: telnetlib3.TelnetReader | telnetlib3.TelnetReaderUnicode, + writer: telnetlib3.TelnetWriter | telnetlib3.TelnetWriterUnicode, +) -> None: + """Shell callback that greets the player and echoes their input.""" + # Cast to unicode variants since we're using encoding="utf8" + _reader = cast(telnetlib3.TelnetReaderUnicode, reader) + _writer = cast(telnetlib3.TelnetWriterUnicode, writer) + + _writer.write("Welcome to the MUD!\r\n") + _writer.write("Type 'quit' to disconnect.\r\n\r\n") + await _writer.drain() + + while not _writer.is_closing(): + _writer.write("mud> ") + await _writer.drain() + + inp = await _reader.readline() + if not inp: + break + + command = inp.strip() + if command == "quit": + _writer.write("Goodbye!\r\n") + break + + _writer.write(f"You typed: {command}\r\n") + + _writer.close() + + +async def run_server() -> None: + """Start the MUD telnet server.""" + server = await telnetlib3.create_server(host="127.0.0.1", port=PORT, shell=shell) + print(f"MUD server running on 127.0.0.1:{PORT}") + print("Connect with: telnet 127.0.0.1 6789") + + try: + while True: + await asyncio.sleep(3600) + except KeyboardInterrupt: + print("\nShutting down...") + server.close() + await server.wait_closed() diff --git a/tests/test_server.py b/tests/test_server.py new file mode 100644 index 0000000..873cf59 --- /dev/null +++ b/tests/test_server.py @@ -0,0 +1,92 @@ +"""Tests for the server module.""" + +import asyncio +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from mudlib import server + + +def test_port_constant(): + """Test that PORT is defined correctly.""" + assert server.PORT == 6789 + assert isinstance(server.PORT, int) + + +def test_shell_exists(): + """Test that the shell callback exists and is callable.""" + assert callable(server.shell) + assert asyncio.iscoroutinefunction(server.shell) + + +def test_run_server_exists(): + """Test that run_server exists and is callable.""" + assert callable(server.run_server) + assert asyncio.iscoroutinefunction(server.run_server) + + +@pytest.mark.asyncio +async def test_shell_greets_and_echoes(): + """Test that shell greets the player and echoes input.""" + reader = AsyncMock() + writer = MagicMock() + writer.is_closing.return_value = False + writer.drain = AsyncMock() + writer.close = MagicMock() + + # Simulate user typing "hello" then "quit" + reader.readline.side_effect = ["hello\r\n", "quit\r\n"] + + await server.shell(reader, writer) + + # Check that welcome message was written + calls = [str(call) for call in writer.write.call_args_list] + assert any("Welcome" in call for call in calls) + + # Check that input was echoed + assert any("hello" in call for call in calls) + + # Check that goodbye was written + assert any("Goodbye" in call for call in calls) + + # Check that close was called + writer.close.assert_called_once() + + +@pytest.mark.asyncio +async def test_shell_handles_empty_input(): + """Test that shell handles EOF gracefully.""" + reader = AsyncMock() + writer = MagicMock() + writer.is_closing.return_value = False + writer.drain = AsyncMock() + writer.close = MagicMock() + + # Simulate EOF + reader.readline.return_value = "" + + await server.shell(reader, writer) + + # Should close cleanly + writer.close.assert_called_once() + + +@pytest.mark.asyncio +async def test_shell_handles_quit(): + """Test that shell exits on quit command.""" + reader = AsyncMock() + writer = MagicMock() + writer.is_closing.return_value = False + writer.drain = AsyncMock() + writer.close = MagicMock() + + reader.readline.return_value = "quit\r\n" + + await server.shell(reader, writer) + + # Check that goodbye was written + calls = [str(call) for call in writer.write.call_args_list] + assert any("Goodbye" in call for call in calls) + + writer.close.assert_called_once() diff --git a/uv.lock b/uv.lock index 817cbb7..fa05154 100644 --- a/uv.lock +++ b/uv.lock @@ -32,6 +32,7 @@ dependencies = [ dev = [ { name = "pyright" }, { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "ruff" }, ] @@ -42,6 +43,7 @@ requires-dist = [{ name = "telnetlib3" }] dev = [ { name = "pyright" }, { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "ruff" }, ] @@ -110,6 +112,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, ] +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + [[package]] name = "ruff" version = "0.15.0"