Add player stats model and persistence

kills, deaths, mob_kills dict, play_time_seconds, unlocked_moves set
on Player. New player_stats SQLite table with save/load functions.
This commit is contained in:
Jared Miller 2026-02-14 11:17:15 -05:00
parent a159de9f86
commit a398227814
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
5 changed files with 327 additions and 1 deletions

View file

@ -35,6 +35,12 @@ class Player(Entity):
aliases: dict[str, str] = field(default_factory=dict) aliases: dict[str, str] = field(default_factory=dict)
_last_msdp: dict = field(default_factory=dict, repr=False) _last_msdp: dict = field(default_factory=dict, repr=False)
_power_task: asyncio.Task | None = None _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 @property
def mode(self) -> str: def mode(self) -> str:

View file

@ -58,6 +58,7 @@ from mudlib.store import (
init_db, init_db,
load_aliases, load_aliases,
load_player_data, load_player_data,
load_player_stats,
save_player, save_player,
update_last_login, update_last_login,
) )
@ -350,6 +351,17 @@ async def shell(
# Load aliases from database # Load aliases from database
player.aliases = load_aliases(player_name) 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 # Reconstruct inventory from saved data
for item_name in player_data.get("inventory", []): for item_name in player_data.get("inventory", []):
template = thing_templates.get(item_name) template = thing_templates.get(item_name)

View file

@ -25,6 +25,16 @@ class PlayerData(TypedDict):
inventory: list[str] 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 # Module-level database path
_db_path: str | None = None _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) # Migrations: add columns if they don't exist (old schemas)
cursor.execute("PRAGMA table_info(accounts)") cursor.execute("PRAGMA table_info(accounts)")
columns = [row[1] for row in cursor.fetchall()] columns = [row[1] for row in cursor.fetchall()]
@ -235,8 +256,9 @@ def save_player(player: Player) -> None:
conn.commit() conn.commit()
conn.close() conn.close()
# Save aliases # Save aliases and stats
save_aliases(player.name, player.aliases) save_aliases(player.name, player.aliases)
save_player_stats(player)
def load_player_data(name: str) -> PlayerData | None: 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() conn.close()
return result 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)),
}

112
tests/test_player_stats.py Normal file
View file

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

86
tests/test_stats_login.py Normal file
View file

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