first commit

This commit is contained in:
2025-11-24 23:10:55 -06:00
commit 8315fa51c9
279 changed files with 74600 additions and 0 deletions

View 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