""" 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 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': 'explore_area', 'display_text': 'Explore the Area', 'icon': 'compass', 'context': ['wilderness', 'dungeon']}, {'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) } # ===== 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.""" client = get_api_client() try: # Get session to access game_state.active_quests session_response = client.get(f'/api/v1/sessions/{session_id}') session_data = session_response.get('result', {}) game_state = session_data.get('game_state', {}) quests = game_state.get('active_quests', []) return render_template( 'game/partials/sidebar_quests.html', session_id=session_id, quests=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 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'), 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//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 @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