Source code for pipeworks_mud_mapper.services.zone_service

"""Zone service for file I/O operations.

This module handles loading, saving, and exporting zone/map files. It
supports the export workflow described in ``goblin_cartography.md``:

- **Map JSON exports** (`*.map.json`): Authoring exports with coordinates
- **Zone JSON exports** (`*.json`): Game truth without coordinates

The Export Workflow
-------------------
::
    Author edits:  SQLite DB (data/mapper.db)


                   ┌─────────────┐
                   │  MapFile    │  (in memory)
                   └──────┬──────┘

            ┌─────────────┴─────────────┐
            ▼                           ▼
    save_map_file()              export_zone()
            │                           │
            ▼                           ▼
    data/exports/maps/*.map.json  data/zones/my_zone.json
    (authoring export)            (game truth, no coords)

File Format Detection
---------------------
The service detects file type by:

1. Extension: ``.map.json`` = map file, ``.json`` = zone file
2. Content: presence of ``coords`` in rooms indicates map file

When loading a zone file (no coords), rooms are auto-placed at origin.
A warning is returned to indicate coordinates need assignment.

Examples
--------
Loading a map file::

    from pathlib import Path
    from pipeworks_mud_mapper.services import zone_service

    map_file = zone_service.load_map_file(Path("data/exports/maps/tutorial.map.json"))

Saving changes::

    zone_service.save_map_file(map_file, Path("data/exports/maps/tutorial.map.json"))

Exporting for game server::

    zone_service.export_zone(map_file, Path("data/zones/tutorial.json"))

Creating a new zone::

    map_file = zone_service.create_new_map_file(
        zone_id="new_zone",
        name="New Zone",
        spawn_room_name="Starting Room",
    )

See Also
--------
- ``models/map_file.py``: MapFile model
- ``models/zone.py``: Zone model
- ``goblin_cartography.md`` Section 2.3: Where Coordinates Live
"""

import json
from datetime import UTC, datetime
from pathlib import Path
from typing import Any, cast

from pipeworks_mud_mapper import __version__ as mapper_version
from pipeworks_mud_mapper.models import Coords, MapFile, MapRoom, Zone
from pipeworks_mud_mapper.models.metadata import ExportedFrom


