"""Telnet server for the MUD.""" import asyncio import logging import os import pathlib import time import tomllib from typing import cast import telnetlib3 from telnetlib3 import GMCP, MSDP, WILL from telnetlib3.server import TelnetServer from telnetlib3.server_shell import readline2 import mudlib.combat.commands import mudlib.commands import mudlib.commands.containers import mudlib.commands.edit import mudlib.commands.examine import mudlib.commands.fly import mudlib.commands.help import mudlib.commands.look import mudlib.commands.movement import mudlib.commands.play import mudlib.commands.portals import mudlib.commands.power import mudlib.commands.quit import mudlib.commands.reload import mudlib.commands.snapneck import mudlib.commands.spawn import mudlib.commands.things import mudlib.commands.use 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.gmcp import ( send_char_status, send_char_vitals, send_map_data, send_msdp_vitals, send_room_info, ) from mudlib.if_session import broadcast_to_spectators from mudlib.mob_ai import process_mobs from mudlib.mobs import load_mob_templates, mob_templates from mudlib.player import Player, players from mudlib.prompt import render_prompt from mudlib.resting import process_resting from mudlib.store import ( PlayerData, account_exists, authenticate, create_account, init_db, load_player_data, save_player, update_last_login, ) from mudlib.thing import Thing from mudlib.things import load_thing_templates, spawn_thing, thing_templates from mudlib.unconscious import process_unconscious from mudlib.world.terrain import World from mudlib.zone import Zone from mudlib.zones import get_zone, load_zones, register_zone log = logging.getLogger(__name__) HOST = os.environ.get("MUD_HOST", "127.0.0.1") PORT = int(os.environ.get("MUD_PORT", "6789")) TICK_RATE = 10 # ticks per second TICK_INTERVAL = 1.0 / TICK_RATE AUTOSAVE_INTERVAL = 60.0 # seconds between auto-saves # Module-level overworld zone instance, created once at startup _overworld: Zone | 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() tick_count = 0 while True: t0 = asyncio.get_event_loop().time() tick_count += 1 clear_expired() await process_combat() await process_mobs(mudlib.combat.commands.combat_moves) await process_resting() await process_unconscious() # MSDP updates once per second (every TICK_RATE ticks) if tick_count % TICK_RATE == 0: for p in list(players.values()): send_msdp_vitals(p) # 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(zone: Zone, start_x: int, start_y: int) -> tuple[int, int]: """Find a passable tile starting from (start_x, start_y) and searching outward. Args: zone: The zone 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 zone.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 = zone.wrap(start_x + dx, start_y + dy) if zone.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} class MudTelnetServer(TelnetServer): """Telnet server that offers GMCP and MSDP during negotiation.""" def begin_negotiation(self) -> None: """Offer GMCP and MSDP as part of initial negotiation.""" super().begin_negotiation() assert self.writer is not None gmcp_ok = self.writer.iac(WILL, GMCP) msdp_ok = self.writer.iac(WILL, MSDP) log.debug("offered GMCP=%s MSDP=%s", gmcp_ok, msdp_ok) 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 _overworld is not None, ( "Overworld zone 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() # Skip empty lines from client negotiation bytes, only close on actual disconnect while True: name_input = await readline2(_reader, _writer) if name_input is None: _writer.close() return if name_input.strip(): break 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 = _overworld.width // 2 center_y = _overworld.height // 2 start_x, start_y = find_passable_start(_overworld, center_x, center_y) player_data = { "x": start_x, "y": start_y, "pl": 100.0, "stamina": 100.0, "max_stamina": 100.0, "flying": False, "zone_name": "overworld", "inventory": [], } # Resolve zone from zone_name using zone registry zone_name = player_data.get("zone_name", "overworld") player_zone = get_zone(zone_name) if player_zone is None: log.warning( "unknown zone '%s' for player '%s', defaulting to overworld", zone_name, player_name, ) player_zone = _overworld # Verify spawn position is still passable if not player_zone.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( player_zone, player_data["x"], player_data["y"] ) player_data["x"] = start_x player_data["y"] = start_y # Create player instance player = Player( name=player_name, location=player_zone, 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, ) # Reconstruct inventory from saved data for item_name in player_data.get("inventory", []): template = thing_templates.get(item_name) if template: spawn_thing(template, player) else: # Template not found — create a bare Thing so it's not lost log.warning( "unknown thing template '%s' for player '%s'", item_name, player_name, ) Thing(name=item_name, location=player) # 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, "") # Send initial GMCP data to rich clients send_char_vitals(player) send_char_status(player) send_room_info(player) send_map_data(player) # Command loop try: while not _writer.is_closing(): # Show appropriate prompt based on mode if player.mode == "editor" and player.editor: _writer.write(f" {player.editor.cursor + 1}> ") else: _writer.write(render_prompt(player)) await _writer.drain() inp = await readline2(_reader, _writer) if inp is None: break command = inp.strip() if not command and player.mode not in ("editor", "if"): continue # Handle editor mode if player.mode == "editor" and player.editor: response = await player.editor.handle_input(inp) if response.output: await player.send(response.output) if response.done: player.editor = None player.mode_stack.pop() # Handle IF mode elif player.mode == "if" and player.if_session: response = await player.if_session.handle_input(command) if response.output: # Ensure output ends with newline output = response.output if not output.endswith("\r\n"): output += "\r\n" await player.send(output) # Broadcast to spectators unless it's an escape command if not command.startswith("::"): spectator_msg = ( f"[{player.name}'s terminal]\r\n> {command}\r\n{output}" ) await broadcast_to_spectators(player, spectator_msg) if response.done: await player.if_session.stop() player.if_session = None player.mode_stack.pop() await player.send("\r\nyou leave the terminal.\r\n") # Notify spectators leave_msg = f"{player.name} steps away from the terminal.\r\n" await broadcast_to_spectators(player, leave_msg) else: # Dispatch normal command await mudlib.commands.dispatch(player, command) # Update GMCP vitals after command (prompt shows vitals, so sync GMCP) send_char_vitals(player) # Check if writer was closed by quit command if _writer.is_closing(): break finally: # Clean up IF session if player was playing if player.if_session: await player.if_session.stop() # Save player state on disconnect (if not already saved by quit command) if player_name in players: save_player(player) player.move_to(None) del players[player_name] log.info("%s disconnected", player_name) _writer.close() async def run_server() -> None: """Start the MUD telnet server.""" global _overworld # 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) # Create overworld zone from generated terrain _overworld = Zone( name="overworld", width=world.width, height=world.height, terrain=world.terrain, toroidal=True, ) log.info("created overworld zone (%dx%d, toroidal)", world.width, world.height) # Register overworld zone register_zone("overworld", _overworld) # Load and register zones from content/zones/ zones_dir = pathlib.Path(__file__).resolve().parents[2] / "content" / "zones" if zones_dir.exists(): loaded_zones = load_zones(zones_dir) for zone_name, zone in loaded_zones.items(): register_zone(zone_name, zone) log.info("loaded %d zones from %s", len(loaded_zones), zones_dir) # 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") # Load mob templates mobs_dir = pathlib.Path(__file__).resolve().parents[2] / "content" / "mobs" if mobs_dir.exists(): loaded = load_mob_templates(mobs_dir) mob_templates.update(loaded) log.info("loaded %d mob templates from %s", len(loaded), mobs_dir) # Load thing templates things_dir = pathlib.Path(__file__).resolve().parents[2] / "content" / "things" if things_dir.exists(): loaded_things = load_thing_templates(things_dir) thing_templates.update(loaded_things) log.info("loaded %d thing templates from %s", len(loaded_things), things_dir) # 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=HOST, port=PORT, shell=shell, connect_maxwait=0.5, protocol_factory=MudTelnetServer, ) log.info("listening on %s:%d", HOST, 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()