Compare commits

...

7 commits

Author SHA1 Message Date
269026259c
Add PlayerData TypedDict to fix type errors 2026-02-07 22:13:27 -05:00
565690cef2
Add fast and parallel testing 2026-02-07 22:03:00 -05:00
43cc427727
Swap out pyright for ty 2026-02-07 22:02:13 -05:00
f36880f82c
Add persistence documentation
Explains the store module architecture, password security considerations,
and the three-layer save strategy (auto-save, logout save, disconnect save).
2026-02-07 21:42:19 -05:00
54998c29c5
Add save on logout and disconnect
Player state is now saved when using the quit command or when the connection
is lost unexpectedly. Ensures progress is preserved even without auto-save.
2026-02-07 21:42:16 -05:00
3fe51f2552
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.
2026-02-07 21:42:12 -05:00
485302fab3
Add store module with SQLite account persistence
Implements account management with password hashing (pbkdf2_hmac with SHA256)
and constant-time comparison. Includes player state serialization for position
and inventory persistence across sessions.
2026-02-07 21:42:07 -05:00
12 changed files with 1214 additions and 78 deletions

2
.gitignore vendored
View file

@ -1,4 +1,6 @@
__pycache__
repos
build
data
.worktrees
.testmondata

77
docs/how/persistence.txt Normal file
View file

