Add login and registration flow with server integration

Adds login/registration prompts on connection, database initialization on
startup, and periodic auto-save every 5 minutes in the game loop. Player
state is now tied to authenticated accounts.
This commit is contained in:
Jared Miller 2026-02-07 21:42:12 -05:00
parent 485302fab3
commit 3fe51f2552
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
3 changed files with 420 additions and 47 deletions

View file

@ -20,6 +20,15 @@ from mudlib.combat.engine import process_combat
from mudlib.content import load_commands from mudlib.content import load_commands
from mudlib.effects import clear_expired from mudlib.effects import clear_expired
from mudlib.player import Player, players from mudlib.player import Player, players
from mudlib.store import (
account_exists,
authenticate,
create_account,
init_db,
load_player_data,
save_player,
update_last_login,
)
from mudlib.world.terrain import World from mudlib.world.terrain import World
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -27,6 +36,7 @@ log = logging.getLogger(__name__)
PORT = 6789 PORT = 6789
TICK_RATE = 10 # ticks per second TICK_RATE = 10 # ticks per second
TICK_INTERVAL = 1.0 / TICK_RATE TICK_INTERVAL = 1.0 / TICK_RATE
AUTOSAVE_INTERVAL = 60.0 # seconds between auto-saves
# Module-level world instance, generated once at startup # Module-level world instance, generated once at startup
_world: World | None = None _world: World | None = None
@ -43,10 +53,23 @@ def load_world_config(world_name: str = "earth") -> dict:
async def game_loop() -> None: async def game_loop() -> None:
"""Run periodic game tasks at TICK_RATE ticks per second.""" """Run periodic game tasks at TICK_RATE ticks per second."""
log.info("game loop started (%d ticks/sec)", TICK_RATE) log.info("game loop started (%d ticks/sec)", TICK_RATE)
last_save_time = time.monotonic()
while True: while True:
t0 = asyncio.get_event_loop().time() t0 = asyncio.get_event_loop().time()
clear_expired() clear_expired()
process_combat() 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 elapsed = asyncio.get_event_loop().time() - t0
sleep_time = TICK_INTERVAL - elapsed sleep_time = TICK_INTERVAL - elapsed
if sleep_time > 0: if sleep_time > 0:
@ -85,6 +108,83 @@ def find_passable_start(world: World, start_x: int, start_y: int) -> tuple[int,
return start_x, start_y 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( async def shell(
reader: telnetlib3.TelnetReader | telnetlib3.TelnetReaderUnicode, reader: telnetlib3.TelnetReader | telnetlib3.TelnetReaderUnicode,
writer: telnetlib3.TelnetWriter | telnetlib3.TelnetWriterUnicode, writer: telnetlib3.TelnetWriter | telnetlib3.TelnetWriterUnicode,
@ -108,23 +208,72 @@ async def shell(
player_name = name_input.strip() player_name = name_input.strip()
# Find a passable starting position (start at world center) # 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 = login_result["player_data"]
if player_data is None:
# New player - find a passable starting position
center_x = _world.width // 2 center_x = _world.width // 2
center_y = _world.height // 2 center_y = _world.height // 2
start_x, start_y = find_passable_start(_world, center_x, center_y) 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 # Create player instance
player = Player( player = Player(
name=player_name, name=player_name,
x=start_x, x=player_data["x"],
y=start_y, y=player_data["y"],
pl=player_data["pl"],
stamina=player_data["stamina"],
max_stamina=player_data["max_stamina"],
flying=player_data["flying"],
writer=_writer, writer=_writer,
reader=_reader, reader=_reader,
) )
# Register player # Register player
players[player_name] = player players[player_name] = player
log.info("%s connected at (%d, %d)", player_name, start_x, start_y) 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(f"\r\nWelcome, {player_name}!\r\n")
_writer.write("Type 'help' for commands or 'quit' to disconnect.\r\n\r\n") _writer.write("Type 'help' for commands or 'quit' to disconnect.\r\n\r\n")
@ -134,6 +283,7 @@ async def shell(
await mudlib.commands.look.cmd_look(player, "") await mudlib.commands.look.cmd_look(player, "")
# Command loop # Command loop
try:
while not _writer.is_closing(): while not _writer.is_closing():
_writer.write("mud> ") _writer.write("mud> ")
await _writer.drain() await _writer.drain()
@ -152,9 +302,10 @@ async def shell(
# Check if writer was closed by quit command # Check if writer was closed by quit command
if _writer.is_closing(): if _writer.is_closing():
break break
finally:
# Clean up: remove from registry if still present # Save player state on disconnect (if not already saved by quit command)
if player_name in players: if player_name in players:
save_player(player)
del players[player_name] del players[player_name]
log.info("%s disconnected", player_name) log.info("%s disconnected", player_name)
@ -165,6 +316,12 @@ async def run_server() -> None:
"""Start the MUD telnet server.""" """Start the MUD telnet server."""
global _world 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) # Generate world once at startup (cached to build/ after first run)
cache_dir = pathlib.Path(__file__).resolve().parents[2] / "build" cache_dir = pathlib.Path(__file__).resolve().parents[2] / "build"
config = load_world_config() config = load_world_config()

164
tests/test_login_flow.py Normal file
View file

@ -0,0 +1,164 @@
"""Tests for login and registration flow."""
import os
import tempfile
import pytest
from mudlib.server import handle_login
from mudlib.store import account_exists, authenticate, init_db
@pytest.fixture
def temp_db():
"""Create a temporary database for testing."""
with tempfile.NamedTemporaryFile(delete=False, suffix=".db") as f:
db_path = f.name
init_db(db_path)
yield db_path
# Cleanup
os.unlink(db_path)
@pytest.mark.asyncio
async def test_login_existing_account_correct_password(temp_db):
"""Login succeeds with correct password."""
from mudlib.store import create_account
create_account("TestUser", "correct_password")
# Mock I/O
inputs = ["correct_password"]
outputs = []
async def mock_write(msg: str):
outputs.append(msg)
async def mock_read():
if inputs:
return inputs.pop(0)
return None
result = await handle_login("TestUser", mock_read, mock_write)
assert result["success"] is True
assert result["player_data"] is not None
assert result["player_data"]["x"] == 0
assert result["player_data"]["y"] == 0
@pytest.mark.asyncio
async def test_login_existing_account_wrong_password(temp_db):
"""Login fails with wrong password after max attempts."""
from mudlib.store import create_account
create_account("TestUser", "correct_password")
# Mock I/O - provide wrong password 3 times
inputs = ["wrong1", "wrong2", "wrong3"]
outputs = []
async def mock_write(msg: str):
outputs.append(msg)
async def mock_read():
if inputs:
return inputs.pop(0)
return None
result = await handle_login("TestUser", mock_read, mock_write)
assert result["success"] is False
assert "Too many failed attempts" in "".join(outputs)
@pytest.mark.asyncio
async def test_login_existing_account_retry_success(temp_db):
"""Login succeeds after retrying wrong password."""
from mudlib.store import create_account
create_account("TestUser", "correct_password")
# Mock I/O - wrong password first, then correct
inputs = ["wrong_password", "correct_password"]
outputs = []
async def mock_write(msg: str):
outputs.append(msg)
async def mock_read():
if inputs:
return inputs.pop(0)
return None
result = await handle_login("TestUser", mock_read, mock_write)
assert result["success"] is True
assert result["player_data"] is not None
@pytest.mark.asyncio
async def test_registration_new_account(temp_db):
"""Registration creates a new account."""
# Mock I/O - answer 'y' to create account, then provide password twice
inputs = ["y", "newpassword", "newpassword"]
outputs = []
async def mock_write(msg: str):
outputs.append(msg)
async def mock_read():
if inputs:
return inputs.pop(0)
return None
result = await handle_login("NewUser", mock_read, mock_write)
assert result["success"] is True
assert account_exists("NewUser")
assert authenticate("NewUser", "newpassword")
@pytest.mark.asyncio
async def test_registration_password_mismatch(temp_db):
"""Registration fails if passwords don't match."""
# Mock I/O - answer 'y', then mismatched passwords, then cancel
inputs = ["y", "password1", "password2", "n"]
outputs = []
async def mock_write(msg: str):
outputs.append(msg)
async def mock_read():
if inputs:
return inputs.pop(0)
return None
result = await handle_login("NewUser", mock_read, mock_write)
assert result["success"] is False
assert not account_exists("NewUser")
@pytest.mark.asyncio
async def test_registration_declined(temp_db):
"""User declines to create account."""
# Mock I/O - answer 'n' to create account
inputs = ["n"]
outputs = []
async def mock_write(msg: str):
outputs.append(msg)
async def mock_read():
if inputs:
return inputs.pop(0)
return None
result = await handle_login("NewUser", mock_read, mock_write)
assert result["success"] is False
assert not account_exists("NewUser")

View file

@ -2,14 +2,31 @@
import asyncio import asyncio
import contextlib import contextlib
import os
import tempfile
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from mudlib import server from mudlib import server
from mudlib.store import init_db
from mudlib.world.terrain import World from mudlib.world.terrain import World
@pytest.fixture
def temp_db():
"""Create a temporary database for testing."""
with tempfile.NamedTemporaryFile(delete=False, suffix=".db") as f:
db_path = f.name
init_db(db_path)
yield db_path
# Cleanup
os.unlink(db_path)
def test_port_constant(): def test_port_constant():
assert server.PORT == 6789 assert server.PORT == 6789
assert isinstance(server.PORT, int) assert isinstance(server.PORT, int)
@ -34,8 +51,9 @@ def test_find_passable_start():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_shell_greets_and_accepts_commands(): async def test_shell_greets_and_accepts_commands(temp_db):
server._world = World(seed=42, width=100, height=100) world = World(seed=42, width=100, height=100)
server._world = world
reader = AsyncMock() reader = AsyncMock()
writer = MagicMock() writer = MagicMock()
@ -43,15 +61,32 @@ async def test_shell_greets_and_accepts_commands():
writer.drain = AsyncMock() writer.drain = AsyncMock()
writer.close = MagicMock() writer.close = MagicMock()
# Need to mock the look command's world reference too
import mudlib.commands.look
original_world = mudlib.commands.look.world
mudlib.commands.look.world = world
try:
readline = "mudlib.server.readline2" readline = "mudlib.server.readline2"
with patch(readline, new_callable=AsyncMock) as mock_readline: with patch(readline, new_callable=AsyncMock) as mock_readline:
mock_readline.side_effect = ["TestPlayer", "look", "quit"] # Simulate: name, create account (y), password, confirm password, look, quit
mock_readline.side_effect = [
"TestPlayer",
"y",
"password",
"password",
"look",
"quit",
]
await server.shell(reader, writer) await server.shell(reader, writer)
calls = [str(call) for call in writer.write.call_args_list] calls = [str(call) for call in writer.write.call_args_list]
assert any("Welcome" in call for call in calls) assert any("Welcome" in call for call in calls)
assert any("TestPlayer" in call for call in calls) assert any("TestPlayer" in call for call in calls)
writer.close.assert_called() writer.close.assert_called()
finally:
mudlib.commands.look.world = original_world
@pytest.mark.asyncio @pytest.mark.asyncio
@ -73,8 +108,9 @@ async def test_shell_handles_eof():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_shell_handles_quit(): async def test_shell_handles_quit(temp_db):
server._world = World(seed=42, width=100, height=100) world = World(seed=42, width=100, height=100)
server._world = world
reader = AsyncMock() reader = AsyncMock()
writer = MagicMock() writer = MagicMock()
@ -82,14 +118,30 @@ async def test_shell_handles_quit():
writer.drain = AsyncMock() writer.drain = AsyncMock()
writer.close = MagicMock() writer.close = MagicMock()
# Need to mock the look command's world reference too
import mudlib.commands.look
original_world = mudlib.commands.look.world
mudlib.commands.look.world = world
try:
readline = "mudlib.server.readline2" readline = "mudlib.server.readline2"
with patch(readline, new_callable=AsyncMock) as mock_readline: with patch(readline, new_callable=AsyncMock) as mock_readline:
mock_readline.side_effect = ["TestPlayer", "quit"] # Simulate: name, create account (y), password, confirm password, quit
mock_readline.side_effect = [
"TestPlayer",
"y",
"password",
"password",
"quit",
]
await server.shell(reader, writer) await server.shell(reader, writer)
calls = [str(call) for call in writer.write.call_args_list] calls = [str(call) for call in writer.write.call_args_list]
assert any("Goodbye" in call for call in calls) assert any("Goodbye" in call for call in calls)
writer.close.assert_called() writer.close.assert_called()
finally:
mudlib.commands.look.world = original_world
def test_load_world_config(): def test_load_world_config():