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,177 @@
# Item Prefix Affixes
# Prefixes appear before the item name: "Flaming Dagger"
#
# Affix Structure:
# affix_id: Unique identifier
# name: Display name (what appears in the item name)
# affix_type: "prefix"
# tier: "minor" (RARE), "major" (EPIC), "legendary" (LEGENDARY only)
# description: Flavor text describing the effect
# stat_bonuses: Dict of stat_name -> bonus value
# defense_bonus: Direct defense bonus
# resistance_bonus: Direct resistance bonus
# damage_bonus: Flat damage bonus (weapons)
# damage_type: Elemental damage type
# elemental_ratio: Portion converted to elemental (0.0-1.0)
# crit_chance_bonus: Added to crit chance
# crit_multiplier_bonus: Added to crit multiplier
# allowed_item_types: [] = all types, or ["weapon", "armor"]
# required_rarity: null = any, or "legendary"
prefixes:
# ==================== ELEMENTAL PREFIXES (FIRE) ====================
flaming:
affix_id: "flaming"
name: "Flaming"
affix_type: "prefix"
tier: "minor"
description: "Imbued with fire magic, dealing bonus fire damage"
damage_type: "fire"
elemental_ratio: 0.25
damage_bonus: 3
allowed_item_types: ["weapon"]
blazing:
affix_id: "blazing"
name: "Blazing"
affix_type: "prefix"
tier: "major"
description: "Wreathed in intense flames"
damage_type: "fire"
elemental_ratio: 0.35
damage_bonus: 6
allowed_item_types: ["weapon"]
# ==================== ELEMENTAL PREFIXES (ICE) ====================
frozen:
affix_id: "frozen"
name: "Frozen"
affix_type: "prefix"
tier: "minor"
description: "Enchanted with frost magic"
damage_type: "ice"
elemental_ratio: 0.25
damage_bonus: 3
allowed_item_types: ["weapon"]
glacial:
affix_id: "glacial"
name: "Glacial"
affix_type: "prefix"
tier: "major"
description: "Encased in eternal ice"
damage_type: "ice"
elemental_ratio: 0.35
damage_bonus: 6
allowed_item_types: ["weapon"]
# ==================== ELEMENTAL PREFIXES (LIGHTNING) ====================
shocking:
affix_id: "shocking"
name: "Shocking"
affix_type: "prefix"
tier: "minor"
description: "Crackles with electrical energy"
damage_type: "lightning"
elemental_ratio: 0.25
damage_bonus: 3
allowed_item_types: ["weapon"]
thundering:
affix_id: "thundering"
name: "Thundering"
affix_type: "prefix"
tier: "major"
description: "Charged with the power of storms"
damage_type: "lightning"
elemental_ratio: 0.35
damage_bonus: 6
allowed_item_types: ["weapon"]
# ==================== MATERIAL PREFIXES ====================
iron:
affix_id: "iron"
name: "Iron"
affix_type: "prefix"
tier: "minor"
description: "Reinforced with sturdy iron"
stat_bonuses:
constitution: 1
defense_bonus: 2
steel:
affix_id: "steel"
name: "Steel"
affix_type: "prefix"
tier: "major"
description: "Forged from fine steel"
stat_bonuses:
constitution: 2
strength: 1
defense_bonus: 4
# ==================== QUALITY PREFIXES ====================
sharp:
affix_id: "sharp"
name: "Sharp"
affix_type: "prefix"
tier: "minor"
description: "Honed to a fine edge"
damage_bonus: 3
crit_chance_bonus: 0.02
allowed_item_types: ["weapon"]
keen:
affix_id: "keen"
name: "Keen"
affix_type: "prefix"
tier: "major"
description: "Razor-sharp edge that finds weak points"
damage_bonus: 5
crit_chance_bonus: 0.04
allowed_item_types: ["weapon"]
# ==================== DEFENSIVE PREFIXES ====================
sturdy:
affix_id: "sturdy"
name: "Sturdy"
affix_type: "prefix"
tier: "minor"
description: "Built to withstand punishment"
defense_bonus: 3
allowed_item_types: ["armor"]
reinforced:
affix_id: "reinforced"
name: "Reinforced"
affix_type: "prefix"
tier: "major"
description: "Heavily reinforced for maximum protection"
defense_bonus: 5
resistance_bonus: 2
allowed_item_types: ["armor"]
# ==================== LEGENDARY PREFIXES ====================
infernal:
affix_id: "infernal"
name: "Infernal"
affix_type: "prefix"
tier: "legendary"
description: "Burns with hellfire"
damage_type: "fire"
elemental_ratio: 0.45
damage_bonus: 12
allowed_item_types: ["weapon"]
required_rarity: "legendary"
vorpal:
affix_id: "vorpal"
name: "Vorpal"
affix_type: "prefix"
tier: "legendary"
description: "Cuts through anything with supernatural precision"
damage_bonus: 10
crit_chance_bonus: 0.08
crit_multiplier_bonus: 0.5
allowed_item_types: ["weapon"]
required_rarity: "legendary"

