Add zone_name to persistence schema

Adds zone_name field to PlayerData and accounts table to track which
zone a player is in. Defaults to 'overworld'. Includes migration logic
to handle existing databases without the column. Server now resolves
zone from zone_name when loading player data.
This commit is contained in:
Jared Miller 2026-02-11 19:33:23 -05:00
parent 875ded5762
commit 1349c2f860
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
2 changed files with 107 additions and 14 deletions

View file

@ -263,21 +263,35 @@ async def shell(
"stamina": 100.0,
"max_stamina": 100.0,
"flying": False,
"zone_name": "overworld",
}
# Resolve zone from zone_name (currently only overworld exists)
zone_name = player_data.get("zone_name", "overworld")
if zone_name == "overworld":
player_zone = _overworld
else:
# Existing player - verify spawn position is still passable
if not _overworld.is_passable(player_data["x"], player_data["y"]):
# Saved position is no longer passable, find a new one
start_x, start_y = find_passable_start(
_overworld, player_data["x"], player_data["y"]
)
player_data["x"] = start_x
player_data["y"] = start_y
# Future: lookup zone by name from a zone registry
log.warning(
"unknown zone '%s' for player '%s', defaulting to overworld",
zone_name,
player_name,
)
player_zone = _overworld
# Verify spawn position is still passable
if not player_zone.is_passable(player_data["x"], player_data["y"]):
# Saved position is no longer passable, find a new one
start_x, start_y = find_passable_start(
player_zone, player_data["x"], player_data["y"]
)
player_data["x"] = start_x
player_data["y"] = start_y
# Create player instance
player = Player(
name=player_name,
location=_overworld,
location=player_zone,
x=player_data["x"],
y=player_data["y"],
pl=player_data["pl"],
@ -428,11 +442,6 @@ async def run_server() -> None:
)
log.info("created overworld zone (%dx%d, toroidal)", _world.width, _world.height)
# Inject world into command modules
mudlib.commands.fly.world = _world
mudlib.commands.look.world = _world
mudlib.combat.commands.world = _world
# Load content-defined commands from TOML files
content_dir = pathlib.Path(__file__).resolve().parents[2] / "content" / "commands"
if content_dir.exists():

View file

@ -15,6 +15,7 @@ from mudlib.store import (
load_player_data,
save_player,
)
from mudlib.zone import Zone
@pytest.fixture
@ -177,3 +178,86 @@ def test_password_hashing_different_salts(temp_db):
# This just verifies the API works correctly - we can't easily check
# the hashes are different without exposing internal details
def test_save_and_load_zone_name(temp_db):
"""save_player and load_player_data persist zone_name."""
create_account("Maria", "password123")
# Create a zone and player
zone = Zone(name="testzone", width=100, height=100)
player = Player(
name="Maria",
location=zone,
x=10,
y=20,
)
# Save and load
save_player(player)
data = load_player_data("Maria")
assert data is not None
assert data["zone_name"] == "testzone"
def test_default_zone_name(temp_db):
"""New accounts have zone_name default to 'overworld'."""
create_account("Noah", "password123")
data = load_player_data("Noah")
assert data is not None
assert data["zone_name"] == "overworld"
def test_zone_name_migration(temp_db):
"""Existing DB without zone_name column still works."""
# Create account, which will create default schema
create_account("Olivia", "password123")
# Simulate old DB by manually removing the zone_name column
import sqlite3
conn = sqlite3.connect(temp_db)
cursor = conn.cursor()
# Check if column exists and remove it
cursor.execute("PRAGMA table_info(accounts)")
columns = [row[1] for row in cursor.fetchall()]
if "zone_name" in columns:
# SQLite doesn't support DROP COLUMN directly, so recreate table
cursor.execute("""
CREATE TABLE accounts_backup AS
SELECT name, password_hash, salt, x, y, pl, stamina,
max_stamina, flying, created_at, last_login
FROM accounts
""")
cursor.execute("DROP TABLE accounts")
cursor.execute("""
CREATE TABLE accounts (
name TEXT PRIMARY KEY COLLATE NOCASE,
password_hash TEXT NOT NULL,
salt TEXT NOT NULL,
x INTEGER NOT NULL DEFAULT 0,
y INTEGER NOT NULL DEFAULT 0,
pl REAL NOT NULL DEFAULT 100.0,
stamina REAL NOT NULL DEFAULT 100.0,
max_stamina REAL NOT NULL DEFAULT 100.0,
flying INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_login TEXT
)
""")
cursor.execute("""
INSERT INTO accounts
SELECT * FROM accounts_backup
""")
cursor.execute("DROP TABLE accounts_backup")
conn.commit()
conn.close()
# Now try to load player data - should handle missing column gracefully
data = load_player_data("Olivia")
assert data is not None
assert data["zone_name"] == "overworld" # Should default