"""Telnet server for the MUD.""" import asyncio import logging import pathlib import time import tomllib from typing import cast import telnetlib3 from telnetlib3.server_shell import readline2 import mudlib.commands import mudlib.commands.fly import mudlib.commands.look import mudlib.commands.movement import mudlib.commands.quit from mudlib.combat.commands import register_combat_commands from mudlib.combat.engine import process_combat from mudlib.content import load_commands from mudlib.effects import clear_expired from mudlib.player import Player, players from mudlib.world.terrain import World log = logging.getLogger(__name__) PORT = 6789 TICK_RATE = 10 # ticks per second TICK_INTERVAL = 1.0 / TICK_RATE # Module-level world instance, generated once at startup _world: World | None = None def load_world_config(world_name: str = "earth") -> dict: """Load world configuration from TOML file.""" worlds_dir = pathlib.Path(__file__).resolve().parents[2] / "worlds" config_path = worlds_dir / world_name / "config.toml" with open(config_path, "rb") as f: return tomllib.load(f) async def game_loop() -> None: """Run periodic game tasks at TICK_RATE ticks per second.""" log.info("game loop started (%d ticks/sec)", TICK_RATE) while True: t0 = asyncio.get_event_loop().time() clear_expired() process_combat() elapsed = asyncio.get_event_loop().time() - t0 sleep_time = TICK_INTERVAL - elapsed if sleep_time > 0: await asyncio.sleep(sleep_time) 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 (cached to build/ after first run) cache_dir = pathlib.Path(__file__).resolve().parents[2] / "build" config = load_world_config() world_cfg = config["world"] log.info( "loading world (seed=%d, %dx%d)...", world_cfg["seed"], world_cfg["width"], world_cfg["height"], ) t0 = time.monotonic() _world = World( seed=world_cfg["seed"], width=world_cfg["width"], height=world_cfg["height"], cache_dir=cache_dir, ) elapsed = time.monotonic() - t0 if _world.cached: log.info("world loaded from cache in %.2fs", elapsed) else: log.info("world generated in %.2fs (cached for next run)", elapsed) # Inject world into command modules mudlib.commands.fly.world = _world 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) # Load combat moves and register as commands combat_dir = pathlib.Path(__file__).resolve().parents[2] / "content" / "combat" if combat_dir.exists(): log.info("loading combat moves from %s", combat_dir) register_combat_commands(combat_dir) log.info("registered combat commands") # 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) loop_task = asyncio.create_task(game_loop()) try: while True: await asyncio.sleep(3600) except KeyboardInterrupt: log.info("shutting down...") loop_task.cancel() server.close() await server.wait_closed()