feat(api,web): tier-based session limits and daily turn usage display
Backend Changes:
- Add tier-based max_sessions config (free: 1, basic: 2, premium: 3, elite: 5)
- Add DELETE /api/v1/sessions/{id} endpoint for hard session deletion
- Cascade delete chat messages when session is deleted
- Add GET /api/v1/usage endpoint for daily turn limit info
- Replace hardcoded TIER_LIMITS with config-based ai_calls_per_day
- Handle unlimited (-1) tier in rate limiter service
Frontend Changes:
- Add inline session delete buttons with HTMX on character list
- Add usage_display.html component showing remaining daily turns
- Display usage indicator on character list and game play pages
- Page refresh after session deletion to update UI state
Documentation:
- Update API_REFERENCE.md with new endpoints and tier limits
- Update API_TESTING.md with session endpoint examples
- Update SESSION_MANAGEMENT.md with tier-based limits
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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/<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
|
||||
custom_actions_per_day: int # -1 for unlimited
|
||||
custom_action_char_limit: int
|
||||
max_sessions: int = 1 # Maximum active game sessions allowed
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -875,7 +875,30 @@ Set-Cookie: coc_session=<session_token>; 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=<session_token>; HttpOnly; Secure; SameSite=Lax; Max-Age
|
||||
|
||||
---
|
||||
|
||||
### End Session
|
||||
### Delete Session
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **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 |
|
||||
|
||||
**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:**
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
### Complete Registration Flow
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user