Files
Code_of_Conquest/api/app/api/combat.py
Phillip Tarrant 03ab783eeb Combat Backend & Data Models
- Implement Combat Service
- Implement Damage Calculator
- Implement Effect Processor
- Implement Combat Actions
- Created Combat API Endpoints
2025-11-26 15:43:20 -06:00

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"
)