Combat Backend & Data Models

- Implement Combat Service
- Implement Damage Calculator
- Implement Effect Processor
- Implement Combat Actions
- Created Combat API Endpoints
This commit is contained in:
2025-11-26 15:43:20 -06:00
parent 30c3b800e6
commit 03ab783eeb
22 changed files with 9091 additions and 5 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,594 @@
"""
Damage Calculator Service
A comprehensive, formula-driven damage calculation system for Code of Conquest.
Handles physical, magical, and elemental damage with LUK stat integration
for variance, critical hits, and accuracy.
Formulas:
Physical: (Weapon_Base + STR * 0.75) * Variance * Crit_Mult - DEF
Magical: (Ability_Base + INT * 0.75) * Variance * Crit_Mult - RES
Elemental: Split between physical and magical components
LUK Integration:
- Miss reduction: 10% base - (LUK * 0.5%), hard cap at 5% miss
- Crit bonus: Base 5% + (LUK * 0.5%), max 25%
- Lucky variance: 5% + (LUK * 0.25%) chance for higher damage roll
"""
import random
from dataclasses import dataclass, field
from typing import Dict, Any, Optional, List
from app.models.stats import Stats
from app.models.enums import DamageType
class CombatConstants:
"""
Combat system tuning constants.
These values control the balance of combat mechanics and can be
adjusted for game balance without modifying formula logic.
"""
# Stat Scaling
# How much primary stats (STR/INT) contribute to damage
# 0.75 means STR 14 adds +10.5 damage
STAT_SCALING_FACTOR: float = 0.75
# Hit/Miss System
BASE_MISS_CHANCE: float = 0.10 # 10% base miss rate
LUK_MISS_REDUCTION: float = 0.005 # 0.5% per LUK point
DEX_EVASION_BONUS: float = 0.0025 # 0.25% per DEX above 10
MIN_MISS_CHANCE: float = 0.05 # Hard cap: 5% minimum miss
# Critical Hits
DEFAULT_CRIT_CHANCE: float = 0.05 # 5% base crit
LUK_CRIT_BONUS: float = 0.005 # 0.5% per LUK point
MAX_CRIT_CHANCE: float = 0.25 # 25% cap (before skills)
DEFAULT_CRIT_MULTIPLIER: float = 2.0
# Damage Variance
BASE_VARIANCE_MIN: float = 0.95 # Minimum variance roll
BASE_VARIANCE_MAX: float = 1.05 # Maximum variance roll
LUCKY_VARIANCE_MIN: float = 1.00 # Lucky roll minimum
LUCKY_VARIANCE_MAX: float = 1.10 # Lucky roll maximum (10% bonus)
BASE_LUCKY_CHANCE: float = 0.05 # 5% base lucky roll chance
LUK_LUCKY_BONUS: float = 0.0025 # 0.25% per LUK point
# Defense Mitigation
# Ensures high-DEF targets still take meaningful damage
MIN_DAMAGE_RATIO: float = 0.20 # 20% of raw always goes through
MIN_DAMAGE: int = 1 # Absolute minimum damage
@dataclass
class DamageResult:
"""
Result of a damage calculation.
Contains the calculated damage values, whether the attack was a crit or miss,
and a human-readable message for the combat log.
Attributes:
total_damage: Final damage after all calculations
physical_damage: Physical component (for split damage)
elemental_damage: Elemental component (for split damage)
damage_type: Primary damage type (physical, fire, etc.)
is_critical: Whether the attack was a critical hit
is_miss: Whether the attack missed entirely
variance_roll: The variance multiplier that was applied
raw_damage: Damage before defense mitigation
message: Human-readable description for combat log
"""
total_damage: int = 0
physical_damage: int = 0
elemental_damage: int = 0
damage_type: DamageType = DamageType.PHYSICAL
elemental_type: Optional[DamageType] = None
is_critical: bool = False
is_miss: bool = False
variance_roll: float = 1.0
raw_damage: int = 0
message: str = ""
def to_dict(self) -> Dict[str, Any]:
"""Serialize damage result to dictionary."""
return {
"total_damage": self.total_damage,
"physical_damage": self.physical_damage,
"elemental_damage": self.elemental_damage,
"damage_type": self.damage_type.value if self.damage_type else "physical",
"elemental_type": self.elemental_type.value if self.elemental_type else None,
"is_critical": self.is_critical,
"is_miss": self.is_miss,
"variance_roll": round(self.variance_roll, 3),
"raw_damage": self.raw_damage,
"message": self.message,
}
class DamageCalculator:
"""
Formula-driven damage calculator for combat.
This class provides static methods for calculating all types of damage
in the combat system, including hit/miss chances, critical hits,
damage variance, and defense mitigation.
All formulas integrate the LUK stat for meaningful randomness while
maintaining a hard cap on miss chance to prevent frustration.
"""
@staticmethod
def calculate_hit_chance(
attacker_luck: int,
defender_dexterity: int,
skill_bonus: float = 0.0
) -> float:
"""
Calculate hit probability for an attack.
Formula:
miss_chance = max(0.05, 0.10 - (LUK * 0.005) + ((DEX - 10) * 0.0025))
hit_chance = 1.0 - miss_chance
Args:
attacker_luck: Attacker's LUK stat
defender_dexterity: Defender's DEX stat
skill_bonus: Additional hit chance from skills (0.0 to 1.0)
Returns:
Hit probability as a float between 0.0 and 1.0
Examples:
LUK 8, DEX 10: miss = 10% - 4% + 0% = 6%
LUK 12, DEX 10: miss = 10% - 6% + 0% = 4% -> capped at 5%
LUK 8, DEX 15: miss = 10% - 4% + 1.25% = 7.25%
"""
# Base miss rate
base_miss = CombatConstants.BASE_MISS_CHANCE
# LUK reduces miss chance
luk_reduction = attacker_luck * CombatConstants.LUK_MISS_REDUCTION
# High DEX increases evasion (only DEX above 10 counts)
dex_above_base = max(0, defender_dexterity - 10)
dex_evasion = dex_above_base * CombatConstants.DEX_EVASION_BONUS
# Calculate final miss chance with hard cap
miss_chance = base_miss - luk_reduction + dex_evasion - skill_bonus
miss_chance = max(CombatConstants.MIN_MISS_CHANCE, miss_chance)
return 1.0 - miss_chance
@staticmethod
def calculate_crit_chance(
attacker_luck: int,
weapon_crit_chance: float = CombatConstants.DEFAULT_CRIT_CHANCE,
skill_bonus: float = 0.0
) -> float:
"""
Calculate critical hit probability.
Formula:
crit_chance = min(0.25, weapon_crit + (LUK * 0.005) + skill_bonus)
Args:
attacker_luck: Attacker's LUK stat
weapon_crit_chance: Base crit chance from weapon (default 5%)
skill_bonus: Additional crit chance from skills
Returns:
Crit probability as a float (capped at 25%)
Examples:
LUK 8, weapon 5%: crit = 5% + 4% = 9%
LUK 12, weapon 5%: crit = 5% + 6% = 11%
LUK 12, weapon 10%: crit = 10% + 6% = 16%
"""
# LUK bonus to crit
luk_bonus = attacker_luck * CombatConstants.LUK_CRIT_BONUS
# Total crit chance with cap
total_crit = weapon_crit_chance + luk_bonus + skill_bonus
return min(CombatConstants.MAX_CRIT_CHANCE, total_crit)
@staticmethod
def calculate_variance(attacker_luck: int) -> float:
"""
Calculate damage variance multiplier with LUK bonus.
Hybrid variance system:
- Base roll: 95% to 105% of damage
- LUK grants chance for "lucky roll": 100% to 110% instead
Args:
attacker_luck: Attacker's LUK stat
Returns:
Variance multiplier (typically 0.95 to 1.10)
Examples:
LUK 8: 7% chance for lucky roll (100-110%)
LUK 12: 8% chance for lucky roll
"""
# Calculate lucky roll chance
lucky_chance = (
CombatConstants.BASE_LUCKY_CHANCE +
(attacker_luck * CombatConstants.LUK_LUCKY_BONUS)
)
# Roll for lucky variance
if random.random() < lucky_chance:
# Lucky roll: higher damage range
return random.uniform(
CombatConstants.LUCKY_VARIANCE_MIN,
CombatConstants.LUCKY_VARIANCE_MAX
)
else:
# Normal roll
return random.uniform(
CombatConstants.BASE_VARIANCE_MIN,
CombatConstants.BASE_VARIANCE_MAX
)
@staticmethod
def apply_defense(
raw_damage: int,
defense: int,
min_damage_ratio: float = CombatConstants.MIN_DAMAGE_RATIO
) -> int:
"""
Apply defense mitigation with minimum damage guarantee.
Ensures at least 20% of raw damage always goes through,
preventing high-DEF tanks from becoming unkillable.
Absolute minimum is always 1 damage.
Args:
raw_damage: Damage before defense
defense: Target's defense value
min_damage_ratio: Minimum % of raw damage that goes through
Returns:
Final damage after mitigation (minimum 1)
Examples:
raw=20, def=5: 20 - 5 = 15 damage
raw=20, def=18: max(4, 2) = 4 damage (20% minimum)
raw=10, def=100: max(2, -90) = 2 damage (20% minimum)
"""
# Calculate mitigated damage
mitigated = raw_damage - defense
# Minimum damage is 20% of raw, or 1, whichever is higher
min_damage = max(CombatConstants.MIN_DAMAGE, int(raw_damage * min_damage_ratio))
return max(min_damage, mitigated)
@classmethod
def calculate_physical_damage(
cls,
attacker_stats: Stats,
defender_stats: Stats,
weapon_damage: int = 0,
weapon_crit_chance: float = CombatConstants.DEFAULT_CRIT_CHANCE,
weapon_crit_multiplier: float = CombatConstants.DEFAULT_CRIT_MULTIPLIER,
ability_base_power: int = 0,
skill_hit_bonus: float = 0.0,
skill_crit_bonus: float = 0.0,
) -> DamageResult:
"""
Calculate physical damage for a melee/ranged attack.
Formula:
Base = Weapon_Base + Ability_Power + (STR * 0.75)
Damage = Base * Variance * Crit_Mult - DEF
Args:
attacker_stats: Attacker's Stats (STR, LUK used)
defender_stats: Defender's Stats (DEX, CON used)
weapon_damage: Base damage from equipped weapon
weapon_crit_chance: Crit chance from weapon (default 5%)
weapon_crit_multiplier: Crit damage multiplier (default 2.0x)
ability_base_power: Additional base power from ability
skill_hit_bonus: Hit chance bonus from skills
skill_crit_bonus: Crit chance bonus from skills
Returns:
DamageResult with calculated damage and metadata
"""
result = DamageResult(damage_type=DamageType.PHYSICAL)
# Step 1: Check for miss
hit_chance = cls.calculate_hit_chance(
attacker_stats.luck,
defender_stats.dexterity,
skill_hit_bonus
)
if random.random() > hit_chance:
result.is_miss = True
result.message = "Attack missed!"
return result
# Step 2: Calculate base damage
# Formula: weapon + ability + (STR * scaling_factor)
str_bonus = attacker_stats.strength * CombatConstants.STAT_SCALING_FACTOR
base_damage = weapon_damage + ability_base_power + str_bonus
# Step 3: Apply variance
variance = cls.calculate_variance(attacker_stats.luck)
result.variance_roll = variance
damage = base_damage * variance
# Step 4: Check for critical hit
crit_chance = cls.calculate_crit_chance(
attacker_stats.luck,
weapon_crit_chance,
skill_crit_bonus
)
if random.random() < crit_chance:
result.is_critical = True
damage *= weapon_crit_multiplier
# Store raw damage before defense
result.raw_damage = int(damage)
# Step 5: Apply defense mitigation
final_damage = cls.apply_defense(int(damage), defender_stats.defense)
result.total_damage = final_damage
result.physical_damage = final_damage
# Build message
crit_text = " CRITICAL HIT!" if result.is_critical else ""
result.message = f"Dealt {final_damage} physical damage.{crit_text}"
return result
@classmethod
def calculate_magical_damage(
cls,
attacker_stats: Stats,
defender_stats: Stats,
ability_base_power: int,
damage_type: DamageType = DamageType.FIRE,
weapon_crit_chance: float = CombatConstants.DEFAULT_CRIT_CHANCE,
weapon_crit_multiplier: float = CombatConstants.DEFAULT_CRIT_MULTIPLIER,
skill_hit_bonus: float = 0.0,
skill_crit_bonus: float = 0.0,
) -> DamageResult:
"""
Calculate magical damage for a spell.
Spells CAN critically hit (same formula as physical).
LUK benefits all classes equally.
Formula:
Base = Ability_Power + (INT * 0.75)
Damage = Base * Variance * Crit_Mult - RES
Args:
attacker_stats: Attacker's Stats (INT, LUK used)
defender_stats: Defender's Stats (DEX, WIS used)
ability_base_power: Base power of the spell
damage_type: Type of magical damage (fire, ice, etc.)
weapon_crit_chance: Crit chance (from focus/staff)
weapon_crit_multiplier: Crit damage multiplier
skill_hit_bonus: Hit chance bonus from skills
skill_crit_bonus: Crit chance bonus from skills
Returns:
DamageResult with calculated damage and metadata
"""
result = DamageResult(damage_type=damage_type)
# Step 1: Check for miss (spells can miss too)
hit_chance = cls.calculate_hit_chance(
attacker_stats.luck,
defender_stats.dexterity,
skill_hit_bonus
)
if random.random() > hit_chance:
result.is_miss = True
result.message = "Spell missed!"
return result
# Step 2: Calculate base damage
# Formula: ability + (INT * scaling_factor)
int_bonus = attacker_stats.intelligence * CombatConstants.STAT_SCALING_FACTOR
base_damage = ability_base_power + int_bonus
# Step 3: Apply variance
variance = cls.calculate_variance(attacker_stats.luck)
result.variance_roll = variance
damage = base_damage * variance
# Step 4: Check for critical hit (spells CAN crit)
crit_chance = cls.calculate_crit_chance(
attacker_stats.luck,
weapon_crit_chance,
skill_crit_bonus
)
if random.random() < crit_chance:
result.is_critical = True
damage *= weapon_crit_multiplier
# Store raw damage before resistance
result.raw_damage = int(damage)
# Step 5: Apply resistance mitigation
final_damage = cls.apply_defense(int(damage), defender_stats.resistance)
result.total_damage = final_damage
result.elemental_damage = final_damage
# Build message
crit_text = " CRITICAL HIT!" if result.is_critical else ""
result.message = f"Dealt {final_damage} {damage_type.value} damage.{crit_text}"
return result
@classmethod
def calculate_elemental_weapon_damage(
cls,
attacker_stats: Stats,
defender_stats: Stats,
weapon_damage: int,
weapon_crit_chance: float,
weapon_crit_multiplier: float,
physical_ratio: float,
elemental_ratio: float,
elemental_type: DamageType,
ability_base_power: int = 0,
skill_hit_bonus: float = 0.0,
skill_crit_bonus: float = 0.0,
) -> DamageResult:
"""
Calculate split damage for elemental weapons (e.g., Fire Sword).
Elemental weapons deal both physical AND elemental damage,
calculated separately against DEF and RES respectively.
Formula:
Physical = (Weapon * PHYS_RATIO + STR * 0.75 * PHYS_RATIO) - DEF
Elemental = (Weapon * ELEM_RATIO + INT * 0.75 * ELEM_RATIO) - RES
Total = Physical + Elemental
Recommended Split Ratios:
- Pure Physical: 100% / 0%
- Fire Sword: 70% / 30%
- Frost Blade: 60% / 40%
- Lightning Spear: 50% / 50%
Args:
attacker_stats: Attacker's Stats
defender_stats: Defender's Stats
weapon_damage: Base weapon damage
weapon_crit_chance: Crit chance from weapon
weapon_crit_multiplier: Crit damage multiplier
physical_ratio: Portion of damage that is physical (0.0-1.0)
elemental_ratio: Portion of damage that is elemental (0.0-1.0)
elemental_type: Type of elemental damage
ability_base_power: Additional base power from ability
skill_hit_bonus: Hit chance bonus from skills
skill_crit_bonus: Crit chance bonus from skills
Returns:
DamageResult with split physical/elemental damage
"""
result = DamageResult(
damage_type=DamageType.PHYSICAL,
elemental_type=elemental_type
)
# Step 1: Check for miss (single roll for entire attack)
hit_chance = cls.calculate_hit_chance(
attacker_stats.luck,
defender_stats.dexterity,
skill_hit_bonus
)
if random.random() > hit_chance:
result.is_miss = True
result.message = "Attack missed!"
return result
# Step 2: Check for critical (single roll applies to both components)
variance = cls.calculate_variance(attacker_stats.luck)
result.variance_roll = variance
crit_chance = cls.calculate_crit_chance(
attacker_stats.luck,
weapon_crit_chance,
skill_crit_bonus
)
is_crit = random.random() < crit_chance
result.is_critical = is_crit
crit_mult = weapon_crit_multiplier if is_crit else 1.0
# Step 3: Calculate physical component
# Physical uses STR scaling
phys_base = (weapon_damage + ability_base_power) * physical_ratio
str_bonus = attacker_stats.strength * CombatConstants.STAT_SCALING_FACTOR * physical_ratio
phys_damage = (phys_base + str_bonus) * variance * crit_mult
phys_final = cls.apply_defense(int(phys_damage), defender_stats.defense)
# Step 4: Calculate elemental component
# Elemental uses INT scaling
elem_base = (weapon_damage + ability_base_power) * elemental_ratio
int_bonus = attacker_stats.intelligence * CombatConstants.STAT_SCALING_FACTOR * elemental_ratio
elem_damage = (elem_base + int_bonus) * variance * crit_mult
elem_final = cls.apply_defense(int(elem_damage), defender_stats.resistance)
# Step 5: Combine results
result.physical_damage = phys_final
result.elemental_damage = elem_final
result.total_damage = phys_final + elem_final
result.raw_damage = int(phys_damage + elem_damage)
# Build message
crit_text = " CRITICAL HIT!" if is_crit else ""
result.message = (
f"Dealt {result.total_damage} damage "
f"({phys_final} physical + {elem_final} {elemental_type.value}).{crit_text}"
)
return result
@classmethod
def calculate_aoe_damage(
cls,
attacker_stats: Stats,
defender_stats_list: List[Stats],
ability_base_power: int,
damage_type: DamageType = DamageType.FIRE,
weapon_crit_chance: float = CombatConstants.DEFAULT_CRIT_CHANCE,
weapon_crit_multiplier: float = CombatConstants.DEFAULT_CRIT_MULTIPLIER,
skill_hit_bonus: float = 0.0,
skill_crit_bonus: float = 0.0,
) -> List[DamageResult]:
"""
Calculate AoE spell damage against multiple targets.
AoE spells deal FULL damage to all targets (balanced by higher mana costs).
Each target has independent hit/crit rolls but shares the base calculation.
Args:
attacker_stats: Attacker's Stats
defender_stats_list: List of defender Stats (one per target)
ability_base_power: Base power of the AoE spell
damage_type: Type of magical damage
weapon_crit_chance: Crit chance from focus/staff
weapon_crit_multiplier: Crit damage multiplier
skill_hit_bonus: Hit chance bonus from skills
skill_crit_bonus: Crit chance bonus from skills
Returns:
List of DamageResult, one per target
"""
results = []
# Each target gets independent damage calculation
for defender_stats in defender_stats_list:
result = cls.calculate_magical_damage(
attacker_stats=attacker_stats,
defender_stats=defender_stats,
ability_base_power=ability_base_power,
damage_type=damage_type,
weapon_crit_chance=weapon_crit_chance,
weapon_crit_multiplier=weapon_crit_multiplier,
skill_hit_bonus=skill_hit_bonus,
skill_crit_bonus=skill_crit_bonus,
)
results.append(result)
return results

