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:
2025-11-26 15:43:20 -06:00
parent 30c3b800e6
commit 03ab783eeb
22 changed files with 9091 additions and 5 deletions

View 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]

View 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

View 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

View 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

View File

@@ -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):

View File

@@ -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