View File

@@ -0,0 +1,155 @@
# Item Suffix Affixes
# Suffixes appear after the item name: "Dagger of Strength"
#
# Suffix naming convention:
# - Minor tier: "of [Stat]" (e.g., "of Strength")
# - Major tier: "of the [Animal/Element]" (e.g., "of the Bear")
# - Legendary tier: "of the [Mythical]" (e.g., "of the Titan")
suffixes:
# ==================== STAT SUFFIXES (MINOR) ====================
of_strength:
affix_id: "of_strength"
name: "of Strength"
affix_type: "suffix"
tier: "minor"
description: "Grants physical power"
stat_bonuses:
strength: 2
of_dexterity:
affix_id: "of_dexterity"
name: "of Dexterity"
affix_type: "suffix"
tier: "minor"
description: "Grants agility and precision"
stat_bonuses:
dexterity: 2
of_constitution:
affix_id: "of_constitution"
name: "of Fortitude"
affix_type: "suffix"
tier: "minor"
description: "Grants endurance"
stat_bonuses:
constitution: 2
of_intelligence:
affix_id: "of_intelligence"
name: "of Intelligence"
affix_type: "suffix"
tier: "minor"
description: "Grants magical aptitude"
stat_bonuses:
intelligence: 2
of_wisdom:
affix_id: "of_wisdom"
name: "of Wisdom"
affix_type: "suffix"
tier: "minor"
description: "Grants insight and perception"
stat_bonuses:
wisdom: 2
of_charisma:
affix_id: "of_charisma"
name: "of Charm"
affix_type: "suffix"
tier: "minor"
description: "Grants social influence"
stat_bonuses:
charisma: 2
of_luck:
affix_id: "of_luck"
name: "of Fortune"
affix_type: "suffix"
tier: "minor"
description: "Grants favor from fate"
stat_bonuses:
luck: 2
# ==================== ENHANCED STAT SUFFIXES (MAJOR) ====================
of_the_bear:
affix_id: "of_the_bear"
name: "of the Bear"
affix_type: "suffix"
tier: "major"
description: "Grants the might and endurance of a bear"
stat_bonuses:
strength: 4
constitution: 2
of_the_fox:
affix_id: "of_the_fox"
name: "of the Fox"
affix_type: "suffix"
tier: "major"
description: "Grants the cunning and agility of a fox"
stat_bonuses:
dexterity: 4
luck: 2
of_the_owl:
affix_id: "of_the_owl"
name: "of the Owl"
affix_type: "suffix"
tier: "major"
description: "Grants the wisdom and insight of an owl"
stat_bonuses:
intelligence: 3
wisdom: 3
# ==================== DEFENSIVE SUFFIXES ====================
of_protection:
affix_id: "of_protection"
name: "of Protection"
affix_type: "suffix"
tier: "minor"
description: "Offers physical protection"
defense_bonus: 3
of_warding:
affix_id: "of_warding"
name: "of Warding"
affix_type: "suffix"
tier: "major"
description: "Wards against physical and magical harm"
defense_bonus: 5
resistance_bonus: 3
# ==================== LEGENDARY SUFFIXES ====================
of_the_titan:
affix_id: "of_the_titan"
name: "of the Titan"
affix_type: "suffix"
tier: "legendary"
description: "Grants titanic strength and endurance"
stat_bonuses:
strength: 8
constitution: 4
required_rarity: "legendary"
of_the_wind:
affix_id: "of_the_wind"
name: "of the Wind"
affix_type: "suffix"
tier: "legendary"
description: "Swift as the wind itself"
stat_bonuses:
dexterity: 8
luck: 4
crit_chance_bonus: 0.05
required_rarity: "legendary"
of_invincibility:
affix_id: "of_invincibility"
name: "of Invincibility"
affix_type: "suffix"
tier: "legendary"
description: "Grants supreme protection"
defense_bonus: 10
resistance_bonus: 8
required_rarity: "legendary"

