Add player housing zone creation and persistence

This commit is contained in:
Jared Miller 2026-02-14 16:46:36 -05:00
parent 1c22530be7
commit 9fac18ad2b
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
2 changed files with 449 additions and 0 deletions

181
src/mudlib/housing.py Normal file
View 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
View 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] == "^"