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:
376
api/tests/test_combat_api.py
Normal file
376
api/tests/test_combat_api.py
Normal file
@@ -0,0 +1,376 @@
|
||||
"""
|
||||
Integration tests for Combat API endpoints.
|
||||
|
||||
Tests the REST API endpoints for combat functionality.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, patch, MagicMock
|
||||
from flask import Flask
|
||||
import json
|
||||
|
||||
from app import create_app
|
||||
from app.api.combat import combat_bp
|
||||
from app.models.combat import CombatEncounter, Combatant, CombatStatus
|
||||
from app.models.stats import Stats
|
||||
from app.models.enemy import EnemyTemplate, EnemyDifficulty
|
||||
from app.services.combat_service import CombatService, ActionResult, CombatRewards
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Test Fixtures
|
||||
# =============================================================================
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
"""Create test Flask application."""
|
||||
app = create_app('development')
|
||||
app.config['TESTING'] = True
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(app):
|
||||
"""Create test client."""
|
||||
return app.test_client()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_stats():
|
||||
"""Sample stats for testing."""
|
||||
return Stats(
|
||||
strength=12,
|
||||
dexterity=14,
|
||||
constitution=10,
|
||||
intelligence=10,
|
||||
wisdom=10,
|
||||
charisma=10,
|
||||
luck=10
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_combatant(sample_stats):
|
||||
"""Sample player combatant."""
|
||||
return Combatant(
|
||||
combatant_id="test_char_001",
|
||||
name="Test Hero",
|
||||
is_player=True,
|
||||
current_hp=50,
|
||||
max_hp=50,
|
||||
current_mp=30,
|
||||
max_mp=30,
|
||||
stats=sample_stats,
|
||||
abilities=["basic_attack", "power_strike"],
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_enemy_combatant(sample_stats):
|
||||
"""Sample enemy combatant."""
|
||||
return Combatant(
|
||||
combatant_id="test_goblin_0",
|
||||
name="Test Goblin",
|
||||
is_player=False,
|
||||
current_hp=25,
|
||||
max_hp=25,
|
||||
current_mp=10,
|
||||
max_mp=10,
|
||||
stats=sample_stats,
|
||||
abilities=["basic_attack"],
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_encounter(sample_combatant, sample_enemy_combatant):
|
||||
"""Sample combat encounter."""
|
||||
encounter = CombatEncounter(
|
||||
encounter_id="test_encounter_001",
|
||||
combatants=[sample_combatant, sample_enemy_combatant],
|
||||
turn_order=[sample_combatant.combatant_id, sample_enemy_combatant.combatant_id],
|
||||
round_number=1,
|
||||
current_turn_index=0,
|
||||
status=CombatStatus.ACTIVE,
|
||||
)
|
||||
return encounter
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# List Enemies Endpoint Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestListEnemiesEndpoint:
|
||||
"""Tests for GET /api/v1/combat/enemies endpoint."""
|
||||
|
||||
def test_list_enemies_success(self, client):
|
||||
"""Test listing all enemy templates."""
|
||||
response = client.get('/api/v1/combat/enemies')
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
|
||||
assert data['status'] == 200
|
||||
assert 'result' in data
|
||||
assert 'enemies' in data['result']
|
||||
|
||||
enemies = data['result']['enemies']
|
||||
assert isinstance(enemies, list)
|
||||
assert len(enemies) >= 6 # We have 6 sample enemies
|
||||
|
||||
# Verify enemy structure
|
||||
enemy_ids = [e['enemy_id'] for e in enemies]
|
||||
assert 'goblin' in enemy_ids
|
||||
|
||||
def test_list_enemies_filter_by_difficulty(self, client):
|
||||
"""Test filtering enemies by difficulty."""
|
||||
response = client.get('/api/v1/combat/enemies?difficulty=easy')
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
|
||||
enemies = data['result']['enemies']
|
||||
for enemy in enemies:
|
||||
assert enemy['difficulty'] == 'easy'
|
||||
|
||||
def test_list_enemies_filter_by_tag(self, client):
|
||||
"""Test filtering enemies by tag."""
|
||||
response = client.get('/api/v1/combat/enemies?tag=humanoid')
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
|
||||
enemies = data['result']['enemies']
|
||||
for enemy in enemies:
|
||||
assert 'humanoid' in [t.lower() for t in enemy['tags']]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Get Enemy Details Endpoint Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestGetEnemyEndpoint:
|
||||
"""Tests for GET /api/v1/combat/enemies/<enemy_id> endpoint."""
|
||||
|
||||
def test_get_enemy_success(self, client):
|
||||
"""Test getting enemy details."""
|
||||
response = client.get('/api/v1/combat/enemies/goblin')
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
|
||||
assert data['status'] == 200
|
||||
# Enemy data is returned directly in result (not nested under 'enemy' key)
|
||||
assert data['result']['enemy_id'] == 'goblin'
|
||||
assert 'base_stats' in data['result']
|
||||
assert 'loot_table' in data['result']
|
||||
|
||||
def test_get_enemy_not_found(self, client):
|
||||
"""Test getting non-existent enemy."""
|
||||
response = client.get('/api/v1/combat/enemies/nonexistent_12345')
|
||||
|
||||
assert response.status_code == 404
|
||||
data = response.get_json()
|
||||
assert data['status'] == 404
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Start Combat Endpoint Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestStartCombatEndpoint:
|
||||
"""Tests for POST /api/v1/combat/start endpoint."""
|
||||
|
||||
def test_start_combat_requires_auth(self, client):
|
||||
"""Test that start combat endpoint requires authentication."""
|
||||
response = client.post(
|
||||
'/api/v1/combat/start',
|
||||
json={
|
||||
'session_id': 'test_session_001',
|
||||
'enemy_ids': ['goblin', 'goblin']
|
||||
}
|
||||
)
|
||||
|
||||
# Should return 401 Unauthorized without valid session
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_start_combat_missing_session_id(self, client):
|
||||
"""Test starting combat without session_id."""
|
||||
response = client.post(
|
||||
'/api/v1/combat/start',
|
||||
json={'enemy_ids': ['goblin']},
|
||||
)
|
||||
|
||||
assert response.status_code in [400, 401]
|
||||
|
||||
def test_start_combat_missing_enemies(self, client):
|
||||
"""Test starting combat without enemies."""
|
||||
response = client.post(
|
||||
'/api/v1/combat/start',
|
||||
json={'session_id': 'test_session'},
|
||||
)
|
||||
|
||||
assert response.status_code in [400, 401]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Execute Action Endpoint Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestExecuteActionEndpoint:
|
||||
"""Tests for POST /api/v1/combat/<session_id>/action endpoint."""
|
||||
|
||||
def test_action_requires_auth(self, client):
|
||||
"""Test that action endpoint requires authentication."""
|
||||
response = client.post(
|
||||
'/api/v1/combat/test_session/action',
|
||||
json={
|
||||
'action_type': 'attack',
|
||||
'target_ids': ['enemy_001']
|
||||
}
|
||||
)
|
||||
|
||||
# Should return 401 Unauthorized without valid session
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_action_missing_type(self, client):
|
||||
"""Test action with missing action_type still requires auth."""
|
||||
# Without auth, returns 401 regardless of payload issues
|
||||
response = client.post(
|
||||
'/api/v1/combat/test_session/action',
|
||||
json={'target_ids': ['enemy_001']}
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Enemy Turn Endpoint Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestEnemyTurnEndpoint:
|
||||
"""Tests for POST /api/v1/combat/<session_id>/enemy-turn endpoint."""
|
||||
|
||||
def test_enemy_turn_requires_auth(self, client):
|
||||
"""Test that enemy turn endpoint requires authentication."""
|
||||
response = client.post('/api/v1/combat/test_session/enemy-turn')
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Flee Endpoint Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestFleeEndpoint:
|
||||
"""Tests for POST /api/v1/combat/<session_id>/flee endpoint."""
|
||||
|
||||
def test_flee_requires_auth(self, client):
|
||||
"""Test that flee endpoint requires authentication."""
|
||||
response = client.post('/api/v1/combat/test_session/flee')
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Get Combat State Endpoint Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestGetCombatStateEndpoint:
|
||||
"""Tests for GET /api/v1/combat/<session_id>/state endpoint."""
|
||||
|
||||
def test_state_requires_auth(self, client):
|
||||
"""Test that state endpoint requires authentication."""
|
||||
response = client.get('/api/v1/combat/test_session/state')
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# End Combat Endpoint Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestEndCombatEndpoint:
|
||||
"""Tests for POST /api/v1/combat/<session_id>/end endpoint."""
|
||||
|
||||
def test_end_requires_auth(self, client):
|
||||
"""Test that end combat endpoint requires authentication."""
|
||||
response = client.post('/api/v1/combat/test_session/end')
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Response Format Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestCombatAPIResponseFormat:
|
||||
"""Tests for API response format consistency."""
|
||||
|
||||
def test_enemies_response_format(self, client):
|
||||
"""Test that enemies list has standard response format."""
|
||||
response = client.get('/api/v1/combat/enemies')
|
||||
data = response.get_json()
|
||||
|
||||
# Standard response fields
|
||||
assert 'app' in data
|
||||
assert 'version' in data
|
||||
assert 'status' in data
|
||||
assert 'timestamp' in data
|
||||
assert 'result' in data
|
||||
|
||||
# Should not have error for successful request
|
||||
assert data['error'] is None or 'error' not in data or data['error'] == {}
|
||||
|
||||
def test_enemy_details_response_format(self, client):
|
||||
"""Test that enemy details has standard response format."""
|
||||
response = client.get('/api/v1/combat/enemies/goblin')
|
||||
data = response.get_json()
|
||||
|
||||
assert data['status'] == 200
|
||||
assert 'result' in data
|
||||
|
||||
# Enemy data is returned directly in result
|
||||
enemy = data['result']
|
||||
# Required enemy fields
|
||||
assert 'enemy_id' in enemy
|
||||
assert 'name' in enemy
|
||||
assert 'description' in enemy
|
||||
assert 'base_stats' in enemy
|
||||
assert 'difficulty' in enemy
|
||||
|
||||
def test_not_found_response_format(self, client):
|
||||
"""Test 404 response format."""
|
||||
response = client.get('/api/v1/combat/enemies/nonexistent_enemy_xyz')
|
||||
data = response.get_json()
|
||||
|
||||
assert data['status'] == 404
|
||||
assert 'error' in data
|
||||
assert data['error'] is not None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Content Type Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestCombatAPIContentType:
|
||||
"""Tests for content type handling."""
|
||||
|
||||
def test_json_content_type_response(self, client):
|
||||
"""Test that API returns JSON content type."""
|
||||
response = client.get('/api/v1/combat/enemies')
|
||||
|
||||
assert response.content_type == 'application/json'
|
||||
|
||||
def test_accepts_json_payload(self, client):
|
||||
"""Test that API accepts JSON payloads."""
|
||||
response = client.post(
|
||||
'/api/v1/combat/start',
|
||||
data=json.dumps({
|
||||
'session_id': 'test',
|
||||
'enemy_ids': ['goblin']
|
||||
}),
|
||||
content_type='application/json'
|
||||
)
|
||||
|
||||
# Should process JSON (even if auth fails)
|
||||
assert response.status_code in [200, 400, 401]
|
||||
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
|
||||
677
api/tests/test_damage_calculator.py
Normal file
677
api/tests/test_damage_calculator.py
Normal file
@@ -0,0 +1,677 @@
|
||||
"""
|
||||
Unit tests for the DamageCalculator service.
|
||||
|
||||
Tests cover:
|
||||
- Hit chance calculations with LUK/DEX
|
||||
- Critical hit chance calculations
|
||||
- Damage variance with lucky rolls
|
||||
- Physical damage formula
|
||||
- Magical damage formula
|
||||
- Elemental split damage
|
||||
- Defense mitigation with minimum guarantee
|
||||
- AoE damage calculations
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import random
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.models.stats import Stats
|
||||
from app.models.enums import DamageType
|
||||
from app.services.damage_calculator import (
|
||||
DamageCalculator,
|
||||
DamageResult,
|
||||
CombatConstants,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Hit Chance Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestHitChance:
|
||||
"""Tests for calculate_hit_chance()."""
|
||||
|
||||
def test_base_hit_chance_with_average_stats(self):
|
||||
"""Test hit chance with average LUK (8) and DEX (10)."""
|
||||
# LUK 8: miss = 10% - 4% = 6%
|
||||
hit_chance = DamageCalculator.calculate_hit_chance(
|
||||
attacker_luck=8,
|
||||
defender_dexterity=10,
|
||||
)
|
||||
assert hit_chance == pytest.approx(0.94, abs=0.001)
|
||||
|
||||
def test_high_luck_reduces_miss_chance(self):
|
||||
"""Test that high LUK reduces miss chance."""
|
||||
# LUK 12: miss = 10% - 6% = 4%, but capped at 5%
|
||||
hit_chance = DamageCalculator.calculate_hit_chance(
|
||||
attacker_luck=12,
|
||||
defender_dexterity=10,
|
||||
)
|
||||
assert hit_chance == pytest.approx(0.95, abs=0.001)
|
||||
|
||||
def test_miss_chance_hard_cap_at_five_percent(self):
|
||||
"""Test that miss chance cannot go below 5% (hard cap)."""
|
||||
# LUK 20: would be 10% - 10% = 0%, but capped at 5%
|
||||
hit_chance = DamageCalculator.calculate_hit_chance(
|
||||
attacker_luck=20,
|
||||
defender_dexterity=10,
|
||||
)
|
||||
assert hit_chance == pytest.approx(0.95, abs=0.001)
|
||||
|
||||
def test_high_dex_increases_evasion(self):
|
||||
"""Test that defender's high DEX increases miss chance."""
|
||||
# LUK 8, DEX 15: miss = 10% - 4% + 1.25% = 7.25%
|
||||
hit_chance = DamageCalculator.calculate_hit_chance(
|
||||
attacker_luck=8,
|
||||
defender_dexterity=15,
|
||||
)
|
||||
assert hit_chance == pytest.approx(0.9275, abs=0.001)
|
||||
|
||||
def test_dex_below_ten_has_no_evasion_bonus(self):
|
||||
"""Test that DEX below 10 doesn't reduce attacker's hit chance."""
|
||||
# DEX 5 should be same as DEX 10 (no negative evasion)
|
||||
hit_low_dex = DamageCalculator.calculate_hit_chance(
|
||||
attacker_luck=8,
|
||||
defender_dexterity=5,
|
||||
)
|
||||
hit_base_dex = DamageCalculator.calculate_hit_chance(
|
||||
attacker_luck=8,
|
||||
defender_dexterity=10,
|
||||
)
|
||||
assert hit_low_dex == hit_base_dex
|
||||
|
||||
def test_skill_bonus_improves_hit_chance(self):
|
||||
"""Test that skill bonus adds to hit chance."""
|
||||
base_hit = DamageCalculator.calculate_hit_chance(
|
||||
attacker_luck=8,
|
||||
defender_dexterity=10,
|
||||
)
|
||||
skill_hit = DamageCalculator.calculate_hit_chance(
|
||||
attacker_luck=8,
|
||||
defender_dexterity=10,
|
||||
skill_bonus=0.05, # 5% bonus
|
||||
)
|
||||
assert skill_hit > base_hit
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Critical Hit Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestCritChance:
|
||||
"""Tests for calculate_crit_chance()."""
|
||||
|
||||
def test_base_crit_with_average_luck(self):
|
||||
"""Test crit chance with average LUK (8)."""
|
||||
# Base 5% + LUK 8 * 0.5% = 5% + 4% = 9%
|
||||
crit_chance = DamageCalculator.calculate_crit_chance(
|
||||
attacker_luck=8,
|
||||
)
|
||||
assert crit_chance == pytest.approx(0.09, abs=0.001)
|
||||
|
||||
def test_high_luck_increases_crit(self):
|
||||
"""Test that high LUK increases crit chance."""
|
||||
# Base 5% + LUK 12 * 0.5% = 5% + 6% = 11%
|
||||
crit_chance = DamageCalculator.calculate_crit_chance(
|
||||
attacker_luck=12,
|
||||
)
|
||||
assert crit_chance == pytest.approx(0.11, abs=0.001)
|
||||
|
||||
def test_weapon_crit_stacks_with_luck(self):
|
||||
"""Test that weapon crit chance stacks with LUK bonus."""
|
||||
# Weapon 10% + LUK 12 * 0.5% = 10% + 6% = 16%
|
||||
crit_chance = DamageCalculator.calculate_crit_chance(
|
||||
attacker_luck=12,
|
||||
weapon_crit_chance=0.10,
|
||||
)
|
||||
assert crit_chance == pytest.approx(0.16, abs=0.001)
|
||||
|
||||
def test_crit_chance_hard_cap_at_25_percent(self):
|
||||
"""Test that crit chance is capped at 25%."""
|
||||
# Weapon 20% + LUK 20 * 0.5% = 20% + 10% = 30%, but capped at 25%
|
||||
crit_chance = DamageCalculator.calculate_crit_chance(
|
||||
attacker_luck=20,
|
||||
weapon_crit_chance=0.20,
|
||||
)
|
||||
assert crit_chance == pytest.approx(0.25, abs=0.001)
|
||||
|
||||
def test_skill_bonus_adds_to_crit(self):
|
||||
"""Test that skill bonus adds to crit chance."""
|
||||
base_crit = DamageCalculator.calculate_crit_chance(
|
||||
attacker_luck=8,
|
||||
)
|
||||
skill_crit = DamageCalculator.calculate_crit_chance(
|
||||
attacker_luck=8,
|
||||
skill_bonus=0.05,
|
||||
)
|
||||
assert skill_crit == base_crit + 0.05
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Damage Variance Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestDamageVariance:
|
||||
"""Tests for calculate_variance()."""
|
||||
|
||||
@patch('random.random')
|
||||
@patch('random.uniform')
|
||||
def test_normal_variance_roll(self, mock_uniform, mock_random):
|
||||
"""Test normal variance roll (95%-105%)."""
|
||||
# Not a lucky roll (random returns high value)
|
||||
mock_random.return_value = 0.99
|
||||
mock_uniform.return_value = 1.0
|
||||
|
||||
variance = DamageCalculator.calculate_variance(attacker_luck=8)
|
||||
|
||||
# Should call uniform with base variance range
|
||||
mock_uniform.assert_called_with(
|
||||
CombatConstants.BASE_VARIANCE_MIN,
|
||||
CombatConstants.BASE_VARIANCE_MAX,
|
||||
)
|
||||
assert variance == 1.0
|
||||
|
||||
@patch('random.random')
|
||||
@patch('random.uniform')
|
||||
def test_lucky_variance_roll(self, mock_uniform, mock_random):
|
||||
"""Test lucky variance roll (100%-110%)."""
|
||||
# Lucky roll (random returns low value)
|
||||
mock_random.return_value = 0.01
|
||||
mock_uniform.return_value = 1.08
|
||||
|
||||
variance = DamageCalculator.calculate_variance(attacker_luck=8)
|
||||
|
||||
# Should call uniform with lucky variance range
|
||||
mock_uniform.assert_called_with(
|
||||
CombatConstants.LUCKY_VARIANCE_MIN,
|
||||
CombatConstants.LUCKY_VARIANCE_MAX,
|
||||
)
|
||||
assert variance == 1.08
|
||||
|
||||
def test_high_luck_increases_lucky_chance(self):
|
||||
"""Test that high LUK increases chance for lucky roll."""
|
||||
# LUK 8: lucky chance = 5% + 2% = 7%
|
||||
# LUK 12: lucky chance = 5% + 3% = 8%
|
||||
# Run many iterations to verify probability
|
||||
lucky_count_low = 0
|
||||
lucky_count_high = 0
|
||||
iterations = 10000
|
||||
|
||||
random.seed(42) # Reproducible
|
||||
for _ in range(iterations):
|
||||
variance = DamageCalculator.calculate_variance(8)
|
||||
if variance >= 1.0:
|
||||
lucky_count_low += 1
|
||||
|
||||
random.seed(42) # Same seed
|
||||
for _ in range(iterations):
|
||||
variance = DamageCalculator.calculate_variance(12)
|
||||
if variance >= 1.0:
|
||||
lucky_count_high += 1
|
||||
|
||||
# Higher LUK should have more lucky rolls
|
||||
# Note: This is a statistical test, might have some variance
|
||||
# Just verify the high LUK isn't dramatically lower
|
||||
assert lucky_count_high >= lucky_count_low * 0.9
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Defense Mitigation Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestDefenseMitigation:
|
||||
"""Tests for apply_defense()."""
|
||||
|
||||
def test_normal_defense_mitigation(self):
|
||||
"""Test standard defense subtraction."""
|
||||
# 20 damage - 5 defense = 15 damage
|
||||
result = DamageCalculator.apply_defense(raw_damage=20, defense=5)
|
||||
assert result == 15
|
||||
|
||||
def test_minimum_damage_guarantee(self):
|
||||
"""Test that minimum 20% damage always goes through."""
|
||||
# 20 damage - 18 defense = 2 damage (but min is 20% of 20 = 4)
|
||||
result = DamageCalculator.apply_defense(raw_damage=20, defense=18)
|
||||
assert result == 4
|
||||
|
||||
def test_defense_higher_than_damage(self):
|
||||
"""Test when defense exceeds raw damage."""
|
||||
# 10 damage - 100 defense = -90, but min is 20% of 10 = 2
|
||||
result = DamageCalculator.apply_defense(raw_damage=10, defense=100)
|
||||
assert result == 2
|
||||
|
||||
def test_absolute_minimum_damage_is_one(self):
|
||||
"""Test that absolute minimum damage is 1."""
|
||||
# 3 damage - 100 defense = negative, but 20% of 3 = 0.6, so min is 1
|
||||
result = DamageCalculator.apply_defense(raw_damage=3, defense=100)
|
||||
assert result == 1
|
||||
|
||||
def test_custom_minimum_ratio(self):
|
||||
"""Test custom minimum damage ratio."""
|
||||
# 20 damage with 30% minimum = at least 6 damage
|
||||
result = DamageCalculator.apply_defense(
|
||||
raw_damage=20,
|
||||
defense=18,
|
||||
min_damage_ratio=0.30,
|
||||
)
|
||||
assert result == 6
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Physical Damage Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestPhysicalDamage:
|
||||
"""Tests for calculate_physical_damage()."""
|
||||
|
||||
def test_basic_physical_damage_formula(self):
|
||||
"""Test the basic physical damage formula."""
|
||||
# Formula: (Weapon + STR * 0.75) * Variance - DEF
|
||||
attacker = Stats(strength=14, luck=0) # LUK 0 to ensure no miss
|
||||
defender = Stats(constitution=10, dexterity=10) # DEF = 5
|
||||
|
||||
# Mock to ensure no miss and no crit, variance = 1.0
|
||||
with patch.object(DamageCalculator, 'calculate_hit_chance', return_value=1.0):
|
||||
with patch.object(DamageCalculator, 'calculate_variance', return_value=1.0):
|
||||
with patch.object(DamageCalculator, 'calculate_crit_chance', return_value=0.0):
|
||||
result = DamageCalculator.calculate_physical_damage(
|
||||
attacker_stats=attacker,
|
||||
defender_stats=defender,
|
||||
weapon_damage=8,
|
||||
)
|
||||
|
||||
# 8 + (14 * 0.75) = 8 + 10.5 = 18.5 -> 18 - 5 = 13
|
||||
assert result.total_damage == 13
|
||||
assert result.is_miss is False
|
||||
assert result.is_critical is False
|
||||
assert result.damage_type == DamageType.PHYSICAL
|
||||
|
||||
def test_physical_damage_miss(self):
|
||||
"""Test that misses deal zero damage."""
|
||||
attacker = Stats(strength=14, luck=0)
|
||||
defender = Stats(dexterity=30) # Very high DEX
|
||||
|
||||
# Force a miss
|
||||
with patch('random.random', return_value=0.99):
|
||||
result = DamageCalculator.calculate_physical_damage(
|
||||
attacker_stats=attacker,
|
||||
defender_stats=defender,
|
||||
weapon_damage=8,
|
||||
)
|
||||
|
||||
assert result.is_miss is True
|
||||
assert result.total_damage == 0
|
||||
assert "missed" in result.message.lower()
|
||||
|
||||
def test_physical_damage_critical_hit(self):
|
||||
"""Test critical hit doubles damage."""
|
||||
attacker = Stats(strength=14, luck=20) # High LUK for crit
|
||||
defender = Stats(constitution=10, dexterity=10)
|
||||
|
||||
# Force hit and crit
|
||||
with patch('random.random', side_effect=[0.01, 0.01]): # Hit, then crit
|
||||
with patch.object(DamageCalculator, 'calculate_variance', return_value=1.0):
|
||||
result = DamageCalculator.calculate_physical_damage(
|
||||
attacker_stats=attacker,
|
||||
defender_stats=defender,
|
||||
weapon_damage=8,
|
||||
weapon_crit_multiplier=2.0,
|
||||
)
|
||||
|
||||
assert result.is_critical is True
|
||||
# Base: 8 + 14*0.75 = 18.5
|
||||
# Crit applied BEFORE int conversion: 18.5 * 2 = 37
|
||||
# After DEF 5: 37 - 5 = 32
|
||||
assert result.total_damage == 32
|
||||
assert "critical" in result.message.lower()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Magical Damage Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestMagicalDamage:
|
||||
"""Tests for calculate_magical_damage()."""
|
||||
|
||||
def test_basic_magical_damage_formula(self):
|
||||
"""Test the basic magical damage formula."""
|
||||
# Formula: (Ability + INT * 0.75) * Variance - RES
|
||||
attacker = Stats(intelligence=15, luck=0)
|
||||
defender = Stats(wisdom=10, dexterity=10) # RES = 5
|
||||
|
||||
with patch.object(DamageCalculator, 'calculate_hit_chance', return_value=1.0):
|
||||
with patch.object(DamageCalculator, 'calculate_variance', return_value=1.0):
|
||||
with patch.object(DamageCalculator, 'calculate_crit_chance', return_value=0.0):
|
||||
result = DamageCalculator.calculate_magical_damage(
|
||||
attacker_stats=attacker,
|
||||
defender_stats=defender,
|
||||
ability_base_power=12,
|
||||
damage_type=DamageType.FIRE,
|
||||
)
|
||||
|
||||
# 12 + (15 * 0.75) = 12 + 11.25 = 23.25 -> 23 - 5 = 18
|
||||
assert result.total_damage == 18
|
||||
assert result.damage_type == DamageType.FIRE
|
||||
assert result.is_miss is False
|
||||
|
||||
def test_spells_can_critically_hit(self):
|
||||
"""Test that spells can crit (per user requirement)."""
|
||||
attacker = Stats(intelligence=15, luck=20)
|
||||
defender = Stats(wisdom=10, dexterity=10)
|
||||
|
||||
# Force hit and crit
|
||||
with patch('random.random', side_effect=[0.01, 0.01]):
|
||||
with patch.object(DamageCalculator, 'calculate_variance', return_value=1.0):
|
||||
result = DamageCalculator.calculate_magical_damage(
|
||||
attacker_stats=attacker,
|
||||
defender_stats=defender,
|
||||
ability_base_power=12,
|
||||
damage_type=DamageType.FIRE,
|
||||
weapon_crit_multiplier=2.0,
|
||||
)
|
||||
|
||||
assert result.is_critical is True
|
||||
# Base: 12 + 15*0.75 = 23.25 -> 23
|
||||
# Crit: 23 * 2 = 46
|
||||
# After RES 5: 46 - 5 = 41
|
||||
assert result.total_damage == 41
|
||||
|
||||
def test_magical_damage_with_different_types(self):
|
||||
"""Test that different damage types are recorded correctly."""
|
||||
attacker = Stats(intelligence=10)
|
||||
defender = Stats(wisdom=10, dexterity=10)
|
||||
|
||||
with patch.object(DamageCalculator, 'calculate_hit_chance', return_value=1.0):
|
||||
with patch.object(DamageCalculator, 'calculate_variance', return_value=1.0):
|
||||
with patch.object(DamageCalculator, 'calculate_crit_chance', return_value=0.0):
|
||||
for damage_type in [DamageType.ICE, DamageType.LIGHTNING, DamageType.HOLY]:
|
||||
result = DamageCalculator.calculate_magical_damage(
|
||||
attacker_stats=attacker,
|
||||
defender_stats=defender,
|
||||
ability_base_power=10,
|
||||
damage_type=damage_type,
|
||||
)
|
||||
assert result.damage_type == damage_type
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Elemental Weapon (Split Damage) Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestElementalWeaponDamage:
|
||||
"""Tests for calculate_elemental_weapon_damage()."""
|
||||
|
||||
def test_split_damage_calculation(self):
|
||||
"""Test 70/30 physical/fire split damage."""
|
||||
# Fire Sword: 70% physical, 30% fire
|
||||
attacker = Stats(strength=14, intelligence=8, luck=0)
|
||||
defender = Stats(constitution=10, wisdom=10, dexterity=10) # DEF=5, RES=5
|
||||
|
||||
with patch.object(DamageCalculator, 'calculate_hit_chance', return_value=1.0):
|
||||
with patch.object(DamageCalculator, 'calculate_variance', return_value=1.0):
|
||||
with patch.object(DamageCalculator, 'calculate_crit_chance', return_value=0.0):
|
||||
result = DamageCalculator.calculate_elemental_weapon_damage(
|
||||
attacker_stats=attacker,
|
||||
defender_stats=defender,
|
||||
weapon_damage=15,
|
||||
weapon_crit_chance=0.05,
|
||||
weapon_crit_multiplier=2.0,
|
||||
physical_ratio=0.7,
|
||||
elemental_ratio=0.3,
|
||||
elemental_type=DamageType.FIRE,
|
||||
)
|
||||
|
||||
# Physical: (15 * 0.7) + (14 * 0.75 * 0.7) - 5 = 10.5 + 7.35 - 5 = 12.85 -> 12
|
||||
# Elemental: (15 * 0.3) + (8 * 0.75 * 0.3) - 5 = 4.5 + 1.8 - 5 = 1.3 -> 1
|
||||
# Total: 12 + 1 = 13 (approximately, depends on min damage)
|
||||
|
||||
assert result.physical_damage > 0
|
||||
assert result.elemental_damage >= 1 # At least minimum damage
|
||||
assert result.total_damage == result.physical_damage + result.elemental_damage
|
||||
assert result.elemental_type == DamageType.FIRE
|
||||
|
||||
def test_50_50_split_damage(self):
|
||||
"""Test 50/50 physical/elemental split (Lightning Spear)."""
|
||||
attacker = Stats(strength=12, intelligence=12, luck=0)
|
||||
defender = Stats(constitution=10, wisdom=10, dexterity=10)
|
||||
|
||||
with patch.object(DamageCalculator, 'calculate_hit_chance', return_value=1.0):
|
||||
with patch.object(DamageCalculator, 'calculate_variance', return_value=1.0):
|
||||
with patch.object(DamageCalculator, 'calculate_crit_chance', return_value=0.0):
|
||||
result = DamageCalculator.calculate_elemental_weapon_damage(
|
||||
attacker_stats=attacker,
|
||||
defender_stats=defender,
|
||||
weapon_damage=20,
|
||||
weapon_crit_chance=0.05,
|
||||
weapon_crit_multiplier=2.0,
|
||||
physical_ratio=0.5,
|
||||
elemental_ratio=0.5,
|
||||
elemental_type=DamageType.LIGHTNING,
|
||||
)
|
||||
|
||||
# Both components should be similar (same stat values)
|
||||
assert abs(result.physical_damage - result.elemental_damage) <= 2
|
||||
|
||||
def test_elemental_crit_applies_to_both_components(self):
|
||||
"""Test that crit multiplier applies to both damage types."""
|
||||
attacker = Stats(strength=14, intelligence=8, luck=20)
|
||||
defender = Stats(constitution=10, wisdom=10, dexterity=10)
|
||||
|
||||
# Force hit and crit
|
||||
with patch('random.random', side_effect=[0.01, 0.01]):
|
||||
with patch.object(DamageCalculator, 'calculate_variance', return_value=1.0):
|
||||
result = DamageCalculator.calculate_elemental_weapon_damage(
|
||||
attacker_stats=attacker,
|
||||
defender_stats=defender,
|
||||
weapon_damage=15,
|
||||
weapon_crit_chance=0.05,
|
||||
weapon_crit_multiplier=2.0,
|
||||
physical_ratio=0.7,
|
||||
elemental_ratio=0.3,
|
||||
elemental_type=DamageType.FIRE,
|
||||
)
|
||||
|
||||
assert result.is_critical is True
|
||||
# Both components should be doubled
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# AoE Damage Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestAoEDamage:
|
||||
"""Tests for calculate_aoe_damage()."""
|
||||
|
||||
def test_aoe_full_damage_to_all_targets(self):
|
||||
"""Test that AoE deals full damage to each target."""
|
||||
attacker = Stats(intelligence=15, luck=0)
|
||||
defenders = [
|
||||
Stats(wisdom=10, dexterity=10),
|
||||
Stats(wisdom=10, dexterity=10),
|
||||
Stats(wisdom=10, dexterity=10),
|
||||
]
|
||||
|
||||
with patch.object(DamageCalculator, 'calculate_hit_chance', return_value=1.0):
|
||||
with patch.object(DamageCalculator, 'calculate_variance', return_value=1.0):
|
||||
with patch.object(DamageCalculator, 'calculate_crit_chance', return_value=0.0):
|
||||
results = DamageCalculator.calculate_aoe_damage(
|
||||
attacker_stats=attacker,
|
||||
defender_stats_list=defenders,
|
||||
ability_base_power=20,
|
||||
damage_type=DamageType.FIRE,
|
||||
)
|
||||
|
||||
assert len(results) == 3
|
||||
# All targets should take the same damage (same stats)
|
||||
for result in results:
|
||||
assert result.total_damage == results[0].total_damage
|
||||
|
||||
def test_aoe_independent_hit_checks(self):
|
||||
"""Test that each target has independent hit/miss rolls."""
|
||||
attacker = Stats(intelligence=15, luck=8)
|
||||
defenders = [
|
||||
Stats(wisdom=10, dexterity=10),
|
||||
Stats(wisdom=10, dexterity=10),
|
||||
]
|
||||
|
||||
# First target hit, second target miss
|
||||
hit_sequence = [0.5, 0.99] # Below hit chance, above hit chance
|
||||
with patch('random.random', side_effect=hit_sequence * 2): # Extra for crit checks
|
||||
results = DamageCalculator.calculate_aoe_damage(
|
||||
attacker_stats=attacker,
|
||||
defender_stats_list=defenders,
|
||||
ability_base_power=20,
|
||||
damage_type=DamageType.FIRE,
|
||||
)
|
||||
|
||||
# At least verify we got results for both
|
||||
assert len(results) == 2
|
||||
|
||||
def test_aoe_with_varying_resistance(self):
|
||||
"""Test that AoE respects different resistances per target."""
|
||||
attacker = Stats(intelligence=15, luck=0)
|
||||
defenders = [
|
||||
Stats(wisdom=10, dexterity=10), # RES = 5
|
||||
Stats(wisdom=20, dexterity=10), # RES = 10
|
||||
]
|
||||
|
||||
with patch.object(DamageCalculator, 'calculate_hit_chance', return_value=1.0):
|
||||
with patch.object(DamageCalculator, 'calculate_variance', return_value=1.0):
|
||||
with patch.object(DamageCalculator, 'calculate_crit_chance', return_value=0.0):
|
||||
results = DamageCalculator.calculate_aoe_damage(
|
||||
attacker_stats=attacker,
|
||||
defender_stats_list=defenders,
|
||||
ability_base_power=20,
|
||||
damage_type=DamageType.FIRE,
|
||||
)
|
||||
|
||||
# First target (lower RES) should take more damage
|
||||
assert results[0].total_damage > results[1].total_damage
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DamageResult Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestDamageResult:
|
||||
"""Tests for DamageResult dataclass."""
|
||||
|
||||
def test_damage_result_to_dict(self):
|
||||
"""Test serialization of DamageResult."""
|
||||
result = DamageResult(
|
||||
total_damage=25,
|
||||
physical_damage=25,
|
||||
elemental_damage=0,
|
||||
damage_type=DamageType.PHYSICAL,
|
||||
is_critical=True,
|
||||
is_miss=False,
|
||||
variance_roll=1.05,
|
||||
raw_damage=30,
|
||||
message="Dealt 25 physical damage. CRITICAL HIT!",
|
||||
)
|
||||
|
||||
data = result.to_dict()
|
||||
|
||||
assert data["total_damage"] == 25
|
||||
assert data["physical_damage"] == 25
|
||||
assert data["damage_type"] == "physical"
|
||||
assert data["is_critical"] is True
|
||||
assert data["is_miss"] is False
|
||||
assert data["variance_roll"] == pytest.approx(1.05, abs=0.001)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Combat Constants Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestCombatConstants:
|
||||
"""Tests for CombatConstants configuration."""
|
||||
|
||||
def test_stat_scaling_factor(self):
|
||||
"""Verify scaling factor is 0.75."""
|
||||
assert CombatConstants.STAT_SCALING_FACTOR == 0.75
|
||||
|
||||
def test_miss_chance_hard_cap(self):
|
||||
"""Verify miss chance hard cap is 5%."""
|
||||
assert CombatConstants.MIN_MISS_CHANCE == 0.05
|
||||
|
||||
def test_crit_chance_cap(self):
|
||||
"""Verify crit chance cap is 25%."""
|
||||
assert CombatConstants.MAX_CRIT_CHANCE == 0.25
|
||||
|
||||
def test_minimum_damage_ratio(self):
|
||||
"""Verify minimum damage ratio is 20%."""
|
||||
assert CombatConstants.MIN_DAMAGE_RATIO == 0.20
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Integration Tests (Full Combat Flow)
|
||||
# =============================================================================
|
||||
|
||||
class TestCombatIntegration:
|
||||
"""Integration tests for complete combat scenarios."""
|
||||
|
||||
def test_vanguard_attack_scenario(self):
|
||||
"""Test Vanguard (STR 14) basic attack."""
|
||||
# Vanguard: STR 14, LUK 8
|
||||
vanguard = Stats(strength=14, dexterity=10, constitution=14, luck=8)
|
||||
goblin = Stats(constitution=10, dexterity=10) # DEF = 5
|
||||
|
||||
with patch.object(DamageCalculator, 'calculate_hit_chance', return_value=1.0):
|
||||
with patch.object(DamageCalculator, 'calculate_variance', return_value=1.0):
|
||||
with patch.object(DamageCalculator, 'calculate_crit_chance', return_value=0.0):
|
||||
result = DamageCalculator.calculate_physical_damage(
|
||||
attacker_stats=vanguard,
|
||||
defender_stats=goblin,
|
||||
weapon_damage=8, # Rusty sword
|
||||
)
|
||||
|
||||
# 8 + (14 * 0.75) = 18.5 -> 18 - 5 = 13
|
||||
assert result.total_damage == 13
|
||||
|
||||
def test_arcanist_fireball_scenario(self):
|
||||
"""Test Arcanist (INT 15) Fireball."""
|
||||
# Arcanist: INT 15, LUK 9
|
||||
arcanist = Stats(intelligence=15, dexterity=10, wisdom=12, luck=9)
|
||||
goblin = Stats(wisdom=10, dexterity=10) # RES = 5
|
||||
|
||||
with patch.object(DamageCalculator, 'calculate_hit_chance', return_value=1.0):
|
||||
with patch.object(DamageCalculator, 'calculate_variance', return_value=1.0):
|
||||
with patch.object(DamageCalculator, 'calculate_crit_chance', return_value=0.0):
|
||||
result = DamageCalculator.calculate_magical_damage(
|
||||
attacker_stats=arcanist,
|
||||
defender_stats=goblin,
|
||||
ability_base_power=12, # Fireball base
|
||||
damage_type=DamageType.FIRE,
|
||||
)
|
||||
|
||||
# 12 + (15 * 0.75) = 23.25 -> 23 - 5 = 18
|
||||
assert result.total_damage == 18
|
||||
|
||||
def test_physical_vs_magical_balance(self):
|
||||
"""Test that physical and magical damage are comparable."""
|
||||
# Same-tier characters should deal similar damage
|
||||
vanguard = Stats(strength=14, luck=8) # Melee
|
||||
arcanist = Stats(intelligence=15, luck=9) # Caster
|
||||
target = Stats(constitution=10, wisdom=10, dexterity=10) # DEF=5, RES=5
|
||||
|
||||
with patch.object(DamageCalculator, 'calculate_hit_chance', return_value=1.0):
|
||||
with patch.object(DamageCalculator, 'calculate_variance', return_value=1.0):
|
||||
with patch.object(DamageCalculator, 'calculate_crit_chance', return_value=0.0):
|
||||
phys_result = DamageCalculator.calculate_physical_damage(
|
||||
attacker_stats=vanguard,
|
||||
defender_stats=target,
|
||||
weapon_damage=8,
|
||||
)
|
||||
magic_result = DamageCalculator.calculate_magical_damage(
|
||||
attacker_stats=arcanist,
|
||||
defender_stats=target,
|
||||
ability_base_power=12,
|
||||
damage_type=DamageType.FIRE,
|
||||
)
|
||||
|
||||
# Mage should deal slightly more (compensates for mana cost)
|
||||
assert magic_result.total_damage >= phys_result.total_damage
|
||||
# But not drastically more (within ~50%)
|
||||
assert magic_result.total_damage <= phys_result.total_damage * 1.5
|
||||
399
api/tests/test_enemy_loader.py
Normal file
399
api/tests/test_enemy_loader.py
Normal file
@@ -0,0 +1,399 @@
|
||||
"""
|
||||
Unit tests for EnemyTemplate model and EnemyLoader service.
|
||||
|
||||
Tests enemy loading, serialization, and filtering functionality.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
|
||||
from app.models.enemy import EnemyTemplate, EnemyDifficulty, LootEntry
|
||||
from app.models.stats import Stats
|
||||
from app.services.enemy_loader import EnemyLoader
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# EnemyTemplate Model Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestEnemyTemplate:
|
||||
"""Tests for EnemyTemplate dataclass."""
|
||||
|
||||
def test_create_basic_enemy(self):
|
||||
"""Test creating an enemy with minimal attributes."""
|
||||
enemy = EnemyTemplate(
|
||||
enemy_id="test_enemy",
|
||||
name="Test Enemy",
|
||||
description="A test enemy",
|
||||
base_stats=Stats(strength=10, constitution=8),
|
||||
)
|
||||
|
||||
assert enemy.enemy_id == "test_enemy"
|
||||
assert enemy.name == "Test Enemy"
|
||||
assert enemy.base_stats.strength == 10
|
||||
assert enemy.difficulty == EnemyDifficulty.EASY # Default
|
||||
|
||||
def test_enemy_with_full_attributes(self):
|
||||
"""Test creating an enemy with all attributes."""
|
||||
loot = [
|
||||
LootEntry(item_id="sword", drop_chance=0.5),
|
||||
LootEntry(item_id="gold", drop_chance=1.0, quantity_min=5, quantity_max=10),
|
||||
]
|
||||
|
||||
enemy = EnemyTemplate(
|
||||
enemy_id="goblin_boss",
|
||||
name="Goblin Boss",
|
||||
description="A fearsome goblin leader",
|
||||
base_stats=Stats(strength=14, dexterity=12, constitution=12),
|
||||
abilities=["basic_attack", "power_strike"],
|
||||
loot_table=loot,
|
||||
experience_reward=100,
|
||||
gold_reward_min=20,
|
||||
gold_reward_max=50,
|
||||
difficulty=EnemyDifficulty.HARD,
|
||||
tags=["humanoid", "goblinoid", "boss"],
|
||||
base_damage=12,
|
||||
crit_chance=0.15,
|
||||
flee_chance=0.25,
|
||||
)
|
||||
|
||||
assert enemy.enemy_id == "goblin_boss"
|
||||
assert enemy.experience_reward == 100
|
||||
assert enemy.difficulty == EnemyDifficulty.HARD
|
||||
assert len(enemy.loot_table) == 2
|
||||
assert len(enemy.abilities) == 2
|
||||
assert "boss" in enemy.tags
|
||||
|
||||
def test_is_boss(self):
|
||||
"""Test boss detection."""
|
||||
easy_enemy = EnemyTemplate(
|
||||
enemy_id="minion",
|
||||
name="Minion",
|
||||
description="",
|
||||
base_stats=Stats(),
|
||||
difficulty=EnemyDifficulty.EASY,
|
||||
)
|
||||
boss_enemy = EnemyTemplate(
|
||||
enemy_id="boss",
|
||||
name="Boss",
|
||||
description="",
|
||||
base_stats=Stats(),
|
||||
difficulty=EnemyDifficulty.BOSS,
|
||||
)
|
||||
|
||||
assert not easy_enemy.is_boss()
|
||||
assert boss_enemy.is_boss()
|
||||
|
||||
def test_has_tag(self):
|
||||
"""Test tag checking."""
|
||||
enemy = EnemyTemplate(
|
||||
enemy_id="zombie",
|
||||
name="Zombie",
|
||||
description="",
|
||||
base_stats=Stats(),
|
||||
tags=["undead", "slow", "Humanoid"], # Mixed case
|
||||
)
|
||||
|
||||
assert enemy.has_tag("undead")
|
||||
assert enemy.has_tag("UNDEAD") # Case insensitive
|
||||
assert enemy.has_tag("humanoid")
|
||||
assert not enemy.has_tag("beast")
|
||||
|
||||
def test_get_gold_reward(self):
|
||||
"""Test gold reward generation."""
|
||||
enemy = EnemyTemplate(
|
||||
enemy_id="test",
|
||||
name="Test",
|
||||
description="",
|
||||
base_stats=Stats(),
|
||||
gold_reward_min=10,
|
||||
gold_reward_max=20,
|
||||
)
|
||||
|
||||
# Run multiple times to check range
|
||||
for _ in range(50):
|
||||
gold = enemy.get_gold_reward()
|
||||
assert 10 <= gold <= 20
|
||||
|
||||
def test_roll_loot_empty_table(self):
|
||||
"""Test loot rolling with empty table."""
|
||||
enemy = EnemyTemplate(
|
||||
enemy_id="test",
|
||||
name="Test",
|
||||
description="",
|
||||
base_stats=Stats(),
|
||||
loot_table=[],
|
||||
)
|
||||
|
||||
drops = enemy.roll_loot()
|
||||
assert drops == []
|
||||
|
||||
def test_roll_loot_guaranteed_drop(self):
|
||||
"""Test loot rolling with guaranteed drop."""
|
||||
enemy = EnemyTemplate(
|
||||
enemy_id="test",
|
||||
name="Test",
|
||||
description="",
|
||||
base_stats=Stats(),
|
||||
loot_table=[
|
||||
LootEntry(item_id="guaranteed_item", drop_chance=1.0),
|
||||
],
|
||||
)
|
||||
|
||||
drops = enemy.roll_loot()
|
||||
assert len(drops) == 1
|
||||
assert drops[0]["item_id"] == "guaranteed_item"
|
||||
|
||||
def test_serialization_round_trip(self):
|
||||
"""Test that to_dict/from_dict preserves data."""
|
||||
original = EnemyTemplate(
|
||||
enemy_id="test_enemy",
|
||||
name="Test Enemy",
|
||||
description="A test description",
|
||||
base_stats=Stats(strength=15, dexterity=12, luck=10),
|
||||
abilities=["attack", "defend"],
|
||||
loot_table=[
|
||||
LootEntry(item_id="sword", drop_chance=0.5),
|
||||
],
|
||||
experience_reward=50,
|
||||
gold_reward_min=10,
|
||||
gold_reward_max=25,
|
||||
difficulty=EnemyDifficulty.MEDIUM,
|
||||
tags=["humanoid", "test"],
|
||||
base_damage=8,
|
||||
crit_chance=0.10,
|
||||
flee_chance=0.40,
|
||||
)
|
||||
|
||||
# Serialize and deserialize
|
||||
data = original.to_dict()
|
||||
restored = EnemyTemplate.from_dict(data)
|
||||
|
||||
# Verify all fields match
|
||||
assert restored.enemy_id == original.enemy_id
|
||||
assert restored.name == original.name
|
||||
assert restored.description == original.description
|
||||
assert restored.base_stats.strength == original.base_stats.strength
|
||||
assert restored.base_stats.luck == original.base_stats.luck
|
||||
assert restored.abilities == original.abilities
|
||||
assert len(restored.loot_table) == len(original.loot_table)
|
||||
assert restored.experience_reward == original.experience_reward
|
||||
assert restored.gold_reward_min == original.gold_reward_min
|
||||
assert restored.gold_reward_max == original.gold_reward_max
|
||||
assert restored.difficulty == original.difficulty
|
||||
assert restored.tags == original.tags
|
||||
assert restored.base_damage == original.base_damage
|
||||
assert restored.crit_chance == pytest.approx(original.crit_chance)
|
||||
assert restored.flee_chance == pytest.approx(original.flee_chance)
|
||||
|
||||
|
||||
class TestLootEntry:
|
||||
"""Tests for LootEntry dataclass."""
|
||||
|
||||
def test_create_loot_entry(self):
|
||||
"""Test creating a loot entry."""
|
||||
entry = LootEntry(
|
||||
item_id="gold_coin",
|
||||
drop_chance=0.75,
|
||||
quantity_min=5,
|
||||
quantity_max=15,
|
||||
)
|
||||
|
||||
assert entry.item_id == "gold_coin"
|
||||
assert entry.drop_chance == 0.75
|
||||
assert entry.quantity_min == 5
|
||||
assert entry.quantity_max == 15
|
||||
|
||||
def test_loot_entry_defaults(self):
|
||||
"""Test loot entry default values."""
|
||||
entry = LootEntry(item_id="item")
|
||||
|
||||
assert entry.drop_chance == 0.1
|
||||
assert entry.quantity_min == 1
|
||||
assert entry.quantity_max == 1
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# EnemyLoader Service Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestEnemyLoader:
|
||||
"""Tests for EnemyLoader service."""
|
||||
|
||||
@pytest.fixture
|
||||
def loader(self):
|
||||
"""Create an enemy loader with the actual data directory."""
|
||||
return EnemyLoader()
|
||||
|
||||
def test_load_goblin(self, loader):
|
||||
"""Test loading the goblin enemy."""
|
||||
enemy = loader.load_enemy("goblin")
|
||||
|
||||
assert enemy is not None
|
||||
assert enemy.enemy_id == "goblin"
|
||||
assert enemy.name == "Goblin Scout"
|
||||
assert enemy.difficulty == EnemyDifficulty.EASY
|
||||
assert "humanoid" in enemy.tags
|
||||
assert "goblinoid" in enemy.tags
|
||||
|
||||
def test_load_goblin_shaman(self, loader):
|
||||
"""Test loading the goblin shaman."""
|
||||
enemy = loader.load_enemy("goblin_shaman")
|
||||
|
||||
assert enemy is not None
|
||||
assert enemy.enemy_id == "goblin_shaman"
|
||||
assert enemy.base_stats.intelligence == 12 # Caster stats
|
||||
assert "caster" in enemy.tags
|
||||
|
||||
def test_load_dire_wolf(self, loader):
|
||||
"""Test loading the dire wolf."""
|
||||
enemy = loader.load_enemy("dire_wolf")
|
||||
|
||||
assert enemy is not None
|
||||
assert enemy.difficulty == EnemyDifficulty.MEDIUM
|
||||
assert "beast" in enemy.tags
|
||||
assert enemy.base_stats.strength == 14
|
||||
|
||||
def test_load_bandit(self, loader):
|
||||
"""Test loading the bandit."""
|
||||
enemy = loader.load_enemy("bandit")
|
||||
|
||||
assert enemy is not None
|
||||
assert enemy.difficulty == EnemyDifficulty.MEDIUM
|
||||
assert "rogue" in enemy.tags
|
||||
assert enemy.crit_chance == 0.12
|
||||
|
||||
def test_load_skeleton_warrior(self, loader):
|
||||
"""Test loading the skeleton warrior."""
|
||||
enemy = loader.load_enemy("skeleton_warrior")
|
||||
|
||||
assert enemy is not None
|
||||
assert "undead" in enemy.tags
|
||||
assert "fearless" in enemy.tags
|
||||
|
||||
def test_load_orc_berserker(self, loader):
|
||||
"""Test loading the orc berserker."""
|
||||
enemy = loader.load_enemy("orc_berserker")
|
||||
|
||||
assert enemy is not None
|
||||
assert enemy.difficulty == EnemyDifficulty.HARD
|
||||
assert enemy.base_stats.strength == 18
|
||||
assert enemy.base_damage == 15
|
||||
|
||||
def test_load_nonexistent_enemy(self, loader):
|
||||
"""Test loading an enemy that doesn't exist."""
|
||||
enemy = loader.load_enemy("nonexistent_enemy_12345")
|
||||
|
||||
assert enemy is None
|
||||
|
||||
def test_load_all_enemies(self, loader):
|
||||
"""Test loading all enemies."""
|
||||
enemies = loader.load_all_enemies()
|
||||
|
||||
# Should have at least our 6 sample enemies
|
||||
assert len(enemies) >= 6
|
||||
assert "goblin" in enemies
|
||||
assert "goblin_shaman" in enemies
|
||||
assert "dire_wolf" in enemies
|
||||
assert "bandit" in enemies
|
||||
assert "skeleton_warrior" in enemies
|
||||
assert "orc_berserker" in enemies
|
||||
|
||||
def test_get_enemies_by_difficulty(self, loader):
|
||||
"""Test filtering enemies by difficulty."""
|
||||
loader.load_all_enemies() # Ensure loaded
|
||||
|
||||
easy_enemies = loader.get_enemies_by_difficulty(EnemyDifficulty.EASY)
|
||||
medium_enemies = loader.get_enemies_by_difficulty(EnemyDifficulty.MEDIUM)
|
||||
hard_enemies = loader.get_enemies_by_difficulty(EnemyDifficulty.HARD)
|
||||
|
||||
# Check we got enemies in each category
|
||||
assert len(easy_enemies) >= 2 # goblin, goblin_shaman
|
||||
assert len(medium_enemies) >= 3 # dire_wolf, bandit, skeleton_warrior
|
||||
assert len(hard_enemies) >= 1 # orc_berserker
|
||||
|
||||
# Verify difficulty is correct
|
||||
for enemy in easy_enemies:
|
||||
assert enemy.difficulty == EnemyDifficulty.EASY
|
||||
|
||||
def test_get_enemies_by_tag(self, loader):
|
||||
"""Test filtering enemies by tag."""
|
||||
loader.load_all_enemies()
|
||||
|
||||
humanoids = loader.get_enemies_by_tag("humanoid")
|
||||
undead = loader.get_enemies_by_tag("undead")
|
||||
beasts = loader.get_enemies_by_tag("beast")
|
||||
|
||||
# Verify results
|
||||
assert len(humanoids) >= 3 # goblin, goblin_shaman, bandit, orc
|
||||
assert len(undead) >= 1 # skeleton_warrior
|
||||
assert len(beasts) >= 1 # dire_wolf
|
||||
|
||||
# Verify tags
|
||||
for enemy in humanoids:
|
||||
assert enemy.has_tag("humanoid")
|
||||
|
||||
def test_get_random_enemies(self, loader):
|
||||
"""Test random enemy selection."""
|
||||
loader.load_all_enemies()
|
||||
|
||||
# Get 3 random enemies
|
||||
random_enemies = loader.get_random_enemies(count=3)
|
||||
|
||||
assert len(random_enemies) == 3
|
||||
# All should be EnemyTemplate instances
|
||||
for enemy in random_enemies:
|
||||
assert isinstance(enemy, EnemyTemplate)
|
||||
|
||||
def test_get_random_enemies_with_filters(self, loader):
|
||||
"""Test random selection with difficulty filter."""
|
||||
loader.load_all_enemies()
|
||||
|
||||
# Get only easy enemies
|
||||
easy_enemies = loader.get_random_enemies(
|
||||
count=5,
|
||||
difficulty=EnemyDifficulty.EASY,
|
||||
)
|
||||
|
||||
# All returned enemies should be easy
|
||||
for enemy in easy_enemies:
|
||||
assert enemy.difficulty == EnemyDifficulty.EASY
|
||||
|
||||
def test_cache_behavior(self, loader):
|
||||
"""Test that caching works correctly."""
|
||||
# Load an enemy twice
|
||||
enemy1 = loader.load_enemy("goblin")
|
||||
enemy2 = loader.load_enemy("goblin")
|
||||
|
||||
# Should be the same object (cached)
|
||||
assert enemy1 is enemy2
|
||||
|
||||
# Clear cache
|
||||
loader.clear_cache()
|
||||
|
||||
# Load again
|
||||
enemy3 = loader.load_enemy("goblin")
|
||||
|
||||
# Should be a new object
|
||||
assert enemy3 is not enemy1
|
||||
assert enemy3.enemy_id == enemy1.enemy_id
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# EnemyDifficulty Enum Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestEnemyDifficulty:
|
||||
"""Tests for EnemyDifficulty enum."""
|
||||
|
||||
def test_difficulty_values(self):
|
||||
"""Test difficulty enum values."""
|
||||
assert EnemyDifficulty.EASY.value == "easy"
|
||||
assert EnemyDifficulty.MEDIUM.value == "medium"
|
||||
assert EnemyDifficulty.HARD.value == "hard"
|
||||
assert EnemyDifficulty.BOSS.value == "boss"
|
||||
|
||||
def test_difficulty_from_string(self):
|
||||
"""Test creating difficulty from string."""
|
||||
assert EnemyDifficulty("easy") == EnemyDifficulty.EASY
|
||||
assert EnemyDifficulty("hard") == EnemyDifficulty.HARD
|
||||
@@ -18,8 +18,10 @@ from app.services.session_service import (
|
||||
SessionNotFound,
|
||||
SessionLimitExceeded,
|
||||
SessionValidationError,
|
||||
MAX_ACTIVE_SESSIONS,
|
||||
)
|
||||
|
||||
# Session limits are now tier-based, using a test default
|
||||
MAX_ACTIVE_SESSIONS_TEST = 3
|
||||
from app.models.session import GameSession, GameState, ConversationEntry
|
||||
from app.models.enums import SessionStatus, SessionType, LocationType
|
||||
from app.models.character import Character
|
||||
@@ -116,7 +118,7 @@ class TestSessionServiceCreation:
|
||||
def test_create_solo_session_limit_exceeded(self, mock_db, mock_appwrite, mock_character_service, sample_character):
|
||||
"""Test session creation fails when limit exceeded."""
|
||||
mock_character_service.get_character.return_value = sample_character
|
||||
mock_db.count_documents.return_value = MAX_ACTIVE_SESSIONS
|
||||
mock_db.count_documents.return_value = MAX_ACTIVE_SESSIONS_TEST
|
||||
|
||||
service = SessionService()
|
||||
with pytest.raises(SessionLimitExceeded):
|
||||
|
||||
@@ -196,3 +196,55 @@ def test_stats_repr():
|
||||
assert "INT=10" in repr_str
|
||||
assert "HP=" in repr_str
|
||||
assert "MP=" in repr_str
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# LUK Computed Properties (Combat System Integration)
|
||||
# =============================================================================
|
||||
|
||||
def test_crit_bonus_calculation():
|
||||
"""Test crit bonus calculation: luck * 0.5%."""
|
||||
stats = Stats(luck=8)
|
||||
assert stats.crit_bonus == pytest.approx(0.04, abs=0.001) # 4%
|
||||
|
||||
stats = Stats(luck=12)
|
||||
assert stats.crit_bonus == pytest.approx(0.06, abs=0.001) # 6%
|
||||
|
||||
stats = Stats(luck=0)
|
||||
assert stats.crit_bonus == pytest.approx(0.0, abs=0.001) # 0%
|
||||
|
||||
|
||||
def test_hit_bonus_calculation():
|
||||
"""Test hit bonus (miss reduction): luck * 0.5%."""
|
||||
stats = Stats(luck=8)
|
||||
assert stats.hit_bonus == pytest.approx(0.04, abs=0.001) # 4%
|
||||
|
||||
stats = Stats(luck=12)
|
||||
assert stats.hit_bonus == pytest.approx(0.06, abs=0.001) # 6%
|
||||
|
||||
stats = Stats(luck=20)
|
||||
assert stats.hit_bonus == pytest.approx(0.10, abs=0.001) # 10%
|
||||
|
||||
|
||||
def test_lucky_roll_chance_calculation():
|
||||
"""Test lucky roll chance: 5% + (luck * 0.25%)."""
|
||||
stats = Stats(luck=8)
|
||||
# 5% + (8 * 0.25%) = 5% + 2% = 7%
|
||||
assert stats.lucky_roll_chance == pytest.approx(0.07, abs=0.001)
|
||||
|
||||
stats = Stats(luck=12)
|
||||
# 5% + (12 * 0.25%) = 5% + 3% = 8%
|
||||
assert stats.lucky_roll_chance == pytest.approx(0.08, abs=0.001)
|
||||
|
||||
stats = Stats(luck=0)
|
||||
# 5% + (0 * 0.25%) = 5%
|
||||
assert stats.lucky_roll_chance == pytest.approx(0.05, abs=0.001)
|
||||
|
||||
|
||||
def test_repr_includes_combat_bonuses():
|
||||
"""Test that repr includes LUK-based combat bonuses."""
|
||||
stats = Stats(luck=10)
|
||||
repr_str = repr(stats)
|
||||
|
||||
assert "CRIT_BONUS=" in repr_str
|
||||
assert "HIT_BONUS=" in repr_str
|
||||
|
||||
Reference in New Issue
Block a user