""" Combat Views Routes for combat UI. """ from flask import Blueprint, render_template, request, redirect, url_for, make_response import structlog from ..utils.api_client import get_api_client, APIError, APINotFoundError from ..utils.auth import require_auth_web as require_auth logger = structlog.get_logger(__name__) combat_bp = Blueprint('combat', __name__, url_prefix='/combat') @combat_bp.route('/') @require_auth def combat_view(session_id: str): """ Render the combat page for an active encounter. Displays the 3-column combat interface with: - Left: Combatants (player + enemies) with HP/MP bars - Center: Combat log + action buttons - Right: Turn order + active effects """ client = get_api_client() try: # Get combat state from API response = client.get(f'/api/v1/combat/{session_id}/state') result = response.get('result', {}) # Check if combat is still active if not result.get('in_combat'): # Combat ended - redirect to game play return redirect(url_for('game.play_session', session_id=session_id)) encounter = result.get('encounter') or {} combat_log = result.get('combat_log', []) # Get current turn combatant ID directly from API response current_turn_id = encounter.get('current_turn') # Find if it's the player's turn is_player_turn = False player_combatant = None for combatant in encounter.get('combatants', []): if combatant.get('is_player'): player_combatant = combatant if combatant.get('combatant_id') == current_turn_id: is_player_turn = True break # Format combat log entries for display formatted_log = [] for entry in combat_log: log_entry = { 'actor': entry.get('combatant_name', entry.get('actor', '')), 'message': entry.get('message', ''), 'damage': entry.get('damage'), 'heal': entry.get('healing'), 'is_crit': entry.get('is_critical', False), 'type': 'player' if entry.get('is_player', False) else 'enemy' } # Detect system messages if entry.get('action_type') in ('round_start', 'combat_start', 'combat_end'): log_entry['type'] = 'system' formatted_log.append(log_entry) return render_template( 'game/combat.html', session_id=session_id, encounter=encounter, combat_log=formatted_log, current_turn_id=current_turn_id, is_player_turn=is_player_turn, player_combatant=player_combatant ) except APINotFoundError: logger.warning("combat_not_found", session_id=session_id) return render_template('errors/404.html', message="No active combat encounter"), 404 except APIError as e: logger.error("failed_to_load_combat", session_id=session_id, error=str(e)) return render_template('errors/500.html', message=str(e)), 500 @combat_bp.route('//action', methods=['POST']) @require_auth def combat_action(session_id: str): """ Execute a combat action (attack, defend, ability, item). Returns updated combat log entries. """ client = get_api_client() action_type = request.form.get('action_type', 'attack') ability_id = request.form.get('ability_id') item_id = request.form.get('item_id') target_id = request.form.get('target_id') try: # Build action payload payload = { 'action_type': action_type } if ability_id: payload['ability_id'] = ability_id if item_id: payload['item_id'] = item_id if target_id: payload['target_id'] = target_id # POST action to API response = client.post(f'/api/v1/combat/{session_id}/action', payload) result = response.get('result', {}) # Check if combat ended combat_ended = result.get('combat_ended', False) combat_status = result.get('combat_status') if combat_ended: # API returns lowercase status values: 'victory', 'defeat', 'fled' status_lower = (combat_status or '').lower() if status_lower == 'victory': return render_template( 'game/partials/combat_victory.html', session_id=session_id, rewards=result.get('rewards', {}) ) elif status_lower == 'defeat': return render_template( 'game/partials/combat_defeat.html', session_id=session_id, gold_lost=result.get('gold_lost', 0), can_retry=result.get('can_retry', False) ) # Format action result for log display # API returns data directly in result, not nested under 'action_result' log_entries = [] # Player action entry player_entry = { 'actor': 'You', 'message': result.get('message', f'used {action_type}'), 'type': 'player', 'is_crit': False } # Add damage info if present damage_results = result.get('damage_results', []) if damage_results: for dmg in damage_results: player_entry['damage'] = dmg.get('total_damage') or dmg.get('damage') player_entry['is_crit'] = dmg.get('is_critical', False) if player_entry['is_crit']: player_entry['type'] = 'crit' # Add healing info if present if result.get('healing'): player_entry['heal'] = result.get('healing') player_entry['type'] = 'heal' log_entries.append(player_entry) # Add any effect entries for effect in result.get('effects_applied', []): # API may use "name" or "effect" key for the effect name effect_name = effect.get('name') or effect.get('effect') or 'Unknown' log_entries.append({ 'actor': '', 'message': effect.get('message', f'Effect applied: {effect_name}'), 'type': 'system' }) # Return log entries HTML resp = make_response(render_template( 'game/partials/combat_log.html', combat_log=log_entries )) # Trigger enemy turn if it's no longer player's turn next_combatant = result.get('next_combatant_id') if next_combatant and not result.get('next_is_player', True): resp.headers['HX-Trigger'] = 'enemyTurn' return resp except APIError as e: logger.error("combat_action_failed", session_id=session_id, action_type=action_type, error=str(e)) return f'''
Action failed: {e}
''', 500 @combat_bp.route('//abilities') @require_auth def combat_abilities(session_id: str): """Get abilities modal for combat.""" client = get_api_client() try: # Get combat state to get player's abilities response = client.get(f'/api/v1/combat/{session_id}/state') result = response.get('result', {}) encounter = result.get('encounter', {}) # Find player combatant player_combatant = None for combatant in encounter.get('combatants', []): if combatant.get('is_player'): player_combatant = combatant break # Get abilities from player combatant or character abilities = [] if player_combatant: ability_ids = player_combatant.get('abilities', []) current_mp = player_combatant.get('current_mp', 0) cooldowns = player_combatant.get('cooldowns', {}) # Fetch ability details (if API has ability endpoint) for ability_id in ability_ids: try: ability_response = client.get(f'/api/v1/abilities/{ability_id}') ability_data = ability_response.get('result', {}) # Check availability mp_cost = ability_data.get('mp_cost', 0) cooldown = cooldowns.get(ability_id, 0) available = current_mp >= mp_cost and cooldown == 0 abilities.append({ 'id': ability_id, 'name': ability_data.get('name', ability_id), 'description': ability_data.get('description', ''), 'mp_cost': mp_cost, 'cooldown': cooldown, 'max_cooldown': ability_data.get('cooldown', 0), 'damage_type': ability_data.get('damage_type'), 'effect_type': ability_data.get('effect_type'), 'available': available }) except (APINotFoundError, APIError): # Ability not found, add basic entry abilities.append({ 'id': ability_id, 'name': ability_id.replace('_', ' ').title(), 'description': '', 'mp_cost': 0, 'cooldown': cooldowns.get(ability_id, 0), 'max_cooldown': 0, 'available': True }) return render_template( 'game/partials/ability_modal.html', session_id=session_id, abilities=abilities ) except APIError as e: logger.error("failed_to_load_abilities", session_id=session_id, error=str(e)) return f''' ''' @combat_bp.route('//items') @require_auth def combat_items(session_id: str): """ Get combat items bottom sheet (consumables only). Returns a bottom sheet UI with only consumable items that can be used in combat. """ client = get_api_client() try: # Get session to find character session_response = client.get(f'/api/v1/sessions/{session_id}') session_data = session_response.get('result', {}) character_id = session_data.get('character_id') # Get character inventory - filter to consumables only consumables = [] if character_id: try: inv_response = client.get(f'/api/v1/characters/{character_id}/inventory') inv_data = inv_response.get('result', {}) inventory = inv_data.get('inventory', []) # Filter to consumable items only for item in inventory: item_type = item.get('item_type', item.get('type', '')) if item_type == 'consumable' or item.get('usable_in_combat', False): consumables.append({ 'item_id': item.get('item_id'), 'name': item.get('name', 'Unknown Item'), 'description': item.get('description', ''), 'effects_on_use': item.get('effects_on_use', []), 'rarity': item.get('rarity', 'common') }) except (APINotFoundError, APIError) as e: logger.warning("failed_to_load_combat_inventory", character_id=character_id, error=str(e)) return render_template( 'game/partials/combat_items_sheet.html', session_id=session_id, consumables=consumables, has_consumables=len(consumables) > 0 ) except APIError as e: logger.error("failed_to_load_items", session_id=session_id, error=str(e)) return f'''

