"""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.caps import parse_mtts 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.store import ( PlayerData, account_exists, authenticate, create_account, init_db, load_player_data, save_player, update_last_login, ) from mudlib.world.terrain import World log = logging.getLogger(__name__) PORT = 6789 TICK_RATE = 10 # ticks per second TICK_INTERVAL = 1.0 / TICK_RATE AUTOSAVE_INTERVAL = 60.0 # seconds between auto-saves # 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) last_save_time = time.monotonic() while True: t0 = asyncio.get_event_loop().time() clear_expired() process_combat() # Periodic auto-save (every 60 seconds) current_time = time.monotonic() if current_time - last_save_time >= AUTOSAVE_INTERVAL: player_count = len(players) if player_count > 0: log.debug("auto-saving %d players", player_count) for player in list(players.values()): save_player(player) last_save_time = current_time 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 handle_login( name: str, read_func, write_func, ) -> dict: """Handle login or registration for a player. Args: name: Player name read_func: Async function to read input write_func: Async function to write output Returns: Dict with 'success' (bool) and 'player_data' (dict or None) """ if account_exists(name): # Existing account - authenticate max_attempts = 3 for attempt in range(max_attempts): await write_func("Password: ") password = await read_func() if password is None or not password.strip(): return {"success": False, "player_data": None} if authenticate(name, password.strip()): # Success - load player data player_data = load_player_data(name) return {"success": True, "player_data": player_data} remaining = max_attempts - attempt - 1 if remaining > 0: msg = f"Incorrect password. {remaining} attempts remaining.\r\n" await write_func(msg) else: await write_func("Too many failed attempts.\r\n") return {"success": False, "player_data": None} # New account - registration await write_func(f"Account '{name}' does not exist. Create new account? (y/n) ") response = await read_func() if response is None or response.strip().lower() != "y": return {"success": False, "player_data": None} # Get password with confirmation while True: await write_func("Choose a password: ") password1 = await read_func() if password1 is None or not password1.strip(): return {"success": False, "player_data": None} await write_func("Confirm password: ") password2 = await read_func() if password2 is None or not password2.strip(): return {"success": False, "player_data": None} if password1.strip() == password2.strip(): # Passwords match - create account if create_account(name, password1.strip()): await write_func("Account created successfully!\r\n") # Return default data for new account player_data = load_player_data(name) return {"success": True, "player_data": player_data} await write_func("Failed to create account.\r\n") return {"success": False, "player_data": None} # Passwords don't match - retry or cancel await write_func("Passwords do not match. Try again? (y/n) ") retry = await read_func() if retry is None or retry.strip().lower() != "y": return {"success": False, "player_data": None} 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() # Handle login/registration async def read_input(): result = await readline2(_reader, _writer) return result async def write_output(msg: str): _writer.write(msg) await _writer.drain() login_result = await handle_login(player_name, read_input, write_output) if not login_result["success"]: _writer.write("Login failed. Disconnecting.\r\n") await _writer.drain() _writer.close() return # Update last login timestamp update_last_login(player_name) # Load player data from database or use defaults for new player player_data: PlayerData | None = login_result["player_data"] if player_data is None: # New player - find a passable starting position center_x = _world.width // 2 center_y = _world.height // 2 start_x, start_y = find_passable_start(_world, center_x, center_y) player_data = { "x": start_x, "y": start_y, "pl": 100.0, "stamina": 100.0, "max_stamina": 100.0, "flying": False, } else: # Existing player - verify spawn position is still passable if not _world.is_passable(player_data["x"], player_data["y"]): # Saved position is no longer passable, find a new one start_x, start_y = find_passable_start( _world, player_data["x"], player_data["y"] ) player_data["x"] = start_x player_data["y"] = start_y # Create player instance player = Player( name=player_name, x=player_data["x"], y=player_data["y"], pl=player_data["pl"], stamina=player_data["stamina"], max_stamina=player_data["max_stamina"], flying=player_data["flying"], writer=_writer, reader=_reader, ) # Parse and store client capabilities from MTTS ttype3 = _writer.get_extra_info("ttype3") player.caps = parse_mtts(ttype3) log.debug( "%s capabilities: %s (color_depth=%s)", player_name, player.caps, player.color_depth, ) # Register player players[player_name] = player log.info( "%s connected at (%d, %d)", player_name, player_data["x"], player_data["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 try: 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 finally: # Save player state on disconnect (if not already saved by quit command) if player_name in players: save_player(player) del players[player_name] log.info("%s disconnected", player_name) _writer.close() async def run_server() -> None: """Start the MUD telnet server.""" global _world # Initialize database data_dir = pathlib.Path(__file__).resolve().parents[2] / "data" db_path = data_dir / "mud.db" log.info("initializing database at %s", db_path) init_db(db_path) # 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()