mud/src/mudlib/server.py
Jared Miller 8934397b1e
Make world wrap seamlessly in both axes
Tileable Perlin noise: each octave wraps its integer grid coordinates
with modulo at the octave's frequency, so gradients at opposite edges
match and the noise field is continuous across the boundary.

Coarse elevation grid interpolation wraps instead of padding boundary
cells. Rivers can flow across world edges. All coordinate access
(get_tile, is_passable, get_viewport) wraps via modulo. Movement,
spawn search, nearby-player detection, and viewport relative positions
all handle the toroidal topology.
2026-02-07 13:50:06 -05:00

164 lines
4.8 KiB
Python

"""Telnet server for the MUD."""
import asyncio
import logging
import time
from typing import cast
import telnetlib3
from telnetlib3.server_shell import readline2
import mudlib.commands
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
log.info("generating world (seed=42, 1000x1000)...")
t0 = time.monotonic()
_world = World(seed=42, width=1000, height=1000)
elapsed = time.monotonic() - t0
log.info("world generated in %.2fs", elapsed)
# Inject world into command modules
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()