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.
164 lines
4.8 KiB
Python
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()
|