Combat Backend & Data Models
- Implement Combat Service - Implement Damage Calculator - Implement Effect Processor - Implement Combat Actions - Created Combat API Endpoints
This commit is contained in:
648
api/tests/test_combat_service.py
Normal file
648
api/tests/test_combat_service.py
Normal file
@@ -0,0 +1,648 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user