[docs] def load_map_file(path: Path) -> MapFile: """Load a map file from disk. Handles both map files (with coords) and zone files (without coords). When loading a zone file, rooms are placed at origin with a warning. Parameters ---------- path : Path Path to the map or zone file. Returns ------- MapFile The loaded map file, ready for editing. Raises ------ FileNotFoundError If the file does not exist. json.JSONDecodeError If the file is not valid JSON. pydantic.ValidationError If the JSON does not match the expected schema. Examples -------- >>> map_file = load_map_file(Path("data/exports/maps/tutorial.map.json")) >>> len(map_file.rooms) 5 """ content = path.read_text(encoding="utf-8") data = json.loads(content) return _parse_map_data(data)
def _parse_map_data(data: dict[str, Any]) -> MapFile: """Parse raw JSON data into a MapFile. Handles legacy format where coords may be lists instead of Coords objects, and zone format where coords may be missing entirely. Parameters ---------- data : dict Raw JSON data from file. Returns ------- MapFile Parsed map file. """ # Check if this is a zone file (no coords) or map file (has coords) has_coords = False if "rooms" in data: for room_data in data["rooms"].values(): if "coords" in room_data: has_coords = True break if not has_coords: # Zone file - add default coords at origin # TODO: Implement auto-layout algorithm (Phase 6) for room_id, room_data in data.get("rooms", {}).items(): if "coords" not in room_data: room_data["coords"] = [0, 0, 0] return MapFile.from_dict(data)
[docs] def save_map_file(map_file: MapFile, path: Path, *, bump_revision: bool = True) -> None: """Save a map file to disk. Saves in the legacy format with coords as [x, y, z] lists for compatibility with existing tools and data files. Parameters ---------- map_file : MapFile The map file to save. path : Path Destination path. Parent directories are created if needed. bump_revision : bool, optional When True, increment ``metadata.map_revision`` before saving. This is the default for explicit save actions. Examples -------- >>> save_map_file(map_file, Path("data/exports/maps/tutorial.map.json")) """ # Ensure parent directory exists path.parent.mkdir(parents=True, exist_ok=True) # Optionally increment map revision for authoring history. if bump_revision: map_file.bump_revision() # Convert to dict with list coords for compatibility data = map_file.to_dict_with_list_coords() # Write with pretty formatting content = json.dumps(data, indent=2, ensure_ascii=False) path.write_text(content + "\n", encoding="utf-8")
[docs] def export_zone(map_file: MapFile, path: Path) -> None: """Export a map file as a zone file (strips coordinates and authoring metadata). This creates the "game truth" file that the MUD server consumes. Coordinates and LLM generation metadata are removed because the game engine operates on topology (connections) not geometry (positions), and doesn't need authoring provenance data. Stripped Fields --------------- The following "authoring scaffolding" fields are removed during export: - **coords**: Room coordinates are visualization aids, not game state. Movement in a MUD is topological (room A connects to room B via "north"), not metric (room B is at position [1, 0, 0]). - **llm_generation**: LLM generation metadata (model, seed, prompts, etc.) exists for authoring purposes only - it enables reproducibility and provenance tracking during map creation, but has no meaning to the game server. This separation reflects the pipe-works philosophy that authoring scaffolding supports the creation process but is not part of the final game state. Parameters ---------- map_file : MapFile The map file to export. path : Path Destination path for the zone file. Examples -------- >>> export_zone(map_file, Path("data/zones/tutorial.json")) Notes ----- The exported zone file can be loaded back, but coordinates and LLM metadata will be lost. Always keep the original map file as the authoring source. See Also -------- MapRoom.to_room : Primary stripping mechanism (model conversion). OllamaGenerationInfo : The metadata model that gets stripped. """ # Ensure parent directory exists path.parent.mkdir(parents=True, exist_ok=True) # Convert to zone (strips coords and llm_generation via MapRoom.to_room()). # The Room model doesn't have these fields, so they're automatically excluded. zone = map_file.to_zone() # Stamp export provenance using the map metadata at export time. zone.metadata.exported_from = ExportedFrom( map_id=map_file.id, map_version=map_file.metadata.map_version, map_revision=map_file.metadata.map_revision, exported_at=datetime.now(UTC), exporter=f"pipeworks_mud_mapper {mapper_version}", ) # Serialize with JSON-friendly values (datetime -> ISO string). data = zone.model_dump(mode="json", exclude_none=True) # Belt-and-suspenders: Remove any lingering authoring fields. # These should already be gone from the model conversion above, # but we explicitly remove them as a safety measure. # This guards against: # - Future model changes that might accidentally include these fields # - Serialization edge cases in Pydantic # - Any other unexpected data leakage for room_data in data.get("rooms", {}).values(): room_data.pop("coords", None) room_data.pop("llm_generation", None) # Write with pretty formatting content = json.dumps(data, indent=2, ensure_ascii=False) path.write_text(content + "\n", encoding="utf-8")
[docs] def create_new_map_file( zone_id: str, name: str, spawn_room_name: str = "Spawn Room", description: str = "", ) -> MapFile: """Create a new empty map file with a spawn room. Parameters ---------- zone_id : str Unique identifier for the zone (e.g., "tutorial_area"). name : str Human-readable display name (e.g., "Tutorial Area"). spawn_room_name : str, default "Spawn Room" Display name for the initial spawn room. description : str, optional Zone description text. Returns ------- MapFile A new map file with a single spawn room at origin. Examples -------- >>> map_file = create_new_map_file( ... zone_id="my_dungeon", ... name="My Dungeon", ... spawn_room_name="Entrance Hall", ... ) >>> map_file.spawn_room 'spawn' >>> map_file.rooms["spawn"].name 'Entrance Hall' """ spawn_room = MapRoom( id="spawn", name=spawn_room_name, description="", coords=Coords(x=0, y=0, z=0), ) return MapFile( id=zone_id, name=name, description=description, spawn_room="spawn", rooms={"spawn": spawn_room}, items={}, )
[docs] def load_zone(path: Path) -> Zone: """Load a zone file (game truth format). This loads a zone file without coordinates. Use this when you need to work with the game truth format directly. Parameters ---------- path : Path Path to the zone file. Returns ------- Zone The loaded zone. Examples -------- >>> zone = load_zone(Path("data/zones/tutorial.json")) """ content = path.read_text(encoding="utf-8") data = json.loads(content) return cast(Zone, Zone.model_validate(data))
[docs] def get_suggested_export_path(map_path: Path, zones_dir: Path | None = None) -> Path: """Get the suggested zone export path for a map file. Converts ``data/exports/maps/foo.map.json`` to ``data/zones/foo.json``. Parameters ---------- map_path : Path Path to the map file. zones_dir : Path | None Optional override for the zones directory. When provided, exports will be placed in this directory regardless of the map path. Returns ------- Path Suggested path for the exported zone file. Examples -------- >>> get_suggested_export_path(Path("data/exports/maps/tutorial.map.json")) PosixPath('data/zones/tutorial.json') """ # Remove .map.json or .json extension name = map_path.name if name.endswith(".map.json"): base_name = name[:-9] # Remove ".map.json" elif name.endswith(".json"): base_name = name[:-5] # Remove ".json" else: base_name = map_path.stem # Construct zones path # If zones_dir is provided, always place exports there. if zones_dir is not None: return Path(zones_dir) / f"{base_name}.json" # If in data/exports/maps/, put in data/zones/; otherwise use same directory. if "maps" in map_path.parts: parts = list(map_path.parts) maps_index = parts.index("maps") parts[maps_index] = "zones" return Path(*parts[:-1]) / f"{base_name}.json" else: return map_path.parent / f"{base_name}.json"
[docs] def list_map_files(directory: Path) -> list[Path]: """List map files in a directory. Returns sorted ``*.map.json`` files for map export inspection. """ directory = Path(directory) if not directory.exists(): return [] return sorted(directory.glob("*.map.json"))
[docs] def list_zone_files(directory: Path) -> list[Path]: """List zone files in a directory. Returns sorted ``*.json`` files for the zone file browser. """ directory = Path(directory) if not directory.exists(): return [] return sorted(p for p in directory.glob("*.json") if not p.name.endswith(".map.json"))