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:
428
api/tests/test_combat_loot_service.py
Normal file
428
api/tests/test_combat_loot_service.py
Normal 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
|
||||
@@ -623,14 +623,22 @@ class TestRewardsCalculation:
|
||||
service = CombatService.__new__(CombatService)
|
||||
service.enemy_loader = Mock()
|
||||
service.character_service = Mock()
|
||||
service.loot_service = Mock()
|
||||
|
||||
# Mock enemy template for rewards
|
||||
mock_template = Mock()
|
||||
mock_template.experience_reward = 50
|
||||
mock_template.get_gold_reward.return_value = 25
|
||||
mock_template.roll_loot.return_value = [{"item_id": "sword", "quantity": 1}]
|
||||
mock_template.difficulty = Mock()
|
||||
mock_template.difficulty.value = "easy"
|
||||
mock_template.is_boss.return_value = False
|
||||
service.enemy_loader.load_enemy.return_value = mock_template
|
||||
|
||||
# Mock loot service to return mock items
|
||||
mock_item = Mock()
|
||||
mock_item.to_dict.return_value = {"item_id": "sword", "quantity": 1}
|
||||
service.loot_service.generate_loot_from_enemy.return_value = [mock_item]
|
||||
|
||||
mock_session = Mock()
|
||||
mock_session.is_solo.return_value = True
|
||||
mock_session.solo_character_id = "test_char"
|
||||
|
||||
224
api/tests/test_loot_entry.py
Normal file
224
api/tests/test_loot_entry.py
Normal file
@@ -0,0 +1,224 @@
|
||||
"""
|
||||
Tests for LootEntry model with hybrid loot support.
|
||||
|
||||
Tests the extended LootEntry dataclass that supports both static
|
||||
and procedural loot types with backward compatibility.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models.enemy import LootEntry, LootType
|
||||
|
||||
|
||||
class TestLootEntryBackwardCompatibility:
|
||||
"""Test that existing YAML format still works."""
|
||||
|
||||
def test_from_dict_defaults_to_static(self):
|
||||
"""Old-style entries without loot_type should default to STATIC."""
|
||||
entry_data = {
|
||||
"item_id": "rusty_dagger",
|
||||
"drop_chance": 0.15,
|
||||
}
|
||||
entry = LootEntry.from_dict(entry_data)
|
||||
|
||||
assert entry.loot_type == LootType.STATIC
|
||||
assert entry.item_id == "rusty_dagger"
|
||||
assert entry.drop_chance == 0.15
|
||||
assert entry.quantity_min == 1
|
||||
assert entry.quantity_max == 1
|
||||
|
||||
def test_from_dict_with_all_old_fields(self):
|
||||
"""Test entry with all old-style fields."""
|
||||
entry_data = {
|
||||
"item_id": "gold_coin",
|
||||
"drop_chance": 0.50,
|
||||
"quantity_min": 1,
|
||||
"quantity_max": 3,
|
||||
}
|
||||
entry = LootEntry.from_dict(entry_data)
|
||||
|
||||
assert entry.loot_type == LootType.STATIC
|
||||
assert entry.item_id == "gold_coin"
|
||||
assert entry.drop_chance == 0.50
|
||||
assert entry.quantity_min == 1
|
||||
assert entry.quantity_max == 3
|
||||
|
||||
def test_to_dict_includes_loot_type(self):
|
||||
"""Serialization should include loot_type."""
|
||||
entry = LootEntry(
|
||||
loot_type=LootType.STATIC,
|
||||
item_id="health_potion",
|
||||
drop_chance=0.2
|
||||
)
|
||||
data = entry.to_dict()
|
||||
|
||||
assert data["loot_type"] == "static"
|
||||
assert data["item_id"] == "health_potion"
|
||||
assert data["drop_chance"] == 0.2
|
||||
|
||||
|
||||
class TestLootEntryStaticType:
|
||||
"""Test static loot entries."""
|
||||
|
||||
def test_static_entry_creation(self):
|
||||
"""Test creating a static loot entry."""
|
||||
entry = LootEntry(
|
||||
loot_type=LootType.STATIC,
|
||||
item_id="goblin_ear",
|
||||
drop_chance=0.60,
|
||||
quantity_min=1,
|
||||
quantity_max=2
|
||||
)
|
||||
|
||||
assert entry.loot_type == LootType.STATIC
|
||||
assert entry.item_id == "goblin_ear"
|
||||
assert entry.item_type is None
|
||||
assert entry.rarity_bonus == 0.0
|
||||
|
||||
def test_static_from_dict_explicit(self):
|
||||
"""Test parsing explicit static entry."""
|
||||
entry_data = {
|
||||
"loot_type": "static",
|
||||
"item_id": "health_potion_small",
|
||||
"drop_chance": 0.10,
|
||||
}
|
||||
entry = LootEntry.from_dict(entry_data)
|
||||
|
||||
assert entry.loot_type == LootType.STATIC
|
||||
assert entry.item_id == "health_potion_small"
|
||||
|
||||
def test_static_to_dict_omits_procedural_fields(self):
|
||||
"""Static entries should omit procedural-only fields."""
|
||||
entry = LootEntry(
|
||||
loot_type=LootType.STATIC,
|
||||
item_id="gold_coin",
|
||||
drop_chance=0.5
|
||||
)
|
||||
data = entry.to_dict()
|
||||
|
||||
assert "item_id" in data
|
||||
assert "item_type" not in data
|
||||
assert "rarity_bonus" not in data
|
||||
|
||||
|
||||
class TestLootEntryProceduralType:
|
||||
"""Test procedural loot entries."""
|
||||
|
||||
def test_procedural_entry_creation(self):
|
||||
"""Test creating a procedural loot entry."""
|
||||
entry = LootEntry(
|
||||
loot_type=LootType.PROCEDURAL,
|
||||
item_type="weapon",
|
||||
drop_chance=0.10,
|
||||
rarity_bonus=0.15
|
||||
)
|
||||
|
||||
assert entry.loot_type == LootType.PROCEDURAL
|
||||
assert entry.item_type == "weapon"
|
||||
assert entry.rarity_bonus == 0.15
|
||||
assert entry.item_id is None
|
||||
|
||||
def test_procedural_from_dict(self):
|
||||
"""Test parsing procedural entry from dict."""
|
||||
entry_data = {
|
||||
"loot_type": "procedural",
|
||||
"item_type": "armor",
|
||||
"drop_chance": 0.08,
|
||||
"rarity_bonus": 0.05,
|
||||
}
|
||||
entry = LootEntry.from_dict(entry_data)
|
||||
|
||||
assert entry.loot_type == LootType.PROCEDURAL
|
||||
assert entry.item_type == "armor"
|
||||
assert entry.drop_chance == 0.08
|
||||
assert entry.rarity_bonus == 0.05
|
||||
|
||||
def test_procedural_to_dict_includes_item_type(self):
|
||||
"""Procedural entries should include item_type and rarity_bonus."""
|
||||
entry = LootEntry(
|
||||
loot_type=LootType.PROCEDURAL,
|
||||
item_type="weapon",
|
||||
drop_chance=0.15,
|
||||
rarity_bonus=0.10
|
||||
)
|
||||
data = entry.to_dict()
|
||||
|
||||
assert data["loot_type"] == "procedural"
|
||||
assert data["item_type"] == "weapon"
|
||||
assert data["rarity_bonus"] == 0.10
|
||||
assert "item_id" not in data
|
||||
|
||||
def test_procedural_default_rarity_bonus(self):
|
||||
"""Procedural entries default to 0.0 rarity bonus."""
|
||||
entry_data = {
|
||||
"loot_type": "procedural",
|
||||
"item_type": "weapon",
|
||||
"drop_chance": 0.10,
|
||||
}
|
||||
entry = LootEntry.from_dict(entry_data)
|
||||
|
||||
assert entry.rarity_bonus == 0.0
|
||||
|
||||
|
||||
class TestLootTypeEnum:
|
||||
"""Test LootType enum values."""
|
||||
|
||||
def test_static_value(self):
|
||||
"""Test STATIC enum value."""
|
||||
assert LootType.STATIC.value == "static"
|
||||
|
||||
def test_procedural_value(self):
|
||||
"""Test PROCEDURAL enum value."""
|
||||
assert LootType.PROCEDURAL.value == "procedural"
|
||||
|
||||
def test_from_string(self):
|
||||
"""Test creating enum from string."""
|
||||
assert LootType("static") == LootType.STATIC
|
||||
assert LootType("procedural") == LootType.PROCEDURAL
|
||||
|
||||
def test_invalid_string_raises(self):
|
||||
"""Test that invalid string raises ValueError."""
|
||||
with pytest.raises(ValueError):
|
||||
LootType("invalid")
|
||||
|
||||
|
||||
class TestLootEntryRoundTrip:
|
||||
"""Test serialization/deserialization round trips."""
|
||||
|
||||
def test_static_round_trip(self):
|
||||
"""Static entry should survive round trip."""
|
||||
original = LootEntry(
|
||||
loot_type=LootType.STATIC,
|
||||
item_id="health_potion_small",
|
||||
drop_chance=0.15,
|
||||
quantity_min=1,
|
||||
quantity_max=2
|
||||
)
|
||||
|
||||
data = original.to_dict()
|
||||
restored = LootEntry.from_dict(data)
|
||||
|
||||
assert restored.loot_type == original.loot_type
|
||||
assert restored.item_id == original.item_id
|
||||
assert restored.drop_chance == original.drop_chance
|
||||
assert restored.quantity_min == original.quantity_min
|
||||
assert restored.quantity_max == original.quantity_max
|
||||
|
||||
def test_procedural_round_trip(self):
|
||||
"""Procedural entry should survive round trip."""
|
||||
original = LootEntry(
|
||||
loot_type=LootType.PROCEDURAL,
|
||||
item_type="weapon",
|
||||
drop_chance=0.25,
|
||||
rarity_bonus=0.15,
|
||||
quantity_min=1,
|
||||
quantity_max=1
|
||||
)
|
||||
|
||||
data = original.to_dict()
|
||||
restored = LootEntry.from_dict(data)
|
||||
|
||||
assert restored.loot_type == original.loot_type
|
||||
assert restored.item_type == original.item_type
|
||||
assert restored.drop_chance == original.drop_chance
|
||||
assert restored.rarity_bonus == original.rarity_bonus
|
||||
194
api/tests/test_static_item_loader.py
Normal file
194
api/tests/test_static_item_loader.py
Normal file
@@ -0,0 +1,194 @@
|
||||
"""
|
||||
Tests for StaticItemLoader service.
|
||||
|
||||
Tests the service that loads predefined items (consumables, materials)
|
||||
from YAML files for use in loot tables.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
|
||||
from app.services.static_item_loader import StaticItemLoader, get_static_item_loader
|
||||
from app.models.enums import ItemType, ItemRarity
|
||||
|
||||
|
||||
class TestStaticItemLoaderInitialization:
|
||||
"""Test service initialization."""
|
||||
|
||||
def test_init_with_default_path(self):
|
||||
"""Service should initialize with default data path."""
|
||||
loader = StaticItemLoader()
|
||||
assert loader.data_dir.exists() or not loader._loaded
|
||||
|
||||
def test_init_with_custom_path(self, tmp_path):
|
||||
"""Service should accept custom data path."""
|
||||
loader = StaticItemLoader(data_dir=str(tmp_path))
|
||||
assert loader.data_dir == tmp_path
|
||||
|
||||
def test_singleton_returns_same_instance(self):
|
||||
"""get_static_item_loader should return singleton."""
|
||||
loader1 = get_static_item_loader()
|
||||
loader2 = get_static_item_loader()
|
||||
assert loader1 is loader2
|
||||
|
||||
|
||||
class TestStaticItemLoaderLoading:
|
||||
"""Test YAML loading functionality."""
|
||||
|
||||
def test_loads_consumables(self):
|
||||
"""Should load consumable items from YAML."""
|
||||
loader = get_static_item_loader()
|
||||
|
||||
# Check that health potion exists
|
||||
assert loader.has_item("health_potion_small")
|
||||
assert loader.has_item("health_potion_medium")
|
||||
|
||||
def test_loads_materials(self):
|
||||
"""Should load material items from YAML."""
|
||||
loader = get_static_item_loader()
|
||||
|
||||
# Check that materials exist
|
||||
assert loader.has_item("goblin_ear")
|
||||
assert loader.has_item("wolf_pelt")
|
||||
|
||||
def test_get_all_item_ids_returns_list(self):
|
||||
"""get_all_item_ids should return list of item IDs."""
|
||||
loader = get_static_item_loader()
|
||||
item_ids = loader.get_all_item_ids()
|
||||
|
||||
assert isinstance(item_ids, list)
|
||||
assert len(item_ids) > 0
|
||||
assert "health_potion_small" in item_ids
|
||||
|
||||
def test_has_item_returns_false_for_missing(self):
|
||||
"""has_item should return False for non-existent items."""
|
||||
loader = get_static_item_loader()
|
||||
assert not loader.has_item("nonexistent_item_xyz")
|
||||
|
||||
|
||||
class TestStaticItemLoaderGetItem:
|
||||
"""Test item retrieval."""
|
||||
|
||||
def test_get_item_returns_item_object(self):
|
||||
"""get_item should return an Item instance."""
|
||||
loader = get_static_item_loader()
|
||||
item = loader.get_item("health_potion_small")
|
||||
|
||||
assert item is not None
|
||||
assert item.name == "Small Health Potion"
|
||||
assert item.item_type == ItemType.CONSUMABLE
|
||||
assert item.rarity == ItemRarity.COMMON
|
||||
|
||||
def test_get_item_has_unique_id(self):
|
||||
"""Each call should create item with unique ID."""
|
||||
loader = get_static_item_loader()
|
||||
|
||||
item1 = loader.get_item("health_potion_small")
|
||||
item2 = loader.get_item("health_potion_small")
|
||||
|
||||
assert item1.item_id != item2.item_id
|
||||
assert "health_potion_small" in item1.item_id
|
||||
assert "health_potion_small" in item2.item_id
|
||||
|
||||
def test_get_item_returns_none_for_missing(self):
|
||||
"""get_item should return None for non-existent items."""
|
||||
loader = get_static_item_loader()
|
||||
item = loader.get_item("nonexistent_item_xyz")
|
||||
|
||||
assert item is None
|
||||
|
||||
def test_get_item_consumable_has_effects(self):
|
||||
"""Consumable items should have effects_on_use."""
|
||||
loader = get_static_item_loader()
|
||||
item = loader.get_item("health_potion_small")
|
||||
|
||||
assert len(item.effects_on_use) > 0
|
||||
effect = item.effects_on_use[0]
|
||||
assert effect.name == "Minor Healing"
|
||||
assert effect.power > 0
|
||||
|
||||
def test_get_item_quest_item_type(self):
|
||||
"""Quest items should have correct type."""
|
||||
loader = get_static_item_loader()
|
||||
item = loader.get_item("goblin_ear")
|
||||
|
||||
assert item is not None
|
||||
assert item.item_type == ItemType.QUEST_ITEM
|
||||
assert item.rarity == ItemRarity.COMMON
|
||||
|
||||
def test_get_item_has_value(self):
|
||||
"""Items should have value set."""
|
||||
loader = get_static_item_loader()
|
||||
item = loader.get_item("health_potion_small")
|
||||
|
||||
assert item.value > 0
|
||||
|
||||
def test_get_item_is_tradeable(self):
|
||||
"""Items should default to tradeable."""
|
||||
loader = get_static_item_loader()
|
||||
item = loader.get_item("goblin_ear")
|
||||
|
||||
assert item.is_tradeable is True
|
||||
|
||||
|
||||
class TestStaticItemLoaderVariousItems:
|
||||
"""Test loading various item types."""
|
||||
|
||||
def test_medium_health_potion(self):
|
||||
"""Test medium health potion properties."""
|
||||
loader = get_static_item_loader()
|
||||
item = loader.get_item("health_potion_medium")
|
||||
|
||||
assert item is not None
|
||||
assert item.rarity == ItemRarity.UNCOMMON
|
||||
assert item.value > 25 # More expensive than small
|
||||
|
||||
def test_large_health_potion(self):
|
||||
"""Test large health potion properties."""
|
||||
loader = get_static_item_loader()
|
||||
item = loader.get_item("health_potion_large")
|
||||
|
||||
assert item is not None
|
||||
assert item.rarity == ItemRarity.RARE
|
||||
|
||||
def test_chieftain_token_rarity(self):
|
||||
"""Test that chieftain token is rare."""
|
||||
loader = get_static_item_loader()
|
||||
item = loader.get_item("goblin_chieftain_token")
|
||||
|
||||
assert item is not None
|
||||
assert item.rarity == ItemRarity.RARE
|
||||
|
||||
def test_elixir_has_buff_effect(self):
|
||||
"""Test that elixirs have buff effects."""
|
||||
loader = get_static_item_loader()
|
||||
item = loader.get_item("elixir_of_strength")
|
||||
|
||||
if item: # Only test if item exists
|
||||
assert len(item.effects_on_use) > 0
|
||||
|
||||
|
||||
class TestStaticItemLoaderCache:
|
||||
"""Test caching behavior."""
|
||||
|
||||
def test_clear_cache(self):
|
||||
"""clear_cache should reset loaded state."""
|
||||
loader = StaticItemLoader()
|
||||
|
||||
# Trigger loading
|
||||
loader._ensure_loaded()
|
||||
assert loader._loaded is True
|
||||
|
||||
# Clear cache
|
||||
loader.clear_cache()
|
||||
assert loader._loaded is False
|
||||
assert len(loader._cache) == 0
|
||||
|
||||
def test_lazy_loading(self):
|
||||
"""Items should be loaded lazily on first access."""
|
||||
loader = StaticItemLoader()
|
||||
assert loader._loaded is False
|
||||
|
||||
# Access triggers loading
|
||||
_ = loader.has_item("health_potion_small")
|
||||
assert loader._loaded is True
|
||||
Reference in New Issue
Block a user