""" Production game views for the play screen. Provides the main gameplay interface with 3-column layout: - Left: Character stats + action buttons - Middle: Narrative + location context - Right: Accordions for history, quests, NPCs, map """ from flask import Blueprint, render_template, request, redirect, url_for import structlog from ..utils.api_client import get_api_client, APIError, APINotFoundError from ..utils.auth import require_auth_web as require_auth, get_current_user logger = structlog.get_logger(__name__) game_bp = Blueprint('game', __name__, url_prefix='/play') # ===== Action Definitions ===== # Actions organized by tier - context filtering happens in template # These are static definitions, available actions come from API session state DEFAULT_ACTIONS = { 'free': [ {'prompt_id': 'ask_locals', 'display_text': 'Ask Locals for Information', 'icon': 'chat', 'context': ['town', 'tavern']}, {'prompt_id': 'search_supplies', 'display_text': 'Search for Supplies', 'icon': 'search', 'context': ['any'], 'cooldown': 2}, {'prompt_id': 'rest_recover', 'display_text': 'Rest and Recover', 'icon': 'bed', 'context': ['town', 'tavern', 'safe_area'], 'cooldown': 3} ], 'premium': [ {'prompt_id': 'investigate_suspicious', 'display_text': 'Investigate Suspicious Activity', 'icon': 'magnifying_glass', 'context': ['any']}, {'prompt_id': 'follow_lead', 'display_text': 'Follow a Lead', 'icon': 'footprints', 'context': ['any']}, {'prompt_id': 'make_camp', 'display_text': 'Make Camp', 'icon': 'campfire', 'context': ['wilderness'], 'cooldown': 5} ], 'elite': [ {'prompt_id': 'consult_texts', 'display_text': 'Consult Ancient Texts', 'icon': 'book', 'context': ['library', 'town'], 'cooldown': 3}, {'prompt_id': 'commune_nature', 'display_text': 'Commune with Nature', 'icon': 'leaf', 'context': ['wilderness'], 'cooldown': 4}, {'prompt_id': 'seek_audience', 'display_text': 'Seek Audience with Authorities', 'icon': 'crown', 'context': ['town'], 'cooldown': 5} ] } def _get_user_tier(client) -> str: """Get user's subscription tier from API or session.""" try: # Try to get user info which includes tier user_response = client.get('/api/v1/auth/me') user_data = user_response.get('result', {}) return user_data.get('tier', 'free') except (APIError, APINotFoundError): # Default to free tier if we can't determine return 'free' def _build_location_from_game_state(game_state: dict) -> dict: """Build location dict from game_state data.""" return { 'location_id': game_state.get('current_location_id') or game_state.get('current_location'), 'name': game_state.get('current_location_name', game_state.get('current_location', 'Unknown')), 'location_type': game_state.get('location_type', 'unknown'), 'region': game_state.get('region', ''), 'description': game_state.get('location_description', ''), 'ambient_description': game_state.get('ambient_description', '') } def _build_character_from_api(char_data: dict) -> dict: """ Build character dict from API character response. Always returns a dict with all required fields, using sensible defaults if the API data is incomplete or empty. """ if not char_data: char_data = {} # Extract stats from base_stats or stats, with defaults stats = char_data.get('base_stats', char_data.get('stats', {})) if not stats: stats = { 'strength': 10, 'dexterity': 10, 'constitution': 10, 'intelligence': 10, 'wisdom': 10, 'charisma': 10 } # Calculate HP/MP - these may come from different places # For now use defaults based on level/constitution level = char_data.get('level', 1) constitution = stats.get('constitution', 10) intelligence = stats.get('intelligence', 10) # Simple HP/MP calculation (can be refined based on game rules) max_hp = max(1, 50 + (level * 10) + ((constitution - 10) * level)) max_mp = max(1, 20 + (level * 5) + ((intelligence - 10) * level // 2)) # Get class name from various possible locations class_name = 'Unknown' if char_data.get('player_class'): class_name = char_data['player_class'].get('name', 'Unknown') elif char_data.get('class_name'): class_name = char_data['class_name'] elif char_data.get('class'): class_name = char_data['class'].replace('_', ' ').title() return { 'character_id': char_data.get('character_id', ''), 'name': char_data.get('name', 'Unknown Hero'), 'class_name': class_name, 'level': level, 'current_hp': char_data.get('current_hp', max_hp), 'max_hp': char_data.get('max_hp', max_hp), 'current_mp': char_data.get('current_mp', max_mp), 'max_mp': char_data.get('max_mp', max_mp), 'stats': stats, 'equipped': char_data.get('equipped', {}), 'inventory': char_data.get('inventory', []), 'gold': char_data.get('gold', 0), 'experience': char_data.get('experience', 0), 'unlocked_skills': char_data.get('unlocked_skills', []) } # ===== Main Routes ===== @game_bp.route('/session/') @require_auth def play_session(session_id: str): """ Production play screen for a game session. Displays 3-column layout with character panel, narrative area, and sidebar accordions for history/quests/NPCs/map. """ client = get_api_client() try: # Get session state (includes game_state with location info) session_response = client.get(f'/api/v1/sessions/{session_id}') session_data = session_response.get('result', {}) # Extract game state and build location info game_state = session_data.get('game_state', {}) location = _build_location_from_game_state(game_state) # Get character details - always build a valid character dict character_id = session_data.get('character_id') char_data = {} if character_id: try: char_response = client.get(f'/api/v1/characters/{character_id}') char_data = char_response.get('result', {}) except (APINotFoundError, APIError) as e: logger.warning("failed_to_load_character", character_id=character_id, error=str(e)) # Always build character with defaults for any missing fields character = _build_character_from_api(char_data) # Get session history (last DM response for display) history = [] dm_response = "Your adventure awaits..." try: history_response = client.get(f'/api/v1/sessions/{session_id}/history?limit=10') history_data = history_response.get('result', {}) history = history_data.get('history', []) # Get the most recent DM response for the main narrative panel if history: dm_response = history[0].get('dm_response', dm_response) except (APINotFoundError, APIError) as e: logger.warning("failed_to_load_history", session_id=session_id, error=str(e)) # Get NPCs at current location npcs = [] current_location_id = location.get('location_id') if current_location_id: try: npcs_response = client.get(f'/api/v1/npcs/at-location/{current_location_id}') npcs = npcs_response.get('result', {}).get('npcs', []) except (APINotFoundError, APIError) as e: logger.debug("no_npcs_at_location", location_id=current_location_id, error=str(e)) # Get available travel destinations (discovered locations) discovered_locations = [] try: travel_response = client.get(f'/api/v1/travel/available?session_id={session_id}') travel_result = travel_response.get('result', {}) discovered_locations = travel_result.get('available_locations', []) # Mark current location for loc in discovered_locations: loc['is_current'] = loc.get('location_id') == current_location_id except (APINotFoundError, APIError) as e: logger.debug("failed_to_load_travel_destinations", session_id=session_id, error=str(e)) # Get quests (from character's active_quests or session) quests = game_state.get('active_quests', []) # If quests are just IDs, we could expand them, but for now use what we have # Get user tier user_tier = _get_user_tier(client) # Fetch usage info for daily turn limits usage_info = {} try: usage_response = client.get("/api/v1/usage") usage_info = usage_response.get('result', {}) except (APINotFoundError, APIError) as e: logger.debug("could_not_fetch_usage_info", error=str(e)) # Build session object for template session = { 'session_id': session_id, 'turn_number': session_data.get('turn_number', 0), 'status': session_data.get('status', 'active') } return render_template( 'game/play.html', session_id=session_id, session=session, character=character, location=location, dm_response=dm_response, history=history, quests=quests, npcs=npcs, discovered_locations=discovered_locations, actions=DEFAULT_ACTIONS, user_tier=user_tier, # Usage display variables remaining=usage_info.get('remaining', 0), daily_limit=usage_info.get('daily_limit', 0), is_limited=usage_info.get('is_limited', False), is_unlimited=usage_info.get('is_unlimited', False), reset_time=usage_info.get('reset_time', '') ) except APINotFoundError: logger.warning("session_not_found", session_id=session_id) return render_template('errors/404.html', message=f"Session {session_id} not found"), 404 except APIError as e: logger.error("failed_to_load_play_session", session_id=session_id, error=str(e)) return render_template('errors/500.html', message=str(e)), 500 # ===== Partial Refresh Routes ===== @game_bp.route('/session//character-panel') @require_auth def character_panel(session_id: str): """Refresh character stats and actions panel.""" client = get_api_client() try: # Get session to find character and location session_response = client.get(f'/api/v1/sessions/{session_id}') session_data = session_response.get('result', {}) game_state = session_data.get('game_state', {}) location = _build_location_from_game_state(game_state) # Get character - always build valid character dict char_data = {} character_id = session_data.get('character_id') if character_id: try: char_response = client.get(f'/api/v1/characters/{character_id}') char_data = char_response.get('result', {}) except (APINotFoundError, APIError): pass character = _build_character_from_api(char_data) user_tier = _get_user_tier(client) # Fetch usage info for daily turn limits usage_info = {} try: usage_response = client.get("/api/v1/usage") usage_info = usage_response.get('result', {}) except (APINotFoundError, APIError) as e: logger.debug("could_not_fetch_usage_info", error=str(e)) return render_template( 'game/partials/character_panel.html', session_id=session_id, character=character, location=location, actions=DEFAULT_ACTIONS, user_tier=user_tier, # Usage display variables remaining=usage_info.get('remaining', 0), daily_limit=usage_info.get('daily_limit', 0), is_limited=usage_info.get('is_limited', False), is_unlimited=usage_info.get('is_unlimited', False), reset_time=usage_info.get('reset_time', '') ) except APIError as e: logger.error("failed_to_refresh_character_panel", session_id=session_id, error=str(e)) return f'
Failed to load character panel: {e}
', 500 @game_bp.route('/session//narrative') @require_auth def narrative_panel(session_id: str): """Refresh narrative content panel.""" client = get_api_client() try: # Get session state session_response = client.get(f'/api/v1/sessions/{session_id}') session_data = session_response.get('result', {}) game_state = session_data.get('game_state', {}) location = _build_location_from_game_state(game_state) # Get latest DM response from history dm_response = "Your adventure awaits..." try: history_response = client.get(f'/api/v1/sessions/{session_id}/history?limit=1') history_data = history_response.get('result', {}) history = history_data.get('history', []) if history: dm_response = history[0].get('dm_response', dm_response) except (APINotFoundError, APIError): pass session = { 'session_id': session_id, 'turn_number': session_data.get('turn_number', 0), 'status': session_data.get('status', 'active') } return render_template( 'game/partials/narrative_panel.html', session_id=session_id, session=session, location=location, dm_response=dm_response ) except APIError as e: logger.error("failed_to_refresh_narrative", session_id=session_id, error=str(e)) return f'
Failed to load narrative: {e}
', 500 @game_bp.route('/session//history') @require_auth def history_accordion(session_id: str): """Refresh history accordion content.""" client = get_api_client() try: history_response = client.get(f'/api/v1/sessions/{session_id}/history?limit=20') history_data = history_response.get('result', {}) history = history_data.get('history', []) return render_template( 'game/partials/sidebar_history.html', session_id=session_id, history=history ) except APIError as e: logger.error("failed_to_refresh_history", session_id=session_id, error=str(e)) return f'
Failed to load history: {e}
', 500 @game_bp.route('/session//quests') @require_auth def quests_accordion(session_id: str): """ Refresh quests accordion content. Fetches full quest data with progress from the character's quest states, enriching each quest with objective progress information. """ 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') enriched_quests = [] if character_id: try: # Get character's quests with progress quests_response = client.get(f'/api/v1/quests/characters/{character_id}/quests') quests_data = quests_response.get('result', {}) active_quests = quests_data.get('active_quests', []) # Process each quest to add display-friendly data for quest in active_quests: progress_data = quest.get('progress', {}) objectives_progress = progress_data.get('objectives_progress', {}) # Enrich objectives with progress enriched_objectives = [] all_complete = True for obj in quest.get('objectives', []): obj_id = obj.get('objective_id', obj.get('description', '')) current = objectives_progress.get(obj_id, 0) # Parse required from progress_text or use default progress_text = obj.get('progress_text', '0/1') required = int(progress_text.split('/')[1]) if '/' in progress_text else 1 is_complete = current >= required if not is_complete: all_complete = False enriched_objectives.append({ 'description': obj.get('description', ''), 'current': current, 'required': required, 'is_complete': is_complete }) enriched_quests.append({ 'quest_id': quest.get('quest_id', ''), 'name': quest.get('name', 'Unknown Quest'), 'description': quest.get('description', ''), 'difficulty': quest.get('difficulty', 'easy'), 'quest_giver': quest.get('quest_giver_name', ''), 'objectives': enriched_objectives, 'rewards': quest.get('rewards', {}), 'is_complete': all_complete }) except (APINotFoundError, APIError) as e: logger.warning("failed_to_load_character_quests", character_id=character_id, error=str(e)) return render_template( 'game/partials/sidebar_quests.html', session_id=session_id, quests=enriched_quests ) except APIError as e: logger.error("failed_to_refresh_quests", session_id=session_id, error=str(e)) return f'
Failed to load quests: {e}
', 500 @game_bp.route('/session//npcs') @require_auth def npcs_accordion(session_id: str): """Refresh NPCs accordion content.""" client = get_api_client() try: # Get session to find current location session_response = client.get(f'/api/v1/sessions/{session_id}') session_data = session_response.get('result', {}) game_state = session_data.get('game_state', {}) current_location_id = game_state.get('current_location_id') or game_state.get('current_location') # Get NPCs at location npcs = [] if current_location_id: try: npcs_response = client.get(f'/api/v1/npcs/at-location/{current_location_id}') npcs = npcs_response.get('result', {}).get('npcs', []) except (APINotFoundError, APIError): pass return render_template( 'game/partials/sidebar_npcs.html', session_id=session_id, npcs=npcs ) except APIError as e: logger.error("failed_to_refresh_npcs", session_id=session_id, error=str(e)) return f'
Failed to load NPCs: {e}
', 500 @game_bp.route('/session//map') @require_auth def map_accordion(session_id: str): """Refresh map accordion content.""" client = get_api_client() try: # Get session for current location session_response = client.get(f'/api/v1/sessions/{session_id}') session_data = session_response.get('result', {}) game_state = session_data.get('game_state', {}) current_location = _build_location_from_game_state(game_state) current_location_id = current_location.get('location_id') # Get available travel destinations discovered_locations = [] try: travel_response = client.get(f'/api/v1/travel/available?session_id={session_id}') travel_result = travel_response.get('result', {}) discovered_locations = travel_result.get('available_locations', []) # Mark current location for loc in discovered_locations: loc['is_current'] = loc.get('location_id') == current_location_id except (APINotFoundError, APIError): pass return render_template( 'game/partials/sidebar_map.html', session_id=session_id, discovered_locations=discovered_locations, current_location=current_location ) except APIError as e: logger.error("failed_to_refresh_map", session_id=session_id, error=str(e)) return f'
Failed to load map: {e}
', 500 # ===== Action Routes ===== @game_bp.route('/session//action', methods=['POST']) @require_auth def take_action(session_id: str): """ Submit an action - returns job polling partial. Handles two action types: - 'button': Predefined action via prompt_id - 'custom': Free-form player text input """ client = get_api_client() action_type = request.form.get('action_type', 'button') try: # Build payload based on action type payload = {'action_type': action_type} if action_type == 'text' or action_type == 'custom': # Free-form text action from player input action_text = request.form.get('action_text', request.form.get('custom_text', '')).strip() if not action_text: return '
Please enter an action.
', 400 logger.info("Player text action submitted", session_id=session_id, action_text=action_text[:100]) payload['action_type'] = 'custom' payload['custom_text'] = action_text player_action = action_text else: # Button action via prompt_id prompt_id = request.form.get('prompt_id') if not prompt_id: return '
No action selected.
', 400 logger.info("Player button action submitted", session_id=session_id, prompt_id=prompt_id) payload['prompt_id'] = prompt_id player_action = None # Will display prompt_id display text # POST to API response = client.post(f'/api/v1/sessions/{session_id}/action', payload) result = response.get('result', {}) job_id = result.get('job_id') if not job_id: # Immediate response (shouldn't happen, but handle it) dm_response = result.get('dm_response', 'Action completed.') return render_template( 'game/partials/dm_response.html', session_id=session_id, dm_response=dm_response ) # Return polling partial return render_template( 'game/partials/job_polling.html', session_id=session_id, job_id=job_id, status=result.get('status', 'queued'), player_action=player_action ) except APIError as e: logger.error("failed_to_take_action", session_id=session_id, error=str(e)) return f'
Action failed: {e}
', 500 @game_bp.route('/session//job/') @require_auth def poll_job(session_id: str, job_id: str): """Poll job status - returns updated partial.""" client = get_api_client() # Get hx_target and hx_swap from query params (passed through from original request) hx_target = request.args.get('_hx_target') hx_swap = request.args.get('_hx_swap') try: response = client.get(f'/api/v1/jobs/{job_id}/status') result = response.get('result', {}) status = result.get('status', 'unknown') if status == 'completed': # Job done - check for NPC dialogue vs story action nested_result = result.get('result', {}) if nested_result.get('context_type') == 'npc_dialogue': # NPC dialogue response - return dialogue partial # Include quest_offer data for inline quest offer card return render_template( 'game/partials/npc_dialogue_response.html', npc_name=nested_result.get('npc_name', 'NPC'), character_name=nested_result.get('character_name', 'You'), conversation_history=nested_result.get('conversation_history', []), player_line=nested_result.get('player_line', ''), dialogue=nested_result.get('dialogue', 'No response'), quest_offer=nested_result.get('quest_offer'), # Quest offer data for UI card npc_id=nested_result.get('npc_id'), # NPC ID for accept/decline session_id=session_id ) else: # Standard DM response dm_response = result.get('dm_response', nested_result.get('dm_response', 'No response')) return render_template( 'game/partials/dm_response.html', session_id=session_id, dm_response=dm_response ) elif status in ('failed', 'error'): error_msg = result.get('error', 'Unknown error occurred') return f'
Action failed: {error_msg}
' else: # Still processing - return polling partial to continue # Pass through hx_target and hx_swap to maintain targeting return render_template( 'game/partials/job_polling.html', session_id=session_id, job_id=job_id, status=status, hx_target=hx_target, hx_swap=hx_swap ) except APIError as e: logger.error("failed_to_poll_job", job_id=job_id, session_id=session_id, error=str(e)) return f'
Failed to check job status: {e}
', 500 # ===== Modal Routes ===== @game_bp.route('/session//equipment-modal') @require_auth def equipment_modal(session_id: str): """Get equipment modal with character's gear.""" 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') character = {} if character_id: char_response = client.get(f'/api/v1/characters/{character_id}') char_data = char_response.get('result', {}) character = _build_character_from_api(char_data) return render_template( 'game/partials/equipment_modal.html', session_id=session_id, character=character ) except APIError as e: logger.error("failed_to_load_equipment_modal", session_id=session_id, error=str(e)) return f''' ''' @game_bp.route('/session//travel-modal') @require_auth def travel_modal(session_id: str): """Get travel modal with available destinations.""" client = get_api_client() try: # Get available travel destinations response = client.get(f'/api/v1/travel/available?session_id={session_id}') result = response.get('result', {}) available_locations = result.get('available_locations', []) current_location_id = result.get('current_location') # Get current location details from session session_response = client.get(f'/api/v1/sessions/{session_id}') session_data = session_response.get('result', {}) game_state = session_data.get('game_state', {}) current_location = _build_location_from_game_state(game_state) # Filter out current location from destinations destinations = [loc for loc in available_locations if loc.get('location_id') != current_location_id] return render_template( 'game/partials/travel_modal.html', session_id=session_id, destinations=destinations, current_location=current_location ) except APIError as e: logger.error("failed_to_load_travel_modal", session_id=session_id, error=str(e)) return f''' ''' @game_bp.route('/session//travel', methods=['POST']) @require_auth def do_travel(session_id: str): """Execute travel to location - returns job polling partial or immediate response.""" client = get_api_client() location_id = request.form.get('location_id') if not location_id: return '
No destination selected.
', 400 try: response = client.post('/api/v1/travel', { 'session_id': session_id, 'location_id': location_id }) result = response.get('result', {}) # Check if travel triggers a job (narrative generation) job_id = result.get('job_id') if job_id: # Close modal and return job polling partial return f''' ''' + render_template( 'game/partials/job_polling.html', job_id=job_id, session_id=session_id, status='queued' ) # Immediate response (no AI generation) narrative = result.get('narrative', result.get('description', 'You arrive at your destination.')) location_name = result.get('location_name', 'Unknown Location') # Close modal and update response area return f'''
Arrived at {location_name}