View File

@@ -0,0 +1,152 @@
# Base Armor Templates for Procedural Generation
#
# These templates define the foundation that affixes attach to.
# Example: "Leather Vest" + "Sturdy" prefix = "Sturdy Leather Vest"
#
# Armor categories:
# - Cloth: Low defense, high resistance (mages)
# - Leather: Balanced defense/resistance (rogues)
# - Chain: Medium defense, low resistance (versatile)
# - Plate: High defense, low resistance (warriors)
armor:
# ==================== CLOTH (MAGE ARMOR) ====================
cloth_robe:
template_id: "cloth_robe"
name: "Cloth Robe"
item_type: "armor"
description: "Simple cloth robes favored by spellcasters"
base_defense: 2
base_resistance: 5
base_value: 15
required_level: 1
drop_weight: 1.3
silk_robe:
template_id: "silk_robe"
name: "Silk Robe"
item_type: "armor"
description: "Fine silk robes that channel magical energy"
base_defense: 3
base_resistance: 8
base_value: 40
required_level: 3
drop_weight: 0.9
arcane_vestments:
template_id: "arcane_vestments"
name: "Arcane Vestments"
item_type: "armor"
description: "Robes woven with magical threads"
base_defense: 5
base_resistance: 12
base_value: 80
required_level: 5
drop_weight: 0.6
min_rarity: "uncommon"
# ==================== LEATHER (ROGUE ARMOR) ====================
leather_vest:
template_id: "leather_vest"
name: "Leather Vest"
item_type: "armor"
description: "Basic leather protection for agile fighters"
base_defense: 5
base_resistance: 2
base_value: 20
required_level: 1
drop_weight: 1.3
studded_leather:
template_id: "studded_leather"
name: "Studded Leather"
item_type: "armor"
description: "Leather armor reinforced with metal studs"
base_defense: 8
base_resistance: 3
base_value: 45
required_level: 3
drop_weight: 1.0
hardened_leather:
template_id: "hardened_leather"
name: "Hardened Leather"
item_type: "armor"
description: "Boiled and hardened leather for superior protection"
base_defense: 12
base_resistance: 5
base_value: 75
required_level: 5
drop_weight: 0.7
min_rarity: "uncommon"
# ==================== CHAIN (VERSATILE) ====================
chain_shirt:
template_id: "chain_shirt"
name: "Chain Shirt"
item_type: "armor"
description: "A shirt of interlocking metal rings"
base_defense: 7
base_resistance: 2
base_value: 35
required_level: 2
drop_weight: 1.0
chainmail:
template_id: "chainmail"
name: "Chainmail"
item_type: "armor"
description: "Full chainmail armor covering torso and arms"
base_defense: 10
base_resistance: 3
base_value: 50
required_level: 3
drop_weight: 1.0
heavy_chainmail:
template_id: "heavy_chainmail"
name: "Heavy Chainmail"
item_type: "armor"
description: "Thick chainmail with reinforced rings"
base_defense: 14
base_resistance: 4
base_value: 85
required_level: 5
drop_weight: 0.7
min_rarity: "uncommon"
# ==================== PLATE (WARRIOR ARMOR) ====================
scale_mail:
template_id: "scale_mail"
name: "Scale Mail"
item_type: "armor"
description: "Overlapping metal scales on leather backing"
base_defense: 12
base_resistance: 2
base_value: 60
required_level: 4
drop_weight: 0.8
half_plate:
template_id: "half_plate"
name: "Half Plate"
item_type: "armor"
description: "Plate armor protecting vital areas"
base_defense: 16
base_resistance: 2
base_value: 120
required_level: 6
drop_weight: 0.5
min_rarity: "rare"
plate_armor:
template_id: "plate_armor"
name: "Plate Armor"
item_type: "armor"
description: "Full metal plate protection"
base_defense: 22
base_resistance: 3
base_value: 200
required_level: 7
drop_weight: 0.4
min_rarity: "rare"

