""" Tests for CombatLootService. Tests the service that orchestrates loot generation from combat, supporting both static and procedural loot drops. """ import pytest from unittest.mock import Mock, patch from app.services.combat_loot_service import ( CombatLootService, LootContext, get_combat_loot_service, DIFFICULTY_RARITY_BONUS, LUCK_CONVERSION_FACTOR ) from app.models.enemy import ( EnemyTemplate, EnemyDifficulty, LootEntry, LootType ) from app.models.stats import Stats from app.models.items import Item from app.models.enums import ItemType, ItemRarity class TestLootContext: """Test LootContext dataclass.""" def test_default_values(self): """Test default context values.""" context = LootContext() assert context.party_average_level == 1 assert context.enemy_difficulty == EnemyDifficulty.EASY assert context.luck_stat == 8 assert context.loot_bonus == 0.0 def test_custom_values(self): """Test creating context with custom values.""" context = LootContext( party_average_level=10, enemy_difficulty=EnemyDifficulty.HARD, luck_stat=15, loot_bonus=0.1 ) assert context.party_average_level == 10 assert context.enemy_difficulty == EnemyDifficulty.HARD assert context.luck_stat == 15 assert context.loot_bonus == 0.1 class TestDifficultyBonuses: """Test difficulty rarity bonus constants.""" def test_easy_bonus(self): """Easy enemies have no bonus.""" assert DIFFICULTY_RARITY_BONUS[EnemyDifficulty.EASY] == 0.0 def test_medium_bonus(self): """Medium enemies have small bonus.""" assert DIFFICULTY_RARITY_BONUS[EnemyDifficulty.MEDIUM] == 0.05 def test_hard_bonus(self): """Hard enemies have moderate bonus.""" assert DIFFICULTY_RARITY_BONUS[EnemyDifficulty.HARD] == 0.15 def test_boss_bonus(self): """Boss enemies have large bonus.""" assert DIFFICULTY_RARITY_BONUS[EnemyDifficulty.BOSS] == 0.30 class TestCombatLootServiceInit: """Test service initialization.""" def test_init_uses_defaults(self): """Service should initialize with default dependencies.""" service = CombatLootService() assert service.item_generator is not None assert service.static_loader is not None def test_singleton_returns_same_instance(self): """get_combat_loot_service should return singleton.""" service1 = get_combat_loot_service() service2 = get_combat_loot_service() assert service1 is service2 class TestCombatLootServiceEffectiveLuck: """Test effective luck calculation.""" def test_base_luck_no_bonus(self): """With no bonuses, effective luck equals base luck.""" service = CombatLootService() entry = LootEntry( loot_type=LootType.PROCEDURAL, item_type="weapon", rarity_bonus=0.0 ) context = LootContext( luck_stat=8, enemy_difficulty=EnemyDifficulty.EASY, loot_bonus=0.0 ) effective = service._calculate_effective_luck(entry, context) # No bonus, so effective should equal base assert effective == 8 def test_difficulty_bonus_adds_luck(self): """Difficulty bonus should increase effective luck.""" service = CombatLootService() entry = LootEntry( loot_type=LootType.PROCEDURAL, item_type="weapon", rarity_bonus=0.0 ) context = LootContext( luck_stat=8, enemy_difficulty=EnemyDifficulty.BOSS, # 0.30 bonus loot_bonus=0.0 ) effective = service._calculate_effective_luck(entry, context) # Boss bonus = 0.30 * 20 = 6 extra luck assert effective == 8 + 6 def test_entry_rarity_bonus_adds_luck(self): """Entry rarity bonus should increase effective luck.""" service = CombatLootService() entry = LootEntry( loot_type=LootType.PROCEDURAL, item_type="weapon", rarity_bonus=0.10 # Entry-specific bonus ) context = LootContext( luck_stat=8, enemy_difficulty=EnemyDifficulty.EASY, loot_bonus=0.0 ) effective = service._calculate_effective_luck(entry, context) # 0.10 * 20 = 2 extra luck assert effective == 8 + 2 def test_combined_bonuses(self): """All bonuses should stack.""" service = CombatLootService() entry = LootEntry( loot_type=LootType.PROCEDURAL, item_type="weapon", rarity_bonus=0.10 ) context = LootContext( luck_stat=10, enemy_difficulty=EnemyDifficulty.HARD, # 0.15 loot_bonus=0.05 ) effective = service._calculate_effective_luck(entry, context) # Total bonus = 0.10 + 0.15 + 0.05 = 0.30 # Extra luck = 0.30 * 20 = 6 expected = 10 + 6 assert effective == expected class TestCombatLootServiceStaticItems: """Test static item generation.""" def test_generate_static_items_returns_items(self): """Should return Item instances for static entries.""" service = CombatLootService() entry = LootEntry( loot_type=LootType.STATIC, item_id="health_potion_small", drop_chance=1.0 ) items = service._generate_static_items(entry, quantity=1) assert len(items) == 1 assert items[0].name == "Small Health Potion" def test_generate_static_items_respects_quantity(self): """Should generate correct quantity of items.""" service = CombatLootService() entry = LootEntry( loot_type=LootType.STATIC, item_id="goblin_ear", drop_chance=1.0 ) items = service._generate_static_items(entry, quantity=3) assert len(items) == 3 # All should be goblin ears with unique IDs for item in items: assert "goblin_ear" in item.item_id def test_generate_static_items_missing_id(self): """Should return empty list if item_id is missing.""" service = CombatLootService() entry = LootEntry( loot_type=LootType.STATIC, item_id=None, drop_chance=1.0 ) items = service._generate_static_items(entry, quantity=1) assert len(items) == 0 class TestCombatLootServiceProceduralItems: """Test procedural item generation.""" def test_generate_procedural_items_returns_items(self): """Should return generated Item instances.""" service = CombatLootService() entry = LootEntry( loot_type=LootType.PROCEDURAL, item_type="weapon", drop_chance=1.0, rarity_bonus=0.0 ) context = LootContext(party_average_level=5) items = service._generate_procedural_items(entry, quantity=1, context=context) assert len(items) == 1 assert items[0].is_weapon() def test_generate_procedural_armor(self): """Should generate armor when item_type is armor.""" service = CombatLootService() entry = LootEntry( loot_type=LootType.PROCEDURAL, item_type="armor", drop_chance=1.0 ) context = LootContext(party_average_level=5) items = service._generate_procedural_items(entry, quantity=1, context=context) assert len(items) == 1 assert items[0].is_armor() def test_generate_procedural_missing_type(self): """Should return empty list if item_type is missing.""" service = CombatLootService() entry = LootEntry( loot_type=LootType.PROCEDURAL, item_type=None, drop_chance=1.0 ) context = LootContext() items = service._generate_procedural_items(entry, quantity=1, context=context) assert len(items) == 0 class TestCombatLootServiceGenerateFromEnemy: """Test full loot generation from enemy templates.""" @pytest.fixture def sample_enemy(self): """Create a sample enemy template for testing.""" return EnemyTemplate( enemy_id="test_goblin", name="Test Goblin", description="A test goblin", base_stats=Stats(), abilities=["basic_attack"], loot_table=[ LootEntry( loot_type=LootType.STATIC, item_id="goblin_ear", drop_chance=1.0, # Guaranteed drop for testing quantity_min=1, quantity_max=1 ) ], experience_reward=10, difficulty=EnemyDifficulty.EASY ) def test_generate_loot_from_enemy_basic(self, sample_enemy): """Should generate loot from enemy loot table.""" service = CombatLootService() context = LootContext() items = service.generate_loot_from_enemy(sample_enemy, context) assert len(items) == 1 assert "goblin_ear" in items[0].item_id def test_generate_loot_respects_drop_chance(self): """Items with 0 drop chance should never drop.""" enemy = EnemyTemplate( enemy_id="test_enemy", name="Test Enemy", description="Test", base_stats=Stats(), abilities=[], loot_table=[ LootEntry( loot_type=LootType.STATIC, item_id="rare_item", drop_chance=0.0, # Never drops ) ], difficulty=EnemyDifficulty.EASY ) service = CombatLootService() context = LootContext() # Run multiple times to ensure it never drops for _ in range(10): items = service.generate_loot_from_enemy(enemy, context) assert len(items) == 0 def test_generate_loot_multiple_entries(self): """Should process all loot table entries.""" enemy = EnemyTemplate( enemy_id="test_enemy", name="Test Enemy", description="Test", base_stats=Stats(), abilities=[], loot_table=[ LootEntry( loot_type=LootType.STATIC, item_id="goblin_ear", drop_chance=1.0, ), LootEntry( loot_type=LootType.STATIC, item_id="health_potion_small", drop_chance=1.0, ) ], difficulty=EnemyDifficulty.EASY ) service = CombatLootService() context = LootContext() items = service.generate_loot_from_enemy(enemy, context) assert len(items) == 2 class TestCombatLootServiceBossLoot: """Test boss loot generation.""" @pytest.fixture def boss_enemy(self): """Create a boss enemy template for testing.""" return EnemyTemplate( enemy_id="test_boss", name="Test Boss", description="A test boss", base_stats=Stats(strength=20, constitution=20), abilities=["basic_attack"], loot_table=[ LootEntry( loot_type=LootType.STATIC, item_id="goblin_chieftain_token", drop_chance=1.0, ) ], experience_reward=100, difficulty=EnemyDifficulty.BOSS ) def test_generate_boss_loot_includes_guaranteed_drops(self, boss_enemy): """Boss loot should include guaranteed equipment drops.""" service = CombatLootService() context = LootContext(party_average_level=10) items = service.generate_boss_loot(boss_enemy, context, guaranteed_drops=1) # Should have at least the loot table drop + guaranteed drop assert len(items) >= 2 def test_generate_boss_loot_non_boss_skips_guaranteed(self): """Non-boss enemies shouldn't get guaranteed drops.""" enemy = EnemyTemplate( enemy_id="test_enemy", name="Test Enemy", description="Test", base_stats=Stats(), abilities=[], loot_table=[ LootEntry( loot_type=LootType.STATIC, item_id="goblin_ear", drop_chance=1.0, ) ], difficulty=EnemyDifficulty.EASY # Not a boss ) service = CombatLootService() context = LootContext() items = service.generate_boss_loot(enemy, context, guaranteed_drops=2) # Should only have the one loot table drop assert len(items) == 1