Files
Code_of_Conquest/api/tests/test_combat_loot_service.py
Phillip Tarrant fdd48034e4 feat(api): implement combat loot integration with hybrid static/procedural system
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)
2025-11-27 00:01:17 -06:00

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