Files
Code_of_Conquest/api/app/services/item_generator.py
Phillip Tarrant a38906b445 feat(api): integrate equipment stats into combat damage system
Equipment-Combat Integration:
- Update Stats damage formula from STR//2 to int(STR*0.75) for better scaling
- Add spell_power system for magical weapons (staves, wands)
- Add spell_power_bonus field to Stats model with spell_power property
- Add spell_power field to Item model with is_magical_weapon() method
- Update Character.get_effective_stats() to populate spell_power_bonus

Combatant Model Updates:
- Add weapon property fields (crit_chance, crit_multiplier, damage_type)
- Add elemental weapon support (elemental_damage_type, physical_ratio, elemental_ratio)
- Update serialization to handle new weapon properties

DamageCalculator Refactoring:
- Remove weapon_damage parameter from calculate_physical_damage()
- Use attacker_stats.damage directly (includes weapon bonus)
- Use attacker_stats.spell_power for magical damage calculations

Combat Service Updates:
- Extract weapon properties in _create_combatant_from_character()
- Use stats.damage_bonus for enemy combatants from templates
- Remove hardcoded _get_weapon_damage() method
- Handle elemental weapons with split damage in _execute_attack()

Item Generation Updates:
- Add base_spell_power to BaseItemTemplate dataclass
- Add ARCANE damage type to DamageType enum
- Add magical weapon templates (wizard_staff, arcane_staff, wand, crystal_wand)

Test Updates:
- Update test_stats.py for new damage formula (0.75 scaling)
- Update test_character.py for equipment bonus calculations
- Update test_damage_calculator.py for new API signatures
- Update test_combat_service.py mock fixture for equipped attribute

Tests: 174 passing
2025-11-26 19:54:58 -06:00

537 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"]
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