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)
535 lines
16 KiB
Python
535 lines
16 KiB
Python
"""
|
|
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"]
|
|
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,
|
|
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
|