diff --git a/api/app/data/affixes/prefixes.yaml b/api/app/data/affixes/prefixes.yaml new file mode 100644 index 0000000..294ce2e --- /dev/null +++ b/api/app/data/affixes/prefixes.yaml @@ -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" diff --git a/api/app/data/affixes/suffixes.yaml b/api/app/data/affixes/suffixes.yaml new file mode 100644 index 0000000..abc8a69 --- /dev/null +++ b/api/app/data/affixes/suffixes.yaml @@ -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" diff --git a/api/app/data/base_items/armor.yaml b/api/app/data/base_items/armor.yaml new file mode 100644 index 0000000..a04e23f --- /dev/null +++ b/api/app/data/base_items/armor.yaml @@ -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" diff --git a/api/app/data/base_items/weapons.yaml b/api/app/data/base_items/weapons.yaml new file mode 100644 index 0000000..eb460ec --- /dev/null +++ b/api/app/data/base_items/weapons.yaml @@ -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 diff --git a/api/app/models/affixes.py b/api/app/models/affixes.py new file mode 100644 index 0000000..47538ac --- /dev/null +++ b/api/app/models/affixes.py @@ -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})" diff --git a/api/app/models/enums.py b/api/app/models/enums.py index a2924cd..e8967b1 100644 --- a/api/app/models/enums.py +++ b/api/app/models/enums.py @@ -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.""" diff --git a/api/app/models/items.py b/api/app/models/items.py index 2365a6e..7c39da6 100644 --- a/api/app/models/items.py +++ b/api/app/models/items.py @@ -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: diff --git a/api/app/services/affix_loader.py b/api/app/services/affix_loader.py new file mode 100644 index 0000000..0acafc2 --- /dev/null +++ b/api/app/services/affix_loader.py @@ -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 diff --git a/api/app/services/base_item_loader.py b/api/app/services/base_item_loader.py new file mode 100644 index 0000000..f80f63b --- /dev/null +++ b/api/app/services/base_item_loader.py @@ -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 diff --git a/api/app/services/item_generator.py b/api/app/services/item_generator.py new file mode 100644 index 0000000..2e7c862 --- /dev/null +++ b/api/app/services/item_generator.py @@ -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 diff --git a/api/tests/test_item_generator.py b/api/tests/test_item_generator.py new file mode 100644 index 0000000..4811c33 --- /dev/null +++ b/api/tests/test_item_generator.py @@ -0,0 +1,527 @@ +""" +Tests for the Item Generator and Affix System. + +Tests cover: +- Affix loading from YAML +- Base item template loading +- Item generation with affixes +- Name generation +- Stat combination +""" + +import pytest +from unittest.mock import patch, MagicMock + +from app.models.affixes import Affix, BaseItemTemplate +from app.models.enums import AffixType, AffixTier, ItemRarity, ItemType, DamageType +from app.services.affix_loader import AffixLoader, get_affix_loader +from app.services.base_item_loader import BaseItemLoader, get_base_item_loader +from app.services.item_generator import ItemGenerator, get_item_generator + + +class TestAffixModel: + """Tests for the Affix dataclass.""" + + def test_affix_creation(self): + """Test creating an Affix instance.""" + affix = Affix( + affix_id="flaming", + name="Flaming", + affix_type=AffixType.PREFIX, + tier=AffixTier.MINOR, + description="Fire damage", + damage_type=DamageType.FIRE, + elemental_ratio=0.25, + damage_bonus=3, + ) + + assert affix.affix_id == "flaming" + assert affix.name == "Flaming" + assert affix.affix_type == AffixType.PREFIX + assert affix.tier == AffixTier.MINOR + assert affix.applies_elemental_damage() + + def test_affix_can_apply_to(self): + """Test affix eligibility checking.""" + # Weapon-only affix + weapon_affix = Affix( + affix_id="sharp", + name="Sharp", + affix_type=AffixType.PREFIX, + tier=AffixTier.MINOR, + allowed_item_types=["weapon"], + ) + + assert weapon_affix.can_apply_to("weapon", "rare") + assert not weapon_affix.can_apply_to("armor", "rare") + + def test_affix_legendary_only(self): + """Test legendary-only affix restriction.""" + legendary_affix = Affix( + affix_id="vorpal", + name="Vorpal", + affix_type=AffixType.PREFIX, + tier=AffixTier.LEGENDARY, + required_rarity="legendary", + ) + + assert legendary_affix.is_legendary_only() + assert legendary_affix.can_apply_to("weapon", "legendary") + assert not legendary_affix.can_apply_to("weapon", "epic") + + def test_affix_serialization(self): + """Test affix to_dict and from_dict.""" + affix = Affix( + affix_id="of_strength", + name="of Strength", + affix_type=AffixType.SUFFIX, + tier=AffixTier.MINOR, + stat_bonuses={"strength": 2}, + ) + + data = affix.to_dict() + restored = Affix.from_dict(data) + + assert restored.affix_id == affix.affix_id + assert restored.name == affix.name + assert restored.stat_bonuses == affix.stat_bonuses + + +class TestBaseItemTemplate: + """Tests for the BaseItemTemplate dataclass.""" + + def test_template_creation(self): + """Test creating a BaseItemTemplate instance.""" + template = BaseItemTemplate( + template_id="dagger", + name="Dagger", + item_type="weapon", + base_damage=6, + base_value=15, + crit_chance=0.08, + required_level=1, + ) + + assert template.template_id == "dagger" + assert template.base_damage == 6 + assert template.crit_chance == 0.08 + + def test_template_rarity_eligibility(self): + """Test template rarity checking.""" + template = BaseItemTemplate( + template_id="plate_armor", + name="Plate Armor", + item_type="armor", + min_rarity="rare", + ) + + assert template.can_generate_at_rarity("rare") + assert template.can_generate_at_rarity("epic") + assert template.can_generate_at_rarity("legendary") + assert not template.can_generate_at_rarity("common") + assert not template.can_generate_at_rarity("uncommon") + + def test_template_level_eligibility(self): + """Test template level checking.""" + template = BaseItemTemplate( + template_id="greatsword", + name="Greatsword", + item_type="weapon", + required_level=5, + ) + + assert template.can_drop_for_level(5) + assert template.can_drop_for_level(10) + assert not template.can_drop_for_level(4) + + +class TestAffixLoader: + """Tests for the AffixLoader service.""" + + def test_loader_initialization(self): + """Test AffixLoader initializes correctly.""" + loader = get_affix_loader() + assert loader is not None + + def test_load_prefixes(self): + """Test loading prefixes from YAML.""" + loader = get_affix_loader() + loader.load_all() + + prefixes = loader.get_all_prefixes() + assert len(prefixes) > 0 + + # Check for known prefix + flaming = loader.get_affix("flaming") + assert flaming is not None + assert flaming.affix_type == AffixType.PREFIX + assert flaming.name == "Flaming" + + def test_load_suffixes(self): + """Test loading suffixes from YAML.""" + loader = get_affix_loader() + loader.load_all() + + suffixes = loader.get_all_suffixes() + assert len(suffixes) > 0 + + # Check for known suffix + of_strength = loader.get_affix("of_strength") + assert of_strength is not None + assert of_strength.affix_type == AffixType.SUFFIX + assert of_strength.name == "of Strength" + + def test_get_eligible_prefixes(self): + """Test filtering eligible prefixes.""" + loader = get_affix_loader() + + # Get weapon prefixes for rare items + eligible = loader.get_eligible_prefixes("weapon", "rare") + assert len(eligible) > 0 + + # All should be applicable to weapons + for prefix in eligible: + assert prefix.can_apply_to("weapon", "rare") + + def test_get_random_prefix(self): + """Test random prefix selection.""" + loader = get_affix_loader() + + prefix = loader.get_random_prefix("weapon", "rare") + assert prefix is not None + assert prefix.affix_type == AffixType.PREFIX + + +class TestBaseItemLoader: + """Tests for the BaseItemLoader service.""" + + def test_loader_initialization(self): + """Test BaseItemLoader initializes correctly.""" + loader = get_base_item_loader() + assert loader is not None + + def test_load_weapons(self): + """Test loading weapon templates from YAML.""" + loader = get_base_item_loader() + loader.load_all() + + weapons = loader.get_all_weapons() + assert len(weapons) > 0 + + # Check for known weapon + dagger = loader.get_template("dagger") + assert dagger is not None + assert dagger.item_type == "weapon" + assert dagger.base_damage > 0 + + def test_load_armor(self): + """Test loading armor templates from YAML.""" + loader = get_base_item_loader() + loader.load_all() + + armor = loader.get_all_armor() + assert len(armor) > 0 + + # Check for known armor + chainmail = loader.get_template("chainmail") + assert chainmail is not None + assert chainmail.item_type == "armor" + assert chainmail.base_defense > 0 + + def test_get_eligible_templates(self): + """Test filtering eligible templates.""" + loader = get_base_item_loader() + + # Get weapons for level 1, common rarity + eligible = loader.get_eligible_templates("weapon", "common", 1) + assert len(eligible) > 0 + + # All should be eligible + for template in eligible: + assert template.can_drop_for_level(1) + assert template.can_generate_at_rarity("common") + + def test_get_random_template(self): + """Test random template selection.""" + loader = get_base_item_loader() + + template = loader.get_random_template("weapon", "common", 1) + assert template is not None + assert template.item_type == "weapon" + + +class TestItemGenerator: + """Tests for the ItemGenerator service.""" + + def test_generator_initialization(self): + """Test ItemGenerator initializes correctly.""" + generator = get_item_generator() + assert generator is not None + + def test_generate_common_item(self): + """Test generating a common item (no affixes).""" + generator = get_item_generator() + + item = generator.generate_item("weapon", ItemRarity.COMMON, 1) + assert item is not None + assert item.rarity == ItemRarity.COMMON + assert item.is_generated + assert len(item.applied_affixes) == 0 + # Common items have no generated name + assert item.generated_name == item.name + + def test_generate_rare_item(self): + """Test generating a rare item (1 affix).""" + generator = get_item_generator() + + item = generator.generate_item("weapon", ItemRarity.RARE, 1) + assert item is not None + assert item.rarity == ItemRarity.RARE + assert item.is_generated + assert len(item.applied_affixes) == 1 + assert item.generated_name != item.name + + def test_generate_epic_item(self): + """Test generating an epic item (2 affixes).""" + generator = get_item_generator() + + item = generator.generate_item("weapon", ItemRarity.EPIC, 1) + assert item is not None + assert item.rarity == ItemRarity.EPIC + assert item.is_generated + assert len(item.applied_affixes) == 2 + + def test_generate_legendary_item(self): + """Test generating a legendary item (3 affixes).""" + generator = get_item_generator() + + item = generator.generate_item("weapon", ItemRarity.LEGENDARY, 5) + assert item is not None + assert item.rarity == ItemRarity.LEGENDARY + assert item.is_generated + assert len(item.applied_affixes) == 3 + + def test_generated_name_format(self): + """Test that generated names follow the expected format.""" + generator = get_item_generator() + + # Generate multiple items and check name patterns + for _ in range(10): + item = generator.generate_item("weapon", ItemRarity.EPIC, 1) + if item: + name = item.get_display_name() + # EPIC should have both prefix and suffix (typically) + # Name should contain the base item name + assert item.name in name or item.base_template_id in name.lower() + + def test_stat_combination(self): + """Test that affix stats are properly combined.""" + generator = get_item_generator() + + # Generate items and verify stat bonuses are present + for _ in range(5): + item = generator.generate_item("weapon", ItemRarity.RARE, 1) + if item and item.applied_affixes: + # Item should have some stat modifications + # Either stat_bonuses, damage_bonus, or elemental properties + has_stats = ( + bool(item.stat_bonuses) or + item.damage > 0 or + item.elemental_ratio > 0 + ) + assert has_stats + + def test_generate_armor(self): + """Test generating armor items.""" + generator = get_item_generator() + + item = generator.generate_item("armor", ItemRarity.RARE, 1) + assert item is not None + assert item.item_type == ItemType.ARMOR + assert item.defense > 0 or item.resistance > 0 + + def test_generate_loot_drop(self): + """Test random loot drop generation.""" + generator = get_item_generator() + + # Generate multiple drops to test randomness + rarities_seen = set() + for _ in range(50): + item = generator.generate_loot_drop(5, luck_stat=8) + if item: + rarities_seen.add(item.rarity) + + # Should see at least common and uncommon + assert ItemRarity.COMMON in rarities_seen or ItemRarity.UNCOMMON in rarities_seen + + def test_luck_affects_rarity(self): + """Test that higher luck increases rare drops.""" + generator = get_item_generator() + + # This is a statistical test - higher luck should trend toward better rarity + low_luck_rares = 0 + high_luck_rares = 0 + + for _ in range(100): + low_luck_item = generator.generate_loot_drop(5, luck_stat=1) + high_luck_item = generator.generate_loot_drop(5, luck_stat=20) + + if low_luck_item and low_luck_item.rarity in [ItemRarity.RARE, ItemRarity.EPIC, ItemRarity.LEGENDARY]: + low_luck_rares += 1 + if high_luck_item and high_luck_item.rarity in [ItemRarity.RARE, ItemRarity.EPIC, ItemRarity.LEGENDARY]: + high_luck_rares += 1 + + # High luck should generally produce more rare+ items + # (This may occasionally fail due to randomness, but should pass most of the time) + # We're just checking the trend, not a strict guarantee + # logger.info(f"Low luck rares: {low_luck_rares}, High luck rares: {high_luck_rares}") + + +class TestNameGeneration: + """Tests specifically for item name generation.""" + + def test_prefix_only_name(self): + """Test name with only a prefix.""" + generator = get_item_generator() + + # Create mock affixes + prefix = Affix( + affix_id="flaming", + name="Flaming", + affix_type=AffixType.PREFIX, + tier=AffixTier.MINOR, + ) + + name = generator._build_name("Dagger", [prefix], []) + assert name == "Flaming Dagger" + + def test_suffix_only_name(self): + """Test name with only a suffix.""" + generator = get_item_generator() + + suffix = Affix( + affix_id="of_strength", + name="of Strength", + affix_type=AffixType.SUFFIX, + tier=AffixTier.MINOR, + ) + + name = generator._build_name("Dagger", [], [suffix]) + assert name == "Dagger of Strength" + + def test_full_name(self): + """Test name with prefix and suffix.""" + generator = get_item_generator() + + prefix = Affix( + affix_id="flaming", + name="Flaming", + affix_type=AffixType.PREFIX, + tier=AffixTier.MINOR, + ) + suffix = Affix( + affix_id="of_strength", + name="of Strength", + affix_type=AffixType.SUFFIX, + tier=AffixTier.MINOR, + ) + + name = generator._build_name("Dagger", [prefix], [suffix]) + assert name == "Flaming Dagger of Strength" + + def test_multiple_prefixes(self): + """Test name with multiple prefixes.""" + generator = get_item_generator() + + prefix1 = Affix( + affix_id="flaming", + name="Flaming", + affix_type=AffixType.PREFIX, + tier=AffixTier.MINOR, + ) + prefix2 = Affix( + affix_id="sharp", + name="Sharp", + affix_type=AffixType.PREFIX, + tier=AffixTier.MINOR, + ) + + name = generator._build_name("Dagger", [prefix1, prefix2], []) + assert name == "Flaming Sharp Dagger" + + +class TestStatCombination: + """Tests for combining affix stats.""" + + def test_combine_stat_bonuses(self): + """Test combining stat bonuses from multiple affixes.""" + generator = get_item_generator() + + affix1 = Affix( + affix_id="test1", + name="Test1", + affix_type=AffixType.PREFIX, + tier=AffixTier.MINOR, + stat_bonuses={"strength": 2, "constitution": 1}, + ) + affix2 = Affix( + affix_id="test2", + name="Test2", + affix_type=AffixType.SUFFIX, + tier=AffixTier.MINOR, + stat_bonuses={"strength": 3, "dexterity": 2}, + ) + + combined = generator._combine_affix_stats([affix1, affix2]) + + assert combined["stat_bonuses"]["strength"] == 5 + assert combined["stat_bonuses"]["constitution"] == 1 + assert combined["stat_bonuses"]["dexterity"] == 2 + + def test_combine_damage_bonuses(self): + """Test combining damage bonuses.""" + generator = get_item_generator() + + affix1 = Affix( + affix_id="sharp", + name="Sharp", + affix_type=AffixType.PREFIX, + tier=AffixTier.MINOR, + damage_bonus=3, + ) + affix2 = Affix( + affix_id="keen", + name="Keen", + affix_type=AffixType.PREFIX, + tier=AffixTier.MAJOR, + damage_bonus=5, + ) + + combined = generator._combine_affix_stats([affix1, affix2]) + + assert combined["damage_bonus"] == 8 + + def test_combine_crit_bonuses(self): + """Test combining crit chance and multiplier bonuses.""" + generator = get_item_generator() + + affix1 = Affix( + affix_id="sharp", + name="Sharp", + affix_type=AffixType.PREFIX, + tier=AffixTier.MINOR, + crit_chance_bonus=0.02, + ) + affix2 = Affix( + affix_id="keen", + name="Keen", + affix_type=AffixType.PREFIX, + tier=AffixTier.MAJOR, + crit_chance_bonus=0.04, + crit_multiplier_bonus=0.5, + ) + + combined = generator._combine_affix_stats([affix1, affix2]) + + assert combined["crit_chance_bonus"] == pytest.approx(0.06) + assert combined["crit_multiplier_bonus"] == pytest.approx(0.5)