Add CombatLootService that orchestrates loot generation from combat, supporting both static item drops (consumables, materials) and procedural equipment generation (weapons, armor with affixes). Key changes: - Extend LootEntry model with LootType enum (STATIC/PROCEDURAL) - Create StaticItemLoader service for consumables/materials from YAML - Create CombatLootService with full rarity formula incorporating: - Party average level - Enemy difficulty tier (EASY: +0%, MEDIUM: +5%, HARD: +15%, BOSS: +30%) - Character luck stat - Per-entry rarity bonus - Integrate with CombatService._calculate_rewards() for automatic loot gen - Add boss guaranteed drops via generate_boss_loot() New enemy variants (goblin family proof-of-concept): - goblin_scout (Easy) - static drops only - goblin_warrior (Medium) - static + procedural weapon drops - goblin_chieftain (Hard) - static + procedural weapon/armor drops Static items added: - consumables.yaml: health/mana potions, elixirs, food - materials.yaml: trophy items, crafting materials Tests: 59 new tests across 3 test files (all passing)
429 lines
13 KiB
Python
429 lines
13 KiB
Python
"""
|
|
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
|