process_decomposing removes expired corpses and broadcasts messages to entities at the same tile. Registered in game loop.
577 lines
19 KiB
Python
577 lines
19 KiB
Python
"""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.corpse import process_decomposing
|
|
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_aliases,
|
|
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()
|
|
await process_decomposing()
|
|
|
|
# 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,
|
|
)
|
|
|
|
# Load aliases from database
|
|
player.aliases = load_aliases(player_name)
|
|
|
|
# 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()
|