171 lines
5.1 KiB
Python
171 lines
5.1 KiB
Python
"""Telnet server for the MUD."""
|
|
|
|
import asyncio
|
|
import logging
|
|
import pathlib
|
|
import time
|
|
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.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 (cached to build/ after first run)
|
|
cache_dir = pathlib.Path(__file__).resolve().parents[2] / "build"
|
|
log.info("loading world (seed=42, 1000x1000)...")
|
|
t0 = time.monotonic()
|
|
_world = World(seed=42, width=1000, height=1000, 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
|
|
|
|
# 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()
|