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,83 @@
"""
Public Web Frontend - Flask Application Factory
This is a lightweight web frontend that provides HTML/HTMX UI for the Code of Conquest game.
All business logic is handled by the API backend - this frontend only renders views and
makes HTTP requests to the API.
"""
from flask import Flask
from flask import render_template
import structlog
import yaml
import os
from pathlib import Path
logger = structlog.get_logger(__name__)
def load_config():
"""Load configuration from YAML file based on environment."""
env = os.getenv("FLASK_ENV", "development")
config_path = Path(__file__).parent.parent / "config" / f"{env}.yaml"
with open(config_path, 'r') as f:
config = yaml.safe_load(f)
logger.info("configuration_loaded", env=env, config_path=str(config_path))
return config
def create_app():
"""Create and configure the Flask application."""
app = Flask(__name__,
template_folder="../templates",
static_folder="../static")
# Load configuration
config = load_config()
app.config.update(config)
# Configure secret key from environment
app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production')
# Context processor to make API config and user available in templates
@app.context_processor
def inject_template_globals():
"""Make API base URL and current user available to all templates."""
from .utils.auth import get_current_user
return {
'api_base_url': app.config.get('api', {}).get('base_url', 'http://localhost:5000'),
'current_user': get_current_user()
}
# Register blueprints
from .views.auth_views import auth_bp
from .views.character_views import character_bp
from .views.game_views import game_bp
app.register_blueprint(auth_bp)
app.register_blueprint(character_bp)
app.register_blueprint(game_bp)
# Register dev blueprint only in development
env = os.getenv("FLASK_ENV", "development")
if env == "development":
from .views.dev import dev_bp
app.register_blueprint(dev_bp)
logger.info("dev_blueprint_registered", message="Dev testing routes available at /dev")
# Error handlers
@app.errorhandler(404)
def not_found(error):
return render_template('errors/404.html'), 404
@app.errorhandler(500)
def internal_error(error):
logger.error("internal_server_error", error=str(error))
return render_template('errors/500.html'), 500
logger.info("flask_app_created", blueprints=["auth", "character", "game"])
return app

87
public_web/app/config.py Normal file
View File

@@ -0,0 +1,87 @@
"""
Configuration loader for Public Web Frontend
Loads environment-specific configuration from YAML files.
"""
import yaml
import os
from pathlib import Path
from dataclasses import dataclass
from typing import Optional
import structlog
logger = structlog.get_logger(__name__)
@dataclass
class ServerConfig:
"""Server configuration settings."""
host: str
port: int
debug: bool
workers: int = 4
@dataclass
class APIConfig:
"""API backend configuration."""
base_url: str
timeout: int = 30
verify_ssl: bool = True
@dataclass
class SessionConfig:
"""Session configuration."""
lifetime_hours: int
cookie_secure: bool
cookie_httponly: bool
cookie_samesite: str
@dataclass
class Config:
"""Main configuration object."""
server: ServerConfig
api: APIConfig
session: SessionConfig
environment: str
def load_config(environment: Optional[str] = None) -> Config:
"""
Load configuration from YAML file.
Args:
environment: Environment name (development, production). If None, uses FLASK_ENV env var.
Returns:
Config object with all settings.
"""
if environment is None:
environment = os.getenv("FLASK_ENV", "development")
config_dir = Path(__file__).parent.parent / "config"
config_path = config_dir / f"{environment}.yaml"
if not config_path.exists():
raise FileNotFoundError(f"Configuration file not found: {config_path}")
with open(config_path, 'r') as f:
data = yaml.safe_load(f)
config = Config(
server=ServerConfig(**data['server']),
api=APIConfig(**data['api']),
session=SessionConfig(**data['session']),
environment=environment
)
logger.info("config_loaded",
environment=environment,
api_url=config.api.base_url,
server_port=config.server.port)
return config

View File

@@ -0,0 +1 @@
"""Utility modules for public web frontend."""

View File

