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)
658 lines
21 KiB
Python
658 lines
21 KiB
Python
"""
|
|
Unit tests for CombatService.
|
|
|
|
Tests combat lifecycle, action execution, and reward distribution.
|
|
Uses mocked dependencies to isolate combat logic testing.
|
|
"""
|
|
|
|
import pytest
|
|
from unittest.mock import Mock, MagicMock, patch
|
|
from uuid import uuid4
|
|
|
|
from app.models.combat import Combatant, CombatEncounter
|
|
from app.models.character import Character
|
|
from app.models.stats import Stats
|
|
from app.models.enemy import EnemyTemplate, EnemyDifficulty
|
|
from app.models.enums import CombatStatus, AbilityType, DamageType
|
|
from app.models.abilities import Ability
|
|
from app.services.combat_service import (
|
|
CombatService,
|
|
CombatAction,
|
|
ActionResult,
|
|
CombatRewards,
|
|
NotInCombatError,
|
|
AlreadyInCombatError,
|
|
InvalidActionError,
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# Test Fixtures
|
|
# =============================================================================
|
|
|
|
@pytest.fixture
|
|
def mock_stats():
|
|
"""Create mock stats for testing."""
|
|
return Stats(
|
|
strength=12,
|
|
dexterity=10,
|
|
constitution=14,
|
|
intelligence=10,
|
|
wisdom=10,
|
|
charisma=8,
|
|
luck=8,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_character(mock_stats):
|
|
"""Create a mock character for testing."""
|
|
char = Mock(spec=Character)
|
|
char.character_id = "test_char_001"
|
|
char.name = "Test Hero"
|
|
char.user_id = "test_user"
|
|
char.level = 5
|
|
char.experience = 1000
|
|
char.gold = 100
|
|
char.unlocked_skills = ["power_strike"]
|
|
char.equipped = {} # No equipment by default
|
|
char.get_effective_stats = Mock(return_value=mock_stats)
|
|
return char
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_enemy_template():
|
|
"""Create a mock enemy template."""
|
|
return EnemyTemplate(
|
|
enemy_id="test_goblin",
|
|
name="Test Goblin",
|
|
description="A test goblin",
|
|
base_stats=Stats(
|
|
strength=8,
|
|
dexterity=12,
|
|
constitution=6,
|
|
intelligence=6,
|
|
wisdom=6,
|
|
charisma=4,
|
|
luck=8,
|
|
),
|
|
abilities=["basic_attack"],
|
|
experience_reward=15,
|
|
gold_reward_min=2,
|
|
gold_reward_max=8,
|
|
difficulty=EnemyDifficulty.EASY,
|
|
tags=["humanoid", "goblinoid"],
|
|
base_damage=4,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_combatant():
|
|
"""Create a mock player combatant."""
|
|
return Combatant(
|
|
combatant_id="test_char_001",
|
|
name="Test Hero",
|
|
is_player=True,
|
|
current_hp=38, # 10 + 14*2
|
|
max_hp=38,
|
|
current_mp=30, # 10 + 10*2
|
|
max_mp=30,
|
|
stats=Stats(
|
|
strength=12,
|
|
dexterity=10,
|
|
constitution=14,
|
|
intelligence=10,
|
|
wisdom=10,
|
|
charisma=8,
|
|
luck=8,
|
|
),
|
|
abilities=["basic_attack", "power_strike"],
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_enemy_combatant():
|
|
"""Create a mock enemy combatant."""
|
|
return Combatant(
|
|
combatant_id="test_goblin_0",
|
|
name="Test Goblin",
|
|
is_player=False,
|
|
current_hp=22, # 10 + 6*2
|
|
max_hp=22,
|
|
current_mp=22,
|
|
max_mp=22,
|
|
stats=Stats(
|
|
strength=8,
|
|
dexterity=12,
|
|
constitution=6,
|
|
intelligence=6,
|
|
wisdom=6,
|
|
charisma=4,
|
|
luck=8,
|
|
),
|
|
abilities=["basic_attack"],
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_encounter(mock_combatant, mock_enemy_combatant):
|
|
"""Create a mock combat encounter."""
|
|
encounter = CombatEncounter(
|
|
encounter_id="test_encounter_001",
|
|
combatants=[mock_combatant, mock_enemy_combatant],
|
|
)
|
|
encounter.initialize_combat()
|
|
return encounter
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_session(mock_encounter):
|
|
"""Create a mock game session."""
|
|
session = Mock()
|
|
session.session_id = "test_session_001"
|
|
session.solo_character_id = "test_char_001"
|
|
session.is_solo = Mock(return_value=True)
|
|
session.is_in_combat = Mock(return_value=False)
|
|
session.combat_encounter = None
|
|
session.start_combat = Mock()
|
|
session.end_combat = Mock()
|
|
return session
|
|
|
|
|
|
# =============================================================================
|
|
# CombatAction Tests
|
|
# =============================================================================
|
|
|
|
class TestCombatAction:
|
|
"""Tests for CombatAction dataclass."""
|
|
|
|
def test_create_attack_action(self):
|
|
"""Test creating an attack action."""
|
|
action = CombatAction(
|
|
action_type="attack",
|
|
target_ids=["enemy_1"],
|
|
)
|
|
|
|
assert action.action_type == "attack"
|
|
assert action.target_ids == ["enemy_1"]
|
|
assert action.ability_id is None
|
|
|
|
def test_create_ability_action(self):
|
|
"""Test creating an ability action."""
|
|
action = CombatAction(
|
|
action_type="ability",
|
|
target_ids=["enemy_1", "enemy_2"],
|
|
ability_id="fireball",
|
|
)
|
|
|
|
assert action.action_type == "ability"
|
|
assert action.ability_id == "fireball"
|
|
assert len(action.target_ids) == 2
|
|
|
|
def test_from_dict(self):
|
|
"""Test creating action from dictionary."""
|
|
data = {
|
|
"action_type": "ability",
|
|
"target_ids": ["enemy_1"],
|
|
"ability_id": "heal",
|
|
}
|
|
|
|
action = CombatAction.from_dict(data)
|
|
|
|
assert action.action_type == "ability"
|
|
assert action.ability_id == "heal"
|
|
|
|
def test_to_dict(self):
|
|
"""Test serializing action to dictionary."""
|
|
action = CombatAction(
|
|
action_type="defend",
|
|
target_ids=[],
|
|
)
|
|
|
|
data = action.to_dict()
|
|
|
|
assert data["action_type"] == "defend"
|
|
assert data["target_ids"] == []
|
|
|
|
|
|
# =============================================================================
|
|
# ActionResult Tests
|
|
# =============================================================================
|
|
|
|
class TestActionResult:
|
|
"""Tests for ActionResult dataclass."""
|
|
|
|
def test_create_success_result(self):
|
|
"""Test creating a successful action result."""
|
|
result = ActionResult(
|
|
success=True,
|
|
message="Attack hits for 15 damage!",
|
|
)
|
|
|
|
assert result.success is True
|
|
assert "15 damage" in result.message
|
|
assert result.combat_ended is False
|
|
|
|
def test_to_dict(self):
|
|
"""Test serializing result to dictionary."""
|
|
result = ActionResult(
|
|
success=True,
|
|
message="Victory!",
|
|
combat_ended=True,
|
|
combat_status=CombatStatus.VICTORY,
|
|
)
|
|
|
|
data = result.to_dict()
|
|
|
|
assert data["success"] is True
|
|
assert data["combat_ended"] is True
|
|
assert data["combat_status"] == "victory"
|
|
|
|
|
|
# =============================================================================
|
|
# CombatRewards Tests
|
|
# =============================================================================
|
|
|
|
class TestCombatRewards:
|
|
"""Tests for CombatRewards dataclass."""
|
|
|
|
def test_create_rewards(self):
|
|
"""Test creating combat rewards."""
|
|
rewards = CombatRewards(
|
|
experience=100,
|
|
gold=50,
|
|
items=[{"item_id": "sword", "quantity": 1}],
|
|
level_ups=["char_1"],
|
|
)
|
|
|
|
assert rewards.experience == 100
|
|
assert rewards.gold == 50
|
|
assert len(rewards.items) == 1
|
|
|
|
def test_to_dict(self):
|
|
"""Test serializing rewards to dictionary."""
|
|
rewards = CombatRewards(experience=50, gold=25)
|
|
data = rewards.to_dict()
|
|
|
|
assert data["experience"] == 50
|
|
assert data["gold"] == 25
|
|
assert data["items"] == []
|
|
|
|
|
|
# =============================================================================
|
|
# Combatant Creation Tests
|
|
# =============================================================================
|
|
|
|
class TestCombatantCreation:
|
|
"""Tests for combatant creation methods."""
|
|
|
|
def test_create_combatant_from_character(self, mock_character):
|
|
"""Test creating a combatant from a player character."""
|
|
service = CombatService.__new__(CombatService)
|
|
|
|
combatant = service._create_combatant_from_character(mock_character)
|
|
|
|
assert combatant.combatant_id == mock_character.character_id
|
|
assert combatant.name == mock_character.name
|
|
assert combatant.is_player is True
|
|
assert combatant.current_hp == combatant.max_hp
|
|
assert "basic_attack" in combatant.abilities
|
|
|
|
def test_create_combatant_from_enemy(self, mock_enemy_template):
|
|
"""Test creating a combatant from an enemy template."""
|
|
service = CombatService.__new__(CombatService)
|
|
|
|
combatant = service._create_combatant_from_enemy(mock_enemy_template, instance_index=0)
|
|
|
|
assert combatant.combatant_id == "test_goblin_0"
|
|
assert combatant.name == mock_enemy_template.name
|
|
assert combatant.is_player is False
|
|
assert combatant.current_hp == combatant.max_hp
|
|
assert "basic_attack" in combatant.abilities
|
|
|
|
def test_create_multiple_enemy_instances(self, mock_enemy_template):
|
|
"""Test creating multiple instances of same enemy."""
|
|
service = CombatService.__new__(CombatService)
|
|
|
|
combatant1 = service._create_combatant_from_enemy(mock_enemy_template, instance_index=0)
|
|
combatant2 = service._create_combatant_from_enemy(mock_enemy_template, instance_index=1)
|
|
combatant3 = service._create_combatant_from_enemy(mock_enemy_template, instance_index=2)
|
|
|
|
# IDs should be unique
|
|
assert combatant1.combatant_id != combatant2.combatant_id
|
|
assert combatant2.combatant_id != combatant3.combatant_id
|
|
|
|
# Names should be numbered
|
|
assert "#" in combatant2.name
|
|
assert "#" in combatant3.name
|
|
|
|
|
|
# =============================================================================
|
|
# Combat Lifecycle Tests
|
|
# =============================================================================
|
|
|
|
class TestCombatLifecycle:
|
|
"""Tests for combat lifecycle methods."""
|
|
|
|
@patch('app.services.combat_service.get_session_service')
|
|
@patch('app.services.combat_service.get_character_service')
|
|
@patch('app.services.combat_service.get_enemy_loader')
|
|
def test_start_combat_success(
|
|
self,
|
|
mock_get_enemy_loader,
|
|
mock_get_char_service,
|
|
mock_get_session_service,
|
|
mock_session,
|
|
mock_character,
|
|
mock_enemy_template,
|
|
):
|
|
"""Test starting combat successfully."""
|
|
# Setup mocks
|
|
mock_session_service = Mock()
|
|
mock_session_service.get_session.return_value = mock_session
|
|
mock_session_service.update_session = Mock()
|
|
mock_get_session_service.return_value = mock_session_service
|
|
|
|
mock_char_service = Mock()
|
|
mock_char_service.get_character.return_value = mock_character
|
|
mock_get_char_service.return_value = mock_char_service
|
|
|
|
mock_enemy_loader = Mock()
|
|
mock_enemy_loader.load_enemy.return_value = mock_enemy_template
|
|
mock_get_enemy_loader.return_value = mock_enemy_loader
|
|
|
|
# Create service and start combat
|
|
service = CombatService()
|
|
encounter = service.start_combat(
|
|
session_id="test_session",
|
|
user_id="test_user",
|
|
enemy_ids=["test_goblin"],
|
|
)
|
|
|
|
assert encounter is not None
|
|
assert encounter.status == CombatStatus.ACTIVE
|
|
assert len(encounter.combatants) == 2 # 1 player + 1 enemy
|
|
assert len(encounter.turn_order) == 2
|
|
mock_session.start_combat.assert_called_once()
|
|
|
|
@patch('app.services.combat_service.get_session_service')
|
|
@patch('app.services.combat_service.get_character_service')
|
|
@patch('app.services.combat_service.get_enemy_loader')
|
|
def test_start_combat_already_in_combat(
|
|
self,
|
|
mock_get_enemy_loader,
|
|
mock_get_char_service,
|
|
mock_get_session_service,
|
|
mock_session,
|
|
):
|
|
"""Test starting combat when already in combat."""
|
|
mock_session.is_in_combat.return_value = True
|
|
|
|
mock_session_service = Mock()
|
|
mock_session_service.get_session.return_value = mock_session
|
|
mock_get_session_service.return_value = mock_session_service
|
|
|
|
service = CombatService()
|
|
|
|
with pytest.raises(AlreadyInCombatError):
|
|
service.start_combat(
|
|
session_id="test_session",
|
|
user_id="test_user",
|
|
enemy_ids=["goblin"],
|
|
)
|
|
|
|
@patch('app.services.combat_service.get_session_service')
|
|
def test_get_combat_state_not_in_combat(
|
|
self,
|
|
mock_get_session_service,
|
|
mock_session,
|
|
):
|
|
"""Test getting combat state when not in combat."""
|
|
mock_session.combat_encounter = None
|
|
|
|
mock_session_service = Mock()
|
|
mock_session_service.get_session.return_value = mock_session
|
|
mock_get_session_service.return_value = mock_session_service
|
|
|
|
service = CombatService()
|
|
result = service.get_combat_state("test_session", "test_user")
|
|
|
|
assert result is None
|
|
|
|
|
|
# =============================================================================
|
|
# Attack Execution Tests
|
|
# =============================================================================
|
|
|
|
class TestAttackExecution:
|
|
"""Tests for attack action execution."""
|
|
|
|
def test_execute_attack_hit(self, mock_encounter, mock_combatant, mock_enemy_combatant):
|
|
"""Test executing a successful attack."""
|
|
service = CombatService.__new__(CombatService)
|
|
service.ability_loader = Mock()
|
|
|
|
# Mock attacker as current combatant
|
|
mock_encounter.turn_order = [mock_combatant.combatant_id, mock_enemy_combatant.combatant_id]
|
|
mock_encounter.current_turn_index = 0
|
|
|
|
result = service._execute_attack(
|
|
mock_encounter,
|
|
mock_combatant,
|
|
[mock_enemy_combatant.combatant_id]
|
|
)
|
|
|
|
assert result.success is True
|
|
assert len(result.damage_results) == 1
|
|
# Damage should have been dealt (HP should be reduced)
|
|
|
|
def test_execute_attack_no_target(self, mock_encounter, mock_combatant):
|
|
"""Test attack with auto-targeting."""
|
|
service = CombatService.__new__(CombatService)
|
|
service.ability_loader = Mock()
|
|
|
|
result = service._execute_attack(
|
|
mock_encounter,
|
|
mock_combatant,
|
|
[] # No targets specified
|
|
)
|
|
|
|
# Should auto-target and succeed
|
|
assert result.success is True
|
|
|
|
|
|
# =============================================================================
|
|
# Defend Action Tests
|
|
# =============================================================================
|
|
|
|
class TestDefendExecution:
|
|
"""Tests for defend action execution."""
|
|
|
|
def test_execute_defend(self, mock_encounter, mock_combatant):
|
|
"""Test executing a defend action."""
|
|
service = CombatService.__new__(CombatService)
|
|
|
|
initial_effects = len(mock_combatant.active_effects)
|
|
|
|
result = service._execute_defend(mock_encounter, mock_combatant)
|
|
|
|
assert result.success is True
|
|
assert "defensive stance" in result.message.lower()
|
|
assert len(result.effects_applied) == 1
|
|
# Combatant should have a new effect
|
|
assert len(mock_combatant.active_effects) == initial_effects + 1
|
|
|
|
|
|
# =============================================================================
|
|
# Flee Action Tests
|
|
# =============================================================================
|
|
|
|
class TestFleeExecution:
|
|
"""Tests for flee action execution."""
|
|
|
|
def test_execute_flee_success(self, mock_encounter, mock_combatant, mock_session):
|
|
"""Test successful flee attempt."""
|
|
service = CombatService.__new__(CombatService)
|
|
|
|
# Force success by patching random
|
|
with patch('random.random', return_value=0.1): # Low roll = success
|
|
result = service._execute_flee(
|
|
mock_encounter,
|
|
mock_combatant,
|
|
mock_session,
|
|
"test_user"
|
|
)
|
|
|
|
assert result.success is True
|
|
assert result.combat_ended is True
|
|
assert result.combat_status == CombatStatus.FLED
|
|
|
|
def test_execute_flee_failure(self, mock_encounter, mock_combatant, mock_session):
|
|
"""Test failed flee attempt."""
|
|
service = CombatService.__new__(CombatService)
|
|
|
|
# Force failure by patching random
|
|
with patch('random.random', return_value=0.9): # High roll = failure
|
|
result = service._execute_flee(
|
|
mock_encounter,
|
|
mock_combatant,
|
|
mock_session,
|
|
"test_user"
|
|
)
|
|
|
|
assert result.success is False
|
|
assert result.combat_ended is False
|
|
|
|
|
|
# =============================================================================
|
|
# Enemy AI Tests
|
|
# =============================================================================
|
|
|
|
class TestEnemyAI:
|
|
"""Tests for enemy AI logic."""
|
|
|
|
def test_choose_enemy_action(self, mock_encounter, mock_enemy_combatant):
|
|
"""Test enemy AI action selection."""
|
|
service = CombatService.__new__(CombatService)
|
|
|
|
action_type, targets = service._choose_enemy_action(
|
|
mock_encounter,
|
|
mock_enemy_combatant
|
|
)
|
|
|
|
# Should choose attack or ability
|
|
assert action_type in ["attack", "ability"]
|
|
# Should target a player
|
|
assert len(targets) > 0
|
|
|
|
def test_choose_enemy_targets_lowest_hp(self, mock_encounter, mock_enemy_combatant):
|
|
"""Test that enemy AI targets lowest HP player."""
|
|
# Add another player with lower HP
|
|
low_hp_player = Combatant(
|
|
combatant_id="low_hp_player",
|
|
name="Wounded Hero",
|
|
is_player=True,
|
|
current_hp=5, # Very low HP
|
|
max_hp=38,
|
|
current_mp=30,
|
|
max_mp=30,
|
|
stats=Stats(),
|
|
abilities=["basic_attack"],
|
|
)
|
|
mock_encounter.combatants.append(low_hp_player)
|
|
|
|
service = CombatService.__new__(CombatService)
|
|
|
|
_, targets = service._choose_enemy_action(
|
|
mock_encounter,
|
|
mock_enemy_combatant
|
|
)
|
|
|
|
# Should target the lowest HP player
|
|
assert targets[0] == "low_hp_player"
|
|
|
|
|
|
# =============================================================================
|
|
# Combat End Condition Tests
|
|
# =============================================================================
|
|
|
|
class TestCombatEndConditions:
|
|
"""Tests for combat end condition checking."""
|
|
|
|
def test_victory_when_all_enemies_dead(self, mock_encounter, mock_combatant, mock_enemy_combatant):
|
|
"""Test victory is detected when all enemies are dead."""
|
|
# Kill the enemy
|
|
mock_enemy_combatant.current_hp = 0
|
|
|
|
status = mock_encounter.check_end_condition()
|
|
|
|
assert status == CombatStatus.VICTORY
|
|
|
|
def test_defeat_when_all_players_dead(self, mock_encounter, mock_combatant, mock_enemy_combatant):
|
|
"""Test defeat is detected when all players are dead."""
|
|
# Kill the player
|
|
mock_combatant.current_hp = 0
|
|
|
|
status = mock_encounter.check_end_condition()
|
|
|
|
assert status == CombatStatus.DEFEAT
|
|
|
|
def test_active_when_both_alive(self, mock_encounter, mock_combatant, mock_enemy_combatant):
|
|
"""Test combat remains active when both sides have survivors."""
|
|
# Both alive
|
|
assert mock_combatant.current_hp > 0
|
|
assert mock_enemy_combatant.current_hp > 0
|
|
|
|
status = mock_encounter.check_end_condition()
|
|
|
|
assert status == CombatStatus.ACTIVE
|
|
|
|
|
|
# =============================================================================
|
|
# Rewards Calculation Tests
|
|
# =============================================================================
|
|
|
|
class TestRewardsCalculation:
|
|
"""Tests for reward distribution."""
|
|
|
|
def test_calculate_rewards_from_enemies(self, mock_encounter, mock_enemy_combatant):
|
|
"""Test reward calculation from defeated enemies."""
|
|
# Mark enemy as dead
|
|
mock_enemy_combatant.current_hp = 0
|
|
|
|
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.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"
|
|
|
|
mock_char = Mock()
|
|
mock_char.level = 1
|
|
mock_char.experience = 0
|
|
mock_char.gold = 0
|
|
service.character_service.get_character.return_value = mock_char
|
|
service.character_service.update_character = Mock()
|
|
|
|
rewards = service._calculate_rewards(mock_encounter, mock_session, "test_user")
|
|
|
|
assert rewards.experience == 50
|
|
assert rewards.gold == 25
|
|
assert len(rewards.items) == 1
|