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:
parent
a159de9f86
commit
a398227814
5 changed files with 327 additions and 1 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
}
|
||||
|
|
|
|||
112
tests/test_player_stats.py
Normal file
112
tests/test_player_stats.py
Normal 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
86
tests/test_stats_login.py
Normal 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
|
||||
Loading…
Reference in a new issue