278 lines
8.3 KiB
Python
278 lines
8.3 KiB
Python
"""
|
|
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
|