""" Character Views Blueprint This module provides web UI routes for character management: - Character creation flow (4 steps) - Character list view - Character detail view - Skill tree view All views require authentication and render HTML templates with HTMX. """ import time from flask import Blueprint, render_template, request, session, redirect, url_for, flash from app.utils.auth import require_auth_web, get_current_user from app.utils.logging import get_logger from app.utils.api_client import ( get_api_client, APIError, APINotFoundError, APITimeoutError ) # Initialize logger logger = get_logger(__file__) # Create blueprint character_bp = Blueprint('character_views', __name__, url_prefix='/characters') # Cache settings _CACHE_TTL = 300 # 5 minutes in seconds _origins_cache = {'data': None, 'timestamp': 0} _classes_cache = {'data': None, 'timestamp': 0} # Wizard session timeout (1 hour) _WIZARD_TIMEOUT = 3600 def _get_cached_origins(api_client): """ Get origins list with caching. Returns cached data if available and fresh, otherwise fetches from API. Args: api_client: API client instance. Returns: List of origin dictionaries. """ global _origins_cache current_time = time.time() if _origins_cache['data'] and (current_time - _origins_cache['timestamp']) < _CACHE_TTL: logger.debug("Using cached origins") return _origins_cache['data'] # Fetch from API response = api_client.get("/api/v1/origins") origins = response.get('result', {}).get('origins', []) # Update cache _origins_cache = {'data': origins, 'timestamp': current_time} logger.debug("Cached origins", count=len(origins)) return origins def _get_cached_classes(api_client): """ Get classes list with caching. Returns cached data if available and fresh, otherwise fetches from API. Args: api_client: API client instance. Returns: List of class dictionaries. """ global _classes_cache current_time = time.time() if _classes_cache['data'] and (current_time - _classes_cache['timestamp']) < _CACHE_TTL: logger.debug("Using cached classes") return _classes_cache['data'] # Fetch from API response = api_client.get("/api/v1/classes") classes = response.get('result', {}).get('classes', []) # Update cache _classes_cache = {'data': classes, 'timestamp': current_time} logger.debug("Cached classes", count=len(classes)) return classes def _cleanup_stale_wizard_session(): """ Clean up stale character creation wizard session data. Called at the start of character creation to remove abandoned wizard data. """ if 'character_creation' in session: creation_data = session['character_creation'] started_at = creation_data.get('started_at', 0) current_time = time.time() if (current_time - started_at) > _WIZARD_TIMEOUT: logger.info("Cleaning up stale wizard session", age_seconds=int(current_time - started_at)) session.pop('character_creation', None) # ===== CHARACTER CREATION FLOW ===== @character_bp.route('/create/origin', methods=['GET', 'POST']) @require_auth_web def create_origin(): """ Step 1: Origin Selection GET: Display all available origins for user to choose from POST: Save selected origin to session and redirect to class selection """ user = get_current_user() api_client = get_api_client() # Clean up any stale wizard session from previous attempts _cleanup_stale_wizard_session() logger.info("Character creation started - origin selection", user_id=user.get('id')) if request.method == 'POST': # Get selected origin from form origin_id = request.form.get('origin_id') if not origin_id: flash('Please select an origin story.', 'error') return redirect(url_for('character_views.create_origin')) # Validate origin exists using cached data try: origins = _get_cached_origins(api_client) # Check if selected origin_id is valid valid_origin = None for origin in origins: if origin.get('id') == origin_id: valid_origin = origin break if not valid_origin: flash('Invalid origin selected.', 'error') return redirect(url_for('character_views.create_origin')) except APIError as e: flash(f'Error validating origin: {e.message}', 'error') return redirect(url_for('character_views.create_origin')) # Store in session with timestamp session['character_creation'] = { 'origin_id': origin_id, 'step': 1, 'started_at': time.time() } logger.info("Origin selected", user_id=user.get('id'), origin_id=origin_id) return redirect(url_for('character_views.create_class')) # GET: Display origin selection using cached data try: origins = _get_cached_origins(api_client) except APIError as e: logger.error("Failed to load origins", error=str(e)) flash('Failed to load origins. Please try again.', 'error') origins = [] return render_template( 'character/create_origin.html', origins=origins, current_step=1 ) @character_bp.route('/create/class', methods=['GET', 'POST']) @require_auth_web def create_class(): """ Step 2: Class Selection GET: Display all available classes for user to choose from POST: Save selected class to session and redirect to customization """ user = get_current_user() api_client = get_api_client() # Ensure we have origin selected first if 'character_creation' not in session or session['character_creation'].get('step', 0) < 1: flash('Please start from the beginning.', 'warning') return redirect(url_for('character_views.create_origin')) if request.method == 'POST': # Get selected class from form class_id = request.form.get('class_id') if not class_id: flash('Please select a class.', 'error') return redirect(url_for('character_views.create_class')) # Validate class exists using cached data try: classes = _get_cached_classes(api_client) # Check if selected class_id is valid valid_class = None for player_class in classes: if player_class.get('class_id') == class_id: valid_class = player_class break if not valid_class: flash('Invalid class selected.', 'error') return redirect(url_for('character_views.create_class')) except APIError as e: flash(f'Error validating class: {e.message}', 'error') return redirect(url_for('character_views.create_class')) # Store in session session['character_creation']['class_id'] = class_id session['character_creation']['step'] = 2 session.modified = True logger.info("Class selected", user_id=user.get('id'), class_id=class_id) return redirect(url_for('character_views.create_customize')) # GET: Display class selection using cached data try: classes = _get_cached_classes(api_client) except APIError as e: logger.error("Failed to load classes", error=str(e)) flash('Failed to load classes. Please try again.', 'error') classes = [] return render_template( 'character/create_class.html', classes=classes, current_step=2 ) @character_bp.route('/create/customize', methods=['GET', 'POST']) @require_auth_web def create_customize(): """ Step 3: Customize Character GET: Display form to enter character name POST: Save character name to session and redirect to confirmation """ user = get_current_user() api_client = get_api_client() # Ensure we have both origin and class selected if 'character_creation' not in session or session['character_creation'].get('step', 0) < 2: flash('Please complete previous steps first.', 'warning') return redirect(url_for('character_views.create_origin')) if request.method == 'POST': # Get character name from form character_name = request.form.get('name', '').strip() if not character_name: flash('Please enter a character name.', 'error') return redirect(url_for('character_views.create_customize')) # Validate name length (3-30 characters) if len(character_name) < 3 or len(character_name) > 30: flash('Character name must be between 3 and 30 characters.', 'error') return redirect(url_for('character_views.create_customize')) # Store in session session['character_creation']['name'] = character_name session['character_creation']['step'] = 3 session.modified = True logger.info("Character name entered", user_id=user.get('id'), name=character_name) return redirect(url_for('character_views.create_confirm')) # GET: Display customization form creation_data = session.get('character_creation', {}) # Load origin and class for display using cached data origin = None player_class = None try: # Find origin in cached list if creation_data.get('origin_id'): origins = _get_cached_origins(api_client) for o in origins: if o.get('id') == creation_data['origin_id']: origin = o break # Fetch class - can use single endpoint if creation_data.get('class_id'): response = api_client.get(f"/api/v1/classes/{creation_data['class_id']}") player_class = response.get('result') except APIError as e: logger.error("Failed to load origin/class data", error=str(e)) return render_template( 'character/create_customize.html', origin=origin, player_class=player_class, current_step=3 ) @character_bp.route('/create/confirm', methods=['GET', 'POST']) @require_auth_web def create_confirm(): """ Step 4: Confirm and Create Character GET: Display character summary for final confirmation POST: Create the character via API and redirect to character list """ user = get_current_user() api_client = get_api_client() # Ensure we have all data if 'character_creation' not in session or session['character_creation'].get('step', 0) < 3: flash('Please complete all steps first.', 'warning') return redirect(url_for('character_views.create_origin')) creation_data = session.get('character_creation', {}) if request.method == 'POST': # Create the character via API try: response = api_client.post("/api/v1/characters", data={ 'name': creation_data['name'], 'class_id': creation_data['class_id'], 'origin_id': creation_data['origin_id'] }) character = response.get('result', {}) # Clear session data session.pop('character_creation', None) logger.info( "Character created successfully", user_id=user.get('id'), character_id=character.get('id'), character_name=character.get('name') ) flash(f'Character "{character.get("name")}" created successfully!', 'success') return redirect(url_for('character_views.list_characters')) except APIError as e: if 'limit' in e.message.lower(): logger.warning("Character limit exceeded", user_id=user.get('id'), error=str(e)) flash(e.message, 'error') return redirect(url_for('character_views.list_characters')) logger.error( "Failed to create character", user_id=user.get('id'), error=str(e) ) flash('An error occurred while creating your character. Please try again.', 'error') return redirect(url_for('character_views.create_origin')) # GET: Display confirmation page using cached data origin = None player_class = None try: # Find origin in cached list origins = _get_cached_origins(api_client) for o in origins: if o.get('id') == creation_data['origin_id']: origin = o break # Fetch class - can use single endpoint response = api_client.get(f"/api/v1/classes/{creation_data['class_id']}") player_class = response.get('result') except APIError as e: logger.error("Failed to load origin/class data", error=str(e)) return render_template( 'character/create_confirm.html', character_name=creation_data['name'], origin=origin, player_class=player_class, current_step=4 ) # ===== CHARACTER MANAGEMENT ===== @character_bp.route('/') @require_auth_web def list_characters(): """ Display list of all characters for the current user. Also fetches active sessions and maps them to characters. """ user = get_current_user() api_client = get_api_client() try: response = api_client.get("/api/v1/characters") result = response.get('result', {}) # API returns characters in nested structure characters = result.get('characters', []) api_tier = result.get('tier', 'free') api_limit = result.get('limit', 1) current_tier = api_tier max_characters = api_limit can_create = len(characters) < max_characters # Fetch all user sessions and map to characters sessions_by_character = {} try: sessions_response = api_client.get("/api/v1/sessions") sessions = sessions_response.get('result', []) # Handle case where result is a list or a dict with sessions key if isinstance(sessions, dict): sessions = sessions.get('sessions', []) for sess in sessions: char_id = sess.get('character_id') if char_id: if char_id not in sessions_by_character: sessions_by_character[char_id] = [] sessions_by_character[char_id].append(sess) except (APIError, APINotFoundError) as e: # Sessions endpoint may not exist or have issues logger.debug("Could not fetch sessions", error=str(e)) # Attach sessions to each character for character in characters: char_id = character.get('character_id') character['sessions'] = sessions_by_character.get(char_id, []) # Fetch usage info for daily turn limits usage_info = {} try: usage_response = api_client.get("/api/v1/usage") usage_info = usage_response.get('result', {}) except (APIError, APINotFoundError) as e: logger.debug("Could not fetch usage info", error=str(e)) logger.info( "Characters listed", user_id=user.get('id'), count=len(characters), tier=current_tier ) return render_template( 'character/list.html', characters=characters, current_tier=current_tier, max_characters=max_characters, can_create=can_create, # 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 APITimeoutError: logger.error("API timeout while listing characters", user_id=user.get('id')) flash('Request timed out. Please try again.', 'error') return render_template( 'character/list.html', characters=[], can_create=False, current_tier='free', max_characters=1, remaining=0, daily_limit=0, is_limited=False, is_unlimited=False, reset_time='' ) except APIError as e: logger.error("Failed to list characters", user_id=user.get('id'), error=str(e)) flash('An error occurred while loading your characters.', 'error') return render_template( 'character/list.html', characters=[], can_create=False, current_tier='free', max_characters=1, remaining=0, daily_limit=0, is_limited=False, is_unlimited=False, reset_time='' ) @character_bp.route('/') @require_auth_web def view_character(character_id: str): """ Display detailed view of a specific character. Args: character_id: ID of the character to view """ user = get_current_user() api_client = get_api_client() try: response = api_client.get(f"/api/v1/characters/{character_id}") character = response.get('result') logger.info( "Character viewed", user_id=user.get('id'), character_id=character_id ) return render_template('character/detail.html', character=character) except APINotFoundError: logger.warning("Character not found", user_id=user.get('id'), character_id=character_id) flash('Character not found.', 'error') return redirect(url_for('character_views.list_characters')) except APIError as e: logger.error( "Failed to view character", user_id=user.get('id'), character_id=character_id, error=str(e) ) flash('An error occurred while loading the character.', 'error') return redirect(url_for('character_views.list_characters')) @character_bp.route('//delete', methods=['POST']) @require_auth_web def delete_character(character_id: str): """ Delete a character (soft delete - marks as inactive). Args: character_id: ID of the character to delete """ user = get_current_user() api_client = get_api_client() try: api_client.delete(f"/api/v1/characters/{character_id}") logger.info("Character deleted", user_id=user.get('id'), character_id=character_id) flash('Character deleted successfully.', 'success') except APINotFoundError: logger.warning("Character not found for deletion", user_id=user.get('id'), character_id=character_id) flash('Character not found.', 'error') except APIError as e: logger.error( "Failed to delete character", user_id=user.get('id'), character_id=character_id, error=str(e) ) flash('An error occurred while deleting the character.', 'error') return redirect(url_for('character_views.list_characters')) # ===== SESSION MANAGEMENT ===== @character_bp.route('//play', methods=['POST']) @require_auth_web def create_session(character_id: str): """ Create a new game session for a character and redirect to play screen. Args: character_id: ID of the character to start a session with """ user = get_current_user() api_client = get_api_client() try: # Create new session via API response = api_client.post("/api/v1/sessions", data={ 'character_id': character_id }) result = response.get('result', {}) session_id = result.get('session_id') if not session_id: flash('Failed to create session - no session ID returned.', 'error') return redirect(url_for('character_views.list_characters')) logger.info( "Session created", user_id=user.get('id'), character_id=character_id, session_id=session_id ) # Redirect to play screen return redirect(url_for('game.play_session', session_id=session_id)) except APINotFoundError: logger.warning("Character not found for session creation", user_id=user.get('id'), character_id=character_id) flash('Character not found.', 'error') return redirect(url_for('character_views.list_characters')) except APIError as e: logger.error( "Failed to create session", user_id=user.get('id'), character_id=character_id, error=str(e) ) # Check for specific errors (session limit, etc.) if 'limit' in str(e).lower(): flash(f'Session limit reached: {e.message}', 'error') else: flash(f'Failed to create session: {e.message}', 'error') return redirect(url_for('character_views.list_characters')) @character_bp.route('/sessions//delete', methods=['DELETE']) @require_auth_web def delete_session(session_id: str): """ Delete a game session via HTMX. This permanently removes the session from the database. Returns a response that triggers page refresh to update UI properly (handles "Continue Playing" button visibility). Args: session_id: ID of the session to delete """ from flask import make_response user = get_current_user() api_client = get_api_client() try: api_client.delete(f"/api/v1/sessions/{session_id}") logger.info( "Session deleted via web UI", user_id=user.get('id'), session_id=session_id ) # Return empty response with HX-Refresh to reload the page # This ensures "Continue Playing" button visibility is correct response = make_response('', 200) response.headers['HX-Refresh'] = 'true' return response except APINotFoundError: logger.warning( "Session not found for deletion", user_id=user.get('id'), session_id=session_id ) # Return error message that HTMX can display return 'Session not found', 404 except APIError as e: logger.error( "Failed to delete session", user_id=user.get('id'), session_id=session_id, error=str(e) ) return 'Failed to delete session', 500 @character_bp.route('//skills') @require_auth_web def view_skills(character_id: str): """ Display skill tree view for a specific character. Args: character_id: ID of the character to view skills for """ user = get_current_user() api_client = get_api_client() try: # Get character data (includes full player_class with skill_trees) response = api_client.get(f"/api/v1/characters/{character_id}") character = response.get('result') # Player class is already embedded in character response player_class = character.get('player_class') logger.info( "Skill tree viewed", user_id=user.get('id'), character_id=character_id, class_id=player_class.get('class_id') if player_class else None ) return render_template( 'character/skills.html', character=character, player_class=player_class ) except APINotFoundError: logger.warning("Character not found for skills view", user_id=user.get('id'), character_id=character_id) flash('Character not found.', 'error') return redirect(url_for('character_views.list_characters')) except APIError as e: logger.error( "Failed to view skills", user_id=user.get('id'), character_id=character_id, error=str(e) ) flash('An error occurred while loading the skill tree.', 'error') return redirect(url_for('character_views.list_characters')) @character_bp.route('//skills/unlock', methods=['POST']) @require_auth_web def unlock_skill(character_id: str): """ Unlock a skill for a character (HTMX endpoint). Args: character_id: ID of the character Returns: Re-rendered skills container partial """ user = get_current_user() api_client = get_api_client() skill_id = request.form.get('skill_id') if not skill_id: return _render_skills_page( api_client, character_id, user, error="No skill specified" ) try: # Call API to unlock skill api_client.post( f"/api/v1/characters/{character_id}/skills/unlock", data={'skill_id': skill_id} ) logger.info( "Skill unlocked", user_id=user.get('id'), character_id=character_id, skill_id=skill_id ) return _render_skills_page( api_client, character_id, user, message=f"Skill unlocked!" ) except APIError as e: logger.error( "Failed to unlock skill", user_id=user.get('id'), character_id=character_id, skill_id=skill_id, error=str(e) ) return _render_skills_page( api_client, character_id, user, error=str(e.message) if hasattr(e, 'message') else "Failed to unlock skill" ) @character_bp.route('//skills/respec', methods=['POST']) @require_auth_web def respec_skills(character_id: str): """ Respec all skills for a character (HTMX endpoint). Args: character_id: ID of the character Returns: Re-rendered skills container partial """ user = get_current_user() api_client = get_api_client() try: # Call API to respec skills response = api_client.post(f"/api/v1/characters/{character_id}/skills/respec") result = response.get('result', {}) logger.info( "Skills respec", user_id=user.get('id'), character_id=character_id, cost=result.get('cost') ) return _render_skills_page( api_client, character_id, user, message=f"Skills reset! {result.get('available_points', 0)} skill points refunded." ) except APIError as e: logger.error( "Failed to respec skills", user_id=user.get('id'), character_id=character_id, error=str(e) ) return _render_skills_page( api_client, character_id, user, error=str(e.message) if hasattr(e, 'message') else "Failed to respec skills" ) def _render_skills_page(api_client, character_id: str, user: dict, message: str = None, error: str = None): """ Helper to render the skills container partial for HTMX updates. Args: api_client: API client instance character_id: Character ID user: Current user dict message: Optional success message error: Optional error message Returns: Rendered skills container HTML (partial, no base template) """ try: # Get fresh character data (includes full player_class with skill_trees) response = api_client.get(f"/api/v1/characters/{character_id}") character = response.get('result') # Player class is already embedded in character response player_class = character.get('player_class') # Use partial template for HTMX responses (no base.html wrapper) return render_template( 'character/partials/skills_container.html', character=character, player_class=player_class, message=message, error=error ) except APIError as e: logger.error("Failed to render skills page", error=str(e)) return render_template( 'character/partials/skills_container.html', character={'character_id': character_id, 'name': 'Unknown', 'unlocked_skills': [], 'available_skill_points': 0, 'level': 1, 'gold': 0}, player_class=None, error="Failed to load character data" )