Files
Code_of_Conquest/api/tests/test_combat_service.py
Phillip Tarrant a38906b445 feat(api): integrate equipment stats into combat damage system
Equipment-Combat Integration:
- Update Stats damage formula from STR//2 to int(STR*0.75) for better scaling
- Add spell_power system for magical weapons (staves, wands)
- Add spell_power_bonus field to Stats model with spell_power property
- Add spell_power field to Item model with is_magical_weapon() method
- Update Character.get_effective_stats() to populate spell_power_bonus

Combatant Model Updates:
- Add weapon property fields (crit_chance, crit_multiplier, damage_type)
- Add elemental weapon support (elemental_damage_type, physical_ratio, elemental_ratio)
- Update serialization to handle new weapon properties

DamageCalculator Refactoring:
- Remove weapon_damage parameter from calculate_physical_damage()
- Use attacker_stats.damage directly (includes weapon bonus)
- Use attacker_stats.spell_power for magical damage calculations

Combat Service Updates:
- Extract weapon properties in _create_combatant_from_character()
- Use stats.damage_bonus for enemy combatants from templates
- Remove hardcoded _get_weapon_damage() method
- Handle elemental weapons with split damage in _execute_attack()

Item Generation Updates:
- Add base_spell_power to BaseItemTemplate dataclass
- Add ARCANE damage type to DamageType enum
- Add magical weapon templates (wizard_staff, arcane_staff, wand, crystal_wand)

Test Updates:
- Update test_stats.py for new damage formula (0.75 scaling)
- Update test_character.py for equipment bonus calculations
- Update test_damage_calculator.py for new API signatures
- Update test_combat_service.py mock fixture for equipped attribute

Tests: 174 passing
2025-11-26 19:54:58 -06:00

650 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()
# 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