1094 lines
34 KiB
Python
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"
|
|
)
|