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:
2025-11-26 17:57:34 -06:00
parent f3ac0c8647
commit 185be7fee0
11 changed files with 2658 additions and 0 deletions

View 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

View File

@@ -0,0 +1,273 @@
"""
Base Item Loader Service - YAML-based base item template loading.
This service loads base item templates (weapons, armor) from YAML files,
providing the foundation for procedural item generation.
"""
from pathlib import Path
from typing import Dict, List, Optional
import random
import yaml
from app.models.affixes import BaseItemTemplate
from app.utils.logging import get_logger
logger = get_logger(__file__)
# Rarity order for comparison
RARITY_ORDER = {
"common": 0,
"uncommon": 1,
"rare": 2,
"epic": 3,
"legendary": 4
}
class BaseItemLoader:
"""
Loads and manages base item templates from YAML configuration files.
This allows game designers to define base items without touching code.
Templates are organized into weapons.yaml and armor.yaml files.
"""
def __init__(self, data_dir: Optional[str] = None):
"""
Initialize the base item loader.
Args:
data_dir: Path to directory containing base item YAML files
Defaults to /app/data/base_items/
"""
if data_dir is None:
# Default to app/data/base_items relative to this file
current_file = Path(__file__)
app_dir = current_file.parent.parent # Go up to /app
data_dir = str(app_dir / "data" / "base_items")
self.data_dir = Path(data_dir)
self._weapon_cache: Dict[str, BaseItemTemplate] = {}
self._armor_cache: Dict[str, BaseItemTemplate] = {}
self._loaded = False
logger.info("BaseItemLoader initialized", data_dir=str(self.data_dir))
def _ensure_loaded(self) -> None:
"""Ensure templates are loaded before any operation."""
if not self._loaded:
self.load_all()
def load_all(self) -> None:
"""Load all base item templates from YAML files."""
if not self.data_dir.exists():
logger.warning("Base item data directory not found", path=str(self.data_dir))
return
# Load weapons
weapons_file = self.data_dir / "weapons.yaml"
if weapons_file.exists():
self._load_templates_from_file(weapons_file, "weapons", self._weapon_cache)
# Load armor
armor_file = self.data_dir / "armor.yaml"
if armor_file.exists():
self._load_templates_from_file(armor_file, "armor", self._armor_cache)
self._loaded = True
logger.info(
"Base item templates loaded",
weapon_count=len(self._weapon_cache),
armor_count=len(self._armor_cache)
)
def _load_templates_from_file(
self,
yaml_file: Path,
key: str,
cache: Dict[str, BaseItemTemplate]
) -> None:
"""
Load templates from a YAML file into the cache.
Args:
yaml_file: Path to the YAML file
key: Top-level key in YAML (e.g., "weapons", "armor")
cache: Cache dictionary to populate
"""
try:
with open(yaml_file, 'r') as f:
data = yaml.safe_load(f)
templates_data = data.get(key, {})
for template_id, template_data in templates_data.items():
# Ensure template_id is set
template_data["template_id"] = template_id
# Set defaults for missing optional fields
template_data.setdefault("description", "")
template_data.setdefault("base_damage", 0)
template_data.setdefault("base_defense", 0)
template_data.setdefault("base_resistance", 0)
template_data.setdefault("base_value", 10)
template_data.setdefault("damage_type", "physical")
template_data.setdefault("crit_chance", 0.05)
template_data.setdefault("crit_multiplier", 2.0)
template_data.setdefault("required_level", 1)
template_data.setdefault("drop_weight", 1.0)
template_data.setdefault("min_rarity", "common")
template = BaseItemTemplate.from_dict(template_data)
cache[template.template_id] = template
logger.debug(
"Templates loaded from file",
file=str(yaml_file),
count=len(templates_data)
)
except Exception as e:
logger.error(
"Failed to load base item file",
file=str(yaml_file),
error=str(e)
)
def get_template(self, template_id: str) -> Optional[BaseItemTemplate]:
"""
Get a specific template by ID.
Args:
template_id: Unique template identifier
Returns:
BaseItemTemplate instance or None if not found
"""
self._ensure_loaded()
if template_id in self._weapon_cache:
return self._weapon_cache[template_id]
if template_id in self._armor_cache:
return self._armor_cache[template_id]
return None
def get_eligible_templates(
self,
item_type: str,
rarity: str,
character_level: int = 1
) -> List[BaseItemTemplate]:
"""
Get all templates eligible for generation.
Args:
item_type: Type of item ("weapon", "armor")
rarity: Target rarity
character_level: Player level for eligibility
Returns:
List of eligible BaseItemTemplate instances
"""
self._ensure_loaded()
# Select the appropriate cache
if item_type == "weapon":
cache = self._weapon_cache
elif item_type == "armor":
cache = self._armor_cache
else:
logger.warning("Unknown item type", item_type=item_type)
return []
eligible = []
for template in cache.values():
# Check level requirement
if not template.can_drop_for_level(character_level):
continue
# Check rarity requirement
if not template.can_generate_at_rarity(rarity):
continue
eligible.append(template)
return eligible
def get_random_template(
self,
item_type: str,
rarity: str,
character_level: int = 1
) -> Optional[BaseItemTemplate]:
"""
Get a random eligible template, weighted by drop_weight.
Args:
item_type: Type of item ("weapon", "armor")
rarity: Target rarity
character_level: Player level for eligibility
Returns:
Random eligible BaseItemTemplate or None if none available
"""
eligible = self.get_eligible_templates(item_type, rarity, character_level)
if not eligible:
logger.warning(
"No templates match criteria",
item_type=item_type,
rarity=rarity,
level=character_level
)
return None
# Weighted random selection based on drop_weight
weights = [t.drop_weight for t in eligible]
return random.choices(eligible, weights=weights, k=1)[0]
def get_all_weapons(self) -> Dict[str, BaseItemTemplate]:
"""
Get all cached weapon templates.
Returns:
Dictionary of weapon templates
"""
self._ensure_loaded()
return self._weapon_cache.copy()
def get_all_armor(self) -> Dict[str, BaseItemTemplate]:
"""
Get all cached armor templates.
Returns:
Dictionary of armor templates
"""
self._ensure_loaded()
return self._armor_cache.copy()
def clear_cache(self) -> None:
"""Clear the template cache, forcing reload on next access."""
self._weapon_cache.clear()
self._armor_cache.clear()
self._loaded = False
logger.debug("Base item template cache cleared")
# Global instance for convenience
_loader_instance: Optional[BaseItemLoader] = None
def get_base_item_loader() -> BaseItemLoader:
"""
Get the global BaseItemLoader instance.
Returns:
Singleton BaseItemLoader instance
"""
global _loader_instance
if _loader_instance is None:
_loader_instance = BaseItemLoader()
return _loader_instance

View 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