From d18f21a031f38281369ffe14861201d0022ec762 Mon Sep 17 00:00:00 2001 From: Jared Miller Date: Wed, 11 Feb 2026 20:42:54 -0500 Subject: [PATCH] Add zone TOML loader and tavern interior zone Implements load_zone() and load_zones() functions to parse zone definitions from TOML files. Wires zone loading into server startup to register all zones from content/zones/ directory. Updates player zone lookup to use the registry instead of hardcoded overworld check. Includes tavern.toml as first hand-built interior zone (8x6 bounded). --- content/zones/tavern.toml | 19 +++++ src/mudlib/server.py | 20 +++-- src/mudlib/zones.py | 72 ++++++++++++++++++ tests/test_zone_loading.py | 146 +++++++++++++++++++++++++++++++++++++ 4 files changed, 252 insertions(+), 5 deletions(-) create mode 100644 content/zones/tavern.toml create mode 100644 tests/test_zone_loading.py diff --git a/content/zones/tavern.toml b/content/zones/tavern.toml new file mode 100644 index 0000000..e0d34e9 --- /dev/null +++ b/content/zones/tavern.toml @@ -0,0 +1,19 @@ +name = "tavern" +description = "a cozy tavern with a crackling fireplace" +width = 8 +height = 6 +toroidal = false + +[terrain] +# rows as strings, one per line +rows = [ + "########", + "#......#", + "#......#", + "#......#", + "#......#", + "####.###", +] + +[terrain.impassable] +tiles = ["#"] diff --git a/src/mudlib/server.py b/src/mudlib/server.py index 106c9c0..4efa1b2 100644 --- a/src/mudlib/server.py +++ b/src/mudlib/server.py @@ -49,6 +49,7 @@ from mudlib.thing import Thing from mudlib.things import load_thing_templates, spawn_thing, thing_templates from mudlib.world.terrain import World from mudlib.zone import Zone +from mudlib.zones import get_zone, load_zones, register_zone log = logging.getLogger(__name__) @@ -270,12 +271,10 @@ async def shell( "inventory": [], } - # Resolve zone from zone_name (currently only overworld exists) + # Resolve zone from zone_name using zone registry zone_name = player_data.get("zone_name", "overworld") - if zone_name == "overworld": - player_zone = _overworld - else: - # Future: lookup zone by name from a zone registry + player_zone = get_zone(zone_name) + if player_zone is None: log.warning( "unknown zone '%s' for player '%s', defaulting to overworld", zone_name, @@ -460,6 +459,17 @@ async def run_server() -> None: ) log.info("created overworld zone (%dx%d, toroidal)", world.width, world.height) + # Register overworld zone + register_zone("overworld", _overworld) + + # Load and register zones from content/zones/ + zones_dir = pathlib.Path(__file__).resolve().parents[2] / "content" / "zones" + if zones_dir.exists(): + loaded_zones = load_zones(zones_dir) + for zone_name, zone in loaded_zones.items(): + register_zone(zone_name, zone) + log.info("loaded %d zones from %s", len(loaded_zones), zones_dir) + # Load content-defined commands from TOML files content_dir = pathlib.Path(__file__).resolve().parents[2] / "content" / "commands" if content_dir.exists(): diff --git a/src/mudlib/zones.py b/src/mudlib/zones.py index f578105..13488de 100644 --- a/src/mudlib/zones.py +++ b/src/mudlib/zones.py @@ -2,8 +2,14 @@ from __future__ import annotations +import logging +import tomllib +from pathlib import Path + from mudlib.zone import Zone +log = logging.getLogger(__name__) + # Module-level zone registry zone_registry: dict[str, Zone] = {} @@ -28,3 +34,69 @@ def get_zone(name: str) -> Zone | None: Zone instance if found, None otherwise """ return zone_registry.get(name) + + +def load_zone(path: Path) -> Zone: + """Load a zone from a TOML file. + + Args: + path: Path to TOML file + + Returns: + Zone instance loaded from file + """ + with open(path, "rb") as f: + data = tomllib.load(f) + + # Extract basic properties + name = data["name"] + width = data["width"] + height = data["height"] + toroidal = data.get("toroidal", True) + + # Parse terrain rows into 2D list + terrain_rows = data.get("terrain", {}).get("rows", []) + terrain = [] + for row in terrain_rows: + terrain.append(list(row)) + + # Parse impassable tiles + impassable_list = data.get("terrain", {}).get("impassable", {}).get("tiles", []) + impassable = set(impassable_list) if impassable_list else {"^", "~"} + + zone = Zone( + name=name, + width=width, + height=height, + toroidal=toroidal, + terrain=terrain, + impassable=impassable, + ) + + return zone + + +def load_zones(directory: Path) -> dict[str, Zone]: + """Load all zones from a directory. + + Args: + directory: Path to directory containing zone TOML files + + Returns: + Dict mapping zone names to Zone instances + """ + zones = {} + + if not directory.exists(): + log.warning("zones directory does not exist: %s", directory) + return zones + + for toml_file in directory.glob("*.toml"): + try: + zone = load_zone(toml_file) + zones[zone.name] = zone + log.debug("loaded zone '%s' from %s", zone.name, toml_file) + except Exception as e: + log.error("failed to load zone from %s: %s", toml_file, e, exc_info=True) + + return zones diff --git a/tests/test_zone_loading.py b/tests/test_zone_loading.py new file mode 100644 index 0000000..9a58698 --- /dev/null +++ b/tests/test_zone_loading.py @@ -0,0 +1,146 @@ +"""Tests for zone loading from TOML files.""" + +import pathlib +import tempfile + +from mudlib.zones import load_zone, load_zones + + +def test_load_zone(): + """Load a zone from TOML file.""" + # Create a temporary TOML file + with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f: + f.write(""" +name = "test_zone" +description = "a test zone" +width = 4 +height = 3 +toroidal = false + +[terrain] +rows = [ + "####", + "#..#", + "####", +] + +[terrain.impassable] +tiles = ["#"] +""") + temp_path = pathlib.Path(f.name) + + try: + zone = load_zone(temp_path) + + assert zone.name == "test_zone" + assert zone.width == 4 + assert zone.height == 3 + assert zone.toroidal is False + assert len(zone.terrain) == 3 + assert zone.terrain[0] == ["#", "#", "#", "#"] + assert zone.terrain[1] == ["#", ".", ".", "#"] + assert zone.terrain[2] == ["#", "#", "#", "#"] + assert zone.impassable == {"#"} + finally: + temp_path.unlink() + + +def test_load_zone_toroidal(): + """Load a toroidal zone.""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f: + f.write(""" +name = "toroidal_zone" +description = "a toroidal test zone" +width = 3 +height = 2 +toroidal = true + +[terrain] +rows = [ + "...", + "...", +] +""") + temp_path = pathlib.Path(f.name) + + try: + zone = load_zone(temp_path) + + assert zone.toroidal is True + # Default impassable set from Zone class + assert zone.impassable == {"^", "~"} + finally: + temp_path.unlink() + + +def test_load_zones_from_directory(): + """Load all zones from a directory.""" + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir_path = pathlib.Path(tmpdir) + + # Create two zone files + zone1_path = tmpdir_path / "zone1.toml" + zone1_path.write_text(""" +name = "zone1" +description = "first zone" +width = 2 +height = 2 +toroidal = false + +[terrain] +rows = [ + "..", + "..", +] +""") + + zone2_path = tmpdir_path / "zone2.toml" + zone2_path.write_text(""" +name = "zone2" +description = "second zone" +width = 3 +height = 3 +toroidal = true + +[terrain] +rows = [ + "###", + "#.#", + "###", +] +""") + + # Create a non-TOML file that should be ignored + (tmpdir_path / "readme.txt").write_text("not a zone file") + + zones = load_zones(tmpdir_path) + + assert len(zones) == 2 + assert "zone1" in zones + assert "zone2" in zones + assert zones["zone1"].name == "zone1" + assert zones["zone2"].name == "zone2" + + +def test_load_tavern_zone(): + """Load the actual tavern zone file.""" + # This tests the real tavern.toml file in content/zones/ + project_root = pathlib.Path(__file__).resolve().parents[1] + tavern_path = project_root / "content" / "zones" / "tavern.toml" + + zone = load_zone(tavern_path) + + assert zone.name == "tavern" + assert zone.width == 8 + assert zone.height == 6 + assert zone.toroidal is False + assert len(zone.terrain) == 6 + assert zone.terrain[0] == ["#", "#", "#", "#", "#", "#", "#", "#"] + assert zone.terrain[5] == ["#", "#", "#", "#", ".", "#", "#", "#"] + assert zone.impassable == {"#"} + # Check that interior is passable + assert zone.is_passable(1, 1) + assert zone.is_passable(4, 3) + # Check that walls are impassable + assert not zone.is_passable(0, 0) + assert not zone.is_passable(7, 0)