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:
Jared Miller 2026-02-11 20:02:40 -05:00
parent c43b3346ae
commit 6081c90ad1
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
6 changed files with 205 additions and 31 deletions

View file

@ -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"]))

View file

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

View file

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

View file

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

View 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"] == []

View file

@ -5,7 +5,6 @@ from mudlib.object import Object
from mudlib.thing import Thing
from mudlib.zone import Zone
# --- construction ---