""" ClassLoader service for loading player class definitions from YAML files. This service reads class configuration files and converts them into PlayerClass dataclass instances, providing caching for performance. """ import yaml from pathlib import Path from typing import Dict, List, Optional import structlog from app.models.skills import PlayerClass, SkillTree, SkillNode from app.models.stats import Stats logger = structlog.get_logger(__name__) class ClassLoader: """ Loads player class definitions from YAML configuration files. This allows game designers to define classes and skill trees without touching code. All class definitions are stored in /app/data/classes/ as YAML files. """ def __init__(self, data_dir: Optional[str] = None): """ Initialize the class loader. Args: data_dir: Path to directory containing class YAML files. Defaults to /app/data/classes/ """ if data_dir is None: # Default to app/data/classes relative to this file current_file = Path(__file__) app_dir = current_file.parent.parent # Go up to /app data_dir = str(app_dir / "data" / "classes") self.data_dir = Path(data_dir) self._class_cache: Dict[str, PlayerClass] = {} logger.info("ClassLoader initialized", data_dir=str(self.data_dir)) def load_class(self, class_id: str) -> Optional[PlayerClass]: """ Load a single player class by ID. Args: class_id: Unique class identifier (e.g., "vanguard") Returns: PlayerClass instance or None if not found """ # Check cache first if class_id in self._class_cache: logger.debug("Class loaded from cache", class_id=class_id) return self._class_cache[class_id] # Construct file path file_path = self.data_dir / f"{class_id}.yaml" if not file_path.exists(): logger.warning("Class file not found", class_id=class_id, file_path=str(file_path)) return None try: # Load YAML file with open(file_path, 'r') as f: data = yaml.safe_load(f) # Parse into PlayerClass player_class = self._parse_class_data(data) # Cache the result self._class_cache[class_id] = player_class logger.info("Class loaded successfully", class_id=class_id) return player_class except Exception as e: logger.error("Failed to load class", class_id=class_id, error=str(e)) return None def load_all_classes(self) -> List[PlayerClass]: """ Load all player classes from the data directory. Returns: List of PlayerClass instances """ classes = [] # Find all YAML files in the directory if not self.data_dir.exists(): logger.error("Class data directory does not exist", data_dir=str(self.data_dir)) return classes for file_path in self.data_dir.glob("*.yaml"): class_id = file_path.stem # Get filename without extension player_class = self.load_class(class_id) if player_class: classes.append(player_class) logger.info("All classes loaded", count=len(classes)) return classes def get_class_by_id(self, class_id: str) -> Optional[PlayerClass]: """ Get a player class by ID (alias for load_class). Args: class_id: Unique class identifier Returns: PlayerClass instance or None if not found """ return self.load_class(class_id) def get_all_class_ids(self) -> List[str]: """ Get a list of all available class IDs. Returns: List of class IDs (e.g., ["vanguard", "assassin", "arcanist"]) """ if not self.data_dir.exists(): return [] return [file_path.stem for file_path in self.data_dir.glob("*.yaml")] def reload_class(self, class_id: str) -> Optional[PlayerClass]: """ Force reload a class from disk, bypassing cache. Useful for development/testing when class definitions change. Args: class_id: Unique class identifier Returns: PlayerClass instance or None if not found """ # Remove from cache if present if class_id in self._class_cache: del self._class_cache[class_id] return self.load_class(class_id) def clear_cache(self): """Clear the class cache. Useful for testing.""" self._class_cache.clear() logger.info("Class cache cleared") def _parse_class_data(self, data: Dict) -> PlayerClass: """ Parse YAML data into a PlayerClass dataclass. Args: data: Dictionary loaded from YAML file Returns: PlayerClass instance Raises: ValueError: If data is invalid or missing required fields """ # Validate required fields required_fields = ["class_id", "name", "description", "base_stats", "skill_trees"] for field in required_fields: if field not in data: raise ValueError(f"Missing required field: {field}") # Parse base stats base_stats = Stats(**data["base_stats"]) # Parse skill trees skill_trees = [] for tree_data in data["skill_trees"]: skill_tree = self._parse_skill_tree(tree_data) skill_trees.append(skill_tree) # Get optional fields starting_equipment = data.get("starting_equipment", []) starting_abilities = data.get("starting_abilities", []) # Create PlayerClass instance player_class = PlayerClass( class_id=data["class_id"], name=data["name"], description=data["description"], base_stats=base_stats, skill_trees=skill_trees, starting_equipment=starting_equipment, starting_abilities=starting_abilities ) return player_class def _parse_skill_tree(self, tree_data: Dict) -> SkillTree: """ Parse a skill tree from YAML data. Args: tree_data: Dictionary containing skill tree data Returns: SkillTree instance """ # Validate required fields required_fields = ["tree_id", "name", "description", "nodes"] for field in required_fields: if field not in tree_data: raise ValueError(f"Missing required field in skill tree: {field}") # Parse skill nodes nodes = [] for node_data in tree_data["nodes"]: skill_node = self._parse_skill_node(node_data) nodes.append(skill_node) # Create SkillTree instance skill_tree = SkillTree( tree_id=tree_data["tree_id"], name=tree_data["name"], description=tree_data["description"], nodes=nodes ) return skill_tree def _parse_skill_node(self, node_data: Dict) -> SkillNode: """ Parse a skill node from YAML data. Args: node_data: Dictionary containing skill node data Returns: SkillNode instance """ # Validate required fields required_fields = ["skill_id", "name", "description", "tier", "effects"] for field in required_fields: if field not in node_data: raise ValueError(f"Missing required field in skill node: {field}") # Create SkillNode instance skill_node = SkillNode( skill_id=node_data["skill_id"], name=node_data["name"], description=node_data["description"], tier=node_data["tier"], prerequisites=node_data.get("prerequisites", []), effects=node_data.get("effects", {}), unlocked=False # Always start locked ) return skill_node # Global instance for convenience _loader_instance: Optional[ClassLoader] = None def get_class_loader() -> ClassLoader: """ Get the global ClassLoader instance. Returns: Singleton ClassLoader instance """ global _loader_instance if _loader_instance is None: _loader_instance = ClassLoader() return _loader_instance