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:
@@ -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,
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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">
|
||||
×
|
||||
</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);
|
||||
|
||||
83
public_web/templates/components/usage_display.html
Normal file
83
public_web/templates/components/usage_display.html
Normal file
@@ -0,0 +1,83 @@
|
||||
{# Daily Usage Indicator Component
|
||||
|
||||
Required template variables:
|
||||
- remaining: Number of turns remaining (-1 if unlimited)
|
||||
- daily_limit: Daily turn limit (-1 if unlimited)
|
||||
- is_limited: Boolean, true if limit reached
|
||||
- is_unlimited: Boolean, true if user has unlimited turns
|
||||
- reset_time: ISO timestamp of when limit resets (optional)
|
||||
#}
|
||||
|
||||
<div class="daily-usage-indicator">
|
||||
{% if is_unlimited %}
|
||||
<span class="usage-badge unlimited">
|
||||
<span class="usage-icon">⚡</span>
|
||||
<span class="usage-text">Unlimited Turns</span>
|
||||
</span>
|
||||
{% elif is_limited %}
|
||||
<span class="usage-badge limited">
|
||||
<span class="usage-icon">⚠</span>
|
||||
<span class="usage-text">Limit Reached</span>
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="usage-badge normal">
|
||||
<span class="usage-icon">⏱</span>
|
||||
<span class="usage-count">{{ remaining }}/{{ daily_limit }}</span>
|
||||
<span class="usage-label">turns today</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.daily-usage-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.usage-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.35rem 0.75rem;
|
||||
border-radius: 12px;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.usage-badge.normal {
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-primary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.usage-badge.limited {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
border: 1px solid var(--accent-red);
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
||||
.usage-badge.unlimited {
|
||||
background: rgba(212, 175, 55, 0.15);
|
||||
border: 1px solid var(--accent-gold);
|
||||
color: var(--accent-gold);
|
||||
}
|
||||
|
||||
.usage-icon {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.usage-count {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.usage-label {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.usage-text {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
</style>
|
||||
@@ -10,6 +10,9 @@ Displays character stats, resource bars, and action buttons
|
||||
<span class="character-class">{{ character.class_name }}</span>
|
||||
<span class="character-level">Level {{ character.level }}</span>
|
||||
</div>
|
||||
<div class="character-usage">
|
||||
{% include 'components/usage_display.html' %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Resource Bars #}
|
||||
|
||||
Reference in New Issue
Block a user