From 9f760bc3af9a53f256496717442e966f34f08c22 Mon Sep 17 00:00:00 2001 From: Jared Miller Date: Sat, 14 Feb 2026 17:36:49 -0500 Subject: [PATCH] Add furniture persistence to home zone TOML --- src/mudlib/housing.py | 56 +++++++++ tests/test_furniture.py | 271 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 327 insertions(+) create mode 100644 tests/test_furniture.py diff --git a/src/mudlib/housing.py b/src/mudlib/housing.py index 55f84cc..664b44f 100644 --- a/src/mudlib/housing.py +++ b/src/mudlib/housing.py @@ -4,6 +4,10 @@ import logging import tomllib from pathlib import Path +from mudlib.entity import Entity +from mudlib.portal import Portal +from mudlib.thing import Thing +from mudlib.things import spawn_thing, thing_templates from mudlib.zone import Zone from mudlib.zones import get_zone, register_zone @@ -114,6 +118,22 @@ def save_home_zone(player_name: str, zone: Zone) -> None: lines.append(f"tiles = [{tiles}]") lines.append("") + # Save furniture (Things in the zone, but not Entities or Portals) + furniture = [ + obj + for obj in zone._contents + if isinstance(obj, Thing) + and not isinstance(obj, Entity) + and not isinstance(obj, Portal) + ] + + for item in furniture: + lines.append("[[furniture]]") + lines.append(f'template = "{item.name}"') + lines.append(f"x = {item.x}") + lines.append(f"y = {item.y}") + lines.append("") + path.write_text("\n".join(lines)) @@ -152,6 +172,42 @@ def load_home_zone(player_name: str) -> Zone | None: safe=data.get("safe", True), ) + # Load furniture + furniture_list = data.get("furniture", []) + for item_data in furniture_list: + template_name = item_data.get("template") + x = item_data.get("x") + y = item_data.get("y") + + if template_name not in thing_templates: + log.warning( + "Skipping unknown furniture template '%s' in %s", + template_name, + player_name, + ) + continue + + if not isinstance(x, int) or not isinstance(y, int): + log.warning( + "Invalid coordinates for furniture '%s' in %s", + template_name, + player_name, + ) + continue + + if not (0 <= x < zone.width and 0 <= y < zone.height): + log.warning( + "Out-of-bounds furniture '%s' at (%d,%d) in %s", + template_name, + x, + y, + player_name, + ) + continue + + template = thing_templates[template_name] + spawn_thing(template, zone, x=x, y=y) + register_zone(zone.name, zone) return zone diff --git a/tests/test_furniture.py b/tests/test_furniture.py new file mode 100644 index 0000000..88385e1 --- /dev/null +++ b/tests/test_furniture.py @@ -0,0 +1,271 @@ +"""Tests for furniture persistence in home zones.""" + +import logging +import tomllib + +import pytest + +from mudlib.entity import Entity +from mudlib.housing import ( + create_home_zone, + init_housing, + load_home_zone, + save_home_zone, +) +from mudlib.portal import Portal +from mudlib.thing import Thing +from mudlib.things import ThingTemplate, spawn_thing, thing_templates +from mudlib.zones import zone_registry + + +@pytest.fixture(autouse=True) +def _clean_registries(): + """Clear registries between tests.""" + saved_zones = dict(zone_registry) + saved_templates = dict(thing_templates) + zone_registry.clear() + thing_templates.clear() + yield + zone_registry.clear() + zone_registry.update(saved_zones) + thing_templates.clear() + thing_templates.update(saved_templates) + + +def test_save_furniture_in_zone_toml(tmp_path): + """save_home_zone() writes furniture to TOML.""" + init_housing(tmp_path) + + # Create zone + zone = create_home_zone("Alice") + + # Add a thing template + table_template = ThingTemplate( + name="table", + description="A wooden table", + portable=False, + ) + thing_templates["table"] = table_template + + # Spawn furniture in the zone + spawn_thing(table_template, zone, x=3, y=4) + + # Save + save_home_zone("Alice", zone) + + # Read the TOML file + zone_file = tmp_path / "alice.toml" + with open(zone_file, "rb") as f: + data = tomllib.load(f) + + # Check furniture section + assert "furniture" in data + assert len(data["furniture"]) == 1 + assert data["furniture"][0]["template"] == "table" + assert data["furniture"][0]["x"] == 3 + assert data["furniture"][0]["y"] == 4 + + +def test_load_furniture_from_toml(tmp_path): + """load_home_zone() spawns furniture from TOML.""" + init_housing(tmp_path) + + # Create zone to get the file + _ = create_home_zone("Bob") + zone_file = tmp_path / "bob.toml" + + # Add furniture entries to the TOML + with open(zone_file) as f: + content = f.read() + content += """ +[[furniture]] +template = "chair" +x = 5 +y = 6 +""" + with open(zone_file, "w") as f: + f.write(content) + + # Add template + chair_template = ThingTemplate( + name="chair", + description="A wooden chair", + portable=True, + ) + thing_templates["chair"] = chair_template + + # Clear registry and load + zone_registry.clear() + loaded = load_home_zone("Bob") + + assert loaded is not None + + # Check that furniture was spawned + chairs = [ + obj + for obj in loaded._contents + if isinstance(obj, Thing) and obj.name == "chair" + ] + assert len(chairs) == 1 + assert chairs[0].x == 5 + assert chairs[0].y == 6 + assert chairs[0].description == "A wooden chair" + + +def test_furniture_round_trip(tmp_path): + """Furniture survives save -> load cycle.""" + init_housing(tmp_path) + + # Create zone + zone = create_home_zone("Charlie") + + # Add templates + table_template = ThingTemplate(name="table", description="A table", portable=False) + chair_template = ThingTemplate(name="chair", description="A chair", portable=True) + thing_templates["table"] = table_template + thing_templates["chair"] = chair_template + + # Spawn furniture + spawn_thing(table_template, zone, x=3, y=4) + spawn_thing(chair_template, zone, x=3, y=5) + + # Save + save_home_zone("Charlie", zone) + + # Clear registry and load + zone_registry.clear() + loaded = load_home_zone("Charlie") + + assert loaded is not None + + # Check furniture + tables = [obj for obj in loaded._contents if obj.name == "table"] + chairs = [obj for obj in loaded._contents if obj.name == "chair"] + + assert len(tables) == 1 + assert tables[0].x == 3 + assert tables[0].y == 4 + + assert len(chairs) == 1 + assert chairs[0].x == 3 + assert chairs[0].y == 5 + + +def test_multiple_furniture_items(tmp_path): + """Multiple furniture items save and load correctly.""" + init_housing(tmp_path) + + zone = create_home_zone("Dave") + + # Add templates + chair_template = ThingTemplate(name="chair", description="A chair", portable=True) + thing_templates["chair"] = chair_template + + # Spawn multiple chairs + spawn_thing(chair_template, zone, x=2, y=2) + spawn_thing(chair_template, zone, x=3, y=2) + spawn_thing(chair_template, zone, x=4, y=2) + + # Save + save_home_zone("Dave", zone) + + # Load + zone_registry.clear() + loaded = load_home_zone("Dave") + + assert loaded is not None + + chairs = [obj for obj in loaded._contents if obj.name == "chair"] + assert len(chairs) == 3 + + # Check positions + positions = {(c.x, c.y) for c in chairs} + assert positions == {(2, 2), (3, 2), (4, 2)} + + +def test_load_unknown_template_skips(tmp_path, caplog): + """Unknown template name in TOML is skipped with warning.""" + caplog.set_level(logging.WARNING) + init_housing(tmp_path) + + # Create zone + _ = create_home_zone("Eve") + zone_file = tmp_path / "eve.toml" + + # Add furniture with unknown template + with open(zone_file) as f: + content = f.read() + content += """ +[[furniture]] +template = "unknown_thing" +x = 1 +y = 1 +""" + with open(zone_file, "w") as f: + f.write(content) + + # Load + zone_registry.clear() + loaded = load_home_zone("Eve") + + assert loaded is not None + + # Check that no furniture was spawned + things = [obj for obj in loaded._contents if isinstance(obj, Thing)] + assert len(things) == 0 + + # Check that warning was logged + assert "unknown_thing" in caplog.text.lower() + + +def test_save_excludes_entities(tmp_path): + """Entities in zone are NOT saved as furniture.""" + init_housing(tmp_path) + + zone = create_home_zone("Frank") + + # Add an entity to the zone + _ = Entity(name="test_mob", location=zone, x=5, y=5) + + # Save + save_home_zone("Frank", zone) + + # Read the TOML + zone_file = tmp_path / "frank.toml" + with open(zone_file, "rb") as f: + data = tomllib.load(f) + + # Furniture section should not exist or be empty + furniture = data.get("furniture", []) + assert len(furniture) == 0 + + +def test_save_excludes_portals(tmp_path): + """Portals are NOT saved as furniture.""" + init_housing(tmp_path) + + zone = create_home_zone("Grace") + + # Add a portal to the zone + _ = Portal( + name="exit", + description="An exit", + location=zone, + x=1, + y=1, + target_zone="overworld", + target_x=10, + target_y=10, + ) + + # Save + save_home_zone("Grace", zone) + + # Read the TOML + zone_file = tmp_path / "grace.toml" + with open(zone_file, "rb") as f: + data = tomllib.load(f) + + # Furniture section should not exist or be empty + furniture = data.get("furniture", []) + assert len(furniture) == 0