diff --git a/api/app/api/sessions.py b/api/app/api/sessions.py index 5f5d599..cdd662a 100644 --- a/api/app/api/sessions.py +++ b/api/app/api/sessions.py @@ -235,7 +235,7 @@ def create_session(): return error_response( status=409, code="SESSION_LIMIT_EXCEEDED", - message="Maximum active sessions limit reached (5). Please end an existing session first." + message=str(e) ) except Exception as e: @@ -602,3 +602,111 @@ def get_history(session_id: str): code="HISTORY_ERROR", message="Failed to get conversation history" ) + + +@sessions_bp.route('/api/v1/sessions/', 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" + ) diff --git a/api/app/config.py b/api/app/config.py index 8d00176..f8e0bc0 100644 --- a/api/app/config.py +++ b/api/app/config.py @@ -76,6 +76,7 @@ class RateLimitTier: ai_calls_per_day: int custom_actions_per_day: int # -1 for unlimited custom_action_char_limit: int + max_sessions: int = 1 # Maximum active game sessions allowed @dataclass diff --git a/api/app/services/chat_message_service.py b/api/app/services/chat_message_service.py index 0bdcff0..122f5f9 100644 --- a/api/app/services/chat_message_service.py +++ b/api/app/services/chat_message_service.py @@ -458,6 +458,59 @@ class ChatMessageService: error=str(e)) 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 def _update_recent_messages_preview( diff --git a/api/app/services/rate_limiter_service.py b/api/app/services/rate_limiter_service.py index 3651b04..bd62d5c 100644 --- a/api/app/services/rate_limiter_service.py +++ b/api/app/services/rate_limiter_service.py @@ -29,6 +29,7 @@ from typing import Optional from app.services.redis_service import RedisService, RedisServiceError from app.ai.model_selector import UserTier from app.utils.logging import get_logger +from app.config import get_config # Initialize logger @@ -75,25 +76,13 @@ class RateLimiterService: This service uses Redis to track daily AI usage per user and enforces limits based on subscription tier. Counters reset daily at midnight UTC. - Tier Limits: - - Free: 20 turns/day - - Basic: 50 turns/day - - Premium: 100 turns/day - - Elite: 200 turns/day + Tier limits are loaded from config (rate_limiting.tiers.{tier}.ai_calls_per_day). + A value of -1 means unlimited. Attributes: 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 DM_QUESTION_LIMITS = { UserTier.FREE: 10, @@ -167,15 +156,27 @@ class RateLimiterService: 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: user_tier: The user's subscription tier 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: """ @@ -227,9 +228,19 @@ class RateLimiterService: RateLimitExceeded: If the user has reached their daily limit RedisServiceError: If Redis operation fails """ - current_usage = self.get_current_usage(user_id) 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: reset_time = self._get_reset_time() @@ -308,11 +319,15 @@ class RateLimiterService: user_tier: The user's subscription tier 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) + # -1 means unlimited + if limit == -1: + return -1 + + current_usage = self.get_current_usage(user_id) remaining = max(0, limit - current_usage) logger.debug( @@ -339,16 +354,25 @@ class RateLimiterService: - user_id: User identifier - user_tier: Subscription tier - current_usage: Current daily usage - - daily_limit: Daily limit for tier - - remaining: Remaining turns + - daily_limit: Daily limit for tier (-1 means unlimited) + - remaining: Remaining turns (-1 if unlimited) - 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) limit = self.get_limit_for_tier(user_tier) - remaining = max(0, limit - current_usage) 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 = { "user_id": user_id, "user_tier": user_tier.value, @@ -356,7 +380,8 @@ class RateLimiterService: "daily_limit": limit, "remaining": remaining, "reset_time": reset_time.isoformat(), - "is_limited": current_usage >= limit + "is_limited": is_limited, + "is_unlimited": is_unlimited } logger.debug("Retrieved usage info", **info) diff --git a/api/app/services/session_service.py b/api/app/services/session_service.py index b782432..cb2236d 100644 --- a/api/app/services/session_service.py +++ b/api/app/services/session_service.py @@ -19,15 +19,13 @@ from app.services.database_service import get_database_service from app.services.appwrite_service import AppwriteService from app.services.character_service import get_character_service, CharacterNotFound 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.config import get_config logger = get_logger(__file__) -# Session limits per user -MAX_ACTIVE_SESSIONS = 5 - - class SessionNotFound(Exception): """Raised when session ID doesn't exist or user doesn't own it.""" pass @@ -129,16 +127,22 @@ class SessionService: if not starting_location_type: 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) - if active_count >= MAX_ACTIVE_SESSIONS: + if active_count >= max_sessions: logger.warning("Session limit exceeded", user_id=user_id, + tier=user_tier, current=active_count, - limit=MAX_ACTIVE_SESSIONS) + limit=max_sessions) raise SessionLimitExceeded( - f"Maximum active sessions reached ({active_count}/{MAX_ACTIVE_SESSIONS}). " - f"Please end an existing session to start a new one." + f"Maximum active sessions reached for {user_tier} tier ({active_count}/{max_sessions}). " + f"Please delete an existing session to start a new one." ) # Generate unique session ID @@ -409,6 +413,58 @@ class SessionService: error=str(e)) 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( self, session_id: str, diff --git a/api/config/development.yaml b/api/config/development.yaml index 2f2c503..c51f966 100644 --- a/api/config/development.yaml +++ b/api/config/development.yaml @@ -59,21 +59,25 @@ rate_limiting: ai_calls_per_day: 50 custom_actions_per_day: 10 custom_action_char_limit: 150 + max_sessions: 1 basic: requests_per_minute: 60 ai_calls_per_day: 200 custom_actions_per_day: 50 custom_action_char_limit: 300 + max_sessions: 2 premium: requests_per_minute: 120 ai_calls_per_day: 1000 custom_actions_per_day: -1 # Unlimited custom_action_char_limit: 500 + max_sessions: 3 elite: requests_per_minute: 300 ai_calls_per_day: -1 # Unlimited custom_actions_per_day: -1 # Unlimited custom_action_char_limit: 500 + max_sessions: 5 session: timeout_minutes: 30 diff --git a/api/config/production.yaml b/api/config/production.yaml index 483fcf7..d969394 100644 --- a/api/config/production.yaml +++ b/api/config/production.yaml @@ -59,21 +59,25 @@ rate_limiting: ai_calls_per_day: 50 custom_actions_per_day: 10 custom_action_char_limit: 150 + max_sessions: 1 basic: requests_per_minute: 60 ai_calls_per_day: 200 custom_actions_per_day: 50 custom_action_char_limit: 300 + max_sessions: 2 premium: requests_per_minute: 120 ai_calls_per_day: 1000 custom_actions_per_day: -1 # Unlimited custom_action_char_limit: 500 + max_sessions: 3 elite: requests_per_minute: 300 ai_calls_per_day: -1 # Unlimited custom_actions_per_day: -1 # Unlimited custom_action_char_limit: 500 + max_sessions: 5 session: timeout_minutes: 30 diff --git a/api/docs/API_REFERENCE.md b/api/docs/API_REFERENCE.md index bde10d5..3db9efe 100644 --- a/api/docs/API_REFERENCE.md +++ b/api/docs/API_REFERENCE.md @@ -875,7 +875,30 @@ Set-Cookie: coc_session=; HttpOnly; Secure; SameSite=Lax; Max-Age **Error Responses:** - `400` - Validation error (missing character_id) - `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=; HttpOnly; Secure; SameSite=Lax; Max-Age --- -### End Session +### Delete Session | | | |---|---| | **Endpoint** | `DELETE /api/v1/sessions/` | -| **Description** | End and archive session | +| **Description** | Permanently delete a session and all associated chat messages | | **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):** ```json { + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-16T10:30:00Z", "result": { - "message": "Session ended", - "final_state": {} + "message": "Session deleted successfully", + "session_id": "sess_789" } } ``` +**Error Responses:** +- `401` - Not authenticated +- `404` - Session not found or not owned by user +- `500` - Internal server error + --- ### 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 | | `RATE_LIMIT_EXCEEDED` | 429 | Too many requests | | `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 | | `SKILL_UNLOCK_ERROR` | 400 | Skill unlock failed (prerequisites, points, or tier) | | `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 -| Tier | Requests/Minute | AI Calls/Day | -|------|-----------------|--------------| -| FREE | 30 | 50 | -| BASIC | 60 | 200 | -| PREMIUM | 120 | 1000 | -| ELITE | 300 | Unlimited | +| Tier | Requests/Minute | AI Calls/Day | Max Sessions | +|------|-----------------|--------------|--------------| +| FREE | 30 | 50 | 1 | +| BASIC | 60 | 200 | 2 | +| PREMIUM | 120 | 1000 | 3 | +| ELITE | 300 | Unlimited | 5 | **Rate Limit Headers:** ``` diff --git a/api/docs/API_TESTING.md b/api/docs/API_TESTING.md index 185e185..e5b3b65 100644 --- a/api/docs/API_TESTING.md +++ b/api/docs/API_TESTING.md @@ -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/` + +**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 ### Complete Registration Flow diff --git a/api/docs/SESSION_MANAGEMENT.md b/api/docs/SESSION_MANAGEMENT.md index f310981..8ba271d 100644 --- a/api/docs/SESSION_MANAGEMENT.md +++ b/api/docs/SESSION_MANAGEMENT.md @@ -126,13 +126,21 @@ session = service.create_solo_session( **Validations:** - 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 **Raises:** - `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 @@ -284,10 +292,18 @@ session = service.add_world_event( | 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 | | 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 @@ -400,7 +416,7 @@ except SessionNotFound: try: service.create_solo_session(user_id, char_id) except SessionLimitExceeded: - # User has 5+ active sessions + # User has reached their tier's session limit pass try: diff --git a/public_web/app/views/character_views.py b/public_web/app/views/character_views.py index c4c0bbf..913596e 100644 --- a/public_web/app/views/character_views.py +++ b/public_web/app/views/character_views.py @@ -456,6 +456,14 @@ def list_characters(): char_id = character.get('character_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( "Characters listed", user_id=user.get('id'), @@ -468,18 +476,46 @@ def list_characters(): characters=characters, current_tier=current_tier, 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: logger.error("API timeout while listing characters", user_id=user.get('id')) flash('Request timed out. Please try again.', 'error') - return render_template('character/list.html', characters=[], can_create=False) + 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: logger.error("Failed to list characters", user_id=user.get('id'), error=str(e)) flash('An error occurred while loading your characters.', 'error') - return render_template('character/list.html', characters=[], can_create=False) + 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('/') @@ -613,6 +649,58 @@ def create_session(character_id: str): return redirect(url_for('character_views.list_characters')) +@character_bp.route('/sessions//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 'Session not found', 404 + + except APIError as e: + logger.error( + "Failed to delete session", + user_id=user.get('id'), + session_id=session_id, + error=str(e) + ) + return 'Failed to delete session', 500 + + @character_bp.route('//skills') @require_auth_web def view_skills(character_id: str): diff --git a/public_web/app/views/game_views.py b/public_web/app/views/game_views.py index d2bb2d4..8801aea 100644 --- a/public_web/app/views/game_views.py +++ b/public_web/app/views/game_views.py @@ -201,6 +201,14 @@ def play_session(session_id: str): # Get user tier 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 session = { 'session_id': session_id, @@ -220,7 +228,13 @@ def play_session(session_id: str): npcs=npcs, discovered_locations=discovered_locations, 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: @@ -259,13 +273,27 @@ def character_panel(session_id: str): character = _build_character_from_api(char_data) 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( 'game/partials/character_panel.html', session_id=session_id, character=character, location=location, 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: logger.error("failed_to_refresh_character_panel", session_id=session_id, error=str(e)) diff --git a/public_web/templates/character/list.html b/public_web/templates/character/list.html index a0ef10a..38cd15a 100644 --- a/public_web/templates/character/list.html +++ b/public_web/templates/character/list.html @@ -10,6 +10,7 @@

{{ characters|length }} of {{ max_characters }} characters {{ current_tier|upper }} + {% include 'components/usage_display.html' %}

@@ -63,10 +64,21 @@
{% for sess in character.sessions[:3] %} - - Turn {{ sess.turn_number or 0 }} - {{ sess.status or 'active' }} - + {% endfor %} {% if character.sessions|length > 3 %} +{{ character.sessions|length - 3 }} more @@ -328,6 +340,35 @@ 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 { font-size: var(--text-xs); color: var(--text-primary); diff --git a/public_web/templates/components/usage_display.html b/public_web/templates/components/usage_display.html new file mode 100644 index 0000000..219b331 --- /dev/null +++ b/public_web/templates/components/usage_display.html @@ -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) +#} + +
+ {% if is_unlimited %} + + + Unlimited Turns + + {% elif is_limited %} + + + Limit Reached + + {% else %} + + + {{ remaining }}/{{ daily_limit }} + turns today + + {% endif %} +
+ + diff --git a/public_web/templates/game/partials/character_panel.html b/public_web/templates/game/partials/character_panel.html index 4d8e554..15b9127 100644 --- a/public_web/templates/game/partials/character_panel.html +++ b/public_web/templates/game/partials/character_panel.html @@ -10,6 +10,9 @@ Displays character stats, resource bars, and action buttons {{ character.class_name }} Level {{ character.level }}
+
+ {% include 'components/usage_display.html' %} +
{# Resource Bars #}