- Implement Combat Service - Implement Damage Calculator - Implement Effect Processor - Implement Combat Actions - Created Combat API Endpoints
730 lines
22 KiB
Python
730 lines
22 KiB
Python
"""
|
|
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"
|
|
)
|