Add description and home_zone fields to player and database
This commit is contained in:
parent
97d5173522
commit
0f3ae87f33
5 changed files with 369 additions and 1 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
94
tests/test_player_fields.py
Normal file
94
tests/test_player_fields.py
Normal 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)
|
||||
206
tests/test_store_description.py
Normal file
206
tests/test_store_description.py
Normal 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
|
||||
Loading…
Reference in a new issue