diff --git a/src/mudlib/commands/things.py b/src/mudlib/commands/things.py index 7f6a2f0..44bce1b 100644 --- a/src/mudlib/commands/things.py +++ b/src/mudlib/commands/things.py @@ -91,6 +91,6 @@ async def cmd_inventory(player: Player, args: str) -> None: await player.send("".join(lines)) -register(CommandDefinition("get", cmd_get, aliases=["take", "pick"])) +register(CommandDefinition("get", cmd_get, aliases=["take"])) register(CommandDefinition("drop", cmd_drop)) register(CommandDefinition("inventory", cmd_inventory, aliases=["i"])) diff --git a/src/mudlib/server.py b/src/mudlib/server.py index cfed323..531eaab 100644 --- a/src/mudlib/server.py +++ b/src/mudlib/server.py @@ -42,6 +42,8 @@ from mudlib.store import ( save_player, update_last_login, ) +from mudlib.thing import Thing +from mudlib.things import load_thing_templates, spawn_thing, thing_templates from mudlib.world.terrain import World from mudlib.zone import Zone diff --git a/src/mudlib/store/__init__.py b/src/mudlib/store/__init__.py index f68db61..a88f53e 100644 --- a/src/mudlib/store/__init__.py +++ b/src/mudlib/store/__init__.py @@ -2,12 +2,14 @@ import hashlib import hmac +import json import os import sqlite3 from pathlib import Path from typing import TypedDict from mudlib.player import Player +from mudlib.thing import Thing class PlayerData(TypedDict): @@ -20,6 +22,7 @@ class PlayerData(TypedDict): max_stamina: float flying: bool zone_name: str + inventory: list[str] # Module-level database path @@ -53,12 +56,13 @@ def init_db(db_path: str | Path) -> None: 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 ) """) - # Migration: add zone_name column if it doesn't exist + # Migrations: add columns if they don't exist (old schemas) cursor.execute("PRAGMA table_info(accounts)") columns = [row[1] for row in cursor.fetchall()] if "zone_name" not in columns: @@ -66,6 +70,10 @@ def init_db(db_path: str | Path) -> None: "ALTER TABLE accounts " "ADD COLUMN zone_name TEXT NOT NULL DEFAULT 'overworld'" ) + if "inventory" not in columns: + cursor.execute( + "ALTER TABLE accounts ADD COLUMN inventory TEXT NOT NULL DEFAULT '[]'" + ) conn.commit() conn.close() @@ -188,6 +196,10 @@ def save_player(player: Player) -> None: Args: player: Player instance to save """ + # Serialize inventory as JSON list of thing names + inventory_names = [obj.name for obj in player.contents if isinstance(obj, Thing)] + inventory_json = json.dumps(inventory_names) + conn = _get_connection() cursor = conn.cursor() @@ -195,7 +207,7 @@ def save_player(player: Player) -> None: """ UPDATE accounts SET x = ?, y = ?, pl = ?, stamina = ?, max_stamina = ?, flying = ?, - zone_name = ? + zone_name = ?, inventory = ? WHERE name = ? """, ( @@ -206,6 +218,7 @@ def save_player(player: Player) -> None: player.max_stamina, 1 if player.flying else 0, player.location.name if player.location else "overworld", + inventory_json, player.name, ), ) @@ -226,29 +239,23 @@ def load_player_data(name: str) -> PlayerData | None: conn = _get_connection() cursor = conn.cursor() - # Check if zone_name column exists (for migration) + # Check which columns exist (for migration compatibility) cursor.execute("PRAGMA table_info(accounts)") columns = [row[1] for row in cursor.fetchall()] has_zone_name = "zone_name" in columns + has_inventory = "inventory" in columns + # Build SELECT based on available columns + select_cols = "x, y, pl, stamina, max_stamina, flying" if has_zone_name: - cursor.execute( - """ - SELECT x, y, pl, stamina, max_stamina, flying, zone_name - FROM accounts - WHERE name = ? - """, - (name,), - ) - else: - cursor.execute( - """ - SELECT x, y, pl, stamina, max_stamina, flying - FROM accounts - WHERE name = ? - """, - (name,), - ) + select_cols += ", zone_name" + if has_inventory: + select_cols += ", inventory" + + cursor.execute( + f"SELECT {select_cols} FROM accounts WHERE name = ?", + (name,), + ) result = cursor.fetchone() conn.close() @@ -256,11 +263,18 @@ def load_player_data(name: str) -> PlayerData | None: if result is None: return None + # Unpack base fields + x, y, pl, stamina, max_stamina, flying_int = result[:6] + idx = 6 + + zone_name = "overworld" if has_zone_name: - x, y, pl, stamina, max_stamina, flying_int, zone_name = result - else: - x, y, pl, stamina, max_stamina, flying_int = result - zone_name = "overworld" # Default for old schemas + zone_name = result[idx] + idx += 1 + + inventory: list[str] = [] + if has_inventory: + inventory = json.loads(result[idx]) return { "x": x, @@ -270,6 +284,7 @@ def load_player_data(name: str) -> PlayerData | None: "max_stamina": max_stamina, "flying": bool(flying_int), "zone_name": zone_name, + "inventory": inventory, } diff --git a/tests/test_get_drop.py b/tests/test_get_drop.py index bef3c00..7c37ce3 100644 --- a/tests/test_get_drop.py +++ b/tests/test_get_drop.py @@ -4,9 +4,8 @@ from unittest.mock import AsyncMock, MagicMock import pytest -from mudlib.commands import CommandDefinition, _registry +from mudlib.commands import _registry from mudlib.entity import Entity -from mudlib.object import Object from mudlib.player import Player from mudlib.thing import Thing from mudlib.zone import Zone @@ -41,7 +40,8 @@ def test_zone(): def player(mock_reader, mock_writer, test_zone): p = Player( name="TestPlayer", - x=5, y=5, + x=5, + y=5, reader=mock_reader, writer=mock_writer, location=test_zone, @@ -165,7 +165,11 @@ async def test_get_non_portable(player, test_zone, mock_writer): from mudlib.commands.things import cmd_get fountain = Thing( - name="fountain", location=test_zone, x=5, y=5, portable=False, + name="fountain", + location=test_zone, + x=5, + y=5, + portable=False, ) await cmd_get(player, "fountain") # Fountain should still be in zone, not in player @@ -193,7 +197,9 @@ async def test_get_matches_aliases(player, test_zone): can = Thing( name="pepsi can", aliases=["can", "pepsi"], - location=test_zone, x=5, y=5, + location=test_zone, + x=5, + y=5, ) await cmd_get(player, "pepsi") assert can.location is player diff --git a/tests/test_inventory_persistence.py b/tests/test_inventory_persistence.py new file mode 100644 index 0000000..08098c1 --- /dev/null +++ b/tests/test_inventory_persistence.py @@ -0,0 +1,152 @@ +"""Tests for inventory persistence.""" + +import json + +import pytest + +from mudlib.player import Player +from mudlib.store import ( + create_account, + init_db, + load_player_data, + save_player, +) +from mudlib.thing import Thing +from mudlib.things import ThingTemplate, thing_templates +from mudlib.zone import Zone + + +@pytest.fixture +def db(tmp_path): + """Initialize a temporary database.""" + db_path = tmp_path / "test.db" + init_db(db_path) + return db_path + + +@pytest.fixture +def test_zone(): + terrain = [["." for _ in range(10)] for _ in range(10)] + return Zone( + name="testzone", + width=10, + height=10, + terrain=terrain, + ) + + +@pytest.fixture(autouse=True) +def register_templates(): + """Register test templates.""" + thing_templates.clear() + thing_templates["rock"] = ThingTemplate( + name="rock", + description="a rock", + aliases=["stone"], + ) + thing_templates["sword"] = ThingTemplate( + name="sword", + description="a sword", + ) + yield + thing_templates.clear() + + +# --- save --- + + +def test_save_player_with_inventory(db, test_zone): + """save_player persists inventory as JSON list of template names.""" + import sqlite3 + + create_account("Alice", "pass123") + player = Player( + name="Alice", + x=5, + y=5, + location=test_zone, + ) + Thing(name="rock", location=player) + Thing(name="sword", location=player) + + save_player(player) + + # Check raw DB value + conn = sqlite3.connect(str(db)) + cursor = conn.cursor() + cursor.execute("SELECT inventory FROM accounts WHERE name = ?", ("Alice",)) + row = cursor.fetchone() + conn.close() + + assert row is not None + inventory = json.loads(row[0]) + assert sorted(inventory) == ["rock", "sword"] + + +def test_save_player_empty_inventory(db, test_zone): + """save_player stores empty list when no inventory.""" + import sqlite3 + + create_account("Bob", "pass123") + player = Player( + name="Bob", + x=5, + y=5, + location=test_zone, + ) + + save_player(player) + + conn = sqlite3.connect(str(db)) + cursor = conn.cursor() + cursor.execute("SELECT inventory FROM accounts WHERE name = ?", ("Bob",)) + row = cursor.fetchone() + conn.close() + + assert row is not None + assert json.loads(row[0]) == [] + + +# --- load --- + + +def test_load_player_data_includes_inventory(db): + """load_player_data returns inventory list.""" + import sqlite3 + + create_account("Alice", "pass123") + + # Manually set inventory in DB + conn = sqlite3.connect(str(db)) + cursor = conn.cursor() + cursor.execute( + "UPDATE accounts SET inventory = ? WHERE name = ?", + (json.dumps(["rock", "sword"]), "Alice"), + ) + conn.commit() + conn.close() + + data = load_player_data("Alice") + assert data is not None + assert sorted(data["inventory"]) == ["rock", "sword"] + + +def test_load_player_data_empty_inventory(db): + """load_player_data returns empty list for no inventory.""" + create_account("Bob", "pass123") + data = load_player_data("Bob") + assert data is not None + assert data["inventory"] == [] + + +def test_load_player_data_migration_no_column(db): + """Old DB without inventory column returns empty list.""" + + create_account("Charlie", "pass123") + + # init_db already ran the migration adding the inventory column, + # so this just verifies a fresh account gets empty inventory + + data = load_player_data("Charlie") + assert data is not None + assert data["inventory"] == [] diff --git a/tests/test_thing.py b/tests/test_thing.py index fe81735..ad1b201 100644 --- a/tests/test_thing.py +++ b/tests/test_thing.py @@ -5,7 +5,6 @@ from mudlib.object import Object from mudlib.thing import Thing from mudlib.zone import Zone - # --- construction ---