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:
534
api/app/services/item_generator.py
Normal file
534
api/app/services/item_generator.py
Normal file
@@ -0,0 +1,534 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user