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)
528 lines
17 KiB
Python
528 lines
17 KiB
Python
"""
|
|
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)
|