""" Ability system for combat actions and spells. This module defines abilities (attacks, spells, skills) that can be used in combat. Abilities are loaded from YAML configuration files for data-driven design. """ from dataclasses import dataclass, field, asdict from typing import Dict, Any, List, Optional import yaml import os from pathlib import Path from app.models.enums import AbilityType, DamageType, EffectType, StatType from app.models.effects import Effect from app.models.stats import Stats @dataclass class Ability: """ Represents an action that can be taken in combat. Abilities can deal damage, apply effects, heal, or perform other actions. They are loaded from YAML files for easy game design iteration. Attributes: ability_id: Unique identifier name: Display name description: What the ability does ability_type: Category (attack, spell, skill, etc.) base_power: Base damage or healing value damage_type: Type of damage dealt (physical, fire, etc.) scaling_stat: Which stat scales this ability's power (if any) scaling_factor: Multiplier for scaling stat (default 0.5) mana_cost: MP required to use this ability cooldown: Turns before ability can be used again effects_applied: List of effects applied to target on hit is_aoe: Whether this affects multiple targets target_count: Number of targets if AoE (0 = all) """ ability_id: str name: str description: str ability_type: AbilityType base_power: int = 0 damage_type: Optional[DamageType] = None scaling_stat: Optional[StatType] = None scaling_factor: float = 0.5 mana_cost: int = 0 cooldown: int = 0 effects_applied: List[Effect] = field(default_factory=list) is_aoe: bool = False target_count: int = 1 def calculate_power(self, caster_stats: Stats) -> int: """ Calculate final power based on caster's stats. Formula: base_power + (scaling_stat × scaling_factor) Minimum power is always 1. Args: caster_stats: The caster's effective stats Returns: Final power value for damage or healing """ power = self.base_power if self.scaling_stat: stat_value = getattr(caster_stats, self.scaling_stat.value) power += int(stat_value * self.scaling_factor) return max(1, power) def get_effects_to_apply(self) -> List[Effect]: """ Get a copy of effects that should be applied to target(s). Creates new Effect instances to avoid sharing references. Returns: List of Effect instances to apply """ return [ Effect( effect_id=f"{self.ability_id}_{effect.name}_{id(effect)}", name=effect.name, effect_type=effect.effect_type, duration=effect.duration, power=effect.power, stat_affected=effect.stat_affected, stacks=effect.stacks, max_stacks=effect.max_stacks, source=self.ability_id, ) for effect in self.effects_applied ] def to_dict(self) -> Dict[str, Any]: """ Serialize ability to a dictionary. Returns: Dictionary containing all ability data """ data = asdict(self) data["ability_type"] = self.ability_type.value if self.damage_type: data["damage_type"] = self.damage_type.value if self.scaling_stat: data["scaling_stat"] = self.scaling_stat.value data["effects_applied"] = [effect.to_dict() for effect in self.effects_applied] return data @classmethod def from_dict(cls, data: Dict[str, Any]) -> 'Ability': """ Deserialize ability from a dictionary. Args: data: Dictionary containing ability data Returns: Ability instance """ # Convert string values back to enums ability_type = AbilityType(data["ability_type"]) damage_type = DamageType(data["damage_type"]) if data.get("damage_type") else None scaling_stat = StatType(data["scaling_stat"]) if data.get("scaling_stat") else None # Deserialize effects effects = [] if "effects_applied" in data and data["effects_applied"]: effects = [Effect.from_dict(e) for e in data["effects_applied"]] return cls( ability_id=data["ability_id"], name=data["name"], description=data["description"], ability_type=ability_type, base_power=data.get("base_power", 0), damage_type=damage_type, scaling_stat=scaling_stat, scaling_factor=data.get("scaling_factor", 0.5), mana_cost=data.get("mana_cost", 0), cooldown=data.get("cooldown", 0), effects_applied=effects, is_aoe=data.get("is_aoe", False), target_count=data.get("target_count", 1), ) def __repr__(self) -> str: """String representation of the ability.""" return ( f"Ability({self.name}, {self.ability_type.value}, " f"power={self.base_power}, cost={self.mana_cost}MP, " f"cooldown={self.cooldown}t)" ) class AbilityLoader: """ Loads abilities from YAML configuration files. This allows game designers to define abilities without touching code. """ def __init__(self, data_dir: Optional[str] = None): """ Initialize the ability loader. Args: data_dir: Path to directory containing ability YAML files Defaults to /app/data/abilities/ """ if data_dir is None: # Default to app/data/abilities relative to this file current_file = Path(__file__) app_dir = current_file.parent.parent # Go up to /app data_dir = str(app_dir / "data" / "abilities") self.data_dir = Path(data_dir) self._ability_cache: Dict[str, Ability] = {} def load_ability(self, ability_id: str) -> Optional[Ability]: """ Load a single ability by ID. Args: ability_id: Unique ability identifier Returns: Ability instance or None if not found """ # Check cache first if ability_id in self._ability_cache: return self._ability_cache[ability_id] # Load from YAML file yaml_file = self.data_dir / f"{ability_id}.yaml" if not yaml_file.exists(): return None with open(yaml_file, 'r') as f: data = yaml.safe_load(f) ability = Ability.from_dict(data) self._ability_cache[ability_id] = ability return ability def load_all_abilities(self) -> Dict[str, Ability]: """ Load all abilities from the data directory. Returns: Dictionary mapping ability_id to Ability instance """ if not self.data_dir.exists(): return {} abilities = {} for yaml_file in self.data_dir.glob("*.yaml"): with open(yaml_file, 'r') as f: data = yaml.safe_load(f) ability = Ability.from_dict(data) abilities[ability.ability_id] = ability self._ability_cache[ability.ability_id] = ability return abilities def clear_cache(self) -> None: """Clear the ability cache, forcing reload on next access.""" self._ability_cache.clear()