From b3801f780f3801310473db170dc34447cd6ce7db Mon Sep 17 00:00:00 2001 From: Jared Miller Date: Wed, 11 Feb 2026 22:03:08 -0500 Subject: [PATCH] Add portal loading from zone TOML files Zone TOML files can now define portals using [[portals]] sections. Each portal specifies coordinates (x, y), a target (zone_name:x,y), and a label. Optional aliases are supported. Portals are automatically created and placed in the zone when it loads. --- src/mudlib/zones.py | 31 ++++++++ tests/test_zone_loading.py | 140 +++++++++++++++++++++++++++++++++++++ 2 files changed, 171 insertions(+) diff --git a/src/mudlib/zones.py b/src/mudlib/zones.py index de417b7..b501332 100644 --- a/src/mudlib/zones.py +++ b/src/mudlib/zones.py @@ -6,6 +6,7 @@ import logging import tomllib from pathlib import Path +from mudlib.portal import Portal from mudlib.zone import Zone log = logging.getLogger(__name__) @@ -77,6 +78,36 @@ def load_zone(path: Path) -> Zone: spawn_y=spawn_y, ) + # Load portals + portals_data = data.get("portals", []) + for portal_dict in portals_data: + # Parse target string "zone_name:x,y" + target = portal_dict["target"] + try: + target_zone, coords = target.split(":") + target_x, target_y = map(int, coords.split(",")) + except ValueError: + log.warning( + "skipping portal '%s' at (%d, %d): malformed target '%s'", + portal_dict["label"], + portal_dict["x"], + portal_dict["y"], + target, + ) + continue + + # Create portal (automatically added to zone._contents via Object.__post_init__) + Portal( + name=portal_dict["label"], + aliases=portal_dict.get("aliases", []), + target_zone=target_zone, + target_x=target_x, + target_y=target_y, + location=zone, + x=portal_dict["x"], + y=portal_dict["y"], + ) + return zone diff --git a/tests/test_zone_loading.py b/tests/test_zone_loading.py index f031a22..419b61c 100644 --- a/tests/test_zone_loading.py +++ b/tests/test_zone_loading.py @@ -211,3 +211,143 @@ rows = [ assert zone.spawn_y == 0 finally: temp_path.unlink() + + +def test_load_zone_with_portals(): + """Load a zone with portals defined.""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f: + f.write(""" +name = "portal_zone" +description = "a zone with portals" +width = 10 +height = 10 + +[terrain] +rows = [ + "..........", + "..........", + "..........", + "..........", + "..........", + "..........", + "..........", + "..........", + "..........", + "..........", +] + +[[portals]] +x = 5 +y = 3 +target = "tavern:1,1" +label = "tavern door" + +[[portals]] +x = 2 +y = 7 +target = "forest:10,5" +label = "forest path" +""") + temp_path = pathlib.Path(f.name) + + try: + zone = load_zone(temp_path) + + # Find portals in zone contents + portals = [obj for obj in zone._contents if obj.__class__.__name__ == "Portal"] + assert len(portals) == 2 + + # Check first portal (tavern door at 5,3) + tavern_portal = [p for p in portals if p.name == "tavern door"][0] + assert tavern_portal.x == 5 + assert tavern_portal.y == 3 + assert tavern_portal.target_zone == "tavern" + assert tavern_portal.target_x == 1 + assert tavern_portal.target_y == 1 + assert tavern_portal.location == zone + + # Check second portal (forest path at 2,7) + forest_portal = [p for p in portals if p.name == "forest path"][0] + assert forest_portal.x == 2 + assert forest_portal.y == 7 + assert forest_portal.target_zone == "forest" + assert forest_portal.target_x == 10 + assert forest_portal.target_y == 5 + assert forest_portal.location == zone + + # Verify portals are at correct coordinates + contents_at_5_3 = zone.contents_at(5, 3) + assert tavern_portal in contents_at_5_3 + + contents_at_2_7 = zone.contents_at(2, 7) + assert forest_portal in contents_at_2_7 + finally: + temp_path.unlink() + + +def test_load_zone_without_portals(): + """Load a zone without portals section works fine.""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f: + f.write(""" +name = "no_portals" +description = "a zone without portals" +width = 3 +height = 3 + +[terrain] +rows = [ + "...", + "...", + "...", +] +""") + temp_path = pathlib.Path(f.name) + + try: + zone = load_zone(temp_path) + + # Should have no portals + portals = [obj for obj in zone._contents if obj.__class__.__name__ == "Portal"] + assert len(portals) == 0 + finally: + temp_path.unlink() + + +def test_load_zone_portal_with_aliases(): + """Portal can have optional aliases field in TOML.""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f: + f.write(""" +name = "alias_zone" +description = "a zone with aliased portal" +width = 5 +height = 5 + +[terrain] +rows = [ + ".....", + ".....", + ".....", + ".....", + ".....", +] + +[[portals]] +x = 2 +y = 2 +target = "elsewhere:0,0" +label = "mysterious gateway" +aliases = ["gateway", "gate", "portal"] +""") + temp_path = pathlib.Path(f.name) + + try: + zone = load_zone(temp_path) + + portals = [obj for obj in zone._contents if obj.__class__.__name__ == "Portal"] + assert len(portals) == 1 + + portal = portals[0] + assert portal.name == "mysterious gateway" + assert portal.aliases == ["gateway", "gate", "portal"] + finally: + temp_path.unlink()