""" Item Generator Service - Procedural item generation with affixes. This service generates Diablo-style items by combining base templates with random affixes, creating items like "Flaming Dagger of Strength". """ import uuid import random from typing import List, Optional, Tuple, Dict, Any from app.models.items import Item from app.models.affixes import Affix, BaseItemTemplate from app.models.enums import ItemType, ItemRarity, DamageType, AffixTier from app.services.affix_loader import get_affix_loader, AffixLoader from app.services.base_item_loader import get_base_item_loader, BaseItemLoader from app.utils.logging import get_logger logger = get_logger(__file__) # Affix count by rarity (COMMON/UNCOMMON get 0 affixes - plain items) AFFIX_COUNTS = { ItemRarity.COMMON: 0, ItemRarity.UNCOMMON: 0, ItemRarity.RARE: 1, ItemRarity.EPIC: 2, ItemRarity.LEGENDARY: 3, } # Tier selection probabilities by rarity # Higher rarity items have better chance at higher tier affixes TIER_WEIGHTS = { ItemRarity.RARE: { AffixTier.MINOR: 0.8, AffixTier.MAJOR: 0.2, AffixTier.LEGENDARY: 0.0, }, ItemRarity.EPIC: { AffixTier.MINOR: 0.3, AffixTier.MAJOR: 0.7, AffixTier.LEGENDARY: 0.0, }, ItemRarity.LEGENDARY: { AffixTier.MINOR: 0.1, AffixTier.MAJOR: 0.4, AffixTier.LEGENDARY: 0.5, }, } # Rarity value multipliers (higher rarity = more valuable) RARITY_VALUE_MULTIPLIER = { ItemRarity.COMMON: 1.0, ItemRarity.UNCOMMON: 1.5, ItemRarity.RARE: 2.5, ItemRarity.EPIC: 5.0, ItemRarity.LEGENDARY: 10.0, } class ItemGenerator: """ Generates procedural items with Diablo-style naming. This service combines base item templates with randomly selected affixes to create unique items with combined stats and generated names. """ def __init__( self, affix_loader: Optional[AffixLoader] = None, base_item_loader: Optional[BaseItemLoader] = None ): """ Initialize the item generator. Args: affix_loader: Optional custom AffixLoader instance base_item_loader: Optional custom BaseItemLoader instance """ self.affix_loader = affix_loader or get_affix_loader() self.base_item_loader = base_item_loader or get_base_item_loader() logger.info("ItemGenerator initialized") def generate_item( self, item_type: str, rarity: ItemRarity, character_level: int = 1, base_template_id: Optional[str] = None ) -> Optional[Item]: """ Generate a procedural item. Args: item_type: "weapon" or "armor" rarity: Target rarity character_level: Player level for template eligibility base_template_id: Optional specific base template to use Returns: Generated Item instance or None if generation fails """ # 1. Get base template base_template = self._get_base_template( item_type, rarity, character_level, base_template_id ) if not base_template: logger.warning( "No base template available", item_type=item_type, rarity=rarity.value, level=character_level ) return None # 2. Get affix count for this rarity affix_count = AFFIX_COUNTS.get(rarity, 0) # 3. Select affixes prefixes, suffixes = self._select_affixes( base_template.item_type, rarity, affix_count ) # 4. Build the item item = self._build_item(base_template, rarity, prefixes, suffixes) logger.info( "Item generated", item_id=item.item_id, name=item.get_display_name(), rarity=rarity.value, affixes=[a.affix_id for a in prefixes + suffixes] ) return item def _get_base_template( self, item_type: str, rarity: ItemRarity, character_level: int, template_id: Optional[str] = None ) -> Optional[BaseItemTemplate]: """ Get a base template for item generation. Args: item_type: Type of item rarity: Target rarity character_level: Player level template_id: Optional specific template ID Returns: BaseItemTemplate instance or None """ if template_id: return self.base_item_loader.get_template(template_id) return self.base_item_loader.get_random_template( item_type, rarity.value, character_level ) def _select_affixes( self, item_type: str, rarity: ItemRarity, count: int ) -> Tuple[List[Affix], List[Affix]]: """ Select random affixes for an item. Distribution logic: - RARE (1 affix): 50% chance prefix, 50% chance suffix - EPIC (2 affixes): 1 prefix AND 1 suffix - LEGENDARY (3 affixes): Mix of prefixes and suffixes Args: item_type: Type of item rarity: Item rarity count: Number of affixes to select Returns: Tuple of (prefixes, suffixes) """ prefixes: List[Affix] = [] suffixes: List[Affix] = [] used_ids: List[str] = [] if count == 0: return prefixes, suffixes # Determine tier for affix selection tier = self._roll_affix_tier(rarity) if count == 1: # RARE: Either prefix OR suffix (50/50) if random.random() < 0.5: prefix = self.affix_loader.get_random_prefix( item_type, rarity.value, tier, used_ids ) if prefix: prefixes.append(prefix) used_ids.append(prefix.affix_id) else: suffix = self.affix_loader.get_random_suffix( item_type, rarity.value, tier, used_ids ) if suffix: suffixes.append(suffix) used_ids.append(suffix.affix_id) elif count == 2: # EPIC: 1 prefix AND 1 suffix tier = self._roll_affix_tier(rarity) prefix = self.affix_loader.get_random_prefix( item_type, rarity.value, tier, used_ids ) if prefix: prefixes.append(prefix) used_ids.append(prefix.affix_id) tier = self._roll_affix_tier(rarity) suffix = self.affix_loader.get_random_suffix( item_type, rarity.value, tier, used_ids ) if suffix: suffixes.append(suffix) used_ids.append(suffix.affix_id) elif count >= 3: # LEGENDARY: Mix of prefixes and suffixes # Try: 2 prefixes + 1 suffix OR 1 prefix + 2 suffixes distribution = random.choice([(2, 1), (1, 2)]) prefix_count, suffix_count = distribution for _ in range(prefix_count): tier = self._roll_affix_tier(rarity) prefix = self.affix_loader.get_random_prefix( item_type, rarity.value, tier, used_ids ) if prefix: prefixes.append(prefix) used_ids.append(prefix.affix_id) for _ in range(suffix_count): tier = self._roll_affix_tier(rarity) suffix = self.affix_loader.get_random_suffix( item_type, rarity.value, tier, used_ids ) if suffix: suffixes.append(suffix) used_ids.append(suffix.affix_id) return prefixes, suffixes def _roll_affix_tier(self, rarity: ItemRarity) -> Optional[AffixTier]: """ Roll for affix tier based on item rarity. Args: rarity: Item rarity Returns: Selected AffixTier or None for no tier filter """ weights = TIER_WEIGHTS.get(rarity) if not weights: return None tiers = list(weights.keys()) tier_weights = list(weights.values()) # Filter out zero-weight options valid_tiers = [] valid_weights = [] for t, w in zip(tiers, tier_weights): if w > 0: valid_tiers.append(t) valid_weights.append(w) if not valid_tiers: return None return random.choices(valid_tiers, weights=valid_weights, k=1)[0] def _build_item( self, base_template: BaseItemTemplate, rarity: ItemRarity, prefixes: List[Affix], suffixes: List[Affix] ) -> Item: """ Build an Item from base template and affixes. Args: base_template: Base item template rarity: Item rarity prefixes: List of prefix affixes suffixes: List of suffix affixes Returns: Fully constructed Item instance """ # Generate unique ID item_id = f"gen_{uuid.uuid4().hex[:12]}" # Build generated name generated_name = self._build_name(base_template.name, prefixes, suffixes) # Combine stats from all affixes combined_stats = self._combine_affix_stats(prefixes + suffixes) # Calculate final item values item_type = ItemType.WEAPON if base_template.item_type == "weapon" else ItemType.ARMOR # Base values from template damage = base_template.base_damage + combined_stats["damage_bonus"] spell_power = base_template.base_spell_power # Magical weapon damage defense = base_template.base_defense + combined_stats["defense_bonus"] resistance = base_template.base_resistance + combined_stats["resistance_bonus"] crit_chance = base_template.crit_chance + combined_stats["crit_chance_bonus"] crit_multiplier = base_template.crit_multiplier + combined_stats["crit_multiplier_bonus"] # Calculate value with rarity multiplier base_value = base_template.base_value rarity_mult = RARITY_VALUE_MULTIPLIER.get(rarity, 1.0) # Add value for each affix affix_value = len(prefixes + suffixes) * 25 final_value = int((base_value + affix_value) * rarity_mult) # Determine elemental damage type (from prefix affixes) elemental_damage_type = None elemental_ratio = 0.0 for prefix in prefixes: if prefix.applies_elemental_damage(): elemental_damage_type = prefix.damage_type elemental_ratio = prefix.elemental_ratio break # Use first elemental prefix # Track applied affixes applied_affixes = [a.affix_id for a in prefixes + suffixes] # Create the item item = Item( item_id=item_id, name=base_template.name, # Base name item_type=item_type, rarity=rarity, description=base_template.description, value=final_value, is_tradeable=True, stat_bonuses=combined_stats["stat_bonuses"], effects_on_use=[], # Not a consumable damage=damage, spell_power=spell_power, # Magical weapon damage bonus damage_type=DamageType(base_template.damage_type) if damage > 0 else None, crit_chance=crit_chance, crit_multiplier=crit_multiplier, elemental_damage_type=elemental_damage_type, physical_ratio=1.0 - elemental_ratio if elemental_ratio > 0 else 1.0, elemental_ratio=elemental_ratio, defense=defense, resistance=resistance, required_level=base_template.required_level, required_class=None, # Affix tracking applied_affixes=applied_affixes, base_template_id=base_template.template_id, generated_name=generated_name, is_generated=True, ) return item def _build_name( self, base_name: str, prefixes: List[Affix], suffixes: List[Affix] ) -> str: """ Build the full item name with affixes. Examples: - RARE (1 prefix): "Flaming Dagger" - RARE (1 suffix): "Dagger of Strength" - EPIC: "Flaming Dagger of Strength" - LEGENDARY: "Blazing Glacial Dagger of the Titan" Note: Rarity is NOT included in name (shown via UI). Args: base_name: Base item name (e.g., "Dagger") prefixes: List of prefix affixes suffixes: List of suffix affixes Returns: Full generated name string """ parts = [] # Add prefix names (in order) for prefix in prefixes: parts.append(prefix.name) # Add base name parts.append(base_name) # Build name string from parts name = " ".join(parts) # Add suffix names (they include "of") for suffix in suffixes: name += f" {suffix.name}" return name def _combine_affix_stats(self, affixes: List[Affix]) -> Dict[str, Any]: """ Combine stats from multiple affixes. Args: affixes: List of affixes to combine Returns: Dictionary with combined stat values """ combined = { "stat_bonuses": {}, "damage_bonus": 0, "defense_bonus": 0, "resistance_bonus": 0, "crit_chance_bonus": 0.0, "crit_multiplier_bonus": 0.0, } for affix in affixes: # Combine stat bonuses for stat_name, bonus in affix.stat_bonuses.items(): current = combined["stat_bonuses"].get(stat_name, 0) combined["stat_bonuses"][stat_name] = current + bonus # Combine direct bonuses combined["damage_bonus"] += affix.damage_bonus combined["defense_bonus"] += affix.defense_bonus combined["resistance_bonus"] += affix.resistance_bonus combined["crit_chance_bonus"] += affix.crit_chance_bonus combined["crit_multiplier_bonus"] += affix.crit_multiplier_bonus return combined def generate_loot_drop( self, character_level: int, luck_stat: int = 8, item_type: Optional[str] = None ) -> Optional[Item]: """ Generate a random loot drop with luck-influenced rarity. Args: character_level: Player level luck_stat: Player's luck stat (affects rarity chance) item_type: Optional item type filter Returns: Generated Item or None """ # Choose random item type if not specified if item_type is None: item_type = random.choice(["weapon", "armor"]) # Roll rarity with luck bonus rarity = self._roll_rarity(luck_stat) return self.generate_item(item_type, rarity, character_level) def _roll_rarity(self, luck_stat: int) -> ItemRarity: """ Roll item rarity with luck bonus. Base chances (luck 8): - COMMON: 50% - UNCOMMON: 30% - RARE: 15% - EPIC: 4% - LEGENDARY: 1% Luck modifies these chances slightly. Args: luck_stat: Player's luck stat Returns: Rolled ItemRarity """ # Calculate luck bonus (luck 8 = baseline) luck_bonus = (luck_stat - 8) * 0.005 roll = random.random() # Thresholds (cumulative) legendary_threshold = 0.01 + luck_bonus epic_threshold = legendary_threshold + 0.04 + luck_bonus * 2 rare_threshold = epic_threshold + 0.15 + luck_bonus * 3 uncommon_threshold = rare_threshold + 0.30 if roll < legendary_threshold: return ItemRarity.LEGENDARY elif roll < epic_threshold: return ItemRarity.EPIC elif roll < rare_threshold: return ItemRarity.RARE elif roll < uncommon_threshold: return ItemRarity.UNCOMMON else: return ItemRarity.COMMON # Global instance for convenience _generator_instance: Optional[ItemGenerator] = None def get_item_generator() -> ItemGenerator: """ Get the global ItemGenerator instance. Returns: Singleton ItemGenerator instance """ global _generator_instance if _generator_instance is None: _generator_instance = ItemGenerator() return _generator_instance