@@ -0,0 +1,336 @@
"""
API Client for Public Web Frontend
Provides HTTP request wrapper for communicating with the API backend.
Handles session cookie forwarding, error handling, and response parsing.
"""
import requests
from flask import request as flask_request, session as flask_session
from typing import Optional, Any
from app.config import load_config
from .logging import get_logger
logger = get_logger(__name__)
class APIError(Exception):
"""Base exception for API errors."""
def __init__(self, message: str, status_code: int = 500, details: Optional[dict] = None):
self.message = message
self.status_code = status_code
self.details = details or {}
super().__init__(self.message)
class APITimeoutError(APIError):
"""Raised when API request times out."""
def __init__(self, message: str = "Request timed out"):
super().__init__(message, status_code=504)
class APINotFoundError(APIError):
"""Raised when resource not found (404)."""
def __init__(self, message: str = "Resource not found"):
super().__init__(message, status_code=404)
class APIAuthenticationError(APIError):
"""Raised when authentication fails (401)."""
def __init__(self, message: str = "Authentication required"):
super().__init__(message, status_code=401)
class APIClient:
"""
HTTP client for making requests to the API backend.
Usage:
client = APIClient()
# GET request
response = client.get("/api/v1/characters")
characters = response.get("result", [])
# POST request
response = client.post("/api/v1/characters", data={"name": "Hero"})
# DELETE request
client.delete("/api/v1/characters/123")
"""
def __init__(self):
"""Initialize API client with config."""
self.config = load_config()
self.base_url = self.config.api.base_url.rstrip('/')
self.timeout = self.config.api.timeout
self.verify_ssl = self.config.api.verify_ssl
def _get_cookies(self) -> dict:
"""
Get cookies to forward to API.
Returns:
Dictionary of cookies to forward (session cookie).
"""
cookies = {}
# Get session cookie from Flask session (stored after login)
try:
if 'api_session_cookie' in flask_session:
cookies['coc_session'] = flask_session['api_session_cookie']
except RuntimeError:
# Outside of request context
pass
return cookies
def _save_session_cookie(self, response: requests.Response) -> None:
"""
Save session cookie from API response to Flask session.
Args:
response: Response from requests library.
"""
try:
session_cookie = response.cookies.get('coc_session')
if session_cookie:
flask_session['api_session_cookie'] = session_cookie
logger.debug("Saved API session cookie to Flask session")
except RuntimeError:
# Outside of request context
pass
def _get_headers(self) -> dict:
"""
Get default headers for API requests.
Returns:
Dictionary of headers.
"""
return {
'Content-Type': 'application/json',
'Accept': 'application/json'
}
def _handle_response(self, response: requests.Response) -> dict:
"""
Handle API response and raise appropriate exceptions.
Args:
response: Response from requests library.
Returns:
Parsed JSON response.
Raises:
APIError: For various HTTP error codes.
"""
try:
data = response.json()
except ValueError:
data = {}
# Check for errors
if response.status_code == 401:
error_msg = data.get('error', {}).get('message', 'Authentication required')
raise APIAuthenticationError(error_msg)
if response.status_code == 404:
error_msg = data.get('error', {}).get('message', 'Resource not found')
raise APINotFoundError(error_msg)
if response.status_code >= 400:
error_msg = data.get('error', {}).get('message', f'API error: {response.status_code}')
error_details = data.get('error', {}).get('details', {})
raise APIError(error_msg, response.status_code, error_details)
return data
def get(self, endpoint: str, params: Optional[dict] = None) -> dict:
"""
Make GET request to API.
Args:
endpoint: API endpoint (e.g., "/api/v1/characters").
params: Optional query parameters.
Returns:
Parsed JSON response.
Raises:
APIError: For various error conditions.
"""
url = f"{self.base_url}{endpoint}"
try:
response = requests.get(
url,
params=params,
headers=self._get_headers(),
cookies=self._get_cookies(),
timeout=self.timeout,
verify=self.verify_ssl
)
logger.debug("API GET request", url=url, status=response.status_code)
return self._handle_response(response)
except requests.exceptions.Timeout:
logger.error("API timeout", url=url)
raise APITimeoutError()
except requests.exceptions.ConnectionError as e:
logger.error("API connection error", url=url, error=str(e))
raise APIError(f"Could not connect to API: {str(e)}", status_code=503)
except requests.exceptions.RequestException as e:
logger.error("API request error", url=url, error=str(e))
raise APIError(f"Request failed: {str(e)}")
def post(self, endpoint: str, data: Optional[dict] = None) -> dict:
"""
Make POST request to API.
Args:
endpoint: API endpoint (e.g., "/api/v1/characters").
data: Request body data.
Returns:
Parsed JSON response.
Raises:
APIError: For various error conditions.
"""
url = f"{self.base_url}{endpoint}"
try:
response = requests.post(
url,
json=data or {},
headers=self._get_headers(),
cookies=self._get_cookies(),
timeout=self.timeout,
verify=self.verify_ssl
)
logger.debug("API POST request", url=url, status=response.status_code)
# Save session cookie if present (for login responses)
self._save_session_cookie(response)
return self._handle_response(response)
except requests.exceptions.Timeout:
logger.error("API timeout", url=url)
raise APITimeoutError()
except requests.exceptions.ConnectionError as e:
logger.error("API connection error", url=url, error=str(e))
raise APIError(f"Could not connect to API: {str(e)}", status_code=503)
except requests.exceptions.RequestException as e:
logger.error("API request error", url=url, error=str(e))
raise APIError(f"Request failed: {str(e)}")
def delete(self, endpoint: str) -> dict:
"""
Make DELETE request to API.
Args:
endpoint: API endpoint (e.g., "/api/v1/characters/123").
Returns:
Parsed JSON response.
Raises:
APIError: For various error conditions.
"""
url = f"{self.base_url}{endpoint}"
try:
response = requests.delete(
url,
headers=self._get_headers(),
cookies=self._get_cookies(),
timeout=self.timeout,
verify=self.verify_ssl
)
logger.debug("API DELETE request", url=url, status=response.status_code)
return self._handle_response(response)
except requests.exceptions.Timeout:
logger.error("API timeout", url=url)
raise APITimeoutError()
except requests.exceptions.ConnectionError as e:
logger.error("API connection error", url=url, error=str(e))
raise APIError(f"Could not connect to API: {str(e)}", status_code=503)
except requests.exceptions.RequestException as e:
logger.error("API request error", url=url, error=str(e))
raise APIError(f"Request failed: {str(e)}")
def put(self, endpoint: str, data: Optional[dict] = None) -> dict:
"""
Make PUT request to API.
Args:
endpoint: API endpoint.
data: Request body data.
Returns:
Parsed JSON response.
Raises:
APIError: For various error conditions.
"""
url = f"{self.base_url}{endpoint}"
try:
response = requests.put(
url,
json=data or {},
headers=self._get_headers(),
cookies=self._get_cookies(),
timeout=self.timeout,
verify=self.verify_ssl
)
logger.debug("API PUT request", url=url, status=response.status_code)
return self._handle_response(response)
except requests.exceptions.Timeout:
logger.error("API timeout", url=url)
raise APITimeoutError()
except requests.exceptions.ConnectionError as e:
logger.error("API connection error", url=url, error=str(e))
raise APIError(f"Could not connect to API: {str(e)}", status_code=503)
except requests.exceptions.RequestException as e:
logger.error("API request error", url=url, error=str(e))
raise APIError(f"Request failed: {str(e)}")
# Singleton instance
_api_client: Optional[APIClient] = None
def get_api_client() -> APIClient:
"""
Get singleton API client instance.
Returns:
APIClient instance.
"""
global _api_client
if _api_client is None:
_api_client = APIClient()
return _api_client

