Files
Code_of_Conquest/api/app/api/combat.py

1094 lines
34 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.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=400,
message=str(e),
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=500,
message="Failed to start combat",
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.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=500,
message="Failed to get combat state",
code="COMBAT_STATE_ERROR"
)
@combat_bp.route('/<session_id>/check', methods=['GET'])
@require_auth
def check_existing_combat(session_id: str):
"""
Check if session has an existing active combat encounter.
Used to detect stale combat sessions when a player tries to start new
combat. Returns a summary of the existing combat if present.
Path Parameters:
session_id: Game session ID
Returns:
If in combat:
{
"has_active_combat": true,
"encounter_id": "enc_abc123",
"round_number": 3,
"status": "active",
"players": [
{"name": "Hero", "current_hp": 45, "max_hp": 100, "is_alive": true}
],
"enemies": [
{"name": "Goblin", "current_hp": 10, "max_hp": 25, "is_alive": true}
]
}
If not in combat:
{
"has_active_combat": false
}
"""
user = get_current_user()
try:
combat_service = get_combat_service()
result = combat_service.check_existing_combat(session_id, user.id)
if result:
return success_response(result)
else:
return success_response({"has_active_combat": False})
except Exception as e:
logger.error("Failed to check existing combat",
session_id=session_id,
error=str(e),
exc_info=True)
return error_response(
status=500,
message="Failed to check combat status",
code="COMBAT_CHECK_ERROR"
)
@combat_bp.route('/<session_id>/abandon', methods=['POST'])
@require_auth
def abandon_combat(session_id: str):
"""
Abandon an existing combat encounter without completing it.
Deletes the encounter from the database and clears the session reference.
No rewards are distributed. Used when a player wants to discard a stale
combat session and start fresh.
Path Parameters:
session_id: Game session ID
Returns:
{
"success": true,
"message": "Combat abandoned"
}
or if no combat existed:
{
"success": false,
"message": "No active combat to abandon"
}
"""
user = get_current_user()
try:
combat_service = get_combat_service()
abandoned = combat_service.abandon_combat(session_id, user.id)
if abandoned:
logger.info("Combat abandoned via API",
session_id=session_id)
return success_response({
"success": True,
"message": "Combat abandoned"
})
else:
return success_response({
"success": False,
"message": "No active combat to abandon"
})
except Exception as e:
logger.error("Failed to abandon combat",
session_id=session_id,
error=str(e),
exc_info=True)
return error_response(
status=500,
message="Failed to abandon combat",
code="COMBAT_ABANDON_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 combatant_id not provided, auto-detect player combatant
if not combatant_id:
try:
combat_service = get_combat_service()
encounter = combat_service.get_combat_state(session_id, user.id)
if encounter:
for combatant in encounter.combatants:
if combatant.is_player:
combatant_id = combatant.combatant_id
break
if not combatant_id:
return validation_error_response(
message="Could not determine player combatant",
details={"field": "combatant_id", "issue": "No player found in combat"}
)
except Exception as e:
logger.error("Failed to auto-detect combatant", error=str(e))
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()
# Support both target_id (singular) and target_ids (array)
target_ids = data.get("target_ids", [])
if not target_ids and data.get("target_id"):
target_ids = [data.get("target_id")]
action = CombatAction(
action_type=action_type,
target_ids=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.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=400,
message=str(e),
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=400,
message=str(e),
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=500,
message="Failed to execute action",
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.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=400,
message=str(e),
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=500,
message="Failed to execute enemy turn",
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()
# If combatant_id not provided, auto-detect player combatant
if not combatant_id:
encounter = combat_service.get_combat_state(session_id, user.id)
if encounter:
for combatant in encounter.combatants:
if combatant.is_player:
combatant_id = combatant.combatant_id
break
if not combatant_id:
return validation_error_response(
message="Could not determine player combatant",
details={"field": "combatant_id", "issue": "No player found in combat"}
)
action = CombatAction(
action_type="flee",
target_ids=[],
)
result = combat_service.execute_action(
session_id=session_id,
user_id=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=400,
message=str(e),
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=500,
message="Failed to attempt flee",
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.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=500,
message="Failed to end combat",
code="COMBAT_END_ERROR"
)
# =============================================================================
# Utility Endpoints
# =============================================================================
@combat_bp.route('/encounters', methods=['GET'])
@require_auth
def get_encounters():
"""
Get random encounter options for the current location.
Generates multiple encounter groups appropriate for the player's
current location and character level. Used by the "Search for Monsters"
feature on the story page.
Query Parameters:
session_id: Game session ID (required)
Returns:
{
"location_name": "Thornwood Forest",
"location_type": "wilderness",
"encounters": [
{
"group_id": "enc_abc123",
"enemies": ["goblin", "goblin", "goblin_scout"],
"enemy_names": ["Goblin Scout", "Goblin Scout", "Goblin Scout"],
"display_name": "3 Goblin Scouts",
"challenge": "Easy"
},
...
]
}
Errors:
400: Missing session_id
404: Session not found or no character
404: No enemies found for location
"""
from app.services.session_service import get_session_service
from app.services.character_service import get_character_service
from app.services.encounter_generator import get_encounter_generator
user = get_current_user()
# Get session_id from query params
session_id = request.args.get("session_id")
if not session_id:
return validation_error_response(
message="session_id query parameter is required",
details={"field": "session_id", "issue": "Missing required parameter"}
)
try:
# Get session to find location and character
session_service = get_session_service()
session = session_service.get_session(session_id, user.id)
if not session:
return not_found_response(message=f"Session not found: {session_id}")
# Get character level
character_id = session.get_character_id()
if not character_id:
return not_found_response(message="No character found in session")
character_service = get_character_service()
character = character_service.get_character(character_id, user.id)
if not character:
return not_found_response(message=f"Character not found: {character_id}")
# Get location info from game state
location_name = session.game_state.current_location
location_type = session.game_state.location_type.value
# Map location types to enemy location_tags
# Some location types may need mapping to available enemy tags
location_type_mapping = {
"town": "town",
"village": "town", # Treat village same as town
"tavern": "tavern",
"wilderness": "wilderness",
"forest": "forest",
"dungeon": "dungeon",
"ruins": "ruins",
"crypt": "crypt",
"road": "road",
"safe_area": "town", # Safe areas might have rats/vermin
"library": "dungeon", # Libraries might have undead guardians
}
mapped_location = location_type_mapping.get(location_type, location_type)
# Generate encounters
encounter_generator = get_encounter_generator()
encounters = encounter_generator.generate_encounters(
location_type=mapped_location,
character_level=character.level,
num_encounters=4
)
# If no encounters found, try wilderness as fallback
if not encounters and mapped_location != "wilderness":
encounters = encounter_generator.generate_encounters(
location_type="wilderness",
character_level=character.level,
num_encounters=4
)
if not encounters:
return not_found_response(
message=f"No enemies found for location type: {location_type}"
)
# Format response
response_data = {
"location_name": location_name,
"location_type": location_type,
"encounters": [enc.to_dict() for enc in encounters]
}
logger.info("Generated encounter options",
session_id=session_id,
location_type=location_type,
character_level=character.level,
num_encounters=len(encounters))
return success_response(response_data)
except Exception as e:
logger.error("Failed to generate encounters",
session_id=session_id,
error=str(e),
exc_info=True)
return error_response(
status=500,
message="Failed to generate encounters",
code="ENCOUNTER_GENERATION_ERROR"
)
@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=500,
message="Failed to list enemies",
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=500,
message="Failed to get enemy details",
code="ENEMY_DETAILS_ERROR"
)
# =============================================================================
# Debug Endpoints
# =============================================================================
@combat_bp.route('/<session_id>/debug/reset-hp-mp', methods=['POST'])
@require_auth
def debug_reset_hp_mp(session_id: str):
"""
Reset player combatant's HP and MP to full (debug endpoint).
This is a debug-only endpoint for testing combat without using items.
Resets the player's current_hp to max_hp and current_mp to max_mp.
Path Parameters:
session_id: Game session ID
Returns:
{
"success": true,
"message": "HP and MP reset to full",
"current_hp": 100,
"max_hp": 100,
"current_mp": 50,
"max_mp": 50
}
Errors:
404: Session not in combat
"""
from app.services.session_service import get_session_service
user = get_current_user()
try:
session_service = get_session_service()
session = session_service.get_session(session_id, user.id)
if not session or not session.combat_encounter:
return not_found_response(message="Session is not in combat")
encounter = session.combat_encounter
# Find player combatant and reset HP/MP
player_combatant = None
for combatant in encounter.combatants:
if combatant.is_player:
combatant.current_hp = combatant.max_hp
combatant.current_mp = combatant.max_mp
player_combatant = combatant
break
if not player_combatant:
return not_found_response(message="No player combatant found in combat")
# Save the updated session state
session_service.update_session(session)
logger.info("Debug: HP/MP reset",
session_id=session_id,
combatant_id=player_combatant.combatant_id)
return success_response({
"success": True,
"message": "HP and MP reset to full",
"current_hp": player_combatant.current_hp,
"max_hp": player_combatant.max_hp,
"current_mp": player_combatant.current_mp,
"max_mp": player_combatant.max_mp,
})
except Exception as e:
logger.error("Failed to reset HP/MP",
session_id=session_id,
error=str(e),
exc_info=True)
return error_response(
status=500,
message="Failed to reset HP/MP",
code="DEBUG_RESET_ERROR"
)