""" Game session data models. This module defines the session-related dataclasses including SessionConfig, GameState, and GameSession which manage multiplayer party sessions. """ from dataclasses import dataclass, field, asdict from typing import Dict, Any, List, Optional from datetime import datetime, timezone from app.models.combat import CombatEncounter from app.models.enums import SessionStatus, SessionType from app.models.action_prompt import LocationType @dataclass class SessionConfig: """ Configuration settings for a game session. Attributes: min_players: Minimum players required (session ends if below this) timeout_minutes: Inactivity timeout in minutes auto_save_interval: Turns between automatic saves """ min_players: int = 1 timeout_minutes: int = 30 auto_save_interval: int = 5 def to_dict(self) -> Dict[str, Any]: """Serialize configuration to dictionary.""" return asdict(self) @classmethod def from_dict(cls, data: Dict[str, Any]) -> 'SessionConfig': """Deserialize configuration from dictionary.""" return cls( min_players=data.get("min_players", 1), timeout_minutes=data.get("timeout_minutes", 30), auto_save_interval=data.get("auto_save_interval", 5), ) @dataclass class GameState: """ Current world/quest state for a game session. Attributes: current_location: Current location name/ID location_type: Type of current location (town, tavern, wilderness, etc.) discovered_locations: All location IDs the party has visited active_quests: Quest IDs currently in progress world_events: Server-wide events affecting this session """ current_location: str = "crossville_village" location_type: LocationType = LocationType.TOWN discovered_locations: List[str] = field(default_factory=list) active_quests: List[str] = field(default_factory=list) world_events: List[Dict[str, Any]] = field(default_factory=list) def to_dict(self) -> Dict[str, Any]: """Serialize game state to dictionary.""" return { "current_location": self.current_location, "location_type": self.location_type.value, "discovered_locations": self.discovered_locations, "active_quests": self.active_quests, "world_events": self.world_events, } @classmethod def from_dict(cls, data: Dict[str, Any]) -> 'GameState': """Deserialize game state from dictionary.""" # Handle location_type as either string or enum location_type_value = data.get("location_type", "town") if isinstance(location_type_value, str): location_type = LocationType(location_type_value) else: location_type = location_type_value return cls( current_location=data.get("current_location", "crossville_village"), location_type=location_type, discovered_locations=data.get("discovered_locations", []), active_quests=data.get("active_quests", []), world_events=data.get("world_events", []), ) @dataclass class ConversationEntry: """ Single entry in the conversation history. Attributes: turn: Turn number character_id: Acting character's ID character_name: Acting character's name action: Player's action/input text dm_response: AI Dungeon Master's response timestamp: ISO timestamp of when entry was created combat_log: Combat actions if any occurred this turn quest_offered: Quest offering info if a quest was offered this turn """ turn: int character_id: str character_name: str action: str dm_response: str timestamp: str = "" combat_log: List[Dict[str, Any]] = field(default_factory=list) quest_offered: Optional[Dict[str, Any]] = None def __post_init__(self): """Initialize timestamp if not provided.""" if not self.timestamp: self.timestamp = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") def to_dict(self) -> Dict[str, Any]: """Serialize conversation entry to dictionary.""" result = { "turn": self.turn, "character_id": self.character_id, "character_name": self.character_name, "action": self.action, "dm_response": self.dm_response, "timestamp": self.timestamp, "combat_log": self.combat_log, } if self.quest_offered: result["quest_offered"] = self.quest_offered return result @classmethod def from_dict(cls, data: Dict[str, Any]) -> 'ConversationEntry': """Deserialize conversation entry from dictionary.""" return cls( turn=data["turn"], character_id=data.get("character_id", ""), character_name=data.get("character_name", ""), action=data["action"], dm_response=data["dm_response"], timestamp=data.get("timestamp", ""), combat_log=data.get("combat_log", []), quest_offered=data.get("quest_offered"), ) @dataclass class GameSession: """ Represents a game session (solo or multiplayer). A session can have one or more players (party) and tracks the entire game state including conversation history, combat encounters, and turn order. Attributes: session_id: Unique identifier session_type: Type of session (solo or multiplayer) solo_character_id: Character ID for single-player sessions (None for multiplayer) user_id: Owner of the session party_member_ids: Character IDs in this party (multiplayer only) config: Session configuration settings combat_encounter: Legacy inline combat data (None if not in combat) active_combat_encounter_id: Reference to combat_encounters table (new system) conversation_history: Turn-by-turn log of actions and DM responses game_state: Current world/quest state turn_order: Character turn order current_turn: Index in turn_order for current turn turn_number: Global turn counter created_at: ISO timestamp of session creation last_activity: ISO timestamp of last action status: Current session status (active, completed, timeout) """ session_id: str session_type: SessionType = SessionType.SOLO solo_character_id: Optional[str] = None user_id: str = "" party_member_ids: List[str] = field(default_factory=list) config: SessionConfig = field(default_factory=SessionConfig) combat_encounter: Optional[CombatEncounter] = None # Legacy: inline combat data active_combat_encounter_id: Optional[str] = None # New: reference to combat_encounters table conversation_history: List[ConversationEntry] = field(default_factory=list) game_state: GameState = field(default_factory=GameState) turn_order: List[str] = field(default_factory=list) current_turn: int = 0 turn_number: int = 0 created_at: str = "" last_activity: str = "" status: SessionStatus = SessionStatus.ACTIVE def __post_init__(self): """Initialize timestamps if not provided.""" if not self.created_at: self.created_at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") if not self.last_activity: self.last_activity = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") def is_in_combat(self) -> bool: """ Check if session is currently in combat. Checks both the new database reference and legacy inline storage for backward compatibility. """ return self.active_combat_encounter_id is not None or self.combat_encounter is not None def start_combat(self, encounter: CombatEncounter) -> None: """ Start a combat encounter. Args: encounter: The combat encounter to begin """ self.combat_encounter = encounter self.update_activity() def end_combat(self) -> None: """End the current combat encounter.""" self.combat_encounter = None self.update_activity() def advance_turn(self) -> str: """ Advance to the next player's turn. Returns: Character ID whose turn it now is """ if not self.turn_order: return "" self.current_turn = (self.current_turn + 1) % len(self.turn_order) self.turn_number += 1 self.update_activity() return self.turn_order[self.current_turn] def get_current_character_id(self) -> Optional[str]: """Get the character ID whose turn it currently is.""" if not self.turn_order: return None return self.turn_order[self.current_turn] def add_conversation_entry(self, entry: ConversationEntry) -> None: """ Add an entry to the conversation history. Args: entry: Conversation entry to add """ self.conversation_history.append(entry) self.update_activity() def update_activity(self) -> None: """Update the last activity timestamp to now.""" self.last_activity = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") def add_party_member(self, character_id: str) -> None: """ Add a character to the party. Args: character_id: Character ID to add """ if character_id not in self.party_member_ids: self.party_member_ids.append(character_id) self.update_activity() def remove_party_member(self, character_id: str) -> None: """ Remove a character from the party. Args: character_id: Character ID to remove """ if character_id in self.party_member_ids: self.party_member_ids.remove(character_id) # Also remove from turn order if character_id in self.turn_order: self.turn_order.remove(character_id) self.update_activity() def check_timeout(self) -> bool: """ Check if session has timed out due to inactivity. Returns: True if session should be marked as timed out """ if self.status != SessionStatus.ACTIVE: return False # Calculate time since last activity last_activity_str = self.last_activity.replace("Z", "+00:00") last_activity_time = datetime.fromisoformat(last_activity_str) now = datetime.now(timezone.utc) elapsed_minutes = (now - last_activity_time).total_seconds() / 60 if elapsed_minutes >= self.config.timeout_minutes: self.status = SessionStatus.TIMEOUT return True return False def check_min_players(self) -> bool: """ Check if session still has minimum required players. Returns: True if session should continue, False if it should end """ if len(self.party_member_ids) < self.config.min_players: if self.status == SessionStatus.ACTIVE: self.status = SessionStatus.COMPLETED return False return True def is_solo(self) -> bool: """Check if this is a solo session.""" return self.session_type == SessionType.SOLO def get_character_id(self) -> Optional[str]: """ Get the primary character ID for the session. For solo sessions, returns solo_character_id. For multiplayer, returns the current character in turn order. """ if self.is_solo(): return self.solo_character_id return self.get_current_character_id() def to_dict(self) -> Dict[str, Any]: """Serialize game session to dictionary.""" return { "session_id": self.session_id, "session_type": self.session_type.value, "solo_character_id": self.solo_character_id, "user_id": self.user_id, "party_member_ids": self.party_member_ids, "config": self.config.to_dict(), "combat_encounter": self.combat_encounter.to_dict() if self.combat_encounter else None, "active_combat_encounter_id": self.active_combat_encounter_id, "conversation_history": [entry.to_dict() for entry in self.conversation_history], "game_state": self.game_state.to_dict(), "turn_order": self.turn_order, "current_turn": self.current_turn, "turn_number": self.turn_number, "created_at": self.created_at, "last_activity": self.last_activity, "status": self.status.value, } @classmethod def from_dict(cls, data: Dict[str, Any]) -> 'GameSession': """Deserialize game session from dictionary.""" config = SessionConfig.from_dict(data.get("config", {})) game_state = GameState.from_dict(data.get("game_state", {})) conversation_history = [ ConversationEntry.from_dict(entry) for entry in data.get("conversation_history", []) ] combat_encounter = None if data.get("combat_encounter"): combat_encounter = CombatEncounter.from_dict(data["combat_encounter"]) status = SessionStatus(data.get("status", "active")) # Handle session_type as either string or enum session_type_value = data.get("session_type", "solo") if isinstance(session_type_value, str): session_type = SessionType(session_type_value) else: session_type = session_type_value return cls( session_id=data["session_id"], session_type=session_type, solo_character_id=data.get("solo_character_id"), user_id=data.get("user_id", ""), party_member_ids=data.get("party_member_ids", []), config=config, combat_encounter=combat_encounter, active_combat_encounter_id=data.get("active_combat_encounter_id"), conversation_history=conversation_history, game_state=game_state, turn_order=data.get("turn_order", []), current_turn=data.get("current_turn", 0), turn_number=data.get("turn_number", 0), created_at=data.get("created_at", ""), last_activity=data.get("last_activity", ""), status=status, ) def __repr__(self) -> str: """String representation of the session.""" if self.is_solo(): return ( f"GameSession({self.session_id}, " f"type=solo, " f"char={self.solo_character_id}, " f"turn={self.turn_number}, " f"status={self.status.value})" ) return ( f"GameSession({self.session_id}, " f"type=multiplayer, " f"party={len(self.party_member_ids)}, " f"turn={self.turn_number}, " f"status={self.status.value})" )