Add description and home_zone fields to player and database

This commit is contained in:
Jared Miller 2026-02-14 16:42:25 -05:00
parent 97d5173522
commit 0f3ae87f33
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
5 changed files with 369 additions and 1 deletions

View file

@ -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:

View file

@ -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

View file

@ -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,
}

View file

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

View file

@ -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