""" 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.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() # 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}] service.enemy_loader.load_enemy.return_value = mock_template 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