first commit
This commit is contained in:
411
api/app/models/session.py
Normal file
411
api/app/models/session.py
Normal file
@@ -0,0 +1,411 @@
|
||||
"""
|
||||
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: Current combat (None if not in combat)
|
||||
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
|
||||
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."""
|
||||
return 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,
|
||||
"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,
|
||||
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})"
|
||||
)
|
||||
Reference in New Issue
Block a user