first commit
This commit is contained in:
0
api/app/api/__init__.py
Normal file
0
api/app/api/__init__.py
Normal file
529
api/app/api/auth.py
Normal file
529
api/app/api/auth.py
Normal file
@@ -0,0 +1,529 @@
|
||||
"""
|
||||
Authentication API Blueprint
|
||||
|
||||
This module provides API endpoints for user authentication and management:
|
||||
- User registration
|
||||
- User login/logout
|
||||
- Email verification
|
||||
- Password reset
|
||||
|
||||
All endpoints follow the standard API response format defined in app.utils.response.
|
||||
"""
|
||||
|
||||
import re
|
||||
from flask import Blueprint, request, make_response, render_template, redirect, url_for, flash
|
||||
from appwrite.exception import AppwriteException
|
||||
|
||||
from app.services.appwrite_service import AppwriteService
|
||||
from app.utils.response import (
|
||||
success_response,
|
||||
created_response,
|
||||
error_response,
|
||||
unauthorized_response,
|
||||
validation_error_response
|
||||
)
|
||||
from app.utils.auth import require_auth, get_current_user, extract_session_token
|
||||
from app.utils.logging import get_logger
|
||||
from app.config import get_config
|
||||
|
||||
|
||||
# Initialize logger
|
||||
logger = get_logger(__file__)
|
||||
|
||||
# Create blueprint
|
||||
auth_bp = Blueprint('auth', __name__)
|
||||
|
||||
|
||||
# ===== VALIDATION FUNCTIONS =====
|
||||
|
||||
def validate_email(email: str) -> tuple[bool, str]:
|
||||
"""
|
||||
Validate email address format.
|
||||
|
||||
Args:
|
||||
email: Email address to validate
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
if not email:
|
||||
return False, "Email is required"
|
||||
|
||||
config = get_config()
|
||||
max_length = config.auth.email_max_length
|
||||
|
||||
if len(email) > max_length:
|
||||
return False, f"Email must be no more than {max_length} characters"
|
||||
|
||||
# Email regex pattern
|
||||
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
|
||||
if not re.match(pattern, email):
|
||||
return False, "Invalid email format"
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
def validate_password(password: str) -> tuple[bool, str]:
|
||||
"""
|
||||
Validate password strength.
|
||||
|
||||
Args:
|
||||
password: Password to validate
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
if not password:
|
||||
return False, "Password is required"
|
||||
|
||||
config = get_config()
|
||||
|
||||
min_length = config.auth.password_min_length
|
||||
if len(password) < min_length:
|
||||
return False, f"Password must be at least {min_length} characters long"
|
||||
|
||||
if len(password) > 128:
|
||||
return False, "Password must be no more than 128 characters"
|
||||
|
||||
errors = []
|
||||
|
||||
if config.auth.password_require_uppercase and not re.search(r'[A-Z]', password):
|
||||
errors.append("at least one uppercase letter")
|
||||
|
||||
if config.auth.password_require_lowercase and not re.search(r'[a-z]', password):
|
||||
errors.append("at least one lowercase letter")
|
||||
|
||||
if config.auth.password_require_number and not re.search(r'[0-9]', password):
|
||||
errors.append("at least one number")
|
||||
|
||||
if config.auth.password_require_special and not re.search(r'[!@#$%^&*()_+\-=\[\]{}|;:,.<>?]', password):
|
||||
errors.append("at least one special character")
|
||||
|
||||
if errors:
|
||||
return False, f"Password must contain {', '.join(errors)}"
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
def validate_name(name: str) -> tuple[bool, str]:
|
||||
"""
|
||||
Validate user name.
|
||||
|
||||
Args:
|
||||
name: Name to validate
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
if not name:
|
||||
return False, "Name is required"
|
||||
|
||||
config = get_config()
|
||||
|
||||
min_length = config.auth.name_min_length
|
||||
max_length = config.auth.name_max_length
|
||||
|
||||
if len(name) < min_length:
|
||||
return False, f"Name must be at least {min_length} characters"
|
||||
|
||||
if len(name) > max_length:
|
||||
return False, f"Name must be no more than {max_length} characters"
|
||||
|
||||
# Allow letters, spaces, hyphens, apostrophes
|
||||
if not re.match(r"^[a-zA-Z\s\-']+$", name):
|
||||
return False, "Name can only contain letters, spaces, hyphens, and apostrophes"
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
# ===== API ENDPOINTS =====
|
||||
|
||||
@auth_bp.route('/api/v1/auth/register', methods=['POST'])
|
||||
def api_register():
|
||||
"""
|
||||
Register a new user account.
|
||||
|
||||
Request Body:
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"password": "SecurePass123!",
|
||||
"name": "Player Name"
|
||||
}
|
||||
|
||||
Returns:
|
||||
201: User created successfully
|
||||
400: Validation error or email already exists
|
||||
500: Internal server error
|
||||
"""
|
||||
try:
|
||||
# Get request data
|
||||
data = request.get_json()
|
||||
|
||||
if not data:
|
||||
return validation_error_response({"error": "Request body is required"})
|
||||
|
||||
email = data.get('email', '').strip().lower()
|
||||
password = data.get('password', '')
|
||||
name = data.get('name', '').strip()
|
||||
|
||||
# Validate inputs
|
||||
validation_errors = {}
|
||||
|
||||
email_valid, email_error = validate_email(email)
|
||||
if not email_valid:
|
||||
validation_errors['email'] = email_error
|
||||
|
||||
password_valid, password_error = validate_password(password)
|
||||
if not password_valid:
|
||||
validation_errors['password'] = password_error
|
||||
|
||||
name_valid, name_error = validate_name(name)
|
||||
if not name_valid:
|
||||
validation_errors['name'] = name_error
|
||||
|
||||
if validation_errors:
|
||||
return validation_error_response(validation_errors)
|
||||
|
||||
# Register user
|
||||
appwrite = AppwriteService()
|
||||
user_data = appwrite.register_user(email=email, password=password, name=name)
|
||||
|
||||
logger.info("User registered successfully", user_id=user_data.id, email=email)
|
||||
|
||||
return created_response(
|
||||
result={
|
||||
"user": user_data.to_dict(),
|
||||
"message": "Registration successful. Please check your email to verify your account."
|
||||
}
|
||||
)
|
||||
|
||||
except AppwriteException as e:
|
||||
logger.error("Registration failed", error=str(e), code=e.code)
|
||||
|
||||
# Check for specific error codes
|
||||
if e.code == 409: # Conflict - user already exists
|
||||
return validation_error_response({"email": "An account with this email already exists"})
|
||||
|
||||
return error_response(message="Registration failed. Please try again.", code="REGISTRATION_ERROR")
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Unexpected error during registration", error=str(e))
|
||||
return error_response(message="An unexpected error occurred", code="INTERNAL_ERROR")
|
||||
|
||||
|
||||
@auth_bp.route('/api/v1/auth/login', methods=['POST'])
|
||||
def api_login():
|
||||
"""
|
||||
Authenticate a user and create a session.
|
||||
|
||||
Request Body:
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"password": "SecurePass123!",
|
||||
"remember_me": false
|
||||
}
|
||||
|
||||
Returns:
|
||||
200: Login successful, session cookie set
|
||||
401: Invalid credentials
|
||||
400: Validation error
|
||||
500: Internal server error
|
||||
"""
|
||||
try:
|
||||
# Get request data
|
||||
data = request.get_json()
|
||||
|
||||
if not data:
|
||||
return validation_error_response({"error": "Request body is required"})
|
||||
|
||||
email = data.get('email', '').strip().lower()
|
||||
password = data.get('password', '')
|
||||
remember_me = data.get('remember_me', False)
|
||||
|
||||
# Validate inputs
|
||||
if not email:
|
||||
return validation_error_response({"email": "Email is required"})
|
||||
|
||||
if not password:
|
||||
return validation_error_response({"password": "Password is required"})
|
||||
|
||||
# Authenticate user
|
||||
appwrite = AppwriteService()
|
||||
session_data, user_data = appwrite.login_user(email=email, password=password)
|
||||
|
||||
logger.info("User logged in successfully", user_id=user_data.id, email=email)
|
||||
|
||||
# Set session cookie
|
||||
config = get_config()
|
||||
duration = config.auth.duration_remember_me if remember_me else config.auth.duration_normal
|
||||
|
||||
response = make_response(success_response(
|
||||
result={
|
||||
"user": user_data.to_dict(),
|
||||
"message": "Login successful"
|
||||
}
|
||||
))
|
||||
|
||||
response.set_cookie(
|
||||
key=config.auth.cookie_name,
|
||||
value=session_data.session_id,
|
||||
max_age=duration,
|
||||
httponly=config.auth.http_only,
|
||||
secure=config.auth.secure,
|
||||
samesite=config.auth.same_site,
|
||||
path=config.auth.path
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
except AppwriteException as e:
|
||||
logger.warning("Login failed", email=email if 'email' in locals() else 'unknown', error=str(e), code=e.code)
|
||||
|
||||
# Generic error message for security (don't reveal if email exists)
|
||||
return unauthorized_response(message="Invalid email or password")
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Unexpected error during login", error=str(e))
|
||||
return error_response(message="An unexpected error occurred", code="INTERNAL_ERROR")
|
||||
|
||||
|
||||
@auth_bp.route('/api/v1/auth/logout', methods=['POST'])
|
||||
@require_auth
|
||||
def api_logout():
|
||||
"""
|
||||
Log out the current user by deleting their session.
|
||||
|
||||
Returns:
|
||||
200: Logout successful, session cookie cleared
|
||||
401: Not authenticated
|
||||
500: Internal server error
|
||||
"""
|
||||
try:
|
||||
# Get session token
|
||||
token = extract_session_token()
|
||||
|
||||
if not token:
|
||||
return unauthorized_response(message="No active session")
|
||||
|
||||
# Logout user
|
||||
appwrite = AppwriteService()
|
||||
appwrite.logout_user(session_id=token)
|
||||
|
||||
user = get_current_user()
|
||||
logger.info("User logged out successfully", user_id=user.id if user else 'unknown')
|
||||
|
||||
# Clear session cookie
|
||||
config = get_config()
|
||||
|
||||
response = make_response(success_response(
|
||||
result={"message": "Logout successful"}
|
||||
))
|
||||
|
||||
response.set_cookie(
|
||||
key=config.auth.cookie_name,
|
||||
value='',
|
||||
max_age=0,
|
||||
httponly=config.auth.http_only,
|
||||
secure=config.auth.secure,
|
||||
samesite=config.auth.same_site,
|
||||
path=config.auth.path
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
except AppwriteException as e:
|
||||
logger.error("Logout failed", error=str(e), code=e.code)
|
||||
return error_response(message="Logout failed", code="LOGOUT_ERROR")
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Unexpected error during logout", error=str(e))
|
||||
return error_response(message="An unexpected error occurred", code="INTERNAL_ERROR")
|
||||
|
||||
|
||||
@auth_bp.route('/api/v1/auth/verify-email', methods=['GET'])
|
||||
def api_verify_email():
|
||||
"""
|
||||
Verify a user's email address.
|
||||
|
||||
Query Parameters:
|
||||
userId: User ID from verification link
|
||||
secret: Verification secret from verification link
|
||||
|
||||
Returns:
|
||||
Redirects to login page with success/error message
|
||||
"""
|
||||
try:
|
||||
user_id = request.args.get('userId')
|
||||
secret = request.args.get('secret')
|
||||
|
||||
if not user_id or not secret:
|
||||
flash("Invalid verification link", "error")
|
||||
return redirect(url_for('auth.login_page'))
|
||||
|
||||
# Verify email
|
||||
appwrite = AppwriteService()
|
||||
appwrite.verify_email(user_id=user_id, secret=secret)
|
||||
|
||||
logger.info("Email verified successfully", user_id=user_id)
|
||||
|
||||
flash("Email verified successfully! You can now log in.", "success")
|
||||
return redirect(url_for('auth.login_page'))
|
||||
|
||||
except AppwriteException as e:
|
||||
logger.error("Email verification failed", error=str(e), code=e.code)
|
||||
flash("Email verification failed. The link may be invalid or expired.", "error")
|
||||
return redirect(url_for('auth.login_page'))
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Unexpected error during email verification", error=str(e))
|
||||
flash("An unexpected error occurred", "error")
|
||||
return redirect(url_for('auth.login_page'))
|
||||
|
||||
|
||||
@auth_bp.route('/api/v1/auth/forgot-password', methods=['POST'])
|
||||
def api_forgot_password():
|
||||
"""
|
||||
Request a password reset email.
|
||||
|
||||
Request Body:
|
||||
{
|
||||
"email": "user@example.com"
|
||||
}
|
||||
|
||||
Returns:
|
||||
200: Always returns success (for security, don't reveal if email exists)
|
||||
400: Validation error
|
||||
500: Internal server error
|
||||
"""
|
||||
try:
|
||||
# Get request data
|
||||
data = request.get_json()
|
||||
|
||||
if not data:
|
||||
return validation_error_response({"error": "Request body is required"})
|
||||
|
||||
email = data.get('email', '').strip().lower()
|
||||
|
||||
# Validate email
|
||||
email_valid, email_error = validate_email(email)
|
||||
if not email_valid:
|
||||
return validation_error_response({"email": email_error})
|
||||
|
||||
# Request password reset
|
||||
appwrite = AppwriteService()
|
||||
appwrite.request_password_reset(email=email)
|
||||
|
||||
logger.info("Password reset requested", email=email)
|
||||
|
||||
# Always return success for security
|
||||
return success_response(
|
||||
result={
|
||||
"message": "If an account exists with this email, you will receive a password reset link shortly."
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Unexpected error during password reset request", error=str(e))
|
||||
# Still return success for security
|
||||
return success_response(
|
||||
result={
|
||||
"message": "If an account exists with this email, you will receive a password reset link shortly."
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@auth_bp.route('/api/v1/auth/reset-password', methods=['POST'])
|
||||
def api_reset_password():
|
||||
"""
|
||||
Confirm password reset and update password.
|
||||
|
||||
Request Body:
|
||||
{
|
||||
"user_id": "user_id_from_link",
|
||||
"secret": "secret_from_link",
|
||||
"password": "NewSecurePass123!"
|
||||
}
|
||||
|
||||
Returns:
|
||||
200: Password reset successful
|
||||
400: Validation error or invalid/expired link
|
||||
500: Internal server error
|
||||
"""
|
||||
try:
|
||||
# Get request data
|
||||
data = request.get_json()
|
||||
|
||||
if not data:
|
||||
return validation_error_response({"error": "Request body is required"})
|
||||
|
||||
user_id = data.get('user_id', '').strip()
|
||||
secret = data.get('secret', '').strip()
|
||||
password = data.get('password', '')
|
||||
|
||||
# Validate inputs
|
||||
validation_errors = {}
|
||||
|
||||
if not user_id:
|
||||
validation_errors['user_id'] = "User ID is required"
|
||||
|
||||
if not secret:
|
||||
validation_errors['secret'] = "Reset secret is required"
|
||||
|
||||
password_valid, password_error = validate_password(password)
|
||||
if not password_valid:
|
||||
validation_errors['password'] = password_error
|
||||
|
||||
if validation_errors:
|
||||
return validation_error_response(validation_errors)
|
||||
|
||||
# Confirm password reset
|
||||
appwrite = AppwriteService()
|
||||
appwrite.confirm_password_reset(user_id=user_id, secret=secret, password=password)
|
||||
|
||||
logger.info("Password reset successfully", user_id=user_id)
|
||||
|
||||
return success_response(
|
||||
result={
|
||||
"message": "Password reset successful. You can now log in with your new password."
|
||||
}
|
||||
)
|
||||
|
||||
except AppwriteException as e:
|
||||
logger.error("Password reset failed", error=str(e), code=e.code)
|
||||
return error_response(
|
||||
message="Password reset failed. The link may be invalid or expired.",
|
||||
code="PASSWORD_RESET_ERROR"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Unexpected error during password reset", error=str(e))
|
||||
return error_response(message="An unexpected error occurred", code="INTERNAL_ERROR")
|
||||
|
||||
|
||||
# ===== TEMPLATE ROUTES (for rendering HTML pages) =====
|
||||
|
||||
@auth_bp.route('/login', methods=['GET'])
|
||||
def login_page():
|
||||
"""Render the login page."""
|
||||
return render_template('auth/login.html')
|
||||
|
||||
|
||||
@auth_bp.route('/register', methods=['GET'])
|
||||
def register_page():
|
||||
"""Render the registration page."""
|
||||
return render_template('auth/register.html')
|
||||
|
||||
|
||||
@auth_bp.route('/forgot-password', methods=['GET'])
|
||||
def forgot_password_page():
|
||||
"""Render the forgot password page."""
|
||||
return render_template('auth/forgot_password.html')
|
||||
|
||||
|
||||
@auth_bp.route('/reset-password', methods=['GET'])
|
||||
def reset_password_page():
|
||||
"""Render the reset password page."""
|
||||
user_id = request.args.get('userId', '')
|
||||
secret = request.args.get('secret', '')
|
||||
|
||||
return render_template('auth/reset_password.html', user_id=user_id, secret=secret)
|
||||
898
api/app/api/characters.py
Normal file
898
api/app/api/characters.py
Normal file
@@ -0,0 +1,898 @@
|
||||
"""
|
||||
Character API Blueprint
|
||||
|
||||
This module provides API endpoints for character management:
|
||||
- List user's characters
|
||||
- Get character details
|
||||
- Create new character
|
||||
- Delete character
|
||||
- Unlock skills
|
||||
- Respec skills
|
||||
|
||||
All endpoints require authentication and enforce ownership validation.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, request
|
||||
|
||||
from app.services.character_service import (
|
||||
get_character_service,
|
||||
CharacterLimitExceeded,
|
||||
CharacterNotFound,
|
||||
SkillUnlockError,
|
||||
InsufficientGold
|
||||
)
|
||||
from app.services.class_loader import get_class_loader
|
||||
from app.services.origin_service import get_origin_service
|
||||
from app.utils.response import (
|
||||
success_response,
|
||||
created_response,
|
||||
error_response,
|
||||
not_found_response,
|
||||
validation_error_response
|
||||
)
|
||||
from app.utils.auth import require_auth, get_current_user
|
||||
from app.utils.logging import get_logger
|
||||
|
||||
|
||||
# Initialize logger
|
||||
logger = get_logger(__file__)
|
||||
|
||||
# Create blueprint
|
||||
characters_bp = Blueprint('characters', __name__)
|
||||
|
||||
|
||||
# ===== VALIDATION FUNCTIONS =====
|
||||
|
||||
def validate_character_name(name: str) -> tuple[bool, str]:
|
||||
"""
|
||||
Validate character name.
|
||||
|
||||
Args:
|
||||
name: Character name to validate
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
if not name:
|
||||
return False, "Character name is required"
|
||||
|
||||
if len(name) < 2:
|
||||
return False, "Character name must be at least 2 characters"
|
||||
|
||||
if len(name) > 50:
|
||||
return False, "Character name must be no more than 50 characters"
|
||||
|
||||
# Allow letters, spaces, hyphens, apostrophes, and common fantasy characters
|
||||
if not all(c.isalnum() or c in " -'" for c in name):
|
||||
return False, "Character name can only contain letters, numbers, spaces, hyphens, and apostrophes"
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
def validate_class_id(class_id: str) -> tuple[bool, str]:
|
||||
"""
|
||||
Validate class ID.
|
||||
|
||||
Args:
|
||||
class_id: Class ID to validate
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
if not class_id:
|
||||
return False, "Class ID is required"
|
||||
|
||||
valid_classes = [
|
||||
'vanguard', 'assassin', 'arcanist', 'luminary',
|
||||
'wildstrider', 'oathkeeper', 'necromancer', 'lorekeeper'
|
||||
]
|
||||
|
||||
if class_id not in valid_classes:
|
||||
return False, f"Invalid class ID. Must be one of: {', '.join(valid_classes)}"
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
def validate_origin_id(origin_id: str) -> tuple[bool, str]:
|
||||
"""
|
||||
Validate origin ID.
|
||||
|
||||
Args:
|
||||
origin_id: Origin ID to validate
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
if not origin_id:
|
||||
return False, "Origin ID is required"
|
||||
|
||||
valid_origins = [
|
||||
'soul_revenant', 'memory_thief', 'shadow_apprentice', 'escaped_captive'
|
||||
]
|
||||
|
||||
if origin_id not in valid_origins:
|
||||
return False, f"Invalid origin ID. Must be one of: {', '.join(valid_origins)}"
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
def validate_skill_id(skill_id: str) -> tuple[bool, str]:
|
||||
"""
|
||||
Validate skill ID.
|
||||
|
||||
Args:
|
||||
skill_id: Skill ID to validate
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
if not skill_id:
|
||||
return False, "Skill ID is required"
|
||||
|
||||
if len(skill_id) > 100:
|
||||
return False, "Skill ID is too long"
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
# ===== API ENDPOINTS =====
|
||||
|
||||
@characters_bp.route('/api/v1/characters', methods=['GET'])
|
||||
@require_auth
|
||||
def list_characters():
|
||||
"""
|
||||
List all characters owned by the current user.
|
||||
|
||||
Returns:
|
||||
200: List of characters
|
||||
401: Not authenticated
|
||||
500: Internal server error
|
||||
|
||||
Example Response:
|
||||
{
|
||||
"result": {
|
||||
"characters": [
|
||||
{
|
||||
"character_id": "char_001",
|
||||
"name": "Thorin Ironheart",
|
||||
"class": "warrior",
|
||||
"level": 5,
|
||||
"gold": 1000
|
||||
}
|
||||
],
|
||||
"count": 1,
|
||||
"tier": "free",
|
||||
"limit": 1
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
user = get_current_user()
|
||||
logger.info("Listing characters", user_id=user.id)
|
||||
|
||||
# Get character service
|
||||
char_service = get_character_service()
|
||||
|
||||
# Get user's characters
|
||||
characters = char_service.get_user_characters(user.id)
|
||||
|
||||
# Get tier information
|
||||
tier = user.tier
|
||||
from app.services.character_service import CHARACTER_LIMITS
|
||||
limit = CHARACTER_LIMITS.get(tier, 1)
|
||||
|
||||
# Convert characters to dict format
|
||||
character_list = [
|
||||
{
|
||||
"character_id": char.character_id,
|
||||
"name": char.name,
|
||||
"class": char.player_class.class_id,
|
||||
"class_name": char.player_class.name,
|
||||
"level": char.level,
|
||||
"experience": char.experience,
|
||||
"gold": char.gold,
|
||||
"current_location": char.current_location,
|
||||
"origin": char.origin.id
|
||||
}
|
||||
for char in characters
|
||||
]
|
||||
|
||||
logger.info("Characters listed successfully",
|
||||
user_id=user.id,
|
||||
count=len(characters))
|
||||
|
||||
return success_response(
|
||||
result={
|
||||
"characters": character_list,
|
||||
"count": len(characters),
|
||||
"tier": tier,
|
||||
"limit": limit
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to list characters",
|
||||
user_id=user.id if user else 'unknown',
|
||||
error=str(e))
|
||||
return error_response(
|
||||
code="CHARACTER_LIST_ERROR",
|
||||
message="Failed to retrieve characters",
|
||||
status=500
|
||||
)
|
||||
|
||||
|
||||
@characters_bp.route('/api/v1/characters/<character_id>', methods=['GET'])
|
||||
@require_auth
|
||||
def get_character(character_id: str):
|
||||
"""
|
||||
Get detailed information about a specific character.
|
||||
|
||||
Args:
|
||||
character_id: Character ID
|
||||
|
||||
Returns:
|
||||
200: Character details
|
||||
401: Not authenticated
|
||||
404: Character not found or not owned by user
|
||||
500: Internal server error
|
||||
|
||||
Example Response:
|
||||
{
|
||||
"result": {
|
||||
"character_id": "char_001",
|
||||
"name": "Thorin Ironheart",
|
||||
"class": {...},
|
||||
"origin": {...},
|
||||
"level": 5,
|
||||
"experience": 250,
|
||||
"base_stats": {...},
|
||||
"unlocked_skills": [...],
|
||||
"inventory": [...],
|
||||
"equipped": {...},
|
||||
"gold": 1000
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
user = get_current_user()
|
||||
logger.info("Getting character",
|
||||
user_id=user.id,
|
||||
character_id=character_id)
|
||||
|
||||
# Get character service
|
||||
char_service = get_character_service()
|
||||
|
||||
# Get character (ownership validated in service)
|
||||
character = char_service.get_character(character_id, user.id)
|
||||
|
||||
logger.info("Character retrieved successfully",
|
||||
user_id=user.id,
|
||||
character_id=character_id)
|
||||
|
||||
return success_response(result=character.to_dict())
|
||||
|
||||
except CharacterNotFound as e:
|
||||
logger.warning("Character not found",
|
||||
user_id=user.id,
|
||||
character_id=character_id,
|
||||
error=str(e))
|
||||
return not_found_response(message=str(e))
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get character",
|
||||
user_id=user.id,
|
||||
character_id=character_id,
|
||||
error=str(e))
|
||||
return error_response(
|
||||
code="CHARACTER_GET_ERROR",
|
||||
message="Failed to retrieve character",
|
||||
status=500
|
||||
)
|
||||
|
||||
|
||||
@characters_bp.route('/api/v1/characters', methods=['POST'])
|
||||
@require_auth
|
||||
def create_character():
|
||||
"""
|
||||
Create a new character for the current user.
|
||||
|
||||
Request Body:
|
||||
{
|
||||
"name": "Thorin Ironheart",
|
||||
"class_id": "warrior",
|
||||
"origin_id": "soul_revenant"
|
||||
}
|
||||
|
||||
Returns:
|
||||
201: Character created successfully
|
||||
400: Validation error or character limit exceeded
|
||||
401: Not authenticated
|
||||
500: Internal server error
|
||||
|
||||
Example Response:
|
||||
{
|
||||
"result": {
|
||||
"character_id": "char_001",
|
||||
"name": "Thorin Ironheart",
|
||||
"class": "warrior",
|
||||
"level": 1,
|
||||
"message": "Character created successfully"
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
user = get_current_user()
|
||||
|
||||
# Get request data
|
||||
data = request.get_json()
|
||||
|
||||
if not data:
|
||||
return validation_error_response(
|
||||
message="Request body is required",
|
||||
details={"error": "Request body is required"}
|
||||
)
|
||||
|
||||
name = data.get('name', '').strip()
|
||||
class_id = data.get('class_id', '').strip().lower()
|
||||
origin_id = data.get('origin_id', '').strip().lower()
|
||||
|
||||
# Validate inputs
|
||||
validation_errors = {}
|
||||
|
||||
name_valid, name_error = validate_character_name(name)
|
||||
if not name_valid:
|
||||
validation_errors['name'] = name_error
|
||||
|
||||
class_valid, class_error = validate_class_id(class_id)
|
||||
if not class_valid:
|
||||
validation_errors['class_id'] = class_error
|
||||
|
||||
origin_valid, origin_error = validate_origin_id(origin_id)
|
||||
if not origin_valid:
|
||||
validation_errors['origin_id'] = origin_error
|
||||
|
||||
if validation_errors:
|
||||
return validation_error_response(
|
||||
message="Validation failed",
|
||||
details=validation_errors
|
||||
)
|
||||
|
||||
logger.info("Creating character",
|
||||
user_id=user.id,
|
||||
name=name,
|
||||
class_id=class_id,
|
||||
origin_id=origin_id)
|
||||
|
||||
# Get character service
|
||||
char_service = get_character_service()
|
||||
|
||||
# Create character
|
||||
character = char_service.create_character(
|
||||
user_id=user.id,
|
||||
name=name,
|
||||
class_id=class_id,
|
||||
origin_id=origin_id
|
||||
)
|
||||
|
||||
logger.info("Character created successfully",
|
||||
user_id=user.id,
|
||||
character_id=character.character_id,
|
||||
name=name)
|
||||
|
||||
return created_response(
|
||||
result={
|
||||
"character_id": character.character_id,
|
||||
"name": character.name,
|
||||
"class": character.player_class.class_id,
|
||||
"class_name": character.player_class.name,
|
||||
"origin": character.origin.id,
|
||||
"origin_name": character.origin.name,
|
||||
"level": character.level,
|
||||
"gold": character.gold,
|
||||
"current_location": character.current_location,
|
||||
"message": "Character created successfully"
|
||||
}
|
||||
)
|
||||
|
||||
except CharacterLimitExceeded as e:
|
||||
logger.warning("Character limit exceeded",
|
||||
user_id=user.id,
|
||||
error=str(e))
|
||||
return error_response(
|
||||
code="CHARACTER_LIMIT_EXCEEDED",
|
||||
message=str(e),
|
||||
status=400
|
||||
)
|
||||
|
||||
except ValueError as e:
|
||||
logger.warning("Invalid class or origin",
|
||||
user_id=user.id,
|
||||
error=str(e))
|
||||
return validation_error_response(
|
||||
message=str(e),
|
||||
details={"error": str(e)}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to create character",
|
||||
user_id=user.id,
|
||||
error=str(e))
|
||||
return error_response(
|
||||
code="CHARACTER_CREATE_ERROR",
|
||||
message="Failed to create character",
|
||||
status=500
|
||||
)
|
||||
|
||||
|
||||
@characters_bp.route('/api/v1/characters/<character_id>', methods=['DELETE'])
|
||||
@require_auth
|
||||
def delete_character(character_id: str):
|
||||
"""
|
||||
Delete a character (soft delete - marks as inactive).
|
||||
|
||||
Args:
|
||||
character_id: Character ID
|
||||
|
||||
Returns:
|
||||
200: Character deleted successfully
|
||||
401: Not authenticated
|
||||
404: Character not found or not owned by user
|
||||
500: Internal server error
|
||||
|
||||
Example Response:
|
||||
{
|
||||
"result": {
|
||||
"message": "Character deleted successfully",
|
||||
"character_id": "char_001"
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
user = get_current_user()
|
||||
logger.info("Deleting character",
|
||||
user_id=user.id,
|
||||
character_id=character_id)
|
||||
|
||||
# Get character service
|
||||
char_service = get_character_service()
|
||||
|
||||
# Delete character (ownership validated in service)
|
||||
char_service.delete_character(character_id, user.id)
|
||||
|
||||
logger.info("Character deleted successfully",
|
||||
user_id=user.id,
|
||||
character_id=character_id)
|
||||
|
||||
return success_response(
|
||||
result={
|
||||
"message": "Character deleted successfully",
|
||||
"character_id": character_id
|
||||
}
|
||||
)
|
||||
|
||||
except CharacterNotFound as e:
|
||||
logger.warning("Character not found for deletion",
|
||||
user_id=user.id,
|
||||
character_id=character_id,
|
||||
error=str(e))
|
||||
return not_found_response(message=str(e))
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to delete character",
|
||||
user_id=user.id,
|
||||
character_id=character_id,
|
||||
error=str(e))
|
||||
return error_response(
|
||||
code="CHARACTER_DELETE_ERROR",
|
||||
message="Failed to delete character",
|
||||
status=500
|
||||
)
|
||||
|
||||
|
||||
@characters_bp.route('/api/v1/characters/<character_id>/skills/unlock', methods=['POST'])
|
||||
@require_auth
|
||||
def unlock_skill(character_id: str):
|
||||
"""
|
||||
Unlock a skill for a character.
|
||||
|
||||
Args:
|
||||
character_id: Character ID
|
||||
|
||||
Request Body:
|
||||
{
|
||||
"skill_id": "power_strike"
|
||||
}
|
||||
|
||||
Returns:
|
||||
200: Skill unlocked successfully
|
||||
400: Validation error or unlock requirements not met
|
||||
401: Not authenticated
|
||||
404: Character not found or not owned by user
|
||||
500: Internal server error
|
||||
|
||||
Example Response:
|
||||
{
|
||||
"result": {
|
||||
"message": "Skill unlocked successfully",
|
||||
"character_id": "char_001",
|
||||
"skill_id": "power_strike",
|
||||
"unlocked_skills": ["power_strike"],
|
||||
"available_points": 0
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
user = get_current_user()
|
||||
|
||||
# Get request data
|
||||
data = request.get_json()
|
||||
|
||||
if not data:
|
||||
return validation_error_response(
|
||||
message="Request body is required",
|
||||
details={"error": "Request body is required"}
|
||||
)
|
||||
|
||||
skill_id = data.get('skill_id', '').strip()
|
||||
|
||||
# Validate skill_id
|
||||
skill_valid, skill_error = validate_skill_id(skill_id)
|
||||
if not skill_valid:
|
||||
return validation_error_response(
|
||||
message="Validation failed",
|
||||
details={"skill_id": skill_error}
|
||||
)
|
||||
|
||||
logger.info("Unlocking skill",
|
||||
user_id=user.id,
|
||||
character_id=character_id,
|
||||
skill_id=skill_id)
|
||||
|
||||
# Get character service
|
||||
char_service = get_character_service()
|
||||
|
||||
# Unlock skill (validates ownership, prerequisites, skill points)
|
||||
character = char_service.unlock_skill(character_id, user.id, skill_id)
|
||||
|
||||
# Calculate available skill points
|
||||
available_points = character.level - len(character.unlocked_skills)
|
||||
|
||||
logger.info("Skill unlocked successfully",
|
||||
user_id=user.id,
|
||||
character_id=character_id,
|
||||
skill_id=skill_id,
|
||||
available_points=available_points)
|
||||
|
||||
return success_response(
|
||||
result={
|
||||
"message": "Skill unlocked successfully",
|
||||
"character_id": character_id,
|
||||
"skill_id": skill_id,
|
||||
"unlocked_skills": character.unlocked_skills,
|
||||
"available_points": available_points
|
||||
}
|
||||
)
|
||||
|
||||
except CharacterNotFound as e:
|
||||
logger.warning("Character not found for skill unlock",
|
||||
user_id=user.id,
|
||||
character_id=character_id,
|
||||
error=str(e))
|
||||
return not_found_response(message=str(e))
|
||||
|
||||
except SkillUnlockError as e:
|
||||
logger.warning("Skill unlock failed",
|
||||
user_id=user.id,
|
||||
character_id=character_id,
|
||||
skill_id=skill_id if 'skill_id' in locals() else 'unknown',
|
||||
error=str(e))
|
||||
return error_response(
|
||||
code="SKILL_UNLOCK_ERROR",
|
||||
message=str(e),
|
||||
status=400
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to unlock skill",
|
||||
user_id=user.id,
|
||||
character_id=character_id,
|
||||
error=str(e))
|
||||
return error_response(
|
||||
code="SKILL_UNLOCK_ERROR",
|
||||
message="Failed to unlock skill",
|
||||
status=500
|
||||
)
|
||||
|
||||
|
||||
@characters_bp.route('/api/v1/characters/<character_id>/skills/respec', methods=['POST'])
|
||||
@require_auth
|
||||
def respec_skills(character_id: str):
|
||||
"""
|
||||
Reset all unlocked skills for a character.
|
||||
|
||||
Cost: level × 100 gold
|
||||
|
||||
Args:
|
||||
character_id: Character ID
|
||||
|
||||
Returns:
|
||||
200: Skills reset successfully
|
||||
400: Insufficient gold
|
||||
401: Not authenticated
|
||||
404: Character not found or not owned by user
|
||||
500: Internal server error
|
||||
|
||||
Example Response:
|
||||
{
|
||||
"result": {
|
||||
"message": "Skills reset successfully",
|
||||
"character_id": "char_001",
|
||||
"cost": 500,
|
||||
"remaining_gold": 500,
|
||||
"available_points": 5
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
user = get_current_user()
|
||||
logger.info("Respecing character skills",
|
||||
user_id=user.id,
|
||||
character_id=character_id)
|
||||
|
||||
# Get character service
|
||||
char_service = get_character_service()
|
||||
|
||||
# Get character to calculate cost
|
||||
character = char_service.get_character(character_id, user.id)
|
||||
respec_cost = character.level * 100
|
||||
|
||||
# Respec skills (validates ownership and gold)
|
||||
character = char_service.respec_skills(character_id, user.id)
|
||||
|
||||
# Calculate available skill points
|
||||
available_points = character.level - len(character.unlocked_skills)
|
||||
|
||||
logger.info("Skills respeced successfully",
|
||||
user_id=user.id,
|
||||
character_id=character_id,
|
||||
cost=respec_cost,
|
||||
remaining_gold=character.gold,
|
||||
available_points=available_points)
|
||||
|
||||
return success_response(
|
||||
result={
|
||||
"message": "Skills reset successfully",
|
||||
"character_id": character_id,
|
||||
"cost": respec_cost,
|
||||
"remaining_gold": character.gold,
|
||||
"available_points": available_points
|
||||
}
|
||||
)
|
||||
|
||||
except CharacterNotFound as e:
|
||||
logger.warning("Character not found for respec",
|
||||
user_id=user.id,
|
||||
character_id=character_id,
|
||||
error=str(e))
|
||||
return not_found_response(message=str(e))
|
||||
|
||||
except InsufficientGold as e:
|
||||
logger.warning("Insufficient gold for respec",
|
||||
user_id=user.id,
|
||||
character_id=character_id,
|
||||
error=str(e))
|
||||
return error_response(
|
||||
code="INSUFFICIENT_GOLD",
|
||||
message=str(e),
|
||||
status=400
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to respec skills",
|
||||
user_id=user.id,
|
||||
character_id=character_id,
|
||||
error=str(e))
|
||||
return error_response(
|
||||
code="RESPEC_ERROR",
|
||||
message="Failed to reset skills",
|
||||
status=500
|
||||
)
|
||||
|
||||
|
||||
# ===== CLASSES & ORIGINS ENDPOINTS (Reference Data) =====
|
||||
|
||||
@characters_bp.route('/api/v1/classes', methods=['GET'])
|
||||
def list_classes():
|
||||
"""
|
||||
List all available player classes.
|
||||
|
||||
This endpoint provides reference data for character creation.
|
||||
No authentication required.
|
||||
|
||||
Returns:
|
||||
200: List of all classes with basic info
|
||||
500: Internal server error
|
||||
|
||||
Example Response:
|
||||
{
|
||||
"result": {
|
||||
"classes": [
|
||||
{
|
||||
"class_id": "vanguard",
|
||||
"name": "Vanguard",
|
||||
"description": "Armored warrior...",
|
||||
"base_stats": {...},
|
||||
"skill_trees": ["Shield Bearer", "Weapon Master"]
|
||||
}
|
||||
],
|
||||
"count": 8
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
logger.info("Listing all classes")
|
||||
|
||||
# Get class loader
|
||||
class_loader = get_class_loader()
|
||||
|
||||
# Get all class IDs
|
||||
class_ids = class_loader.get_all_class_ids()
|
||||
|
||||
# Load all classes
|
||||
classes = []
|
||||
for class_id in class_ids:
|
||||
player_class = class_loader.load_class(class_id)
|
||||
if player_class:
|
||||
classes.append({
|
||||
"class_id": player_class.class_id,
|
||||
"name": player_class.name,
|
||||
"description": player_class.description,
|
||||
"base_stats": player_class.base_stats.to_dict(),
|
||||
"skill_trees": [tree.name for tree in player_class.skill_trees],
|
||||
"starting_equipment": player_class.starting_equipment,
|
||||
"starting_abilities": player_class.starting_abilities
|
||||
})
|
||||
|
||||
logger.info("Classes listed successfully", count=len(classes))
|
||||
|
||||
return success_response(
|
||||
result={
|
||||
"classes": classes,
|
||||
"count": len(classes)
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to list classes", error=str(e))
|
||||
return error_response(
|
||||
code="CLASS_LIST_ERROR",
|
||||
message="Failed to retrieve classes",
|
||||
status=500
|
||||
)
|
||||
|
||||
|
||||
@characters_bp.route('/api/v1/classes/<class_id>', methods=['GET'])
|
||||
def get_class(class_id: str):
|
||||
"""
|
||||
Get detailed information about a specific class.
|
||||
|
||||
This endpoint provides full class data including skill trees.
|
||||
No authentication required.
|
||||
|
||||
Args:
|
||||
class_id: Class ID (e.g., "vanguard", "assassin")
|
||||
|
||||
Returns:
|
||||
200: Full class details with skill trees
|
||||
404: Class not found
|
||||
500: Internal server error
|
||||
|
||||
Example Response:
|
||||
{
|
||||
"result": {
|
||||
"class_id": "vanguard",
|
||||
"name": "Vanguard",
|
||||
"description": "Armored warrior...",
|
||||
"base_stats": {...},
|
||||
"skill_trees": [
|
||||
{
|
||||
"tree_id": "shield_bearer",
|
||||
"name": "Shield Bearer",
|
||||
"nodes": [...]
|
||||
}
|
||||
],
|
||||
"starting_equipment": [...],
|
||||
"starting_abilities": [...]
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
logger.info("Getting class details", class_id=class_id)
|
||||
|
||||
# Get class loader
|
||||
class_loader = get_class_loader()
|
||||
|
||||
# Load class
|
||||
player_class = class_loader.load_class(class_id)
|
||||
|
||||
if not player_class:
|
||||
logger.warning("Class not found", class_id=class_id)
|
||||
return not_found_response(message=f"Class not found: {class_id}")
|
||||
|
||||
logger.info("Class retrieved successfully", class_id=class_id)
|
||||
|
||||
# Return full class data
|
||||
return success_response(result=player_class.to_dict())
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get class",
|
||||
class_id=class_id,
|
||||
error=str(e))
|
||||
return error_response(
|
||||
code="CLASS_GET_ERROR",
|
||||
message="Failed to retrieve class",
|
||||
status=500
|
||||
)
|
||||
|
||||
|
||||
@characters_bp.route('/api/v1/origins', methods=['GET'])
|
||||
def list_origins():
|
||||
"""
|
||||
List all available character origins.
|
||||
|
||||
This endpoint provides reference data for character creation.
|
||||
No authentication required.
|
||||
|
||||
Returns:
|
||||
200: List of all origins
|
||||
500: Internal server error
|
||||
|
||||
Example Response:
|
||||
{
|
||||
"result": {
|
||||
"origins": [
|
||||
{
|
||||
"id": "soul_revenant",
|
||||
"name": "Soul Revenant",
|
||||
"description": "Returned from death...",
|
||||
"starting_location": {...},
|
||||
"narrative_hooks": [...],
|
||||
"starting_bonus": {...}
|
||||
}
|
||||
],
|
||||
"count": 4
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
logger.info("Listing all origins")
|
||||
|
||||
# Get origin service
|
||||
origin_service = get_origin_service()
|
||||
|
||||
# Get all origin IDs
|
||||
origin_ids = origin_service.get_all_origin_ids()
|
||||
|
||||
# Load all origins
|
||||
origins = []
|
||||
for origin_id in origin_ids:
|
||||
origin = origin_service.load_origin(origin_id)
|
||||
if origin:
|
||||
origins.append(origin.to_dict())
|
||||
|
||||
logger.info("Origins listed successfully", count=len(origins))
|
||||
|
||||
return success_response(
|
||||
result={
|
||||
"origins": origins,
|
||||
"count": len(origins)
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to list origins", error=str(e))
|
||||
return error_response(
|
||||
code="ORIGIN_LIST_ERROR",
|
||||
message="Failed to retrieve origins",
|
||||
status=500
|
||||
)
|
||||
302
api/app/api/game_mechanics.py
Normal file
302
api/app/api/game_mechanics.py
Normal file
@@ -0,0 +1,302 @@
|
||||
"""
|
||||
Game Mechanics API Blueprint
|
||||
|
||||
This module provides API endpoints for game mechanics that determine
|
||||
outcomes before AI narration:
|
||||
- Skill checks (perception, persuasion, stealth, etc.)
|
||||
- Search/loot actions
|
||||
- Dice rolls
|
||||
|
||||
These endpoints return structured results that can be used for UI
|
||||
dice animations and then passed to AI for narrative description.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, request
|
||||
|
||||
from app.services.outcome_service import outcome_service
|
||||
from app.services.character_service import get_character_service, CharacterNotFound
|
||||
from app.game_logic.dice import SkillType, Difficulty
|
||||
from app.utils.response import (
|
||||
success_response,
|
||||
error_response,
|
||||
not_found_response,
|
||||
validation_error_response
|
||||
)
|
||||
from app.utils.auth import require_auth, get_current_user
|
||||
from app.utils.logging import get_logger
|
||||
|
||||
|
||||
# Initialize logger
|
||||
logger = get_logger(__file__)
|
||||
|
||||
# Create blueprint
|
||||
game_mechanics_bp = Blueprint('game_mechanics', __name__, url_prefix='/api/v1/game')
|
||||
|
||||
|
||||
# Valid skill types for API validation
|
||||
VALID_SKILL_TYPES = [skill.name.lower() for skill in SkillType]
|
||||
|
||||
# Valid difficulty names
|
||||
VALID_DIFFICULTIES = ["trivial", "easy", "medium", "hard", "very_hard", "nearly_impossible"]
|
||||
|
||||
|
||||
@game_mechanics_bp.route('/check', methods=['POST'])
|
||||
@require_auth
|
||||
def perform_check():
|
||||
"""
|
||||
Perform a skill check or search action.
|
||||
|
||||
This endpoint determines the outcome of chance-based actions before
|
||||
they are passed to AI for narration. The result includes all dice
|
||||
roll details for UI display.
|
||||
|
||||
Request JSON:
|
||||
{
|
||||
"character_id": "...",
|
||||
"check_type": "search" | "skill",
|
||||
"skill": "perception", // Required for skill checks
|
||||
"dc": 15, // Optional, can use difficulty instead
|
||||
"difficulty": "medium", // Optional, alternative to dc
|
||||
"location_type": "forest", // For search checks
|
||||
"context": {} // Optional additional context
|
||||
}
|
||||
|
||||
Returns:
|
||||
For search checks:
|
||||
{
|
||||
"check_result": {
|
||||
"roll": 14,
|
||||
"modifier": 3,
|
||||
"total": 17,
|
||||
"dc": 15,
|
||||
"success": true,
|
||||
"margin": 2
|
||||
},
|
||||
"items_found": [...],
|
||||
"gold_found": 5
|
||||
}
|
||||
|
||||
For skill checks:
|
||||
{
|
||||
"check_result": {...},
|
||||
"context": {
|
||||
"skill_used": "persuasion",
|
||||
"stat_used": "charisma",
|
||||
...
|
||||
}
|
||||
}
|
||||
"""
|
||||
user = get_current_user()
|
||||
data = request.get_json()
|
||||
|
||||
if not data:
|
||||
return validation_error_response(
|
||||
message="Request body is required",
|
||||
details={"field": "body", "issue": "Missing JSON body"}
|
||||
)
|
||||
|
||||
# Validate required fields
|
||||
character_id = data.get("character_id")
|
||||
check_type = data.get("check_type")
|
||||
|
||||
if not character_id:
|
||||
return validation_error_response(
|
||||
message="Character ID is required",
|
||||
details={"field": "character_id", "issue": "Missing required field"}
|
||||
)
|
||||
|
||||
if not check_type:
|
||||
return validation_error_response(
|
||||
message="Check type is required",
|
||||
details={"field": "check_type", "issue": "Missing required field"}
|
||||
)
|
||||
|
||||
if check_type not in ["search", "skill"]:
|
||||
return validation_error_response(
|
||||
message="Invalid check type",
|
||||
details={"field": "check_type", "issue": "Must be 'search' or 'skill'"}
|
||||
)
|
||||
|
||||
# Get character and verify ownership
|
||||
try:
|
||||
character_service = get_character_service()
|
||||
character = character_service.get_character(character_id)
|
||||
|
||||
if character.user_id != user["user_id"]:
|
||||
return error_response(
|
||||
status_code=403,
|
||||
message="You don't have permission to access this character",
|
||||
error_code="FORBIDDEN"
|
||||
)
|
||||
except CharacterNotFound:
|
||||
return not_found_response(
|
||||
message=f"Character not found: {character_id}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("character_fetch_error", error=str(e), character_id=character_id)
|
||||
return error_response(
|
||||
status_code=500,
|
||||
message="Failed to fetch character",
|
||||
error_code="CHARACTER_FETCH_ERROR"
|
||||
)
|
||||
|
||||
# Determine DC from difficulty name or direct value
|
||||
dc = data.get("dc")
|
||||
difficulty = data.get("difficulty")
|
||||
|
||||
if dc is None and difficulty:
|
||||
if difficulty.lower() not in VALID_DIFFICULTIES:
|
||||
return validation_error_response(
|
||||
message="Invalid difficulty",
|
||||
details={
|
||||
"field": "difficulty",
|
||||
"issue": f"Must be one of: {', '.join(VALID_DIFFICULTIES)}"
|
||||
}
|
||||
)
|
||||
dc = outcome_service.get_dc_for_difficulty(difficulty)
|
||||
elif dc is None:
|
||||
# Default to medium difficulty
|
||||
dc = Difficulty.MEDIUM.value
|
||||
|
||||
# Validate DC range
|
||||
if not isinstance(dc, int) or dc < 1 or dc > 35:
|
||||
return validation_error_response(
|
||||
message="Invalid DC value",
|
||||
details={"field": "dc", "issue": "DC must be an integer between 1 and 35"}
|
||||
)
|
||||
|
||||
# Get optional bonus
|
||||
bonus = data.get("bonus", 0)
|
||||
if not isinstance(bonus, int):
|
||||
bonus = 0
|
||||
|
||||
# Perform the check based on type
|
||||
try:
|
||||
if check_type == "search":
|
||||
# Search check uses perception
|
||||
location_type = data.get("location_type", "default")
|
||||
outcome = outcome_service.determine_search_outcome(
|
||||
character=character,
|
||||
location_type=location_type,
|
||||
dc=dc,
|
||||
bonus=bonus
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"search_check_performed",
|
||||
user_id=user["user_id"],
|
||||
character_id=character_id,
|
||||
location_type=location_type,
|
||||
success=outcome.check_result.success
|
||||
)
|
||||
|
||||
return success_response(result=outcome.to_dict())
|
||||
|
||||
else: # skill check
|
||||
skill = data.get("skill")
|
||||
if not skill:
|
||||
return validation_error_response(
|
||||
message="Skill is required for skill checks",
|
||||
details={"field": "skill", "issue": "Missing required field"}
|
||||
)
|
||||
|
||||
skill_lower = skill.lower()
|
||||
if skill_lower not in VALID_SKILL_TYPES:
|
||||
return validation_error_response(
|
||||
message="Invalid skill type",
|
||||
details={
|
||||
"field": "skill",
|
||||
"issue": f"Must be one of: {', '.join(VALID_SKILL_TYPES)}"
|
||||
}
|
||||
)
|
||||
|
||||
# Convert to SkillType enum
|
||||
skill_type = SkillType[skill.upper()]
|
||||
|
||||
# Get additional context
|
||||
context = data.get("context", {})
|
||||
|
||||
outcome = outcome_service.determine_skill_check_outcome(
|
||||
character=character,
|
||||
skill_type=skill_type,
|
||||
dc=dc,
|
||||
bonus=bonus,
|
||||
context=context
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"skill_check_performed",
|
||||
user_id=user["user_id"],
|
||||
character_id=character_id,
|
||||
skill=skill_lower,
|
||||
success=outcome.check_result.success
|
||||
)
|
||||
|
||||
return success_response(result=outcome.to_dict())
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"check_error",
|
||||
error=str(e),
|
||||
check_type=check_type,
|
||||
character_id=character_id
|
||||
)
|
||||
return error_response(
|
||||
status_code=500,
|
||||
message="Failed to perform check",
|
||||
error_code="CHECK_ERROR"
|
||||
)
|
||||
|
||||
|
||||
@game_mechanics_bp.route('/skills', methods=['GET'])
|
||||
def list_skills():
|
||||
"""
|
||||
List all available skill types.
|
||||
|
||||
Returns the skill types available for skill checks,
|
||||
along with their associated base stats.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"skills": [
|
||||
{
|
||||
"name": "perception",
|
||||
"stat": "wisdom",
|
||||
"description": "..."
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
"""
|
||||
skills = []
|
||||
for skill in SkillType:
|
||||
skills.append({
|
||||
"name": skill.name.lower(),
|
||||
"stat": skill.value,
|
||||
})
|
||||
|
||||
return success_response(result={"skills": skills})
|
||||
|
||||
|
||||
@game_mechanics_bp.route('/difficulties', methods=['GET'])
|
||||
def list_difficulties():
|
||||
"""
|
||||
List all difficulty levels and their DC values.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"difficulties": [
|
||||
{"name": "trivial", "dc": 5},
|
||||
{"name": "easy", "dc": 10},
|
||||
...
|
||||
]
|
||||
}
|
||||
"""
|
||||
difficulties = []
|
||||
for diff in Difficulty:
|
||||
difficulties.append({
|
||||
"name": diff.name.lower(),
|
||||
"dc": diff.value,
|
||||
})
|
||||
|
||||
return success_response(result={"difficulties": difficulties})
|
||||
60
api/app/api/health.py
Normal file
60
api/app/api/health.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""
|
||||
Health Check API Blueprint
|
||||
|
||||
This module provides a simple health check endpoint for monitoring
|
||||
and testing API connectivity.
|
||||
"""
|
||||
|
||||
from flask import Blueprint
|
||||
from app.utils.response import success_response
|
||||
from app.utils.logging import get_logger
|
||||
from app.config import get_config
|
||||
|
||||
|
||||
# Initialize logger
|
||||
logger = get_logger(__file__)
|
||||
|
||||
# Create blueprint
|
||||
health_bp = Blueprint('health', __name__, url_prefix='/api/v1')
|
||||
|
||||
|
||||
@health_bp.route('/health', methods=['GET'])
|
||||
def health_check():
|
||||
"""
|
||||
Health check endpoint.
|
||||
|
||||
Returns basic service status and version information.
|
||||
Useful for monitoring, load balancers, and testing API connectivity.
|
||||
|
||||
Returns:
|
||||
JSON response with status "ok" and version info
|
||||
|
||||
Example:
|
||||
GET /api/v1/health
|
||||
|
||||
Response:
|
||||
{
|
||||
"app": "Code of Conquest",
|
||||
"version": "0.1.0",
|
||||
"status": 200,
|
||||
"timestamp": "2025-11-16T...",
|
||||
"result": {
|
||||
"status": "ok",
|
||||
"service": "Code of Conquest API",
|
||||
"version": "0.1.0"
|
||||
},
|
||||
"error": null,
|
||||
"meta": {}
|
||||
}
|
||||
"""
|
||||
config = get_config()
|
||||
|
||||
logger.debug("Health check requested")
|
||||
|
||||
return success_response(
|
||||
result={
|
||||
"status": "ok",
|
||||
"service": "Code of Conquest API",
|
||||
"version": config.app.version
|
||||
}
|
||||
)
|
||||
71
api/app/api/jobs.py
Normal file
71
api/app/api/jobs.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""
|
||||
Jobs API Blueprint
|
||||
|
||||
This module provides API endpoints for job status polling:
|
||||
- Get job status
|
||||
- Get job result
|
||||
|
||||
All endpoints require authentication.
|
||||
"""
|
||||
|
||||
from flask import Blueprint
|
||||
|
||||
from app.tasks.ai_tasks import get_job_status, get_job_result
|
||||
from app.utils.response import (
|
||||
success_response,
|
||||
not_found_response,
|
||||
error_response
|
||||
)
|
||||
from app.utils.auth import require_auth
|
||||
from app.utils.logging import get_logger
|
||||
|
||||
|
||||
# Initialize logger
|
||||
logger = get_logger(__file__)
|
||||
|
||||
# Create blueprint
|
||||
jobs_bp = Blueprint('jobs', __name__)
|
||||
|
||||
|
||||
@jobs_bp.route('/api/v1/jobs/<job_id>/status', methods=['GET'])
|
||||
@require_auth
|
||||
def job_status(job_id: str):
|
||||
"""
|
||||
Get the status of an AI job.
|
||||
|
||||
Args:
|
||||
job_id: The job ID returned from action submission
|
||||
|
||||
Returns:
|
||||
JSON response with job status and result if completed
|
||||
"""
|
||||
try:
|
||||
status_info = get_job_status(job_id)
|
||||
|
||||
if not status_info or status_info.get('status') == 'not_found':
|
||||
return not_found_response(f"Job not found: {job_id}")
|
||||
|
||||
# If completed, include the result
|
||||
if status_info.get('status') == 'completed':
|
||||
result = get_job_result(job_id)
|
||||
if result:
|
||||
status_info['dm_response'] = result.get('dm_response', '')
|
||||
status_info['tokens_used'] = result.get('tokens_used', 0)
|
||||
status_info['model'] = result.get('model', '')
|
||||
|
||||
logger.debug("Job status retrieved",
|
||||
job_id=job_id,
|
||||
status=status_info.get('status'))
|
||||
|
||||
return success_response(status_info)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get job status",
|
||||
job_id=job_id,
|
||||
error=str(e),
|
||||
exc_info=True)
|
||||
return error_response(
|
||||
status=500,
|
||||
code="JOB_STATUS_ERROR",
|
||||
message="Failed to get job status"
|
||||
)
|
||||
429
api/app/api/npcs.py
Normal file
429
api/app/api/npcs.py
Normal file
@@ -0,0 +1,429 @@
|
||||
"""
|
||||
NPC API Blueprint
|
||||
|
||||
This module provides API endpoints for NPC interactions:
|
||||
- Get NPC details
|
||||
- Talk to NPC (queues AI dialogue generation)
|
||||
- Get NPCs at location
|
||||
|
||||
All endpoints require authentication and enforce ownership validation.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from flask import Blueprint, request
|
||||
|
||||
from app.services.session_service import get_session_service, SessionNotFound
|
||||
from app.services.character_service import get_character_service, CharacterNotFound
|
||||
from app.services.npc_loader import get_npc_loader
|
||||
from app.services.location_loader import get_location_loader
|
||||
from app.tasks.ai_tasks import enqueue_ai_task, TaskType
|
||||
from app.utils.response import (
|
||||
success_response,
|
||||
accepted_response,
|
||||
error_response,
|
||||
not_found_response,
|
||||
validation_error_response
|
||||
)
|
||||
from app.utils.auth import require_auth, get_current_user
|
||||
from app.utils.logging import get_logger
|
||||
|
||||
|
||||
# Initialize logger
|
||||
logger = get_logger(__file__)
|
||||
|
||||
# Create blueprint
|
||||
npcs_bp = Blueprint('npcs', __name__)
|
||||
|
||||
|
||||
@npcs_bp.route('/api/v1/npcs/<npc_id>', methods=['GET'])
|
||||
@require_auth
|
||||
def get_npc_details(npc_id: str):
|
||||
"""
|
||||
Get NPC details with knowledge filtered by character interaction state.
|
||||
|
||||
Path params:
|
||||
npc_id: NPC ID to get details for
|
||||
|
||||
Query params:
|
||||
character_id: Optional character ID for filtering revealed secrets
|
||||
|
||||
Returns:
|
||||
JSON response with NPC details
|
||||
"""
|
||||
try:
|
||||
user = get_current_user()
|
||||
character_id = request.args.get('character_id')
|
||||
|
||||
# Load NPC
|
||||
npc_loader = get_npc_loader()
|
||||
npc = npc_loader.load_npc(npc_id)
|
||||
|
||||
if not npc:
|
||||
return not_found_response("NPC not found")
|
||||
|
||||
npc_data = npc.to_dict()
|
||||
|
||||
# Filter knowledge based on character interaction state
|
||||
if character_id:
|
||||
try:
|
||||
character_service = get_character_service()
|
||||
character = character_service.get_character(character_id, user.id)
|
||||
|
||||
if character:
|
||||
# Get revealed secrets based on conditions
|
||||
revealed = character_service.check_npc_secret_conditions(character, npc)
|
||||
|
||||
# Build available knowledge (public + revealed)
|
||||
available_knowledge = []
|
||||
if npc.knowledge:
|
||||
available_knowledge.extend(npc.knowledge.public)
|
||||
available_knowledge.extend(revealed)
|
||||
|
||||
npc_data["available_knowledge"] = available_knowledge
|
||||
|
||||
# Remove secret knowledge from response
|
||||
if npc_data.get("knowledge"):
|
||||
npc_data["knowledge"]["secret"] = []
|
||||
npc_data["knowledge"]["will_share_if"] = []
|
||||
|
||||
# Add interaction summary
|
||||
interaction = character.npc_interactions.get(npc_id, {})
|
||||
npc_data["interaction_summary"] = {
|
||||
"interaction_count": interaction.get("interaction_count", 0),
|
||||
"relationship_level": interaction.get("relationship_level", 50),
|
||||
"first_met": interaction.get("first_met"),
|
||||
}
|
||||
|
||||
except CharacterNotFound:
|
||||
logger.debug("Character not found for NPC filter", character_id=character_id)
|
||||
|
||||
return success_response(npc_data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get NPC details",
|
||||
npc_id=npc_id,
|
||||
error=str(e))
|
||||
return error_response("Failed to get NPC", 500)
|
||||
|
||||
|
||||
@npcs_bp.route('/api/v1/npcs/<npc_id>/talk', methods=['POST'])
|
||||
@require_auth
|
||||
def talk_to_npc(npc_id: str):
|
||||
"""
|
||||
Initiate conversation with an NPC.
|
||||
|
||||
Validates NPC is at current location, updates interaction state,
|
||||
and queues AI dialogue generation task.
|
||||
|
||||
Path params:
|
||||
npc_id: NPC ID to talk to
|
||||
|
||||
Request body:
|
||||
session_id: Active session ID
|
||||
topic: Conversation topic/opener (default: "greeting")
|
||||
player_response: What the player says to the NPC (overrides topic if provided)
|
||||
|
||||
Returns:
|
||||
JSON response with job_id for polling result
|
||||
"""
|
||||
try:
|
||||
user = get_current_user()
|
||||
data = request.get_json()
|
||||
|
||||
session_id = data.get('session_id')
|
||||
# player_response overrides topic for bidirectional dialogue
|
||||
player_response = data.get('player_response')
|
||||
topic = player_response if player_response else data.get('topic', 'greeting')
|
||||
|
||||
if not session_id:
|
||||
return validation_error_response("session_id is required")
|
||||
|
||||
# Get session
|
||||
session_service = get_session_service()
|
||||
session = session_service.get_session(session_id, user.id)
|
||||
|
||||
# Load NPC
|
||||
npc_loader = get_npc_loader()
|
||||
npc = npc_loader.load_npc(npc_id)
|
||||
|
||||
if not npc:
|
||||
return not_found_response("NPC not found")
|
||||
|
||||
# Validate NPC is at current location
|
||||
if npc.location_id != session.game_state.current_location:
|
||||
logger.warning("NPC not at current location",
|
||||
npc_id=npc_id,
|
||||
npc_location=npc.location_id,
|
||||
current_location=session.game_state.current_location)
|
||||
return error_response("NPC is not at your current location", 400)
|
||||
|
||||
# Get character
|
||||
character_service = get_character_service()
|
||||
character = character_service.get_character(session.solo_character_id, user.id)
|
||||
|
||||
# Get or create interaction state
|
||||
now = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
interaction = character.npc_interactions.get(npc_id, {})
|
||||
|
||||
if not interaction:
|
||||
# First meeting
|
||||
interaction = {
|
||||
"npc_id": npc_id,
|
||||
"first_met": now,
|
||||
"last_interaction": now,
|
||||
"interaction_count": 1,
|
||||
"revealed_secrets": [],
|
||||
"relationship_level": 50,
|
||||
"custom_flags": {},
|
||||
}
|
||||
else:
|
||||
# Update existing interaction
|
||||
interaction["last_interaction"] = now
|
||||
interaction["interaction_count"] = interaction.get("interaction_count", 0) + 1
|
||||
|
||||
# Check for newly revealed secrets
|
||||
revealed = character_service.check_npc_secret_conditions(character, npc)
|
||||
|
||||
# Update character with new interaction state
|
||||
character_service.update_npc_interaction(
|
||||
character.character_id,
|
||||
user.id,
|
||||
npc_id,
|
||||
interaction
|
||||
)
|
||||
|
||||
# Build NPC knowledge for AI context
|
||||
npc_knowledge = []
|
||||
if npc.knowledge:
|
||||
npc_knowledge.extend(npc.knowledge.public)
|
||||
npc_knowledge.extend(revealed)
|
||||
|
||||
# Get previous dialogue history for context (last 3 exchanges)
|
||||
previous_dialogue = character_service.get_npc_dialogue_history(
|
||||
character.character_id,
|
||||
user.id,
|
||||
npc_id,
|
||||
limit=3
|
||||
)
|
||||
|
||||
# Prepare AI context
|
||||
task_context = {
|
||||
"session_id": session_id,
|
||||
"character_id": character.character_id,
|
||||
"character": character.to_story_dict(),
|
||||
"npc": npc.to_story_dict(),
|
||||
"npc_full": npc.to_dict(), # Full NPC data for reference
|
||||
"conversation_topic": topic,
|
||||
"game_state": session.game_state.to_dict(),
|
||||
"npc_knowledge": npc_knowledge,
|
||||
"revealed_secrets": revealed,
|
||||
"interaction_count": interaction["interaction_count"],
|
||||
"relationship_level": interaction.get("relationship_level", 50),
|
||||
"previous_dialogue": previous_dialogue, # Pass conversation history
|
||||
}
|
||||
|
||||
# Enqueue AI task
|
||||
result = enqueue_ai_task(
|
||||
task_type=TaskType.NPC_DIALOGUE,
|
||||
user_id=user.id,
|
||||
context=task_context,
|
||||
priority="normal",
|
||||
session_id=session_id,
|
||||
character_id=character.character_id
|
||||
)
|
||||
|
||||
logger.info("NPC dialogue task queued",
|
||||
user_id=user.id,
|
||||
npc_id=npc_id,
|
||||
job_id=result.get('job_id'),
|
||||
interaction_count=interaction["interaction_count"])
|
||||
|
||||
return accepted_response({
|
||||
"job_id": result.get('job_id'),
|
||||
"status": "queued",
|
||||
"message": f"Starting conversation with {npc.name}...",
|
||||
"npc_name": npc.name,
|
||||
"npc_role": npc.role,
|
||||
})
|
||||
|
||||
except SessionNotFound:
|
||||
return not_found_response("Session not found")
|
||||
except CharacterNotFound:
|
||||
return not_found_response("Character not found")
|
||||
except Exception as e:
|
||||
logger.error("Failed to talk to NPC",
|
||||
npc_id=npc_id,
|
||||
error=str(e))
|
||||
return error_response("Failed to start conversation", 500)
|
||||
|
||||
|
||||
@npcs_bp.route('/api/v1/npcs/at-location/<location_id>', methods=['GET'])
|
||||
@require_auth
|
||||
def get_npcs_at_location(location_id: str):
|
||||
"""
|
||||
Get all NPCs at a specific location.
|
||||
|
||||
Path params:
|
||||
location_id: Location ID to get NPCs for
|
||||
|
||||
Returns:
|
||||
JSON response with list of NPCs at location
|
||||
"""
|
||||
try:
|
||||
npc_loader = get_npc_loader()
|
||||
npcs = npc_loader.get_npcs_at_location(location_id)
|
||||
|
||||
npcs_list = []
|
||||
for npc in npcs:
|
||||
npcs_list.append({
|
||||
"npc_id": npc.npc_id,
|
||||
"name": npc.name,
|
||||
"role": npc.role,
|
||||
"appearance": npc.appearance.brief,
|
||||
"tags": npc.tags,
|
||||
})
|
||||
|
||||
return success_response({
|
||||
"location_id": location_id,
|
||||
"npcs": npcs_list,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get NPCs at location",
|
||||
location_id=location_id,
|
||||
error=str(e))
|
||||
return error_response("Failed to get NPCs", 500)
|
||||
|
||||
|
||||
@npcs_bp.route('/api/v1/npcs/<npc_id>/relationship', methods=['POST'])
|
||||
@require_auth
|
||||
def adjust_npc_relationship(npc_id: str):
|
||||
"""
|
||||
Adjust relationship level with an NPC.
|
||||
|
||||
Path params:
|
||||
npc_id: NPC ID
|
||||
|
||||
Request body:
|
||||
character_id: Character ID
|
||||
adjustment: Amount to add/subtract (can be negative)
|
||||
|
||||
Returns:
|
||||
JSON response with updated relationship level
|
||||
"""
|
||||
try:
|
||||
user = get_current_user()
|
||||
data = request.get_json()
|
||||
|
||||
character_id = data.get('character_id')
|
||||
adjustment = data.get('adjustment', 0)
|
||||
|
||||
if not character_id:
|
||||
return validation_error_response("character_id is required")
|
||||
|
||||
if not isinstance(adjustment, int):
|
||||
return validation_error_response("adjustment must be an integer")
|
||||
|
||||
# Validate NPC exists
|
||||
npc_loader = get_npc_loader()
|
||||
npc = npc_loader.load_npc(npc_id)
|
||||
|
||||
if not npc:
|
||||
return not_found_response("NPC not found")
|
||||
|
||||
# Adjust relationship
|
||||
character_service = get_character_service()
|
||||
character = character_service.adjust_npc_relationship(
|
||||
character_id,
|
||||
user.id,
|
||||
npc_id,
|
||||
adjustment
|
||||
)
|
||||
|
||||
new_level = character.npc_interactions.get(npc_id, {}).get("relationship_level", 50)
|
||||
|
||||
logger.info("NPC relationship adjusted",
|
||||
npc_id=npc_id,
|
||||
character_id=character_id,
|
||||
adjustment=adjustment,
|
||||
new_level=new_level)
|
||||
|
||||
return success_response({
|
||||
"npc_id": npc_id,
|
||||
"relationship_level": new_level,
|
||||
})
|
||||
|
||||
except CharacterNotFound:
|
||||
return not_found_response("Character not found")
|
||||
except Exception as e:
|
||||
logger.error("Failed to adjust NPC relationship",
|
||||
npc_id=npc_id,
|
||||
error=str(e))
|
||||
return error_response("Failed to adjust relationship", 500)
|
||||
|
||||
|
||||
@npcs_bp.route('/api/v1/npcs/<npc_id>/flag', methods=['POST'])
|
||||
@require_auth
|
||||
def set_npc_flag(npc_id: str):
|
||||
"""
|
||||
Set a custom flag on NPC interaction (e.g., "helped_with_rats": true).
|
||||
|
||||
Path params:
|
||||
npc_id: NPC ID
|
||||
|
||||
Request body:
|
||||
character_id: Character ID
|
||||
flag_name: Name of the flag
|
||||
flag_value: Value to set
|
||||
|
||||
Returns:
|
||||
JSON response confirming flag was set
|
||||
"""
|
||||
try:
|
||||
user = get_current_user()
|
||||
data = request.get_json()
|
||||
|
||||
character_id = data.get('character_id')
|
||||
flag_name = data.get('flag_name')
|
||||
flag_value = data.get('flag_value')
|
||||
|
||||
if not character_id:
|
||||
return validation_error_response("character_id is required")
|
||||
if not flag_name:
|
||||
return validation_error_response("flag_name is required")
|
||||
|
||||
# Validate NPC exists
|
||||
npc_loader = get_npc_loader()
|
||||
npc = npc_loader.load_npc(npc_id)
|
||||
|
||||
if not npc:
|
||||
return not_found_response("NPC not found")
|
||||
|
||||
# Set flag
|
||||
character_service = get_character_service()
|
||||
character_service.set_npc_custom_flag(
|
||||
character_id,
|
||||
user.id,
|
||||
npc_id,
|
||||
flag_name,
|
||||
flag_value
|
||||
)
|
||||
|
||||
logger.info("NPC flag set",
|
||||
npc_id=npc_id,
|
||||
character_id=character_id,
|
||||
flag_name=flag_name)
|
||||
|
||||
return success_response({
|
||||
"npc_id": npc_id,
|
||||
"flag_name": flag_name,
|
||||
"flag_value": flag_value,
|
||||
})
|
||||
|
||||
except CharacterNotFound:
|
||||
return not_found_response("Character not found")
|
||||
except Exception as e:
|
||||
logger.error("Failed to set NPC flag",
|
||||
npc_id=npc_id,
|
||||
error=str(e))
|
||||
return error_response("Failed to set flag", 500)
|
||||
604
api/app/api/sessions.py
Normal file
604
api/app/api/sessions.py
Normal file
@@ -0,0 +1,604 @@
|
||||
"""
|
||||
Sessions API Blueprint
|
||||
|
||||
This module provides API endpoints for story session management:
|
||||
- Create new solo session
|
||||
- Get session state
|
||||
- Take action (async AI processing)
|
||||
- Get conversation history
|
||||
|
||||
All endpoints require authentication and enforce ownership validation.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, request, g
|
||||
|
||||
from app.services.session_service import (
|
||||
get_session_service,
|
||||
SessionNotFound,
|
||||
SessionLimitExceeded,
|
||||
SessionValidationError
|
||||
)
|
||||
from app.services.character_service import CharacterNotFound, get_character_service
|
||||
from app.services.rate_limiter_service import RateLimiterService, RateLimitExceeded
|
||||
from app.services.action_prompt_loader import ActionPromptLoader, ActionPromptNotFoundError
|
||||
from app.services.outcome_service import outcome_service
|
||||
from app.tasks.ai_tasks import enqueue_ai_task, TaskType, get_job_status, get_job_result
|
||||
from app.ai.model_selector import UserTier
|
||||
from app.models.action_prompt import LocationType
|
||||
from app.game_logic.dice import SkillType
|
||||
from app.utils.response import (
|
||||
success_response,
|
||||
created_response,
|
||||
accepted_response,
|
||||
error_response,
|
||||
not_found_response,
|
||||
validation_error_response,
|
||||
rate_limit_exceeded_response
|
||||
)
|
||||
from app.utils.auth import require_auth, get_current_user
|
||||
from app.utils.logging import get_logger
|
||||
from app.config import get_config
|
||||
|
||||
|
||||
# Initialize logger
|
||||
logger = get_logger(__file__)
|
||||
|
||||
# Create blueprint
|
||||
sessions_bp = Blueprint('sessions', __name__)
|
||||
|
||||
|
||||
# ===== VALIDATION FUNCTIONS =====
|
||||
|
||||
def validate_character_id(character_id: str) -> tuple[bool, str]:
|
||||
"""
|
||||
Validate character ID format.
|
||||
|
||||
Args:
|
||||
character_id: Character ID to validate
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
if not character_id:
|
||||
return False, "Character ID is required"
|
||||
|
||||
if not isinstance(character_id, str):
|
||||
return False, "Character ID must be a string"
|
||||
|
||||
if len(character_id) > 100:
|
||||
return False, "Character ID is too long"
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
def validate_action_request(data: dict, user_tier: UserTier) -> tuple[bool, str]:
|
||||
"""
|
||||
Validate action request data.
|
||||
|
||||
Args:
|
||||
data: Request JSON data
|
||||
user_tier: User's subscription tier
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
action_type = data.get('action_type')
|
||||
|
||||
if action_type != 'button':
|
||||
return False, "action_type must be 'button'"
|
||||
|
||||
if not data.get('prompt_id'):
|
||||
return False, "prompt_id is required for button actions"
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
def get_user_tier_from_user(user) -> UserTier:
|
||||
"""
|
||||
Get UserTier enum from user object.
|
||||
|
||||
Args:
|
||||
user: User object from auth
|
||||
|
||||
Returns:
|
||||
UserTier enum value
|
||||
"""
|
||||
# Map user tier string to UserTier enum
|
||||
tier_mapping = {
|
||||
'free': UserTier.FREE,
|
||||
'basic': UserTier.BASIC,
|
||||
'premium': UserTier.PREMIUM,
|
||||
'elite': UserTier.ELITE
|
||||
}
|
||||
|
||||
user_tier_str = getattr(user, 'tier', 'free').lower()
|
||||
return tier_mapping.get(user_tier_str, UserTier.FREE)
|
||||
|
||||
|
||||
# ===== API ENDPOINTS =====
|
||||
|
||||
@sessions_bp.route('/api/v1/sessions', methods=['GET'])
|
||||
@require_auth
|
||||
def list_sessions():
|
||||
"""
|
||||
List user's active game sessions.
|
||||
|
||||
Returns all active sessions for the authenticated user with basic session info.
|
||||
|
||||
Returns:
|
||||
JSON response with list of sessions
|
||||
"""
|
||||
try:
|
||||
user = get_current_user()
|
||||
user_id = user.id
|
||||
session_service = get_session_service()
|
||||
|
||||
# Get user's active sessions
|
||||
sessions = session_service.get_user_sessions(user_id, active_only=True)
|
||||
|
||||
# Build response with basic session info
|
||||
sessions_list = []
|
||||
for session in sessions:
|
||||
sessions_list.append({
|
||||
'session_id': session.session_id,
|
||||
'character_id': session.solo_character_id,
|
||||
'turn_number': session.turn_number,
|
||||
'status': session.status.value,
|
||||
'created_at': session.created_at,
|
||||
'last_activity': session.last_activity,
|
||||
'game_state': {
|
||||
'current_location': session.game_state.current_location,
|
||||
'location_type': session.game_state.location_type.value
|
||||
}
|
||||
})
|
||||
|
||||
logger.info("Sessions listed successfully",
|
||||
user_id=user_id,
|
||||
count=len(sessions_list))
|
||||
|
||||
return success_response(sessions_list)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to list sessions", error=str(e))
|
||||
return error_response(f"Failed to list sessions: {str(e)}", 500)
|
||||
|
||||
|
||||
@sessions_bp.route('/api/v1/sessions', methods=['POST'])
|
||||
@require_auth
|
||||
def create_session():
|
||||
"""
|
||||
Create a new solo game session.
|
||||
|
||||
Request Body:
|
||||
{
|
||||
"character_id": "char_456"
|
||||
}
|
||||
|
||||
Returns:
|
||||
201: Session created with initial state
|
||||
400: Validation error
|
||||
401: Not authenticated
|
||||
404: Character not found
|
||||
409: Session limit exceeded
|
||||
500: Internal server error
|
||||
"""
|
||||
logger.info("Creating new session")
|
||||
|
||||
try:
|
||||
# Get current user
|
||||
user = get_current_user()
|
||||
user_id = user.id
|
||||
|
||||
# Parse and validate request
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return validation_error_response("Request body is required")
|
||||
|
||||
character_id = data.get('character_id')
|
||||
is_valid, error_msg = validate_character_id(character_id)
|
||||
if not is_valid:
|
||||
return validation_error_response(error_msg)
|
||||
|
||||
# Create session
|
||||
session_service = get_session_service()
|
||||
session = session_service.create_solo_session(
|
||||
user_id=user_id,
|
||||
character_id=character_id
|
||||
)
|
||||
|
||||
logger.info("Session created successfully",
|
||||
session_id=session.session_id,
|
||||
user_id=user_id,
|
||||
character_id=character_id)
|
||||
|
||||
# Return session data
|
||||
return created_response({
|
||||
"session_id": session.session_id,
|
||||
"character_id": session.solo_character_id,
|
||||
"turn_number": session.turn_number,
|
||||
"game_state": {
|
||||
"current_location": session.game_state.current_location,
|
||||
"location_type": session.game_state.location_type.value,
|
||||
"active_quests": session.game_state.active_quests
|
||||
}
|
||||
})
|
||||
|
||||
except CharacterNotFound as e:
|
||||
logger.warning("Character not found for session creation",
|
||||
error=str(e))
|
||||
return not_found_response("Character not found")
|
||||
|
||||
except SessionLimitExceeded as e:
|
||||
logger.warning("Session limit exceeded",
|
||||
user_id=user_id if 'user_id' in locals() else 'unknown',
|
||||
error=str(e))
|
||||
return error_response(
|
||||
status=409,
|
||||
code="SESSION_LIMIT_EXCEEDED",
|
||||
message="Maximum active sessions limit reached (5). Please end an existing session first."
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to create session",
|
||||
error=str(e),
|
||||
exc_info=True)
|
||||
return error_response(
|
||||
status=500,
|
||||
code="SESSION_CREATE_ERROR",
|
||||
message="Failed to create session"
|
||||
)
|
||||
|
||||
|
||||
@sessions_bp.route('/api/v1/sessions/<session_id>/action', methods=['POST'])
|
||||
@require_auth
|
||||
def take_action(session_id: str):
|
||||
"""
|
||||
Submit an action for AI processing (async).
|
||||
|
||||
Request Body:
|
||||
{
|
||||
"action_type": "button",
|
||||
"prompt_id": "ask_locals"
|
||||
}
|
||||
|
||||
Returns:
|
||||
202: Action queued for processing
|
||||
400: Validation error
|
||||
401: Not authenticated
|
||||
403: Action not available for tier/location
|
||||
404: Session not found
|
||||
429: Rate limit exceeded
|
||||
500: Internal server error
|
||||
"""
|
||||
logger.info("Processing action request", session_id=session_id)
|
||||
|
||||
try:
|
||||
# Get current user
|
||||
user = get_current_user()
|
||||
user_id = user.id
|
||||
user_tier = get_user_tier_from_user(user)
|
||||
|
||||
# Verify session ownership and get session
|
||||
session_service = get_session_service()
|
||||
session = session_service.get_session(session_id, user_id)
|
||||
|
||||
# Parse and validate request
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return validation_error_response("Request body is required")
|
||||
|
||||
is_valid, error_msg = validate_action_request(data, user_tier)
|
||||
if not is_valid:
|
||||
return validation_error_response(error_msg)
|
||||
|
||||
# Check rate limit
|
||||
rate_limiter = RateLimiterService()
|
||||
|
||||
try:
|
||||
rate_limiter.check_rate_limit(user_id, user_tier)
|
||||
except RateLimitExceeded as e:
|
||||
logger.warning("Rate limit exceeded",
|
||||
user_id=user_id,
|
||||
tier=user_tier.value)
|
||||
return rate_limit_exceeded_response(
|
||||
message=f"Daily turn limit reached ({e.limit} turns). Resets at {e.reset_time.strftime('%H:%M UTC')}"
|
||||
)
|
||||
|
||||
# Build action context for AI task
|
||||
prompt_id = data.get('prompt_id')
|
||||
|
||||
# Validate prompt exists and is available
|
||||
loader = ActionPromptLoader()
|
||||
try:
|
||||
action_prompt = loader.get_action_by_id(prompt_id)
|
||||
except ActionPromptNotFoundError:
|
||||
return validation_error_response(f"Invalid prompt_id: {prompt_id}")
|
||||
|
||||
# Check if action is available for user's tier and location
|
||||
location_type = session.game_state.location_type
|
||||
if not action_prompt.is_available(user_tier, location_type):
|
||||
return error_response(
|
||||
status=403,
|
||||
code="ACTION_NOT_AVAILABLE",
|
||||
message="This action is not available for your tier or location"
|
||||
)
|
||||
|
||||
action_text = action_prompt.display_text
|
||||
dm_prompt_template = action_prompt.dm_prompt_template
|
||||
|
||||
# Fetch character data for AI context
|
||||
character_service = get_character_service()
|
||||
character = character_service.get_character(session.solo_character_id, user_id)
|
||||
if not character:
|
||||
return not_found_response(f"Character {session.solo_character_id} not found")
|
||||
|
||||
# Perform dice check if action requires it
|
||||
check_outcome = None
|
||||
if action_prompt.requires_check:
|
||||
check_req = action_prompt.requires_check
|
||||
location_type_str = session.game_state.location_type.value if hasattr(session.game_state.location_type, 'value') else str(session.game_state.location_type)
|
||||
|
||||
# Get DC from difficulty
|
||||
dc = outcome_service.get_dc_for_difficulty(check_req.difficulty)
|
||||
|
||||
if check_req.check_type == "search":
|
||||
# Search check - uses perception and returns items/gold
|
||||
outcome = outcome_service.determine_search_outcome(
|
||||
character=character,
|
||||
location_type=location_type_str,
|
||||
dc=dc
|
||||
)
|
||||
check_outcome = outcome.to_dict()
|
||||
|
||||
logger.info(
|
||||
"Search check performed",
|
||||
character_id=character.character_id,
|
||||
success=outcome.check_result.success,
|
||||
items_found=len(outcome.items_found),
|
||||
gold_found=outcome.gold_found
|
||||
)
|
||||
elif check_req.check_type == "skill" and check_req.skill:
|
||||
# Skill check - generic skill vs DC
|
||||
try:
|
||||
skill_type = SkillType[check_req.skill.upper()]
|
||||
outcome = outcome_service.determine_skill_check_outcome(
|
||||
character=character,
|
||||
skill_type=skill_type,
|
||||
dc=dc
|
||||
)
|
||||
check_outcome = outcome.to_dict()
|
||||
|
||||
logger.info(
|
||||
"Skill check performed",
|
||||
character_id=character.character_id,
|
||||
skill=check_req.skill,
|
||||
success=outcome.check_result.success
|
||||
)
|
||||
except (KeyError, ValueError) as e:
|
||||
logger.warning(
|
||||
"Invalid skill type in action prompt",
|
||||
prompt_id=action_prompt.prompt_id,
|
||||
skill=check_req.skill,
|
||||
error=str(e)
|
||||
)
|
||||
|
||||
# Queue AI task
|
||||
# Use trimmed character data for AI prompts (reduces tokens, focuses on story-relevant info)
|
||||
task_context = {
|
||||
"session_id": session_id,
|
||||
"character_id": session.solo_character_id,
|
||||
"action": action_text,
|
||||
"prompt_id": prompt_id,
|
||||
"dm_prompt_template": dm_prompt_template,
|
||||
"character": character.to_story_dict(),
|
||||
"game_state": session.game_state.to_dict(),
|
||||
"turn_number": session.turn_number,
|
||||
"conversation_history": [entry.to_dict() for entry in session.conversation_history],
|
||||
"world_context": None, # TODO: Add world context source when available
|
||||
"check_outcome": check_outcome # Dice check result for predetermined outcomes
|
||||
}
|
||||
|
||||
result = enqueue_ai_task(
|
||||
task_type=TaskType.NARRATIVE,
|
||||
user_id=user_id,
|
||||
context=task_context,
|
||||
priority="normal",
|
||||
session_id=session_id,
|
||||
character_id=session.solo_character_id
|
||||
)
|
||||
|
||||
# Increment rate limit counter
|
||||
rate_limiter.increment_usage(user_id)
|
||||
|
||||
logger.info("Action queued for processing",
|
||||
session_id=session_id,
|
||||
job_id=result.get('job_id'),
|
||||
prompt_id=prompt_id)
|
||||
|
||||
return accepted_response({
|
||||
"job_id": result.get('job_id'),
|
||||
"status": result.get('status', 'queued'),
|
||||
"message": "Your action is being processed..."
|
||||
})
|
||||
|
||||
except SessionNotFound as e:
|
||||
logger.warning("Session not found for action",
|
||||
session_id=session_id,
|
||||
error=str(e))
|
||||
return not_found_response("Session not found")
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to process action",
|
||||
session_id=session_id,
|
||||
error=str(e),
|
||||
exc_info=True)
|
||||
return error_response(
|
||||
status=500,
|
||||
code="ACTION_PROCESS_ERROR",
|
||||
message="Failed to process action"
|
||||
)
|
||||
|
||||
|
||||
@sessions_bp.route('/api/v1/sessions/<session_id>', methods=['GET'])
|
||||
@require_auth
|
||||
def get_session_state(session_id: str):
|
||||
"""
|
||||
Get current session state with available actions.
|
||||
|
||||
Returns:
|
||||
200: Session state
|
||||
401: Not authenticated
|
||||
404: Session not found
|
||||
500: Internal server error
|
||||
"""
|
||||
logger.info("Getting session state", session_id=session_id)
|
||||
|
||||
try:
|
||||
# Get current user
|
||||
user = get_current_user()
|
||||
user_id = user.id
|
||||
user_tier = get_user_tier_from_user(user)
|
||||
|
||||
# Get session
|
||||
session_service = get_session_service()
|
||||
session = session_service.get_session(session_id, user_id)
|
||||
|
||||
# Get available actions based on location and tier
|
||||
loader = ActionPromptLoader()
|
||||
location_type = session.game_state.location_type
|
||||
|
||||
available_actions = []
|
||||
for action in loader.get_available_actions(user_tier, location_type):
|
||||
available_actions.append({
|
||||
"prompt_id": action.prompt_id,
|
||||
"display_text": action.display_text,
|
||||
"description": action.description,
|
||||
"category": action.category.value
|
||||
})
|
||||
|
||||
logger.debug("Session state retrieved",
|
||||
session_id=session_id,
|
||||
turn_number=session.turn_number)
|
||||
|
||||
return success_response({
|
||||
"session_id": session.session_id,
|
||||
"character_id": session.get_character_id(),
|
||||
"turn_number": session.turn_number,
|
||||
"status": session.status.value,
|
||||
"game_state": {
|
||||
"current_location": session.game_state.current_location,
|
||||
"location_type": session.game_state.location_type.value,
|
||||
"active_quests": session.game_state.active_quests
|
||||
},
|
||||
"available_actions": available_actions
|
||||
})
|
||||
|
||||
except SessionNotFound as e:
|
||||
logger.warning("Session not found",
|
||||
session_id=session_id,
|
||||
error=str(e))
|
||||
return not_found_response("Session not found")
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get session state",
|
||||
session_id=session_id,
|
||||
error=str(e),
|
||||
exc_info=True)
|
||||
return error_response(
|
||||
status=500,
|
||||
code="SESSION_STATE_ERROR",
|
||||
message="Failed to get session state"
|
||||
)
|
||||
|
||||
|
||||
@sessions_bp.route('/api/v1/sessions/<session_id>/history', methods=['GET'])
|
||||
@require_auth
|
||||
def get_history(session_id: str):
|
||||
"""
|
||||
Get conversation history for a session.
|
||||
|
||||
Query Parameters:
|
||||
limit: Number of entries to return (default 20)
|
||||
offset: Number of entries to skip (default 0)
|
||||
|
||||
Returns:
|
||||
200: Paginated conversation history
|
||||
401: Not authenticated
|
||||
404: Session not found
|
||||
500: Internal server error
|
||||
"""
|
||||
logger.info("Getting conversation history", session_id=session_id)
|
||||
|
||||
try:
|
||||
# Get current user
|
||||
user = get_current_user()
|
||||
user_id = user.id
|
||||
|
||||
# Get pagination params
|
||||
limit = request.args.get('limit', 20, type=int)
|
||||
offset = request.args.get('offset', 0, type=int)
|
||||
|
||||
# Clamp values
|
||||
limit = max(1, min(limit, 100)) # 1-100
|
||||
offset = max(0, offset)
|
||||
|
||||
# Verify session ownership
|
||||
session_service = get_session_service()
|
||||
session = session_service.get_session(session_id, user_id)
|
||||
|
||||
# Get total history
|
||||
total_history = session.conversation_history
|
||||
total_turns = len(total_history)
|
||||
|
||||
# Apply pagination (from beginning)
|
||||
paginated_history = total_history[offset:offset + limit]
|
||||
|
||||
# Format history entries
|
||||
history_data = []
|
||||
for entry in paginated_history:
|
||||
# Handle timestamp - could be datetime object or already a string
|
||||
timestamp = None
|
||||
if hasattr(entry, 'timestamp') and entry.timestamp:
|
||||
if isinstance(entry.timestamp, str):
|
||||
timestamp = entry.timestamp
|
||||
else:
|
||||
timestamp = entry.timestamp.isoformat()
|
||||
|
||||
history_data.append({
|
||||
"turn": entry.turn,
|
||||
"action": entry.action,
|
||||
"dm_response": entry.dm_response,
|
||||
"timestamp": timestamp
|
||||
})
|
||||
|
||||
logger.debug("Conversation history retrieved",
|
||||
session_id=session_id,
|
||||
total=total_turns,
|
||||
returned=len(history_data))
|
||||
|
||||
return success_response({
|
||||
"total_turns": total_turns,
|
||||
"history": history_data,
|
||||
"pagination": {
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"has_more": (offset + limit) < total_turns
|
||||
}
|
||||
})
|
||||
|
||||
except SessionNotFound as e:
|
||||
logger.warning("Session not found for history",
|
||||
session_id=session_id,
|
||||
error=str(e))
|
||||
return not_found_response("Session not found")
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get conversation history",
|
||||
session_id=session_id,
|
||||
error=str(e),
|
||||
exc_info=True)
|
||||
return error_response(
|
||||
status=500,
|
||||
code="HISTORY_ERROR",
|
||||
message="Failed to get conversation history"
|
||||
)
|
||||
306
api/app/api/travel.py
Normal file
306
api/app/api/travel.py
Normal file
@@ -0,0 +1,306 @@
|
||||
"""
|
||||
Travel API Blueprint
|
||||
|
||||
This module provides API endpoints for location-based travel:
|
||||
- Get available destinations
|
||||
- Travel to a location
|
||||
- Get current location details
|
||||
|
||||
All endpoints require authentication and enforce ownership validation.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, request
|
||||
|
||||
from app.services.session_service import get_session_service, SessionNotFound
|
||||
from app.services.character_service import get_character_service, CharacterNotFound
|
||||
from app.services.location_loader import get_location_loader
|
||||
from app.services.npc_loader import get_npc_loader
|
||||
from app.utils.response import (
|
||||
success_response,
|
||||
error_response,
|
||||
not_found_response,
|
||||
validation_error_response
|
||||
)
|
||||
from app.utils.auth import require_auth, get_current_user
|
||||
from app.utils.logging import get_logger
|
||||
|
||||
|
||||
# Initialize logger
|
||||
logger = get_logger(__file__)
|
||||
|
||||
# Create blueprint
|
||||
travel_bp = Blueprint('travel', __name__)
|
||||
|
||||
|
||||
@travel_bp.route('/api/v1/travel/available', methods=['GET'])
|
||||
@require_auth
|
||||
def get_available_destinations():
|
||||
"""
|
||||
Get all locations the character can travel to.
|
||||
|
||||
Query params:
|
||||
session_id: Active session ID
|
||||
|
||||
Returns:
|
||||
JSON response with list of available destinations
|
||||
"""
|
||||
try:
|
||||
user = get_current_user()
|
||||
session_id = request.args.get('session_id')
|
||||
|
||||
if not session_id:
|
||||
return validation_error_response("session_id query parameter is required")
|
||||
|
||||
# Get session and verify ownership
|
||||
session_service = get_session_service()
|
||||
session = session_service.get_session(session_id, user.id)
|
||||
|
||||
# Get character for discovered locations
|
||||
character_service = get_character_service()
|
||||
character = character_service.get_character(session.solo_character_id, user.id)
|
||||
|
||||
# Load location details for each discovered location
|
||||
location_loader = get_location_loader()
|
||||
destinations = []
|
||||
|
||||
for loc_id in character.discovered_locations:
|
||||
# Skip current location
|
||||
if loc_id == session.game_state.current_location:
|
||||
continue
|
||||
|
||||
location = location_loader.load_location(loc_id)
|
||||
if location:
|
||||
destinations.append({
|
||||
"location_id": location.location_id,
|
||||
"name": location.name,
|
||||
"location_type": location.location_type.value,
|
||||
"region_id": location.region_id,
|
||||
"description": location.description[:200] + "..." if len(location.description) > 200 else location.description,
|
||||
})
|
||||
|
||||
logger.info("Retrieved available destinations",
|
||||
user_id=user.id,
|
||||
session_id=session_id,
|
||||
destination_count=len(destinations))
|
||||
|
||||
return success_response({
|
||||
"current_location": session.game_state.current_location,
|
||||
"destinations": destinations
|
||||
})
|
||||
|
||||
except SessionNotFound:
|
||||
return not_found_response("Session not found")
|
||||
except CharacterNotFound:
|
||||
return not_found_response("Character not found")
|
||||
except Exception as e:
|
||||
logger.error("Failed to get available destinations",
|
||||
error=str(e))
|
||||
return error_response("Failed to get destinations", 500)
|
||||
|
||||
|
||||
@travel_bp.route('/api/v1/travel', methods=['POST'])
|
||||
@require_auth
|
||||
def travel_to_location():
|
||||
"""
|
||||
Travel to a discovered location.
|
||||
|
||||
Request body:
|
||||
session_id: Active session ID
|
||||
location_id: Target location ID
|
||||
|
||||
Returns:
|
||||
JSON response with new location details and NPCs present
|
||||
"""
|
||||
try:
|
||||
user = get_current_user()
|
||||
data = request.get_json()
|
||||
|
||||
session_id = data.get('session_id')
|
||||
location_id = data.get('location_id')
|
||||
|
||||
# Validate required fields
|
||||
if not session_id:
|
||||
return validation_error_response("session_id is required")
|
||||
if not location_id:
|
||||
return validation_error_response("location_id is required")
|
||||
|
||||
# Get session and verify ownership
|
||||
session_service = get_session_service()
|
||||
session = session_service.get_session(session_id, user.id)
|
||||
|
||||
# Get character and verify location is discovered
|
||||
character_service = get_character_service()
|
||||
character = character_service.get_character(session.solo_character_id, user.id)
|
||||
|
||||
if location_id not in character.discovered_locations:
|
||||
logger.warning("Attempted travel to undiscovered location",
|
||||
user_id=user.id,
|
||||
character_id=character.character_id,
|
||||
location_id=location_id)
|
||||
return error_response("Location not discovered", 403)
|
||||
|
||||
# Load location details
|
||||
location_loader = get_location_loader()
|
||||
location = location_loader.load_location(location_id)
|
||||
|
||||
if not location:
|
||||
logger.error("Location not found in data files",
|
||||
location_id=location_id)
|
||||
return not_found_response("Location not found")
|
||||
|
||||
# Update session with new location
|
||||
session = session_service.update_location(
|
||||
session_id,
|
||||
location_id,
|
||||
location.location_type
|
||||
)
|
||||
|
||||
# Get NPCs at new location
|
||||
npc_loader = get_npc_loader()
|
||||
npcs = npc_loader.get_npcs_at_location(location_id)
|
||||
|
||||
# Build NPC summary list
|
||||
npcs_present = []
|
||||
for npc in npcs:
|
||||
npcs_present.append({
|
||||
"npc_id": npc.npc_id,
|
||||
"name": npc.name,
|
||||
"role": npc.role,
|
||||
"appearance": npc.appearance.brief,
|
||||
})
|
||||
|
||||
logger.info("Character traveled to location",
|
||||
user_id=user.id,
|
||||
session_id=session_id,
|
||||
location_id=location_id)
|
||||
|
||||
return success_response({
|
||||
"location": location.to_dict(),
|
||||
"npcs_present": npcs_present,
|
||||
"game_state": session.game_state.to_dict(),
|
||||
})
|
||||
|
||||
except SessionNotFound:
|
||||
return not_found_response("Session not found")
|
||||
except CharacterNotFound:
|
||||
return not_found_response("Character not found")
|
||||
except Exception as e:
|
||||
logger.error("Failed to travel to location",
|
||||
error=str(e))
|
||||
return error_response("Failed to travel", 500)
|
||||
|
||||
|
||||
@travel_bp.route('/api/v1/travel/location/<location_id>', methods=['GET'])
|
||||
@require_auth
|
||||
def get_location_details(location_id: str):
|
||||
"""
|
||||
Get details about a specific location.
|
||||
|
||||
Path params:
|
||||
location_id: Location ID to get details for
|
||||
|
||||
Query params:
|
||||
session_id: Active session ID (optional, for context)
|
||||
|
||||
Returns:
|
||||
JSON response with location details and NPCs
|
||||
"""
|
||||
try:
|
||||
user = get_current_user()
|
||||
|
||||
# Load location
|
||||
location_loader = get_location_loader()
|
||||
location = location_loader.load_location(location_id)
|
||||
|
||||
if not location:
|
||||
return not_found_response("Location not found")
|
||||
|
||||
# Get NPCs at location
|
||||
npc_loader = get_npc_loader()
|
||||
npcs = npc_loader.get_npcs_at_location(location_id)
|
||||
|
||||
npcs_present = []
|
||||
for npc in npcs:
|
||||
npcs_present.append({
|
||||
"npc_id": npc.npc_id,
|
||||
"name": npc.name,
|
||||
"role": npc.role,
|
||||
"appearance": npc.appearance.brief,
|
||||
})
|
||||
|
||||
return success_response({
|
||||
"location": location.to_dict(),
|
||||
"npcs_present": npcs_present,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get location details",
|
||||
location_id=location_id,
|
||||
error=str(e))
|
||||
return error_response("Failed to get location", 500)
|
||||
|
||||
|
||||
@travel_bp.route('/api/v1/travel/current', methods=['GET'])
|
||||
@require_auth
|
||||
def get_current_location():
|
||||
"""
|
||||
Get details about the current location in a session.
|
||||
|
||||
Query params:
|
||||
session_id: Active session ID
|
||||
|
||||
Returns:
|
||||
JSON response with current location details and NPCs
|
||||
"""
|
||||
try:
|
||||
user = get_current_user()
|
||||
session_id = request.args.get('session_id')
|
||||
|
||||
if not session_id:
|
||||
return validation_error_response("session_id query parameter is required")
|
||||
|
||||
# Get session
|
||||
session_service = get_session_service()
|
||||
session = session_service.get_session(session_id, user.id)
|
||||
|
||||
current_location_id = session.game_state.current_location
|
||||
|
||||
# Load location
|
||||
location_loader = get_location_loader()
|
||||
location = location_loader.load_location(current_location_id)
|
||||
|
||||
if not location:
|
||||
# Location not in data files - return basic info from session
|
||||
return success_response({
|
||||
"location": {
|
||||
"location_id": current_location_id,
|
||||
"name": current_location_id,
|
||||
"location_type": session.game_state.location_type.value,
|
||||
},
|
||||
"npcs_present": [],
|
||||
})
|
||||
|
||||
# Get NPCs at location
|
||||
npc_loader = get_npc_loader()
|
||||
npcs = npc_loader.get_npcs_at_location(current_location_id)
|
||||
|
||||
npcs_present = []
|
||||
for npc in npcs:
|
||||
npcs_present.append({
|
||||
"npc_id": npc.npc_id,
|
||||
"name": npc.name,
|
||||
"role": npc.role,
|
||||
"appearance": npc.appearance.brief,
|
||||
})
|
||||
|
||||
return success_response({
|
||||
"location": location.to_dict(),
|
||||
"npcs_present": npcs_present,
|
||||
})
|
||||
|
||||
except SessionNotFound:
|
||||
return not_found_response("Session not found")
|
||||
except Exception as e:
|
||||
logger.error("Failed to get current location",
|
||||
error=str(e))
|
||||
return error_response("Failed to get current location", 500)
|
||||
Reference in New Issue
Block a user