Add player housing zone creation and persistence
This commit is contained in:
parent
1c22530be7
commit
9fac18ad2b
2 changed files with 449 additions and 0 deletions
181
src/mudlib/housing.py
Normal file
181
src/mudlib/housing.py
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
"""Player housing — personal zones."""
|
||||
|
||||
import logging
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
from mudlib.zone import Zone
|
||||
from mudlib.zones import get_zone, register_zone
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Default home zone dimensions
|
||||
HOME_WIDTH = 9
|
||||
HOME_HEIGHT = 9
|
||||
HOME_SPAWN_X = 4
|
||||
HOME_SPAWN_Y = 4
|
||||
|
||||
# Where player zone files live
|
||||
_zones_dir: Path | None = None
|
||||
|
||||
|
||||
def init_housing(zones_dir: Path) -> None:
|
||||
"""Set the directory for player zone files."""
|
||||
global _zones_dir
|
||||
_zones_dir = zones_dir
|
||||
_zones_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def _home_zone_name(player_name: str) -> str:
|
||||
"""Return the zone registry name for a player's home."""
|
||||
return f"home:{player_name.lower()}"
|
||||
|
||||
|
||||
def _zone_path(player_name: str) -> Path:
|
||||
"""Return the TOML file path for a player's home zone."""
|
||||
if _zones_dir is None:
|
||||
raise RuntimeError("Call init_housing() first")
|
||||
return _zones_dir / f"{player_name.lower()}.toml"
|
||||
|
||||
|
||||
def create_home_zone(player_name: str) -> Zone:
|
||||
"""Create a default home zone for a player.
|
||||
|
||||
Creates the zone, registers it, and saves it to disk.
|
||||
|
||||
Args:
|
||||
player_name: The player's name
|
||||
|
||||
Returns:
|
||||
The newly created Zone
|
||||
"""
|
||||
zone_name = _home_zone_name(player_name)
|
||||
|
||||
# Build terrain — simple grass field with a border
|
||||
terrain = []
|
||||
for y in range(HOME_HEIGHT):
|
||||
row = []
|
||||
for x in range(HOME_WIDTH):
|
||||
if x == 0 or x == HOME_WIDTH - 1 or y == 0 or y == HOME_HEIGHT - 1:
|
||||
row.append("#") # wall border
|
||||
else:
|
||||
row.append(".") # open grass
|
||||
terrain.append(row)
|
||||
|
||||
zone = Zone(
|
||||
name=zone_name,
|
||||
description=f"{player_name}'s home",
|
||||
width=HOME_WIDTH,
|
||||
height=HOME_HEIGHT,
|
||||
toroidal=False,
|
||||
terrain=terrain,
|
||||
impassable={"#", "^", "~"},
|
||||
spawn_x=HOME_SPAWN_X,
|
||||
spawn_y=HOME_SPAWN_Y,
|
||||
safe=True,
|
||||
)
|
||||
|
||||
register_zone(zone_name, zone)
|
||||
save_home_zone(player_name, zone)
|
||||
|
||||
return zone
|
||||
|
||||
|
||||
def save_home_zone(player_name: str, zone: Zone) -> None:
|
||||
"""Save a player's home zone to TOML.
|
||||
|
||||
Args:
|
||||
player_name: The player's name
|
||||
zone: The zone to save
|
||||
"""
|
||||
path = _zone_path(player_name)
|
||||
|
||||
# Build TOML content
|
||||
lines = []
|
||||
escaped_name = zone.name.replace("\\", "\\\\").replace('"', '\\"')
|
||||
escaped_desc = zone.description.replace("\\", "\\\\").replace('"', '\\"')
|
||||
lines.append(f'name = "{escaped_name}"')
|
||||
lines.append(f'description = "{escaped_desc}"')
|
||||
lines.append(f"width = {zone.width}")
|
||||
lines.append(f"height = {zone.height}")
|
||||
lines.append(f"toroidal = {'true' if zone.toroidal else 'false'}")
|
||||
lines.append(f"spawn_x = {zone.spawn_x}")
|
||||
lines.append(f"spawn_y = {zone.spawn_y}")
|
||||
lines.append(f"safe = {'true' if zone.safe else 'false'}")
|
||||
lines.append("")
|
||||
lines.append("[terrain]")
|
||||
lines.append("rows = [")
|
||||
for row in zone.terrain:
|
||||
lines.append(f' "{"".join(row)}",')
|
||||
lines.append("]")
|
||||
lines.append("")
|
||||
lines.append("[terrain.impassable]")
|
||||
tiles = ", ".join(f'"{t}"' for t in sorted(zone.impassable))
|
||||
lines.append(f"tiles = [{tiles}]")
|
||||
lines.append("")
|
||||
|
||||
path.write_text("\n".join(lines))
|
||||
|
||||
|
||||
def load_home_zone(player_name: str) -> Zone | None:
|
||||
"""Load a player's home zone from disk if it exists.
|
||||
|
||||
Also registers it in the zone registry.
|
||||
|
||||
Args:
|
||||
player_name: The player's name
|
||||
|
||||
Returns:
|
||||
The Zone if it exists on disk, None otherwise
|
||||
"""
|
||||
path = _zone_path(player_name)
|
||||
if not path.exists():
|
||||
return None
|
||||
|
||||
with open(path, "rb") as f:
|
||||
data = tomllib.load(f)
|
||||
|
||||
terrain = [list(row) for row in data.get("terrain", {}).get("rows", [])]
|
||||
impassable_list = data.get("terrain", {}).get("impassable", {}).get("tiles", [])
|
||||
impassable = set(impassable_list) if impassable_list else {"#", "^", "~"}
|
||||
|
||||
zone = Zone(
|
||||
name=data["name"],
|
||||
description=data.get("description", ""),
|
||||
width=data["width"],
|
||||
height=data["height"],
|
||||
toroidal=data.get("toroidal", False),
|
||||
terrain=terrain,
|
||||
impassable=impassable,
|
||||
spawn_x=data.get("spawn_x", 0),
|
||||
spawn_y=data.get("spawn_y", 0),
|
||||
safe=data.get("safe", True),
|
||||
)
|
||||
|
||||
register_zone(zone.name, zone)
|
||||
return zone
|
||||
|
||||
|
||||
def get_or_create_home(player_name: str) -> Zone:
|
||||
"""Get the player's home zone, creating it if it doesn't exist.
|
||||
|
||||
Args:
|
||||
player_name: The player's name
|
||||
|
||||
Returns:
|
||||
The player's home Zone
|
||||
"""
|
||||
zone_name = _home_zone_name(player_name)
|
||||
|
||||
# Check registry first
|
||||
zone = get_zone(zone_name)
|
||||
if zone is not None:
|
||||
return zone
|
||||
|
||||
# Try loading from disk
|
||||
zone = load_home_zone(player_name)
|
||||
if zone is not None:
|
||||
return zone
|
||||
|
||||
# Create new
|
||||
return create_home_zone(player_name)
|
||||
268
tests/test_housing.py
Normal file
268
tests/test_housing.py
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
"""Tests for player housing system."""
|
||||
|
||||
import tomllib
|
||||
|
||||
import pytest
|
||||
|
||||
from mudlib.housing import (
|
||||
HOME_HEIGHT,
|
||||
HOME_SPAWN_X,
|
||||
HOME_SPAWN_Y,
|
||||
HOME_WIDTH,
|
||||
_home_zone_name,
|
||||
create_home_zone,
|
||||
get_or_create_home,
|
||||
init_housing,
|
||||
load_home_zone,
|
||||
save_home_zone,
|
||||
)
|
||||
from mudlib.zones import get_zone, zone_registry
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_zone_registry():
|
||||
"""Clear zone registry between tests."""
|
||||
saved = dict(zone_registry)
|
||||
zone_registry.clear()
|
||||
yield
|
||||
zone_registry.clear()
|
||||
zone_registry.update(saved)
|
||||
|
||||
|
||||
def test_init_housing_creates_directory(tmp_path):
|
||||
"""init_housing() creates the zones directory."""
|
||||
zones_dir = tmp_path / "zones"
|
||||
assert not zones_dir.exists()
|
||||
|
||||
init_housing(zones_dir)
|
||||
|
||||
assert zones_dir.exists()
|
||||
assert zones_dir.is_dir()
|
||||
|
||||
|
||||
def test_home_zone_name():
|
||||
"""_home_zone_name() returns correct format."""
|
||||
assert _home_zone_name("Alice") == "home:alice"
|
||||
assert _home_zone_name("bob") == "home:bob"
|
||||
assert _home_zone_name("Charlie") == "home:charlie"
|
||||
|
||||
|
||||
def test_create_home_zone(tmp_path):
|
||||
"""create_home_zone() creates a zone with correct properties."""
|
||||
init_housing(tmp_path)
|
||||
|
||||
zone = create_home_zone("Alice")
|
||||
|
||||
# Check basic properties
|
||||
assert zone.name == "home:alice"
|
||||
assert zone.description == "Alice's home"
|
||||
assert zone.width == HOME_WIDTH
|
||||
assert zone.height == HOME_HEIGHT
|
||||
assert zone.toroidal is False
|
||||
assert zone.spawn_x == HOME_SPAWN_X
|
||||
assert zone.spawn_y == HOME_SPAWN_Y
|
||||
assert zone.safe is True
|
||||
assert zone.impassable == {"#", "^", "~"}
|
||||
|
||||
# Check terrain dimensions
|
||||
assert len(zone.terrain) == HOME_HEIGHT
|
||||
assert all(len(row) == HOME_WIDTH for row in zone.terrain)
|
||||
|
||||
# Check border is walls
|
||||
for x in range(HOME_WIDTH):
|
||||
assert zone.terrain[0][x] == "#" # top
|
||||
assert zone.terrain[HOME_HEIGHT - 1][x] == "#" # bottom
|
||||
for y in range(HOME_HEIGHT):
|
||||
assert zone.terrain[y][0] == "#" # left
|
||||
assert zone.terrain[y][HOME_WIDTH - 1] == "#" # right
|
||||
|
||||
# Check interior is grass
|
||||
for y in range(1, HOME_HEIGHT - 1):
|
||||
for x in range(1, HOME_WIDTH - 1):
|
||||
assert zone.terrain[y][x] == "."
|
||||
|
||||
# Check spawn point is passable
|
||||
assert zone.terrain[HOME_SPAWN_Y][HOME_SPAWN_X] == "."
|
||||
|
||||
|
||||
def test_create_registers_zone(tmp_path):
|
||||
"""create_home_zone() registers the zone."""
|
||||
init_housing(tmp_path)
|
||||
|
||||
zone = create_home_zone("Bob")
|
||||
|
||||
registered = get_zone("home:bob")
|
||||
assert registered is zone
|
||||
|
||||
|
||||
def test_save_home_zone(tmp_path):
|
||||
"""save_home_zone() writes a valid TOML file."""
|
||||
init_housing(tmp_path)
|
||||
|
||||
create_home_zone("Charlie")
|
||||
zone_file = tmp_path / "charlie.toml"
|
||||
|
||||
assert zone_file.exists()
|
||||
|
||||
# Verify TOML is valid and contains expected data
|
||||
with open(zone_file, "rb") as f:
|
||||
data = tomllib.load(f)
|
||||
|
||||
assert data["name"] == "home:charlie"
|
||||
assert data["description"] == "Charlie's home"
|
||||
assert data["width"] == HOME_WIDTH
|
||||
assert data["height"] == HOME_HEIGHT
|
||||
assert data["toroidal"] is False
|
||||
assert data["spawn_x"] == HOME_SPAWN_X
|
||||
assert data["spawn_y"] == HOME_SPAWN_Y
|
||||
assert data["safe"] is True
|
||||
|
||||
# Check terrain
|
||||
rows = data["terrain"]["rows"]
|
||||
assert len(rows) == HOME_HEIGHT
|
||||
assert all(len(row) == HOME_WIDTH for row in rows)
|
||||
|
||||
# Check impassable
|
||||
assert set(data["terrain"]["impassable"]["tiles"]) == {"#", "^", "~"}
|
||||
|
||||
|
||||
def test_load_home_zone(tmp_path):
|
||||
"""load_home_zone() reads a zone from disk."""
|
||||
init_housing(tmp_path)
|
||||
|
||||
# Create and save a zone
|
||||
_ = create_home_zone("Dave")
|
||||
|
||||
# Clear registry
|
||||
zone_registry.clear()
|
||||
|
||||
# Load it back
|
||||
loaded = load_home_zone("Dave")
|
||||
|
||||
assert loaded is not None
|
||||
assert loaded.name == "home:dave"
|
||||
assert loaded.description == "Dave's home"
|
||||
assert loaded.width == HOME_WIDTH
|
||||
assert loaded.height == HOME_HEIGHT
|
||||
assert loaded.toroidal is False
|
||||
assert loaded.spawn_x == HOME_SPAWN_X
|
||||
assert loaded.spawn_y == HOME_SPAWN_Y
|
||||
assert loaded.safe is True
|
||||
assert loaded.impassable == {"#", "^", "~"}
|
||||
|
||||
|
||||
def test_load_registers_zone(tmp_path):
|
||||
"""load_home_zone() registers the zone."""
|
||||
init_housing(tmp_path)
|
||||
|
||||
create_home_zone("Eve")
|
||||
zone_registry.clear()
|
||||
|
||||
loaded = load_home_zone("Eve")
|
||||
|
||||
registered = get_zone("home:eve")
|
||||
assert registered is loaded
|
||||
|
||||
|
||||
def test_load_nonexistent_returns_none(tmp_path):
|
||||
"""load_home_zone() returns None if file doesn't exist."""
|
||||
init_housing(tmp_path)
|
||||
|
||||
result = load_home_zone("Nobody")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_round_trip(tmp_path):
|
||||
"""Create -> save -> load produces equivalent zone."""
|
||||
init_housing(tmp_path)
|
||||
|
||||
original = create_home_zone("Frank")
|
||||
zone_registry.clear()
|
||||
|
||||
loaded = load_home_zone("Frank")
|
||||
assert loaded is not None
|
||||
|
||||
# Compare all fields
|
||||
assert loaded.name == original.name
|
||||
assert loaded.description == original.description
|
||||
assert loaded.width == original.width
|
||||
assert loaded.height == original.height
|
||||
assert loaded.toroidal == original.toroidal
|
||||
assert loaded.spawn_x == original.spawn_x
|
||||
assert loaded.spawn_y == original.spawn_y
|
||||
assert loaded.safe == original.safe
|
||||
assert loaded.impassable == original.impassable
|
||||
|
||||
# Compare terrain
|
||||
assert len(loaded.terrain) == len(original.terrain)
|
||||
for loaded_row, orig_row in zip(loaded.terrain, original.terrain, strict=True):
|
||||
assert loaded_row == orig_row
|
||||
|
||||
|
||||
def test_get_or_create_home_creates_new(tmp_path):
|
||||
"""get_or_create_home() creates a zone on first call."""
|
||||
init_housing(tmp_path)
|
||||
|
||||
zone = get_or_create_home("Grace")
|
||||
|
||||
assert zone.name == "home:grace"
|
||||
assert zone.description == "Grace's home"
|
||||
|
||||
|
||||
def test_get_or_create_home_returns_existing_from_registry(tmp_path):
|
||||
"""get_or_create_home() returns existing zone from registry."""
|
||||
init_housing(tmp_path)
|
||||
|
||||
first = get_or_create_home("Hank")
|
||||
second = get_or_create_home("Hank")
|
||||
|
||||
assert second is first
|
||||
|
||||
|
||||
def test_get_or_create_home_loads_from_disk(tmp_path):
|
||||
"""get_or_create_home() loads from disk if not in registry."""
|
||||
init_housing(tmp_path)
|
||||
|
||||
create_home_zone("Iris")
|
||||
zone_registry.clear()
|
||||
|
||||
loaded = get_or_create_home("Iris")
|
||||
|
||||
assert loaded.name == "home:iris"
|
||||
|
||||
|
||||
def test_case_insensitive_zone_names(tmp_path):
|
||||
"""Zone names are lowercased consistently."""
|
||||
init_housing(tmp_path)
|
||||
|
||||
zone1 = create_home_zone("JACK")
|
||||
zone2 = create_home_zone("Jack")
|
||||
zone3 = create_home_zone("jack")
|
||||
|
||||
# All should reference the same zone name
|
||||
assert zone1.name == "home:jack"
|
||||
assert zone2.name == "home:jack"
|
||||
assert zone3.name == "home:jack"
|
||||
|
||||
|
||||
def test_save_preserves_modifications(tmp_path):
|
||||
"""save_home_zone() preserves modifications to terrain."""
|
||||
init_housing(tmp_path)
|
||||
|
||||
zone = create_home_zone("Kate")
|
||||
|
||||
# Modify terrain
|
||||
zone.terrain[2][2] = "~" # add water
|
||||
zone.terrain[3][3] = "^" # add mountain
|
||||
|
||||
# Save modifications
|
||||
save_home_zone("Kate", zone)
|
||||
|
||||
# Load and verify
|
||||
zone_registry.clear()
|
||||
loaded = load_home_zone("Kate")
|
||||
assert loaded is not None
|
||||
|
||||
assert loaded.terrain[2][2] == "~"
|
||||
assert loaded.terrain[3][3] == "^"
|
||||
Loading…
Reference in a new issue