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:
177
api/app/data/affixes/prefixes.yaml
Normal file
177
api/app/data/affixes/prefixes.yaml
Normal 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"
|
||||
155
api/app/data/affixes/suffixes.yaml
Normal file
155
api/app/data/affixes/suffixes.yaml
Normal 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"
|
||||
152
api/app/data/base_items/armor.yaml
Normal file
152
api/app/data/base_items/armor.yaml
Normal 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"
|
||||
182
api/app/data/base_items/weapons.yaml
Normal file
182
api/app/data/base_items/weapons.yaml
Normal 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
303
api/app/models/affixes.py
Normal 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})"
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
315
api/app/services/affix_loader.py
Normal file
315
api/app/services/affix_loader.py
Normal 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
|
||||
273
api/app/services/base_item_loader.py
Normal file
273
api/app/services/base_item_loader.py
Normal 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
|
||||
534
api/app/services/item_generator.py
Normal file
534
api/app/services/item_generator.py
Normal file
@@ -0,0 +1,534 @@
|
||||
"""
|
||||
Item Generator Service - Procedural item generation with affixes.
|
||||
|
||||
This service generates Diablo-style items by combining base templates with
|
||||
random affixes, creating items like "Flaming Dagger of Strength".
|
||||
"""
|
||||
|
||||
import uuid
|
||||
import random
|
||||
from typing import List, Optional, Tuple, Dict, Any
|
||||
|
||||
from app.models.items import Item
|
||||
from app.models.affixes import Affix, BaseItemTemplate
|
||||
from app.models.enums import ItemType, ItemRarity, DamageType, AffixTier
|
||||
from app.services.affix_loader import get_affix_loader, AffixLoader
|
||||
from app.services.base_item_loader import get_base_item_loader, BaseItemLoader
|
||||
from app.utils.logging import get_logger
|
||||
|
||||
logger = get_logger(__file__)
|
||||
|
||||
# Affix count by rarity (COMMON/UNCOMMON get 0 affixes - plain items)
|
||||
AFFIX_COUNTS = {
|
||||
ItemRarity.COMMON: 0,
|
||||
ItemRarity.UNCOMMON: 0,
|
||||
ItemRarity.RARE: 1,
|
||||
ItemRarity.EPIC: 2,
|
||||
ItemRarity.LEGENDARY: 3,
|
||||
}
|
||||
|
||||
# Tier selection probabilities by rarity
|
||||
# Higher rarity items have better chance at higher tier affixes
|
||||
TIER_WEIGHTS = {
|
||||
ItemRarity.RARE: {
|
||||
AffixTier.MINOR: 0.8,
|
||||
AffixTier.MAJOR: 0.2,
|
||||
AffixTier.LEGENDARY: 0.0,
|
||||
},
|
||||
ItemRarity.EPIC: {
|
||||
AffixTier.MINOR: 0.3,
|
||||
AffixTier.MAJOR: 0.7,
|
||||
AffixTier.LEGENDARY: 0.0,
|
||||
},
|
||||
ItemRarity.LEGENDARY: {
|
||||
AffixTier.MINOR: 0.1,
|
||||
AffixTier.MAJOR: 0.4,
|
||||
AffixTier.LEGENDARY: 0.5,
|
||||
},
|
||||
}
|
||||
|
||||
# Rarity value multipliers (higher rarity = more valuable)
|
||||
RARITY_VALUE_MULTIPLIER = {
|
||||
ItemRarity.COMMON: 1.0,
|
||||
ItemRarity.UNCOMMON: 1.5,
|
||||
ItemRarity.RARE: 2.5,
|
||||
ItemRarity.EPIC: 5.0,
|
||||
ItemRarity.LEGENDARY: 10.0,
|
||||
}
|
||||
|
||||
|
||||
class ItemGenerator:
|
||||
"""
|
||||
Generates procedural items with Diablo-style naming.
|
||||
|
||||
This service combines base item templates with randomly selected affixes
|
||||
to create unique items with combined stats and generated names.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
affix_loader: Optional[AffixLoader] = None,
|
||||
base_item_loader: Optional[BaseItemLoader] = None
|
||||
):
|
||||
"""
|
||||
Initialize the item generator.
|
||||
|
||||
Args:
|
||||
affix_loader: Optional custom AffixLoader instance
|
||||
base_item_loader: Optional custom BaseItemLoader instance
|
||||
"""
|
||||
self.affix_loader = affix_loader or get_affix_loader()
|
||||
self.base_item_loader = base_item_loader or get_base_item_loader()
|
||||
|
||||
logger.info("ItemGenerator initialized")
|
||||
|
||||
def generate_item(
|
||||
self,
|
||||
item_type: str,
|
||||
rarity: ItemRarity,
|
||||
character_level: int = 1,
|
||||
base_template_id: Optional[str] = None
|
||||
) -> Optional[Item]:
|
||||
"""
|
||||
Generate a procedural item.
|
||||
|
||||
Args:
|
||||
item_type: "weapon" or "armor"
|
||||
rarity: Target rarity
|
||||
character_level: Player level for template eligibility
|
||||
base_template_id: Optional specific base template to use
|
||||
|
||||
Returns:
|
||||
Generated Item instance or None if generation fails
|
||||
"""
|
||||
# 1. Get base template
|
||||
base_template = self._get_base_template(
|
||||
item_type, rarity, character_level, base_template_id
|
||||
)
|
||||
if not base_template:
|
||||
logger.warning(
|
||||
"No base template available",
|
||||
item_type=item_type,
|
||||
rarity=rarity.value,
|
||||
level=character_level
|
||||
)
|
||||
return None
|
||||
|
||||
# 2. Get affix count for this rarity
|
||||
affix_count = AFFIX_COUNTS.get(rarity, 0)
|
||||
|
||||
# 3. Select affixes
|
||||
prefixes, suffixes = self._select_affixes(
|
||||
base_template.item_type, rarity, affix_count
|
||||
)
|
||||
|
||||
# 4. Build the item
|
||||
item = self._build_item(base_template, rarity, prefixes, suffixes)
|
||||
|
||||
logger.info(
|
||||
"Item generated",
|
||||
item_id=item.item_id,
|
||||
name=item.get_display_name(),
|
||||
rarity=rarity.value,
|
||||
affixes=[a.affix_id for a in prefixes + suffixes]
|
||||
)
|
||||
|
||||
return item
|
||||
|
||||
def _get_base_template(
|
||||
self,
|
||||
item_type: str,
|
||||
rarity: ItemRarity,
|
||||
character_level: int,
|
||||
template_id: Optional[str] = None
|
||||
) -> Optional[BaseItemTemplate]:
|
||||
"""
|
||||
Get a base template for item generation.
|
||||
|
||||
Args:
|
||||
item_type: Type of item
|
||||
rarity: Target rarity
|
||||
character_level: Player level
|
||||
template_id: Optional specific template ID
|
||||
|
||||
Returns:
|
||||
BaseItemTemplate instance or None
|
||||
"""
|
||||
if template_id:
|
||||
return self.base_item_loader.get_template(template_id)
|
||||
|
||||
return self.base_item_loader.get_random_template(
|
||||
item_type, rarity.value, character_level
|
||||
)
|
||||
|
||||
def _select_affixes(
|
||||
self,
|
||||
item_type: str,
|
||||
rarity: ItemRarity,
|
||||
count: int
|
||||
) -> Tuple[List[Affix], List[Affix]]:
|
||||
"""
|
||||
Select random affixes for an item.
|
||||
|
||||
Distribution logic:
|
||||
- RARE (1 affix): 50% chance prefix, 50% chance suffix
|
||||
- EPIC (2 affixes): 1 prefix AND 1 suffix
|
||||
- LEGENDARY (3 affixes): Mix of prefixes and suffixes
|
||||
|
||||
Args:
|
||||
item_type: Type of item
|
||||
rarity: Item rarity
|
||||
count: Number of affixes to select
|
||||
|
||||
Returns:
|
||||
Tuple of (prefixes, suffixes)
|
||||
"""
|
||||
prefixes: List[Affix] = []
|
||||
suffixes: List[Affix] = []
|
||||
used_ids: List[str] = []
|
||||
|
||||
if count == 0:
|
||||
return prefixes, suffixes
|
||||
|
||||
# Determine tier for affix selection
|
||||
tier = self._roll_affix_tier(rarity)
|
||||
|
||||
if count == 1:
|
||||
# RARE: Either prefix OR suffix (50/50)
|
||||
if random.random() < 0.5:
|
||||
prefix = self.affix_loader.get_random_prefix(
|
||||
item_type, rarity.value, tier, used_ids
|
||||
)
|
||||
if prefix:
|
||||
prefixes.append(prefix)
|
||||
used_ids.append(prefix.affix_id)
|
||||
else:
|
||||
suffix = self.affix_loader.get_random_suffix(
|
||||
item_type, rarity.value, tier, used_ids
|
||||
)
|
||||
if suffix:
|
||||
suffixes.append(suffix)
|
||||
used_ids.append(suffix.affix_id)
|
||||
|
||||
elif count == 2:
|
||||
# EPIC: 1 prefix AND 1 suffix
|
||||
tier = self._roll_affix_tier(rarity)
|
||||
prefix = self.affix_loader.get_random_prefix(
|
||||
item_type, rarity.value, tier, used_ids
|
||||
)
|
||||
if prefix:
|
||||
prefixes.append(prefix)
|
||||
used_ids.append(prefix.affix_id)
|
||||
|
||||
tier = self._roll_affix_tier(rarity)
|
||||
suffix = self.affix_loader.get_random_suffix(
|
||||
item_type, rarity.value, tier, used_ids
|
||||
)
|
||||
if suffix:
|
||||
suffixes.append(suffix)
|
||||
used_ids.append(suffix.affix_id)
|
||||
|
||||
elif count >= 3:
|
||||
# LEGENDARY: Mix of prefixes and suffixes
|
||||
# Try: 2 prefixes + 1 suffix OR 1 prefix + 2 suffixes
|
||||
distribution = random.choice([(2, 1), (1, 2)])
|
||||
prefix_count, suffix_count = distribution
|
||||
|
||||
for _ in range(prefix_count):
|
||||
tier = self._roll_affix_tier(rarity)
|
||||
prefix = self.affix_loader.get_random_prefix(
|
||||
item_type, rarity.value, tier, used_ids
|
||||
)
|
||||
if prefix:
|
||||
prefixes.append(prefix)
|
||||
used_ids.append(prefix.affix_id)
|
||||
|
||||
for _ in range(suffix_count):
|
||||
tier = self._roll_affix_tier(rarity)
|
||||
suffix = self.affix_loader.get_random_suffix(
|
||||
item_type, rarity.value, tier, used_ids
|
||||
)
|
||||
if suffix:
|
||||
suffixes.append(suffix)
|
||||
used_ids.append(suffix.affix_id)
|
||||
|
||||
return prefixes, suffixes
|
||||
|
||||
def _roll_affix_tier(self, rarity: ItemRarity) -> Optional[AffixTier]:
|
||||
"""
|
||||
Roll for affix tier based on item rarity.
|
||||
|
||||
Args:
|
||||
rarity: Item rarity
|
||||
|
||||
Returns:
|
||||
Selected AffixTier or None for no tier filter
|
||||
"""
|
||||
weights = TIER_WEIGHTS.get(rarity)
|
||||
if not weights:
|
||||
return None
|
||||
|
||||
tiers = list(weights.keys())
|
||||
tier_weights = list(weights.values())
|
||||
|
||||
# Filter out zero-weight options
|
||||
valid_tiers = []
|
||||
valid_weights = []
|
||||
for t, w in zip(tiers, tier_weights):
|
||||
if w > 0:
|
||||
valid_tiers.append(t)
|
||||
valid_weights.append(w)
|
||||
|
||||
if not valid_tiers:
|
||||
return None
|
||||
|
||||
return random.choices(valid_tiers, weights=valid_weights, k=1)[0]
|
||||
|
||||
def _build_item(
|
||||
self,
|
||||
base_template: BaseItemTemplate,
|
||||
rarity: ItemRarity,
|
||||
prefixes: List[Affix],
|
||||
suffixes: List[Affix]
|
||||
) -> Item:
|
||||
"""
|
||||
Build an Item from base template and affixes.
|
||||
|
||||
Args:
|
||||
base_template: Base item template
|
||||
rarity: Item rarity
|
||||
prefixes: List of prefix affixes
|
||||
suffixes: List of suffix affixes
|
||||
|
||||
Returns:
|
||||
Fully constructed Item instance
|
||||
"""
|
||||
# Generate unique ID
|
||||
item_id = f"gen_{uuid.uuid4().hex[:12]}"
|
||||
|
||||
# Build generated name
|
||||
generated_name = self._build_name(base_template.name, prefixes, suffixes)
|
||||
|
||||
# Combine stats from all affixes
|
||||
combined_stats = self._combine_affix_stats(prefixes + suffixes)
|
||||
|
||||
# Calculate final item values
|
||||
item_type = ItemType.WEAPON if base_template.item_type == "weapon" else ItemType.ARMOR
|
||||
|
||||
# Base values from template
|
||||
damage = base_template.base_damage + combined_stats["damage_bonus"]
|
||||
defense = base_template.base_defense + combined_stats["defense_bonus"]
|
||||
resistance = base_template.base_resistance + combined_stats["resistance_bonus"]
|
||||
crit_chance = base_template.crit_chance + combined_stats["crit_chance_bonus"]
|
||||
crit_multiplier = base_template.crit_multiplier + combined_stats["crit_multiplier_bonus"]
|
||||
|
||||
# Calculate value with rarity multiplier
|
||||
base_value = base_template.base_value
|
||||
rarity_mult = RARITY_VALUE_MULTIPLIER.get(rarity, 1.0)
|
||||
# Add value for each affix
|
||||
affix_value = len(prefixes + suffixes) * 25
|
||||
final_value = int((base_value + affix_value) * rarity_mult)
|
||||
|
||||
# Determine elemental damage type (from prefix affixes)
|
||||
elemental_damage_type = None
|
||||
elemental_ratio = 0.0
|
||||
for prefix in prefixes:
|
||||
if prefix.applies_elemental_damage():
|
||||
elemental_damage_type = prefix.damage_type
|
||||
elemental_ratio = prefix.elemental_ratio
|
||||
break # Use first elemental prefix
|
||||
|
||||
# Track applied affixes
|
||||
applied_affixes = [a.affix_id for a in prefixes + suffixes]
|
||||
|
||||
# Create the item
|
||||
item = Item(
|
||||
item_id=item_id,
|
||||
name=base_template.name, # Base name
|
||||
item_type=item_type,
|
||||
rarity=rarity,
|
||||
description=base_template.description,
|
||||
value=final_value,
|
||||
is_tradeable=True,
|
||||
stat_bonuses=combined_stats["stat_bonuses"],
|
||||
effects_on_use=[], # Not a consumable
|
||||
damage=damage,
|
||||
damage_type=DamageType(base_template.damage_type) if damage > 0 else None,
|
||||
crit_chance=crit_chance,
|
||||
crit_multiplier=crit_multiplier,
|
||||
elemental_damage_type=elemental_damage_type,
|
||||
physical_ratio=1.0 - elemental_ratio if elemental_ratio > 0 else 1.0,
|
||||
elemental_ratio=elemental_ratio,
|
||||
defense=defense,
|
||||
resistance=resistance,
|
||||
required_level=base_template.required_level,
|
||||
required_class=None,
|
||||
# Affix tracking
|
||||
applied_affixes=applied_affixes,
|
||||
base_template_id=base_template.template_id,
|
||||
generated_name=generated_name,
|
||||
is_generated=True,
|
||||
)
|
||||
|
||||
return item
|
||||
|
||||
def _build_name(
|
||||
self,
|
||||
base_name: str,
|
||||
prefixes: List[Affix],
|
||||
suffixes: List[Affix]
|
||||
) -> str:
|
||||
"""
|
||||
Build the full item name with affixes.
|
||||
|
||||
Examples:
|
||||
- RARE (1 prefix): "Flaming Dagger"
|
||||
- RARE (1 suffix): "Dagger of Strength"
|
||||
- EPIC: "Flaming Dagger of Strength"
|
||||
- LEGENDARY: "Blazing Glacial Dagger of the Titan"
|
||||
|
||||
Note: Rarity is NOT included in name (shown via UI).
|
||||
|
||||
Args:
|
||||
base_name: Base item name (e.g., "Dagger")
|
||||
prefixes: List of prefix affixes
|
||||
suffixes: List of suffix affixes
|
||||
|
||||
Returns:
|
||||
Full generated name string
|
||||
"""
|
||||
parts = []
|
||||
|
||||
# Add prefix names (in order)
|
||||
for prefix in prefixes:
|
||||
parts.append(prefix.name)
|
||||
|
||||
# Add base name
|
||||
parts.append(base_name)
|
||||
|
||||
# Build name string from parts
|
||||
name = " ".join(parts)
|
||||
|
||||
# Add suffix names (they include "of")
|
||||
for suffix in suffixes:
|
||||
name += f" {suffix.name}"
|
||||
|
||||
return name
|
||||
|
||||
def _combine_affix_stats(self, affixes: List[Affix]) -> Dict[str, Any]:
|
||||
"""
|
||||
Combine stats from multiple affixes.
|
||||
|
||||
Args:
|
||||
affixes: List of affixes to combine
|
||||
|
||||
Returns:
|
||||
Dictionary with combined stat values
|
||||
"""
|
||||
combined = {
|
||||
"stat_bonuses": {},
|
||||
"damage_bonus": 0,
|
||||
"defense_bonus": 0,
|
||||
"resistance_bonus": 0,
|
||||
"crit_chance_bonus": 0.0,
|
||||
"crit_multiplier_bonus": 0.0,
|
||||
}
|
||||
|
||||
for affix in affixes:
|
||||
# Combine stat bonuses
|
||||
for stat_name, bonus in affix.stat_bonuses.items():
|
||||
current = combined["stat_bonuses"].get(stat_name, 0)
|
||||
combined["stat_bonuses"][stat_name] = current + bonus
|
||||
|
||||
# Combine direct bonuses
|
||||
combined["damage_bonus"] += affix.damage_bonus
|
||||
combined["defense_bonus"] += affix.defense_bonus
|
||||
combined["resistance_bonus"] += affix.resistance_bonus
|
||||
combined["crit_chance_bonus"] += affix.crit_chance_bonus
|
||||
combined["crit_multiplier_bonus"] += affix.crit_multiplier_bonus
|
||||
|
||||
return combined
|
||||
|
||||
def generate_loot_drop(
|
||||
self,
|
||||
character_level: int,
|
||||
luck_stat: int = 8,
|
||||
item_type: Optional[str] = None
|
||||
) -> Optional[Item]:
|
||||
"""
|
||||
Generate a random loot drop with luck-influenced rarity.
|
||||
|
||||
Args:
|
||||
character_level: Player level
|
||||
luck_stat: Player's luck stat (affects rarity chance)
|
||||
item_type: Optional item type filter
|
||||
|
||||
Returns:
|
||||
Generated Item or None
|
||||
"""
|
||||
# Choose random item type if not specified
|
||||
if item_type is None:
|
||||
item_type = random.choice(["weapon", "armor"])
|
||||
|
||||
# Roll rarity with luck bonus
|
||||
rarity = self._roll_rarity(luck_stat)
|
||||
|
||||
return self.generate_item(item_type, rarity, character_level)
|
||||
|
||||
def _roll_rarity(self, luck_stat: int) -> ItemRarity:
|
||||
"""
|
||||
Roll item rarity with luck bonus.
|
||||
|
||||
Base chances (luck 8):
|
||||
- COMMON: 50%
|
||||
- UNCOMMON: 30%
|
||||
- RARE: 15%
|
||||
- EPIC: 4%
|
||||
- LEGENDARY: 1%
|
||||
|
||||
Luck modifies these chances slightly.
|
||||
|
||||
Args:
|
||||
luck_stat: Player's luck stat
|
||||
|
||||
Returns:
|
||||
Rolled ItemRarity
|
||||
"""
|
||||
# Calculate luck bonus (luck 8 = baseline)
|
||||
luck_bonus = (luck_stat - 8) * 0.005
|
||||
|
||||
roll = random.random()
|
||||
|
||||
# Thresholds (cumulative)
|
||||
legendary_threshold = 0.01 + luck_bonus
|
||||
epic_threshold = legendary_threshold + 0.04 + luck_bonus * 2
|
||||
rare_threshold = epic_threshold + 0.15 + luck_bonus * 3
|
||||
uncommon_threshold = rare_threshold + 0.30
|
||||
|
||||
if roll < legendary_threshold:
|
||||
return ItemRarity.LEGENDARY
|
||||
elif roll < epic_threshold:
|
||||
return ItemRarity.EPIC
|
||||
elif roll < rare_threshold:
|
||||
return ItemRarity.RARE
|
||||
elif roll < uncommon_threshold:
|
||||
return ItemRarity.UNCOMMON
|
||||
else:
|
||||
return ItemRarity.COMMON
|
||||
|
||||
|
||||
# Global instance for convenience
|
||||
_generator_instance: Optional[ItemGenerator] = None
|
||||
|
||||
|
||||
def get_item_generator() -> ItemGenerator:
|
||||
"""
|
||||
Get the global ItemGenerator instance.
|
||||
|
||||
Returns:
|
||||
Singleton ItemGenerator instance
|
||||
"""
|
||||
global _generator_instance
|
||||
if _generator_instance is None:
|
||||
_generator_instance = ItemGenerator()
|
||||
return _generator_instance
|
||||
Reference in New Issue
Block a user