""" 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, []) 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 ) 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) 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) @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('//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 response = api_client.get(f"/api/v1/characters/{character_id}") character = response.get('result') # Load class data to get skill trees class_id = character.get('class_id') player_class = None if class_id: response = api_client.get(f"/api/v1/classes/{class_id}") player_class = response.get('result') logger.info( "Skill tree viewed", user_id=user.get('id'), character_id=character_id ) 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'))