first commit

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

0
api/app/api/__init__.py Normal file
View File

529
api/app/api/auth.py Normal file
View 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
View 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
)

View 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
View 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
View 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
View 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
View 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
View 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)