@ -0,0 +1,77 @@
PERSISTENCE
This document explains how player accounts and state are persisted to disk.
WHAT'S PERSISTED
Player accounts:
- Username (case-insensitive)
- Password (hashed with PBKDF2-HMAC-SHA256 and random salt)
- Position (x, y coordinates)
- Combat stats (pl, stamina, max_stamina)
- Flying status
- Created timestamp
- Last login timestamp
What's NOT persisted (runtime state only):
- Telnet I/O (writer, reader)
- Mode stack (normal/combat/editor modes)
- Active effects
- Combat encounters
- Commands being executed
DATABASE LOCATION
Default: data/mud.db (SQLite)
The database is initialized on server startup via init_db(). The store module
uses a simple module-level _db_path variable to track the connection.
LOGIN FLOW
New player:
1. Enter name
2. If account doesn't exist, prompt "Create new account? (y/n)"
3. If yes, prompt for password twice (must match)
4. Create account with hashed password
5. Start at default position (world center, or nearest passable tile)
Existing player:
1. Enter name
2. Prompt for password
3. Verify password (max 3 attempts)
4. Load saved position and stats
5. If saved position is no longer passable (world changed), find nearest
passable tile
WHEN SAVES HAPPEN
1. On quit: When player types "quit" command
2. On disconnect: When connection is lost (network error, timeout, etc)
3. Periodic auto-save: Every 60 seconds for all connected players
The periodic auto-save runs in the game loop alongside combat and effects
processing. It only triggers if at least one player is connected.
IMPLEMENTATION NOTES
Password hashing:
- Uses hashlib.pbkdf2_hmac (stdlib, no external dependencies)
- 100,000 iterations of SHA-256
- Random 32-byte salt per account (from os.urandom)
- Both hash and salt stored in database
Case-insensitive names:
- SQLite COLLATE NOCASE on name column
- "Jared", "jared", and "JARED" are the same account
Synchronous operations:
- Store module uses sync sqlite3 (not async)
- Save operations are fast enough for our scale
- Game loop calls save_player() directly (doesn't block tick processing)
TESTING
See tests/test_store.py for store module unit tests
See tests/test_login_flow.py for login/registration integration tests
See tests/test_persistence.py for save-on-quit and auto-save tests

View file

@ -3,11 +3,17 @@ lint:
uv run ruff format .
typecheck:
uv run pyright
uvx ty check
test:
uv run pytest
test-fast:
uv run pytest --testmon
test-parallel:
uv run pytest -n auto
check: lint typecheck test
run:

View file

@ -10,9 +10,10 @@ dependencies = [
[dependency-groups]
dev = [
"ruff",
"pyright",
"pytest",
"pytest-asyncio",
"pytest-testmon",
"pytest-xdist",
]
[build-system]
@ -33,9 +34,5 @@ exclude = ["repos", ".worktrees"]
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM"]
[tool.pyright]
pythonVersion = "3.12"
exclude = ["repos", ".worktrees", ".venv"]
[tool.pytest.ini_options]
testpaths = ["tests"]

View file

@ -2,6 +2,7 @@
from mudlib.commands import CommandDefinition, register
from mudlib.player import Player, players
from mudlib.store import save_player
async def cmd_quit(player: Player, args: str) -> None:
@ -11,6 +12,9 @@ async def cmd_quit(player: Player, args: str) -> None:
player: The player executing the command
args: Command arguments (unused)
"""
# Save player state before disconnecting
save_player(player)
player.writer.write("Goodbye!\r\n")
await player.writer.drain()
player.writer.close()

View file

@ -20,6 +20,16 @@ from mudlib.combat.engine import process_combat
from mudlib.content import load_commands
from mudlib.effects import clear_expired
from mudlib.player import Player, players
from mudlib.store import (
PlayerData,
account_exists,
authenticate,
create_account,
init_db,
load_player_data,
save_player,
update_last_login,
)
from mudlib.world.terrain import World
log = logging.getLogger(__name__)
@ -27,6 +37,7 @@ log = logging.getLogger(__name__)
PORT = 6789
TICK_RATE = 10 # ticks per second
TICK_INTERVAL = 1.0 / TICK_RATE
AUTOSAVE_INTERVAL = 60.0 # seconds between auto-saves
# Module-level world instance, generated once at startup
_world: World | None = None
@ -43,10 +54,23 @@ def load_world_config(world_name: str = "earth") -> dict:
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()
while True:
t0 = asyncio.get_event_loop().time()
clear_expired()
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
sleep_time = TICK_INTERVAL - elapsed
if sleep_time > 0:
@ -85,6 +109,83 @@ def find_passable_start(world: World, start_x: int, start_y: int) -> tuple[int,
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(
reader: telnetlib3.TelnetReader | telnetlib3.TelnetReaderUnicode,
writer: telnetlib3.TelnetWriter | telnetlib3.TelnetWriterUnicode,
@ -108,23 +209,72 @@ async def shell(
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)
# Handle login/registration
async def read_input():
result = await readline2(_reader, _writer)
return result
# Create player
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 = _world.width // 2
center_y = _world.height // 2
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 instance
player = Player(
name=player_name,
x=start_x,
y=start_y,
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,
)
# Register 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("Type 'help' for commands or 'quit' to disconnect.\r\n\r\n")
@ -134,37 +284,45 @@ async def shell(
await mudlib.commands.look.cmd_look(player, "")
# Command loop
while not _writer.is_closing():
_writer.write("mud> ")
await _writer.drain()
try:
while not _writer.is_closing():
_writer.write("mud> ")
await _writer.drain()
inp = await readline2(_reader, _writer)
if inp is None:
break
inp = await readline2(_reader, _writer)
if inp is None:
break
command = inp.strip()
if not command:
continue
command = inp.strip()
if not command:
continue
# Dispatch command
await mudlib.commands.dispatch(player, command)
# Dispatch command
await mudlib.commands.dispatch(player, command)
# Check if writer was closed by quit command
if _writer.is_closing():
break
# Check if writer was closed by quit command
if _writer.is_closing():
break
finally:
# Save player state on disconnect (if not already saved by quit command)
if player_name in players:
save_player(player)
del players[player_name]
log.info("%s disconnected", player_name)
# 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()
_writer.close()
async def run_server() -> None:
"""Start the MUD telnet server."""
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)
cache_dir = pathlib.Path(__file__).resolve().parents[2] / "build"
config = load_world_config()

View file

@ -0,0 +1,255 @@
"""SQLite persistence for player accounts and state."""
import hashlib
import hmac
import os
import sqlite3
from pathlib import Path
from typing import TypedDict
from mudlib.player import Player
class PlayerData(TypedDict):
"""Shape of persisted player data from the database."""
x: int
y: int
pl: float
stamina: float
max_stamina: float
flying: bool
# Module-level database path
_db_path: str | None = None
def init_db(db_path: str | Path) -> None:
"""Initialize the database and create tables if needed.
Args:
db_path: Path to the SQLite database file
"""
global _db_path
_db_path = str(db_path)
# Ensure parent directory exists
Path(_db_path).parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(_db_path)
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS accounts (
name TEXT PRIMARY KEY COLLATE NOCASE,
password_hash TEXT NOT NULL,
salt TEXT NOT NULL,
x INTEGER NOT NULL DEFAULT 0,
y INTEGER NOT NULL DEFAULT 0,
pl REAL NOT NULL DEFAULT 100.0,
stamina REAL NOT NULL DEFAULT 100.0,
max_stamina REAL NOT NULL DEFAULT 100.0,
flying INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_login TEXT
)
""")
conn.commit()
conn.close()
def _get_connection() -> sqlite3.Connection:
"""Get a connection to the database.
Returns:
sqlite3.Connection
Raises:
RuntimeError: If init_db has not been called
"""
if _db_path is None:
raise RuntimeError("Database not initialized. Call init_db() first.")
return sqlite3.connect(_db_path)
def _hash_password(password: str, salt: bytes) -> str:
"""Hash a password with the given salt.
Args:
password: The password to hash
salt: Salt bytes
Returns:
Hex-encoded hash
"""
return hashlib.pbkdf2_hmac(
"sha256", password.encode("utf-8"), salt, iterations=100000
).hex()
def create_account(name: str, password: str) -> bool:
"""Create a new account with the given name and password.
Args:
name: Account name (case-insensitive)
password: Password to hash and store
Returns:
True if account was created, False if name is already taken
"""
if account_exists(name):
return False
# Generate random salt
salt = os.urandom(32)
password_hash = _hash_password(password, salt)
conn = _get_connection()
cursor = conn.cursor()
try:
cursor.execute(
"INSERT INTO accounts (name, password_hash, salt) VALUES (?, ?, ?)",
(name, password_hash, salt.hex()),
)
conn.commit()
return True
except sqlite3.IntegrityError:
# Race condition: someone created the account between our check and insert
return False
finally:
conn.close()
def account_exists(name: str) -> bool:
"""Check if an account exists.
Args:
name: Account name (case-insensitive)
Returns:
True if account exists, False otherwise
"""
conn = _get_connection()
cursor = conn.cursor()
cursor.execute("SELECT 1 FROM accounts WHERE name = ? LIMIT 1", (name,))
result = cursor.fetchone()
conn.close()
return result is not None
def authenticate(name: str, password: str) -> bool:
"""Verify a password for the given account.
Args:
name: Account name (case-insensitive)
password: Password to verify
Returns:
True if password is correct, False otherwise
"""
conn = _get_connection()
cursor = conn.cursor()
cursor.execute("SELECT password_hash, salt FROM accounts WHERE name = ?", (name,))
result = cursor.fetchone()
conn.close()
if result is None:
return False
stored_hash, salt_hex = result
salt = bytes.fromhex(salt_hex)
password_hash = _hash_password(password, salt)
return hmac.compare_digest(password_hash, stored_hash)
def save_player(player: Player) -> None:
"""Save player state to the database.
The account must already exist. This updates game state only.
Args:
player: Player instance to save
"""
conn = _get_connection()
cursor = conn.cursor()
cursor.execute(
"""
UPDATE accounts
SET x = ?, y = ?, pl = ?, stamina = ?, max_stamina = ?, flying = ?
WHERE name = ?
""",
(
player.x,
player.y,
player.pl,
player.stamina,
player.max_stamina,
1 if player.flying else 0,
player.name,
),
)
conn.commit()
conn.close()
def load_player_data(name: str) -> PlayerData | None:
"""Load player data from the database.
Args:
name: Account name (case-insensitive)
Returns:
Dictionary of persisted fields, or None if account not found
"""
conn = _get_connection()
cursor = conn.cursor()
cursor.execute(
"""
SELECT x, y, pl, stamina, max_stamina, flying
FROM accounts
WHERE name = ?
""",
(name,),
)
result = cursor.fetchone()
conn.close()
if result is None:
return None
x, y, pl, stamina, max_stamina, flying_int = result
return {
"x": x,
"y": y,
"pl": pl,
"stamina": stamina,
"max_stamina": max_stamina,
"flying": bool(flying_int),
}
def update_last_login(name: str) -> None:
"""Update the last_login timestamp for an account.
Args:
name: Account name (case-insensitive)
"""
conn = _get_connection()
cursor = conn.cursor()
cursor.execute(
"UPDATE accounts SET last_login = datetime('now') WHERE name = ?", (name,)
)
conn.commit()
conn.close()

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")

153
tests/test_persistence.py Normal file
View file

@ -0,0 +1,153 @@
"""Tests for persistence behavior (save on quit/disconnect)."""
import os
import tempfile
from unittest.mock import AsyncMock, MagicMock
import pytest
from mudlib.commands.quit import cmd_quit
from mudlib.player import Player, players
from mudlib.store import create_account, init_db, load_player_data, save_player
@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.fixture
def mock_writer():
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
writer.close = MagicMock()
writer.is_closing = MagicMock(return_value=False)
return writer
@pytest.fixture
def mock_reader():
return MagicMock()
@pytest.mark.asyncio
async def test_quit_saves_player_state(temp_db, mock_reader, mock_writer):
"""Quit command saves player state before disconnecting."""
# Create an account
create_account("TestPlayer", "password")
# Create player with modified state
player = Player(
name="TestPlayer",
x=42,
y=17,
pl=85.0,
stamina=60.0,
max_stamina=120.0,
flying=True,
reader=mock_reader,
writer=mock_writer,
)
# Add to player registry
players["TestPlayer"] = player
# Execute quit command
await cmd_quit(player, "")
# Verify player was saved
data = load_player_data("TestPlayer")
assert data is not None
assert data["x"] == 42
assert data["y"] == 17
assert data["pl"] == 85.0
assert data["stamina"] == 60.0
assert data["max_stamina"] == 120.0
assert data["flying"] is True
# Verify player was removed from registry
assert "TestPlayer" not in players
@pytest.mark.asyncio
async def test_save_multiple_times(temp_db, mock_reader, mock_writer):
"""Player state can be saved multiple times (updates)."""
create_account("TestPlayer", "password")
player = Player(
name="TestPlayer",
x=10,
y=20,
reader=mock_reader,
writer=mock_writer,
)
# Save initial state
players["TestPlayer"] = player
await cmd_quit(player, "")
# Verify initial save
data = load_player_data("TestPlayer")
assert data is not None
assert data["x"] == 10
assert data["y"] == 20
# Create new player instance with different position
player2 = Player(
name="TestPlayer",
x=50,
y=60,
reader=mock_reader,
writer=mock_writer,
)
# Save again
players["TestPlayer"] = player2
await cmd_quit(player2, "")
# Verify updated state
data = load_player_data("TestPlayer")
assert data is not None
assert data["x"] == 50
assert data["y"] == 60
@pytest.mark.asyncio
async def test_autosave_persists_player_changes(temp_db, mock_reader, mock_writer):
"""Auto-save persists player state changes during gameplay."""
create_account("TestPlayer", "password")
# Create player
player = Player(
name="TestPlayer",
x=10,
y=20,
pl=100.0,
reader=mock_reader,
writer=mock_writer,
)
# Simulate player moving and taking damage
player.x = 25
player.y = 35
player.pl = 75.0
# Manually trigger save (simulating auto-save)
save_player(player)
# Verify state was saved
data = load_player_data("TestPlayer")
assert data is not None
assert data["x"] == 25
assert data["y"] == 35
assert data["pl"] == 75.0

View file

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

179
tests/test_store.py Normal file
View file

@ -0,0 +1,179 @@
"""Tests for the store (persistence) module."""
import os
import tempfile
from pathlib import Path
import pytest
from mudlib.player import Player
from mudlib.store import (
account_exists,
authenticate,
create_account,
init_db,
load_player_data,
save_player,
)
@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_init_db_creates_file(temp_db):
"""init_db creates the database file."""
assert Path(temp_db).exists()
def test_create_account_success(temp_db):
"""create_account creates a new account."""
assert create_account("Alice", "password123")
assert account_exists("Alice")
def test_create_account_case_insensitive(temp_db):
"""Account names are case-insensitive."""
create_account("Bob", "password123")
assert account_exists("bob")
assert account_exists("BOB")
assert account_exists("Bob")
def test_create_account_duplicate_fails(temp_db):
"""create_account fails if name is already taken."""
assert create_account("Charlie", "password123")
assert not create_account("Charlie", "different_password")
assert not create_account("charlie", "different_password") # case insensitive
def test_authenticate_success(temp_db):
"""authenticate returns True for correct password."""
create_account("Dave", "correct_password")
assert authenticate("Dave", "correct_password")
def test_authenticate_case_insensitive_name(temp_db):
"""authenticate works with case-insensitive names."""
create_account("Eve", "password123")
assert authenticate("eve", "password123")
assert authenticate("EVE", "password123")
def test_authenticate_wrong_password(temp_db):
"""authenticate returns False for wrong password."""
create_account("Frank", "correct_password")
assert not authenticate("Frank", "wrong_password")
def test_authenticate_nonexistent_account(temp_db):
"""authenticate returns False for nonexistent account."""
assert not authenticate("Ghost", "any_password")
def test_save_and_load_player_data(temp_db):
"""save_player and load_player_data persist player state."""
# Create account first
create_account("Grace", "password123")
# Create a player with non-default values
player = Player(
name="Grace",
x=42,
y=17,
pl=85.5,
stamina=60.0,
max_stamina=120.0,
flying=True,
)
# Save and load
save_player(player)
data = load_player_data("Grace")
assert data is not None
assert data["x"] == 42
assert data["y"] == 17
assert data["pl"] == 85.5
assert data["stamina"] == 60.0
assert data["max_stamina"] == 120.0
assert data["flying"] is True
def test_load_player_data_case_insensitive(temp_db):
"""load_player_data works with case-insensitive names."""
create_account("Henry", "password123")
player = Player(name="Henry", x=10, y=20)
save_player(player)
data_lower = load_player_data("henry")
data_upper = load_player_data("HENRY")
assert data_lower is not None
assert data_upper is not None
assert data_lower["x"] == 10
assert data_upper["x"] == 10
def test_load_player_data_nonexistent(temp_db):
"""load_player_data returns None for nonexistent account."""
assert load_player_data("Nobody") is None
def test_save_player_updates_existing(temp_db):
"""save_player updates existing player data."""
create_account("Iris", "password123")
# First save
player = Player(name="Iris", x=10, y=20, pl=100.0)
save_player(player)
# Update and save again
player.x = 50
player.y = 60
player.pl = 75.0
save_player(player)
# Load and verify
data = load_player_data("Iris")
assert data is not None
assert data["x"] == 50
assert data["y"] == 60
assert data["pl"] == 75.0
def test_default_values(temp_db):
"""New accounts have default values."""
create_account("Jack", "password123")
data = load_player_data("Jack")
assert data is not None
assert data["x"] == 0
assert data["y"] == 0
assert data["pl"] == 100.0
assert data["stamina"] == 100.0
assert data["max_stamina"] == 100.0
assert data["flying"] is False
def test_password_hashing_different_salts(temp_db):
"""Different accounts with same password have different hashes."""
create_account("Kate", "same_password")
create_account("Leo", "same_password")
# Both should authenticate
assert authenticate("Kate", "same_password")
assert authenticate("Leo", "same_password")
# This just verifies the API works correctly - we can't easily check
# the hashes are different without exposing internal details

139
uv.lock
View file

@ -11,6 +11,89 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "coverage"
version = "7.13.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/11/43/3e4ac666cc35f231fa70c94e9f38459299de1a152813f9d2f60fc5f3ecaf/coverage-7.13.3.tar.gz", hash = "sha256:f7f6182d3dfb8802c1747eacbfe611b669455b69b7c037484bb1efbbb56711ac", size = 826832, upload-time = "2026-02-03T14:02:30.944Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/44/330f8e83b143f6668778ed61d17ece9dc48459e9e74669177de02f45fec5/coverage-7.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ed48b4170caa2c4420e0cd27dc977caaffc7eecc317355751df8373dddcef595", size = 219441, upload-time = "2026-02-03T14:00:22.585Z" },
{ url = "https://files.pythonhosted.org/packages/08/e7/29db05693562c2e65bdf6910c0af2fd6f9325b8f43caf7a258413f369e30/coverage-7.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8f2adf4bcffbbec41f366f2e6dffb9d24e8172d16e91da5799c9b7ed6b5716e6", size = 219801, upload-time = "2026-02-03T14:00:24.186Z" },
{ url = "https://files.pythonhosted.org/packages/90/ae/7f8a78249b02b0818db46220795f8ac8312ea4abd1d37d79ea81db5cae81/coverage-7.13.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:01119735c690786b6966a1e9f098da4cd7ca9174c4cfe076d04e653105488395", size = 251306, upload-time = "2026-02-03T14:00:25.798Z" },
{ url = "https://files.pythonhosted.org/packages/62/71/a18a53d1808e09b2e9ebd6b47dad5e92daf4c38b0686b4c4d1b2f3e42b7f/coverage-7.13.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8bb09e83c603f152d855f666d70a71765ca8e67332e5829e62cb9466c176af23", size = 254051, upload-time = "2026-02-03T14:00:27.474Z" },
{ url = "https://files.pythonhosted.org/packages/4a/0a/eb30f6455d04c5a3396d0696cad2df0269ae7444bb322f86ffe3376f7bf9/coverage-7.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b607a40cba795cfac6d130220d25962931ce101f2f478a29822b19755377fb34", size = 255160, upload-time = "2026-02-03T14:00:29.024Z" },
{ url = "https://files.pythonhosted.org/packages/7b/7e/a45baac86274ce3ed842dbb84f14560c673ad30535f397d89164ec56c5df/coverage-7.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:44f14a62f5da2e9aedf9080e01d2cda61df39197d48e323538ec037336d68da8", size = 251709, upload-time = "2026-02-03T14:00:30.641Z" },
{ url = "https://files.pythonhosted.org/packages/c0/df/dd0dc12f30da11349993f3e218901fdf82f45ee44773596050c8f5a1fb25/coverage-7.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:debf29e0b157769843dff0981cc76f79e0ed04e36bb773c6cac5f6029054bd8a", size = 253083, upload-time = "2026-02-03T14:00:32.14Z" },
{ url = "https://files.pythonhosted.org/packages/ab/32/fc764c8389a8ce95cb90eb97af4c32f392ab0ac23ec57cadeefb887188d3/coverage-7.13.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:824bb95cd71604031ae9a48edb91fd6effde669522f960375668ed21b36e3ec4", size = 251227, upload-time = "2026-02-03T14:00:34.721Z" },
{ url = "https://files.pythonhosted.org/packages/dd/ca/d025e9da8f06f24c34d2da9873957cfc5f7e0d67802c3e34d0caa8452130/coverage-7.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8f1010029a5b52dc427c8e2a8dbddb2303ddd180b806687d1acd1bb1d06649e7", size = 250794, upload-time = "2026-02-03T14:00:36.278Z" },
{ url = "https://files.pythonhosted.org/packages/45/c7/76bf35d5d488ec8f68682eb8e7671acc50a6d2d1c1182de1d2b6d4ffad3b/coverage-7.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cd5dee4fd7659d8306ffa79eeaaafd91fa30a302dac3af723b9b469e549247e0", size = 252671, upload-time = "2026-02-03T14:00:38.368Z" },
{ url = "https://files.pythonhosted.org/packages/bf/10/1921f1a03a7c209e1cb374f81a6b9b68b03cdb3ecc3433c189bc90e2a3d5/coverage-7.13.3-cp312-cp312-win32.whl", hash = "sha256:f7f153d0184d45f3873b3ad3ad22694fd73aadcb8cdbc4337ab4b41ea6b4dff1", size = 221986, upload-time = "2026-02-03T14:00:40.442Z" },
{ url = "https://files.pythonhosted.org/packages/3c/7c/f5d93297f8e125a80c15545edc754d93e0ed8ba255b65e609b185296af01/coverage-7.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:03a6e5e1e50819d6d7436f5bc40c92ded7e484e400716886ac921e35c133149d", size = 222793, upload-time = "2026-02-03T14:00:42.106Z" },
{ url = "https://files.pythonhosted.org/packages/43/59/c86b84170015b4555ebabca8649bdf9f4a1f737a73168088385ed0f947c4/coverage-7.13.3-cp312-cp312-win_arm64.whl", hash = "sha256:51c4c42c0e7d09a822b08b6cf79b3c4db8333fffde7450da946719ba0d45730f", size = 221410, upload-time = "2026-02-03T14:00:43.726Z" },
{ url = "https://files.pythonhosted.org/packages/81/f3/4c333da7b373e8c8bfb62517e8174a01dcc373d7a9083698e3b39d50d59c/coverage-7.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:853c3d3c79ff0db65797aad79dee6be020efd218ac4510f15a205f1e8d13ce25", size = 219468, upload-time = "2026-02-03T14:00:45.829Z" },
{ url = "https://files.pythonhosted.org/packages/d6/31/0714337b7d23630c8de2f4d56acf43c65f8728a45ed529b34410683f7217/coverage-7.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f75695e157c83d374f88dcc646a60cb94173304a9258b2e74ba5a66b7614a51a", size = 219839, upload-time = "2026-02-03T14:00:47.407Z" },
{ url = "https://files.pythonhosted.org/packages/12/99/bd6f2a2738144c98945666f90cae446ed870cecf0421c767475fcf42cdbe/coverage-7.13.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2d098709621d0819039f3f1e471ee554f55a0b2ac0d816883c765b14129b5627", size = 250828, upload-time = "2026-02-03T14:00:49.029Z" },
{ url = "https://files.pythonhosted.org/packages/6f/99/97b600225fbf631e6f5bfd3ad5bcaf87fbb9e34ff87492e5a572ff01bbe2/coverage-7.13.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:16d23d6579cf80a474ad160ca14d8b319abaa6db62759d6eef53b2fc979b58c8", size = 253432, upload-time = "2026-02-03T14:00:50.655Z" },
{ url = "https://files.pythonhosted.org/packages/5f/5c/abe2b3490bda26bd4f5e3e799be0bdf00bd81edebedc2c9da8d3ef288fa8/coverage-7.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:00d34b29a59d2076e6f318b30a00a69bf63687e30cd882984ed444e753990cc1", size = 254672, upload-time = "2026-02-03T14:00:52.757Z" },
{ url = "https://files.pythonhosted.org/packages/31/ba/5d1957c76b40daff53971fe0adb84d9c2162b614280031d1d0653dd010c1/coverage-7.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ab6d72bffac9deb6e6cb0f61042e748de3f9f8e98afb0375a8e64b0b6e11746b", size = 251050, upload-time = "2026-02-03T14:00:54.332Z" },
{ url = "https://files.pythonhosted.org/packages/69/dc/dffdf3bfe9d32090f047d3c3085378558cb4eb6778cda7de414ad74581ed/coverage-7.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e129328ad1258e49cae0123a3b5fcb93d6c2fa90d540f0b4c7cdcdc019aaa3dc", size = 252801, upload-time = "2026-02-03T14:00:56.121Z" },
{ url = "https://files.pythonhosted.org/packages/87/51/cdf6198b0f2746e04511a30dc9185d7b8cdd895276c07bdb538e37f1cd50/coverage-7.13.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2213a8d88ed35459bda71597599d4eec7c2ebad201c88f0bfc2c26fd9b0dd2ea", size = 250763, upload-time = "2026-02-03T14:00:58.719Z" },
{ url = "https://files.pythonhosted.org/packages/d7/1a/596b7d62218c1d69f2475b69cc6b211e33c83c902f38ee6ae9766dd422da/coverage-7.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:00dd3f02de6d5f5c9c3d95e3e036c3c2e2a669f8bf2d3ceb92505c4ce7838f67", size = 250587, upload-time = "2026-02-03T14:01:01.197Z" },
{ url = "https://files.pythonhosted.org/packages/f7/46/52330d5841ff660f22c130b75f5e1dd3e352c8e7baef5e5fef6b14e3e991/coverage-7.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f9bada7bc660d20b23d7d312ebe29e927b655cf414dadcdb6335a2075695bd86", size = 252358, upload-time = "2026-02-03T14:01:02.824Z" },
{ url = "https://files.pythonhosted.org/packages/36/8a/e69a5be51923097ba7d5cff9724466e74fe486e9232020ba97c809a8b42b/coverage-7.13.3-cp313-cp313-win32.whl", hash = "sha256:75b3c0300f3fa15809bd62d9ca8b170eb21fcf0100eb4b4154d6dc8b3a5bbd43", size = 222007, upload-time = "2026-02-03T14:01:04.876Z" },
{ url = "https://files.pythonhosted.org/packages/0a/09/a5a069bcee0d613bdd48ee7637fa73bc09e7ed4342b26890f2df97cc9682/coverage-7.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:a2f7589c6132c44c53f6e705e1a6677e2b7821378c22f7703b2cf5388d0d4587", size = 222812, upload-time = "2026-02-03T14:01:07.296Z" },
{ url = "https://files.pythonhosted.org/packages/3d/4f/d62ad7dfe32f9e3d4a10c178bb6f98b10b083d6e0530ca202b399371f6c1/coverage-7.13.3-cp313-cp313-win_arm64.whl", hash = "sha256:123ceaf2b9d8c614f01110f908a341e05b1b305d6b2ada98763b9a5a59756051", size = 221433, upload-time = "2026-02-03T14:01:09.156Z" },
{ url = "https://files.pythonhosted.org/packages/04/b2/4876c46d723d80b9c5b695f1a11bf5f7c3dabf540ec00d6edc076ff025e6/coverage-7.13.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:cc7fd0f726795420f3678ac82ff882c7fc33770bd0074463b5aef7293285ace9", size = 220162, upload-time = "2026-02-03T14:01:11.409Z" },
{ url = "https://files.pythonhosted.org/packages/fc/04/9942b64a0e0bdda2c109f56bda42b2a59d9d3df4c94b85a323c1cae9fc77/coverage-7.13.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d358dc408edc28730aed5477a69338e444e62fba0b7e9e4a131c505fadad691e", size = 220510, upload-time = "2026-02-03T14:01:13.038Z" },
{ url = "https://files.pythonhosted.org/packages/5a/82/5cfe1e81eae525b74669f9795f37eb3edd4679b873d79d1e6c1c14ee6c1c/coverage-7.13.3-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5d67b9ed6f7b5527b209b24b3df9f2e5bf0198c1bbf99c6971b0e2dcb7e2a107", size = 261801, upload-time = "2026-02-03T14:01:14.674Z" },
{ url = "https://files.pythonhosted.org/packages/0b/ec/a553d7f742fd2cd12e36a16a7b4b3582d5934b496ef2b5ea8abeb10903d4/coverage-7.13.3-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:59224bfb2e9b37c1335ae35d00daa3a5b4e0b1a20f530be208fff1ecfa436f43", size = 263882, upload-time = "2026-02-03T14:01:16.343Z" },
{ url = "https://files.pythonhosted.org/packages/e1/58/8f54a2a93e3d675635bc406de1c9ac8d551312142ff52c9d71b5e533ad45/coverage-7.13.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae9306b5299e31e31e0d3b908c66bcb6e7e3ddca143dea0266e9ce6c667346d3", size = 266306, upload-time = "2026-02-03T14:01:18.02Z" },
{ url = "https://files.pythonhosted.org/packages/1a/be/e593399fd6ea1f00aee79ebd7cc401021f218d34e96682a92e1bae092ff6/coverage-7.13.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:343aaeb5f8bb7bcd38620fd7bc56e6ee8207847d8c6103a1e7b72322d381ba4a", size = 261051, upload-time = "2026-02-03T14:01:19.757Z" },
{ url = "https://files.pythonhosted.org/packages/5c/e5/e9e0f6138b21bcdebccac36fbfde9cf15eb1bbcea9f5b1f35cd1f465fb91/coverage-7.13.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b2182129f4c101272ff5f2f18038d7b698db1bf8e7aa9e615cb48440899ad32e", size = 263868, upload-time = "2026-02-03T14:01:21.487Z" },
{ url = "https://files.pythonhosted.org/packages/9a/bf/de72cfebb69756f2d4a2dde35efcc33c47d85cd3ebdf844b3914aac2ef28/coverage-7.13.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:94d2ac94bd0cc57c5626f52f8c2fffed1444b5ae8c9fc68320306cc2b255e155", size = 261498, upload-time = "2026-02-03T14:01:23.097Z" },
{ url = "https://files.pythonhosted.org/packages/f2/91/4a2d313a70fc2e98ca53afd1c8ce67a89b1944cd996589a5b1fe7fbb3e5c/coverage-7.13.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:65436cde5ecabe26fb2f0bf598962f0a054d3f23ad529361326ac002c61a2a1e", size = 260394, upload-time = "2026-02-03T14:01:24.949Z" },
{ url = "https://files.pythonhosted.org/packages/40/83/25113af7cf6941e779eb7ed8de2a677865b859a07ccee9146d4cc06a03e3/coverage-7.13.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:db83b77f97129813dbd463a67e5335adc6a6a91db652cc085d60c2d512746f96", size = 262579, upload-time = "2026-02-03T14:01:26.703Z" },
{ url = "https://files.pythonhosted.org/packages/1e/19/a5f2b96262977e82fb9aabbe19b4d83561f5d063f18dde3e72f34ffc3b2f/coverage-7.13.3-cp313-cp313t-win32.whl", hash = "sha256:dfb428e41377e6b9ba1b0a32df6db5409cb089a0ed1d0a672dc4953ec110d84f", size = 222679, upload-time = "2026-02-03T14:01:28.553Z" },
{ url = "https://files.pythonhosted.org/packages/81/82/ef1747b88c87a5c7d7edc3704799ebd650189a9158e680a063308b6125ef/coverage-7.13.3-cp313-cp313t-win_amd64.whl", hash = "sha256:5badd7e596e6b0c89aa8ec6d37f4473e4357f982ce57f9a2942b0221cd9cf60c", size = 223740, upload-time = "2026-02-03T14:01:30.776Z" },
{ url = "https://files.pythonhosted.org/packages/1c/4c/a67c7bb5b560241c22736a9cb2f14c5034149ffae18630323fde787339e4/coverage-7.13.3-cp313-cp313t-win_arm64.whl", hash = "sha256:989aa158c0eb19d83c76c26f4ba00dbb272485c56e452010a3450bdbc9daafd9", size = 221996, upload-time = "2026-02-03T14:01:32.495Z" },
{ url = "https://files.pythonhosted.org/packages/5e/b3/677bb43427fed9298905106f39c6520ac75f746f81b8f01104526a8026e4/coverage-7.13.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c6f6169bbdbdb85aab8ac0392d776948907267fcc91deeacf6f9d55f7a83ae3b", size = 219513, upload-time = "2026-02-03T14:01:34.29Z" },
{ url = "https://files.pythonhosted.org/packages/42/53/290046e3bbf8986cdb7366a42dab3440b9983711eaff044a51b11006c67b/coverage-7.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2f5e731627a3d5ef11a2a35aa0c6f7c435867c7ccbc391268eb4f2ca5dbdcc10", size = 219850, upload-time = "2026-02-03T14:01:35.984Z" },
{ url = "https://files.pythonhosted.org/packages/ea/2b/ab41f10345ba2e49d5e299be8663be2b7db33e77ac1b85cd0af985ea6406/coverage-7.13.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9db3a3285d91c0b70fab9f39f0a4aa37d375873677efe4e71e58d8321e8c5d39", size = 250886, upload-time = "2026-02-03T14:01:38.287Z" },
{ url = "https://files.pythonhosted.org/packages/72/2d/b3f6913ee5a1d5cdd04106f257e5fac5d048992ffc2d9995d07b0f17739f/coverage-7.13.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:06e49c5897cb12e3f7ecdc111d44e97c4f6d0557b81a7a0204ed70a8b038f86f", size = 253393, upload-time = "2026-02-03T14:01:40.118Z" },
{ url = "https://files.pythonhosted.org/packages/f0/f6/b1f48810ffc6accf49a35b9943636560768f0812330f7456aa87dc39aff5/coverage-7.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb25061a66802df9fc13a9ba1967d25faa4dae0418db469264fd9860a921dde4", size = 254740, upload-time = "2026-02-03T14:01:42.413Z" },
{ url = "https://files.pythonhosted.org/packages/57/d0/e59c54f9be0b61808f6bc4c8c4346bd79f02dd6bbc3f476ef26124661f20/coverage-7.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:99fee45adbb1caeb914da16f70e557fb7ff6ddc9e4b14de665bd41af631367ef", size = 250905, upload-time = "2026-02-03T14:01:44.163Z" },
{ url = "https://files.pythonhosted.org/packages/d5/f7/5291bcdf498bafbee3796bb32ef6966e9915aebd4d0954123c8eae921c32/coverage-7.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:318002f1fd819bdc1651c619268aa5bc853c35fa5cc6d1e8c96bd9cd6c828b75", size = 252753, upload-time = "2026-02-03T14:01:45.974Z" },
{ url = "https://files.pythonhosted.org/packages/a0/a9/1dcafa918c281554dae6e10ece88c1add82db685be123e1b05c2056ff3fb/coverage-7.13.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:71295f2d1d170b9977dc386d46a7a1b7cbb30e5405492529b4c930113a33f895", size = 250716, upload-time = "2026-02-03T14:01:48.844Z" },
{ url = "https://files.pythonhosted.org/packages/44/bb/4ea4eabcce8c4f6235df6e059fbc5db49107b24c4bdffc44aee81aeca5a8/coverage-7.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5b1ad2e0dc672625c44bc4fe34514602a9fd8b10d52ddc414dc585f74453516c", size = 250530, upload-time = "2026-02-03T14:01:50.793Z" },
{ url = "https://files.pythonhosted.org/packages/6d/31/4a6c9e6a71367e6f923b27b528448c37f4e959b7e4029330523014691007/coverage-7.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b2beb64c145593a50d90db5c7178f55daeae129123b0d265bdb3cbec83e5194a", size = 252186, upload-time = "2026-02-03T14:01:52.607Z" },
{ url = "https://files.pythonhosted.org/packages/27/92/e1451ef6390a4f655dc42da35d9971212f7abbbcad0bdb7af4407897eb76/coverage-7.13.3-cp314-cp314-win32.whl", hash = "sha256:3d1aed4f4e837a832df2f3b4f68a690eede0de4560a2dbc214ea0bc55aabcdb4", size = 222253, upload-time = "2026-02-03T14:01:55.071Z" },
{ url = "https://files.pythonhosted.org/packages/8a/98/78885a861a88de020c32a2693487c37d15a9873372953f0c3c159d575a43/coverage-7.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9f9efbbaf79f935d5fbe3ad814825cbce4f6cdb3054384cb49f0c0f496125fa0", size = 223069, upload-time = "2026-02-03T14:01:56.95Z" },
{ url = "https://files.pythonhosted.org/packages/eb/fb/3784753a48da58a5337972abf7ca58b1fb0f1bda21bc7b4fae992fd28e47/coverage-7.13.3-cp314-cp314-win_arm64.whl", hash = "sha256:31b6e889c53d4e6687ca63706148049494aace140cffece1c4dc6acadb70a7b3", size = 221633, upload-time = "2026-02-03T14:01:58.758Z" },
{ url = "https://files.pythonhosted.org/packages/40/f9/75b732d9674d32cdbffe801ed5f770786dd1c97eecedef2125b0d25102dc/coverage-7.13.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c5e9787cec750793a19a28df7edd85ac4e49d3fb91721afcdc3b86f6c08d9aa8", size = 220243, upload-time = "2026-02-03T14:02:01.109Z" },
{ url = "https://files.pythonhosted.org/packages/cf/7e/2868ec95de5a65703e6f0c87407ea822d1feb3619600fbc3c1c4fa986090/coverage-7.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5b86db331c682fd0e4be7098e6acee5e8a293f824d41487c667a93705d415ca", size = 220515, upload-time = "2026-02-03T14:02:02.862Z" },
{ url = "https://files.pythonhosted.org/packages/7d/eb/9f0d349652fced20bcaea0f67fc5777bd097c92369f267975732f3dc5f45/coverage-7.13.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:edc7754932682d52cf6e7a71806e529ecd5ce660e630e8bd1d37109a2e5f63ba", size = 261874, upload-time = "2026-02-03T14:02:04.727Z" },
{ url = "https://files.pythonhosted.org/packages/ee/a5/6619bc4a6c7b139b16818149a3e74ab2e21599ff9a7b6811b6afde99f8ec/coverage-7.13.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d3a16d6398666510a6886f67f43d9537bfd0e13aca299688a19daa84f543122f", size = 264004, upload-time = "2026-02-03T14:02:06.634Z" },
{ url = "https://files.pythonhosted.org/packages/29/b7/90aa3fc645a50c6f07881fca4fd0ba21e3bfb6ce3a7078424ea3a35c74c9/coverage-7.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:303d38b19626c1981e1bb067a9928236d88eb0e4479b18a74812f05a82071508", size = 266408, upload-time = "2026-02-03T14:02:09.037Z" },
{ url = "https://files.pythonhosted.org/packages/62/55/08bb2a1e4dcbae384e638f0effef486ba5987b06700e481691891427d879/coverage-7.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:284e06eadfe15ddfee2f4ee56631f164ef897a7d7d5a15bca5f0bb88889fc5ba", size = 260977, upload-time = "2026-02-03T14:02:11.755Z" },
{ url = "https://files.pythonhosted.org/packages/9b/76/8bd4ae055a42d8fb5dd2230e5cf36ff2e05f85f2427e91b11a27fea52ed7/coverage-7.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d401f0864a1d3198422816878e4e84ca89ec1c1bf166ecc0ae01380a39b888cd", size = 263868, upload-time = "2026-02-03T14:02:13.565Z" },
{ url = "https://files.pythonhosted.org/packages/e3/f9/ba000560f11e9e32ec03df5aa8477242c2d95b379c99ac9a7b2e7fbacb1a/coverage-7.13.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3f379b02c18a64de78c4ccdddf1c81c2c5ae1956c72dacb9133d7dd7809794ab", size = 261474, upload-time = "2026-02-03T14:02:16.069Z" },
{ url = "https://files.pythonhosted.org/packages/90/4b/4de4de8f9ca7af4733bfcf4baa440121b7dbb3856daf8428ce91481ff63b/coverage-7.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:7a482f2da9086971efb12daca1d6547007ede3674ea06e16d7663414445c683e", size = 260317, upload-time = "2026-02-03T14:02:17.996Z" },
{ url = "https://files.pythonhosted.org/packages/05/71/5cd8436e2c21410ff70be81f738c0dddea91bcc3189b1517d26e0102ccb3/coverage-7.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:562136b0d401992118d9b49fbee5454e16f95f85b120a4226a04d816e33fe024", size = 262635, upload-time = "2026-02-03T14:02:20.405Z" },
{ url = "https://files.pythonhosted.org/packages/e7/f8/2834bb45bdd70b55a33ec354b8b5f6062fc90e5bb787e14385903a979503/coverage-7.13.3-cp314-cp314t-win32.whl", hash = "sha256:ca46e5c3be3b195098dd88711890b8011a9fa4feca942292bb84714ce5eab5d3", size = 223035, upload-time = "2026-02-03T14:02:22.323Z" },
{ url = "https://files.pythonhosted.org/packages/26/75/f8290f0073c00d9ae14056d2b84ab92dff21d5370e464cb6cb06f52bf580/coverage-7.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:06d316dbb3d9fd44cca05b2dbcfbef22948493d63a1f28e828d43e6cc505fed8", size = 224142, upload-time = "2026-02-03T14:02:24.143Z" },
{ url = "https://files.pythonhosted.org/packages/03/01/43ac78dfea8946c4a9161bbc034b5549115cb2b56781a4b574927f0d141a/coverage-7.13.3-cp314-cp314t-win_arm64.whl", hash = "sha256:299d66e9218193f9dc6e4880629ed7c4cd23486005166247c283fb98531656c3", size = 222166, upload-time = "2026-02-03T14:02:26.005Z" },
{ url = "https://files.pythonhosted.org/packages/7d/fb/70af542d2d938c778c9373ce253aa4116dbe7c0a5672f78b2b2ae0e1b94b/coverage-7.13.3-py3-none-any.whl", hash = "sha256:90a8af9dba6429b2573199622d72e0ebf024d6276f16abce394ad4d181bb0910", size = 211237, upload-time = "2026-02-03T14:02:27.986Z" },
]
[[package]]
name = "execnet"
version = "2.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
@ -30,32 +113,25 @@ dependencies = [
[package.dev-dependencies]
dev = [
{ name = "pyright" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-testmon" },
{ name = "pytest-xdist" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [{ name = "telnetlib3", directory = "../../src/telnetlib3" }]
requires-dist = [{ name = "telnetlib3", directory = "../../../../src/telnetlib3" }]
[package.metadata.requires-dev]
dev = [
{ name = "pyright" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-testmon" },
{ name = "pytest-xdist" },
{ name = "ruff" },
]
[[package]]
name = "nodeenv"
version = "1.10.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" },
]
[[package]]
name = "packaging"
version = "26.0"
@ -83,19 +159,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[package]]
name = "pyright"
version = "1.1.408"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nodeenv" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/74/b2/5db700e52554b8f025faa9c3c624c59f1f6c8841ba81ab97641b54322f16/pyright-1.1.408.tar.gz", hash = "sha256:f28f2321f96852fa50b5829ea492f6adb0e6954568d1caa3f3af3a5f555eb684", size = 4400578, upload-time = "2026-01-08T08:07:38.795Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/82/a2c93e32800940d9573fb28c346772a14778b84ba7524e691b324620ab89/pyright-1.1.408-py3-none-any.whl", hash = "sha256:090b32865f4fdb1e0e6cd82bf5618480d48eecd2eb2e70f960982a3d9a4c17c1", size = 6399144, upload-time = "2026-01-08T08:07:37.082Z" },
]
[[package]]
name = "pytest"
version = "9.0.2"
@ -125,6 +188,32 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
]
[[package]]
name = "pytest-testmon"
version = "2.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "coverage" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4d/1d/3e4230cc67cd6205bbe03c3527500c0ccaf7f0c78b436537eac71590ee4a/pytest_testmon-2.2.0.tar.gz", hash = "sha256:01f488e955ed0e0049777bee598bf1f647dd524e06f544c31a24e68f8d775a51", size = 23108, upload-time = "2025-12-01T07:30:24.76Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/61/55/ebb3c2f59fb089f08d00f764830d35780fc4e4c41dffcadafa3264682b65/pytest_testmon-2.2.0-py3-none-any.whl", hash = "sha256:2604ca44a54d61a2e830d9ce828b41a837075e4ebc1f81b148add8e90d34815b", size = 25199, upload-time = "2025-12-01T07:30:23.623Z" },
]
[[package]]
name = "pytest-xdist"
version = "3.8.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "execnet" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" },
]
[[package]]
name = "ruff"
version = "0.15.0"