feat/session-management #5
@@ -235,7 +235,7 @@ def create_session():
|
|||||||
return error_response(
|
return error_response(
|
||||||
status=409,
|
status=409,
|
||||||
code="SESSION_LIMIT_EXCEEDED",
|
code="SESSION_LIMIT_EXCEEDED",
|
||||||
message="Maximum active sessions limit reached (5). Please end an existing session first."
|
message=str(e)
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -602,3 +602,111 @@ def get_history(session_id: str):
|
|||||||
code="HISTORY_ERROR",
|
code="HISTORY_ERROR",
|
||||||
message="Failed to get conversation history"
|
message="Failed to get conversation history"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@sessions_bp.route('/api/v1/sessions/<session_id>', methods=['DELETE'])
|
||||||
|
@require_auth
|
||||||
|
def delete_session(session_id: str):
|
||||||
|
"""
|
||||||
|
Permanently delete a game session.
|
||||||
|
|
||||||
|
This removes the session from the database entirely. The session cannot be
|
||||||
|
recovered after deletion. Use this to free up session slots for users who
|
||||||
|
have reached their tier limit.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
200: Session deleted successfully
|
||||||
|
401: Not authenticated
|
||||||
|
404: Session not found or not owned by user
|
||||||
|
500: Internal server error
|
||||||
|
"""
|
||||||
|
logger.info("Deleting session", session_id=session_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get current user
|
||||||
|
user = get_current_user()
|
||||||
|
user_id = user.id
|
||||||
|
|
||||||
|
# Delete session (validates ownership internally)
|
||||||
|
session_service = get_session_service()
|
||||||
|
session_service.delete_session(session_id, user_id)
|
||||||
|
|
||||||
|
logger.info("Session deleted successfully",
|
||||||
|
session_id=session_id,
|
||||||
|
user_id=user_id)
|
||||||
|
|
||||||
|
return success_response({
|
||||||
|
"message": "Session deleted successfully",
|
||||||
|
"session_id": session_id
|
||||||
|
})
|
||||||
|
|
||||||
|
except SessionNotFound as e:
|
||||||
|
logger.warning("Session not found for deletion",
|
||||||
|
session_id=session_id,
|
||||||
|
error=str(e))
|
||||||
|
return not_found_response("Session not found")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to delete session",
|
||||||
|
session_id=session_id,
|
||||||
|
error=str(e),
|
||||||
|
exc_info=True)
|
||||||
|
return error_response(
|
||||||
|
status=500,
|
||||||
|
code="SESSION_DELETE_ERROR",
|
||||||
|
message="Failed to delete session"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@sessions_bp.route('/api/v1/usage', methods=['GET'])
|
||||||
|
@require_auth
|
||||||
|
def get_usage():
|
||||||
|
"""
|
||||||
|
Get user's daily usage information.
|
||||||
|
|
||||||
|
Returns the current daily turn usage, limit, remaining turns,
|
||||||
|
and reset time. Limits are based on user's subscription tier.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
200: Usage information
|
||||||
|
{
|
||||||
|
"user_id": "user_123",
|
||||||
|
"user_tier": "free",
|
||||||
|
"current_usage": 15,
|
||||||
|
"daily_limit": 50,
|
||||||
|
"remaining": 35,
|
||||||
|
"reset_time": "2025-11-27T00:00:00+00:00",
|
||||||
|
"is_limited": false,
|
||||||
|
"is_unlimited": false
|
||||||
|
}
|
||||||
|
401: Not authenticated
|
||||||
|
500: Internal server error
|
||||||
|
"""
|
||||||
|
logger.info("Getting usage info")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get current user and tier
|
||||||
|
user = get_current_user()
|
||||||
|
user_id = user.id
|
||||||
|
user_tier = get_user_tier_from_user(user)
|
||||||
|
|
||||||
|
# Get usage info from rate limiter
|
||||||
|
rate_limiter = RateLimiterService()
|
||||||
|
usage_info = rate_limiter.get_usage_info(user_id, user_tier)
|
||||||
|
|
||||||
|
logger.debug("Usage info retrieved",
|
||||||
|
user_id=user_id,
|
||||||
|
current_usage=usage_info.get('current_usage'),
|
||||||
|
remaining=usage_info.get('remaining'))
|
||||||
|
|
||||||
|
return success_response(usage_info)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to get usage info",
|
||||||
|
error=str(e),
|
||||||
|
exc_info=True)
|
||||||
|
return error_response(
|
||||||
|
status=500,
|
||||||
|
code="USAGE_ERROR",
|
||||||
|
message="Failed to get usage information"
|
||||||
|
)
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ class RateLimitTier:
|
|||||||
ai_calls_per_day: int
|
ai_calls_per_day: int
|
||||||
custom_actions_per_day: int # -1 for unlimited
|
custom_actions_per_day: int # -1 for unlimited
|
||||||
custom_action_char_limit: int
|
custom_action_char_limit: int
|
||||||
|
max_sessions: int = 1 # Maximum active game sessions allowed
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|||||||
@@ -458,6 +458,59 @@ class ChatMessageService:
|
|||||||
error=str(e))
|
error=str(e))
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
def delete_messages_by_session(self, session_id: str) -> int:
|
||||||
|
"""
|
||||||
|
Permanently delete all chat messages associated with a session.
|
||||||
|
|
||||||
|
Used when a session is deleted to clean up associated messages.
|
||||||
|
This is a hard delete - messages are removed from the database entirely.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: Session ID whose messages should be deleted
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of messages deleted
|
||||||
|
|
||||||
|
Note:
|
||||||
|
This method does not validate ownership because it's called from
|
||||||
|
session_service after ownership has already been validated.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Query all messages with this session_id
|
||||||
|
messages = self.db.list_rows(
|
||||||
|
table_id='chat_messages',
|
||||||
|
queries=[Query.equal('session_id', session_id)],
|
||||||
|
limit=1000 # Handle up to 1000 messages per session
|
||||||
|
)
|
||||||
|
|
||||||
|
deleted_count = 0
|
||||||
|
for message in messages:
|
||||||
|
try:
|
||||||
|
self.db.delete_document(
|
||||||
|
collection_id='chat_messages',
|
||||||
|
document_id=message.id
|
||||||
|
)
|
||||||
|
deleted_count += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to delete individual message",
|
||||||
|
message_id=message.id,
|
||||||
|
session_id=session_id,
|
||||||
|
error=str(e))
|
||||||
|
# Continue deleting other messages
|
||||||
|
|
||||||
|
logger.info("Deleted messages for session",
|
||||||
|
session_id=session_id,
|
||||||
|
deleted_count=deleted_count)
|
||||||
|
|
||||||
|
return deleted_count
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to delete messages by session",
|
||||||
|
session_id=session_id,
|
||||||
|
error=str(e))
|
||||||
|
# Don't raise - session deletion should still proceed
|
||||||
|
return 0
|
||||||
|
|
||||||
# Helper Methods
|
# Helper Methods
|
||||||
|
|
||||||
def _update_recent_messages_preview(
|
def _update_recent_messages_preview(
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ from typing import Optional
|
|||||||
from app.services.redis_service import RedisService, RedisServiceError
|
from app.services.redis_service import RedisService, RedisServiceError
|
||||||
from app.ai.model_selector import UserTier
|
from app.ai.model_selector import UserTier
|
||||||
from app.utils.logging import get_logger
|
from app.utils.logging import get_logger
|
||||||
|
from app.config import get_config
|
||||||
|
|
||||||
|
|
||||||
# Initialize logger
|
# Initialize logger
|
||||||
@@ -75,25 +76,13 @@ class RateLimiterService:
|
|||||||
This service uses Redis to track daily AI usage per user and enforces
|
This service uses Redis to track daily AI usage per user and enforces
|
||||||
limits based on subscription tier. Counters reset daily at midnight UTC.
|
limits based on subscription tier. Counters reset daily at midnight UTC.
|
||||||
|
|
||||||
Tier Limits:
|
Tier limits are loaded from config (rate_limiting.tiers.{tier}.ai_calls_per_day).
|
||||||
- Free: 20 turns/day
|
A value of -1 means unlimited.
|
||||||
- Basic: 50 turns/day
|
|
||||||
- Premium: 100 turns/day
|
|
||||||
- Elite: 200 turns/day
|
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
redis: RedisService instance for counter storage
|
redis: RedisService instance for counter storage
|
||||||
tier_limits: Mapping of tier to daily turn limit
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Daily turn limits per tier
|
|
||||||
TIER_LIMITS = {
|
|
||||||
UserTier.FREE: 20,
|
|
||||||
UserTier.BASIC: 50,
|
|
||||||
UserTier.PREMIUM: 100,
|
|
||||||
UserTier.ELITE: 200,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Daily DM question limits per tier
|
# Daily DM question limits per tier
|
||||||
DM_QUESTION_LIMITS = {
|
DM_QUESTION_LIMITS = {
|
||||||
UserTier.FREE: 10,
|
UserTier.FREE: 10,
|
||||||
@@ -118,7 +107,7 @@ class RateLimiterService:
|
|||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"RateLimiterService initialized",
|
"RateLimiterService initialized",
|
||||||
tier_limits=self.TIER_LIMITS
|
dm_question_limits=self.DM_QUESTION_LIMITS
|
||||||
)
|
)
|
||||||
|
|
||||||
def _get_daily_key(self, user_id: str, day: Optional[date] = None) -> str:
|
def _get_daily_key(self, user_id: str, day: Optional[date] = None) -> str:
|
||||||
@@ -167,15 +156,27 @@ class RateLimiterService:
|
|||||||
|
|
||||||
def get_limit_for_tier(self, user_tier: UserTier) -> int:
|
def get_limit_for_tier(self, user_tier: UserTier) -> int:
|
||||||
"""
|
"""
|
||||||
Get the daily turn limit for a specific tier.
|
Get the daily turn limit for a specific tier from config.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
user_tier: The user's subscription tier
|
user_tier: The user's subscription tier
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Daily turn limit for the tier
|
Daily turn limit for the tier (-1 means unlimited)
|
||||||
"""
|
"""
|
||||||
return self.TIER_LIMITS.get(user_tier, self.TIER_LIMITS[UserTier.FREE])
|
config = get_config()
|
||||||
|
tier_name = user_tier.value.lower()
|
||||||
|
tier_config = config.rate_limiting.tiers.get(tier_name)
|
||||||
|
|
||||||
|
if tier_config:
|
||||||
|
return tier_config.ai_calls_per_day
|
||||||
|
|
||||||
|
# Fallback to default if tier not found in config
|
||||||
|
logger.warning(
|
||||||
|
"Tier not found in config, using default limit",
|
||||||
|
tier=tier_name
|
||||||
|
)
|
||||||
|
return 50 # Default fallback
|
||||||
|
|
||||||
def get_current_usage(self, user_id: str) -> int:
|
def get_current_usage(self, user_id: str) -> int:
|
||||||
"""
|
"""
|
||||||
@@ -227,9 +228,19 @@ class RateLimiterService:
|
|||||||
RateLimitExceeded: If the user has reached their daily limit
|
RateLimitExceeded: If the user has reached their daily limit
|
||||||
RedisServiceError: If Redis operation fails
|
RedisServiceError: If Redis operation fails
|
||||||
"""
|
"""
|
||||||
current_usage = self.get_current_usage(user_id)
|
|
||||||
limit = self.get_limit_for_tier(user_tier)
|
limit = self.get_limit_for_tier(user_tier)
|
||||||
|
|
||||||
|
# -1 means unlimited
|
||||||
|
if limit == -1:
|
||||||
|
logger.debug(
|
||||||
|
"Rate limit check passed (unlimited)",
|
||||||
|
user_id=user_id,
|
||||||
|
user_tier=user_tier.value
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
current_usage = self.get_current_usage(user_id)
|
||||||
|
|
||||||
if current_usage >= limit:
|
if current_usage >= limit:
|
||||||
reset_time = self._get_reset_time()
|
reset_time = self._get_reset_time()
|
||||||
|
|
||||||
@@ -308,11 +319,15 @@ class RateLimiterService:
|
|||||||
user_tier: The user's subscription tier
|
user_tier: The user's subscription tier
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Number of turns remaining (0 if limit reached)
|
Number of turns remaining (-1 if unlimited, 0 if limit reached)
|
||||||
"""
|
"""
|
||||||
current_usage = self.get_current_usage(user_id)
|
|
||||||
limit = self.get_limit_for_tier(user_tier)
|
limit = self.get_limit_for_tier(user_tier)
|
||||||
|
|
||||||
|
# -1 means unlimited
|
||||||
|
if limit == -1:
|
||||||
|
return -1
|
||||||
|
|
||||||
|
current_usage = self.get_current_usage(user_id)
|
||||||
remaining = max(0, limit - current_usage)
|
remaining = max(0, limit - current_usage)
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@@ -339,16 +354,25 @@ class RateLimiterService:
|
|||||||
- user_id: User identifier
|
- user_id: User identifier
|
||||||
- user_tier: Subscription tier
|
- user_tier: Subscription tier
|
||||||
- current_usage: Current daily usage
|
- current_usage: Current daily usage
|
||||||
- daily_limit: Daily limit for tier
|
- daily_limit: Daily limit for tier (-1 means unlimited)
|
||||||
- remaining: Remaining turns
|
- remaining: Remaining turns (-1 if unlimited)
|
||||||
- reset_time: ISO format UTC reset time
|
- reset_time: ISO format UTC reset time
|
||||||
- is_limited: Whether limit has been reached
|
- is_limited: Whether limit has been reached (always False if unlimited)
|
||||||
|
- is_unlimited: Whether user has unlimited turns
|
||||||
"""
|
"""
|
||||||
current_usage = self.get_current_usage(user_id)
|
current_usage = self.get_current_usage(user_id)
|
||||||
limit = self.get_limit_for_tier(user_tier)
|
limit = self.get_limit_for_tier(user_tier)
|
||||||
remaining = max(0, limit - current_usage)
|
|
||||||
reset_time = self._get_reset_time()
|
reset_time = self._get_reset_time()
|
||||||
|
|
||||||
|
# Handle unlimited tier (-1)
|
||||||
|
is_unlimited = (limit == -1)
|
||||||
|
if is_unlimited:
|
||||||
|
remaining = -1
|
||||||
|
is_limited = False
|
||||||
|
else:
|
||||||
|
remaining = max(0, limit - current_usage)
|
||||||
|
is_limited = current_usage >= limit
|
||||||
|
|
||||||
info = {
|
info = {
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
"user_tier": user_tier.value,
|
"user_tier": user_tier.value,
|
||||||
@@ -356,7 +380,8 @@ class RateLimiterService:
|
|||||||
"daily_limit": limit,
|
"daily_limit": limit,
|
||||||
"remaining": remaining,
|
"remaining": remaining,
|
||||||
"reset_time": reset_time.isoformat(),
|
"reset_time": reset_time.isoformat(),
|
||||||
"is_limited": current_usage >= limit
|
"is_limited": is_limited,
|
||||||
|
"is_unlimited": is_unlimited
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug("Retrieved usage info", **info)
|
logger.debug("Retrieved usage info", **info)
|
||||||
|
|||||||
@@ -19,15 +19,13 @@ from app.services.database_service import get_database_service
|
|||||||
from app.services.appwrite_service import AppwriteService
|
from app.services.appwrite_service import AppwriteService
|
||||||
from app.services.character_service import get_character_service, CharacterNotFound
|
from app.services.character_service import get_character_service, CharacterNotFound
|
||||||
from app.services.location_loader import get_location_loader
|
from app.services.location_loader import get_location_loader
|
||||||
|
from app.services.chat_message_service import get_chat_message_service
|
||||||
from app.utils.logging import get_logger
|
from app.utils.logging import get_logger
|
||||||
|
from app.config import get_config
|
||||||
|
|
||||||
logger = get_logger(__file__)
|
logger = get_logger(__file__)
|
||||||
|
|
||||||
|
|
||||||
# Session limits per user
|
|
||||||
MAX_ACTIVE_SESSIONS = 5
|
|
||||||
|
|
||||||
|
|
||||||
class SessionNotFound(Exception):
|
class SessionNotFound(Exception):
|
||||||
"""Raised when session ID doesn't exist or user doesn't own it."""
|
"""Raised when session ID doesn't exist or user doesn't own it."""
|
||||||
pass
|
pass
|
||||||
@@ -129,16 +127,22 @@ class SessionService:
|
|||||||
if not starting_location_type:
|
if not starting_location_type:
|
||||||
starting_location_type = LocationType.TOWN
|
starting_location_type = LocationType.TOWN
|
||||||
|
|
||||||
# Check session limit
|
# Check session limit based on user's subscription tier
|
||||||
|
user_tier = self.appwrite.get_user_tier(user_id)
|
||||||
|
config = get_config()
|
||||||
|
tier_config = config.rate_limiting.tiers.get(user_tier)
|
||||||
|
max_sessions = tier_config.max_sessions if tier_config else 1
|
||||||
|
|
||||||
active_count = self.count_user_sessions(user_id, active_only=True)
|
active_count = self.count_user_sessions(user_id, active_only=True)
|
||||||
if active_count >= MAX_ACTIVE_SESSIONS:
|
if active_count >= max_sessions:
|
||||||
logger.warning("Session limit exceeded",
|
logger.warning("Session limit exceeded",
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
|
tier=user_tier,
|
||||||
current=active_count,
|
current=active_count,
|
||||||
limit=MAX_ACTIVE_SESSIONS)
|
limit=max_sessions)
|
||||||
raise SessionLimitExceeded(
|
raise SessionLimitExceeded(
|
||||||
f"Maximum active sessions reached ({active_count}/{MAX_ACTIVE_SESSIONS}). "
|
f"Maximum active sessions reached for {user_tier} tier ({active_count}/{max_sessions}). "
|
||||||
f"Please end an existing session to start a new one."
|
f"Please delete an existing session to start a new one."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Generate unique session ID
|
# Generate unique session ID
|
||||||
@@ -409,6 +413,58 @@ class SessionService:
|
|||||||
error=str(e))
|
error=str(e))
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
def delete_session(self, session_id: str, user_id: str) -> bool:
|
||||||
|
"""
|
||||||
|
Permanently delete a session from the database.
|
||||||
|
|
||||||
|
Unlike end_session(), this method removes the session document entirely
|
||||||
|
from the database. Use this when the user wants to free up their session
|
||||||
|
slot and doesn't need to preserve the game history.
|
||||||
|
|
||||||
|
Also deletes all chat messages associated with this session.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: Session ID to delete
|
||||||
|
user_id: User ID for ownership validation
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if deleted successfully
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
SessionNotFound: If session doesn't exist or user doesn't own it
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
logger.info("Deleting session", session_id=session_id, user_id=user_id)
|
||||||
|
|
||||||
|
# Verify ownership first (raises SessionNotFound if invalid)
|
||||||
|
self.get_session(session_id, user_id)
|
||||||
|
|
||||||
|
# Delete associated chat messages first
|
||||||
|
chat_service = get_chat_message_service()
|
||||||
|
deleted_messages = chat_service.delete_messages_by_session(session_id)
|
||||||
|
logger.info("Deleted associated chat messages",
|
||||||
|
session_id=session_id,
|
||||||
|
message_count=deleted_messages)
|
||||||
|
|
||||||
|
# Delete session from database
|
||||||
|
self.db.delete_document(
|
||||||
|
collection_id=self.collection_id,
|
||||||
|
document_id=session_id
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Session deleted successfully",
|
||||||
|
session_id=session_id,
|
||||||
|
user_id=user_id)
|
||||||
|
return True
|
||||||
|
|
||||||
|
except SessionNotFound:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to delete session",
|
||||||
|
session_id=session_id,
|
||||||
|
error=str(e))
|
||||||
|
raise
|
||||||
|
|
||||||
def add_conversation_entry(
|
def add_conversation_entry(
|
||||||
self,
|
self,
|
||||||
session_id: str,
|
session_id: str,
|
||||||
|
|||||||
@@ -59,21 +59,25 @@ rate_limiting:
|
|||||||
ai_calls_per_day: 50
|
ai_calls_per_day: 50
|
||||||
custom_actions_per_day: 10
|
custom_actions_per_day: 10
|
||||||
custom_action_char_limit: 150
|
custom_action_char_limit: 150
|
||||||
|
max_sessions: 1
|
||||||
basic:
|
basic:
|
||||||
requests_per_minute: 60
|
requests_per_minute: 60
|
||||||
ai_calls_per_day: 200
|
ai_calls_per_day: 200
|
||||||
custom_actions_per_day: 50
|
custom_actions_per_day: 50
|
||||||
custom_action_char_limit: 300
|
custom_action_char_limit: 300
|
||||||
|
max_sessions: 2
|
||||||
premium:
|
premium:
|
||||||
requests_per_minute: 120
|
requests_per_minute: 120
|
||||||
ai_calls_per_day: 1000
|
ai_calls_per_day: 1000
|
||||||
custom_actions_per_day: -1 # Unlimited
|
custom_actions_per_day: -1 # Unlimited
|
||||||
custom_action_char_limit: 500
|
custom_action_char_limit: 500
|
||||||
|
max_sessions: 3
|
||||||
elite:
|
elite:
|
||||||
requests_per_minute: 300
|
requests_per_minute: 300
|
||||||
ai_calls_per_day: -1 # Unlimited
|
ai_calls_per_day: -1 # Unlimited
|
||||||
custom_actions_per_day: -1 # Unlimited
|
custom_actions_per_day: -1 # Unlimited
|
||||||
custom_action_char_limit: 500
|
custom_action_char_limit: 500
|
||||||
|
max_sessions: 5
|
||||||
|
|
||||||
session:
|
session:
|
||||||
timeout_minutes: 30
|
timeout_minutes: 30
|
||||||
|
|||||||
@@ -59,21 +59,25 @@ rate_limiting:
|
|||||||
ai_calls_per_day: 50
|
ai_calls_per_day: 50
|
||||||
custom_actions_per_day: 10
|
custom_actions_per_day: 10
|
||||||
custom_action_char_limit: 150
|
custom_action_char_limit: 150
|
||||||
|
max_sessions: 1
|
||||||
basic:
|
basic:
|
||||||
requests_per_minute: 60
|
requests_per_minute: 60
|
||||||
ai_calls_per_day: 200
|
ai_calls_per_day: 200
|
||||||
custom_actions_per_day: 50
|
custom_actions_per_day: 50
|
||||||
custom_action_char_limit: 300
|
custom_action_char_limit: 300
|
||||||
|
max_sessions: 2
|
||||||
premium:
|
premium:
|
||||||
requests_per_minute: 120
|
requests_per_minute: 120
|
||||||
ai_calls_per_day: 1000
|
ai_calls_per_day: 1000
|
||||||
custom_actions_per_day: -1 # Unlimited
|
custom_actions_per_day: -1 # Unlimited
|
||||||
custom_action_char_limit: 500
|
custom_action_char_limit: 500
|
||||||
|
max_sessions: 3
|
||||||
elite:
|
elite:
|
||||||
requests_per_minute: 300
|
requests_per_minute: 300
|
||||||
ai_calls_per_day: -1 # Unlimited
|
ai_calls_per_day: -1 # Unlimited
|
||||||
custom_actions_per_day: -1 # Unlimited
|
custom_actions_per_day: -1 # Unlimited
|
||||||
custom_action_char_limit: 500
|
custom_action_char_limit: 500
|
||||||
|
max_sessions: 5
|
||||||
|
|
||||||
session:
|
session:
|
||||||
timeout_minutes: 30
|
timeout_minutes: 30
|
||||||
|
|||||||
@@ -473,14 +473,18 @@ monthly = tracker.get_monthly_cost("user_123", 2025, 11)
|
|||||||
|
|
||||||
Tier-based daily limits enforced via `app/services/rate_limiter_service.py`.
|
Tier-based daily limits enforced via `app/services/rate_limiter_service.py`.
|
||||||
|
|
||||||
|
Limits are loaded from config (`rate_limiting.tiers.{tier}.ai_calls_per_day`).
|
||||||
|
|
||||||
### AI Calls (Turns)
|
### AI Calls (Turns)
|
||||||
|
|
||||||
| Tier | Daily Limit |
|
| Tier | Daily Limit |
|
||||||
|------|------------|
|
|------|------------|
|
||||||
| FREE | 20 turns |
|
| FREE | 50 turns |
|
||||||
| BASIC | 50 turns |
|
| BASIC | 200 turns |
|
||||||
| PREMIUM | 100 turns |
|
| PREMIUM | 1000 turns |
|
||||||
| ELITE | 200 turns |
|
| ELITE | Unlimited |
|
||||||
|
|
||||||
|
A value of `-1` in config means unlimited.
|
||||||
|
|
||||||
### Custom Actions
|
### Custom Actions
|
||||||
|
|
||||||
|
|||||||
@@ -875,7 +875,30 @@ Set-Cookie: coc_session=<session_token>; HttpOnly; Secure; SameSite=Lax; Max-Age
|
|||||||
**Error Responses:**
|
**Error Responses:**
|
||||||
- `400` - Validation error (missing character_id)
|
- `400` - Validation error (missing character_id)
|
||||||
- `404` - Character not found
|
- `404` - Character not found
|
||||||
- `409` - Session limit exceeded (max 5 active sessions)
|
- `409` - Session limit exceeded (tier-based limit)
|
||||||
|
|
||||||
|
**Session Limits by Tier:**
|
||||||
|
| Tier | Max Active Sessions |
|
||||||
|
|------|---------------------|
|
||||||
|
| FREE | 1 |
|
||||||
|
| BASIC | 2 |
|
||||||
|
| PREMIUM | 3 |
|
||||||
|
| ELITE | 5 |
|
||||||
|
|
||||||
|
**Error Response (409 Conflict - Session Limit Exceeded):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"app": "Code of Conquest",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"status": 409,
|
||||||
|
"timestamp": "2025-11-16T10:30:00Z",
|
||||||
|
"result": null,
|
||||||
|
"error": {
|
||||||
|
"code": "SESSION_LIMIT_EXCEEDED",
|
||||||
|
"message": "Maximum active sessions reached for free tier (1/1). Please delete an existing session to start a new one."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1096,24 +1119,39 @@ Set-Cookie: coc_session=<session_token>; HttpOnly; Secure; SameSite=Lax; Max-Age
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### End Session
|
### Delete Session
|
||||||
|
|
||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Endpoint** | `DELETE /api/v1/sessions/<session_id>` |
|
| **Endpoint** | `DELETE /api/v1/sessions/<session_id>` |
|
||||||
| **Description** | End and archive session |
|
| **Description** | Permanently delete a session and all associated chat messages |
|
||||||
| **Auth Required** | Yes |
|
| **Auth Required** | Yes |
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
- Permanently removes the session from the database (hard delete)
|
||||||
|
- Also deletes all chat messages associated with this session
|
||||||
|
- Frees up the session slot for the user's tier limit
|
||||||
|
- Cannot be undone
|
||||||
|
|
||||||
**Response (200 OK):**
|
**Response (200 OK):**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
"app": "Code of Conquest",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"status": 200,
|
||||||
|
"timestamp": "2025-11-16T10:30:00Z",
|
||||||
"result": {
|
"result": {
|
||||||
"message": "Session ended",
|
"message": "Session deleted successfully",
|
||||||
"final_state": {}
|
"session_id": "sess_789"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Error Responses:**
|
||||||
|
- `401` - Not authenticated
|
||||||
|
- `404` - Session not found or not owned by user
|
||||||
|
- `500` - Internal server error
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Export Session
|
### Export Session
|
||||||
@@ -2589,6 +2627,7 @@ GET /api/v1/characters/char_123/chats/search?q=quest&npc_id=npc_grom_ironbeard&c
|
|||||||
| `INVALID_INPUT` | 400 | Validation error |
|
| `INVALID_INPUT` | 400 | Validation error |
|
||||||
| `RATE_LIMIT_EXCEEDED` | 429 | Too many requests |
|
| `RATE_LIMIT_EXCEEDED` | 429 | Too many requests |
|
||||||
| `CHARACTER_LIMIT_EXCEEDED` | 400 | User has reached character limit for their tier |
|
| `CHARACTER_LIMIT_EXCEEDED` | 400 | User has reached character limit for their tier |
|
||||||
|
| `SESSION_LIMIT_EXCEEDED` | 409 | User has reached session limit for their tier |
|
||||||
| `CHARACTER_NOT_FOUND` | 404 | Character does not exist or not accessible |
|
| `CHARACTER_NOT_FOUND` | 404 | Character does not exist or not accessible |
|
||||||
| `SKILL_UNLOCK_ERROR` | 400 | Skill unlock failed (prerequisites, points, or tier) |
|
| `SKILL_UNLOCK_ERROR` | 400 | Skill unlock failed (prerequisites, points, or tier) |
|
||||||
| `INSUFFICIENT_FUNDS` | 400 | Not enough gold |
|
| `INSUFFICIENT_FUNDS` | 400 | Not enough gold |
|
||||||
@@ -2602,12 +2641,12 @@ GET /api/v1/characters/char_123/chats/search?q=quest&npc_id=npc_grom_ironbeard&c
|
|||||||
|
|
||||||
## Rate Limiting
|
## Rate Limiting
|
||||||
|
|
||||||
| Tier | Requests/Minute | AI Calls/Day |
|
| Tier | Requests/Minute | AI Calls/Day | Max Sessions |
|
||||||
|------|-----------------|--------------|
|
|------|-----------------|--------------|--------------|
|
||||||
| FREE | 30 | 50 |
|
| FREE | 30 | 50 | 1 |
|
||||||
| BASIC | 60 | 200 |
|
| BASIC | 60 | 200 | 2 |
|
||||||
| PREMIUM | 120 | 1000 |
|
| PREMIUM | 120 | 1000 | 3 |
|
||||||
| ELITE | 300 | Unlimited |
|
| ELITE | 300 | Unlimited | 5 |
|
||||||
|
|
||||||
**Rate Limit Headers:**
|
**Rate Limit Headers:**
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1447,6 +1447,166 @@ curl http://localhost:5000/api/v1/origins
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Session Endpoints
|
||||||
|
|
||||||
|
### 1. Create Session
|
||||||
|
|
||||||
|
**Endpoint:** `POST /api/v1/sessions`
|
||||||
|
|
||||||
|
**Description:** Create a new game session for a character. Subject to tier-based limits (Free: 1, Basic: 2, Premium: 3, Elite: 5).
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# httpie
|
||||||
|
http --session=user1 POST localhost:5000/api/v1/sessions \
|
||||||
|
character_id="char_123"
|
||||||
|
|
||||||
|
# curl
|
||||||
|
curl -X POST http://localhost:5000/api/v1/sessions \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-b cookies.txt \
|
||||||
|
-d '{
|
||||||
|
"character_id": "char_123"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Success Response (201 Created):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"app": "Code of Conquest",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"status": 201,
|
||||||
|
"timestamp": "2025-11-26T10:30:00Z",
|
||||||
|
"result": {
|
||||||
|
"session_id": "sess_789",
|
||||||
|
"character_id": "char_123",
|
||||||
|
"turn_number": 0,
|
||||||
|
"game_state": {
|
||||||
|
"current_location": "crossville_village",
|
||||||
|
"location_type": "town",
|
||||||
|
"active_quests": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error Response (409 Conflict - Session Limit Exceeded):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"app": "Code of Conquest",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"status": 409,
|
||||||
|
"timestamp": "2025-11-26T10:30:00Z",
|
||||||
|
"error": {
|
||||||
|
"code": "SESSION_LIMIT_EXCEEDED",
|
||||||
|
"message": "Maximum active sessions reached for free tier (1/1). Please delete an existing session to start a new one."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. List Sessions
|
||||||
|
|
||||||
|
**Endpoint:** `GET /api/v1/sessions`
|
||||||
|
|
||||||
|
**Description:** Get all active sessions for the authenticated user.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# httpie
|
||||||
|
http --session=user1 GET localhost:5000/api/v1/sessions
|
||||||
|
|
||||||
|
# curl
|
||||||
|
curl http://localhost:5000/api/v1/sessions -b cookies.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
**Success Response (200 OK):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"app": "Code of Conquest",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"status": 200,
|
||||||
|
"timestamp": "2025-11-26T10:30:00Z",
|
||||||
|
"result": [
|
||||||
|
{
|
||||||
|
"session_id": "sess_789",
|
||||||
|
"character_id": "char_123",
|
||||||
|
"turn_number": 5,
|
||||||
|
"status": "active",
|
||||||
|
"created_at": "2025-11-26T10:00:00Z",
|
||||||
|
"last_activity": "2025-11-26T10:25:00Z",
|
||||||
|
"game_state": {
|
||||||
|
"current_location": "crossville_village",
|
||||||
|
"location_type": "town"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Delete Session
|
||||||
|
|
||||||
|
**Endpoint:** `DELETE /api/v1/sessions/<session_id>`
|
||||||
|
|
||||||
|
**Description:** Permanently delete a session and all associated chat messages. This frees up a session slot for your tier limit.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# httpie
|
||||||
|
http --session=user1 DELETE localhost:5000/api/v1/sessions/sess_789
|
||||||
|
|
||||||
|
# curl
|
||||||
|
curl -X DELETE http://localhost:5000/api/v1/sessions/sess_789 \
|
||||||
|
-b cookies.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
**Success Response (200 OK):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"app": "Code of Conquest",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"status": 200,
|
||||||
|
"timestamp": "2025-11-26T10:30:00Z",
|
||||||
|
"result": {
|
||||||
|
"message": "Session deleted successfully",
|
||||||
|
"session_id": "sess_789"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error Response (404 Not Found):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"app": "Code of Conquest",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"status": 404,
|
||||||
|
"timestamp": "2025-11-26T10:30:00Z",
|
||||||
|
"error": {
|
||||||
|
"code": "NOT_FOUND",
|
||||||
|
"message": "Session not found"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** Deleting a session:
|
||||||
|
- Permanently removes the session from the database
|
||||||
|
- Deletes all chat messages associated with the session
|
||||||
|
- Cannot be undone
|
||||||
|
- Frees up a session slot for your tier limit
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Testing Workflows
|
## Testing Workflows
|
||||||
|
|
||||||
### Complete Registration Flow
|
### Complete Registration Flow
|
||||||
|
|||||||
@@ -126,13 +126,21 @@ session = service.create_solo_session(
|
|||||||
|
|
||||||
**Validations:**
|
**Validations:**
|
||||||
- User must own the character
|
- User must own the character
|
||||||
- User cannot exceed 5 active sessions
|
- User cannot exceed their tier's session limit
|
||||||
|
|
||||||
|
**Session Limits by Tier:**
|
||||||
|
| Tier | Max Sessions |
|
||||||
|
|------|--------------|
|
||||||
|
| FREE | 1 |
|
||||||
|
| BASIC | 2 |
|
||||||
|
| PREMIUM | 3 |
|
||||||
|
| ELITE | 5 |
|
||||||
|
|
||||||
**Returns:** `GameSession` instance
|
**Returns:** `GameSession` instance
|
||||||
|
|
||||||
**Raises:**
|
**Raises:**
|
||||||
- `CharacterNotFound` - Character doesn't exist or user doesn't own it
|
- `CharacterNotFound` - Character doesn't exist or user doesn't own it
|
||||||
- `SessionLimitExceeded` - User has 5+ active sessions
|
- `SessionLimitExceeded` - User has reached their tier's session limit
|
||||||
|
|
||||||
### Retrieving Sessions
|
### Retrieving Sessions
|
||||||
|
|
||||||
@@ -284,10 +292,18 @@ session = service.add_world_event(
|
|||||||
|
|
||||||
| Limit | Value | Notes |
|
| Limit | Value | Notes |
|
||||||
|-------|-------|-------|
|
|-------|-------|-------|
|
||||||
| Active sessions per user | 5 | End existing sessions to create new |
|
| Active sessions per user | Tier-based (1-5) | See tier limits below |
|
||||||
| Active quests per session | 2 | Complete or abandon to accept new |
|
| Active quests per session | 2 | Complete or abandon to accept new |
|
||||||
| Conversation history | Unlimited | Consider archiving for very long sessions |
|
| Conversation history | Unlimited | Consider archiving for very long sessions |
|
||||||
|
|
||||||
|
**Session Limits by Tier:**
|
||||||
|
| Tier | Max Sessions |
|
||||||
|
|------|--------------|
|
||||||
|
| FREE | 1 |
|
||||||
|
| BASIC | 2 |
|
||||||
|
| PREMIUM | 3 |
|
||||||
|
| ELITE | 5 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Database Schema
|
## Database Schema
|
||||||
@@ -400,7 +416,7 @@ except SessionNotFound:
|
|||||||
try:
|
try:
|
||||||
service.create_solo_session(user_id, char_id)
|
service.create_solo_session(user_id, char_id)
|
||||||
except SessionLimitExceeded:
|
except SessionLimitExceeded:
|
||||||
# User has 5+ active sessions
|
# User has reached their tier's session limit
|
||||||
pass
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -270,14 +270,33 @@ class MonthlyUsageSummary:
|
|||||||
|
|
||||||
### Daily Turn Limits
|
### Daily Turn Limits
|
||||||
|
|
||||||
|
Limits are loaded from config (`rate_limiting.tiers.{tier}.ai_calls_per_day`):
|
||||||
|
|
||||||
| Tier | Limit | Cost Level |
|
| Tier | Limit | Cost Level |
|
||||||
|------|-------|------------|
|
|------|-------|------------|
|
||||||
| FREE | 20 turns/day | Zero |
|
| FREE | 50 turns/day | Zero |
|
||||||
| BASIC | 50 turns/day | Low |
|
| BASIC | 200 turns/day | Low |
|
||||||
| PREMIUM | 100 turns/day | Medium |
|
| PREMIUM | 1000 turns/day | Medium |
|
||||||
| ELITE | 200 turns/day | High |
|
| ELITE | Unlimited | High |
|
||||||
|
|
||||||
Counters reset at midnight UTC.
|
Counters reset at midnight UTC. A value of `-1` in config means unlimited.
|
||||||
|
|
||||||
|
### Usage API Endpoint
|
||||||
|
|
||||||
|
Get current usage info via `GET /api/v1/usage`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"user_id": "user_123",
|
||||||
|
"user_tier": "free",
|
||||||
|
"current_usage": 15,
|
||||||
|
"daily_limit": 50,
|
||||||
|
"remaining": 35,
|
||||||
|
"reset_time": "2025-11-27T00:00:00+00:00",
|
||||||
|
"is_limited": false,
|
||||||
|
"is_unlimited": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### Custom Action Limits
|
### Custom Action Limits
|
||||||
|
|
||||||
@@ -342,14 +361,15 @@ info = limiter.get_usage_info("user_123", UserTier.PREMIUM)
|
|||||||
# "user_id": "user_123",
|
# "user_id": "user_123",
|
||||||
# "user_tier": "premium",
|
# "user_tier": "premium",
|
||||||
# "current_usage": 45,
|
# "current_usage": 45,
|
||||||
# "daily_limit": 100,
|
# "daily_limit": 1000,
|
||||||
# "remaining": 55,
|
# "remaining": 955,
|
||||||
# "reset_time": "2025-11-22T00:00:00+00:00",
|
# "reset_time": "2025-11-22T00:00:00+00:00",
|
||||||
# "is_limited": False
|
# "is_limited": False,
|
||||||
|
# "is_unlimited": False
|
||||||
# }
|
# }
|
||||||
|
|
||||||
# Get limit for tier
|
# Get limit for tier (-1 means unlimited)
|
||||||
limit = limiter.get_limit_for_tier(UserTier.ELITE) # 200
|
limit = limiter.get_limit_for_tier(UserTier.ELITE) # -1 (unlimited)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Admin Functions
|
### Admin Functions
|
||||||
@@ -539,9 +559,11 @@ When rate limited, prompt upgrades:
|
|||||||
|
|
||||||
```python
|
```python
|
||||||
if e.user_tier == UserTier.FREE:
|
if e.user_tier == UserTier.FREE:
|
||||||
message = "Upgrade to Basic for 50 turns/day!"
|
message = "Upgrade to Basic for 200 turns/day!"
|
||||||
elif e.user_tier == UserTier.BASIC:
|
elif e.user_tier == UserTier.BASIC:
|
||||||
message = "Upgrade to Premium for 100 turns/day!"
|
message = "Upgrade to Premium for 1000 turns/day!"
|
||||||
|
elif e.user_tier == UserTier.PREMIUM:
|
||||||
|
message = "Upgrade to Elite for unlimited turns!"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -585,8 +607,8 @@ def test_log_usage():
|
|||||||
def test_rate_limit_exceeded():
|
def test_rate_limit_exceeded():
|
||||||
limiter = RateLimiterService()
|
limiter = RateLimiterService()
|
||||||
|
|
||||||
# Exceed free tier limit
|
# Exceed free tier limit (50 from config)
|
||||||
for _ in range(20):
|
for _ in range(50):
|
||||||
limiter.increment_usage("test_user")
|
limiter.increment_usage("test_user")
|
||||||
|
|
||||||
with pytest.raises(RateLimitExceeded):
|
with pytest.raises(RateLimitExceeded):
|
||||||
|
|||||||
@@ -456,6 +456,14 @@ def list_characters():
|
|||||||
char_id = character.get('character_id')
|
char_id = character.get('character_id')
|
||||||
character['sessions'] = sessions_by_character.get(char_id, [])
|
character['sessions'] = sessions_by_character.get(char_id, [])
|
||||||
|
|
||||||
|
# Fetch usage info for daily turn limits
|
||||||
|
usage_info = {}
|
||||||
|
try:
|
||||||
|
usage_response = api_client.get("/api/v1/usage")
|
||||||
|
usage_info = usage_response.get('result', {})
|
||||||
|
except (APIError, APINotFoundError) as e:
|
||||||
|
logger.debug("Could not fetch usage info", error=str(e))
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Characters listed",
|
"Characters listed",
|
||||||
user_id=user.get('id'),
|
user_id=user.get('id'),
|
||||||
@@ -468,18 +476,46 @@ def list_characters():
|
|||||||
characters=characters,
|
characters=characters,
|
||||||
current_tier=current_tier,
|
current_tier=current_tier,
|
||||||
max_characters=max_characters,
|
max_characters=max_characters,
|
||||||
can_create=can_create
|
can_create=can_create,
|
||||||
|
# Usage display variables
|
||||||
|
remaining=usage_info.get('remaining', 0),
|
||||||
|
daily_limit=usage_info.get('daily_limit', 0),
|
||||||
|
is_limited=usage_info.get('is_limited', False),
|
||||||
|
is_unlimited=usage_info.get('is_unlimited', False),
|
||||||
|
reset_time=usage_info.get('reset_time', '')
|
||||||
)
|
)
|
||||||
|
|
||||||
except APITimeoutError:
|
except APITimeoutError:
|
||||||
logger.error("API timeout while listing characters", user_id=user.get('id'))
|
logger.error("API timeout while listing characters", user_id=user.get('id'))
|
||||||
flash('Request timed out. Please try again.', 'error')
|
flash('Request timed out. Please try again.', 'error')
|
||||||
return render_template('character/list.html', characters=[], can_create=False)
|
return render_template(
|
||||||
|
'character/list.html',
|
||||||
|
characters=[],
|
||||||
|
can_create=False,
|
||||||
|
current_tier='free',
|
||||||
|
max_characters=1,
|
||||||
|
remaining=0,
|
||||||
|
daily_limit=0,
|
||||||
|
is_limited=False,
|
||||||
|
is_unlimited=False,
|
||||||
|
reset_time=''
|
||||||
|
)
|
||||||
|
|
||||||
except APIError as e:
|
except APIError as e:
|
||||||
logger.error("Failed to list characters", user_id=user.get('id'), error=str(e))
|
logger.error("Failed to list characters", user_id=user.get('id'), error=str(e))
|
||||||
flash('An error occurred while loading your characters.', 'error')
|
flash('An error occurred while loading your characters.', 'error')
|
||||||
return render_template('character/list.html', characters=[], can_create=False)
|
return render_template(
|
||||||
|
'character/list.html',
|
||||||
|
characters=[],
|
||||||
|
can_create=False,
|
||||||
|
current_tier='free',
|
||||||
|
max_characters=1,
|
||||||
|
remaining=0,
|
||||||
|
daily_limit=0,
|
||||||
|
is_limited=False,
|
||||||
|
is_unlimited=False,
|
||||||
|
reset_time=''
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@character_bp.route('/<character_id>')
|
@character_bp.route('/<character_id>')
|
||||||
@@ -613,6 +649,58 @@ def create_session(character_id: str):
|
|||||||
return redirect(url_for('character_views.list_characters'))
|
return redirect(url_for('character_views.list_characters'))
|
||||||
|
|
||||||
|
|
||||||
|
@character_bp.route('/sessions/<session_id>/delete', methods=['DELETE'])
|
||||||
|
@require_auth_web
|
||||||
|
def delete_session(session_id: str):
|
||||||
|
"""
|
||||||
|
Delete a game session via HTMX.
|
||||||
|
|
||||||
|
This permanently removes the session from the database.
|
||||||
|
Returns a response that triggers page refresh to update UI properly
|
||||||
|
(handles "Continue Playing" button visibility).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: ID of the session to delete
|
||||||
|
"""
|
||||||
|
from flask import make_response
|
||||||
|
|
||||||
|
user = get_current_user()
|
||||||
|
api_client = get_api_client()
|
||||||
|
|
||||||
|
try:
|
||||||
|
api_client.delete(f"/api/v1/sessions/{session_id}")
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Session deleted via web UI",
|
||||||
|
user_id=user.get('id'),
|
||||||
|
session_id=session_id
|
||||||
|
)
|
||||||
|
|
||||||
|
# Return empty response with HX-Refresh to reload the page
|
||||||
|
# This ensures "Continue Playing" button visibility is correct
|
||||||
|
response = make_response('', 200)
|
||||||
|
response.headers['HX-Refresh'] = 'true'
|
||||||
|
return response
|
||||||
|
|
||||||
|
except APINotFoundError:
|
||||||
|
logger.warning(
|
||||||
|
"Session not found for deletion",
|
||||||
|
user_id=user.get('id'),
|
||||||
|
session_id=session_id
|
||||||
|
)
|
||||||
|
# Return error message that HTMX can display
|
||||||
|
return '<span class="error-message">Session not found</span>', 404
|
||||||
|
|
||||||
|
except APIError as e:
|
||||||
|
logger.error(
|
||||||
|
"Failed to delete session",
|
||||||
|
user_id=user.get('id'),
|
||||||
|
session_id=session_id,
|
||||||
|
error=str(e)
|
||||||
|
)
|
||||||
|
return '<span class="error-message">Failed to delete session</span>', 500
|
||||||
|
|
||||||
|
|
||||||
@character_bp.route('/<character_id>/skills')
|
@character_bp.route('/<character_id>/skills')
|
||||||
@require_auth_web
|
@require_auth_web
|
||||||
def view_skills(character_id: str):
|
def view_skills(character_id: str):
|
||||||
|
|||||||
@@ -201,6 +201,14 @@ def play_session(session_id: str):
|
|||||||
# Get user tier
|
# Get user tier
|
||||||
user_tier = _get_user_tier(client)
|
user_tier = _get_user_tier(client)
|
||||||
|
|
||||||
|
# Fetch usage info for daily turn limits
|
||||||
|
usage_info = {}
|
||||||
|
try:
|
||||||
|
usage_response = client.get("/api/v1/usage")
|
||||||
|
usage_info = usage_response.get('result', {})
|
||||||
|
except (APINotFoundError, APIError) as e:
|
||||||
|
logger.debug("could_not_fetch_usage_info", error=str(e))
|
||||||
|
|
||||||
# Build session object for template
|
# Build session object for template
|
||||||
session = {
|
session = {
|
||||||
'session_id': session_id,
|
'session_id': session_id,
|
||||||
@@ -220,7 +228,13 @@ def play_session(session_id: str):
|
|||||||
npcs=npcs,
|
npcs=npcs,
|
||||||
discovered_locations=discovered_locations,
|
discovered_locations=discovered_locations,
|
||||||
actions=DEFAULT_ACTIONS,
|
actions=DEFAULT_ACTIONS,
|
||||||
user_tier=user_tier
|
user_tier=user_tier,
|
||||||
|
# Usage display variables
|
||||||
|
remaining=usage_info.get('remaining', 0),
|
||||||
|
daily_limit=usage_info.get('daily_limit', 0),
|
||||||
|
is_limited=usage_info.get('is_limited', False),
|
||||||
|
is_unlimited=usage_info.get('is_unlimited', False),
|
||||||
|
reset_time=usage_info.get('reset_time', '')
|
||||||
)
|
)
|
||||||
|
|
||||||
except APINotFoundError:
|
except APINotFoundError:
|
||||||
@@ -259,13 +273,27 @@ def character_panel(session_id: str):
|
|||||||
character = _build_character_from_api(char_data)
|
character = _build_character_from_api(char_data)
|
||||||
user_tier = _get_user_tier(client)
|
user_tier = _get_user_tier(client)
|
||||||
|
|
||||||
|
# Fetch usage info for daily turn limits
|
||||||
|
usage_info = {}
|
||||||
|
try:
|
||||||
|
usage_response = client.get("/api/v1/usage")
|
||||||
|
usage_info = usage_response.get('result', {})
|
||||||
|
except (APINotFoundError, APIError) as e:
|
||||||
|
logger.debug("could_not_fetch_usage_info", error=str(e))
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
'game/partials/character_panel.html',
|
'game/partials/character_panel.html',
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
character=character,
|
character=character,
|
||||||
location=location,
|
location=location,
|
||||||
actions=DEFAULT_ACTIONS,
|
actions=DEFAULT_ACTIONS,
|
||||||
user_tier=user_tier
|
user_tier=user_tier,
|
||||||
|
# Usage display variables
|
||||||
|
remaining=usage_info.get('remaining', 0),
|
||||||
|
daily_limit=usage_info.get('daily_limit', 0),
|
||||||
|
is_limited=usage_info.get('is_limited', False),
|
||||||
|
is_unlimited=usage_info.get('is_unlimited', False),
|
||||||
|
reset_time=usage_info.get('reset_time', '')
|
||||||
)
|
)
|
||||||
except APIError as e:
|
except APIError as e:
|
||||||
logger.error("failed_to_refresh_character_panel", session_id=session_id, error=str(e))
|
logger.error("failed_to_refresh_character_panel", session_id=session_id, error=str(e))
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
<p class="page-subtitle">
|
<p class="page-subtitle">
|
||||||
{{ characters|length }} of {{ max_characters }} characters
|
{{ characters|length }} of {{ max_characters }} characters
|
||||||
<span class="tier-badge">{{ current_tier|upper }}</span>
|
<span class="tier-badge">{{ current_tier|upper }}</span>
|
||||||
|
{% include 'components/usage_display.html' %}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
@@ -63,10 +64,21 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="sessions-list">
|
<div class="sessions-list">
|
||||||
{% for sess in character.sessions[:3] %}
|
{% for sess in character.sessions[:3] %}
|
||||||
<a href="{{ url_for('game.play_session', session_id=sess.session_id) }}" class="session-link">
|
<div class="session-item" id="session-{{ sess.session_id }}">
|
||||||
<span class="session-turn">Turn {{ sess.turn_number or 0 }}</span>
|
<a href="{{ url_for('game.play_session', session_id=sess.session_id) }}" class="session-link">
|
||||||
<span class="session-status {{ sess.status or 'active' }}">{{ sess.status or 'active' }}</span>
|
<span class="session-turn">Turn {{ sess.turn_number or 0 }}</span>
|
||||||
</a>
|
<span class="session-status {{ sess.status or 'active' }}">{{ sess.status or 'active' }}</span>
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
hx-delete="{{ url_for('character_views.delete_session', session_id=sess.session_id) }}"
|
||||||
|
hx-confirm="Delete this session? This action cannot be undone."
|
||||||
|
hx-target="#session-{{ sess.session_id }}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
class="btn-delete-session"
|
||||||
|
title="Delete Session">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% if character.sessions|length > 3 %}
|
{% if character.sessions|length > 3 %}
|
||||||
<span class="sessions-more">+{{ character.sessions|length - 3 }} more</span>
|
<span class="sessions-more">+{{ character.sessions|length - 3 }} more</span>
|
||||||
@@ -328,6 +340,35 @@
|
|||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.session-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-delete-session {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--accent-red);
|
||||||
|
border-radius: 3px;
|
||||||
|
color: var(--accent-red);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-delete-session:hover {
|
||||||
|
background: var(--accent-red);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
.session-turn {
|
.session-turn {
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
|||||||
83
public_web/templates/components/usage_display.html
Normal file
83
public_web/templates/components/usage_display.html
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
{# Daily Usage Indicator Component
|
||||||
|
|
||||||
|
Required template variables:
|
||||||
|
- remaining: Number of turns remaining (-1 if unlimited)
|
||||||
|
- daily_limit: Daily turn limit (-1 if unlimited)
|
||||||
|
- is_limited: Boolean, true if limit reached
|
||||||
|
- is_unlimited: Boolean, true if user has unlimited turns
|
||||||
|
- reset_time: ISO timestamp of when limit resets (optional)
|
||||||
|
#}
|
||||||
|
|
||||||
|
<div class="daily-usage-indicator">
|
||||||
|
{% if is_unlimited %}
|
||||||
|
<span class="usage-badge unlimited">
|
||||||
|
<span class="usage-icon">⚡</span>
|
||||||
|
<span class="usage-text">Unlimited Turns</span>
|
||||||
|
</span>
|
||||||
|
{% elif is_limited %}
|
||||||
|
<span class="usage-badge limited">
|
||||||
|
<span class="usage-icon">⚠</span>
|
||||||
|
<span class="usage-text">Limit Reached</span>
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="usage-badge normal">
|
||||||
|
<span class="usage-icon">⏱</span>
|
||||||
|
<span class="usage-count">{{ remaining }}/{{ daily_limit }}</span>
|
||||||
|
<span class="usage-label">turns today</span>
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.daily-usage-indicator {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.35rem 0.75rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-badge.normal {
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-badge.limited {
|
||||||
|
background: rgba(239, 68, 68, 0.15);
|
||||||
|
border: 1px solid var(--accent-red);
|
||||||
|
color: var(--accent-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-badge.unlimited {
|
||||||
|
background: rgba(212, 175, 55, 0.15);
|
||||||
|
border: 1px solid var(--accent-gold);
|
||||||
|
color: var(--accent-gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-icon {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-count {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-label {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-text {
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -10,6 +10,9 @@ Displays character stats, resource bars, and action buttons
|
|||||||
<span class="character-class">{{ character.class_name }}</span>
|
<span class="character-class">{{ character.class_name }}</span>
|
||||||
<span class="character-level">Level {{ character.level }}</span>
|
<span class="character-level">Level {{ character.level }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="character-usage">
|
||||||
|
{% include 'components/usage_display.html' %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Resource Bars #}
|
{# Resource Bars #}
|
||||||
|
|||||||
Reference in New Issue
Block a user