first commit

This commit is contained in:
2025-11-24 23:10:55 -06:00
commit 8315fa51c9
279 changed files with 74600 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
"""
Views package for Code of Conquest web UI.
"""

View File

@@ -0,0 +1,193 @@
"""
Auth Views Blueprint
This module provides web UI routes for authentication:
- Login page
- Registration page
- Password reset pages
- Email verification
All forms use HTMX to submit to the API endpoints.
"""
from flask import Blueprint, render_template, redirect, url_for, request, session
from app.utils.auth import get_current_user, clear_user_session
from app.utils.logging import get_logger
from app.utils.api_client import get_api_client, APIError
# Initialize logger
logger = get_logger(__file__)
# Create blueprint
auth_bp = Blueprint('auth_views', __name__)
@auth_bp.route('/')
def index():
"""
Landing page / home page.
If user is authenticated, redirect to character list.
Otherwise, redirect to login page.
"""
user = get_current_user()
if user:
logger.info("Authenticated user accessing home, redirecting to characters", user_id=user.get('id'))
return redirect(url_for('character_views.list_characters'))
logger.info("Unauthenticated user accessing home, redirecting to login")
return redirect(url_for('auth_views.login'))
@auth_bp.route('/login', methods=['GET', 'POST'])
def login():
"""
Display login page and handle login.
GET: If user is already authenticated, redirect to character list.
POST: Authenticate via API and set session.
"""
user = get_current_user()
if user:
logger.info("User already authenticated, redirecting to characters", user_id=user.get('id'))
return redirect(url_for('character_views.list_characters'))
if request.method == 'POST':
# Get form data
email = request.form.get('email', '').strip()
password = request.form.get('password', '')
if not email or not password:
return render_template('auth/login.html', error="Email and password are required")
# Call API to authenticate
try:
api_client = get_api_client()
response = api_client.post("/api/v1/auth/login", data={
'email': email,
'password': password
})
# Store user in session
if response.get('result'):
session['user'] = response['result']
logger.info("User logged in successfully", user_id=response['result'].get('id'))
# Redirect to next page or character list
next_url = session.pop('next', None)
if next_url:
return redirect(next_url)
return redirect(url_for('character_views.list_characters'))
except APIError as e:
logger.warning("Login failed", error=str(e))
return render_template('auth/login.html', error=e.message)
logger.info("Rendering login page")
return render_template('auth/login.html')
@auth_bp.route('/register')
def register():
"""
Display registration page.
If user is already authenticated, redirect to character list.
"""
user = get_current_user()
if user:
logger.info("User already authenticated, redirecting to characters", user_id=user.get('id'))
return redirect(url_for('character_views.list_characters'))
logger.info("Rendering registration page")
return render_template('auth/register.html')
@auth_bp.route('/forgot-password')
def forgot_password():
"""
Display forgot password page.
Allows users to request a password reset email.
"""
logger.info("Rendering forgot password page")
return render_template('auth/forgot_password.html')
@auth_bp.route('/reset-password')
def reset_password():
"""
Display password reset page.
This page is accessed via a link in the password reset email.
The reset token should be in the query parameters.
"""
# Get reset token from query parameters
token = request.args.get('token')
user_id = request.args.get('userId')
secret = request.args.get('secret')
if not all([token, user_id, secret]):
logger.warning("Reset password accessed without required parameters")
# Could redirect to forgot-password with an error message
return redirect(url_for('auth_views.forgot_password'))
logger.info("Rendering password reset page", user_id=user_id)
return render_template(
'auth/reset_password.html',
token=token,
user_id=user_id,
secret=secret
)
@auth_bp.route('/verify-email')
def verify_email():
"""
Display email verification page.
This page is accessed via a link in the verification email.
The verification token should be in the query parameters.
"""
# Get verification token from query parameters
token = request.args.get('token')
user_id = request.args.get('userId')
secret = request.args.get('secret')
if not all([token, user_id, secret]):
logger.warning("Email verification accessed without required parameters")
return redirect(url_for('auth_views.login'))
logger.info("Rendering email verification page", user_id=user_id)
return render_template(
'auth/verify_email.html',
token=token,
user_id=user_id,
secret=secret
)
@auth_bp.route('/logout', methods=['POST'])
def logout():
"""
Handle logout by calling API and clearing session.
This is a convenience route for non-HTMX logout forms.
"""
logger.info("Logout initiated via web form")
# Call API to logout (this will invalidate session cookie)
try:
api_client = get_api_client()
api_client.post("/api/v1/auth/logout")
except APIError as e:
logger.error("Failed to call logout API", error=str(e))
# Clear local session
clear_user_session()
return redirect(url_for('auth_views.login'))