View File

@@ -0,0 +1,260 @@
"""
Enemy Loader Service - YAML-based enemy template loading.
This service loads enemy definitions from YAML files, providing a data-driven
approach to defining monsters and enemies for combat encounters.
"""
from pathlib import Path
from typing import Dict, List, Optional
import yaml
from app.models.enemy import EnemyTemplate, EnemyDifficulty
from app.utils.logging import get_logger
logger = get_logger(__file__)
class EnemyLoader:
"""
Loads enemy templates from YAML configuration files.
This allows game designers to define enemies without touching code.
Enemy files are organized by difficulty in subdirectories.
"""
def __init__(self, data_dir: Optional[str] = None):
"""
Initialize the enemy loader.
Args:
data_dir: Path to directory containing enemy YAML files
Defaults to /app/data/enemies/
"""
if data_dir is None:
# Default to app/data/enemies relative to this file
current_file = Path(__file__)
app_dir = current_file.parent.parent # Go up to /app
data_dir = str(app_dir / "data" / "enemies")
self.data_dir = Path(data_dir)
self._enemy_cache: Dict[str, EnemyTemplate] = {}
self._loaded = False
logger.info("EnemyLoader initialized", data_dir=str(self.data_dir))
def load_enemy(self, enemy_id: str) -> Optional[EnemyTemplate]:
"""
Load a single enemy template by ID.
Args:
enemy_id: Unique enemy identifier
Returns:
EnemyTemplate instance or None if not found
"""
# Check cache first
if enemy_id in self._enemy_cache:
return self._enemy_cache[enemy_id]
# If not cached, try loading all enemies first
if not self._loaded:
self.load_all_enemies()
if enemy_id in self._enemy_cache:
return self._enemy_cache[enemy_id]
# Try loading from specific YAML file
yaml_file = self.data_dir / f"{enemy_id}.yaml"
if yaml_file.exists():
return self._load_from_file(yaml_file)
# Search in subdirectories
for subdir in self.data_dir.iterdir():
if subdir.is_dir():
yaml_file = subdir / f"{enemy_id}.yaml"
if yaml_file.exists():
return self._load_from_file(yaml_file)
logger.warning("Enemy not found", enemy_id=enemy_id)
return None
def _load_from_file(self, yaml_file: Path) -> Optional[EnemyTemplate]:
"""
Load an enemy template from a specific YAML file.
Args:
yaml_file: Path to the YAML file
Returns:
EnemyTemplate instance or None on error
"""
try:
with open(yaml_file, 'r') as f:
data = yaml.safe_load(f)
enemy = EnemyTemplate.from_dict(data)
self._enemy_cache[enemy.enemy_id] = enemy
logger.debug("Enemy loaded", enemy_id=enemy.enemy_id, file=str(yaml_file))
return enemy
except Exception as e:
logger.error("Failed to load enemy file",
file=str(yaml_file),
error=str(e))
return None
def load_all_enemies(self) -> Dict[str, EnemyTemplate]:
"""
Load all enemy templates from the data directory.
Searches both the root directory and subdirectories for YAML files.
Returns:
Dictionary mapping enemy_id to EnemyTemplate instance
"""
if not self.data_dir.exists():
logger.warning("Enemy data directory not found", path=str(self.data_dir))
return {}
enemies = {}
# Load from root directory
for yaml_file in self.data_dir.glob("*.yaml"):
enemy = self._load_from_file(yaml_file)
if enemy:
enemies[enemy.enemy_id] = enemy
# Load from subdirectories (organized by difficulty)
for subdir in self.data_dir.iterdir():
if subdir.is_dir():
for yaml_file in subdir.glob("*.yaml"):
enemy = self._load_from_file(yaml_file)
if enemy:
enemies[enemy.enemy_id] = enemy
self._loaded = True
logger.info("All enemies loaded", count=len(enemies))
return enemies
def get_enemies_by_difficulty(
self,
difficulty: EnemyDifficulty
) -> List[EnemyTemplate]:
"""
Get all enemies matching a difficulty level.
Args:
difficulty: Difficulty level to filter by
Returns:
List of EnemyTemplate instances
"""
if not self._loaded:
self.load_all_enemies()
return [
enemy for enemy in self._enemy_cache.values()
if enemy.difficulty == difficulty
]
def get_enemies_by_tag(self, tag: str) -> List[EnemyTemplate]:
"""
Get all enemies with a specific tag.
Args:
tag: Tag to filter by (e.g., "undead", "beast", "humanoid")
Returns:
List of EnemyTemplate instances with that tag
"""
if not self._loaded:
self.load_all_enemies()
return [
enemy for enemy in self._enemy_cache.values()
if enemy.has_tag(tag)
]
def get_random_enemies(
self,
count: int = 1,
difficulty: Optional[EnemyDifficulty] = None,
tag: Optional[str] = None,
exclude_bosses: bool = True
) -> List[EnemyTemplate]:
"""
Get random enemies for encounter generation.
Args:
count: Number of enemies to select
difficulty: Optional difficulty filter
tag: Optional tag filter
exclude_bosses: Whether to exclude boss enemies
Returns:
List of randomly selected EnemyTemplate instances
"""
import random
if not self._loaded:
self.load_all_enemies()
# Build candidate list
candidates = list(self._enemy_cache.values())
# Apply filters
if difficulty:
candidates = [e for e in candidates if e.difficulty == difficulty]
if tag:
candidates = [e for e in candidates if e.has_tag(tag)]
if exclude_bosses:
candidates = [e for e in candidates if not e.is_boss()]
if not candidates:
logger.warning("No enemies match filters",
difficulty=difficulty.value if difficulty else None,
tag=tag)
return []
# Select random enemies (with replacement if needed)
if len(candidates) >= count:
return random.sample(candidates, count)
else:
# Not enough unique enemies, allow duplicates
return random.choices(candidates, k=count)
def clear_cache(self) -> None:
"""Clear the enemy cache, forcing reload on next access."""
self._enemy_cache.clear()
self._loaded = False
logger.debug("Enemy cache cleared")
def get_all_cached(self) -> Dict[str, EnemyTemplate]:
"""
Get all cached enemies.
Returns:
Dictionary of cached enemy templates
"""
if not self._loaded:
self.load_all_enemies()
return self._enemy_cache.copy()
# Global instance for convenience
_loader_instance: Optional[EnemyLoader] = None
def get_enemy_loader() -> EnemyLoader:
"""
Get the global EnemyLoader instance.
Returns:
Singleton EnemyLoader instance
"""
global _loader_instance
if _loader_instance is None:
_loader_instance = EnemyLoader()
return _loader_instance