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:
parent
875ded5762
commit
1349c2f860
2 changed files with 107 additions and 14 deletions
|
|
@ -263,21 +263,35 @@ async def shell(
|
||||||
"stamina": 100.0,
|
"stamina": 100.0,
|
||||||
"max_stamina": 100.0,
|
"max_stamina": 100.0,
|
||||||
"flying": False,
|
"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:
|
else:
|
||||||
# Existing player - verify spawn position is still passable
|
# Future: lookup zone by name from a zone registry
|
||||||
if not _overworld.is_passable(player_data["x"], player_data["y"]):
|
log.warning(
|
||||||
# Saved position is no longer passable, find a new one
|
"unknown zone '%s' for player '%s', defaulting to overworld",
|
||||||
start_x, start_y = find_passable_start(
|
zone_name,
|
||||||
_overworld, player_data["x"], player_data["y"]
|
player_name,
|
||||||
)
|
)
|
||||||
player_data["x"] = start_x
|
player_zone = _overworld
|
||||||
player_data["y"] = start_y
|
|
||||||
|
# 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
|
# Create player instance
|
||||||
player = Player(
|
player = Player(
|
||||||
name=player_name,
|
name=player_name,
|
||||||
location=_overworld,
|
location=player_zone,
|
||||||
x=player_data["x"],
|
x=player_data["x"],
|
||||||
y=player_data["y"],
|
y=player_data["y"],
|
||||||
pl=player_data["pl"],
|
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)
|
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
|
# Load content-defined commands from TOML files
|
||||||
content_dir = pathlib.Path(__file__).resolve().parents[2] / "content" / "commands"
|
content_dir = pathlib.Path(__file__).resolve().parents[2] / "content" / "commands"
|
||||||
if content_dir.exists():
|
if content_dir.exists():
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ from mudlib.store import (
|
||||||
load_player_data,
|
load_player_data,
|
||||||
save_player,
|
save_player,
|
||||||
)
|
)
|
||||||
|
from mudlib.zone import Zone
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@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
|
# This just verifies the API works correctly - we can't easily check
|
||||||
# the hashes are different without exposing internal details
|
# 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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue