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)
This commit is contained in:
2025-11-27 00:01:17 -06:00
parent a38906b445
commit fdd48034e4
14 changed files with 2257 additions and 26 deletions

View File

@@ -0,0 +1,428 @@
"""
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