first commit
This commit is contained in:
83
public_web/app/__init__.py
Normal file
83
public_web/app/__init__.py
Normal 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
87
public_web/app/config.py
Normal 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
|
||||
1
public_web/app/utils/__init__.py
Normal file
1
public_web/app/utils/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Utility modules for public web frontend."""
|
||||
336
public_web/app/utils/api_client.py
Normal file
336
public_web/app/utils/api_client.py
Normal 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
|
||||
146
public_web/app/utils/auth.py
Normal file
146
public_web/app/utils/auth.py
Normal 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
|
||||
47
public_web/app/utils/logging.py
Normal file
47
public_web/app/utils/logging.py
Normal 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()
|
||||
3
public_web/app/views/__init__.py
Normal file
3
public_web/app/views/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Views package for Code of Conquest web UI.
|
||||
"""
|
||||
193
public_web/app/views/auth_views.py
Normal file
193
public_web/app/views/auth_views.py
Normal 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'))
|
||||
666
public_web/app/views/character_views.py
Normal file
666
public_web/app/views/character_views.py
Normal 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
382
public_web/app/views/dev.py
Normal 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
|
||||
796
public_web/app/views/game_views.py
Normal file
796
public_web/app/views/game_views.py
Normal 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
|
||||
Reference in New Issue
Block a user