Add bare echoing telnet server

This commit is contained in:
Jared Miller 2026-02-07 10:05:34 -05:00
parent 27ec0a8a22
commit a83248b387
7 changed files with 176 additions and 0 deletions

View file

@ -9,3 +9,6 @@ test:
uv run pytest uv run pytest
check: lint typecheck test check: lint typecheck test
run:
uv run python -m mudlib

3
mud.tin Normal file
View file

@ -0,0 +1,3 @@
#NOP TinTin++ config for connecting to the MUD server
#split 0 1
#session mud localhost 6789

View file

@ -12,6 +12,7 @@ dev = [
"ruff", "ruff",
"pyright", "pyright",
"pytest", "pytest",
"pytest-asyncio",
] ]
[build-system] [build-system]

8
src/mudlib/__main__.py Normal file
View file

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

54
src/mudlib/server.py Normal file
View file

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

92
tests/test_server.py Normal file
View file

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

15
uv.lock
View file

@ -32,6 +32,7 @@ dependencies = [
dev = [ dev = [
{ name = "pyright" }, { name = "pyright" },
{ name = "pytest" }, { name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "ruff" }, { name = "ruff" },
] ]
@ -42,6 +43,7 @@ requires-dist = [{ name = "telnetlib3" }]
dev = [ dev = [
{ name = "pyright" }, { name = "pyright" },
{ name = "pytest" }, { name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "ruff" }, { 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" }, { 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]] [[package]]
name = "ruff" name = "ruff"
version = "0.15.0" version = "0.15.0"