first commit
This commit is contained in:
277
api/app/services/class_loader.py
Normal file
277
api/app/services/class_loader.py
Normal file
@@ -0,0 +1,277 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user