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:
@@ -169,8 +169,12 @@ def register_blueprints(app: Flask) -> None:
|
||||
app.register_blueprint(chat_bp)
|
||||
logger.info("Chat API blueprint registered")
|
||||
|
||||
# Import and register Combat API blueprint
|
||||
from app.api.combat import combat_bp
|
||||
app.register_blueprint(combat_bp)
|
||||
logger.info("Combat API blueprint registered")
|
||||
|
||||
# TODO: Register additional blueprints as they are created
|
||||
# from app.api import combat, marketplace, shop
|
||||
# app.register_blueprint(combat.bp, url_prefix='/api/v1/combat')
|
||||
# from app.api import marketplace, shop
|
||||
# app.register_blueprint(marketplace.bp, url_prefix='/api/v1/marketplace')
|
||||
# app.register_blueprint(shop.bp, url_prefix='/api/v1/shop')
|
||||
|
||||
729
api/app/api/combat.py
Normal file
729
api/app/api/combat.py
Normal file
@@ -0,0 +1,729 @@
|
||||
"""
|
||||
Combat API Blueprint
|
||||
|
||||
This module provides API endpoints for turn-based combat:
|
||||
- Starting combat encounters
|
||||
- Executing combat actions (attack, ability, defend, flee)
|
||||
- Getting combat state
|
||||
- Processing enemy turns
|
||||
"""
|
||||
|
||||
from flask import Blueprint, request
|
||||
|
||||
from app.services.combat_service import (
|
||||
get_combat_service,
|
||||
CombatAction,
|
||||
CombatError,
|
||||
NotInCombatError,
|
||||
AlreadyInCombatError,
|
||||
InvalidActionError,
|
||||
InsufficientResourceError,
|
||||
)
|
||||
from app.models.enums import CombatStatus
|
||||
from app.utils.response import (
|
||||
success_response,
|
||||
error_response,
|
||||
not_found_response,
|
||||
validation_error_response
|
||||
)
|
||||
from app.utils.auth import require_auth, get_current_user
|
||||
from app.utils.logging import get_logger
|
||||
|
||||
|
||||
# Initialize logger
|
||||
logger = get_logger(__file__)
|
||||
|
||||
# Create blueprint
|
||||
combat_bp = Blueprint('combat', __name__, url_prefix='/api/v1/combat')
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Combat Lifecycle Endpoints
|
||||
# =============================================================================
|
||||
|
||||
@combat_bp.route('/start', methods=['POST'])
|
||||
@require_auth
|
||||
def start_combat():
|
||||
"""
|
||||
Start a new combat encounter.
|
||||
|
||||
Creates a combat encounter with the session's character(s) vs specified enemies.
|
||||
Rolls initiative and sets up turn order.
|
||||
|
||||
Request JSON:
|
||||
{
|
||||
"session_id": "sess_123",
|
||||
"enemy_ids": ["goblin", "goblin", "goblin_shaman"]
|
||||
}
|
||||
|
||||
Returns:
|
||||
{
|
||||
"encounter_id": "enc_abc123",
|
||||
"combatants": [...],
|
||||
"turn_order": [...],
|
||||
"current_turn": "char_456",
|
||||
"round_number": 1,
|
||||
"status": "active"
|
||||
}
|
||||
|
||||
Errors:
|
||||
400: Missing required fields
|
||||
400: Already in combat
|
||||
404: Enemy template not found
|
||||
"""
|
||||
user = get_current_user()
|
||||
data = request.get_json()
|
||||
|
||||
if not data:
|
||||
return validation_error_response(
|
||||
message="Request body is required",
|
||||
details={"field": "body", "issue": "Missing JSON body"}
|
||||
)
|
||||
|
||||
# Validate required fields
|
||||
session_id = data.get("session_id")
|
||||
enemy_ids = data.get("enemy_ids", [])
|
||||
|
||||
if not session_id:
|
||||
return validation_error_response(
|
||||
message="session_id is required",
|
||||
details={"field": "session_id", "issue": "Missing required field"}
|
||||
)
|
||||
|
||||
if not enemy_ids:
|
||||
return validation_error_response(
|
||||
message="enemy_ids is required and must not be empty",
|
||||
details={"field": "enemy_ids", "issue": "Missing or empty list"}
|
||||
)
|
||||
|
||||
try:
|
||||
combat_service = get_combat_service()
|
||||
encounter = combat_service.start_combat(
|
||||
session_id=session_id,
|
||||
user_id=user["user_id"],
|
||||
enemy_ids=enemy_ids,
|
||||
)
|
||||
|
||||
# Format response
|
||||
current = encounter.get_current_combatant()
|
||||
response_data = {
|
||||
"encounter_id": encounter.encounter_id,
|
||||
"combatants": [
|
||||
{
|
||||
"combatant_id": c.combatant_id,
|
||||
"name": c.name,
|
||||
"is_player": c.is_player,
|
||||
"current_hp": c.current_hp,
|
||||
"max_hp": c.max_hp,
|
||||
"current_mp": c.current_mp,
|
||||
"max_mp": c.max_mp,
|
||||
"initiative": c.initiative,
|
||||
"abilities": c.abilities,
|
||||
}
|
||||
for c in encounter.combatants
|
||||
],
|
||||
"turn_order": encounter.turn_order,
|
||||
"current_turn": current.combatant_id if current else None,
|
||||
"round_number": encounter.round_number,
|
||||
"status": encounter.status.value,
|
||||
}
|
||||
|
||||
logger.info("Combat started via API",
|
||||
session_id=session_id,
|
||||
encounter_id=encounter.encounter_id,
|
||||
enemy_count=len(enemy_ids))
|
||||
|
||||
return success_response(response_data)
|
||||
|
||||
except AlreadyInCombatError as e:
|
||||
logger.warning("Attempt to start combat while already in combat",
|
||||
session_id=session_id)
|
||||
return error_response(
|
||||
status_code=400,
|
||||
message=str(e),
|
||||
error_code="ALREADY_IN_COMBAT"
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.warning("Invalid enemy ID",
|
||||
session_id=session_id,
|
||||
error=str(e))
|
||||
return not_found_response(message=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to start combat",
|
||||
session_id=session_id,
|
||||
error=str(e),
|
||||
exc_info=True)
|
||||
return error_response(
|
||||
status_code=500,
|
||||
message="Failed to start combat",
|
||||
error_code="COMBAT_START_ERROR"
|
||||
)
|
||||
|
||||
|
||||
@combat_bp.route('/<session_id>/state', methods=['GET'])
|
||||
@require_auth
|
||||
def get_combat_state(session_id: str):
|
||||
"""
|
||||
Get current combat state for a session.
|
||||
|
||||
Returns the full combat encounter state including all combatants,
|
||||
turn order, combat log, and current status.
|
||||
|
||||
Path Parameters:
|
||||
session_id: Game session ID
|
||||
|
||||
Returns:
|
||||
{
|
||||
"in_combat": true,
|
||||
"encounter": {
|
||||
"encounter_id": "...",
|
||||
"combatants": [...],
|
||||
"turn_order": [...],
|
||||
"current_turn": "...",
|
||||
"round_number": 1,
|
||||
"status": "active",
|
||||
"combat_log": [...]
|
||||
}
|
||||
}
|
||||
|
||||
or if not in combat:
|
||||
{
|
||||
"in_combat": false,
|
||||
"encounter": null
|
||||
}
|
||||
"""
|
||||
user = get_current_user()
|
||||
|
||||
try:
|
||||
combat_service = get_combat_service()
|
||||
encounter = combat_service.get_combat_state(session_id, user["user_id"])
|
||||
|
||||
if not encounter:
|
||||
return success_response({
|
||||
"in_combat": False,
|
||||
"encounter": None
|
||||
})
|
||||
|
||||
current = encounter.get_current_combatant()
|
||||
response_data = {
|
||||
"in_combat": True,
|
||||
"encounter": {
|
||||
"encounter_id": encounter.encounter_id,
|
||||
"combatants": [
|
||||
{
|
||||
"combatant_id": c.combatant_id,
|
||||
"name": c.name,
|
||||
"is_player": c.is_player,
|
||||
"current_hp": c.current_hp,
|
||||
"max_hp": c.max_hp,
|
||||
"current_mp": c.current_mp,
|
||||
"max_mp": c.max_mp,
|
||||
"is_alive": c.is_alive(),
|
||||
"is_stunned": c.is_stunned(),
|
||||
"active_effects": [
|
||||
{"name": e.name, "duration": e.duration}
|
||||
for e in c.active_effects
|
||||
],
|
||||
"abilities": c.abilities,
|
||||
"cooldowns": c.cooldowns,
|
||||
}
|
||||
for c in encounter.combatants
|
||||
],
|
||||
"turn_order": encounter.turn_order,
|
||||
"current_turn": current.combatant_id if current else None,
|
||||
"round_number": encounter.round_number,
|
||||
"status": encounter.status.value,
|
||||
"combat_log": encounter.combat_log[-10:], # Last 10 entries
|
||||
}
|
||||
}
|
||||
|
||||
return success_response(response_data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get combat state",
|
||||
session_id=session_id,
|
||||
error=str(e),
|
||||
exc_info=True)
|
||||
return error_response(
|
||||
status_code=500,
|
||||
message="Failed to get combat state",
|
||||
error_code="COMBAT_STATE_ERROR"
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Action Execution Endpoints
|
||||
# =============================================================================
|
||||
|
||||
@combat_bp.route('/<session_id>/action', methods=['POST'])
|
||||
@require_auth
|
||||
def execute_action(session_id: str):
|
||||
"""
|
||||
Execute a combat action for a combatant.
|
||||
|
||||
Processes the specified action (attack, ability, defend, flee, item)
|
||||
for the given combatant. Must be that combatant's turn.
|
||||
|
||||
Path Parameters:
|
||||
session_id: Game session ID
|
||||
|
||||
Request JSON:
|
||||
{
|
||||
"combatant_id": "char_456",
|
||||
"action_type": "attack" | "ability" | "defend" | "flee" | "item",
|
||||
"target_ids": ["enemy_1"], // Optional, auto-targets if omitted
|
||||
"ability_id": "fireball", // Required for ability actions
|
||||
"item_id": "health_potion" // Required for item actions
|
||||
}
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": true,
|
||||
"message": "Attack hits for 15 damage!",
|
||||
"damage_results": [...],
|
||||
"effects_applied": [...],
|
||||
"combat_ended": false,
|
||||
"combat_status": null,
|
||||
"next_combatant_id": "goblin_0"
|
||||
}
|
||||
|
||||
Errors:
|
||||
400: Missing required fields
|
||||
400: Not this combatant's turn
|
||||
400: Invalid action
|
||||
404: Session not in combat
|
||||
"""
|
||||
user = get_current_user()
|
||||
data = request.get_json()
|
||||
|
||||
if not data:
|
||||
return validation_error_response(
|
||||
message="Request body is required",
|
||||
details={"field": "body", "issue": "Missing JSON body"}
|
||||
)
|
||||
|
||||
# Validate required fields
|
||||
combatant_id = data.get("combatant_id")
|
||||
action_type = data.get("action_type")
|
||||
|
||||
if not combatant_id:
|
||||
return validation_error_response(
|
||||
message="combatant_id is required",
|
||||
details={"field": "combatant_id", "issue": "Missing required field"}
|
||||
)
|
||||
|
||||
if not action_type:
|
||||
return validation_error_response(
|
||||
message="action_type is required",
|
||||
details={"field": "action_type", "issue": "Missing required field"}
|
||||
)
|
||||
|
||||
valid_actions = ["attack", "ability", "defend", "flee", "item"]
|
||||
if action_type not in valid_actions:
|
||||
return validation_error_response(
|
||||
message=f"Invalid action_type. Must be one of: {valid_actions}",
|
||||
details={"field": "action_type", "issue": "Invalid value"}
|
||||
)
|
||||
|
||||
# Validate ability_id for ability actions
|
||||
if action_type == "ability" and not data.get("ability_id"):
|
||||
return validation_error_response(
|
||||
message="ability_id is required for ability actions",
|
||||
details={"field": "ability_id", "issue": "Missing required field"}
|
||||
)
|
||||
|
||||
try:
|
||||
combat_service = get_combat_service()
|
||||
|
||||
action = CombatAction(
|
||||
action_type=action_type,
|
||||
target_ids=data.get("target_ids", []),
|
||||
ability_id=data.get("ability_id"),
|
||||
item_id=data.get("item_id"),
|
||||
)
|
||||
|
||||
result = combat_service.execute_action(
|
||||
session_id=session_id,
|
||||
user_id=user["user_id"],
|
||||
combatant_id=combatant_id,
|
||||
action=action,
|
||||
)
|
||||
|
||||
logger.info("Combat action executed",
|
||||
session_id=session_id,
|
||||
combatant_id=combatant_id,
|
||||
action_type=action_type,
|
||||
success=result.success)
|
||||
|
||||
return success_response(result.to_dict())
|
||||
|
||||
except NotInCombatError as e:
|
||||
logger.warning("Action attempted while not in combat",
|
||||
session_id=session_id)
|
||||
return not_found_response(message="Session is not in combat")
|
||||
except InvalidActionError as e:
|
||||
logger.warning("Invalid combat action",
|
||||
session_id=session_id,
|
||||
combatant_id=combatant_id,
|
||||
error=str(e))
|
||||
return error_response(
|
||||
status_code=400,
|
||||
message=str(e),
|
||||
error_code="INVALID_ACTION"
|
||||
)
|
||||
except InsufficientResourceError as e:
|
||||
logger.warning("Insufficient resources for action",
|
||||
session_id=session_id,
|
||||
combatant_id=combatant_id,
|
||||
error=str(e))
|
||||
return error_response(
|
||||
status_code=400,
|
||||
message=str(e),
|
||||
error_code="INSUFFICIENT_RESOURCES"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Failed to execute combat action",
|
||||
session_id=session_id,
|
||||
combatant_id=combatant_id,
|
||||
action_type=action_type,
|
||||
error=str(e),
|
||||
exc_info=True)
|
||||
return error_response(
|
||||
status_code=500,
|
||||
message="Failed to execute action",
|
||||
error_code="ACTION_EXECUTION_ERROR"
|
||||
)
|
||||
|
||||
|
||||
@combat_bp.route('/<session_id>/enemy-turn', methods=['POST'])
|
||||
@require_auth
|
||||
def execute_enemy_turn(session_id: str):
|
||||
"""
|
||||
Execute the current enemy's turn using AI logic.
|
||||
|
||||
Called when it's an enemy combatant's turn. The enemy AI will
|
||||
automatically choose and execute an appropriate action.
|
||||
|
||||
Path Parameters:
|
||||
session_id: Game session ID
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": true,
|
||||
"message": "Goblin attacks Hero for 8 damage!",
|
||||
"damage_results": [...],
|
||||
"effects_applied": [...],
|
||||
"combat_ended": false,
|
||||
"combat_status": null,
|
||||
"next_combatant_id": "char_456"
|
||||
}
|
||||
|
||||
Errors:
|
||||
400: Current combatant is not an enemy
|
||||
404: Session not in combat
|
||||
"""
|
||||
user = get_current_user()
|
||||
|
||||
try:
|
||||
combat_service = get_combat_service()
|
||||
result = combat_service.execute_enemy_turn(
|
||||
session_id=session_id,
|
||||
user_id=user["user_id"],
|
||||
)
|
||||
|
||||
logger.info("Enemy turn executed",
|
||||
session_id=session_id,
|
||||
success=result.success)
|
||||
|
||||
return success_response(result.to_dict())
|
||||
|
||||
except NotInCombatError as e:
|
||||
return not_found_response(message="Session is not in combat")
|
||||
except InvalidActionError as e:
|
||||
return error_response(
|
||||
status_code=400,
|
||||
message=str(e),
|
||||
error_code="INVALID_ACTION"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Failed to execute enemy turn",
|
||||
session_id=session_id,
|
||||
error=str(e),
|
||||
exc_info=True)
|
||||
return error_response(
|
||||
status_code=500,
|
||||
message="Failed to execute enemy turn",
|
||||
error_code="ENEMY_TURN_ERROR"
|
||||
)
|
||||
|
||||
|
||||
@combat_bp.route('/<session_id>/flee', methods=['POST'])
|
||||
@require_auth
|
||||
def attempt_flee(session_id: str):
|
||||
"""
|
||||
Attempt to flee from combat.
|
||||
|
||||
The current combatant attempts to flee. Success is based on
|
||||
DEX comparison with enemies. Failed flee attempts consume the turn.
|
||||
|
||||
Path Parameters:
|
||||
session_id: Game session ID
|
||||
|
||||
Request JSON:
|
||||
{
|
||||
"combatant_id": "char_456"
|
||||
}
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": true,
|
||||
"message": "Successfully fled from combat!",
|
||||
"combat_ended": true,
|
||||
"combat_status": "fled"
|
||||
}
|
||||
|
||||
or on failure:
|
||||
{
|
||||
"success": false,
|
||||
"message": "Failed to flee! (Roll: 0.35, Needed: 0.50)",
|
||||
"combat_ended": false
|
||||
}
|
||||
"""
|
||||
user = get_current_user()
|
||||
data = request.get_json() or {}
|
||||
|
||||
combatant_id = data.get("combatant_id")
|
||||
|
||||
try:
|
||||
combat_service = get_combat_service()
|
||||
|
||||
action = CombatAction(
|
||||
action_type="flee",
|
||||
target_ids=[],
|
||||
)
|
||||
|
||||
result = combat_service.execute_action(
|
||||
session_id=session_id,
|
||||
user_id=user["user_id"],
|
||||
combatant_id=combatant_id,
|
||||
action=action,
|
||||
)
|
||||
|
||||
return success_response(result.to_dict())
|
||||
|
||||
except NotInCombatError:
|
||||
return not_found_response(message="Session is not in combat")
|
||||
except InvalidActionError as e:
|
||||
return error_response(
|
||||
status_code=400,
|
||||
message=str(e),
|
||||
error_code="INVALID_ACTION"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Failed flee attempt",
|
||||
session_id=session_id,
|
||||
error=str(e),
|
||||
exc_info=True)
|
||||
return error_response(
|
||||
status_code=500,
|
||||
message="Failed to attempt flee",
|
||||
error_code="FLEE_ERROR"
|
||||
)
|
||||
|
||||
|
||||
@combat_bp.route('/<session_id>/end', methods=['POST'])
|
||||
@require_auth
|
||||
def end_combat(session_id: str):
|
||||
"""
|
||||
Force end the current combat (debug/admin endpoint).
|
||||
|
||||
Ends combat with the specified outcome. Should normally only be used
|
||||
for debugging or admin purposes - combat usually ends automatically.
|
||||
|
||||
Path Parameters:
|
||||
session_id: Game session ID
|
||||
|
||||
Request JSON:
|
||||
{
|
||||
"outcome": "victory" | "defeat" | "fled"
|
||||
}
|
||||
|
||||
Returns:
|
||||
{
|
||||
"outcome": "victory",
|
||||
"rewards": {
|
||||
"experience": 100,
|
||||
"gold": 50,
|
||||
"items": [...],
|
||||
"level_ups": []
|
||||
}
|
||||
}
|
||||
"""
|
||||
user = get_current_user()
|
||||
data = request.get_json() or {}
|
||||
|
||||
outcome_str = data.get("outcome", "fled")
|
||||
|
||||
# Parse outcome
|
||||
try:
|
||||
outcome = CombatStatus(outcome_str)
|
||||
except ValueError:
|
||||
return validation_error_response(
|
||||
message="Invalid outcome. Must be: victory, defeat, or fled",
|
||||
details={"field": "outcome", "issue": "Invalid value"}
|
||||
)
|
||||
|
||||
try:
|
||||
combat_service = get_combat_service()
|
||||
rewards = combat_service.end_combat(
|
||||
session_id=session_id,
|
||||
user_id=user["user_id"],
|
||||
outcome=outcome,
|
||||
)
|
||||
|
||||
logger.info("Combat force-ended",
|
||||
session_id=session_id,
|
||||
outcome=outcome_str)
|
||||
|
||||
return success_response({
|
||||
"outcome": outcome_str,
|
||||
"rewards": rewards.to_dict() if outcome == CombatStatus.VICTORY else None,
|
||||
})
|
||||
|
||||
except NotInCombatError:
|
||||
return not_found_response(message="Session is not in combat")
|
||||
except Exception as e:
|
||||
logger.error("Failed to end combat",
|
||||
session_id=session_id,
|
||||
error=str(e),
|
||||
exc_info=True)
|
||||
return error_response(
|
||||
status_code=500,
|
||||
message="Failed to end combat",
|
||||
error_code="COMBAT_END_ERROR"
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Utility Endpoints
|
||||
# =============================================================================
|
||||
|
||||
@combat_bp.route('/enemies', methods=['GET'])
|
||||
def list_enemies():
|
||||
"""
|
||||
List all available enemy templates.
|
||||
|
||||
Returns a list of all enemy templates that can be used in combat.
|
||||
Useful for encounter building and testing.
|
||||
|
||||
Query Parameters:
|
||||
difficulty: Filter by difficulty (easy, medium, hard, boss)
|
||||
tag: Filter by tag (undead, beast, humanoid, etc.)
|
||||
|
||||
Returns:
|
||||
{
|
||||
"enemies": [
|
||||
{
|
||||
"enemy_id": "goblin",
|
||||
"name": "Goblin Scout",
|
||||
"difficulty": "easy",
|
||||
"tags": ["humanoid", "goblinoid"],
|
||||
"experience_reward": 15
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
"""
|
||||
from app.services.enemy_loader import get_enemy_loader
|
||||
from app.models.enemy import EnemyDifficulty
|
||||
|
||||
difficulty = request.args.get("difficulty")
|
||||
tag = request.args.get("tag")
|
||||
|
||||
try:
|
||||
enemy_loader = get_enemy_loader()
|
||||
enemy_loader.load_all_enemies()
|
||||
|
||||
if difficulty:
|
||||
try:
|
||||
diff = EnemyDifficulty(difficulty)
|
||||
enemies = enemy_loader.get_enemies_by_difficulty(diff)
|
||||
except ValueError:
|
||||
return validation_error_response(
|
||||
message="Invalid difficulty",
|
||||
details={"field": "difficulty", "issue": "Must be: easy, medium, hard, boss"}
|
||||
)
|
||||
elif tag:
|
||||
enemies = enemy_loader.get_enemies_by_tag(tag)
|
||||
else:
|
||||
enemies = list(enemy_loader.get_all_cached().values())
|
||||
|
||||
response_data = {
|
||||
"enemies": [
|
||||
{
|
||||
"enemy_id": e.enemy_id,
|
||||
"name": e.name,
|
||||
"description": e.description[:100] + "..." if len(e.description) > 100 else e.description,
|
||||
"difficulty": e.difficulty.value,
|
||||
"tags": e.tags,
|
||||
"experience_reward": e.experience_reward,
|
||||
"gold_reward_range": [e.gold_reward_min, e.gold_reward_max],
|
||||
}
|
||||
for e in enemies
|
||||
]
|
||||
}
|
||||
|
||||
return success_response(response_data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to list enemies",
|
||||
error=str(e),
|
||||
exc_info=True)
|
||||
return error_response(
|
||||
status_code=500,
|
||||
message="Failed to list enemies",
|
||||
error_code="ENEMY_LIST_ERROR"
|
||||
)
|
||||
|
||||
|
||||
@combat_bp.route('/enemies/<enemy_id>', methods=['GET'])
|
||||
def get_enemy_details(enemy_id: str):
|
||||
"""
|
||||
Get detailed information about a specific enemy template.
|
||||
|
||||
Path Parameters:
|
||||
enemy_id: Enemy template ID
|
||||
|
||||
Returns:
|
||||
{
|
||||
"enemy_id": "goblin",
|
||||
"name": "Goblin Scout",
|
||||
"description": "...",
|
||||
"base_stats": {...},
|
||||
"abilities": [...],
|
||||
"loot_table": [...],
|
||||
"difficulty": "easy",
|
||||
...
|
||||
}
|
||||
"""
|
||||
from app.services.enemy_loader import get_enemy_loader
|
||||
|
||||
try:
|
||||
enemy_loader = get_enemy_loader()
|
||||
enemy = enemy_loader.load_enemy(enemy_id)
|
||||
|
||||
if not enemy:
|
||||
return not_found_response(message=f"Enemy not found: {enemy_id}")
|
||||
|
||||
return success_response(enemy.to_dict())
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get enemy details",
|
||||
enemy_id=enemy_id,
|
||||
error=str(e),
|
||||
exc_info=True)
|
||||
return error_response(
|
||||
status_code=500,
|
||||
message="Failed to get enemy details",
|
||||
error_code="ENEMY_DETAILS_ERROR"
|
||||
)
|
||||
55
api/app/data/enemies/bandit.yaml
Normal file
55
api/app/data/enemies/bandit.yaml
Normal file
@@ -0,0 +1,55 @@
|
||||
# Bandit - Medium humanoid with weapon
|
||||
# A highway robber armed with sword and dagger
|
||||
|
||||
enemy_id: bandit
|
||||
name: Bandit Rogue
|
||||
description: >
|
||||
A rough-looking human in worn leather armor, their face partially hidden
|
||||
by a tattered hood. They fight with a chipped sword and keep a dagger
|
||||
ready for backstabs. Desperation has made them dangerous.
|
||||
|
||||
base_stats:
|
||||
strength: 12
|
||||
dexterity: 14
|
||||
constitution: 10
|
||||
intelligence: 10
|
||||
wisdom: 8
|
||||
charisma: 8
|
||||
luck: 10
|
||||
|
||||
abilities:
|
||||
- basic_attack
|
||||
- quick_strike
|
||||
- dirty_trick
|
||||
|
||||
loot_table:
|
||||
- item_id: bandit_sword
|
||||
drop_chance: 0.20
|
||||
quantity_min: 1
|
||||
quantity_max: 1
|
||||
- item_id: leather_armor
|
||||
drop_chance: 0.15
|
||||
quantity_min: 1
|
||||
quantity_max: 1
|
||||
- item_id: lockpick
|
||||
drop_chance: 0.25
|
||||
quantity_min: 1
|
||||
quantity_max: 3
|
||||
- item_id: gold_coin
|
||||
drop_chance: 0.80
|
||||
quantity_min: 5
|
||||
quantity_max: 20
|
||||
|
||||
experience_reward: 35
|
||||
gold_reward_min: 10
|
||||
gold_reward_max: 30
|
||||
difficulty: medium
|
||||
|
||||
tags:
|
||||
- humanoid
|
||||
- rogue
|
||||
- armed
|
||||
|
||||
base_damage: 8
|
||||
crit_chance: 0.12
|
||||
flee_chance: 0.45
|
||||
52
api/app/data/enemies/dire_wolf.yaml
Normal file
52
api/app/data/enemies/dire_wolf.yaml
Normal file
@@ -0,0 +1,52 @@
|
||||
# Dire Wolf - Medium beast enemy
|
||||
# A large, ferocious predator
|
||||
|
||||
enemy_id: dire_wolf
|
||||
name: Dire Wolf
|
||||
description: >
|
||||
A massive wolf the size of a horse, with matted black fur and eyes
|
||||
that glow with predatory intelligence. Its fangs are as long as daggers,
|
||||
and its growl rumbles like distant thunder.
|
||||
|
||||
base_stats:
|
||||
strength: 14
|
||||
dexterity: 14
|
||||
constitution: 12
|
||||
intelligence: 4
|
||||
wisdom: 10
|
||||
charisma: 6
|
||||
luck: 8
|
||||
|
||||
abilities:
|
||||
- basic_attack
|
||||
- savage_bite
|
||||
- pack_howl
|
||||
|
||||
loot_table:
|
||||
- item_id: wolf_pelt
|
||||
drop_chance: 0.60
|
||||
quantity_min: 1
|
||||
quantity_max: 1
|
||||
- item_id: wolf_fang
|
||||
drop_chance: 0.40
|
||||
quantity_min: 1
|
||||
quantity_max: 2
|
||||
- item_id: beast_meat
|
||||
drop_chance: 0.70
|
||||
quantity_min: 1
|
||||
quantity_max: 3
|
||||
|
||||
experience_reward: 40
|
||||
gold_reward_min: 0
|
||||
gold_reward_max: 5
|
||||
difficulty: medium
|
||||
|
||||
tags:
|
||||
- beast
|
||||
- wolf
|
||||
- large
|
||||
- pack
|
||||
|
||||
base_damage: 10
|
||||
crit_chance: 0.10
|
||||
flee_chance: 0.40
|
||||
45
api/app/data/enemies/goblin.yaml
Normal file
45
api/app/data/enemies/goblin.yaml
Normal file
@@ -0,0 +1,45 @@
|
||||
# Goblin - Easy melee enemy (STR-focused)
|
||||
# A small, cunning creature that attacks in groups
|
||||
|
||||
enemy_id: goblin
|
||||
name: Goblin Scout
|
||||
description: >
|
||||
A small, green-skinned creature with pointed ears and sharp teeth.
|
||||
Goblins are cowardly alone but dangerous in groups, using crude weapons
|
||||
and dirty tactics to overwhelm their prey.
|
||||
|
||||
base_stats:
|
||||
strength: 8
|
||||
dexterity: 12
|
||||
constitution: 6
|
||||
intelligence: 6
|
||||
wisdom: 6
|
||||
charisma: 4
|
||||
luck: 8
|
||||
|
||||
abilities:
|
||||
- basic_attack
|
||||
|
||||
loot_table:
|
||||
- item_id: rusty_dagger
|
||||
drop_chance: 0.15
|
||||
quantity_min: 1
|
||||
quantity_max: 1
|
||||
- item_id: gold_coin
|
||||
drop_chance: 0.50
|
||||
quantity_min: 1
|
||||
quantity_max: 3
|
||||
|
||||
experience_reward: 15
|
||||
gold_reward_min: 2
|
||||
gold_reward_max: 8
|
||||
difficulty: easy
|
||||
|
||||
tags:
|
||||
- humanoid
|
||||
- goblinoid
|
||||
- small
|
||||
|
||||
base_damage: 4
|
||||
crit_chance: 0.05
|
||||
flee_chance: 0.60
|
||||
52
api/app/data/enemies/goblin_shaman.yaml
Normal file
52
api/app/data/enemies/goblin_shaman.yaml
Normal file
@@ -0,0 +1,52 @@
|
||||
# Goblin Shaman - Easy caster enemy (INT-focused)
|
||||
# A goblin spellcaster that provides magical support
|
||||
|
||||
enemy_id: goblin_shaman
|
||||
name: Goblin Shaman
|
||||
description: >
|
||||
A hunched goblin wrapped in tattered robes, clutching a staff adorned
|
||||
with bones and feathers. It mutters dark incantations and hurls bolts
|
||||
of sickly green fire at its enemies.
|
||||
|
||||
base_stats:
|
||||
strength: 4
|
||||
dexterity: 10
|
||||
constitution: 6
|
||||
intelligence: 12
|
||||
wisdom: 10
|
||||
charisma: 6
|
||||
luck: 10
|
||||
|
||||
abilities:
|
||||
- basic_attack
|
||||
- fire_bolt
|
||||
- minor_heal
|
||||
|
||||
loot_table:
|
||||
- item_id: shaman_staff
|
||||
drop_chance: 0.10
|
||||
quantity_min: 1
|
||||
quantity_max: 1
|
||||
- item_id: mana_potion_small
|
||||
drop_chance: 0.20
|
||||
quantity_min: 1
|
||||
quantity_max: 1
|
||||
- item_id: gold_coin
|
||||
drop_chance: 0.60
|
||||
quantity_min: 3
|
||||
quantity_max: 8
|
||||
|
||||
experience_reward: 25
|
||||
gold_reward_min: 5
|
||||
gold_reward_max: 15
|
||||
difficulty: easy
|
||||
|
||||
tags:
|
||||
- humanoid
|
||||
- goblinoid
|
||||
- caster
|
||||
- small
|
||||
|
||||
base_damage: 3
|
||||
crit_chance: 0.08
|
||||
flee_chance: 0.55
|
||||
58
api/app/data/enemies/orc_berserker.yaml
Normal file
58
api/app/data/enemies/orc_berserker.yaml
Normal file
@@ -0,0 +1,58 @@
|
||||
# Orc Berserker - Hard heavy hitter
|
||||
# A fearsome orc warrior in a battle rage
|
||||
|
||||
enemy_id: orc_berserker
|
||||
name: Orc Berserker
|
||||
description: >
|
||||
A towering mass of green muscle and fury, covered in tribal war paint
|
||||
and scars from countless battles. Foam flecks at the corners of its
|
||||
mouth as it swings a massive greataxe with terrifying speed. In its
|
||||
battle rage, it feels no pain and shows no mercy.
|
||||
|
||||
base_stats:
|
||||
strength: 18
|
||||
dexterity: 10
|
||||
constitution: 16
|
||||
intelligence: 6
|
||||
wisdom: 6
|
||||
charisma: 4
|
||||
luck: 8
|
||||
|
||||
abilities:
|
||||
- basic_attack
|
||||
- cleave
|
||||
- berserker_rage
|
||||
- intimidating_shout
|
||||
|
||||
loot_table:
|
||||
- item_id: orc_greataxe
|
||||
drop_chance: 0.20
|
||||
quantity_min: 1
|
||||
quantity_max: 1
|
||||
- item_id: orc_war_paint
|
||||
drop_chance: 0.35
|
||||
quantity_min: 1
|
||||
quantity_max: 2
|
||||
- item_id: beast_hide_armor
|
||||
drop_chance: 0.15
|
||||
quantity_min: 1
|
||||
quantity_max: 1
|
||||
- item_id: gold_coin
|
||||
drop_chance: 0.70
|
||||
quantity_min: 15
|
||||
quantity_max: 40
|
||||
|
||||
experience_reward: 80
|
||||
gold_reward_min: 20
|
||||
gold_reward_max: 50
|
||||
difficulty: hard
|
||||
|
||||
tags:
|
||||
- humanoid
|
||||
- orc
|
||||
- berserker
|
||||
- large
|
||||
|
||||
base_damage: 15
|
||||
crit_chance: 0.15
|
||||
flee_chance: 0.30
|
||||
52
api/app/data/enemies/skeleton_warrior.yaml
Normal file
52
api/app/data/enemies/skeleton_warrior.yaml
Normal file
@@ -0,0 +1,52 @@
|
||||
# Skeleton Warrior - Medium undead melee
|
||||
# An animated skeleton wielding ancient weapons
|
||||
|
||||
enemy_id: skeleton_warrior
|
||||
name: Skeleton Warrior
|
||||
description: >
|
||||
The animated remains of a long-dead soldier, held together by dark magic.
|
||||
Its empty eye sockets glow with pale blue fire, and it wields a rusted
|
||||
but deadly sword with unnatural precision. It knows no fear and feels no pain.
|
||||
|
||||
base_stats:
|
||||
strength: 12
|
||||
dexterity: 10
|
||||
constitution: 10
|
||||
intelligence: 4
|
||||
wisdom: 6
|
||||
charisma: 2
|
||||
luck: 6
|
||||
|
||||
abilities:
|
||||
- basic_attack
|
||||
- shield_bash
|
||||
- bone_rattle
|
||||
|
||||
loot_table:
|
||||
- item_id: ancient_sword
|
||||
drop_chance: 0.15
|
||||
quantity_min: 1
|
||||
quantity_max: 1
|
||||
- item_id: bone_fragment
|
||||
drop_chance: 0.80
|
||||
quantity_min: 2
|
||||
quantity_max: 5
|
||||
- item_id: soul_essence
|
||||
drop_chance: 0.10
|
||||
quantity_min: 1
|
||||
quantity_max: 1
|
||||
|
||||
experience_reward: 45
|
||||
gold_reward_min: 0
|
||||
gold_reward_max: 10
|
||||
difficulty: medium
|
||||
|
||||
tags:
|
||||
- undead
|
||||
- skeleton
|
||||
- armed
|
||||
- fearless
|
||||
|
||||
base_damage: 9
|
||||
crit_chance: 0.08
|
||||
flee_chance: 0.50
|
||||
217
api/app/models/enemy.py
Normal file
217
api/app/models/enemy.py
Normal file
@@ -0,0 +1,217 @@
|
||||
"""
|
||||
Enemy data models for combat encounters.
|
||||
|
||||
This module defines the EnemyTemplate dataclass representing enemies/monsters
|
||||
that can be encountered in combat. Enemy definitions are loaded from YAML files
|
||||
for data-driven game design.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from typing import Dict, Any, List, Optional, Tuple
|
||||
from enum import Enum
|
||||
|
||||
from app.models.stats import Stats
|
||||
|
||||
|
||||
class EnemyDifficulty(Enum):
|
||||
"""Enemy difficulty levels for scaling and encounter building."""
|
||||
EASY = "easy"
|
||||
MEDIUM = "medium"
|
||||
HARD = "hard"
|
||||
BOSS = "boss"
|
||||
|
||||
|
||||
@dataclass
|
||||
class LootEntry:
|
||||
"""
|
||||
Single entry in an enemy's loot table.
|
||||
|
||||
Attributes:
|
||||
item_id: Reference to item definition
|
||||
drop_chance: Probability of dropping (0.0 to 1.0)
|
||||
quantity_min: Minimum quantity if dropped
|
||||
quantity_max: Maximum quantity if dropped
|
||||
"""
|
||||
|
||||
item_id: str
|
||||
drop_chance: float = 0.1
|
||||
quantity_min: int = 1
|
||||
quantity_max: int = 1
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize loot entry to dictionary."""
|
||||
return asdict(self)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'LootEntry':
|
||||
"""Deserialize loot entry from dictionary."""
|
||||
return cls(
|
||||
item_id=data["item_id"],
|
||||
drop_chance=data.get("drop_chance", 0.1),
|
||||
quantity_min=data.get("quantity_min", 1),
|
||||
quantity_max=data.get("quantity_max", 1),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class EnemyTemplate:
|
||||
"""
|
||||
Template definition for an enemy type.
|
||||
|
||||
EnemyTemplates define the base characteristics of enemy types. When combat
|
||||
starts, instances are created from templates with randomized variations.
|
||||
|
||||
Attributes:
|
||||
enemy_id: Unique identifier (e.g., "goblin", "dire_wolf")
|
||||
name: Display name (e.g., "Goblin Scout")
|
||||
description: Flavor text about the enemy
|
||||
base_stats: Base stat block for this enemy
|
||||
abilities: List of ability_ids this enemy can use
|
||||
loot_table: Potential drops on defeat
|
||||
experience_reward: Base XP granted on defeat
|
||||
gold_reward_min: Minimum gold dropped
|
||||
gold_reward_max: Maximum gold dropped
|
||||
difficulty: Difficulty classification for encounter building
|
||||
tags: Classification tags (e.g., ["humanoid", "goblinoid"])
|
||||
image_url: Optional image reference for UI
|
||||
|
||||
Combat-specific attributes:
|
||||
base_damage: Base damage for basic attack (no weapon)
|
||||
crit_chance: Critical hit chance (0.0 to 1.0)
|
||||
flee_chance: Chance to successfully flee from this enemy
|
||||
"""
|
||||
|
||||
enemy_id: str
|
||||
name: str
|
||||
description: str
|
||||
base_stats: Stats
|
||||
abilities: List[str] = field(default_factory=list)
|
||||
loot_table: List[LootEntry] = field(default_factory=list)
|
||||
experience_reward: int = 10
|
||||
gold_reward_min: int = 1
|
||||
gold_reward_max: int = 5
|
||||
difficulty: EnemyDifficulty = EnemyDifficulty.EASY
|
||||
tags: List[str] = field(default_factory=list)
|
||||
image_url: Optional[str] = None
|
||||
|
||||
# Combat attributes
|
||||
base_damage: int = 5
|
||||
crit_chance: float = 0.05
|
||||
flee_chance: float = 0.5
|
||||
|
||||
def get_gold_reward(self) -> int:
|
||||
"""
|
||||
Roll random gold reward within range.
|
||||
|
||||
Returns:
|
||||
Random gold amount between min and max
|
||||
"""
|
||||
import random
|
||||
return random.randint(self.gold_reward_min, self.gold_reward_max)
|
||||
|
||||
def roll_loot(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Roll for loot drops based on loot table.
|
||||
|
||||
Returns:
|
||||
List of dropped items with quantities
|
||||
"""
|
||||
import random
|
||||
drops = []
|
||||
|
||||
for entry in self.loot_table:
|
||||
if random.random() < entry.drop_chance:
|
||||
quantity = random.randint(entry.quantity_min, entry.quantity_max)
|
||||
drops.append({
|
||||
"item_id": entry.item_id,
|
||||
"quantity": quantity,
|
||||
})
|
||||
|
||||
return drops
|
||||
|
||||
def is_boss(self) -> bool:
|
||||
"""Check if this enemy is a boss."""
|
||||
return self.difficulty == EnemyDifficulty.BOSS
|
||||
|
||||
def has_tag(self, tag: str) -> bool:
|
||||
"""Check if enemy has a specific tag."""
|
||||
return tag.lower() in [t.lower() for t in self.tags]
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Serialize enemy template to dictionary.
|
||||
|
||||
Returns:
|
||||
Dictionary containing all enemy data
|
||||
"""
|
||||
return {
|
||||
"enemy_id": self.enemy_id,
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"base_stats": self.base_stats.to_dict(),
|
||||
"abilities": self.abilities,
|
||||
"loot_table": [entry.to_dict() for entry in self.loot_table],
|
||||
"experience_reward": self.experience_reward,
|
||||
"gold_reward_min": self.gold_reward_min,
|
||||
"gold_reward_max": self.gold_reward_max,
|
||||
"difficulty": self.difficulty.value,
|
||||
"tags": self.tags,
|
||||
"image_url": self.image_url,
|
||||
"base_damage": self.base_damage,
|
||||
"crit_chance": self.crit_chance,
|
||||
"flee_chance": self.flee_chance,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'EnemyTemplate':
|
||||
"""
|
||||
Deserialize enemy template from dictionary.
|
||||
|
||||
Args:
|
||||
data: Dictionary containing enemy data (from YAML or JSON)
|
||||
|
||||
Returns:
|
||||
EnemyTemplate instance
|
||||
"""
|
||||
# Parse base stats
|
||||
stats_data = data.get("base_stats", {})
|
||||
base_stats = Stats.from_dict(stats_data)
|
||||
|
||||
# Parse loot table
|
||||
loot_table = [
|
||||
LootEntry.from_dict(entry)
|
||||
for entry in data.get("loot_table", [])
|
||||
]
|
||||
|
||||
# Parse difficulty
|
||||
difficulty_value = data.get("difficulty", "easy")
|
||||
if isinstance(difficulty_value, str):
|
||||
difficulty = EnemyDifficulty(difficulty_value)
|
||||
else:
|
||||
difficulty = difficulty_value
|
||||
|
||||
return cls(
|
||||
enemy_id=data["enemy_id"],
|
||||
name=data["name"],
|
||||
description=data.get("description", ""),
|
||||
base_stats=base_stats,
|
||||
abilities=data.get("abilities", []),
|
||||
loot_table=loot_table,
|
||||
experience_reward=data.get("experience_reward", 10),
|
||||
gold_reward_min=data.get("gold_reward_min", 1),
|
||||
gold_reward_max=data.get("gold_reward_max", 5),
|
||||
difficulty=difficulty,
|
||||
tags=data.get("tags", []),
|
||||
image_url=data.get("image_url"),
|
||||
base_damage=data.get("base_damage", 5),
|
||||
crit_chance=data.get("crit_chance", 0.05),
|
||||
flee_chance=data.get("flee_chance", 0.5),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""String representation of the enemy template."""
|
||||
return (
|
||||
f"EnemyTemplate({self.enemy_id}, {self.name}, "
|
||||
f"difficulty={self.difficulty.value}, "
|
||||
f"xp={self.experience_reward})"
|
||||
)
|
||||
@@ -65,6 +65,12 @@ class Item:
|
||||
crit_chance: float = 0.05 # 5% default critical hit chance
|
||||
crit_multiplier: float = 2.0 # 2x damage on critical hit
|
||||
|
||||
# Elemental weapon properties (for split damage like Fire Sword)
|
||||
# These enable weapons to deal both physical AND elemental damage
|
||||
elemental_damage_type: Optional[DamageType] = None # Secondary damage type (fire, ice, etc.)
|
||||
physical_ratio: float = 1.0 # Portion of damage that is physical (0.0-1.0)
|
||||
elemental_ratio: float = 0.0 # Portion of damage that is elemental (0.0-1.0)
|
||||
|
||||
# Armor-specific
|
||||
defense: int = 0
|
||||
resistance: int = 0
|
||||
@@ -89,6 +95,27 @@ class Item:
|
||||
"""Check if this item is a quest item."""
|
||||
return self.item_type == ItemType.QUEST_ITEM
|
||||
|
||||
def is_elemental_weapon(self) -> bool:
|
||||
"""
|
||||
Check if this weapon deals elemental damage (split damage).
|
||||
|
||||
Elemental weapons deal both physical AND elemental damage,
|
||||
calculated separately against DEF and RES.
|
||||
|
||||
Examples:
|
||||
Fire Sword: 70% physical / 30% fire
|
||||
Frost Blade: 60% physical / 40% ice
|
||||
Lightning Spear: 50% physical / 50% lightning
|
||||
|
||||
Returns:
|
||||
True if weapon has elemental damage component
|
||||
"""
|
||||
return (
|
||||
self.is_weapon() and
|
||||
self.elemental_ratio > 0.0 and
|
||||
self.elemental_damage_type is not None
|
||||
)
|
||||
|
||||
def can_equip(self, character_level: int, character_class: Optional[str] = None) -> bool:
|
||||
"""
|
||||
Check if a character can equip this item.
|
||||
@@ -133,6 +160,8 @@ class Item:
|
||||
data["item_type"] = self.item_type.value
|
||||
if self.damage_type:
|
||||
data["damage_type"] = self.damage_type.value
|
||||
if self.elemental_damage_type:
|
||||
data["elemental_damage_type"] = self.elemental_damage_type.value
|
||||
data["effects_on_use"] = [effect.to_dict() for effect in self.effects_on_use]
|
||||
return data
|
||||
|
||||
@@ -150,6 +179,11 @@ class Item:
|
||||
# Convert string values back to enums
|
||||
item_type = ItemType(data["item_type"])
|
||||
damage_type = DamageType(data["damage_type"]) if data.get("damage_type") else None
|
||||
elemental_damage_type = (
|
||||
DamageType(data["elemental_damage_type"])
|
||||
if data.get("elemental_damage_type")
|
||||
else None
|
||||
)
|
||||
|
||||
# Deserialize effects
|
||||
effects = []
|
||||
@@ -169,6 +203,9 @@ class Item:
|
||||
damage_type=damage_type,
|
||||
crit_chance=data.get("crit_chance", 0.05),
|
||||
crit_multiplier=data.get("crit_multiplier", 2.0),
|
||||
elemental_damage_type=elemental_damage_type,
|
||||
physical_ratio=data.get("physical_ratio", 1.0),
|
||||
elemental_ratio=data.get("elemental_ratio", 0.0),
|
||||
defense=data.get("defense", 0),
|
||||
resistance=data.get("resistance", 0),
|
||||
required_level=data.get("required_level", 1),
|
||||
@@ -178,6 +215,12 @@ class Item:
|
||||
def __repr__(self) -> str:
|
||||
"""String representation of the item."""
|
||||
if self.is_weapon():
|
||||
if self.is_elemental_weapon():
|
||||
return (
|
||||
f"Item({self.name}, elemental_weapon, dmg={self.damage}, "
|
||||
f"phys={self.physical_ratio:.0%}/{self.elemental_damage_type.value}={self.elemental_ratio:.0%}, "
|
||||
f"crit={self.crit_chance*100:.0f}%, value={self.value}g)"
|
||||
)
|
||||
return (
|
||||
f"Item({self.name}, weapon, dmg={self.damage}, "
|
||||
f"crit={self.crit_chance*100:.0f}%, value={self.value}g)"
|
||||
|
||||
@@ -86,6 +86,63 @@ class Stats:
|
||||
"""
|
||||
return self.wisdom // 2
|
||||
|
||||
@property
|
||||
def crit_bonus(self) -> float:
|
||||
"""
|
||||
Calculate critical hit chance bonus from luck.
|
||||
|
||||
Formula: luck * 0.5% (0.005)
|
||||
|
||||
This bonus is added to the weapon's base crit chance.
|
||||
The total crit chance is capped at 25% in the DamageCalculator.
|
||||
|
||||
Returns:
|
||||
Crit chance bonus as a decimal (e.g., 0.04 for LUK 8)
|
||||
|
||||
Examples:
|
||||
LUK 8: 0.04 (4% bonus)
|
||||
LUK 12: 0.06 (6% bonus)
|
||||
"""
|
||||
return self.luck * 0.005
|
||||
|
||||
@property
|
||||
def hit_bonus(self) -> float:
|
||||
"""
|
||||
Calculate hit chance bonus (miss reduction) from luck.
|
||||
|
||||
Formula: luck * 0.5% (0.005)
|
||||
|
||||
This reduces the base 10% miss chance. The minimum miss
|
||||
chance is hard capped at 5% to prevent frustration.
|
||||
|
||||
Returns:
|
||||
Miss reduction as a decimal (e.g., 0.04 for LUK 8)
|
||||
|
||||
Examples:
|
||||
LUK 8: 0.04 (reduces miss from 10% to 6%)
|
||||
LUK 12: 0.06 (reduces miss from 10% to 4%, capped at 5%)
|
||||
"""
|
||||
return self.luck * 0.005
|
||||
|
||||
@property
|
||||
def lucky_roll_chance(self) -> float:
|
||||
"""
|
||||
Calculate chance for a "lucky" high damage variance roll.
|
||||
|
||||
Formula: 5% + (luck * 0.25%)
|
||||
|
||||
When triggered, damage variance uses 100%-110% instead of 95%-105%.
|
||||
This gives LUK characters more frequent high damage rolls.
|
||||
|
||||
Returns:
|
||||
Lucky roll chance as a decimal
|
||||
|
||||
Examples:
|
||||
LUK 8: 0.07 (7% chance for lucky roll)
|
||||
LUK 12: 0.08 (8% chance for lucky roll)
|
||||
"""
|
||||
return 0.05 + (self.luck * 0.0025)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Serialize stats to a dictionary.
|
||||
@@ -140,5 +197,6 @@ class Stats:
|
||||
f"CON={self.constitution}, INT={self.intelligence}, "
|
||||
f"WIS={self.wisdom}, CHA={self.charisma}, LUK={self.luck}, "
|
||||
f"HP={self.hit_points}, MP={self.mana_points}, "
|
||||
f"DEF={self.defense}, RES={self.resistance})"
|
||||
f"DEF={self.defense}, RES={self.resistance}, "
|
||||
f"CRIT_BONUS={self.crit_bonus:.1%}, HIT_BONUS={self.hit_bonus:.1%})"
|
||||
)
|
||||
|
||||
1068
api/app/services/combat_service.py
Normal file
1068
api/app/services/combat_service.py
Normal file
File diff suppressed because it is too large
Load Diff
594
api/app/services/damage_calculator.py
Normal file
594
api/app/services/damage_calculator.py
Normal file
@@ -0,0 +1,594 @@
|
||||
"""
|
||||
Damage Calculator Service
|
||||
|
||||
A comprehensive, formula-driven damage calculation system for Code of Conquest.
|
||||
Handles physical, magical, and elemental damage with LUK stat integration
|
||||
for variance, critical hits, and accuracy.
|
||||
|
||||
Formulas:
|
||||
Physical: (Weapon_Base + STR * 0.75) * Variance * Crit_Mult - DEF
|
||||
Magical: (Ability_Base + INT * 0.75) * Variance * Crit_Mult - RES
|
||||
Elemental: Split between physical and magical components
|
||||
|
||||
LUK Integration:
|
||||
- Miss reduction: 10% base - (LUK * 0.5%), hard cap at 5% miss
|
||||
- Crit bonus: Base 5% + (LUK * 0.5%), max 25%
|
||||
- Lucky variance: 5% + (LUK * 0.25%) chance for higher damage roll
|
||||
"""
|
||||
|
||||
import random
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, Any, Optional, List
|
||||
|
||||
from app.models.stats import Stats
|
||||
from app.models.enums import DamageType
|
||||
|
||||
|
||||
class CombatConstants:
|
||||
"""
|
||||
Combat system tuning constants.
|
||||
|
||||
These values control the balance of combat mechanics and can be
|
||||
adjusted for game balance without modifying formula logic.
|
||||
"""
|
||||
|
||||
# Stat Scaling
|
||||
# How much primary stats (STR/INT) contribute to damage
|
||||
# 0.75 means STR 14 adds +10.5 damage
|
||||
STAT_SCALING_FACTOR: float = 0.75
|
||||
|
||||
# Hit/Miss System
|
||||
BASE_MISS_CHANCE: float = 0.10 # 10% base miss rate
|
||||
LUK_MISS_REDUCTION: float = 0.005 # 0.5% per LUK point
|
||||
DEX_EVASION_BONUS: float = 0.0025 # 0.25% per DEX above 10
|
||||
MIN_MISS_CHANCE: float = 0.05 # Hard cap: 5% minimum miss
|
||||
|
||||
# Critical Hits
|
||||
DEFAULT_CRIT_CHANCE: float = 0.05 # 5% base crit
|
||||
LUK_CRIT_BONUS: float = 0.005 # 0.5% per LUK point
|
||||
MAX_CRIT_CHANCE: float = 0.25 # 25% cap (before skills)
|
||||
DEFAULT_CRIT_MULTIPLIER: float = 2.0
|
||||
|
||||
# Damage Variance
|
||||
BASE_VARIANCE_MIN: float = 0.95 # Minimum variance roll
|
||||
BASE_VARIANCE_MAX: float = 1.05 # Maximum variance roll
|
||||
LUCKY_VARIANCE_MIN: float = 1.00 # Lucky roll minimum
|
||||
LUCKY_VARIANCE_MAX: float = 1.10 # Lucky roll maximum (10% bonus)
|
||||
BASE_LUCKY_CHANCE: float = 0.05 # 5% base lucky roll chance
|
||||
LUK_LUCKY_BONUS: float = 0.0025 # 0.25% per LUK point
|
||||
|
||||
# Defense Mitigation
|
||||
# Ensures high-DEF targets still take meaningful damage
|
||||
MIN_DAMAGE_RATIO: float = 0.20 # 20% of raw always goes through
|
||||
MIN_DAMAGE: int = 1 # Absolute minimum damage
|
||||
|
||||
|
||||
@dataclass
|
||||
class DamageResult:
|
||||
"""
|
||||
Result of a damage calculation.
|
||||
|
||||
Contains the calculated damage values, whether the attack was a crit or miss,
|
||||
and a human-readable message for the combat log.
|
||||
|
||||
Attributes:
|
||||
total_damage: Final damage after all calculations
|
||||
physical_damage: Physical component (for split damage)
|
||||
elemental_damage: Elemental component (for split damage)
|
||||
damage_type: Primary damage type (physical, fire, etc.)
|
||||
is_critical: Whether the attack was a critical hit
|
||||
is_miss: Whether the attack missed entirely
|
||||
variance_roll: The variance multiplier that was applied
|
||||
raw_damage: Damage before defense mitigation
|
||||
message: Human-readable description for combat log
|
||||
"""
|
||||
|
||||
total_damage: int = 0
|
||||
physical_damage: int = 0
|
||||
elemental_damage: int = 0
|
||||
damage_type: DamageType = DamageType.PHYSICAL
|
||||
elemental_type: Optional[DamageType] = None
|
||||
is_critical: bool = False
|
||||
is_miss: bool = False
|
||||
variance_roll: float = 1.0
|
||||
raw_damage: int = 0
|
||||
message: str = ""
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize damage result to dictionary."""
|
||||
return {
|
||||
"total_damage": self.total_damage,
|
||||
"physical_damage": self.physical_damage,
|
||||
"elemental_damage": self.elemental_damage,
|
||||
"damage_type": self.damage_type.value if self.damage_type else "physical",
|
||||
"elemental_type": self.elemental_type.value if self.elemental_type else None,
|
||||
"is_critical": self.is_critical,
|
||||
"is_miss": self.is_miss,
|
||||
"variance_roll": round(self.variance_roll, 3),
|
||||
"raw_damage": self.raw_damage,
|
||||
"message": self.message,
|
||||
}
|
||||
|
||||
|
||||
class DamageCalculator:
|
||||
"""
|
||||
Formula-driven damage calculator for combat.
|
||||
|
||||
This class provides static methods for calculating all types of damage
|
||||
in the combat system, including hit/miss chances, critical hits,
|
||||
damage variance, and defense mitigation.
|
||||
|
||||
All formulas integrate the LUK stat for meaningful randomness while
|
||||
maintaining a hard cap on miss chance to prevent frustration.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def calculate_hit_chance(
|
||||
attacker_luck: int,
|
||||
defender_dexterity: int,
|
||||
skill_bonus: float = 0.0
|
||||
) -> float:
|
||||
"""
|
||||
Calculate hit probability for an attack.
|
||||
|
||||
Formula:
|
||||
miss_chance = max(0.05, 0.10 - (LUK * 0.005) + ((DEX - 10) * 0.0025))
|
||||
hit_chance = 1.0 - miss_chance
|
||||
|
||||
Args:
|
||||
attacker_luck: Attacker's LUK stat
|
||||
defender_dexterity: Defender's DEX stat
|
||||
skill_bonus: Additional hit chance from skills (0.0 to 1.0)
|
||||
|
||||
Returns:
|
||||
Hit probability as a float between 0.0 and 1.0
|
||||
|
||||
Examples:
|
||||
LUK 8, DEX 10: miss = 10% - 4% + 0% = 6%
|
||||
LUK 12, DEX 10: miss = 10% - 6% + 0% = 4% -> capped at 5%
|
||||
LUK 8, DEX 15: miss = 10% - 4% + 1.25% = 7.25%
|
||||
"""
|
||||
# Base miss rate
|
||||
base_miss = CombatConstants.BASE_MISS_CHANCE
|
||||
|
||||
# LUK reduces miss chance
|
||||
luk_reduction = attacker_luck * CombatConstants.LUK_MISS_REDUCTION
|
||||
|
||||
# High DEX increases evasion (only DEX above 10 counts)
|
||||
dex_above_base = max(0, defender_dexterity - 10)
|
||||
dex_evasion = dex_above_base * CombatConstants.DEX_EVASION_BONUS
|
||||
|
||||
# Calculate final miss chance with hard cap
|
||||
miss_chance = base_miss - luk_reduction + dex_evasion - skill_bonus
|
||||
miss_chance = max(CombatConstants.MIN_MISS_CHANCE, miss_chance)
|
||||
|
||||
return 1.0 - miss_chance
|
||||
|
||||
@staticmethod
|
||||
def calculate_crit_chance(
|
||||
attacker_luck: int,
|
||||
weapon_crit_chance: float = CombatConstants.DEFAULT_CRIT_CHANCE,
|
||||
skill_bonus: float = 0.0
|
||||
) -> float:
|
||||
"""
|
||||
Calculate critical hit probability.
|
||||
|
||||
Formula:
|
||||
crit_chance = min(0.25, weapon_crit + (LUK * 0.005) + skill_bonus)
|
||||
|
||||
Args:
|
||||
attacker_luck: Attacker's LUK stat
|
||||
weapon_crit_chance: Base crit chance from weapon (default 5%)
|
||||
skill_bonus: Additional crit chance from skills
|
||||
|
||||
Returns:
|
||||
Crit probability as a float (capped at 25%)
|
||||
|
||||
Examples:
|
||||
LUK 8, weapon 5%: crit = 5% + 4% = 9%
|
||||
LUK 12, weapon 5%: crit = 5% + 6% = 11%
|
||||
LUK 12, weapon 10%: crit = 10% + 6% = 16%
|
||||
"""
|
||||
# LUK bonus to crit
|
||||
luk_bonus = attacker_luck * CombatConstants.LUK_CRIT_BONUS
|
||||
|
||||
# Total crit chance with cap
|
||||
total_crit = weapon_crit_chance + luk_bonus + skill_bonus
|
||||
|
||||
return min(CombatConstants.MAX_CRIT_CHANCE, total_crit)
|
||||
|
||||
@staticmethod
|
||||
def calculate_variance(attacker_luck: int) -> float:
|
||||
"""
|
||||
Calculate damage variance multiplier with LUK bonus.
|
||||
|
||||
Hybrid variance system:
|
||||
- Base roll: 95% to 105% of damage
|
||||
- LUK grants chance for "lucky roll": 100% to 110% instead
|
||||
|
||||
Args:
|
||||
attacker_luck: Attacker's LUK stat
|
||||
|
||||
Returns:
|
||||
Variance multiplier (typically 0.95 to 1.10)
|
||||
|
||||
Examples:
|
||||
LUK 8: 7% chance for lucky roll (100-110%)
|
||||
LUK 12: 8% chance for lucky roll
|
||||
"""
|
||||
# Calculate lucky roll chance
|
||||
lucky_chance = (
|
||||
CombatConstants.BASE_LUCKY_CHANCE +
|
||||
(attacker_luck * CombatConstants.LUK_LUCKY_BONUS)
|
||||
)
|
||||
|
||||
# Roll for lucky variance
|
||||
if random.random() < lucky_chance:
|
||||
# Lucky roll: higher damage range
|
||||
return random.uniform(
|
||||
CombatConstants.LUCKY_VARIANCE_MIN,
|
||||
CombatConstants.LUCKY_VARIANCE_MAX
|
||||
)
|
||||
else:
|
||||
# Normal roll
|
||||
return random.uniform(
|
||||
CombatConstants.BASE_VARIANCE_MIN,
|
||||
CombatConstants.BASE_VARIANCE_MAX
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def apply_defense(
|
||||
raw_damage: int,
|
||||
defense: int,
|
||||
min_damage_ratio: float = CombatConstants.MIN_DAMAGE_RATIO
|
||||
) -> int:
|
||||
"""
|
||||
Apply defense mitigation with minimum damage guarantee.
|
||||
|
||||
Ensures at least 20% of raw damage always goes through,
|
||||
preventing high-DEF tanks from becoming unkillable.
|
||||
Absolute minimum is always 1 damage.
|
||||
|
||||
Args:
|
||||
raw_damage: Damage before defense
|
||||
defense: Target's defense value
|
||||
min_damage_ratio: Minimum % of raw damage that goes through
|
||||
|
||||
Returns:
|
||||
Final damage after mitigation (minimum 1)
|
||||
|
||||
Examples:
|
||||
raw=20, def=5: 20 - 5 = 15 damage
|
||||
raw=20, def=18: max(4, 2) = 4 damage (20% minimum)
|
||||
raw=10, def=100: max(2, -90) = 2 damage (20% minimum)
|
||||
"""
|
||||
# Calculate mitigated damage
|
||||
mitigated = raw_damage - defense
|
||||
|
||||
# Minimum damage is 20% of raw, or 1, whichever is higher
|
||||
min_damage = max(CombatConstants.MIN_DAMAGE, int(raw_damage * min_damage_ratio))
|
||||
|
||||
return max(min_damage, mitigated)
|
||||
|
||||
@classmethod
|
||||
def calculate_physical_damage(
|
||||
cls,
|
||||
attacker_stats: Stats,
|
||||
defender_stats: Stats,
|
||||
weapon_damage: int = 0,
|
||||
weapon_crit_chance: float = CombatConstants.DEFAULT_CRIT_CHANCE,
|
||||
weapon_crit_multiplier: float = CombatConstants.DEFAULT_CRIT_MULTIPLIER,
|
||||
ability_base_power: int = 0,
|
||||
skill_hit_bonus: float = 0.0,
|
||||
skill_crit_bonus: float = 0.0,
|
||||
) -> DamageResult:
|
||||
"""
|
||||
Calculate physical damage for a melee/ranged attack.
|
||||
|
||||
Formula:
|
||||
Base = Weapon_Base + Ability_Power + (STR * 0.75)
|
||||
Damage = Base * Variance * Crit_Mult - DEF
|
||||
|
||||
Args:
|
||||
attacker_stats: Attacker's Stats (STR, LUK used)
|
||||
defender_stats: Defender's Stats (DEX, CON used)
|
||||
weapon_damage: Base damage from equipped weapon
|
||||
weapon_crit_chance: Crit chance from weapon (default 5%)
|
||||
weapon_crit_multiplier: Crit damage multiplier (default 2.0x)
|
||||
ability_base_power: Additional base power from ability
|
||||
skill_hit_bonus: Hit chance bonus from skills
|
||||
skill_crit_bonus: Crit chance bonus from skills
|
||||
|
||||
Returns:
|
||||
DamageResult with calculated damage and metadata
|
||||
"""
|
||||
result = DamageResult(damage_type=DamageType.PHYSICAL)
|
||||
|
||||
# Step 1: Check for miss
|
||||
hit_chance = cls.calculate_hit_chance(
|
||||
attacker_stats.luck,
|
||||
defender_stats.dexterity,
|
||||
skill_hit_bonus
|
||||
)
|
||||
|
||||
if random.random() > hit_chance:
|
||||
result.is_miss = True
|
||||
result.message = "Attack missed!"
|
||||
return result
|
||||
|
||||
# Step 2: Calculate base damage
|
||||
# Formula: weapon + ability + (STR * scaling_factor)
|
||||
str_bonus = attacker_stats.strength * CombatConstants.STAT_SCALING_FACTOR
|
||||
base_damage = weapon_damage + ability_base_power + str_bonus
|
||||
|
||||
# Step 3: Apply variance
|
||||
variance = cls.calculate_variance(attacker_stats.luck)
|
||||
result.variance_roll = variance
|
||||
damage = base_damage * variance
|
||||
|
||||
# Step 4: Check for critical hit
|
||||
crit_chance = cls.calculate_crit_chance(
|
||||
attacker_stats.luck,
|
||||
weapon_crit_chance,
|
||||
skill_crit_bonus
|
||||
)
|
||||
|
||||
if random.random() < crit_chance:
|
||||
result.is_critical = True
|
||||
damage *= weapon_crit_multiplier
|
||||
|
||||
# Store raw damage before defense
|
||||
result.raw_damage = int(damage)
|
||||
|
||||
# Step 5: Apply defense mitigation
|
||||
final_damage = cls.apply_defense(int(damage), defender_stats.defense)
|
||||
|
||||
result.total_damage = final_damage
|
||||
result.physical_damage = final_damage
|
||||
|
||||
# Build message
|
||||
crit_text = " CRITICAL HIT!" if result.is_critical else ""
|
||||
result.message = f"Dealt {final_damage} physical damage.{crit_text}"
|
||||
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def calculate_magical_damage(
|
||||
cls,
|
||||
attacker_stats: Stats,
|
||||
defender_stats: Stats,
|
||||
ability_base_power: int,
|
||||
damage_type: DamageType = DamageType.FIRE,
|
||||
weapon_crit_chance: float = CombatConstants.DEFAULT_CRIT_CHANCE,
|
||||
weapon_crit_multiplier: float = CombatConstants.DEFAULT_CRIT_MULTIPLIER,
|
||||
skill_hit_bonus: float = 0.0,
|
||||
skill_crit_bonus: float = 0.0,
|
||||
) -> DamageResult:
|
||||
"""
|
||||
Calculate magical damage for a spell.
|
||||
|
||||
Spells CAN critically hit (same formula as physical).
|
||||
LUK benefits all classes equally.
|
||||
|
||||
Formula:
|
||||
Base = Ability_Power + (INT * 0.75)
|
||||
Damage = Base * Variance * Crit_Mult - RES
|
||||
|
||||
Args:
|
||||
attacker_stats: Attacker's Stats (INT, LUK used)
|
||||
defender_stats: Defender's Stats (DEX, WIS used)
|
||||
ability_base_power: Base power of the spell
|
||||
damage_type: Type of magical damage (fire, ice, etc.)
|
||||
weapon_crit_chance: Crit chance (from focus/staff)
|
||||
weapon_crit_multiplier: Crit damage multiplier
|
||||
skill_hit_bonus: Hit chance bonus from skills
|
||||
skill_crit_bonus: Crit chance bonus from skills
|
||||
|
||||
Returns:
|
||||
DamageResult with calculated damage and metadata
|
||||
"""
|
||||
result = DamageResult(damage_type=damage_type)
|
||||
|
||||
# Step 1: Check for miss (spells can miss too)
|
||||
hit_chance = cls.calculate_hit_chance(
|
||||
attacker_stats.luck,
|
||||
defender_stats.dexterity,
|
||||
skill_hit_bonus
|
||||
)
|
||||
|
||||
if random.random() > hit_chance:
|
||||
result.is_miss = True
|
||||
result.message = "Spell missed!"
|
||||
return result
|
||||
|
||||
# Step 2: Calculate base damage
|
||||
# Formula: ability + (INT * scaling_factor)
|
||||
int_bonus = attacker_stats.intelligence * CombatConstants.STAT_SCALING_FACTOR
|
||||
base_damage = ability_base_power + int_bonus
|
||||
|
||||
# Step 3: Apply variance
|
||||
variance = cls.calculate_variance(attacker_stats.luck)
|
||||
result.variance_roll = variance
|
||||
damage = base_damage * variance
|
||||
|
||||
# Step 4: Check for critical hit (spells CAN crit)
|
||||
crit_chance = cls.calculate_crit_chance(
|
||||
attacker_stats.luck,
|
||||
weapon_crit_chance,
|
||||
skill_crit_bonus
|
||||
)
|
||||
|
||||
if random.random() < crit_chance:
|
||||
result.is_critical = True
|
||||
damage *= weapon_crit_multiplier
|
||||
|
||||
# Store raw damage before resistance
|
||||
result.raw_damage = int(damage)
|
||||
|
||||
# Step 5: Apply resistance mitigation
|
||||
final_damage = cls.apply_defense(int(damage), defender_stats.resistance)
|
||||
|
||||
result.total_damage = final_damage
|
||||
result.elemental_damage = final_damage
|
||||
|
||||
# Build message
|
||||
crit_text = " CRITICAL HIT!" if result.is_critical else ""
|
||||
result.message = f"Dealt {final_damage} {damage_type.value} damage.{crit_text}"
|
||||
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def calculate_elemental_weapon_damage(
|
||||
cls,
|
||||
attacker_stats: Stats,
|
||||
defender_stats: Stats,
|
||||
weapon_damage: int,
|
||||
weapon_crit_chance: float,
|
||||
weapon_crit_multiplier: float,
|
||||
physical_ratio: float,
|
||||
elemental_ratio: float,
|
||||
elemental_type: DamageType,
|
||||
ability_base_power: int = 0,
|
||||
skill_hit_bonus: float = 0.0,
|
||||
skill_crit_bonus: float = 0.0,
|
||||
) -> DamageResult:
|
||||
"""
|
||||
Calculate split damage for elemental weapons (e.g., Fire Sword).
|
||||
|
||||
Elemental weapons deal both physical AND elemental damage,
|
||||
calculated separately against DEF and RES respectively.
|
||||
|
||||
Formula:
|
||||
Physical = (Weapon * PHYS_RATIO + STR * 0.75 * PHYS_RATIO) - DEF
|
||||
Elemental = (Weapon * ELEM_RATIO + INT * 0.75 * ELEM_RATIO) - RES
|
||||
Total = Physical + Elemental
|
||||
|
||||
Recommended Split Ratios:
|
||||
- Pure Physical: 100% / 0%
|
||||
- Fire Sword: 70% / 30%
|
||||
- Frost Blade: 60% / 40%
|
||||
- Lightning Spear: 50% / 50%
|
||||
|
||||
Args:
|
||||
attacker_stats: Attacker's Stats
|
||||
defender_stats: Defender's Stats
|
||||
weapon_damage: Base weapon damage
|
||||
weapon_crit_chance: Crit chance from weapon
|
||||
weapon_crit_multiplier: Crit damage multiplier
|
||||
physical_ratio: Portion of damage that is physical (0.0-1.0)
|
||||
elemental_ratio: Portion of damage that is elemental (0.0-1.0)
|
||||
elemental_type: Type of elemental damage
|
||||
ability_base_power: Additional base power from ability
|
||||
skill_hit_bonus: Hit chance bonus from skills
|
||||
skill_crit_bonus: Crit chance bonus from skills
|
||||
|
||||
Returns:
|
||||
DamageResult with split physical/elemental damage
|
||||
"""
|
||||
result = DamageResult(
|
||||
damage_type=DamageType.PHYSICAL,
|
||||
elemental_type=elemental_type
|
||||
)
|
||||
|
||||
# Step 1: Check for miss (single roll for entire attack)
|
||||
hit_chance = cls.calculate_hit_chance(
|
||||
attacker_stats.luck,
|
||||
defender_stats.dexterity,
|
||||
skill_hit_bonus
|
||||
)
|
||||
|
||||
if random.random() > hit_chance:
|
||||
result.is_miss = True
|
||||
result.message = "Attack missed!"
|
||||
return result
|
||||
|
||||
# Step 2: Check for critical (single roll applies to both components)
|
||||
variance = cls.calculate_variance(attacker_stats.luck)
|
||||
result.variance_roll = variance
|
||||
|
||||
crit_chance = cls.calculate_crit_chance(
|
||||
attacker_stats.luck,
|
||||
weapon_crit_chance,
|
||||
skill_crit_bonus
|
||||
)
|
||||
is_crit = random.random() < crit_chance
|
||||
result.is_critical = is_crit
|
||||
crit_mult = weapon_crit_multiplier if is_crit else 1.0
|
||||
|
||||
# Step 3: Calculate physical component
|
||||
# Physical uses STR scaling
|
||||
phys_base = (weapon_damage + ability_base_power) * physical_ratio
|
||||
str_bonus = attacker_stats.strength * CombatConstants.STAT_SCALING_FACTOR * physical_ratio
|
||||
phys_damage = (phys_base + str_bonus) * variance * crit_mult
|
||||
phys_final = cls.apply_defense(int(phys_damage), defender_stats.defense)
|
||||
|
||||
# Step 4: Calculate elemental component
|
||||
# Elemental uses INT scaling
|
||||
elem_base = (weapon_damage + ability_base_power) * elemental_ratio
|
||||
int_bonus = attacker_stats.intelligence * CombatConstants.STAT_SCALING_FACTOR * elemental_ratio
|
||||
elem_damage = (elem_base + int_bonus) * variance * crit_mult
|
||||
elem_final = cls.apply_defense(int(elem_damage), defender_stats.resistance)
|
||||
|
||||
# Step 5: Combine results
|
||||
result.physical_damage = phys_final
|
||||
result.elemental_damage = elem_final
|
||||
result.total_damage = phys_final + elem_final
|
||||
result.raw_damage = int(phys_damage + elem_damage)
|
||||
|
||||
# Build message
|
||||
crit_text = " CRITICAL HIT!" if is_crit else ""
|
||||
result.message = (
|
||||
f"Dealt {result.total_damage} damage "
|
||||
f"({phys_final} physical + {elem_final} {elemental_type.value}).{crit_text}"
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def calculate_aoe_damage(
|
||||
cls,
|
||||
attacker_stats: Stats,
|
||||
defender_stats_list: List[Stats],
|
||||
ability_base_power: int,
|
||||
damage_type: DamageType = DamageType.FIRE,
|
||||
weapon_crit_chance: float = CombatConstants.DEFAULT_CRIT_CHANCE,
|
||||
weapon_crit_multiplier: float = CombatConstants.DEFAULT_CRIT_MULTIPLIER,
|
||||
skill_hit_bonus: float = 0.0,
|
||||
skill_crit_bonus: float = 0.0,
|
||||
) -> List[DamageResult]:
|
||||
"""
|
||||
Calculate AoE spell damage against multiple targets.
|
||||
|
||||
AoE spells deal FULL damage to all targets (balanced by higher mana costs).
|
||||
Each target has independent hit/crit rolls but shares the base calculation.
|
||||
|
||||
Args:
|
||||
attacker_stats: Attacker's Stats
|
||||
defender_stats_list: List of defender Stats (one per target)
|
||||
ability_base_power: Base power of the AoE spell
|
||||
damage_type: Type of magical damage
|
||||
weapon_crit_chance: Crit chance from focus/staff
|
||||
weapon_crit_multiplier: Crit damage multiplier
|
||||
skill_hit_bonus: Hit chance bonus from skills
|
||||
skill_crit_bonus: Crit chance bonus from skills
|
||||
|
||||
Returns:
|
||||
List of DamageResult, one per target
|
||||
"""
|
||||
results = []
|
||||
|
||||
# Each target gets independent damage calculation
|
||||
for defender_stats in defender_stats_list:
|
||||
result = cls.calculate_magical_damage(
|
||||
attacker_stats=attacker_stats,
|
||||
defender_stats=defender_stats,
|
||||
ability_base_power=ability_base_power,
|
||||
damage_type=damage_type,
|
||||
weapon_crit_chance=weapon_crit_chance,
|
||||
weapon_crit_multiplier=weapon_crit_multiplier,
|
||||
skill_hit_bonus=skill_hit_bonus,
|
||||
skill_crit_bonus=skill_crit_bonus,
|
||||
)
|
||||
results.append(result)
|
||||
|
||||
return results
|
||||
260
api/app/services/enemy_loader.py
Normal file
260
api/app/services/enemy_loader.py
Normal file
@@ -0,0 +1,260 @@
|
||||
"""
|
||||
Enemy Loader Service - YAML-based enemy template loading.
|
||||
|
||||
This service loads enemy definitions from YAML files, providing a data-driven
|
||||
approach to defining monsters and enemies for combat encounters.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
import yaml
|
||||
|
||||
from app.models.enemy import EnemyTemplate, EnemyDifficulty
|
||||
from app.utils.logging import get_logger
|
||||
|
||||
logger = get_logger(__file__)
|
||||
|
||||
|
||||
class EnemyLoader:
|
||||
"""
|
||||
Loads enemy templates from YAML configuration files.
|
||||
|
||||
This allows game designers to define enemies without touching code.
|
||||
Enemy files are organized by difficulty in subdirectories.
|
||||
"""
|
||||
|
||||
def __init__(self, data_dir: Optional[str] = None):
|
||||
"""
|
||||
Initialize the enemy loader.
|
||||
|
||||
Args:
|
||||
data_dir: Path to directory containing enemy YAML files
|
||||
Defaults to /app/data/enemies/
|
||||
"""
|
||||
if data_dir is None:
|
||||
# Default to app/data/enemies relative to this file
|
||||
current_file = Path(__file__)
|
||||
app_dir = current_file.parent.parent # Go up to /app
|
||||
data_dir = str(app_dir / "data" / "enemies")
|
||||
|
||||
self.data_dir = Path(data_dir)
|
||||
self._enemy_cache: Dict[str, EnemyTemplate] = {}
|
||||
self._loaded = False
|
||||
|
||||
logger.info("EnemyLoader initialized", data_dir=str(self.data_dir))
|
||||
|
||||
def load_enemy(self, enemy_id: str) -> Optional[EnemyTemplate]:
|
||||
"""
|
||||
Load a single enemy template by ID.
|
||||
|
||||
Args:
|
||||
enemy_id: Unique enemy identifier
|
||||
|
||||
Returns:
|
||||
EnemyTemplate instance or None if not found
|
||||
"""
|
||||
# Check cache first
|
||||
if enemy_id in self._enemy_cache:
|
||||
return self._enemy_cache[enemy_id]
|
||||
|
||||
# If not cached, try loading all enemies first
|
||||
if not self._loaded:
|
||||
self.load_all_enemies()
|
||||
if enemy_id in self._enemy_cache:
|
||||
return self._enemy_cache[enemy_id]
|
||||
|
||||
# Try loading from specific YAML file
|
||||
yaml_file = self.data_dir / f"{enemy_id}.yaml"
|
||||
if yaml_file.exists():
|
||||
return self._load_from_file(yaml_file)
|
||||
|
||||
# Search in subdirectories
|
||||
for subdir in self.data_dir.iterdir():
|
||||
if subdir.is_dir():
|
||||
yaml_file = subdir / f"{enemy_id}.yaml"
|
||||
if yaml_file.exists():
|
||||
return self._load_from_file(yaml_file)
|
||||
|
||||
logger.warning("Enemy not found", enemy_id=enemy_id)
|
||||
return None
|
||||
|
||||
def _load_from_file(self, yaml_file: Path) -> Optional[EnemyTemplate]:
|
||||
"""
|
||||
Load an enemy template from a specific YAML file.
|
||||
|
||||
Args:
|
||||
yaml_file: Path to the YAML file
|
||||
|
||||
Returns:
|
||||
EnemyTemplate instance or None on error
|
||||
"""
|
||||
try:
|
||||
with open(yaml_file, 'r') as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
enemy = EnemyTemplate.from_dict(data)
|
||||
self._enemy_cache[enemy.enemy_id] = enemy
|
||||
|
||||
logger.debug("Enemy loaded", enemy_id=enemy.enemy_id, file=str(yaml_file))
|
||||
return enemy
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to load enemy file",
|
||||
file=str(yaml_file),
|
||||
error=str(e))
|
||||
return None
|
||||
|
||||
def load_all_enemies(self) -> Dict[str, EnemyTemplate]:
|
||||
"""
|
||||
Load all enemy templates from the data directory.
|
||||
|
||||
Searches both the root directory and subdirectories for YAML files.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping enemy_id to EnemyTemplate instance
|
||||
"""
|
||||
if not self.data_dir.exists():
|
||||
logger.warning("Enemy data directory not found", path=str(self.data_dir))
|
||||
return {}
|
||||
|
||||
enemies = {}
|
||||
|
||||
# Load from root directory
|
||||
for yaml_file in self.data_dir.glob("*.yaml"):
|
||||
enemy = self._load_from_file(yaml_file)
|
||||
if enemy:
|
||||
enemies[enemy.enemy_id] = enemy
|
||||
|
||||
# Load from subdirectories (organized by difficulty)
|
||||
for subdir in self.data_dir.iterdir():
|
||||
if subdir.is_dir():
|
||||
for yaml_file in subdir.glob("*.yaml"):
|
||||
enemy = self._load_from_file(yaml_file)
|
||||
if enemy:
|
||||
enemies[enemy.enemy_id] = enemy
|
||||
|
||||
self._loaded = True
|
||||
logger.info("All enemies loaded", count=len(enemies))
|
||||
|
||||
return enemies
|
||||
|
||||
def get_enemies_by_difficulty(
|
||||
self,
|
||||
difficulty: EnemyDifficulty
|
||||
) -> List[EnemyTemplate]:
|
||||
"""
|
||||
Get all enemies matching a difficulty level.
|
||||
|
||||
Args:
|
||||
difficulty: Difficulty level to filter by
|
||||
|
||||
Returns:
|
||||
List of EnemyTemplate instances
|
||||
"""
|
||||
if not self._loaded:
|
||||
self.load_all_enemies()
|
||||
|
||||
return [
|
||||
enemy for enemy in self._enemy_cache.values()
|
||||
if enemy.difficulty == difficulty
|
||||
]
|
||||
|
||||
def get_enemies_by_tag(self, tag: str) -> List[EnemyTemplate]:
|
||||
"""
|
||||
Get all enemies with a specific tag.
|
||||
|
||||
Args:
|
||||
tag: Tag to filter by (e.g., "undead", "beast", "humanoid")
|
||||
|
||||
Returns:
|
||||
List of EnemyTemplate instances with that tag
|
||||
"""
|
||||
if not self._loaded:
|
||||
self.load_all_enemies()
|
||||
|
||||
return [
|
||||
enemy for enemy in self._enemy_cache.values()
|
||||
if enemy.has_tag(tag)
|
||||
]
|
||||
|
||||
def get_random_enemies(
|
||||
self,
|
||||
count: int = 1,
|
||||
difficulty: Optional[EnemyDifficulty] = None,
|
||||
tag: Optional[str] = None,
|
||||
exclude_bosses: bool = True
|
||||
) -> List[EnemyTemplate]:
|
||||
"""
|
||||
Get random enemies for encounter generation.
|
||||
|
||||
Args:
|
||||
count: Number of enemies to select
|
||||
difficulty: Optional difficulty filter
|
||||
tag: Optional tag filter
|
||||
exclude_bosses: Whether to exclude boss enemies
|
||||
|
||||
Returns:
|
||||
List of randomly selected EnemyTemplate instances
|
||||
"""
|
||||
import random
|
||||
|
||||
if not self._loaded:
|
||||
self.load_all_enemies()
|
||||
|
||||
# Build candidate list
|
||||
candidates = list(self._enemy_cache.values())
|
||||
|
||||
# Apply filters
|
||||
if difficulty:
|
||||
candidates = [e for e in candidates if e.difficulty == difficulty]
|
||||
if tag:
|
||||
candidates = [e for e in candidates if e.has_tag(tag)]
|
||||
if exclude_bosses:
|
||||
candidates = [e for e in candidates if not e.is_boss()]
|
||||
|
||||
if not candidates:
|
||||
logger.warning("No enemies match filters",
|
||||
difficulty=difficulty.value if difficulty else None,
|
||||
tag=tag)
|
||||
return []
|
||||
|
||||
# Select random enemies (with replacement if needed)
|
||||
if len(candidates) >= count:
|
||||
return random.sample(candidates, count)
|
||||
else:
|
||||
# Not enough unique enemies, allow duplicates
|
||||
return random.choices(candidates, k=count)
|
||||
|
||||
def clear_cache(self) -> None:
|
||||
"""Clear the enemy cache, forcing reload on next access."""
|
||||
self._enemy_cache.clear()
|
||||
self._loaded = False
|
||||
logger.debug("Enemy cache cleared")
|
||||
|
||||
def get_all_cached(self) -> Dict[str, EnemyTemplate]:
|
||||
"""
|
||||
Get all cached enemies.
|
||||
|
||||
Returns:
|
||||
Dictionary of cached enemy templates
|
||||
"""
|
||||
if not self._loaded:
|
||||
self.load_all_enemies()
|
||||
return self._enemy_cache.copy()
|
||||
|
||||
|
||||
# Global instance for convenience
|
||||
_loader_instance: Optional[EnemyLoader] = None
|
||||
|
||||
|
||||
def get_enemy_loader() -> EnemyLoader:
|
||||
"""
|
||||
Get the global EnemyLoader instance.
|
||||
|
||||
Returns:
|
||||
Singleton EnemyLoader instance
|
||||
"""
|
||||
global _loader_instance
|
||||
if _loader_instance is None:
|
||||
_loader_instance = EnemyLoader()
|
||||
return _loader_instance
|
||||
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