From 058ba1b7de6538cf4ea70d112a23b339af7df6f1 Mon Sep 17 00:00:00 2001 From: Jared Miller Date: Wed, 11 Feb 2026 22:18:20 -0500 Subject: [PATCH] Add zone TOML export --- src/mudlib/export.py | 96 +++++++++++ src/mudlib/zone.py | 1 + src/mudlib/zones.py | 2 + tests/test_zone_export.py | 324 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 423 insertions(+) create mode 100644 src/mudlib/export.py create mode 100644 tests/test_zone_export.py diff --git a/src/mudlib/export.py b/src/mudlib/export.py new file mode 100644 index 0000000..2789b21 --- /dev/null +++ b/src/mudlib/export.py @@ -0,0 +1,96 @@ +"""Export zone data to TOML files.""" + +from __future__ import annotations + +from pathlib import Path + +from mudlib.portal import Portal +from mudlib.zone import Zone + + +def export_zone(zone: Zone) -> str: + """Export a Zone to TOML string. + + Args: + zone: Zone instance to export + + Returns: + TOML-formatted string representation of the zone + """ + lines = [] + + # Basic fields + lines.append(f'name = "{zone.name}"') + if zone.description: + lines.append(f'description = "{zone.description}"') + lines.append(f"width = {zone.width}") + lines.append(f"height = {zone.height}") + lines.append(f"toroidal = {str(zone.toroidal).lower()}") + lines.append(f"spawn_x = {zone.spawn_x}") + lines.append(f"spawn_y = {zone.spawn_y}") + lines.append("") + + # Terrain section + lines.append("[terrain]") + lines.append("rows = [") + for row in zone.terrain: + row_str = "".join(row) + lines.append(f' "{row_str}",') + lines.append("]") + lines.append("") + + # Impassable tiles + lines.append("[terrain.impassable]") + impassable_list = sorted(zone.impassable) + tiles_str = ", ".join(f'"{tile}"' for tile in impassable_list) + lines.append(f"tiles = [{tiles_str}]") + + # Ambient messages (if present) + if zone.ambient_messages: + lines.append("") + lines.append("[ambient]") + lines.append(f"interval = {zone.ambient_interval}") + lines.append("messages = [") + for msg in zone.ambient_messages: + # Escape quotes in messages + escaped_msg = msg.replace('"', '\\"') + lines.append(f' "{escaped_msg}",') + lines.append("]") + + # Portals (if any) + portals = [obj for obj in zone._contents if isinstance(obj, Portal)] + if portals: + lines.append("") + for portal in portals: + lines.append("[[portals]]") + lines.append(f"x = {portal.x}") + lines.append(f"y = {portal.y}") + target = f"{portal.target_zone}:{portal.target_x},{portal.target_y}" + lines.append(f'target = "{target}"') + lines.append(f'label = "{portal.name}"') + if portal.aliases: + aliases_str = ", ".join(f'"{alias}"' for alias in portal.aliases) + lines.append(f"aliases = [{aliases_str}]") + lines.append("") + + # Spawn rules (if any) + if zone.spawn_rules: + for spawn_rule in zone.spawn_rules: + lines.append("[[spawns]]") + lines.append(f'mob = "{spawn_rule.mob}"') + lines.append(f"max_count = {spawn_rule.max_count}") + lines.append(f"respawn_seconds = {spawn_rule.respawn_seconds}") + lines.append("") + + return "\n".join(lines) + + +def export_zone_to_file(zone: Zone, path: Path) -> None: + """Export a Zone to a TOML file. + + Args: + zone: Zone instance to export + path: Path where the TOML file should be written + """ + toml_str = export_zone(zone) + path.write_text(toml_str) diff --git a/src/mudlib/zone.py b/src/mudlib/zone.py index c211d64..fbc8e76 100644 --- a/src/mudlib/zone.py +++ b/src/mudlib/zone.py @@ -30,6 +30,7 @@ class Zone(Object): a zone. A tavern interior is a zone. A pocket dimension is a zone. """ + description: str = "" width: int = 0 height: int = 0 toroidal: bool = True diff --git a/src/mudlib/zones.py b/src/mudlib/zones.py index 46f52e4..8c916bf 100644 --- a/src/mudlib/zones.py +++ b/src/mudlib/zones.py @@ -51,6 +51,7 @@ def load_zone(path: Path) -> Zone: # Extract basic properties name = data["name"] + description = data.get("description", "") width = data["width"] height = data["height"] toroidal = data.get("toroidal", True) @@ -85,6 +86,7 @@ def load_zone(path: Path) -> Zone: zone = Zone( name=name, + description=description, width=width, height=height, toroidal=toroidal, diff --git a/tests/test_zone_export.py b/tests/test_zone_export.py new file mode 100644 index 0000000..ad7356d --- /dev/null +++ b/tests/test_zone_export.py @@ -0,0 +1,324 @@ +"""Tests for zone export to TOML files.""" + +import pathlib +import tempfile + +from mudlib.export import export_zone, export_zone_to_file +from mudlib.portal import Portal +from mudlib.zone import SpawnRule, Zone +from mudlib.zones import load_zone + + +def test_export_basic_zone(): + """Export a simple zone and verify TOML output has correct fields.""" + zone = Zone( + name="test_zone", + width=4, + height=3, + toroidal=False, + terrain=[ + ["#", "#", "#", "#"], + ["#", ".", ".", "#"], + ["#", "#", "#", "#"], + ], + impassable={"#"}, + spawn_x=0, + spawn_y=0, + ) + # Set description as an attribute (zones loaded from TOML have this) + zone.description = "a test zone" + + toml_str = export_zone(zone) + + # Verify basic fields are present + assert 'name = "test_zone"' in toml_str + assert 'description = "a test zone"' in toml_str + assert "width = 4" in toml_str + assert "height = 3" in toml_str + assert "toroidal = false" in toml_str + assert "spawn_x = 0" in toml_str + assert "spawn_y = 0" in toml_str + + # Verify terrain section + assert "[terrain]" in toml_str + assert '"####"' in toml_str + assert '"#..#"' in toml_str + + # Verify impassable tiles + assert "[terrain.impassable]" in toml_str + assert '"#"' in toml_str + + +def test_export_zone_round_trip(): + """Export a zone, load it back, verify it matches the original.""" + original = Zone( + name="round_trip", + width=5, + height=4, + toroidal=True, + terrain=[ + [".", ".", ".", ".", "."], + [".", "#", "#", "#", "."], + [".", "#", ".", "#", "."], + [".", ".", ".", ".", "."], + ], + impassable={"#"}, + spawn_x=2, + spawn_y=1, + ) + original.description = "round trip test" + + toml_str = export_zone(original) + + # Write to temp file and load back + with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f: + f.write(toml_str) + temp_path = pathlib.Path(f.name) + + try: + loaded = load_zone(temp_path) + + # Verify all fields match + 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.terrain == original.terrain + assert loaded.impassable == original.impassable + finally: + temp_path.unlink() + + +def test_export_zone_with_portals(): + """Zone with Portal objects exports [[portals]] sections.""" + zone = Zone( + name="portal_zone", + width=10, + height=10, + terrain=[["." for _ in range(10)] for _ in range(10)], + impassable=set(), + ) + zone.description = "a zone with portals" + + # Add portals + Portal( + name="tavern door", + location=zone, + x=5, + y=3, + target_zone="tavern", + target_x=1, + target_y=1, + ) + Portal( + name="forest path", + aliases=["path", "entrance"], + location=zone, + x=2, + y=7, + target_zone="forest", + target_x=10, + target_y=5, + ) + + toml_str = export_zone(zone) + + # Verify portals section exists + assert "[[portals]]" in toml_str + + # Verify first portal + assert "x = 5" in toml_str + assert "y = 3" in toml_str + assert 'target = "tavern:1,1"' in toml_str + assert 'label = "tavern door"' in toml_str + + # Verify second portal + assert "x = 2" in toml_str + assert "y = 7" in toml_str + assert 'target = "forest:10,5"' in toml_str + assert 'label = "forest path"' in toml_str + assert 'aliases = ["path", "entrance"]' in toml_str + + +def test_export_zone_with_portals_round_trip(): + """Export a zone with portals, load it back, verify portals match.""" + zone = Zone( + name="portal_round_trip", + width=8, + height=6, + toroidal=False, + terrain=[["." for _ in range(8)] for _ in range(6)], + impassable=set(), + spawn_x=0, + spawn_y=0, + ) + zone.description = "portal round trip test" + + # Add portals + Portal( + name="tavern door", + location=zone, + x=5, + y=3, + target_zone="tavern", + target_x=1, + target_y=2, + ) + Portal( + name="forest path", + aliases=["path"], + location=zone, + x=2, + y=4, + target_zone="forest", + target_x=10, + target_y=5, + ) + + toml_str = export_zone(zone) + + # Write to temp file and load back + with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f: + f.write(toml_str) + temp_path = pathlib.Path(f.name) + + try: + loaded = load_zone(temp_path) + + # Verify basic zone fields + assert loaded.name == zone.name + assert loaded.description == zone.description + assert loaded.width == zone.width + assert loaded.height == zone.height + + # Verify portals were loaded correctly + portals = [ + obj for obj in loaded._contents if obj.__class__.__name__ == "Portal" + ] + assert len(portals) == 2 + + # Sort by y coordinate for consistent ordering + portals.sort(key=lambda p: (p.y, p.x)) + + # Verify first portal (tavern door at 5,3) + assert portals[0].name == "tavern door" + assert portals[0].x == 5 + assert portals[0].y == 3 + assert portals[0].target_zone == "tavern" + assert portals[0].target_x == 1 + assert portals[0].target_y == 2 + + # Verify second portal (forest path at 2,4) + assert portals[1].name == "forest path" + assert portals[1].x == 2 + assert portals[1].y == 4 + assert portals[1].target_zone == "forest" + assert portals[1].target_x == 10 + assert portals[1].target_y == 5 + assert "path" in portals[1].aliases + finally: + temp_path.unlink() + + +def test_export_zone_with_spawn_point(): + """spawn_x/spawn_y are in the output.""" + zone = Zone( + name="spawn_zone", + width=10, + height=10, + terrain=[["." for _ in range(10)] for _ in range(10)], + spawn_x=5, + spawn_y=7, + ) + zone.description = "a zone with spawn point" + + toml_str = export_zone(zone) + + assert "spawn_x = 5" in toml_str + assert "spawn_y = 7" in toml_str + + +def test_export_zone_to_file(): + """Write TOML to a file, load it back.""" + zone = Zone( + name="file_zone", + width=3, + height=3, + terrain=[ + ["#", "#", "#"], + ["#", ".", "#"], + ["#", "#", "#"], + ], + impassable={"#"}, + ) + zone.description = "exported to file" + + with tempfile.TemporaryDirectory() as tmpdir: + output_path = pathlib.Path(tmpdir) / "test_zone.toml" + + export_zone_to_file(zone, output_path) + + # Verify file exists + assert output_path.exists() + + # Load it back + loaded = load_zone(output_path) + + assert loaded.name == zone.name + assert loaded.width == zone.width + assert loaded.height == zone.height + assert loaded.terrain == zone.terrain + + +def test_export_zone_with_ambient_messages(): + """Zone with ambient messages exports [ambient] section.""" + zone = Zone( + name="ambient_zone", + width=5, + height=5, + terrain=[["." for _ in range(5)] for _ in range(5)], + ambient_messages=[ + "Birds chirp overhead.", + "A cool breeze passes by.", + "Leaves rustle in the distance.", + ], + ambient_interval=90, + ) + zone.description = "a zone with ambient messages" + + toml_str = export_zone(zone) + + # Verify ambient section + assert "[ambient]" in toml_str + assert "interval = 90" in toml_str + assert "Birds chirp overhead." in toml_str + assert "A cool breeze passes by." in toml_str + assert "Leaves rustle in the distance." in toml_str + + +def test_export_zone_with_spawn_rules(): + """Zone with spawn rules exports [[spawns]] sections.""" + zone = Zone( + name="spawn_zone", + width=10, + height=10, + terrain=[["." for _ in range(10)] for _ in range(10)], + spawn_rules=[ + SpawnRule(mob="squirrel", max_count=2, respawn_seconds=180), + SpawnRule(mob="crow", max_count=1, respawn_seconds=300), + ], + ) + + toml_str = export_zone(zone) + + # Verify spawns sections + assert "[[spawns]]" in toml_str + assert 'mob = "squirrel"' in toml_str + assert "max_count = 2" in toml_str + assert "respawn_seconds = 180" in toml_str + assert 'mob = "crow"' in toml_str + assert "max_count = 1" in toml_str + assert "respawn_seconds = 300" in toml_str