{narrative}
''' + render_template( 'game/partials/dm_response.html', session_id=session_id, dm_response=f"**Arrived at {location_name}**\n\n{narrative}" ) except APIError as e: logger.error("failed_to_travel", session_id=session_id, location_id=location_id, error=str(e)) return f'
Travel failed: {e}
', 500 @game_bp.route('/session//monster-modal') @require_auth def monster_modal(session_id: str): """ Get monster selection modal with encounter options. Fetches random encounter groups appropriate for the current location and character level from the API. """ client = get_api_client() try: # Get encounter options from API response = client.get(f'/api/v1/combat/encounters?session_id={session_id}') result = response.get('result', {}) location_name = result.get('location_name', 'Unknown Area') encounters = result.get('encounters', []) return render_template( 'game/partials/monster_modal.html', session_id=session_id, location_name=location_name, encounters=encounters ) except APINotFoundError as e: # No enemies found for this location return render_template( 'game/partials/monster_modal.html', session_id=session_id, location_name='this area', encounters=[] ) except APIError as e: logger.error("failed_to_load_monster_modal", session_id=session_id, error=str(e)) return f''' ''' @game_bp.route('/session//combat/start', methods=['POST']) @require_auth def start_combat(session_id: str): """ Start combat with selected enemies. Called when player selects an encounter from the monster modal. Initiates combat via API and redirects to combat UI. If there's already an active combat session, shows a conflict modal allowing the user to resume or abandon the existing combat. """ from flask import make_response client = get_api_client() # Get enemy_ids from request # HTMX hx-vals sends as form data (not JSON), where arrays become multiple values if request.is_json: enemy_ids = request.json.get('enemy_ids', []) else: # Form data: array values come as multiple entries with the same key enemy_ids = request.form.getlist('enemy_ids') if not enemy_ids: return '
No enemies selected.
', 400 try: # Start combat via API response = client.post('/api/v1/combat/start', { 'session_id': session_id, 'enemy_ids': enemy_ids }) result = response.get('result', {}) encounter_id = result.get('encounter_id') if not encounter_id: logger.error("combat_start_no_encounter_id", session_id=session_id) return '
Failed to start combat - no encounter ID returned.
', 500 logger.info("combat_started_from_modal", session_id=session_id, encounter_id=encounter_id, enemy_count=len(enemy_ids)) # Close modal and redirect to combat page resp = make_response('') resp.headers['HX-Redirect'] = url_for('combat.combat_view', session_id=session_id) return resp except APIError as e: # Check if this is an "already in combat" error error_str = str(e) if 'already in combat' in error_str.lower() or 'ALREADY_IN_COMBAT' in error_str: # Fetch existing combat info and show conflict modal try: check_response = client.get(f'/api/v1/combat/{session_id}/check') combat_info = check_response.get('result', {}) if combat_info.get('has_active_combat'): return render_template( 'game/partials/combat_conflict_modal.html', session_id=session_id, combat_info=combat_info, pending_enemy_ids=enemy_ids ) except APIError: pass # Fall through to generic error logger.error("failed_to_start_combat", session_id=session_id, error=str(e)) return f'
Failed to start combat: {e}
', 500 @game_bp.route('/session//combat/check', methods=['GET']) @require_auth def check_combat_status(session_id: str): """ Check if the session has an active combat. Returns JSON with combat status that can be used by HTMX to decide whether to show the monster modal or conflict modal. """ client = get_api_client() try: response = client.get(f'/api/v1/combat/{session_id}/check') result = response.get('result', {}) return result except APIError as e: logger.error("failed_to_check_combat", session_id=session_id, error=str(e)) return {'has_active_combat': False, 'error': str(e)} @game_bp.route('/session//combat/abandon', methods=['POST']) @require_auth def abandon_combat(session_id: str): """ Abandon an existing combat session. Called when player chooses to abandon their current combat in order to start a fresh one. """ client = get_api_client() try: response = client.post(f'/api/v1/combat/{session_id}/abandon', {}) result = response.get('result', {}) if result.get('success'): logger.info("combat_abandoned", session_id=session_id) # Return success - the frontend will then try to start new combat return render_template( 'game/partials/combat_abandoned_success.html', session_id=session_id, message="Combat abandoned. You can now start a new encounter." ) else: return '
No active combat to abandon.
', 400 except APIError as e: logger.error("failed_to_abandon_combat", session_id=session_id, error=str(e)) return f'
Failed to abandon combat: {e}
', 500 @game_bp.route('/session//combat/abandon-and-start', methods=['POST']) @require_auth def abandon_and_start_combat(session_id: str): """ Abandon existing combat and start a new one in a single action. This is a convenience endpoint that combines abandon + start for a smoother user experience in the conflict modal. """ from flask import make_response client = get_api_client() # Get enemy_ids from request if request.is_json: enemy_ids = request.json.get('enemy_ids', []) else: enemy_ids = request.form.getlist('enemy_ids') if not enemy_ids: return '
No enemies selected.
', 400 try: # First abandon the existing combat abandon_response = client.post(f'/api/v1/combat/{session_id}/abandon', {}) abandon_result = abandon_response.get('result', {}) if not abandon_result.get('success'): # No combat to abandon, but that's fine - proceed with start logger.info("no_combat_to_abandon", session_id=session_id) # Now start the new combat start_response = client.post('/api/v1/combat/start', { 'session_id': session_id, 'enemy_ids': enemy_ids }) result = start_response.get('result', {}) encounter_id = result.get('encounter_id') if not encounter_id: logger.error("combat_start_no_encounter_id_after_abandon", session_id=session_id) return '
Failed to start combat - no encounter ID returned.
', 500 logger.info("combat_started_after_abandon", session_id=session_id, encounter_id=encounter_id, enemy_count=len(enemy_ids)) # Close modal and redirect to combat page resp = make_response('') resp.headers['HX-Redirect'] = url_for('combat.combat_view', session_id=session_id) return resp except APIError as e: logger.error("failed_to_abandon_and_start_combat", session_id=session_id, error=str(e)) return f'
Failed to start combat: {e}
', 500 @game_bp.route('/session//npc/') @require_auth def npc_chat_page(session_id: str, npc_id: str): """ Dedicated NPC chat page (mobile-friendly full page view). Used on mobile devices for better UX. """ 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 NPC details with relationship info npc_response = client.get(f'/api/v1/npcs/{npc_id}?character_id={character_id}') npc_data = npc_response.get('result', {}) npc = { 'npc_id': npc_data.get('npc_id'), 'name': npc_data.get('name'), 'role': npc_data.get('role'), 'appearance': npc_data.get('appearance', {}).get('brief', ''), 'tags': npc_data.get('tags', []), 'image_url': npc_data.get('image_url') } # Get relationship info interaction_summary = npc_data.get('interaction_summary', {}) relationship_level = interaction_summary.get('relationship_level', 50) interaction_count = interaction_summary.get('interaction_count', 0) # Conversation history would come from character's npc_interactions # For now, we'll leave it empty - the API returns it in dialogue responses conversation_history = [] return render_template( 'game/npc_chat_page.html', session_id=session_id, npc=npc, conversation_history=conversation_history, relationship_level=relationship_level, interaction_count=interaction_count ) except APINotFoundError: return render_template('errors/404.html', message="NPC not found"), 404 except APIError as e: logger.error("failed_to_load_npc_chat_page", session_id=session_id, npc_id=npc_id, error=str(e)) return render_template('errors/500.html', message=f"Failed to load NPC: {e}"), 500 @game_bp.route('/session//npc//chat') @require_auth def npc_chat_modal(session_id: str, npc_id: str): """ Get NPC chat modal with conversation history. Used on desktop for modal overlay experience. """ 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 NPC details with relationship info npc_response = client.get(f'/api/v1/npcs/{npc_id}?character_id={character_id}') npc_data = npc_response.get('result', {}) npc = { 'npc_id': npc_data.get('npc_id'), 'name': npc_data.get('name'), 'role': npc_data.get('role'), 'appearance': npc_data.get('appearance', {}).get('brief', ''), 'tags': npc_data.get('tags', []), 'image_url': npc_data.get('image_url') } # Get relationship info interaction_summary = npc_data.get('interaction_summary', {}) relationship_level = interaction_summary.get('relationship_level', 50) interaction_count = interaction_summary.get('interaction_count', 0) # Conversation history would come from character's npc_interactions # For now, we'll leave it empty - the API returns it in dialogue responses conversation_history = [] return render_template( 'game/partials/npc_chat_modal.html', session_id=session_id, npc=npc, conversation_history=conversation_history, relationship_level=relationship_level, interaction_count=interaction_count ) except APINotFoundError: return '
NPC not found
', 404 except APIError as e: logger.error("failed_to_load_npc_chat", session_id=session_id, npc_id=npc_id, error=str(e)) return f''' ''' @game_bp.route('/session//npc//history') @require_auth def npc_chat_history(session_id: str, npc_id: str): """Get last 5 chat messages for history sidebar.""" client = get_api_client() try: # Get session to find character_id session_response = client.get(f'/api/v1/sessions/{session_id}') session_data = session_response.get('result', {}) character_id = session_data.get('character_id') # Fetch last 5 messages from chat service # API endpoint: GET /api/v1/characters/{character_id}/chats/{npc_id}?limit=5 history_response = client.get( f'/api/v1/characters/{character_id}/chats/{npc_id}', params={'limit': 5, 'offset': 0} ) result_data = history_response.get('result', {}) messages = result_data.get('messages', []) # Extract messages array from result # Render history partial return render_template( 'game/partials/npc_chat_history.html', messages=messages, session_id=session_id, npc_id=npc_id ) except APIError as e: logger.error("failed_to_load_chat_history", session_id=session_id, npc_id=npc_id, error=str(e)) return '
Failed to load history
', 500 # ===== Inventory Routes ===== @game_bp.route('/session//inventory-modal') @require_auth def inventory_modal(session_id: str): """ Get inventory modal with all items. Supports filtering by item type via ?filter= parameter. """ client = get_api_client() filter_type = request.args.get('filter', 'all') 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') inventory = [] equipped = {} gold = 0 inventory_count = 0 inventory_max = 100 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', []) equipped = inv_data.get('equipped', {}) inventory_count = inv_data.get('inventory_count', len(inventory)) inventory_max = inv_data.get('max_inventory', 100) # Get gold from character char_response = client.get(f'/api/v1/characters/{character_id}') char_data = char_response.get('result', {}) gold = char_data.get('gold', 0) except (APINotFoundError, APIError) as e: logger.warning("failed_to_load_inventory", character_id=character_id, error=str(e)) # Filter inventory by type if specified if filter_type != 'all': inventory = [item for item in inventory if item.get('item_type') == filter_type] return render_template( 'game/partials/inventory_modal.html', session_id=session_id, inventory=inventory, equipped=equipped, gold=gold, inventory_count=inventory_count, inventory_max=inventory_max, filter=filter_type ) except APIError as e: logger.error("failed_to_load_inventory_modal", session_id=session_id, error=str(e)) return f''' ''' @game_bp.route('/session//inventory/item/') @require_auth def inventory_item_detail(session_id: str, item_id: str): """Get item detail partial for HTMX swap.""" 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 # Determine suggested slot for equipment suggested_slot = None item_type = item.get('item_type', '') if item_type == 'weapon': suggested_slot = 'weapon' elif item_type == 'armor': # Could be any armor slot - default to chest suggested_slot = 'chest' return render_template( 'game/partials/inventory_item_detail.html', session_id=session_id, item=item, suggested_slot=suggested_slot ) except APIError as e: logger.error("failed_to_load_item_detail", session_id=session_id, item_id=item_id, error=str(e)) return f'
Failed to load item: {e}
', 500 @game_bp.route('/session//inventory/use', methods=['POST']) @require_auth def inventory_use(session_id: str): """Use a consumable item.""" client = get_api_client() item_id = request.form.get('item_id') if not item_id: return '
No item selected
', 400 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') if not character_id: return '
No character found
', 400 # Use the item via API client.post(f'/api/v1/characters/{character_id}/inventory/use', { 'item_id': item_id }) # Return updated character panel return redirect(url_for('game.character_panel', session_id=session_id)) except APIError as e: logger.error("failed_to_use_item", session_id=session_id, item_id=item_id, error=str(e)) return f'
Failed to use item: {e}
', 500 @game_bp.route('/session//inventory/equip', methods=['POST']) @require_auth def inventory_equip(session_id: str): """Equip an item to a slot.""" client = get_api_client() item_id = request.form.get('item_id') slot = request.form.get('slot') if not item_id: return '
No item selected
', 400 if not slot: logger.warning("equip_missing_slot", item_id=item_id) return '
No equipment slot specified
', 400 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') if not character_id: return '
No character found
', 400 # Equip the item via API payload = {'item_id': item_id, 'slot': slot} client.post(f'/api/v1/characters/{character_id}/inventory/equip', payload) # Return updated character panel return redirect(url_for('game.character_panel', session_id=session_id)) except APIError as e: logger.error("failed_to_equip_item", session_id=session_id, item_id=item_id, slot=slot, error=str(e)) return f'
Failed to equip item: {e}
', 500 @game_bp.route('/session//inventory/', methods=['DELETE']) @require_auth def inventory_drop(session_id: str, item_id: str): """Drop (delete) an item from inventory.""" 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') if not character_id: return '
No character found
', 400 # Delete the item via API client.delete(f'/api/v1/characters/{character_id}/inventory/{item_id}') # Return updated inventory modal return redirect(url_for('game.inventory_modal', session_id=session_id)) except APIError as e: logger.error("failed_to_drop_item", session_id=session_id, item_id=item_id, error=str(e)) return f'
Failed to drop item: {e}
', 500 @game_bp.route('/session//npc//talk', methods=['POST']) @require_auth def talk_to_npc(session_id: str, npc_id: str): """Send message to NPC - returns dialogue response or job polling partial.""" client = get_api_client() # Support both topic (initial greeting) and player_response (conversation) player_response = request.form.get('player_response') topic = request.form.get('topic', 'greeting') try: payload = {'session_id': session_id} if player_response: # Player typed a custom response payload['player_response'] = player_response else: # Initial greeting click payload['topic'] = topic response = client.post(f'/api/v1/npcs/{npc_id}/talk', payload) result = response.get('result', {}) # Check if it's a job-based response (async) or immediate job_id = result.get('job_id') if job_id: # Return job polling partial for the chat area # Use hx-target="this" and hx-swap="outerHTML" to replace loading div with response in-place return render_template( 'game/partials/job_polling.html', job_id=job_id, session_id=session_id, status='queued', is_npc_dialogue=True, hx_target='this', # Target the loading div itself hx_swap='outerHTML' # Replace entire loading div with response ) # Immediate response (if AI is sync or cached) dialogue = result.get('dialogue', result.get('response', 'The NPC has nothing to say.')) npc_name = result.get('npc_name', 'NPC') # Return dialogue in chat format player_display = player_response if player_response else f"[{topic}]" return f'''
You: {player_display}
{npc_name}: {dialogue}
''' except APINotFoundError: return '
NPC not found.
', 404 except APIError as e: logger.error("failed_to_talk_to_npc", session_id=session_id, npc_id=npc_id, error=str(e)) return f'
Failed to talk to NPC: {e}
', 500 # ===== Quest Accept/Decline Routes ===== @game_bp.route('/session//quest/accept', methods=['POST']) @require_auth def accept_quest(session_id: str): """ Accept a quest offer from NPC chat. Called when player clicks 'Accept Quest' button on inline quest offer card. Returns updated card with confirmation message and triggers toast notification. """ client = get_api_client() quest_id = request.form.get('quest_id') npc_id = request.form.get('npc_id') npc_name = request.form.get('npc_name', 'NPC') if not quest_id: return render_template( 'game/partials/quest_action_response.html', action='error', error_message='No quest specified', session_id=session_id ) try: # Get character_id from session session_response = client.get(f'/api/v1/sessions/{session_id}') session_data = session_response.get('result', {}) character_id = session_data.get('character_id') if not character_id: return render_template( 'game/partials/quest_action_response.html', action='error', error_message='Session error - no character found', session_id=session_id ) # Call API to accept quest response = client.post('/api/v1/quests/accept', { 'character_id': character_id, 'quest_id': quest_id, 'npc_id': npc_id }) result = response.get('result', {}) quest_name = result.get('quest_name', 'Quest') logger.info( "quest_accepted", quest_id=quest_id, quest_name=quest_name, character_id=character_id, session_id=session_id ) return render_template( 'game/partials/quest_action_response.html', action='accept', quest_name=quest_name, npc_name=npc_name, session_id=session_id ) except APIError as e: logger.error( "failed_to_accept_quest", quest_id=quest_id, session_id=session_id, error=str(e) ) return render_template( 'game/partials/quest_action_response.html', action='error', error_message=str(e), session_id=session_id ) @game_bp.route('/session//quest/decline', methods=['POST']) @require_auth def decline_quest(session_id: str): """ Decline a quest offer from NPC chat. Called when player clicks 'Decline' button on inline quest offer card. Returns updated card with decline confirmation. """ client = get_api_client() quest_id = request.form.get('quest_id') npc_id = request.form.get('npc_id') npc_name = request.form.get('npc_name', 'NPC') if not quest_id: return render_template( 'game/partials/quest_action_response.html', action='error', error_message='No quest specified', session_id=session_id ) try: # Get character_id from session session_response = client.get(f'/api/v1/sessions/{session_id}') session_data = session_response.get('result', {}) character_id = session_data.get('character_id') if not character_id: return render_template( 'game/partials/quest_action_response.html', action='error', error_message='Session error - no character found', session_id=session_id ) # Call API to decline quest client.post('/api/v1/quests/decline', { 'character_id': character_id, 'quest_id': quest_id, 'npc_id': npc_id }) logger.info( "quest_declined", quest_id=quest_id, character_id=character_id, session_id=session_id ) return render_template( 'game/partials/quest_action_response.html', action='decline', quest_name='', # Not needed for decline message npc_name=npc_name, session_id=session_id ) except APIError as e: logger.error( "failed_to_decline_quest", quest_id=quest_id, session_id=session_id, error=str(e) ) return render_template( 'game/partials/quest_action_response.html', action='error', error_message=str(e), session_id=session_id ) # ===== Shop Routes ===== @game_bp.route('/session//shop-modal') @require_auth def shop_modal(session_id: str): """ Get shop modal for browsing and purchasing items. Supports filtering by item type via ?filter= parameter. Uses the general_store shop. """ client = get_api_client() filter_type = request.args.get('filter', 'all') message = request.args.get('message', '') error = request.args.get('error', '') 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') gold = 0 inventory = [] shop = {} if character_id: try: # Get shop inventory with character context (for affordability) shop_response = client.get( f'/api/v1/shop/general_store/inventory', params={'character_id': character_id} ) shop_data = shop_response.get('result', {}) shop = shop_data.get('shop', {}) inventory = shop_data.get('inventory', []) # Get character gold char_data = shop_data.get('character', {}) gold = char_data.get('gold', 0) except (APINotFoundError, APIError) as e: logger.warning("failed_to_load_shop", character_id=character_id, error=str(e)) error = "Failed to load shop inventory" # Filter inventory by type if specified if filter_type != 'all': inventory = [ entry for entry in inventory if entry.get('item', {}).get('item_type') == filter_type ] return render_template( 'game/partials/shop_modal.html', session_id=session_id, shop=shop, inventory=inventory, gold=gold, filter=filter_type, message=message, error=error ) except APIError as e: logger.error("failed_to_load_shop_modal", session_id=session_id, error=str(e)) return f''' ''' @game_bp.route('/session//shop/purchase', methods=['POST']) @require_auth def shop_purchase(session_id: str): """ Purchase an item from the shop. HTMX endpoint - returns updated shop modal. """ client = get_api_client() item_id = request.form.get('item_id') quantity = int(request.form.get('quantity', 1)) 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') if not character_id: return shop_modal_with_error(session_id, "No character found for this session") if not item_id: return shop_modal_with_error(session_id, "No item specified") # Attempt purchase purchase_data = { 'character_id': character_id, 'item_id': item_id, 'quantity': quantity, 'session_id': session_id } response = client.post('/api/v1/shop/general_store/purchase', json=purchase_data) result = response.get('result', {}) # Get item name for message purchase_info = result.get('purchase', {}) item_name = purchase_info.get('item_id', item_id) total_cost = purchase_info.get('total_cost', 0) message = f"Purchased {item_name} for {total_cost} gold!" logger.info( "shop_purchase_success", session_id=session_id, character_id=character_id, item_id=item_id, quantity=quantity, total_cost=total_cost ) # Re-render shop modal with success message return redirect(url_for('game.shop_modal', session_id=session_id, message=message)) except APIError as e: logger.error( "shop_purchase_failed", session_id=session_id, item_id=item_id, error=str(e) ) error_msg = str(e.message) if hasattr(e, 'message') else str(e) return redirect(url_for('game.shop_modal', session_id=session_id, error=error_msg)) @game_bp.route('/session//shop/sell', methods=['POST']) @require_auth def shop_sell(session_id: str): """ Sell an item to the shop. HTMX endpoint - returns updated shop modal. """ client = get_api_client() item_instance_id = request.form.get('item_instance_id') quantity = int(request.form.get('quantity', 1)) 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') if not character_id: return redirect(url_for('game.shop_modal', session_id=session_id, error="No character found")) if not item_instance_id: return redirect(url_for('game.shop_modal', session_id=session_id, error="No item specified")) # Attempt sale sale_data = { 'character_id': character_id, 'item_instance_id': item_instance_id, 'quantity': quantity, 'session_id': session_id } response = client.post('/api/v1/shop/general_store/sell', json=sale_data) result = response.get('result', {}) sale_info = result.get('sale', {}) item_name = sale_info.get('item_name', 'Item') total_earned = sale_info.get('total_earned', 0) message = f"Sold {item_name} for {total_earned} gold!" logger.info( "shop_sell_success", session_id=session_id, character_id=character_id, item_instance_id=item_instance_id, total_earned=total_earned ) return redirect(url_for('game.shop_modal', session_id=session_id, message=message)) except APIError as e: logger.error( "shop_sell_failed", session_id=session_id, item_instance_id=item_instance_id, error=str(e) ) error_msg = str(e.message) if hasattr(e, 'message') else str(e) return redirect(url_for('game.shop_modal', session_id=session_id, error=error_msg)) def shop_modal_with_error(session_id: str, error: str): """Helper to render shop modal with an error message.""" return redirect(url_for('game.shop_modal', session_id=session_id, error=error)) # ===== Quest Routes ===== @game_bp.route('/session//quest/') @require_auth def quest_detail(session_id: str, quest_id: str): """ Get quest detail modal showing progress and options. Displays the full quest details with objective progress, rewards, and options to abandon (if in progress) or claim rewards (if complete). """ 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') if not character_id: return _quest_error_modal("No character found for this session") # Get quest details quest_response = client.get(f'/api/v1/quests/{quest_id}') quest = quest_response.get('result', {}) if not quest: return _quest_error_modal(f"Quest not found: {quest_id}") # Get character's quest progress quests_response = client.get(f'/api/v1/quests/characters/{character_id}/quests') quests_data = quests_response.get('result', {}) active_quests = quests_data.get('active_quests', []) # Find this quest's progress quest_state = None objectives_progress = {} accepted_at = None for active_quest in active_quests: if active_quest.get('quest_id') == quest_id: progress_data = active_quest.get('progress', {}) objectives_progress = progress_data.get('objectives_progress', {}) accepted_at = progress_data.get('accepted_at', '') break # Build enriched objectives with progress enriched_objectives = [] all_complete = True for obj in quest.get('objectives', []): obj_id = obj.get('objective_id', '') progress_text = obj.get('progress_text', '0/1') required = int(progress_text.split('/')[1]) if '/' in progress_text else 1 current = objectives_progress.get(obj_id, 0) is_complete = current >= required if not is_complete: all_complete = False enriched_objectives.append({ 'objective_id': obj_id, 'description': obj.get('description', ''), 'current_progress': current, 'required_progress': required, 'is_complete': is_complete }) return render_template( 'game/partials/quest_detail_modal.html', session_id=session_id, quest=quest, objectives=enriched_objectives, quest_complete=all_complete, accepted_at=accepted_at ) except APIError as e: logger.error("failed_to_load_quest_detail", session_id=session_id, quest_id=quest_id, error=str(e)) return _quest_error_modal(f"Failed to load quest: {e}") @game_bp.route('/session//quest/offer/') @require_auth def quest_offer(session_id: str, quest_id: str): """ Display quest offer modal. Shows quest details when an NPC offers a quest, with accept/decline options. """ client = get_api_client() npc_id = request.args.get('npc_id', '') npc_name = request.args.get('npc_name', '') offer_dialogue = request.args.get('offer_dialogue', '') try: # Get session to check character's quest count session_response = client.get(f'/api/v1/sessions/{session_id}') session_data = session_response.get('result', {}) character_id = session_data.get('character_id') at_max_quests = False if character_id: try: quests_response = client.get(f'/api/v1/quests/characters/{character_id}/quests') quests_data = quests_response.get('result', {}) active_count = quests_data.get('active_count', 0) at_max_quests = active_count >= 2 except APIError: pass # Get quest details quest_response = client.get(f'/api/v1/quests/{quest_id}') quest = quest_response.get('result', {}) if not quest: return _quest_error_modal(f"Quest not found: {quest_id}") return render_template( 'game/partials/quest_offer_modal.html', session_id=session_id, quest=quest, npc_id=npc_id, npc_name=npc_name, offer_dialogue=offer_dialogue, at_max_quests=at_max_quests ) except APIError as e: logger.error("failed_to_load_quest_offer", session_id=session_id, quest_id=quest_id, error=str(e)) return _quest_error_modal(f"Failed to load quest offer: {e}") @game_bp.route('/session//quest/accept', methods=['POST']) @require_auth def quest_accept(session_id: str): """ Accept a quest offer. Calls the API to add the quest to the character's active quests. """ client = get_api_client() quest_id = request.form.get('quest_id') or request.get_json().get('quest_id') if request.is_json else request.form.get('quest_id') npc_id = request.form.get('npc_id') or (request.get_json().get('npc_id') if request.is_json else request.form.get('npc_id')) 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') if not character_id: return _quest_error_modal("No character found for this session") # Accept the quest accept_response = client.post('/api/v1/quests/accept', json={ 'character_id': character_id, 'quest_id': quest_id, 'npc_id': npc_id }) result = accept_response.get('result', {}) quest_name = result.get('quest_name', 'Quest') logger.info( "quest_accepted", session_id=session_id, character_id=character_id, quest_id=quest_id ) # Return success message that will close modal return f''' ''' except APIError as e: logger.error("quest_accept_failed", session_id=session_id, quest_id=quest_id, error=str(e)) error_msg = str(e.message) if hasattr(e, 'message') else str(e) return _quest_error_modal(f"Failed to accept quest: {error_msg}") @game_bp.route('/session//quest/decline', methods=['POST']) @require_auth def quest_decline(session_id: str): """ Decline a quest offer. Sets a flag to prevent immediate re-offering. """ client = get_api_client() quest_id = request.form.get('quest_id') or (request.get_json().get('quest_id') if request.is_json else request.form.get('quest_id')) npc_id = request.form.get('npc_id') or (request.get_json().get('npc_id') if request.is_json else request.form.get('npc_id')) 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') if not character_id: return _quest_error_modal("No character found for this session") # Decline the quest client.post('/api/v1/quests/decline', json={ 'character_id': character_id, 'quest_id': quest_id, 'npc_id': npc_id }) logger.info( "quest_declined", session_id=session_id, character_id=character_id, quest_id=quest_id ) # Just close the modal return '' except APIError as e: logger.error("quest_decline_failed", session_id=session_id, quest_id=quest_id, error=str(e)) return '' # Close modal anyway @game_bp.route('/session//quest/abandon', methods=['POST']) @require_auth def quest_abandon(session_id: str): """ Abandon an active quest. Removes the quest from active quests. """ client = get_api_client() quest_id = request.form.get('quest_id') or (request.get_json().get('quest_id') if request.is_json else request.form.get('quest_id')) 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') if not character_id: return _quest_error_modal("No character found for this session") # Abandon the quest client.post('/api/v1/quests/abandon', json={ 'character_id': character_id, 'quest_id': quest_id }) logger.info( "quest_abandoned", session_id=session_id, character_id=character_id, quest_id=quest_id ) # Return confirmation that will close modal return f''' ''' except APIError as e: logger.error("quest_abandon_failed", session_id=session_id, quest_id=quest_id, error=str(e)) error_msg = str(e.message) if hasattr(e, 'message') else str(e) return _quest_error_modal(f"Failed to abandon quest: {error_msg}") @game_bp.route('/session//quest/complete', methods=['POST']) @require_auth def quest_complete(session_id: str): """ Complete a quest and claim rewards. Grants rewards and moves quest to completed list. """ client = get_api_client() quest_id = request.form.get('quest_id') or (request.get_json().get('quest_id') if request.is_json else request.form.get('quest_id')) 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') if not character_id: return _quest_error_modal("No character found for this session") # Complete the quest complete_response = client.post('/api/v1/quests/complete', json={ 'character_id': character_id, 'quest_id': quest_id }) result = complete_response.get('result', {}) quest_name = result.get('quest_name', 'Quest') rewards = result.get('rewards', {}) leveled_up = result.get('leveled_up', False) new_level = result.get('new_level') logger.info( "quest_completed", session_id=session_id, character_id=character_id, quest_id=quest_id, rewards=rewards ) # Build rewards display rewards_html = [] if rewards.get('gold'): rewards_html.append(f"
  • 💰 {rewards['gold']} Gold
  • ") if rewards.get('experience'): rewards_html.append(f"
  • ★ {rewards['experience']} XP
  • ") for item in rewards.get('items', []): rewards_html.append(f"
  • 🎁 {item}
  • ") level_up_html = "" if leveled_up and new_level: level_up_html = f'
    Level Up! You are now level {new_level}!
    ' return f''' ''' except APIError as e: logger.error("quest_complete_failed", session_id=session_id, quest_id=quest_id, error=str(e)) error_msg = str(e.message) if hasattr(e, 'message') else str(e) return _quest_error_modal(f"Failed to complete quest: {error_msg}") def _quest_error_modal(error_message: str) -> str: """Helper to render a quest error modal.""" return f''' '''