first commit
This commit is contained in:
705
api/app/services/session_service.py
Normal file
705
api/app/services/session_service.py
Normal file
@@ -0,0 +1,705 @@
|
||||
"""
|
||||
Session Service - CRUD operations for game sessions.
|
||||
|
||||
This service handles creating, reading, updating, and managing game sessions,
|
||||
with support for both solo and multiplayer sessions.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import List, Optional
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from appwrite.query import Query
|
||||
from appwrite.id import ID
|
||||
|
||||
from app.models.session import GameSession, GameState, ConversationEntry, SessionConfig
|
||||
from app.models.enums import SessionStatus, SessionType
|
||||
from app.models.action_prompt import LocationType
|
||||
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.utils.logging import get_logger
|
||||
|
||||
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
|
||||
|
||||
|
||||
class SessionLimitExceeded(Exception):
|
||||
"""Raised when user tries to create more sessions than allowed."""
|
||||
pass
|
||||
|
||||
|
||||
class SessionValidationError(Exception):
|
||||
"""Raised when session validation fails."""
|
||||
pass
|
||||
|
||||
|
||||
class SessionService:
|
||||
"""
|
||||
Service for managing game sessions.
|
||||
|
||||
This service provides:
|
||||
- Session creation (solo and multiplayer)
|
||||
- Session retrieval and listing
|
||||
- Session state updates
|
||||
- Conversation history management
|
||||
- Game state tracking
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the session service with dependencies."""
|
||||
self.db = get_database_service()
|
||||
self.appwrite = AppwriteService()
|
||||
self.character_service = get_character_service()
|
||||
self.collection_id = "game_sessions"
|
||||
|
||||
logger.info("SessionService initialized")
|
||||
|
||||
def create_solo_session(
|
||||
self,
|
||||
user_id: str,
|
||||
character_id: str,
|
||||
starting_location: Optional[str] = None,
|
||||
starting_location_type: Optional[LocationType] = None
|
||||
) -> GameSession:
|
||||
"""
|
||||
Create a new solo game session.
|
||||
|
||||
This method:
|
||||
1. Validates user owns the character
|
||||
2. Validates user hasn't exceeded session limit
|
||||
3. Determines starting location from location data
|
||||
4. Creates session with initial game state
|
||||
5. Stores in Appwrite database
|
||||
|
||||
Args:
|
||||
user_id: Owner's user ID
|
||||
character_id: Character ID for this session
|
||||
starting_location: Initial location ID (optional, uses default starting location)
|
||||
starting_location_type: Initial location type (optional, derived from location data)
|
||||
|
||||
Returns:
|
||||
Created GameSession instance
|
||||
|
||||
Raises:
|
||||
CharacterNotFound: If character doesn't exist or user doesn't own it
|
||||
SessionLimitExceeded: If user has reached active session limit
|
||||
"""
|
||||
try:
|
||||
logger.info("Creating solo session",
|
||||
user_id=user_id,
|
||||
character_id=character_id)
|
||||
|
||||
# Validate user owns the character
|
||||
character = self.character_service.get_character(character_id, user_id)
|
||||
if not character:
|
||||
raise CharacterNotFound(f"Character not found: {character_id}")
|
||||
|
||||
# Determine starting location from location data if not provided
|
||||
if not starting_location:
|
||||
location_loader = get_location_loader()
|
||||
starting_locations = location_loader.get_starting_locations()
|
||||
|
||||
if starting_locations:
|
||||
# Use first starting location (usually crossville_village)
|
||||
start_loc = starting_locations[0]
|
||||
starting_location = start_loc.location_id
|
||||
# Convert from enums.LocationType to action_prompt.LocationType via string value
|
||||
starting_location_type = LocationType(start_loc.location_type.value)
|
||||
logger.info("Using starting location from data",
|
||||
location_id=starting_location,
|
||||
location_type=starting_location_type.value)
|
||||
else:
|
||||
# Fallback to crossville_village
|
||||
starting_location = "crossville_village"
|
||||
starting_location_type = LocationType.TOWN
|
||||
logger.warning("No starting locations found, using fallback",
|
||||
location_id=starting_location)
|
||||
|
||||
# Ensure location type is set
|
||||
if not starting_location_type:
|
||||
starting_location_type = LocationType.TOWN
|
||||
|
||||
# Check session limit
|
||||
active_count = self.count_user_sessions(user_id, active_only=True)
|
||||
if active_count >= MAX_ACTIVE_SESSIONS:
|
||||
logger.warning("Session limit exceeded",
|
||||
user_id=user_id,
|
||||
current=active_count,
|
||||
limit=MAX_ACTIVE_SESSIONS)
|
||||
raise SessionLimitExceeded(
|
||||
f"Maximum active sessions reached ({active_count}/{MAX_ACTIVE_SESSIONS}). "
|
||||
f"Please end an existing session to start a new one."
|
||||
)
|
||||
|
||||
# Generate unique session ID
|
||||
session_id = ID.unique()
|
||||
|
||||
# Create game state with starting location
|
||||
game_state = GameState(
|
||||
current_location=starting_location,
|
||||
location_type=starting_location_type,
|
||||
discovered_locations=[starting_location],
|
||||
active_quests=[],
|
||||
world_events=[]
|
||||
)
|
||||
|
||||
# Create session instance
|
||||
session = GameSession(
|
||||
session_id=session_id,
|
||||
session_type=SessionType.SOLO,
|
||||
solo_character_id=character_id,
|
||||
user_id=user_id,
|
||||
party_member_ids=[],
|
||||
config=SessionConfig(),
|
||||
game_state=game_state,
|
||||
turn_order=[character_id],
|
||||
current_turn=0,
|
||||
turn_number=0,
|
||||
status=SessionStatus.ACTIVE
|
||||
)
|
||||
|
||||
# Serialize and store
|
||||
session_dict = session.to_dict()
|
||||
session_json = json.dumps(session_dict)
|
||||
|
||||
document_data = {
|
||||
'userId': user_id,
|
||||
'characterId': character_id,
|
||||
'sessionData': session_json,
|
||||
'status': SessionStatus.ACTIVE.value,
|
||||
'sessionType': SessionType.SOLO.value
|
||||
}
|
||||
|
||||
self.db.create_document(
|
||||
collection_id=self.collection_id,
|
||||
data=document_data,
|
||||
document_id=session_id
|
||||
)
|
||||
|
||||
logger.info("Solo session created successfully",
|
||||
session_id=session_id,
|
||||
user_id=user_id,
|
||||
character_id=character_id)
|
||||
|
||||
return session
|
||||
|
||||
except (CharacterNotFound, SessionLimitExceeded):
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Failed to create solo session",
|
||||
user_id=user_id,
|
||||
character_id=character_id,
|
||||
error=str(e))
|
||||
raise
|
||||
|
||||
def get_session(self, session_id: str, user_id: Optional[str] = None) -> GameSession:
|
||||
"""
|
||||
Get a session by ID.
|
||||
|
||||
Args:
|
||||
session_id: Session ID
|
||||
user_id: Optional user ID for ownership validation
|
||||
|
||||
Returns:
|
||||
GameSession instance
|
||||
|
||||
Raises:
|
||||
SessionNotFound: If session doesn't exist or user doesn't own it
|
||||
"""
|
||||
try:
|
||||
logger.debug("Fetching session", session_id=session_id)
|
||||
|
||||
# Get document from database
|
||||
document = self.db.get_row(self.collection_id, session_id)
|
||||
|
||||
if not document:
|
||||
logger.warning("Session not found", session_id=session_id)
|
||||
raise SessionNotFound(f"Session not found: {session_id}")
|
||||
|
||||
# Verify ownership if user_id provided
|
||||
if user_id and document.data.get('userId') != user_id:
|
||||
logger.warning("Session ownership mismatch",
|
||||
session_id=session_id,
|
||||
expected_user=user_id,
|
||||
actual_user=document.data.get('userId'))
|
||||
raise SessionNotFound(f"Session not found: {session_id}")
|
||||
|
||||
# Parse session data
|
||||
session_json = document.data.get('sessionData')
|
||||
session_dict = json.loads(session_json)
|
||||
session = GameSession.from_dict(session_dict)
|
||||
|
||||
logger.debug("Session fetched successfully", session_id=session_id)
|
||||
return session
|
||||
|
||||
except SessionNotFound:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Failed to fetch session",
|
||||
session_id=session_id,
|
||||
error=str(e))
|
||||
raise
|
||||
|
||||
def update_session(self, session: GameSession) -> GameSession:
|
||||
"""
|
||||
Update a session in the database.
|
||||
|
||||
Args:
|
||||
session: GameSession instance with updated data
|
||||
|
||||
Returns:
|
||||
Updated GameSession instance
|
||||
"""
|
||||
try:
|
||||
logger.debug("Updating session", session_id=session.session_id)
|
||||
|
||||
# Serialize session
|
||||
session_dict = session.to_dict()
|
||||
session_json = json.dumps(session_dict)
|
||||
|
||||
# Update in database
|
||||
self.db.update_document(
|
||||
collection_id=self.collection_id,
|
||||
document_id=session.session_id,
|
||||
data={
|
||||
'sessionData': session_json,
|
||||
'status': session.status.value
|
||||
}
|
||||
)
|
||||
|
||||
logger.debug("Session updated successfully", session_id=session.session_id)
|
||||
return session
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to update session",
|
||||
session_id=session.session_id,
|
||||
error=str(e))
|
||||
raise
|
||||
|
||||
def get_user_sessions(
|
||||
self,
|
||||
user_id: str,
|
||||
active_only: bool = True,
|
||||
limit: int = 25
|
||||
) -> List[GameSession]:
|
||||
"""
|
||||
Get all sessions for a user.
|
||||
|
||||
Args:
|
||||
user_id: User ID
|
||||
active_only: If True, only return active sessions
|
||||
limit: Maximum number of sessions to return
|
||||
|
||||
Returns:
|
||||
List of GameSession instances
|
||||
"""
|
||||
try:
|
||||
logger.debug("Fetching user sessions",
|
||||
user_id=user_id,
|
||||
active_only=active_only)
|
||||
|
||||
# Build query
|
||||
queries = [Query.equal('userId', user_id)]
|
||||
if active_only:
|
||||
queries.append(Query.equal('status', SessionStatus.ACTIVE.value))
|
||||
|
||||
documents = self.db.list_rows(
|
||||
table_id=self.collection_id,
|
||||
queries=queries,
|
||||
limit=limit
|
||||
)
|
||||
|
||||
# Parse all sessions
|
||||
sessions = []
|
||||
for document in documents:
|
||||
try:
|
||||
session_json = document.data.get('sessionData')
|
||||
session_dict = json.loads(session_json)
|
||||
session = GameSession.from_dict(session_dict)
|
||||
sessions.append(session)
|
||||
except Exception as e:
|
||||
logger.error("Failed to parse session",
|
||||
document_id=document.id,
|
||||
error=str(e))
|
||||
continue
|
||||
|
||||
logger.debug("User sessions fetched",
|
||||
user_id=user_id,
|
||||
count=len(sessions))
|
||||
|
||||
return sessions
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to fetch user sessions",
|
||||
user_id=user_id,
|
||||
error=str(e))
|
||||
raise
|
||||
|
||||
def count_user_sessions(self, user_id: str, active_only: bool = True) -> int:
|
||||
"""
|
||||
Count sessions for a user.
|
||||
|
||||
Args:
|
||||
user_id: User ID
|
||||
active_only: If True, only count active sessions
|
||||
|
||||
Returns:
|
||||
Number of sessions
|
||||
"""
|
||||
try:
|
||||
queries = [Query.equal('userId', user_id)]
|
||||
if active_only:
|
||||
queries.append(Query.equal('status', SessionStatus.ACTIVE.value))
|
||||
|
||||
count = self.db.count_documents(
|
||||
collection_id=self.collection_id,
|
||||
queries=queries
|
||||
)
|
||||
|
||||
logger.debug("Session count",
|
||||
user_id=user_id,
|
||||
active_only=active_only,
|
||||
count=count)
|
||||
return count
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to count sessions",
|
||||
user_id=user_id,
|
||||
error=str(e))
|
||||
return 0
|
||||
|
||||
def end_session(self, session_id: str, user_id: str) -> GameSession:
|
||||
"""
|
||||
End a session by marking it as completed.
|
||||
|
||||
Args:
|
||||
session_id: Session ID
|
||||
user_id: User ID for ownership validation
|
||||
|
||||
Returns:
|
||||
Updated GameSession instance
|
||||
|
||||
Raises:
|
||||
SessionNotFound: If session doesn't exist or user doesn't own it
|
||||
"""
|
||||
try:
|
||||
logger.info("Ending session", session_id=session_id, user_id=user_id)
|
||||
|
||||
session = self.get_session(session_id, user_id)
|
||||
session.status = SessionStatus.COMPLETED
|
||||
session.update_activity()
|
||||
|
||||
return self.update_session(session)
|
||||
|
||||
except SessionNotFound:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Failed to end session",
|
||||
session_id=session_id,
|
||||
error=str(e))
|
||||
raise
|
||||
|
||||
def add_conversation_entry(
|
||||
self,
|
||||
session_id: str,
|
||||
character_id: str,
|
||||
character_name: str,
|
||||
action: str,
|
||||
dm_response: str,
|
||||
combat_log: Optional[List] = None,
|
||||
quest_offered: Optional[dict] = None
|
||||
) -> GameSession:
|
||||
"""
|
||||
Add an entry to the conversation history.
|
||||
|
||||
This method automatically:
|
||||
- Increments turn number
|
||||
- Adds timestamp
|
||||
- Updates last activity
|
||||
|
||||
Args:
|
||||
session_id: Session ID
|
||||
character_id: Acting character's ID
|
||||
character_name: Acting character's name
|
||||
action: Player's action text
|
||||
dm_response: AI DM's response
|
||||
combat_log: Optional combat actions
|
||||
quest_offered: Optional quest offering info
|
||||
|
||||
Returns:
|
||||
Updated GameSession instance
|
||||
"""
|
||||
try:
|
||||
logger.debug("Adding conversation entry",
|
||||
session_id=session_id,
|
||||
character_id=character_id)
|
||||
|
||||
session = self.get_session(session_id)
|
||||
|
||||
# Create conversation entry
|
||||
entry = ConversationEntry(
|
||||
turn=session.turn_number + 1,
|
||||
character_id=character_id,
|
||||
character_name=character_name,
|
||||
action=action,
|
||||
dm_response=dm_response,
|
||||
combat_log=combat_log or [],
|
||||
quest_offered=quest_offered
|
||||
)
|
||||
|
||||
# Add entry and increment turn
|
||||
session.conversation_history.append(entry)
|
||||
session.turn_number += 1
|
||||
session.update_activity()
|
||||
|
||||
# Save to database
|
||||
return self.update_session(session)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to add conversation entry",
|
||||
session_id=session_id,
|
||||
error=str(e))
|
||||
raise
|
||||
|
||||
def get_conversation_history(
|
||||
self,
|
||||
session_id: str,
|
||||
limit: Optional[int] = None,
|
||||
offset: int = 0
|
||||
) -> List[ConversationEntry]:
|
||||
"""
|
||||
Get conversation history for a session.
|
||||
|
||||
Args:
|
||||
session_id: Session ID
|
||||
limit: Maximum entries to return (None for all)
|
||||
offset: Number of entries to skip from end
|
||||
|
||||
Returns:
|
||||
List of ConversationEntry instances
|
||||
"""
|
||||
try:
|
||||
session = self.get_session(session_id)
|
||||
history = session.conversation_history
|
||||
|
||||
# Apply offset (from end)
|
||||
if offset > 0:
|
||||
history = history[:-offset] if offset < len(history) else []
|
||||
|
||||
# Apply limit (from end)
|
||||
if limit and len(history) > limit:
|
||||
history = history[-limit:]
|
||||
|
||||
return history
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get conversation history",
|
||||
session_id=session_id,
|
||||
error=str(e))
|
||||
raise
|
||||
|
||||
def get_recent_history(self, session_id: str, num_turns: int = 3) -> List[ConversationEntry]:
|
||||
"""
|
||||
Get the most recent conversation entries for AI context.
|
||||
|
||||
Args:
|
||||
session_id: Session ID
|
||||
num_turns: Number of recent turns to return
|
||||
|
||||
Returns:
|
||||
List of most recent ConversationEntry instances
|
||||
"""
|
||||
return self.get_conversation_history(session_id, limit=num_turns)
|
||||
|
||||
def update_location(
|
||||
self,
|
||||
session_id: str,
|
||||
new_location: str,
|
||||
location_type: LocationType
|
||||
) -> GameSession:
|
||||
"""
|
||||
Update the current location in the session.
|
||||
|
||||
Also adds location to discovered_locations if not already there.
|
||||
|
||||
Args:
|
||||
session_id: Session ID
|
||||
new_location: New location name
|
||||
location_type: New location type
|
||||
|
||||
Returns:
|
||||
Updated GameSession instance
|
||||
"""
|
||||
try:
|
||||
logger.debug("Updating location",
|
||||
session_id=session_id,
|
||||
new_location=new_location)
|
||||
|
||||
session = self.get_session(session_id)
|
||||
session.game_state.current_location = new_location
|
||||
session.game_state.location_type = location_type
|
||||
|
||||
# Track discovered locations
|
||||
if new_location not in session.game_state.discovered_locations:
|
||||
session.game_state.discovered_locations.append(new_location)
|
||||
|
||||
session.update_activity()
|
||||
return self.update_session(session)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to update location",
|
||||
session_id=session_id,
|
||||
error=str(e))
|
||||
raise
|
||||
|
||||
def add_discovered_location(self, session_id: str, location: str) -> GameSession:
|
||||
"""
|
||||
Add a location to the discovered locations list.
|
||||
|
||||
Args:
|
||||
session_id: Session ID
|
||||
location: Location name to add
|
||||
|
||||
Returns:
|
||||
Updated GameSession instance
|
||||
"""
|
||||
try:
|
||||
session = self.get_session(session_id)
|
||||
|
||||
if location not in session.game_state.discovered_locations:
|
||||
session.game_state.discovered_locations.append(location)
|
||||
session.update_activity()
|
||||
return self.update_session(session)
|
||||
|
||||
return session
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to add discovered location",
|
||||
session_id=session_id,
|
||||
error=str(e))
|
||||
raise
|
||||
|
||||
def add_active_quest(self, session_id: str, quest_id: str) -> GameSession:
|
||||
"""
|
||||
Add a quest to the active quests list.
|
||||
|
||||
Validates max 2 active quests limit.
|
||||
|
||||
Args:
|
||||
session_id: Session ID
|
||||
quest_id: Quest ID to add
|
||||
|
||||
Returns:
|
||||
Updated GameSession instance
|
||||
|
||||
Raises:
|
||||
SessionValidationError: If max quests limit exceeded
|
||||
"""
|
||||
try:
|
||||
session = self.get_session(session_id)
|
||||
|
||||
# Check max active quests (2)
|
||||
if len(session.game_state.active_quests) >= 2:
|
||||
raise SessionValidationError(
|
||||
"Maximum active quests reached (2/2). "
|
||||
"Complete or abandon a quest to accept a new one."
|
||||
)
|
||||
|
||||
if quest_id not in session.game_state.active_quests:
|
||||
session.game_state.active_quests.append(quest_id)
|
||||
session.update_activity()
|
||||
return self.update_session(session)
|
||||
|
||||
return session
|
||||
|
||||
except SessionValidationError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Failed to add active quest",
|
||||
session_id=session_id,
|
||||
quest_id=quest_id,
|
||||
error=str(e))
|
||||
raise
|
||||
|
||||
def remove_active_quest(self, session_id: str, quest_id: str) -> GameSession:
|
||||
"""
|
||||
Remove a quest from the active quests list.
|
||||
|
||||
Args:
|
||||
session_id: Session ID
|
||||
quest_id: Quest ID to remove
|
||||
|
||||
Returns:
|
||||
Updated GameSession instance
|
||||
"""
|
||||
try:
|
||||
session = self.get_session(session_id)
|
||||
|
||||
if quest_id in session.game_state.active_quests:
|
||||
session.game_state.active_quests.remove(quest_id)
|
||||
session.update_activity()
|
||||
return self.update_session(session)
|
||||
|
||||
return session
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to remove active quest",
|
||||
session_id=session_id,
|
||||
quest_id=quest_id,
|
||||
error=str(e))
|
||||
raise
|
||||
|
||||
def add_world_event(self, session_id: str, event: dict) -> GameSession:
|
||||
"""
|
||||
Add a world event to the session.
|
||||
|
||||
Args:
|
||||
session_id: Session ID
|
||||
event: Event dictionary with type, description, etc.
|
||||
|
||||
Returns:
|
||||
Updated GameSession instance
|
||||
"""
|
||||
try:
|
||||
session = self.get_session(session_id)
|
||||
|
||||
# Add timestamp if not present
|
||||
if 'timestamp' not in event:
|
||||
event['timestamp'] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
session.game_state.world_events.append(event)
|
||||
session.update_activity()
|
||||
return self.update_session(session)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to add world event",
|
||||
session_id=session_id,
|
||||
error=str(e))
|
||||
raise
|
||||
|
||||
|
||||
# Global instance for convenience
|
||||
_service_instance: Optional[SessionService] = None
|
||||
|
||||
|
||||
def get_session_service() -> SessionService:
|
||||
"""
|
||||
Get the global SessionService instance.
|
||||
|
||||
Returns:
|
||||
Singleton SessionService instance
|
||||
"""
|
||||
global _service_instance
|
||||
if _service_instance is None:
|
||||
_service_instance = SessionService()
|
||||
return _service_instance
|
||||
Reference in New Issue
Block a user