diff --git a/src/mudlib/player.py b/src/mudlib/player.py index ebcb88e..f153a31 100644 --- a/src/mudlib/player.py +++ b/src/mudlib/player.py @@ -35,6 +35,12 @@ class Player(Entity): aliases: dict[str, str] = field(default_factory=dict) _last_msdp: dict = field(default_factory=dict, repr=False) _power_task: asyncio.Task | None = None + kills: int = 0 + deaths: int = 0 + mob_kills: dict[str, int] = field(default_factory=dict) + play_time_seconds: float = 0.0 + unlocked_moves: set[str] = field(default_factory=set) + session_start: float = 0.0 @property def mode(self) -> str: diff --git a/src/mudlib/server.py b/src/mudlib/server.py index 975c524..3b16fb7 100644 --- a/src/mudlib/server.py +++ b/src/mudlib/server.py @@ -58,6 +58,7 @@ from mudlib.store import ( init_db, load_aliases, load_player_data, + load_player_stats, save_player, update_last_login, ) @@ -350,6 +351,17 @@ async def shell( # Load aliases from database player.aliases = load_aliases(player_name) + # Load stats from database + stats = load_player_stats(player_name) + player.kills = stats["kills"] + player.deaths = stats["deaths"] + player.mob_kills = stats["mob_kills"] + player.play_time_seconds = stats["play_time_seconds"] + player.unlocked_moves = stats["unlocked_moves"] + + # Set session start time for play time tracking + player.session_start = time.monotonic() + # Reconstruct inventory from saved data for item_name in player_data.get("inventory", []): template = thing_templates.get(item_name) diff --git a/src/mudlib/store/__init__.py b/src/mudlib/store/__init__.py index 66b1622..8b8e801 100644 --- a/src/mudlib/store/__init__.py +++ b/src/mudlib/store/__init__.py @@ -25,6 +25,16 @@ class PlayerData(TypedDict): inventory: list[str] +class StatsData(TypedDict): + """Shape of persisted stats data from the database.""" + + kills: int + deaths: int + mob_kills: dict[str, int] + play_time_seconds: float + unlocked_moves: set[str] + + # Module-level database path _db_path: str | None = None @@ -71,6 +81,17 @@ def init_db(db_path: str | Path) -> None: ) """) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS player_stats ( + player_name TEXT PRIMARY KEY COLLATE NOCASE, + kills INTEGER NOT NULL DEFAULT 0, + deaths INTEGER NOT NULL DEFAULT 0, + mob_kills TEXT NOT NULL DEFAULT '{}', + play_time_seconds REAL NOT NULL DEFAULT 0.0, + unlocked_moves TEXT NOT NULL DEFAULT '[]' + ) + """) + # Migrations: add columns if they don't exist (old schemas) cursor.execute("PRAGMA table_info(accounts)") columns = [row[1] for row in cursor.fetchall()] @@ -235,8 +256,9 @@ def save_player(player: Player) -> None: conn.commit() conn.close() - # Save aliases + # Save aliases and stats save_aliases(player.name, player.aliases) + save_player_stats(player) def load_player_data(name: str) -> PlayerData | None: @@ -371,3 +393,91 @@ def load_aliases(name: str, db_path: str | Path | None = None) -> dict[str, str] conn.close() return result + + +def save_player_stats(player: Player, db_path: str | Path | None = None) -> None: + """Save player stats to the database. + + Args: + player: Player instance with stats to save + db_path: Optional explicit database path (for testing) + """ + conn = sqlite3.connect(str(db_path)) if db_path is not None else _get_connection() + + cursor = conn.cursor() + + # Serialize mob_kills as JSON dict and unlocked_moves as sorted JSON list + mob_kills_json = json.dumps(player.mob_kills) + unlocked_moves_json = json.dumps(sorted(player.unlocked_moves)) + + cursor.execute( + """ + INSERT INTO player_stats + (player_name, kills, deaths, mob_kills, play_time_seconds, unlocked_moves) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(player_name) DO UPDATE SET + kills = excluded.kills, + deaths = excluded.deaths, + mob_kills = excluded.mob_kills, + play_time_seconds = excluded.play_time_seconds, + unlocked_moves = excluded.unlocked_moves + """, + ( + player.name, + player.kills, + player.deaths, + mob_kills_json, + player.play_time_seconds, + unlocked_moves_json, + ), + ) + + conn.commit() + conn.close() + + +def load_player_stats(name: str, db_path: str | Path | None = None) -> StatsData: + """Load player stats from the database. + + Args: + name: Player name (case-insensitive) + db_path: Optional explicit database path (for testing) + + Returns: + Dictionary with stats fields, defaults if no row exists + """ + conn = sqlite3.connect(str(db_path)) if db_path is not None else _get_connection() + + cursor = conn.cursor() + + cursor.execute( + """ + SELECT kills, deaths, mob_kills, play_time_seconds, unlocked_moves + FROM player_stats + WHERE player_name = ? + """, + (name,), + ) + + result = cursor.fetchone() + conn.close() + + if result is None: + # Return defaults if no stats row exists + return { + "kills": 0, + "deaths": 0, + "mob_kills": {}, + "play_time_seconds": 0.0, + "unlocked_moves": set(), + } + + kills, deaths, mob_kills_json, play_time_seconds, unlocked_moves_json = result + + return { + "kills": kills, + "deaths": deaths, + "mob_kills": json.loads(mob_kills_json), + "play_time_seconds": play_time_seconds, + "unlocked_moves": set(json.loads(unlocked_moves_json)), + } diff --git a/tests/test_player_stats.py b/tests/test_player_stats.py new file mode 100644 index 0000000..9ba8500 --- /dev/null +++ b/tests/test_player_stats.py @@ -0,0 +1,112 @@ +"""Tests for player stats tracking and persistence.""" + +import pytest + +from mudlib.player import Player +from mudlib.store import init_db, load_player_stats, save_player_stats + + +@pytest.fixture +def db(tmp_path): + """Create a temporary test database.""" + db_path = tmp_path / "test.db" + init_db(db_path) + return db_path + + +@pytest.fixture +def player_with_stats(mock_reader, mock_writer, test_zone): + """Create a player with non-default stats.""" + p = Player(name="Ryu", x=0, y=0, reader=mock_reader, writer=mock_writer) + p.location = test_zone + p.kills = 5 + p.deaths = 2 + p.mob_kills = {"goblin": 3, "rat": 2} + p.play_time_seconds = 3600.0 + p.unlocked_moves = {"roundhouse", "sweep"} + p.session_start = 1234567890.0 + return p + + +def test_player_stats_fields_exist_with_defaults(player): + """Player stats fields exist with correct default values.""" + assert player.kills == 0 + assert player.deaths == 0 + assert player.mob_kills == {} + assert player.play_time_seconds == 0.0 + assert player.unlocked_moves == set() + assert player.session_start == 0.0 + + +def test_stats_persistence_round_trip(db, player_with_stats): + """Stats can be saved and loaded back correctly.""" + # Save stats + save_player_stats(player_with_stats, db) + + # Load stats back + stats = load_player_stats(player_with_stats.name, db) + + # Verify all values match + assert stats["kills"] == 5 + assert stats["deaths"] == 2 + assert stats["mob_kills"] == {"goblin": 3, "rat": 2} + assert stats["play_time_seconds"] == 3600.0 + assert stats["unlocked_moves"] == {"roundhouse", "sweep"} + + +def test_stats_persistence_with_fresh_player(db, player): + """Loading stats for player without stats row returns defaults.""" + # player exists in accounts but has no stats row yet + stats = load_player_stats(player.name, db) + + # Should get defaults + assert stats["kills"] == 0 + assert stats["deaths"] == 0 + assert stats["mob_kills"] == {} + assert stats["play_time_seconds"] == 0.0 + assert stats["unlocked_moves"] == set() + + +def test_stats_table_created_on_init_db(tmp_path): + """init_db creates the player_stats table.""" + import sqlite3 + + db_path = tmp_path / "test.db" + init_db(db_path) + + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + # Check table exists + cursor.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='player_stats'" + ) + result = cursor.fetchone() + conn.close() + + assert result is not None + assert result[0] == "player_stats" + + +def test_stats_update_existing_row(db, player_with_stats): + """Updating stats for existing player overwrites old values.""" + # Save initial stats + save_player_stats(player_with_stats, db) + + # Modify stats + player_with_stats.kills = 10 + player_with_stats.deaths = 5 + player_with_stats.mob_kills = {"dragon": 1} + player_with_stats.play_time_seconds = 7200.0 + player_with_stats.unlocked_moves = {"fireball"} + + # Save again + save_player_stats(player_with_stats, db) + + # Load and verify new values + stats = load_player_stats(player_with_stats.name, db) + assert stats["kills"] == 10 + assert stats["deaths"] == 5 + assert stats["mob_kills"] == {"dragon": 1} + assert stats["play_time_seconds"] == 7200.0 + assert stats["unlocked_moves"] == {"fireball"} diff --git a/tests/test_stats_login.py b/tests/test_stats_login.py new file mode 100644 index 0000000..3e2b20b --- /dev/null +++ b/tests/test_stats_login.py @@ -0,0 +1,86 @@ +"""Tests for stats loading on login and session_start initialization.""" + +import time + +import pytest + +from mudlib.player import Player, accumulate_play_time +from mudlib.store import init_db, load_player_stats, save_player + + +@pytest.fixture +def db(tmp_path): + """Create a temporary test database.""" + db_path = tmp_path / "test.db" + init_db(db_path) + return db_path + + +def test_stats_load_and_apply_round_trip(db, mock_reader, mock_writer, test_zone): + """Stats can be saved, loaded, and applied to a new player instance.""" + # Create player with known stats + p1 = Player(name="Ken", x=0, y=0, reader=mock_reader, writer=mock_writer) + p1.location = test_zone + p1.kills = 5 + p1.deaths = 2 + p1.mob_kills = {"goblin": 3} + p1.play_time_seconds = 1000.0 + p1.unlocked_moves = {"roundhouse"} + + # Save the player (which saves stats internally) + save_player(p1) + + # Load stats from database + stats = load_player_stats("Ken", db) + + # Create a new player instance and apply loaded stats + p2 = Player(name="Ken", x=0, y=0, reader=mock_reader, writer=mock_writer) + p2.location = test_zone + p2.kills = stats["kills"] + p2.deaths = stats["deaths"] + p2.mob_kills = stats["mob_kills"] + p2.play_time_seconds = stats["play_time_seconds"] + p2.unlocked_moves = stats["unlocked_moves"] + + # Verify all stats match + assert p2.kills == 5 + assert p2.deaths == 2 + assert p2.mob_kills == {"goblin": 3} + assert p2.play_time_seconds == 1000.0 + assert p2.unlocked_moves == {"roundhouse"} + + +def test_session_start_set_after_login_setup(mock_reader, mock_writer, test_zone): + """session_start is set to non-zero after login setup.""" + # Simulate login setup + p = Player(name="Ryu", x=0, y=0, reader=mock_reader, writer=mock_writer) + p.location = test_zone + + # Set session_start (this is what server.py does) + p.session_start = time.monotonic() + + # Verify it's non-zero + assert p.session_start > 0 + + +def test_accumulate_play_time_with_real_session_start( + mock_reader, mock_writer, test_zone +): + """accumulate_play_time works correctly with real session_start.""" + p = Player(name="Chun", x=0, y=0, reader=mock_reader, writer=mock_writer) + p.location = test_zone + p.play_time_seconds = 100.0 + + # Set session_start to 60 seconds ago (simulating 60 seconds of play) + p.session_start = time.monotonic() - 60.0 + + # Accumulate play time + accumulate_play_time(p) + + # Play time should have increased by approximately 60 seconds + assert p.play_time_seconds >= 159.0 + assert p.play_time_seconds <= 161.0 + + # session_start should be reset to current time + assert p.session_start > 0 + assert time.monotonic() - p.session_start < 1.0