View File

@@ -0,0 +1,666 @@
"""
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('/<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('/<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
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'))

382
public_web/app/views/dev.py Normal file
View File

@@ -0,0 +1,382 @@
"""
Development-only views for testing API functionality.
This blueprint only loads when FLASK_ENV=development.
Provides HTMX-based testing interfaces for API endpoints.
"""
from flask import Blueprint, render_template, request, jsonify
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__)
dev_bp = Blueprint('dev', __name__, url_prefix='/dev')
@dev_bp.route('/')
def index():
"""Dev tools hub - links to all testing interfaces."""
return render_template('dev/index.html')
@dev_bp.route('/story')
@require_auth
def story_hub():
"""Story testing hub - select character and create/load sessions."""
client = get_api_client()
try:
# Get user's characters
characters_response = client.get('/api/v1/characters')
result = characters_response.get('result', {})
characters = result.get('characters', [])
# Get user's active sessions (if endpoint exists)
sessions = []
try:
sessions_response = client.get('/api/v1/sessions')
sessions = sessions_response.get('result', [])
except (APINotFoundError, APIError):
# Sessions list endpoint may not exist yet or has issues
pass
return render_template(
'dev/story.html',
characters=characters,
sessions=sessions
)
except APIError as e:
logger.error("failed_to_load_story_hub", error=str(e))
return render_template('dev/story.html', characters=[], sessions=[], error=str(e))
@dev_bp.route('/story/session/<session_id>')
@require_auth
def story_session(session_id: str):
"""Story session gameplay interface."""
client = get_api_client()
try:
# Get session state
session_response = client.get(f'/api/v1/sessions/{session_id}')
session_data = session_response.get('result', {})
# Get session history
history_response = client.get(f'/api/v1/sessions/{session_id}/history?limit=50')
history_data = history_response.get('result', {})
# Get NPCs at current location
npcs_present = []
game_state = session_data.get('game_state', {})
current_location = game_state.get('current_location_id') or game_state.get('current_location')
if current_location:
try:
npcs_response = client.get(f'/api/v1/npcs/at-location/{current_location}')
npcs_present = npcs_response.get('result', {}).get('npcs', [])
except (APINotFoundError, APIError):
# NPCs endpoint may not exist yet
pass
return render_template(
'dev/story_session.html',
session=session_data,
history=history_data.get('history', []),
session_id=session_id,
npcs_present=npcs_present
)
except APINotFoundError:
return render_template('dev/story.html', error=f"Session {session_id} not found"), 404
except APIError as e:
logger.error("failed_to_load_session", session_id=session_id, error=str(e))
return render_template('dev/story.html', error=str(e)), 500
# HTMX Partial endpoints
@dev_bp.route('/story/create-session', methods=['POST'])
@require_auth
def create_session():
"""Create a new story session - returns HTMX partial."""
client = get_api_client()
character_id = request.form.get('character_id')
logger.info("create_session called",
character_id=character_id,
form_data=dict(request.form))
if not character_id:
return '<div class="error">No character selected</div>', 400
try:
response = client.post('/api/v1/sessions', {'character_id': character_id})
session_data = response.get('result', {})
session_id = session_data.get('session_id')
# Return redirect script to session page
return f'''
<script>window.location.href = '/dev/story/session/{session_id}';</script>
<div class="success">Session created! Redirecting...</div>
'''
except APIError as e:
logger.error("failed_to_create_session", character_id=character_id, error=str(e))
return f'<div class="error">Failed to create session: {e}</div>', 500
@dev_bp.route('/story/action/<session_id>', methods=['POST'])
@require_auth
def take_action(session_id: str):
"""Submit an action - returns job status partial for polling."""
client = get_api_client()
action_type = request.form.get('action_type', 'button')
prompt_id = request.form.get('prompt_id')
custom_text = request.form.get('custom_text')
question = request.form.get('question')
payload = {'action_type': action_type}
if action_type == 'button' and prompt_id:
payload['prompt_id'] = prompt_id
elif action_type == 'custom' and custom_text:
payload['custom_text'] = custom_text
elif action_type == 'ask_dm' and question:
payload['question'] = question
try:
response = client.post(f'/api/v1/sessions/{session_id}/action', payload)
result = response.get('result', {})
job_id = result.get('job_id')
# Return polling partial
return render_template(
'dev/partials/job_status.html',
job_id=job_id,
session_id=session_id,
status='queued'
)
except APIError as e:
logger.error("failed_to_take_action", session_id=session_id, error=str(e))
return f'<div class="error">Action failed: {e}</div>', 500
@dev_bp.route('/story/job-status/<job_id>')
@require_auth
def job_status(job_id: str):
"""Poll job status - returns updated partial."""
client = get_api_client()
session_id = request.args.get('session_id', '')
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 - return response
# Check for NPC dialogue (in result.dialogue) vs story action (in dm_response)
nested_result = result.get('result', {})
if nested_result.get('context_type') == 'npc_dialogue':
# Use NPC dialogue template with conversation history
return render_template(
'dev/partials/npc_dialogue.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:
dm_response = result.get('dm_response', 'No response')
return render_template(
'dev/partials/dm_response.html',
dm_response=dm_response,
raw_result=result,
session_id=session_id
)
elif status in ('failed', 'error'):
error_msg = result.get('error', 'Unknown error')
return f'<div class="error">Job failed: {error_msg}</div>'
else:
# Still processing - return polling partial
return render_template(
'dev/partials/job_status.html',
job_id=job_id,
session_id=session_id,
status=status
)
except APIError as e:
logger.error("failed_to_get_job_status", job_id=job_id, error=str(e))
return f'<div class="error">Failed to get job status: {e}</div>', 500
@dev_bp.route('/story/history/<session_id>')
@require_auth
def get_history(session_id: str):
"""Get session history - returns HTMX partial."""
client = get_api_client()
limit = request.args.get('limit', 20, type=int)
offset = request.args.get('offset', 0, type=int)
try:
response = client.get(f'/api/v1/sessions/{session_id}/history?limit={limit}&offset={offset}')
result = response.get('result', {})
return render_template(
'dev/partials/history.html',
history=result.get('history', []),
pagination=result.get('pagination', {}),
session_id=session_id
)
except APIError as e:
logger.error("failed_to_get_history", session_id=session_id, error=str(e))
return f'<div class="error">Failed to load history: {e}</div>', 500
@dev_bp.route('/story/state/<session_id>')
@require_auth
def get_state(session_id: str):
"""Get current session state - returns HTMX partial."""
client = get_api_client()
try:
response = client.get(f'/api/v1/sessions/{session_id}')
session_data = response.get('result', {})
return render_template(
'dev/partials/session_state.html',
session=session_data,
session_id=session_id
)
except APIError as e:
logger.error("failed_to_get_state", session_id=session_id, error=str(e))
return f'<div class="error">Failed to load state: {e}</div>', 500
# ===== NPC & Travel Endpoints =====
@dev_bp.route('/story/talk/<session_id>/<npc_id>', methods=['POST'])
@require_auth
def talk_to_npc(session_id: str, npc_id: str):
"""Talk to an NPC - returns dialogue response."""
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 render_template(
'dev/partials/job_status.html',
job_id=job_id,
session_id=session_id,
status='queued',
is_npc_dialogue=True
)
# 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 f'''
<div class="npc-dialogue">
<div class="npc-dialogue-header">{npc_name} says:</div>
<div class="npc-dialogue-content">{dialogue}</div>
</div>
'''
except APINotFoundError:
return '<div class="error">NPC not found.</div>', 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'<div class="error">Failed to talk to NPC: {e}</div>', 500
@dev_bp.route('/story/travel-modal/<session_id>')
@require_auth
def travel_modal(session_id: str):
"""Get travel modal with available locations."""
client = get_api_client()
try:
response = client.get(f'/api/v1/travel/available?session_id={session_id}')
result = response.get('result', {})
available_locations = result.get('available_locations', [])
return render_template(
'dev/partials/travel_modal.html',
locations=available_locations,
session_id=session_id
)
except APIError as e:
logger.error("failed_to_get_travel_options", session_id=session_id, error=str(e))
return f'''
<div class="modal-overlay" onclick="this.remove()">
<div class="modal-content" onclick="event.stopPropagation()">
<h3>Travel</h3>
<div class="error">Failed to load travel options: {e}</div>
<button class="modal-close" onclick="this.closest('.modal-overlay').remove()">Close</button>
</div>
</div>
'''
@dev_bp.route('/story/travel/<session_id>', methods=['POST'])
@require_auth
def do_travel(session_id: str):
"""Travel to a new location - returns updated DM response."""
client = get_api_client()
location_id = request.form.get('location_id')
if not location_id:
return '<div class="error">No destination selected.</div>', 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:
return render_template(
'dev/partials/job_status.html',
job_id=job_id,
session_id=session_id,
status='queued'
)
# Immediate response
narrative = result.get('narrative', result.get('description', 'You arrive at your destination.'))
location_name = result.get('location_name', 'Unknown Location')
# Return script to close modal and update response
return f'''
<script>
document.querySelector('.modal-overlay')?.remove();
</script>
<div>
<strong>Arrived at {location_name}</strong><br><br>
{narrative}
</div>
'''
except APIError as e:
logger.error("failed_to_travel", session_id=session_id, location_id=location_id, error=str(e))
return f'<div class="error">Travel failed: {e}</div>', 500

View File

@@ -0,0 +1,796 @@
"""
Production game views for the play screen.
Provides the main gameplay interface with 3-column layout:
- Left: Character stats + action buttons
- Middle: Narrative + location context
- Right: Accordions for history, quests, NPCs, map
"""
from flask import Blueprint, render_template, request
import structlog
from ..utils.api_client import get_api_client, APIError, APINotFoundError
from ..utils.auth import require_auth_web as require_auth, get_current_user
logger = structlog.get_logger(__name__)
game_bp = Blueprint('game', __name__, url_prefix='/play')
# ===== Action Definitions =====
# Actions organized by tier - context filtering happens in template
# These are static definitions, available actions come from API session state
DEFAULT_ACTIONS = {
'free': [
{'prompt_id': 'ask_locals', 'display_text': 'Ask Locals for Information', 'icon': 'chat', 'context': ['town', 'tavern']},
{'prompt_id': 'explore_area', 'display_text': 'Explore the Area', 'icon': 'compass', 'context': ['wilderness', 'dungeon']},
{'prompt_id': 'search_supplies', 'display_text': 'Search for Supplies', 'icon': 'search', 'context': ['any'], 'cooldown': 2},
{'prompt_id': 'rest_recover', 'display_text': 'Rest and Recover', 'icon': 'bed', 'context': ['town', 'tavern', 'safe_area'], 'cooldown': 3}
],
'premium': [
{'prompt_id': 'investigate_suspicious', 'display_text': 'Investigate Suspicious Activity', 'icon': 'magnifying_glass', 'context': ['any']},
{'prompt_id': 'follow_lead', 'display_text': 'Follow a Lead', 'icon': 'footprints', 'context': ['any']},
{'prompt_id': 'make_camp', 'display_text': 'Make Camp', 'icon': 'campfire', 'context': ['wilderness'], 'cooldown': 5}
],
'elite': [
{'prompt_id': 'consult_texts', 'display_text': 'Consult Ancient Texts', 'icon': 'book', 'context': ['library', 'town'], 'cooldown': 3},
{'prompt_id': 'commune_nature', 'display_text': 'Commune with Nature', 'icon': 'leaf', 'context': ['wilderness'], 'cooldown': 4},
{'prompt_id': 'seek_audience', 'display_text': 'Seek Audience with Authorities', 'icon': 'crown', 'context': ['town'], 'cooldown': 5}
]
}
def _get_user_tier(client) -> str:
"""Get user's subscription tier from API or session."""
try:
# Try to get user info which includes tier
user_response = client.get('/api/v1/auth/me')
user_data = user_response.get('result', {})
return user_data.get('tier', 'free')
except (APIError, APINotFoundError):
# Default to free tier if we can't determine
return 'free'
def _build_location_from_game_state(game_state: dict) -> dict:
"""Build location dict from game_state data."""
return {
'location_id': game_state.get('current_location_id') or game_state.get('current_location'),
'name': game_state.get('current_location_name', game_state.get('current_location', 'Unknown')),
'location_type': game_state.get('location_type', 'unknown'),
'region': game_state.get('region', ''),
'description': game_state.get('location_description', ''),
'ambient_description': game_state.get('ambient_description', '')
}
def _build_character_from_api(char_data: dict) -> dict:
"""
Build character dict from API character response.
Always returns a dict with all required fields, using sensible defaults
if the API data is incomplete or empty.
"""
if not char_data:
char_data = {}
# Extract stats from base_stats or stats, with defaults
stats = char_data.get('base_stats', char_data.get('stats', {}))
if not stats:
stats = {
'strength': 10,
'dexterity': 10,
'constitution': 10,
'intelligence': 10,
'wisdom': 10,
'charisma': 10
}
# Calculate HP/MP - these may come from different places
# For now use defaults based on level/constitution
level = char_data.get('level', 1)
constitution = stats.get('constitution', 10)
intelligence = stats.get('intelligence', 10)
# Simple HP/MP calculation (can be refined based on game rules)
max_hp = max(1, 50 + (level * 10) + ((constitution - 10) * level))
max_mp = max(1, 20 + (level * 5) + ((intelligence - 10) * level // 2))
# Get class name from various possible locations
class_name = 'Unknown'
if char_data.get('player_class'):
class_name = char_data['player_class'].get('name', 'Unknown')
elif char_data.get('class_name'):
class_name = char_data['class_name']
elif char_data.get('class'):
class_name = char_data['class'].replace('_', ' ').title()
return {
'character_id': char_data.get('character_id', ''),
'name': char_data.get('name', 'Unknown Hero'),
'class_name': class_name,
'level': level,
'current_hp': char_data.get('current_hp', max_hp),
'max_hp': char_data.get('max_hp', max_hp),
'current_mp': char_data.get('current_mp', max_mp),
'max_mp': char_data.get('max_mp', max_mp),
'stats': stats,
'equipped': char_data.get('equipped', {}),
'inventory': char_data.get('inventory', []),
'gold': char_data.get('gold', 0),
'experience': char_data.get('experience', 0)
}
# ===== Main Routes =====
@game_bp.route('/session/<session_id>')
@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)
# 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
)
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/<session_id>/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)
return render_template(
'game/partials/character_panel.html',
session_id=session_id,
character=character,
location=location,
actions=DEFAULT_ACTIONS,
user_tier=user_tier
)
except APIError as e:
logger.error("failed_to_refresh_character_panel", session_id=session_id, error=str(e))
return f'<div class="error">Failed to load character panel: {e}</div>', 500
@game_bp.route('/session/<session_id>/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'<div class="error">Failed to load narrative: {e}</div>', 500
@game_bp.route('/session/<session_id>/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'<div class="error">Failed to load history: {e}</div>', 500
@game_bp.route('/session/<session_id>/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'<div class="error">Failed to load quests: {e}</div>', 500
@game_bp.route('/session/<session_id>/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'<div class="error">Failed to load NPCs: {e}</div>', 500
@game_bp.route('/session/<session_id>/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'<div class="error">Failed to load map: {e}</div>', 500
# ===== Action Routes =====
@game_bp.route('/session/<session_id>/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 '<div class="dm-response error">Please enter an action.</div>', 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 '<div class="dm-response error">No action selected.</div>', 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'<div class="dm-response error">Action failed: {e}</div>', 500
@game_bp.route('/session/<session_id>/job/<job_id>')
@require_auth
def poll_job(session_id: str, job_id: str):
"""Poll job status - returns updated partial."""
client = get_api_client()
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'<div class="dm-response error">Action failed: {error_msg}</div>'
else:
# Still processing - return polling partial to continue
return render_template(
'game/partials/job_polling.html',
session_id=session_id,
job_id=job_id,
status=status
)
except APIError as e:
logger.error("failed_to_poll_job", job_id=job_id, session_id=session_id, error=str(e))
return f'<div class="dm-response error">Failed to check job status: {e}</div>', 500
# ===== Modal Routes =====
@game_bp.route('/session/<session_id>/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'''
<div class="modal-overlay" onclick="this.remove()">
<div class="modal-content" onclick="event.stopPropagation()">
<h3>Equipment</h3>
<div class="error">Failed to load equipment: {e}</div>
<button class="modal-close" onclick="this.closest('.modal-overlay').remove()">Close</button>
</div>
</div>
'''
@game_bp.route('/session/<session_id>/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'''
<div class="modal-overlay" onclick="this.remove()">
<div class="modal-content" onclick="event.stopPropagation()">
<h3>Travel</h3>
<div class="error">Failed to load travel options: {e}</div>
<button class="modal-close" onclick="this.closest('.modal-overlay').remove()">Close</button>
</div>
</div>
'''
@game_bp.route('/session/<session_id>/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 '<div class="error">No destination selected.</div>', 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'''
<script>document.querySelector('.modal-overlay')?.remove();</script>
''' + 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'''
<script>document.querySelector('.modal-overlay')?.remove();</script>
<div class="dm-response">
<strong>Arrived at {location_name}</strong><br><br>
{narrative}
</div>
''' + 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'<div class="error">Travel failed: {e}</div>', 500
@game_bp.route('/session/<session_id>/npc/<npc_id>/chat')
@require_auth
def npc_chat_modal(session_id: str, npc_id: str):
"""Get NPC chat modal with conversation history."""
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', [])
}
# 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 '<div class="error">NPC not found</div>', 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'''
<div class="modal-overlay" onclick="this.remove()">
<div class="modal-content" onclick="event.stopPropagation()">
<h3>Talk to NPC</h3>
<div class="error">Failed to load NPC info: {e}</div>
<button class="modal-close" onclick="this.closest('.modal-overlay').remove()">Close</button>
</div>
</div>
'''
@game_bp.route('/session/<session_id>/npc/<npc_id>/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
return render_template(
'game/partials/job_polling.html',
job_id=job_id,
session_id=session_id,
status='queued',
is_npc_dialogue=True
)
# 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'''
<div class="chat-message chat-message--player">
<strong>You:</strong> {player_display}
</div>
<div class="chat-message chat-message--npc">
<strong>{npc_name}:</strong> {dialogue}
</div>
'''
except APINotFoundError:
return '<div class="error">NPC not found.</div>', 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'<div class="error">Failed to talk to NPC: {e}</div>', 500