"""Telnet server for the MUD.""" import asyncio import logging import time from typing import cast import telnetlib3 from telnetlib3.server_shell import readline2 import mudlib.commands import mudlib.commands.look import mudlib.commands.movement import mudlib.commands.quit from mudlib.player import Player, players from mudlib.world.terrain import World log = logging.getLogger(__name__) PORT = 6789 # Module-level world instance, generated once at startup _world: World | None = None def find_passable_start(world: World, start_x: int, start_y: int) -> tuple[int, int]: """Find a passable tile starting from (start_x, start_y) and searching outward. Args: world: The world to search in start_x: Starting X coordinate start_y: Starting Y coordinate Returns: Tuple of (x, y) for the first passable tile found """ # Try the starting position first if world.is_passable(start_x, start_y): return start_x, start_y # Spiral outward from the starting position (wrapping) for radius in range(1, 100): for dx in range(-radius, radius + 1): for dy in range(-radius, radius + 1): # Only check the perimeter of the current radius if abs(dx) != radius and abs(dy) != radius: continue x, y = world.wrap(start_x + dx, start_y + dy) if world.is_passable(x, y): return x, y # Fallback to starting position if nothing found return start_x, start_y async def shell( reader: telnetlib3.TelnetReader | telnetlib3.TelnetReaderUnicode, writer: telnetlib3.TelnetWriter | telnetlib3.TelnetWriterUnicode, ) -> None: """Shell callback that handles player connection and command loop.""" _reader = cast(telnetlib3.TelnetReaderUnicode, reader) _writer = cast(telnetlib3.TelnetWriterUnicode, writer) assert _world is not None, "World must be initialized before accepting connections" log.debug("new connection from %s", _writer.get_extra_info("peername")) _writer.write("Welcome to the MUD!\r\n") _writer.write("What is your name? ") await _writer.drain() name_input = await readline2(_reader, _writer) if name_input is None or not name_input.strip(): _writer.close() return player_name = name_input.strip() # Find a passable starting position (start at world center) center_x = _world.width // 2 center_y = _world.height // 2 start_x, start_y = find_passable_start(_world, center_x, center_y) # Create player player = Player( name=player_name, x=start_x, y=start_y, writer=_writer, reader=_reader, ) # Register player players[player_name] = player log.info("%s connected at (%d, %d)", player_name, start_x, start_y) _writer.write(f"\r\nWelcome, {player_name}!\r\n") _writer.write("Type 'help' for commands or 'quit' to disconnect.\r\n\r\n") await _writer.drain() # Show initial map await mudlib.commands.look.cmd_look(player, "") # Command loop while not _writer.is_closing(): _writer.write("mud> ") await _writer.drain() inp = await readline2(_reader, _writer) if inp is None: break command = inp.strip() if not command: continue # Dispatch command await mudlib.commands.dispatch(player, command) # Check if writer was closed by quit command if _writer.is_closing(): break # Clean up: remove from registry if still present if player_name in players: del players[player_name] log.info("%s disconnected", player_name) _writer.close() async def run_server() -> None: """Start the MUD telnet server.""" global _world # Generate world once at startup log.info("generating world (seed=42, 1000x1000)...") t0 = time.monotonic() _world = World(seed=42, width=1000, height=1000) elapsed = time.monotonic() - t0 log.info("world generated in %.2fs", elapsed) # Inject world into command modules mudlib.commands.look.world = _world mudlib.commands.movement.world = _world # 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 # telnetlib3 still waits for the full timeout. 0.5s is plenty. server = await telnetlib3.create_server( host="127.0.0.1", port=PORT, shell=shell, connect_maxwait=0.5 ) log.info("listening on 127.0.0.1:%d", PORT) try: while True: await asyncio.sleep(3600) except KeyboardInterrupt: log.info("shutting down...") server.close() await server.wait_closed()