Use Item

Failed to load items: {e}
''' @combat_bp.route('//items//detail') @require_auth def combat_item_detail(session_id: str, item_id: str): """Get item detail for combat bottom sheet.""" client = get_api_client() try: # Get session to find character session_response = client.get(f'/api/v1/sessions/{session_id}') session_data = session_response.get('result', {}) character_id = session_data.get('character_id') item = None if character_id: # Get inventory and find the item inv_response = client.get(f'/api/v1/characters/{character_id}/inventory') inv_data = inv_response.get('result', {}) inventory = inv_data.get('inventory', []) for inv_item in inventory: if inv_item.get('item_id') == item_id: item = inv_item break if not item: return '

Item not found

', 404 # Format effect description effect_desc = item.get('description', 'Use this item') effects = item.get('effects_on_use', []) if effects: effect_parts = [] for effect in effects: if effect.get('stat') == 'hp': effect_parts.append(f"Restores {effect.get('value', 0)} HP") elif effect.get('stat') == 'mp': effect_parts.append(f"Restores {effect.get('value', 0)} MP") elif effect.get('name'): effect_parts.append(effect.get('name')) if effect_parts: effect_desc = ', '.join(effect_parts) return f'''
{item.get('name', 'Item')}
{effect_desc}
''' except APIError as e: logger.error("failed_to_load_combat_item_detail", session_id=session_id, item_id=item_id, error=str(e)) return f'

Failed to load item: {e}

', 500 @combat_bp.route('//flee', methods=['POST']) @require_auth def combat_flee(session_id: str): """Attempt to flee from combat.""" client = get_api_client() try: response = client.post(f'/api/v1/combat/{session_id}/flee', {}) result = response.get('result', {}) if result.get('success'): # Flee successful - use HX-Redirect for HTMX resp = make_response(f'''
{result.get('message', 'You fled from combat!')}
''') resp.headers['HX-Redirect'] = url_for('game.play_session', session_id=session_id) return resp else: # Flee failed - return log entry, trigger enemy turn resp = make_response(f'''
{result.get('message', 'Failed to flee!')}
''') # Failed flee consumes turn, so trigger enemy turn if needed if not result.get('next_is_player', True): resp.headers['HX-Trigger'] = 'enemyTurn' return resp except APIError as e: logger.error("flee_failed", session_id=session_id, error=str(e)) return f'''
Flee failed: {e}
''', 500 @combat_bp.route('//enemy-turn', methods=['POST']) @require_auth def combat_enemy_turn(session_id: str): """Execute enemy turn and return result.""" client = get_api_client() try: response = client.post(f'/api/v1/combat/{session_id}/enemy-turn', {}) result = response.get('result', {}) # Check if combat ended combat_ended = result.get('combat_ended', False) combat_status = result.get('combat_status') if combat_ended: # API returns lowercase status values: 'victory', 'defeat', 'fled' status_lower = (combat_status or '').lower() if status_lower == 'victory': return render_template( 'game/partials/combat_victory.html', session_id=session_id, rewards=result.get('rewards', {}) ) elif status_lower == 'defeat': return render_template( 'game/partials/combat_defeat.html', session_id=session_id, gold_lost=result.get('gold_lost', 0), can_retry=result.get('can_retry', False) ) # Format enemy action for log # API returns ActionResult directly in result, not nested under action_result log_entries = [{ 'actor': 'Enemy', 'message': result.get('message', 'attacks'), 'type': 'enemy', 'is_crit': False }] # Add damage info - API returns total_damage, not damage damage_results = result.get('damage_results', []) if damage_results: log_entries[0]['damage'] = damage_results[0].get('total_damage') log_entries[0]['is_crit'] = damage_results[0].get('is_critical', False) # Check if it's still enemy turn (multiple enemies) resp = make_response(render_template( 'game/partials/combat_log.html', combat_log=log_entries )) # If next combatant is also an enemy, trigger another enemy turn if result.get('next_combatant_id') and not result.get('next_is_player', True): resp.headers['HX-Trigger'] = 'enemyTurn' return resp except APIError as e: logger.error("enemy_turn_failed", session_id=session_id, error=str(e)) return f'''
Enemy turn error: {e}
''', 500 @combat_bp.route('//log') @require_auth def combat_log(session_id: str): """Get current combat log.""" client = get_api_client() try: response = client.get(f'/api/v1/combat/{session_id}/state') result = response.get('result', {}) combat_log_data = result.get('combat_log', []) # Format log entries formatted_log = [] for entry in combat_log_data: log_entry = { 'actor': entry.get('combatant_name', entry.get('actor', '')), 'message': entry.get('message', ''), 'damage': entry.get('damage'), 'heal': entry.get('healing'), 'is_crit': entry.get('is_critical', False), 'type': 'player' if entry.get('is_player', False) else 'enemy' } if entry.get('action_type') in ('round_start', 'combat_start', 'combat_end'): log_entry['type'] = 'system' formatted_log.append(log_entry) return render_template( 'game/partials/combat_log.html', combat_log=formatted_log ) except APIError as e: logger.error("failed_to_load_combat_log", session_id=session_id, error=str(e)) return '
Failed to load combat log
', 500 @combat_bp.route('//results') @require_auth def combat_results(session_id: str): """Display combat results (victory/defeat).""" client = get_api_client() try: response = client.get(f'/api/v1/combat/{session_id}/results') results = response.get('result', {}) return render_template( 'game/combat_results.html', victory=results['victory'], xp_gained=results['xp_gained'], gold_gained=results['gold_gained'], loot=results['loot'] ) except APIError as e: logger.error("failed_to_load_combat_results", session_id=session_id, error=str(e)) return redirect(url_for('game.play_session', session_id=session_id))