Files
Code_of_Conquest/api/tests/test_combat_service.py

670 lines
22 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": "test_sword",
"name": "Test Sword",
"item_type": "weapon",
"rarity": "common",
"description": "A test sword",
"value": 10,
"damage": 5,
}
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
mock_char.add_item = Mock() # Mock the add_item method
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
# Verify items were added to character inventory
assert mock_char.add_item.called, "Items should be added to character inventory"
assert mock_char.add_item.call_count == 1, "Should add 1 item to inventory"