mud/src/mudlib/server.py
Jared Miller b0fcb080d3
Wire client capabilities into Player & terrain
Parse MTTS from telnetlib3 writer during connection and store capabilities
on Player.caps field. Add convenience property Player.color_depth that
delegates to caps.color_depth for easy access by rendering code.

Changes:
- Add caps field to Player with default 16-color ANSI capabilities
- Parse MTTS in server shell after Player creation using parse_mtts()
- Add Player.color_depth property for quick capability checks
- Add tests verifying Player caps integration and color_depth property
2026-02-07 22:44:45 -05:00

399 lines
13 KiB
Python

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