diff --git a/src/mudlib/housing.py b/src/mudlib/housing.py new file mode 100644 index 0000000..55f84cc --- /dev/null +++ b/src/mudlib/housing.py @@ -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) diff --git a/tests/test_housing.py b/tests/test_housing.py new file mode 100644 index 0000000..af2247b --- /dev/null +++ b/tests/test_housing.py @@ -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] == "^"