""" 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.""" 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//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 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} if slot: payload['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, 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 # ===== 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))