From 1349c2f8603a7fd9b1f639b01c763f1fa1830462 Mon Sep 17 00:00:00 2001 From: Jared Miller Date: Wed, 11 Feb 2026 19:33:23 -0500 Subject: [PATCH] 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. --- src/mudlib/server.py | 37 +++++++++++-------- tests/test_store.py | 84 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 14 deletions(-) diff --git a/src/mudlib/server.py b/src/mudlib/server.py index e9a57d3..3e9be20 100644 --- a/src/mudlib/server.py +++ b/src/mudlib/server.py @@ -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(): diff --git a/tests/test_store.py b/tests/test_store.py index ab309ac..1750116 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -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