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

View file

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

View file

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

View file

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

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.thing import Thing
from mudlib.zone import Zone from mudlib.zone import Zone
# --- construction --- # --- construction ---