View File

@@ -0,0 +1,182 @@
# Base Weapon Templates for Procedural Generation
#
# These templates define the foundation that affixes attach to.
# Example: "Dagger" + "Flaming" prefix = "Flaming Dagger"
#
# Template Structure:
# template_id: Unique identifier
# name: Base item name
# item_type: "weapon"
# description: Flavor text
# base_damage: Weapon damage
# base_value: Gold value
# damage_type: "physical" (default)
# crit_chance: Critical hit chance (0.0-1.0)
# crit_multiplier: Crit damage multiplier
# required_level: Min level to use/drop
# drop_weight: Higher = more common (1.0 = standard)
# min_rarity: Minimum rarity for this template
weapons:
# ==================== ONE-HANDED SWORDS ====================
dagger:
template_id: "dagger"
name: "Dagger"
item_type: "weapon"
description: "A small, quick blade for close combat"
base_damage: 6
base_value: 15
damage_type: "physical"
crit_chance: 0.08
crit_multiplier: 2.0
required_level: 1
drop_weight: 1.5
short_sword:
template_id: "short_sword"
name: "Short Sword"
item_type: "weapon"
description: "A versatile one-handed blade"
base_damage: 10
base_value: 30
damage_type: "physical"
crit_chance: 0.06
crit_multiplier: 2.0
required_level: 1
drop_weight: 1.3
longsword:
template_id: "longsword"
name: "Longsword"
item_type: "weapon"
description: "A standard warrior's blade"
base_damage: 14
base_value: 50
damage_type: "physical"
crit_chance: 0.05
crit_multiplier: 2.0
required_level: 3
drop_weight: 1.0
# ==================== TWO-HANDED WEAPONS ====================
greatsword:
template_id: "greatsword"
name: "Greatsword"
item_type: "weapon"
description: "A massive two-handed blade"
base_damage: 22
base_value: 100
damage_type: "physical"
crit_chance: 0.04
crit_multiplier: 2.5
required_level: 5
drop_weight: 0.7
min_rarity: "uncommon"
# ==================== AXES ====================
hatchet:
template_id: "hatchet"
name: "Hatchet"
item_type: "weapon"
description: "A small throwing axe"
base_damage: 8
base_value: 20
damage_type: "physical"
crit_chance: 0.06
crit_multiplier: 2.2
required_level: 1
drop_weight: 1.2
battle_axe:
template_id: "battle_axe"
name: "Battle Axe"
item_type: "weapon"
description: "A heavy axe designed for combat"
base_damage: 16
base_value: 60
damage_type: "physical"
crit_chance: 0.05
crit_multiplier: 2.3
required_level: 4
drop_weight: 0.9
# ==================== BLUNT WEAPONS ====================
club:
template_id: "club"
name: "Club"
item_type: "weapon"
description: "A simple wooden club"
base_damage: 7
base_value: 10
damage_type: "physical"
crit_chance: 0.04
crit_multiplier: 2.0
required_level: 1
drop_weight: 1.5
mace:
template_id: "mace"
name: "Mace"
item_type: "weapon"
description: "A flanged mace for crushing armor"
base_damage: 12
base_value: 40
damage_type: "physical"
crit_chance: 0.05
crit_multiplier: 2.0
required_level: 2
drop_weight: 1.0
# ==================== STAVES ====================
quarterstaff:
template_id: "quarterstaff"
name: "Quarterstaff"
item_type: "weapon"
description: "A simple wooden staff"
base_damage: 6
base_value: 10
damage_type: "physical"
crit_chance: 0.05
crit_multiplier: 2.0
required_level: 1
drop_weight: 1.2
wizard_staff:
template_id: "wizard_staff"
name: "Wizard Staff"
item_type: "weapon"
description: "A staff attuned to magical energy"
base_damage: 8
base_value: 45
damage_type: "physical"
crit_chance: 0.05
crit_multiplier: 2.0
required_level: 3
drop_weight: 0.8
# ==================== RANGED ====================
shortbow:
template_id: "shortbow"
name: "Shortbow"
item_type: "weapon"
description: "A compact bow for quick shots"
base_damage: 8
base_value: 25
damage_type: "physical"
crit_chance: 0.07
crit_multiplier: 2.0
required_level: 1
drop_weight: 1.1
longbow:
template_id: "longbow"
name: "Longbow"
item_type: "weapon"
description: "A powerful bow with excellent range"
base_damage: 14
base_value: 55
damage_type: "physical"
crit_chance: 0.08
crit_multiplier: 2.2
required_level: 4
drop_weight: 0.9

