feat(api): implement Diablo-style item affix system
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)
This commit is contained in:
315
api/app/services/affix_loader.py
Normal file
315
api/app/services/affix_loader.py
Normal file
@@ -0,0 +1,315 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user