Merge pull request 'feat/session-management' (#5) from feat/session-management into dev

Reviewed-on: #5
This commit was merged in pull request #5.
This commit is contained in:
2025-11-26 16:08:42 +00:00
17 changed files with 813 additions and 78 deletions

View File

@@ -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"
)

View File

@@ -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

View File

@@ -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(

View File

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

View File

@@ -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,

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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:**
``` ```

View File

@@ -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

View File

@@ -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:

View File

@@ -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):

View File

@@ -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):

View File

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

View File

@@ -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">
&times;
</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);

View 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">&#9889;</span>
<span class="usage-text">Unlimited Turns</span>
</span>
{% elif is_limited %}
<span class="usage-badge limited">
<span class="usage-icon">&#9888;</span>
<span class="usage-text">Limit Reached</span>
</span>
{% else %}
<span class="usage-badge normal">
<span class="usage-icon">&#9201;</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>

View File

@@ -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 #}