303
api/app/models/affixes.py Normal file
View File

@@ -0,0 +1,303 @@
"""
Item affix system for procedural item generation.
This module defines affixes (prefixes and suffixes) that can be attached to items
to provide stat bonuses and generate Diablo-style names like "Flaming Dagger of Strength".
"""
from dataclasses import dataclass, field, asdict
from typing import Dict, Any, List, Optional
from app.models.enums import AffixType, AffixTier, DamageType, ItemRarity
@dataclass
class Affix:
"""
Represents a single item affix (prefix or suffix).
Affixes provide stat bonuses and contribute to item naming.
Prefixes appear before the item name: "Flaming Dagger"
Suffixes appear after the item name: "Dagger of Strength"
Attributes:
affix_id: Unique identifier (e.g., "flaming", "of_strength")
name: Display name for the affix (e.g., "Flaming", "of Strength")
affix_type: PREFIX or SUFFIX
tier: MINOR, MAJOR, or LEGENDARY (determines bonus magnitude)
description: Human-readable description of the affix effect
Stat Bonuses:
stat_bonuses: Dict mapping stat name to bonus value
Example: {"strength": 2, "constitution": 1}
defense_bonus: Direct defense bonus
resistance_bonus: Direct resistance bonus
Weapon Properties (PREFIX only, elemental):
damage_bonus: Flat damage bonus added to weapon
damage_type: Elemental damage type (fire, ice, etc.)
elemental_ratio: Portion of damage converted to elemental (0.0-1.0)
crit_chance_bonus: Added to weapon crit chance
crit_multiplier_bonus: Added to crit damage multiplier
Restrictions:
allowed_item_types: Empty list = all types allowed
required_rarity: Minimum rarity to roll this affix (for legendary-only)
"""
affix_id: str
name: str
affix_type: AffixType
tier: AffixTier
description: str = ""
# Stat bonuses (applies to any item)
stat_bonuses: Dict[str, int] = field(default_factory=dict)
defense_bonus: int = 0
resistance_bonus: int = 0
# Weapon-specific bonuses
damage_bonus: int = 0
damage_type: Optional[DamageType] = None
elemental_ratio: float = 0.0
crit_chance_bonus: float = 0.0
crit_multiplier_bonus: float = 0.0
# Restrictions
allowed_item_types: List[str] = field(default_factory=list)
required_rarity: Optional[str] = None
def applies_elemental_damage(self) -> bool:
"""
Check if this affix converts damage to elemental.
Returns:
True if affix adds elemental damage component
"""
return self.damage_type is not None and self.elemental_ratio > 0.0
def is_legendary_only(self) -> bool:
"""
Check if this affix only rolls on legendary items.
Returns:
True if affix requires legendary rarity
"""
return self.required_rarity == "legendary"
def can_apply_to(self, item_type: str, rarity: str) -> bool:
"""
Check if this affix can be applied to an item.
Args:
item_type: Type of item ("weapon", "armor", etc.)
rarity: Item rarity ("common", "rare", "epic", "legendary")
Returns:
True if affix can be applied, False otherwise
"""
# Check rarity requirement
if self.required_rarity and rarity != self.required_rarity:
return False
# Check item type restriction
if self.allowed_item_types and item_type not in self.allowed_item_types:
return False
return True
def to_dict(self) -> Dict[str, Any]:
"""
Serialize affix to dictionary.
Returns:
Dictionary containing all affix data
"""
data = asdict(self)
data["affix_type"] = self.affix_type.value
data["tier"] = self.tier.value
if self.damage_type:
data["damage_type"] = self.damage_type.value
return data
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'Affix':
"""
Deserialize affix from dictionary.
Args:
data: Dictionary containing affix data
Returns:
Affix instance
"""
affix_type = AffixType(data["affix_type"])
tier = AffixTier(data["tier"])
damage_type = DamageType(data["damage_type"]) if data.get("damage_type") else None
return cls(
affix_id=data["affix_id"],
name=data["name"],
affix_type=affix_type,
tier=tier,
description=data.get("description", ""),
stat_bonuses=data.get("stat_bonuses", {}),
defense_bonus=data.get("defense_bonus", 0),
resistance_bonus=data.get("resistance_bonus", 0),
damage_bonus=data.get("damage_bonus", 0),
damage_type=damage_type,
elemental_ratio=data.get("elemental_ratio", 0.0),
crit_chance_bonus=data.get("crit_chance_bonus", 0.0),
crit_multiplier_bonus=data.get("crit_multiplier_bonus", 0.0),
allowed_item_types=data.get("allowed_item_types", []),
required_rarity=data.get("required_rarity"),
)
def __repr__(self) -> str:
"""String representation of the affix."""
bonuses = []
if self.stat_bonuses:
bonuses.append(f"stats={self.stat_bonuses}")
if self.damage_bonus:
bonuses.append(f"dmg+{self.damage_bonus}")
if self.defense_bonus:
bonuses.append(f"def+{self.defense_bonus}")
if self.applies_elemental_damage():
bonuses.append(f"{self.damage_type.value}={self.elemental_ratio:.0%}")
bonus_str = ", ".join(bonuses) if bonuses else "no bonuses"
return f"Affix({self.name}, {self.affix_type.value}, {self.tier.value}, {bonus_str})"
@dataclass
class BaseItemTemplate:
"""
Template for base items used in procedural generation.
Base items define the foundation (e.g., "Dagger", "Longsword", "Chainmail")
that affixes attach to during item generation.
Attributes:
template_id: Unique identifier (e.g., "dagger", "longsword")
name: Display name (e.g., "Dagger", "Longsword")
item_type: Category ("weapon", "armor")
description: Flavor text for the base item
Base Stats:
base_damage: Base weapon damage (weapons only)
base_defense: Base armor defense (armor only)
base_resistance: Base magic resistance (armor only)
base_value: Base gold value before rarity/affix modifiers
Weapon Properties:
damage_type: Primary damage type (usually "physical")
crit_chance: Base critical hit chance
crit_multiplier: Base critical damage multiplier
Generation:
required_level: Minimum character level for this template
drop_weight: Weighting for random selection (higher = more common)
min_rarity: Minimum rarity this template can generate at
"""
template_id: str
name: str
item_type: str # "weapon" or "armor"
description: str = ""
# Base stats
base_damage: int = 0
base_defense: int = 0
base_resistance: int = 0
base_value: int = 10
# Weapon properties
damage_type: str = "physical"
crit_chance: float = 0.05
crit_multiplier: float = 2.0
# Generation settings
required_level: int = 1
drop_weight: float = 1.0
min_rarity: str = "common"
def can_generate_at_rarity(self, rarity: str) -> bool:
"""
Check if this template can generate at a given rarity.
Some templates (like greatswords) may only drop at rare+.
Args:
rarity: Target rarity to check
Returns:
True if template can generate at this rarity
"""
rarity_order = ["common", "uncommon", "rare", "epic", "legendary"]
min_index = rarity_order.index(self.min_rarity)
target_index = rarity_order.index(rarity)
return target_index >= min_index
def can_drop_for_level(self, character_level: int) -> bool:
"""
Check if this template can drop for a character level.
Args:
character_level: Character's current level
Returns:
True if template can drop for this level
"""
return character_level >= self.required_level
def to_dict(self) -> Dict[str, Any]:
"""
Serialize template to dictionary.
Returns:
Dictionary containing all template data
"""
return asdict(self)
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'BaseItemTemplate':
"""
Deserialize template from dictionary.
Args:
data: Dictionary containing template data
Returns:
BaseItemTemplate instance
"""
return cls(
template_id=data["template_id"],
name=data["name"],
item_type=data["item_type"],
description=data.get("description", ""),
base_damage=data.get("base_damage", 0),
base_defense=data.get("base_defense", 0),
base_resistance=data.get("base_resistance", 0),
base_value=data.get("base_value", 10),
damage_type=data.get("damage_type", "physical"),
crit_chance=data.get("crit_chance", 0.05),
crit_multiplier=data.get("crit_multiplier", 2.0),
required_level=data.get("required_level", 1),
drop_weight=data.get("drop_weight", 1.0),
min_rarity=data.get("min_rarity", "common"),
)
def __repr__(self) -> str:
"""String representation of the template."""
if self.item_type == "weapon":
return (
f"BaseItemTemplate({self.name}, weapon, dmg={self.base_damage}, "
f"crit={self.crit_chance*100:.0f}%, lvl={self.required_level})"
)
elif self.item_type == "armor":
return (
f"BaseItemTemplate({self.name}, armor, def={self.base_defense}, "
f"res={self.base_resistance}, lvl={self.required_level})"
)
else:
return f"BaseItemTemplate({self.name}, {self.item_type})"

