Compare commits

..

4 Commits

Author SHA1 Message Date
4d26c43d1d Merge pull request 'feat/session-management' (#5) from feat/session-management into dev
Reviewed-on: #5
2025-11-26 16:08:42 +00:00
51f6041ee4 fix(api): remove reference to non-existent TIER_LIMITS attribute
The RateLimiterService.__init__ was logging self.TIER_LIMITS which doesn't
exist after refactoring to config-based tier limits. Changed to log the
existing DM_QUESTION_LIMITS attribute instead.
2025-11-26 10:07:35 -06:00
19808dd44c docs: update rate limit values to match config-based system
- Update USAGE_TRACKING.md with new tier limits (50, 200, 1000, unlimited)
- Update AI_INTEGRATION.md with new tier limits
- Add note that limits are loaded from config (ai_calls_per_day)
- Document GET /api/v1/usage endpoint
- Update examples to show is_unlimited field
- Fix test examples with correct limit values

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-26 10:02:30 -06:00
61a42d3a77 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>
2025-11-26 10:00:45 -06:00
17 changed files with 813 additions and 78 deletions

View File

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

View File

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

View File

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

View File

@@ -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,
@@ -118,7 +107,7 @@ class RateLimiterService:
logger.info(
"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:
@@ -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)

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

View File

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

View File

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

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`.
Limits are loaded from config (`rate_limiting.tiers.{tier}.ai_calls_per_day`).
### AI Calls (Turns)
| Tier | Daily Limit |
|------|------------|
| FREE | 20 turns |
| BASIC | 50 turns |
| PREMIUM | 100 turns |
| ELITE | 200 turns |
| FREE | 50 turns |
| BASIC | 200 turns |
| PREMIUM | 1000 turns |
| ELITE | Unlimited |
A value of `-1` in config means unlimited.
### Custom Actions

View File

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

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
### Complete Registration Flow

View File

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

View File

@@ -270,14 +270,33 @@ class MonthlyUsageSummary:
### Daily Turn Limits
Limits are loaded from config (`rate_limiting.tiers.{tier}.ai_calls_per_day`):
| Tier | Limit | Cost Level |
|------|-------|------------|
| FREE | 20 turns/day | Zero |
| BASIC | 50 turns/day | Low |
| PREMIUM | 100 turns/day | Medium |
| ELITE | 200 turns/day | High |
| FREE | 50 turns/day | Zero |
| BASIC | 200 turns/day | Low |
| PREMIUM | 1000 turns/day | Medium |
| 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
@@ -342,14 +361,15 @@ info = limiter.get_usage_info("user_123", UserTier.PREMIUM)
# "user_id": "user_123",
# "user_tier": "premium",
# "current_usage": 45,
# "daily_limit": 100,
# "remaining": 55,
# "daily_limit": 1000,
# "remaining": 955,
# "reset_time": "2025-11-22T00:00:00+00:00",
# "is_limited": False
# "is_limited": False,
# "is_unlimited": False
# }
# Get limit for tier
limit = limiter.get_limit_for_tier(UserTier.ELITE) # 200
# Get limit for tier (-1 means unlimited)
limit = limiter.get_limit_for_tier(UserTier.ELITE) # -1 (unlimited)
```
### Admin Functions
@@ -539,9 +559,11 @@ When rate limited, prompt upgrades:
```python
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:
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():
limiter = RateLimiterService()
# Exceed free tier limit
for _ in range(20):
# Exceed free tier limit (50 from config)
for _ in range(50):
limiter.increment_usage("test_user")
with pytest.raises(RateLimitExceeded):

View File

@@ -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('/<character_id>')
@@ -613,6 +649,58 @@ def create_session(character_id: str):
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')
@require_auth_web
def view_skills(character_id: str):

View File

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

View File

@@ -10,6 +10,7 @@
<p class="page-subtitle">
{{ characters|length }} of {{ max_characters }} characters
<span class="tier-badge">{{ current_tier|upper }}</span>
{% include 'components/usage_display.html' %}
</p>
</div>
<div class="header-right">
@@ -63,10 +64,21 @@
</div>
<div class="sessions-list">
{% for sess in character.sessions[:3] %}
<a href="{{ url_for('game.play_session', session_id=sess.session_id) }}" class="session-link">
<span class="session-turn">Turn {{ sess.turn_number or 0 }}</span>
<span class="session-status {{ sess.status or 'active' }}">{{ sess.status or 'active' }}</span>
</a>
<div class="session-item" id="session-{{ sess.session_id }}">
<a href="{{ url_for('game.play_session', session_id=sess.session_id) }}" class="session-link">
<span class="session-turn">Turn {{ sess.turn_number or 0 }}</span>
<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 %}
{% if character.sessions|length > 3 %}
<span class="sessions-more">+{{ character.sessions|length - 3 }} more</span>
@@ -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);

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-level">Level {{ character.level }}</span>
</div>
<div class="character-usage">
{% include 'components/usage_display.html' %}
</div>
</div>
{# Resource Bars #}