From 0f3ae87f33a97d08152030ccbb00fe97683b911a Mon Sep 17 00:00:00 2001 From: Jared Miller Date: Sat, 14 Feb 2026 16:42:25 -0500 Subject: [PATCH] Add description and home_zone fields to player and database --- src/mudlib/player.py | 3 + src/mudlib/server.py | 2 + src/mudlib/store/__init__.py | 65 +++++++++- tests/test_player_fields.py | 94 +++++++++++++++ tests/test_store_description.py | 206 ++++++++++++++++++++++++++++++++ 5 files changed, 369 insertions(+), 1 deletion(-) create mode 100644 tests/test_player_fields.py create mode 100644 tests/test_store_description.py diff --git a/src/mudlib/player.py b/src/mudlib/player.py index dcb3699..fe39024 100644 --- a/src/mudlib/player.py +++ b/src/mudlib/player.py @@ -43,6 +43,9 @@ class Player(Entity): unlocked_moves: set[str] = field(default_factory=set) session_start: float = 0.0 is_admin: bool = False + description: str = "" + home_zone: str | None = None + return_location: tuple[str, int, int] | None = None @property def mode(self) -> str: diff --git a/src/mudlib/server.py b/src/mudlib/server.py index 472a8b4..38df4d0 100644 --- a/src/mudlib/server.py +++ b/src/mudlib/server.py @@ -324,6 +324,8 @@ async def shell( "flying": False, "zone_name": "overworld", "inventory": [], + "description": "", + "home_zone": None, } # Resolve zone from zone_name using zone registry diff --git a/src/mudlib/store/__init__.py b/src/mudlib/store/__init__.py index 38bf051..b926364 100644 --- a/src/mudlib/store/__init__.py +++ b/src/mudlib/store/__init__.py @@ -23,6 +23,8 @@ class PlayerData(TypedDict): flying: bool zone_name: str inventory: list[str] + description: str + home_zone: str | None class StatsData(TypedDict): @@ -104,6 +106,12 @@ def init_db(db_path: str | Path) -> None: cursor.execute( "ALTER TABLE accounts ADD COLUMN inventory TEXT NOT NULL DEFAULT '[]'" ) + if "description" not in columns: + cursor.execute( + "ALTER TABLE accounts ADD COLUMN description TEXT NOT NULL DEFAULT ''" + ) + if "home_zone" not in columns: + cursor.execute("ALTER TABLE accounts ADD COLUMN home_zone TEXT") conn.commit() conn.close() @@ -218,6 +226,40 @@ def authenticate(name: str, password: str) -> bool: return hmac.compare_digest(password_hash, stored_hash) +def save_player_description(name: str, description: str) -> None: + """Save a player's description to the database. + + Args: + name: Account name + description: Description text + """ + conn = _get_connection() + cursor = conn.cursor() + cursor.execute( + "UPDATE accounts SET description = ? WHERE name = ?", + (description, name), + ) + conn.commit() + conn.close() + + +def save_player_home_zone(name: str, home_zone: str | None) -> None: + """Save a player's home zone to the database. + + Args: + name: Account name + home_zone: Home zone name + """ + conn = _get_connection() + cursor = conn.cursor() + cursor.execute( + "UPDATE accounts SET home_zone = ? WHERE name = ?", + (home_zone, name), + ) + conn.commit() + conn.close() + + def save_player(player: Player) -> None: """Save player state to the database. @@ -242,7 +284,7 @@ def save_player(player: Player) -> None: """ UPDATE accounts SET x = ?, y = ?, pl = ?, stamina = ?, max_stamina = ?, flying = ?, - zone_name = ?, inventory = ? + zone_name = ?, inventory = ?, description = ?, home_zone = ? WHERE name = ? """, ( @@ -254,6 +296,8 @@ def save_player(player: Player) -> None: 1 if player.flying else 0, player.location.name if player.location else "overworld", inventory_json, + player.description, + player.home_zone, player.name, ), ) @@ -283,6 +327,8 @@ def load_player_data(name: str) -> PlayerData | None: columns = [row[1] for row in cursor.fetchall()] has_zone_name = "zone_name" in columns has_inventory = "inventory" in columns + has_description = "description" in columns + has_home_zone = "home_zone" in columns # Build SELECT based on available columns select_cols = "x, y, pl, stamina, max_stamina, flying" @@ -290,6 +336,10 @@ def load_player_data(name: str) -> PlayerData | None: select_cols += ", zone_name" if has_inventory: select_cols += ", inventory" + if has_description: + select_cols += ", description" + if has_home_zone: + select_cols += ", home_zone" cursor.execute( f"SELECT {select_cols} FROM accounts WHERE name = ?", @@ -314,6 +364,17 @@ def load_player_data(name: str) -> PlayerData | None: inventory: list[str] = [] if has_inventory: inventory = json.loads(result[idx]) + idx += 1 + + description = "" + if has_description: + description = result[idx] + idx += 1 + + home_zone = None + if has_home_zone: + home_zone = result[idx] + idx += 1 return { "x": x, @@ -324,6 +385,8 @@ def load_player_data(name: str) -> PlayerData | None: "flying": bool(flying_int), "zone_name": zone_name, "inventory": inventory, + "description": description, + "home_zone": home_zone, } diff --git a/tests/test_player_fields.py b/tests/test_player_fields.py new file mode 100644 index 0000000..5bf8667 --- /dev/null +++ b/tests/test_player_fields.py @@ -0,0 +1,94 @@ +"""Tests for player description and home_zone fields.""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from mudlib.player import Player + + +@pytest.fixture +def mock_writer(): + writer = MagicMock() + writer.write = MagicMock() + writer.drain = AsyncMock() + return writer + + +@pytest.fixture +def mock_reader(): + return MagicMock() + + +def test_player_default_description(mock_reader, mock_writer): + """Test that Player has default empty description.""" + player = Player(name="Hero", x=0, y=0, reader=mock_reader, writer=mock_writer) + assert player.description == "" + + +def test_player_default_home_zone(mock_reader, mock_writer): + """Test that Player has default None home_zone.""" + player = Player(name="Hero", x=0, y=0, reader=mock_reader, writer=mock_writer) + assert player.home_zone is None + + +def test_player_default_return_location(mock_reader, mock_writer): + """Test that Player has default None return_location.""" + player = Player(name="Hero", x=0, y=0, reader=mock_reader, writer=mock_writer) + assert player.return_location is None + + +def test_player_custom_description(mock_reader, mock_writer): + """Test that Player can have custom description.""" + player = Player( + name="Hero", + x=0, + y=0, + reader=mock_reader, + writer=mock_writer, + description="A brave adventurer", + ) + assert player.description == "A brave adventurer" + + +def test_player_custom_home_zone(mock_reader, mock_writer): + """Test that Player can have custom home_zone.""" + player = Player( + name="Hero", + x=0, + y=0, + reader=mock_reader, + writer=mock_writer, + home_zone="residential", + ) + assert player.home_zone == "residential" + + +def test_player_custom_return_location(mock_reader, mock_writer): + """Test that Player can have custom return_location.""" + player = Player( + name="Hero", + x=0, + y=0, + reader=mock_reader, + writer=mock_writer, + return_location=("residential", 10, 20), + ) + assert player.return_location == ("residential", 10, 20) + + +def test_player_all_housing_fields(mock_reader, mock_writer): + """Test that Player can have all housing fields set together.""" + player = Player( + name="Hero", + x=0, + y=0, + reader=mock_reader, + writer=mock_writer, + description="A homeowner", + home_zone="residential", + return_location=("residential", 5, 15), + ) + assert player.description == "A homeowner" + assert player.home_zone == "residential" + assert player.return_location == ("residential", 5, 15) diff --git a/tests/test_store_description.py b/tests/test_store_description.py new file mode 100644 index 0000000..2434892 --- /dev/null +++ b/tests/test_store_description.py @@ -0,0 +1,206 @@ +"""Tests for player description and home_zone persistence.""" + +import os +import tempfile + +import pytest + +from mudlib.player import Player +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) + + +def test_init_db_creates_description_column(temp_db): + """init_db creates description column.""" + import sqlite3 + + conn = sqlite3.connect(temp_db) + cursor = conn.cursor() + cursor.execute("PRAGMA table_info(accounts)") + columns = [row[1] for row in cursor.fetchall()] + conn.close() + + assert "description" in columns + + +def test_init_db_creates_home_zone_column(temp_db): + """init_db creates home_zone column.""" + import sqlite3 + + conn = sqlite3.connect(temp_db) + cursor = conn.cursor() + cursor.execute("PRAGMA table_info(accounts)") + columns = [row[1] for row in cursor.fetchall()] + conn.close() + + assert "home_zone" in columns + + +def test_default_description(temp_db): + """New accounts have description default to empty string.""" + create_account("Alice", "password123") + data = load_player_data("Alice") + + assert data is not None + assert data["description"] == "" + + +def test_default_home_zone(temp_db): + """New accounts have home_zone default to None.""" + create_account("Bob", "password123") + data = load_player_data("Bob") + + assert data is not None + assert data["home_zone"] is None + + +def test_save_and_load_description(temp_db): + """save_player and load_player_data persist description.""" + create_account("Charlie", "password123") + + player = Player(name="Charlie", x=0, y=0, description="A brave warrior") + save_player(player) + + data = load_player_data("Charlie") + assert data is not None + assert data["description"] == "A brave warrior" + + +def test_save_and_load_home_zone(temp_db): + """save_player and load_player_data persist home_zone.""" + create_account("Diana", "password123") + + player = Player(name="Diana", x=0, y=0, home_zone="residential") + save_player(player) + + data = load_player_data("Diana") + assert data is not None + assert data["home_zone"] == "residential" + + +def test_save_and_load_both_fields(temp_db): + """save_player and load_player_data persist both description and home_zone.""" + create_account("Eve", "password123") + + player = Player( + name="Eve", + x=10, + y=20, + description="A skilled mage", + home_zone="wizard_tower", + ) + save_player(player) + + data = load_player_data("Eve") + assert data is not None + assert data["description"] == "A skilled mage" + assert data["home_zone"] == "wizard_tower" + + +def test_update_description(temp_db): + """save_player updates existing description.""" + create_account("Frank", "password123") + + player = Player(name="Frank", x=0, y=0, description="A novice") + save_player(player) + + player.description = "An experienced adventurer" + save_player(player) + + data = load_player_data("Frank") + assert data is not None + assert data["description"] == "An experienced adventurer" + + +def test_update_home_zone(temp_db): + """save_player updates existing home_zone.""" + create_account("Grace", "password123") + + player = Player(name="Grace", x=0, y=0, home_zone=None) + save_player(player) + + player.home_zone = "residential" + save_player(player) + + data = load_player_data("Grace") + assert data is not None + assert data["home_zone"] == "residential" + + +def test_clear_home_zone(temp_db): + """save_player can clear home_zone back to None.""" + create_account("Henry", "password123") + + player = Player(name="Henry", x=0, y=0, home_zone="residential") + save_player(player) + + player.home_zone = None + save_player(player) + + data = load_player_data("Henry") + assert data is not None + assert data["home_zone"] is None + + +def test_migration_from_old_schema(temp_db): + """Loading from DB without description/home_zone columns works.""" + import sqlite3 + + # Create account first + create_account("Iris", "password123") + + # Simulate old DB by removing the new columns + conn = sqlite3.connect(temp_db) + cursor = conn.cursor() + + # Backup and recreate without new columns + cursor.execute(""" + CREATE TABLE accounts_backup AS + SELECT name, password_hash, salt, x, y, pl, stamina, + max_stamina, flying, zone_name, inventory, created_at, last_login + FROM accounts + """) + cursor.execute("DROP TABLE accounts") + cursor.execute(""" + CREATE TABLE 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, + zone_name TEXT NOT NULL DEFAULT 'overworld', + inventory TEXT NOT NULL DEFAULT '[]', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + last_login TEXT + ) + """) + cursor.execute(""" + INSERT INTO accounts + SELECT * FROM accounts_backup + """) + cursor.execute("DROP TABLE accounts_backup") + conn.commit() + conn.close() + + # Should still load with default values + data = load_player_data("Iris") + assert data is not None + assert data["description"] == "" + assert data["home_zone"] is None