Source code for pipeworks_mud_mapper.models.map_file

"""MapFile model for authoring exports.

This module defines the MapFile model - the JSON export format used for
authoring data when exporting from SQLite. Map files contain everything
needed for visual map editing: rooms, exits, items, AND coordinates.

SQLite + Export Workflow
------------------------
As described in ``goblin_cartography.md`` Section 2.4, the mapper now stores
authoring data in SQLite and exports MapFile/Zone JSON on demand.

File Naming Convention
----------------------
Map files use the ``.map.json`` extension to distinguish from zone files::

    data/exports/maps/crooked_pipe.map.json  # Authoring export (has coords)
    data/zones/crooked_pipe.json             # Game truth (no coords)

This makes it clear which file is the source and which is generated.

Data Flow
---------
::

    SQLite DB  ──► MapFile ──┬──► Export ──► exports/maps/zone.map.json

                              └──► Export ──► zones/zone.json
                                       (strips coords)

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

    from pathlib import Path
    from pipeworks_mud_mapper.models import MapFile

    content = Path("data/exports/maps/my_zone.map.json").read_text()
    map_file = MapFile.model_validate_json(content)

Creating a new map::

    map_file = MapFile(
        id="new_zone",
        name="New Zone",
        spawn_room="spawn",
        rooms={
            "spawn": MapRoom(
                id="spawn",
                name="Spawn Room",
                coords=Coords(x=0, y=0, z=0),
            ),
        },
    )

Exporting to zone (strips coordinates)::

    zone = map_file.to_zone()
    Path("data/zones/new_zone.json").write_text(
        zone.model_dump_json(indent=2, exclude_none=True)
    )

See Also
--------
Zone : Game truth format (no coordinates).
MapRoom : Room with coordinates.
"""

from typing import Any, cast

from pydantic import BaseModel, Field, field_validator, model_validator

from pipeworks_mud_mapper.models.metadata import MapMetadata, ZoneMetadata
from pipeworks_mud_mapper.models.room import Coords, Direction, MapRoom
from pipeworks_mud_mapper.models.zone import Zone