View File

@@ -0,0 +1,146 @@
"""
Authentication utilities for public web frontend.
Provides authentication checking and decorators for protected routes.
Uses API backend for session validation.
"""
from functools import wraps
from flask import session, redirect, url_for, request, flash
from .logging import get_logger
logger = get_logger(__file__)
# Track last API validation time per session to avoid excessive checks
_SESSION_VALIDATION_KEY = '_api_validated_at'
def get_current_user():
"""
Get the currently authenticated user from session.
Returns:
Dictionary with user data if authenticated, None otherwise.
"""
# Check if we have user in Flask session
if 'user' in session and session.get('user'):
return session['user']
return None
def require_auth_web(f):
"""
Decorator to require authentication for web routes.
Validates the session with the API backend and redirects to
login if not authenticated.
Args:
f: Flask route function
Returns:
Wrapped function that checks authentication
"""
@wraps(f)
def decorated_function(*args, **kwargs):
user = get_current_user()
if user is None:
logger.info("Unauthenticated access attempt", path=request.path)
# Store the intended destination
session['next'] = request.url
return redirect(url_for('auth_views.login'))
return f(*args, **kwargs)
return decorated_function
def clear_user_session():
"""
Clear user session data.
Should be called after logout.
"""
session.pop('user', None)
session.pop('next', None)
session.pop('api_session_cookie', None)
session.pop(_SESSION_VALIDATION_KEY, None)
logger.debug("User session cleared")
def require_auth_strict(revalidate_interval: int = 300):
"""
Decorator to require authentication with API session validation.
This decorator validates the session with the API backend periodically
to ensure the session is still valid on the server side.
Args:
revalidate_interval: Seconds between API validation checks (default 5 minutes).
Set to 0 to validate on every request.
Returns:
Decorator function.
Usage:
@app.route('/protected')
@require_auth_strict() # Validates every 5 minutes
def protected_route():
pass
@app.route('/sensitive')
@require_auth_strict(revalidate_interval=0) # Validates every request
def sensitive_route():
pass
"""
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
import time
from .api_client import get_api_client, APIAuthenticationError, APIError
user = get_current_user()
if user is None:
logger.info("Unauthenticated access attempt", path=request.path)
session['next'] = request.url
return redirect(url_for('auth_views.login'))
# Check if we need to revalidate with API
current_time = time.time()
last_validated = session.get(_SESSION_VALIDATION_KEY, 0)
if revalidate_interval == 0 or (current_time - last_validated) > revalidate_interval:
try:
# Validate session by hitting a lightweight endpoint
api_client = get_api_client()
api_client.get("/api/v1/auth/me")
# Update validation timestamp
session[_SESSION_VALIDATION_KEY] = current_time
session.modified = True
logger.debug("API session validated", user_id=user.get('id'))
except APIAuthenticationError:
# Session expired on server side
logger.warning(
"API session expired",
user_id=user.get('id'),
path=request.path
)
clear_user_session()
flash('Your session has expired. Please log in again.', 'warning')
session['next'] = request.url
return redirect(url_for('auth_views.login'))
except APIError as e:
# API error - log but allow through (fail open for availability)
logger.error(
"API validation error",
user_id=user.get('id'),
error=str(e)
)
# Don't block the user, but don't update validation timestamp
return f(*args, **kwargs)
return decorated_function
return decorator

View File

@@ -0,0 +1,47 @@
"""
Logging utilities for public web frontend.
Simplified logging wrapper using structlog.
"""
import structlog
import logging
import sys
from pathlib import Path
def setup_logging():
"""Configure structured logging for the web frontend."""
structlog.configure(
processors=[
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.add_log_level,
structlog.processors.StackInfoRenderer(),
structlog.dev.ConsoleRenderer()
],
wrapper_class=structlog.make_filtering_bound_logger(logging.INFO),
context_class=dict,
logger_factory=structlog.PrintLoggerFactory(),
cache_logger_on_first_use=True,
)
def get_logger(name: str):
"""
Get a logger instance.
Args:
name: Logger name (usually __file__)
Returns:
Configured structlog logger
"""
if isinstance(name, str) and name.endswith('.py'):
# Extract module name from file path
name = Path(name).stem
return structlog.get_logger(name)
# Setup logging on module import
setup_logging()

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