Add procedural item generation with affix naming system: - Items with RARE/EPIC/LEGENDARY rarity get dynamic names - Prefixes (e.g., "Flaming") add elemental damage, material bonuses - Suffixes (e.g., "of Strength") add stat bonuses - Affix count scales with rarity: RARE=1, EPIC=2, LEGENDARY=3 New files: - models/affixes.py: Affix and BaseItemTemplate dataclasses - services/affix_loader.py: YAML-based affix pool loading - services/base_item_loader.py: Base item template loading - services/item_generator.py: Main procedural generation service - data/affixes/prefixes.yaml: 14 prefix definitions - data/affixes/suffixes.yaml: 15 suffix definitions - data/base_items/weapons.yaml: 12 weapon templates - data/base_items/armor.yaml: 12 armor templates - tests/test_item_generator.py: 34 comprehensive tests Modified: - enums.py: Added AffixType and AffixTier enums - items.py: Added affix tracking fields (applied_affixes, generated_name) Example output: "Frozen Dagger of the Bear" (EPIC with ice damage + STR/CON)
316 lines
9.0 KiB
Python
316 lines
9.0 KiB
Python
"""
|
|
Affix Loader Service - YAML-based affix pool loading.
|
|
|
|
This service loads prefix and suffix affix definitions from YAML files,
|
|
providing a data-driven approach to item generation.
|
|
"""
|
|
|
|
from pathlib import Path
|
|
from typing import Dict, List, Optional
|
|
import random
|
|
import yaml
|
|
|
|
from app.models.affixes import Affix
|
|
from app.models.enums import AffixType, AffixTier
|
|
from app.utils.logging import get_logger
|
|
|
|
logger = get_logger(__file__)
|
|
|
|
|
|
class AffixLoader:
|
|
"""
|
|
Loads and manages item affixes from YAML configuration files.
|
|
|
|
This allows game designers to define affixes without touching code.
|
|
Affixes are organized into prefixes.yaml and suffixes.yaml files.
|
|
"""
|
|
|
|
def __init__(self, data_dir: Optional[str] = None):
|
|
"""
|
|
Initialize the affix loader.
|
|
|
|
Args:
|
|
data_dir: Path to directory containing affix YAML files
|
|
Defaults to /app/data/affixes/
|
|
"""
|
|
if data_dir is None:
|
|
# Default to app/data/affixes relative to this file
|
|
current_file = Path(__file__)
|
|
app_dir = current_file.parent.parent # Go up to /app
|
|
data_dir = str(app_dir / "data" / "affixes")
|
|
|
|
self.data_dir = Path(data_dir)
|
|
self._prefix_cache: Dict[str, Affix] = {}
|
|
self._suffix_cache: Dict[str, Affix] = {}
|
|
self._loaded = False
|
|
|
|
logger.info("AffixLoader initialized", data_dir=str(self.data_dir))
|
|
|
|
def _ensure_loaded(self) -> None:
|
|
"""Ensure affixes are loaded before any operation."""
|
|
if not self._loaded:
|
|
self.load_all()
|
|
|
|
def load_all(self) -> None:
|
|
"""Load all affixes from YAML files."""
|
|
if not self.data_dir.exists():
|
|
logger.warning("Affix data directory not found", path=str(self.data_dir))
|
|
return
|
|
|
|
# Load prefixes
|
|
prefixes_file = self.data_dir / "prefixes.yaml"
|
|
if prefixes_file.exists():
|
|
self._load_affixes_from_file(prefixes_file, self._prefix_cache)
|
|
|
|
# Load suffixes
|
|
suffixes_file = self.data_dir / "suffixes.yaml"
|
|
if suffixes_file.exists():
|
|
self._load_affixes_from_file(suffixes_file, self._suffix_cache)
|
|
|
|
self._loaded = True
|
|
logger.info(
|
|
"Affixes loaded",
|
|
prefix_count=len(self._prefix_cache),
|
|
suffix_count=len(self._suffix_cache)
|
|
)
|
|
|
|
def _load_affixes_from_file(
|
|
self,
|
|
yaml_file: Path,
|
|
cache: Dict[str, Affix]
|
|
) -> None:
|
|
"""
|
|
Load affixes from a YAML file into the cache.
|
|
|
|
Args:
|
|
yaml_file: Path to the YAML file
|
|
cache: Cache dictionary to populate
|
|
"""
|
|
try:
|
|
with open(yaml_file, 'r') as f:
|
|
data = yaml.safe_load(f)
|
|
|
|
# Get the top-level key (prefixes or suffixes)
|
|
affix_key = "prefixes" if "prefixes" in data else "suffixes"
|
|
affixes_data = data.get(affix_key, {})
|
|
|
|
for affix_id, affix_data in affixes_data.items():
|
|
# Ensure affix_id is set
|
|
affix_data["affix_id"] = affix_id
|
|
|
|
# Set defaults for missing optional fields
|
|
affix_data.setdefault("stat_bonuses", {})
|
|
affix_data.setdefault("defense_bonus", 0)
|
|
affix_data.setdefault("resistance_bonus", 0)
|
|
affix_data.setdefault("damage_bonus", 0)
|
|
affix_data.setdefault("elemental_ratio", 0.0)
|
|
affix_data.setdefault("crit_chance_bonus", 0.0)
|
|
affix_data.setdefault("crit_multiplier_bonus", 0.0)
|
|
affix_data.setdefault("allowed_item_types", [])
|
|
affix_data.setdefault("required_rarity", None)
|
|
|
|
affix = Affix.from_dict(affix_data)
|
|
cache[affix.affix_id] = affix
|
|
|
|
logger.debug(
|
|
"Affixes loaded from file",
|
|
file=str(yaml_file),
|
|
count=len(affixes_data)
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(
|
|
"Failed to load affix file",
|
|
file=str(yaml_file),
|
|
error=str(e)
|
|
)
|
|
|
|
def get_affix(self, affix_id: str) -> Optional[Affix]:
|
|
"""
|
|
Get a specific affix by ID.
|
|
|
|
Args:
|
|
affix_id: Unique affix identifier
|
|
|
|
Returns:
|
|
Affix instance or None if not found
|
|
"""
|
|
self._ensure_loaded()
|
|
|
|
if affix_id in self._prefix_cache:
|
|
return self._prefix_cache[affix_id]
|
|
if affix_id in self._suffix_cache:
|
|
return self._suffix_cache[affix_id]
|
|
|
|
return None
|
|
|
|
def get_eligible_prefixes(
|
|
self,
|
|
item_type: str,
|
|
rarity: str,
|
|
tier: Optional[AffixTier] = None
|
|
) -> List[Affix]:
|
|
"""
|
|
Get all prefixes eligible for an item.
|
|
|
|
Args:
|
|
item_type: Type of item ("weapon", "armor")
|
|
rarity: Item rarity ("rare", "epic", "legendary")
|
|
tier: Optional tier filter
|
|
|
|
Returns:
|
|
List of eligible Affix instances
|
|
"""
|
|
self._ensure_loaded()
|
|
|
|
eligible = []
|
|
for affix in self._prefix_cache.values():
|
|
# Check if affix can apply to this item
|
|
if not affix.can_apply_to(item_type, rarity):
|
|
continue
|
|
|
|
# Apply tier filter if specified
|
|
if tier and affix.tier != tier:
|
|
continue
|
|
|
|
eligible.append(affix)
|
|
|
|
return eligible
|
|
|
|
def get_eligible_suffixes(
|
|
self,
|
|
item_type: str,
|
|
rarity: str,
|
|
tier: Optional[AffixTier] = None
|
|
) -> List[Affix]:
|
|
"""
|
|
Get all suffixes eligible for an item.
|
|
|
|
Args:
|
|
item_type: Type of item ("weapon", "armor")
|
|
rarity: Item rarity ("rare", "epic", "legendary")
|
|
tier: Optional tier filter
|
|
|
|
Returns:
|
|
List of eligible Affix instances
|
|
"""
|
|
self._ensure_loaded()
|
|
|
|
eligible = []
|
|
for affix in self._suffix_cache.values():
|
|
# Check if affix can apply to this item
|
|
if not affix.can_apply_to(item_type, rarity):
|
|
continue
|
|
|
|
# Apply tier filter if specified
|
|
if tier and affix.tier != tier:
|
|
continue
|
|
|
|
eligible.append(affix)
|
|
|
|
return eligible
|
|
|
|
def get_random_prefix(
|
|
self,
|
|
item_type: str,
|
|
rarity: str,
|
|
tier: Optional[AffixTier] = None,
|
|
exclude_ids: Optional[List[str]] = None
|
|
) -> Optional[Affix]:
|
|
"""
|
|
Get a random eligible prefix.
|
|
|
|
Args:
|
|
item_type: Type of item ("weapon", "armor")
|
|
rarity: Item rarity
|
|
tier: Optional tier filter
|
|
exclude_ids: Affix IDs to exclude (for avoiding duplicates)
|
|
|
|
Returns:
|
|
Random eligible Affix or None if none available
|
|
"""
|
|
eligible = self.get_eligible_prefixes(item_type, rarity, tier)
|
|
|
|
# Filter out excluded IDs
|
|
if exclude_ids:
|
|
eligible = [a for a in eligible if a.affix_id not in exclude_ids]
|
|
|
|
if not eligible:
|
|
return None
|
|
|
|
return random.choice(eligible)
|
|
|
|
def get_random_suffix(
|
|
self,
|
|
item_type: str,
|
|
rarity: str,
|
|
tier: Optional[AffixTier] = None,
|
|
exclude_ids: Optional[List[str]] = None
|
|
) -> Optional[Affix]:
|
|
"""
|
|
Get a random eligible suffix.
|
|
|
|
Args:
|
|
item_type: Type of item ("weapon", "armor")
|
|
rarity: Item rarity
|
|
tier: Optional tier filter
|
|
exclude_ids: Affix IDs to exclude (for avoiding duplicates)
|
|
|
|
Returns:
|
|
Random eligible Affix or None if none available
|
|
"""
|
|
eligible = self.get_eligible_suffixes(item_type, rarity, tier)
|
|
|
|
# Filter out excluded IDs
|
|
if exclude_ids:
|
|
eligible = [a for a in eligible if a.affix_id not in exclude_ids]
|
|
|
|
if not eligible:
|
|
return None
|
|
|
|
return random.choice(eligible)
|
|
|
|
def get_all_prefixes(self) -> Dict[str, Affix]:
|
|
"""
|
|
Get all cached prefixes.
|
|
|
|
Returns:
|
|
Dictionary of prefix affixes
|
|
"""
|
|
self._ensure_loaded()
|
|
return self._prefix_cache.copy()
|
|
|
|
def get_all_suffixes(self) -> Dict[str, Affix]:
|
|
"""
|
|
Get all cached suffixes.
|
|
|
|
Returns:
|
|
Dictionary of suffix affixes
|
|
"""
|
|
self._ensure_loaded()
|
|
return self._suffix_cache.copy()
|
|
|
|
def clear_cache(self) -> None:
|
|
"""Clear the affix cache, forcing reload on next access."""
|
|
self._prefix_cache.clear()
|
|
self._suffix_cache.clear()
|
|
self._loaded = False
|
|
logger.debug("Affix cache cleared")
|
|
|
|
|
|
# Global instance for convenience
|
|
_loader_instance: Optional[AffixLoader] = None
|
|
|
|
|
|
def get_affix_loader() -> AffixLoader:
|
|
"""
|
|
Get the global AffixLoader instance.
|
|
|
|
Returns:
|
|
Singleton AffixLoader instance
|
|
"""
|
|
global _loader_instance
|
|
if _loader_instance is None:
|
|
_loader_instance = AffixLoader()
|
|
return _loader_instance
|