[docs] class MapFile(BaseModel): """A map file in authoring format (includes coordinates). MapFile is the primary data structure used by the mapper tool during editing. It contains all zone data plus coordinates for visualization. When exporting to a zone file (for the MUD server), use ``to_zone()`` which strips coordinates and converts MapRooms to Rooms. Attributes ---------- id : str Unique zone identifier. Becomes the filename for both map and zone files. name : str Human-readable display name. metadata : MapMetadata Versioning metadata used to pair map and zone exports. description : str Optional zone description. spawn_room : str Room ID where players enter. Must exist in rooms dict. rooms : dict[str, MapRoom] Mapping of room ID to MapRoom objects (with coordinates). items : dict[str, dict] Mapping of item ID to item definitions. Examples -------- >>> map_file = MapFile( ... id="tutorial", ... name="Tutorial Area", ... spawn_room="spawn", ... rooms={ ... "spawn": MapRoom( ... id="spawn", ... name="Start", ... coords=Coords(x=0, y=0, z=0), ... ), ... }, ... ) Converting to zone format:: >>> zone = map_file.to_zone() >>> type(zone) <class 'pipeworks_mud_mapper.models.zone.Zone'> See Also -------- Zone : Game truth format without coordinates. MapRoom : Room model with coordinates. """ id: str = Field(..., min_length=1, description="Unique zone identifier") name: str = Field(..., min_length=1, description="Display name") metadata: MapMetadata = Field( default_factory=MapMetadata, description="Versioning metadata for authoring files", ) description: str = Field(default="", description="Zone description") spawn_room: str = Field(..., min_length=1, description="Entry room ID") rooms: dict[str, MapRoom] = Field( default_factory=dict, description="Room ID to MapRoom mapping" ) items: dict[str, dict[str, Any]] = Field( default_factory=dict, description="Item ID to item definition mapping" )
[docs] @field_validator("id") @classmethod def validate_id(cls, v: str) -> str: """Validate zone ID format. Zone IDs must start with a letter and contain only lowercase letters, numbers, and underscores. """ if not v[0].isalpha(): raise ValueError("Zone ID must start with a letter") if not v.replace("_", "").isalnum(): raise ValueError("Zone ID must contain only letters, numbers, and underscores") if v != v.lower(): raise ValueError("Zone ID must be lowercase") return v
[docs] @model_validator(mode="after") def validate_spawn_room_exists(self) -> "MapFile": """Ensure spawn_room references an existing room.""" if self.spawn_room not in self.rooms: raise ValueError( f"spawn_room '{self.spawn_room}' does not exist in rooms. " f"Available rooms: {list(self.rooms.keys())}" ) return self
[docs] @model_validator(mode="after") def validate_room_ids_match_keys(self) -> "MapFile": """Ensure room IDs match their dictionary keys.""" for key, room in self.rooms.items(): if room.id != key: raise ValueError(f"Room ID mismatch: key '{key}' but room.id is '{room.id}'") return self
[docs] def to_zone(self) -> Zone: """Convert to game truth format by stripping coordinates. Creates a Zone instance suitable for export to the MUD server. All MapRooms are converted to Rooms (without coordinates). Versioning metadata is carried into the Zone metadata. Returns ------- Zone A Zone instance without coordinates. Examples -------- >>> map_file = MapFile( ... id="test", ... name="Test", ... spawn_room="spawn", ... rooms={"spawn": MapRoom(id="spawn", name="S", coords=Coords())}, ... ) >>> zone = map_file.to_zone() >>> hasattr(zone.rooms["spawn"], 'coords') False """ return Zone( metadata=ZoneMetadata(schema_version=self.metadata.schema_version), id=self.id, name=self.name, description=self.description, spawn_room=self.spawn_room, rooms={room_id: room.to_room() for room_id, room in self.rooms.items()}, items=self.items.copy(), )
[docs] def bump_revision(self) -> int: """Increment the authoring revision counter. This is called on every map save to provide a lightweight edit history. """ self.metadata.map_revision += 1 return self.metadata.map_revision
[docs] def bump_version(self) -> str: """Increment the authoring milestone version. Map versions are stored as strings for flexibility, but must be numeric for auto-increment. If non-numeric, raise a clear error. """ try: next_version = int(self.metadata.map_version) + 1 except ValueError as exc: raise ValueError( f"map_version must be numeric to auto-increment: {self.metadata.map_version}" ) from exc self.metadata.map_version = str(next_version) return self.metadata.map_version
[docs] def get_room(self, room_id: str) -> MapRoom | None: """Get a room by ID. Parameters ---------- room_id : str The room ID to look up. Returns ------- MapRoom or None The room if found, None otherwise. """ return self.rooms.get(room_id)
[docs] def get_room_at_coords(self, coords: Coords) -> MapRoom | None: """Find a room at the given coordinates. Parameters ---------- coords : Coords The coordinates to search for. Returns ------- MapRoom or None The room at those coordinates, or None if no room exists there. Examples -------- >>> room = map_file.get_room_at_coords(Coords(x=0, y=0, z=0)) """ target = coords.to_tuple() for room in self.rooms.values(): if room.coords.to_tuple() == target: return room return None
[docs] def find_room_in_direction( self, from_coords: Coords, direction: Direction, exclude_room: str | None = None, ) -> MapRoom | None: """Find the nearest room in a given direction from coordinates. This method searches for rooms that lie in the specified direction from the given coordinates, regardless of distance. It's used for auto-connecting exits when the target room may not be at the exact adjacent coordinate. Parameters ---------- from_coords : Coords Starting position to search from. direction : Direction Direction to search in (north, south, east, west, up, down). exclude_room : str, optional Room ID to exclude from search (typically the source room). Returns ------- MapRoom or None The nearest room in that direction, or None if no room found. Examples -------- >>> room = map_file.find_room_in_direction( ... Coords(x=0, y=0, z=0), ... "north", ... exclude_room="spawn", ... ) """ from pipeworks_mud_mapper.models.room import DIRECTION_OFFSETS dx, dy, dz = DIRECTION_OFFSETS[direction] fx, fy, fz = from_coords.to_tuple() candidates: list[tuple[int, MapRoom]] = [] for room in self.rooms.values(): if exclude_room and room.id == exclude_room: continue rx, ry, rz = room.coords.to_tuple() # Check if room is in the correct direction # For each axis, if the offset is non-zero, the room must be # in that direction (same sign) and other axes should match in_direction = True if dx != 0: # East/West: check X axis if dx > 0 and rx <= fx: in_direction = False # Must be east (rx > fx) elif dx < 0 and rx >= fx: in_direction = False # Must be west (rx < fx) elif ry != fy or rz != fz: in_direction = False # Y and Z must match elif dy != 0: # North/South: check Y axis if dy > 0 and ry <= fy: in_direction = False # Must be north (ry > fy) elif dy < 0 and ry >= fy: in_direction = False # Must be south (ry < fy) elif rx != fx or rz != fz: in_direction = False # X and Z must match elif dz != 0: # Up/Down: check Z axis if dz > 0 and rz <= fz: in_direction = False # Must be up (rz > fz) elif dz < 0 and rz >= fz: in_direction = False # Must be down (rz < fz) elif rx != fx or ry != fy: in_direction = False # X and Y must match if in_direction: # Calculate Manhattan distance distance = abs(rx - fx) + abs(ry - fy) + abs(rz - fz) candidates.append((distance, room)) if not candidates: return None # Return the nearest room candidates.sort(key=lambda x: x[0]) return candidates[0][1]
[docs] def add_room( self, room_id: str, name: str, coords: Coords, description: str = "", ) -> MapRoom: """Add a new room to the map. Parameters ---------- room_id : str Unique identifier for the room. name : str Display name for the room. coords : Coords Position in 3D space. description : str, optional Room description text. Returns ------- MapRoom The newly created room. Raises ------ ValueError If a room with that ID already exists. Examples -------- >>> room = map_file.add_room( ... room_id="new_room", ... name="New Room", ... coords=Coords(x=5, y=0, z=0), ... ) """ if room_id in self.rooms: raise ValueError(f"Room '{room_id}' already exists") room = MapRoom( id=room_id, name=name, description=description, coords=coords, ) self.rooms[room_id] = room return room
[docs] def create_exit( self, from_room_id: str, direction: Direction, to_room_id: str, bidirectional: bool = True, ) -> None: """Create an exit between two rooms. Parameters ---------- from_room_id : str Source room ID. direction : Direction Direction of the exit. to_room_id : str Target room ID. bidirectional : bool, default True If True, also create the reverse exit. Raises ------ ValueError If either room doesn't exist. Examples -------- >>> map_file.create_exit("spawn", "north", "hallway") # Creates spawn->north->hallway AND hallway->south->spawn """ from pipeworks_mud_mapper.models.room import OPPOSITE_DIRECTION if from_room_id not in self.rooms: raise ValueError(f"Source room '{from_room_id}' does not exist") if to_room_id not in self.rooms: raise ValueError(f"Target room '{to_room_id}' does not exist") self.rooms[from_room_id].exits[direction] = to_room_id if bidirectional: opposite = OPPOSITE_DIRECTION[direction] self.rooms[to_room_id].exits[opposite] = from_room_id
[docs] def remove_exit( self, from_room_id: str, direction: Direction, bidirectional: bool = True, ) -> None: """Remove an exit from a room. Parameters ---------- from_room_id : str Source room ID. direction : Direction Direction of the exit to remove. bidirectional : bool, default True If True, also remove the reverse exit from the target room. Examples -------- >>> map_file.remove_exit("spawn", "north") # Removes spawn->north AND (if exists) target->south->spawn """ from pipeworks_mud_mapper.models.room import OPPOSITE_DIRECTION room = self.rooms.get(from_room_id) if not room: return target_id = room.exits.pop(direction, None) if bidirectional and target_id: target_room = self.rooms.get(target_id) if target_room: opposite = OPPOSITE_DIRECTION[direction] # Only remove if it points back to us if target_room.exits.get(opposite) == from_room_id: target_room.exits.pop(opposite, None)
[docs] @classmethod def from_dict(cls, data: dict) -> "MapFile": """Create MapFile from a dictionary (legacy format support). This method handles the legacy format where rooms have coords as lists rather than Coords objects. Parameters ---------- data : dict Map file data, potentially in legacy format. Returns ------- MapFile New MapFile instance. Examples -------- >>> data = { ... "id": "test", ... "name": "Test", ... "spawn_room": "spawn", ... "rooms": { ... "spawn": { ... "id": "spawn", ... "name": "Spawn", ... "coords": [0, 0, 0], ... "exits": {}, ... "items": [], ... } ... }, ... "items": {}, ... } >>> map_file = MapFile.from_dict(data) """ data = data.copy() if "rooms" in data: data["rooms"] = { room_id: MapRoom.from_dict(room_data) for room_id, room_data in data["rooms"].items() } return cast("MapFile", cls.model_validate(data))
[docs] def to_dict_with_list_coords(self) -> dict[str, Any]: """Export to dictionary with coords as lists (legacy format). This method produces output compatible with existing map files. It handles two format conversions: 1. **Coords as lists**: Converts ``{"x": 0, "y": 0, "z": 0}`` to ``[0, 0, 0]`` for backwards compatibility with existing map files. 2. **Datetime as ISO string**: Uses ``mode="json"`` serialization to convert datetime objects (like ``llm_generation.generated_at``) to ISO 8601 strings. This ensures JSON compatibility when saving map files. The ``llm_generation`` field (if present) contains a ``generated_at`` datetime that must be serialized for JSON storage. Using ``mode="json"`` handles this automatically, producing strings like ``"2024-01-15T10:30:00+00:00"``. Returns ------- dict Map file data with coords as [x, y, z] lists and datetimes as ISO strings. Examples -------- >>> data = map_file.to_dict_with_list_coords() >>> data["rooms"]["spawn"]["coords"] [0, 0, 0] With llm_generation metadata:: >>> room_data = data["rooms"]["spawn"] >>> room_data["llm_generation"]["generated_at"] '2024-01-15T10:30:00+00:00' """ # Use mode="json" to serialize datetime objects as ISO strings. # This is necessary for llm_generation.generated_at to be JSON-compatible. # Without this, datetime objects would cause JSON serialization errors. data: dict[str, Any] = self.model_dump(mode="json") # Convert coords from dict format to list format for backwards compatibility. # Pydantic serializes Coords as {"x": 0, "y": 0, "z": 0} but our file format # uses [x, y, z] lists for compactness and readability. for room_data in data["rooms"].values(): if "coords" in room_data and isinstance(room_data["coords"], dict): coords = room_data["coords"] room_data["coords"] = [coords["x"], coords["y"], coords["z"]] return data