Add inventory persistence to player saves
Inventory saved as JSON list of thing template names in an inventory column. Migration adds column to existing databases. load_player_data returns inventory list, save_player serializes Thing names from contents.
This commit is contained in:
parent
c43b3346ae
commit
6081c90ad1
6 changed files with 205 additions and 31 deletions
|
|
@ -91,6 +91,6 @@ async def cmd_inventory(player: Player, args: str) -> None:
|
||||||
await player.send("".join(lines))
|
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("drop", cmd_drop))
|
||||||
register(CommandDefinition("inventory", cmd_inventory, aliases=["i"]))
|
register(CommandDefinition("inventory", cmd_inventory, aliases=["i"]))
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,8 @@ from mudlib.store import (
|
||||||
save_player,
|
save_player,
|
||||||
update_last_login,
|
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.world.terrain import World
|
||||||
from mudlib.zone import Zone
|
from mudlib.zone import Zone
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,14 @@
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TypedDict
|
from typing import TypedDict
|
||||||
|
|
||||||
from mudlib.player import Player
|
from mudlib.player import Player
|
||||||
|
from mudlib.thing import Thing
|
||||||
|
|
||||||
|
|
||||||
class PlayerData(TypedDict):
|
class PlayerData(TypedDict):
|
||||||
|
|
@ -20,6 +22,7 @@ class PlayerData(TypedDict):
|
||||||
max_stamina: float
|
max_stamina: float
|
||||||
flying: bool
|
flying: bool
|
||||||
zone_name: str
|
zone_name: str
|
||||||
|
inventory: list[str]
|
||||||
|
|
||||||
|
|
||||||
# Module-level database path
|
# Module-level database path
|
||||||
|
|
@ -53,12 +56,13 @@ def init_db(db_path: str | Path) -> None:
|
||||||
max_stamina REAL NOT NULL DEFAULT 100.0,
|
max_stamina REAL NOT NULL DEFAULT 100.0,
|
||||||
flying INTEGER NOT NULL DEFAULT 0,
|
flying INTEGER NOT NULL DEFAULT 0,
|
||||||
zone_name TEXT NOT NULL DEFAULT 'overworld',
|
zone_name TEXT NOT NULL DEFAULT 'overworld',
|
||||||
|
inventory TEXT NOT NULL DEFAULT '[]',
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
last_login TEXT
|
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)")
|
cursor.execute("PRAGMA table_info(accounts)")
|
||||||
columns = [row[1] for row in cursor.fetchall()]
|
columns = [row[1] for row in cursor.fetchall()]
|
||||||
if "zone_name" not in columns:
|
if "zone_name" not in columns:
|
||||||
|
|
@ -66,6 +70,10 @@ def init_db(db_path: str | Path) -> None:
|
||||||
"ALTER TABLE accounts "
|
"ALTER TABLE accounts "
|
||||||
"ADD COLUMN zone_name TEXT NOT NULL DEFAULT 'overworld'"
|
"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.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
@ -188,6 +196,10 @@ def save_player(player: Player) -> None:
|
||||||
Args:
|
Args:
|
||||||
player: Player instance to save
|
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()
|
conn = _get_connection()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
|
@ -195,7 +207,7 @@ def save_player(player: Player) -> None:
|
||||||
"""
|
"""
|
||||||
UPDATE accounts
|
UPDATE accounts
|
||||||
SET x = ?, y = ?, pl = ?, stamina = ?, max_stamina = ?, flying = ?,
|
SET x = ?, y = ?, pl = ?, stamina = ?, max_stamina = ?, flying = ?,
|
||||||
zone_name = ?
|
zone_name = ?, inventory = ?
|
||||||
WHERE name = ?
|
WHERE name = ?
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
|
|
@ -206,6 +218,7 @@ def save_player(player: Player) -> None:
|
||||||
player.max_stamina,
|
player.max_stamina,
|
||||||
1 if player.flying else 0,
|
1 if player.flying else 0,
|
||||||
player.location.name if player.location else "overworld",
|
player.location.name if player.location else "overworld",
|
||||||
|
inventory_json,
|
||||||
player.name,
|
player.name,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -226,29 +239,23 @@ def load_player_data(name: str) -> PlayerData | None:
|
||||||
conn = _get_connection()
|
conn = _get_connection()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
# Check if zone_name column exists (for migration)
|
# Check which columns exist (for migration compatibility)
|
||||||
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()]
|
||||||
has_zone_name = "zone_name" in columns
|
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:
|
if has_zone_name:
|
||||||
cursor.execute(
|
select_cols += ", zone_name"
|
||||||
"""
|
if has_inventory:
|
||||||
SELECT x, y, pl, stamina, max_stamina, flying, zone_name
|
select_cols += ", inventory"
|
||||||
FROM accounts
|
|
||||||
WHERE name = ?
|
cursor.execute(
|
||||||
""",
|
f"SELECT {select_cols} FROM accounts WHERE name = ?",
|
||||||
(name,),
|
(name,),
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
cursor.execute(
|
|
||||||
"""
|
|
||||||
SELECT x, y, pl, stamina, max_stamina, flying
|
|
||||||
FROM accounts
|
|
||||||
WHERE name = ?
|
|
||||||
""",
|
|
||||||
(name,),
|
|
||||||
)
|
|
||||||
|
|
||||||
result = cursor.fetchone()
|
result = cursor.fetchone()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
@ -256,11 +263,18 @@ def load_player_data(name: str) -> PlayerData | None:
|
||||||
if result is None:
|
if result is None:
|
||||||
return 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:
|
if has_zone_name:
|
||||||
x, y, pl, stamina, max_stamina, flying_int, zone_name = result
|
zone_name = result[idx]
|
||||||
else:
|
idx += 1
|
||||||
x, y, pl, stamina, max_stamina, flying_int = result
|
|
||||||
zone_name = "overworld" # Default for old schemas
|
inventory: list[str] = []
|
||||||
|
if has_inventory:
|
||||||
|
inventory = json.loads(result[idx])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"x": x,
|
"x": x,
|
||||||
|
|
@ -270,6 +284,7 @@ def load_player_data(name: str) -> PlayerData | None:
|
||||||
"max_stamina": max_stamina,
|
"max_stamina": max_stamina,
|
||||||
"flying": bool(flying_int),
|
"flying": bool(flying_int),
|
||||||
"zone_name": zone_name,
|
"zone_name": zone_name,
|
||||||
|
"inventory": inventory,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,8 @@ from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from mudlib.commands import CommandDefinition, _registry
|
from mudlib.commands import _registry
|
||||||
from mudlib.entity import Entity
|
from mudlib.entity import Entity
|
||||||
from mudlib.object import Object
|
|
||||||
from mudlib.player import Player
|
from mudlib.player import Player
|
||||||
from mudlib.thing import Thing
|
from mudlib.thing import Thing
|
||||||
from mudlib.zone import Zone
|
from mudlib.zone import Zone
|
||||||
|
|
@ -41,7 +40,8 @@ def test_zone():
|
||||||
def player(mock_reader, mock_writer, test_zone):
|
def player(mock_reader, mock_writer, test_zone):
|
||||||
p = Player(
|
p = Player(
|
||||||
name="TestPlayer",
|
name="TestPlayer",
|
||||||
x=5, y=5,
|
x=5,
|
||||||
|
y=5,
|
||||||
reader=mock_reader,
|
reader=mock_reader,
|
||||||
writer=mock_writer,
|
writer=mock_writer,
|
||||||
location=test_zone,
|
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
|
from mudlib.commands.things import cmd_get
|
||||||
|
|
||||||
fountain = Thing(
|
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")
|
await cmd_get(player, "fountain")
|
||||||
# Fountain should still be in zone, not in player
|
# Fountain should still be in zone, not in player
|
||||||
|
|
@ -193,7 +197,9 @@ async def test_get_matches_aliases(player, test_zone):
|
||||||
can = Thing(
|
can = Thing(
|
||||||
name="pepsi can",
|
name="pepsi can",
|
||||||
aliases=["can", "pepsi"],
|
aliases=["can", "pepsi"],
|
||||||
location=test_zone, x=5, y=5,
|
location=test_zone,
|
||||||
|
x=5,
|
||||||
|
y=5,
|
||||||
)
|
)
|
||||||
await cmd_get(player, "pepsi")
|
await cmd_get(player, "pepsi")
|
||||||
assert can.location is player
|
assert can.location is player
|
||||||
|
|
|
||||||
152
tests/test_inventory_persistence.py
Normal file
152
tests/test_inventory_persistence.py
Normal file
|
|
@ -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"] == []
|
||||||
|
|
@ -5,7 +5,6 @@ from mudlib.object import Object
|
||||||
from mudlib.thing import Thing
|
from mudlib.thing import Thing
|
||||||
from mudlib.zone import Zone
|
from mudlib.zone import Zone
|
||||||
|
|
||||||
|
|
||||||
# --- construction ---
|
# --- construction ---
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue