893 lines
28 KiB
Python
893 lines
28 KiB
Python
"""
|
|
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('/<character_id>')
|
|
@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('/<character_id>/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('/<character_id>/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/<session_id>/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 '<span class="error-message">Session not found</span>', 404
|
|
|
|
except APIError as e:
|
|
logger.error(
|
|
"Failed to delete session",
|
|
user_id=user.get('id'),
|
|
session_id=session_id,
|
|
error=str(e)
|
|
)
|
|
return '<span class="error-message">Failed to delete session</span>', 500
|
|
|
|
|
|
@character_bp.route('/<character_id>/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('/<character_id>/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('/<character_id>/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"
|
|
)
|