""" LocationLoader service for loading location definitions from YAML files. This service reads location configuration files and converts them into Location dataclass instances, providing caching for performance. Locations are organized by region subdirectories. """ import yaml from pathlib import Path from typing import Dict, List, Optional import structlog from app.models.location import Location, Region from app.models.enums import LocationType logger = structlog.get_logger(__name__) class LocationLoader: """ Loads location definitions from YAML configuration files. Locations are organized in region subdirectories: /app/data/locations/ regions/ crossville.yaml crossville/ crossville_village.yaml crossville_tavern.yaml This allows game designers to define world locations without touching code. """ def __init__(self, data_dir: Optional[str] = None): """ Initialize the location loader. Args: data_dir: Path to directory containing location YAML files. Defaults to /app/data/locations/ """ if data_dir is None: # Default to app/data/locations relative to this file current_file = Path(__file__) app_dir = current_file.parent.parent # Go up to /app data_dir = str(app_dir / "data" / "locations") self.data_dir = Path(data_dir) self._location_cache: Dict[str, Location] = {} self._region_cache: Dict[str, Region] = {} logger.info("LocationLoader initialized", data_dir=str(self.data_dir)) def load_location(self, location_id: str) -> Optional[Location]: """ Load a single location by ID. Searches all region subdirectories for the location file. Args: location_id: Unique location identifier (e.g., "crossville_tavern") Returns: Location instance or None if not found """ # Check cache first if location_id in self._location_cache: logger.debug("Location loaded from cache", location_id=location_id) return self._location_cache[location_id] # Search in region subdirectories if not self.data_dir.exists(): logger.error("Location data directory does not exist", data_dir=str(self.data_dir)) return None for region_dir in self.data_dir.iterdir(): # Skip non-directories and the regions folder if not region_dir.is_dir() or region_dir.name == "regions": continue file_path = region_dir / f"{location_id}.yaml" if file_path.exists(): return self._load_location_file(file_path) logger.warning("Location not found", location_id=location_id) return None def _load_location_file(self, file_path: Path) -> Optional[Location]: """ Load a location from a specific file. Args: file_path: Path to the YAML file Returns: Location instance or None if loading fails """ try: with open(file_path, 'r') as f: data = yaml.safe_load(f) location = self._parse_location_data(data) self._location_cache[location.location_id] = location logger.info("Location loaded successfully", location_id=location.location_id) return location except Exception as e: logger.error("Failed to load location", file=str(file_path), error=str(e)) return None def _parse_location_data(self, data: Dict) -> Location: """ Parse YAML data into a Location dataclass. Args: data: Dictionary loaded from YAML file Returns: Location instance Raises: ValueError: If data is invalid or missing required fields """ # Validate required fields required_fields = ["location_id", "name", "region_id", "description"] for field in required_fields: if field not in data: raise ValueError(f"Missing required field: {field}") # Parse location type - default to town location_type_str = data.get("location_type", "town") try: location_type = LocationType(location_type_str) except ValueError: logger.warning( "Invalid location type, defaulting to town", location_id=data["location_id"], invalid_type=location_type_str ) location_type = LocationType.TOWN return Location( location_id=data["location_id"], name=data["name"], location_type=location_type, region_id=data["region_id"], description=data["description"], lore=data.get("lore"), ambient_description=data.get("ambient_description"), available_quests=data.get("available_quests", []), npc_ids=data.get("npc_ids", []), discoverable_locations=data.get("discoverable_locations", []), is_starting_location=data.get("is_starting_location", False), tags=data.get("tags", []), ) def load_all_locations(self) -> List[Location]: """ Load all locations from all region directories. Returns: List of Location instances """ locations = [] if not self.data_dir.exists(): logger.error("Location data directory does not exist", data_dir=str(self.data_dir)) return locations for region_dir in self.data_dir.iterdir(): # Skip non-directories and the regions folder if not region_dir.is_dir() or region_dir.name == "regions": continue for file_path in region_dir.glob("*.yaml"): location = self._load_location_file(file_path) if location: locations.append(location) logger.info("All locations loaded", count=len(locations)) return locations def load_region(self, region_id: str) -> Optional[Region]: """ Load a region definition. Args: region_id: Unique region identifier (e.g., "crossville") Returns: Region instance or None if not found """ # Check cache first if region_id in self._region_cache: logger.debug("Region loaded from cache", region_id=region_id) return self._region_cache[region_id] file_path = self.data_dir / "regions" / f"{region_id}.yaml" if not file_path.exists(): logger.warning("Region file not found", region_id=region_id) return None try: with open(file_path, 'r') as f: data = yaml.safe_load(f) region = Region.from_dict(data) self._region_cache[region_id] = region logger.info("Region loaded successfully", region_id=region_id) return region except Exception as e: logger.error("Failed to load region", region_id=region_id, error=str(e)) return None def get_locations_in_region(self, region_id: str) -> List[Location]: """ Get all locations belonging to a specific region. Args: region_id: Region identifier Returns: List of Location instances in this region """ # Load all locations if cache is empty if not self._location_cache: self.load_all_locations() return [ loc for loc in self._location_cache.values() if loc.region_id == region_id ] def get_starting_locations(self) -> List[Location]: """ Get all locations that can be starting points. Returns: List of Location instances marked as starting locations """ # Load all locations if cache is empty if not self._location_cache: self.load_all_locations() return [ loc for loc in self._location_cache.values() if loc.is_starting_location ] def get_location_by_type(self, location_type: LocationType) -> List[Location]: """ Get all locations of a specific type. Args: location_type: Type to filter by Returns: List of Location instances of this type """ # Load all locations if cache is empty if not self._location_cache: self.load_all_locations() return [ loc for loc in self._location_cache.values() if loc.location_type == location_type ] def get_all_location_ids(self) -> List[str]: """ Get a list of all available location IDs. Returns: List of location IDs """ # Load all locations if cache is empty if not self._location_cache: self.load_all_locations() return list(self._location_cache.keys()) def reload_location(self, location_id: str) -> Optional[Location]: """ Force reload a location from disk, bypassing cache. Useful for development/testing when location definitions change. Args: location_id: Unique location identifier Returns: Location instance or None if not found """ # Remove from cache if present if location_id in self._location_cache: del self._location_cache[location_id] return self.load_location(location_id) def clear_cache(self) -> None: """Clear all cached data. Useful for testing.""" self._location_cache.clear() self._region_cache.clear() logger.info("Location cache cleared") # Global singleton instance _loader_instance: Optional[LocationLoader] = None def get_location_loader() -> LocationLoader: """ Get the global LocationLoader instance. Returns: Singleton LocationLoader instance """ global _loader_instance if _loader_instance is None: _loader_instance = LocationLoader() return _loader_instance