View File

@@ -50,6 +50,21 @@ class ItemRarity(Enum):
LEGENDARY = "legendary" # Orange/gold - best items
class AffixType(Enum):
"""Types of item affixes for procedural item generation."""
PREFIX = "prefix" # Appears before item name: "Flaming Dagger"
SUFFIX = "suffix" # Appears after item name: "Dagger of Strength"
class AffixTier(Enum):
"""Affix power tiers determining bonus magnitudes."""
MINOR = "minor" # Weaker bonuses, rolls on RARE items
MAJOR = "major" # Medium bonuses, rolls on EPIC items
LEGENDARY = "legendary" # Strongest bonuses, LEGENDARY only
class StatType(Enum):
"""Character attribute types."""

View File

@@ -81,6 +81,24 @@ class Item:
required_level: int = 1
required_class: Optional[str] = None
# Affix tracking (for procedurally generated items)
applied_affixes: List[str] = field(default_factory=list) # List of affix_ids
base_template_id: Optional[str] = None # ID of base item template used
generated_name: Optional[str] = None # Full generated name with affixes
is_generated: bool = False # True if created by item generator
def get_display_name(self) -> str:
"""
Get the item's display name.
For generated items, returns the affix-enhanced name.
For static items, returns the base name.
Returns:
Display name string
"""
return self.generated_name or self.name
def is_weapon(self) -> bool:
"""Check if this item is a weapon."""
return self.item_type == ItemType.WEAPON
@@ -166,6 +184,8 @@ class Item:
if self.elemental_damage_type:
data["elemental_damage_type"] = self.elemental_damage_type.value
data["effects_on_use"] = [effect.to_dict() for effect in self.effects_on_use]
# Include display_name for convenience
data["display_name"] = self.get_display_name()
return data
@classmethod
@@ -215,6 +235,11 @@ class Item:
resistance=data.get("resistance", 0),
required_level=data.get("required_level", 1),
required_class=data.get("required_class"),
# Affix tracking fields
applied_affixes=data.get("applied_affixes", []),
base_template_id=data.get("base_template_id"),
generated_name=data.get("generated_name"),
is_generated=data.get("is_generated", False),
)
def __repr__(self) -> str:

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