""" Combat Repository - Database operations for combat encounters. This service handles all CRUD operations for combat data stored in dedicated database tables (combat_encounters, combat_rounds). Separates combat persistence from the CombatService which handles business logic and game mechanics. """ import json from typing import Dict, Any, List, Optional from datetime import datetime, timezone, timedelta from uuid import uuid4 from appwrite.query import Query from app.models.combat import CombatEncounter, Combatant from app.models.enums import CombatStatus from app.services.database_service import get_database_service, DatabaseService from app.utils.logging import get_logger logger = get_logger(__file__) # ============================================================================= # Exceptions # ============================================================================= class CombatEncounterNotFound(Exception): """Raised when combat encounter is not found in database.""" pass class CombatRoundNotFound(Exception): """Raised when combat round is not found in database.""" pass # ============================================================================= # Combat Repository # ============================================================================= class CombatRepository: """ Repository for combat encounter database operations. Handles: - Creating and reading combat encounters - Updating combat state during actions - Saving per-round history for logging and replay - Time-based cleanup of old combat data Tables: - combat_encounters: Main encounter state and metadata - combat_rounds: Per-round action history """ # Table IDs ENCOUNTERS_TABLE = "combat_encounters" ROUNDS_TABLE = "combat_rounds" # Default retention period for cleanup (days) DEFAULT_RETENTION_DAYS = 7 def __init__(self, db: Optional[DatabaseService] = None): """ Initialize the combat repository. Args: db: Optional DatabaseService instance (for testing/injection) """ self.db = db or get_database_service() logger.info("CombatRepository initialized") # ========================================================================= # Encounter CRUD Operations # ========================================================================= def create_encounter( self, encounter: CombatEncounter, session_id: str, user_id: str ) -> str: """ Create a new combat encounter record. Args: encounter: CombatEncounter instance to persist session_id: Game session ID this encounter belongs to user_id: Owner user ID for authorization Returns: encounter_id of created record """ created_at = self._get_timestamp() data = { 'sessionId': session_id, 'userId': user_id, 'status': encounter.status.value, 'roundNumber': encounter.round_number, 'currentTurnIndex': encounter.current_turn_index, 'turnOrder': json.dumps(encounter.turn_order), 'combatantsData': json.dumps([c.to_dict() for c in encounter.combatants]), 'combatLog': json.dumps(encounter.combat_log), 'created_at': created_at, } self.db.create_row( table_id=self.ENCOUNTERS_TABLE, data=data, row_id=encounter.encounter_id ) logger.info("Combat encounter created", encounter_id=encounter.encounter_id, session_id=session_id, combatant_count=len(encounter.combatants)) return encounter.encounter_id def get_encounter(self, encounter_id: str) -> Optional[CombatEncounter]: """ Get a combat encounter by ID. Args: encounter_id: Encounter ID to fetch Returns: CombatEncounter or None if not found """ logger.info("Fetching encounter from database", encounter_id=encounter_id) row = self.db.get_row(self.ENCOUNTERS_TABLE, encounter_id) if not row: logger.warning("Encounter not found", encounter_id=encounter_id) return None logger.info("Raw database row data", encounter_id=encounter_id, currentTurnIndex=row.data.get('currentTurnIndex'), roundNumber=row.data.get('roundNumber')) encounter = self._row_to_encounter(row.data, encounter_id) logger.info("Encounter object created", encounter_id=encounter_id, current_turn_index=encounter.current_turn_index, turn_order=encounter.turn_order) return encounter def get_encounter_by_session( self, session_id: str, active_only: bool = True ) -> Optional[CombatEncounter]: """ Get combat encounter for a session. Args: session_id: Game session ID active_only: If True, only return active encounters Returns: CombatEncounter or None if not found """ queries = [Query.equal('sessionId', session_id)] if active_only: queries.append(Query.equal('status', CombatStatus.ACTIVE.value)) rows = self.db.list_rows( table_id=self.ENCOUNTERS_TABLE, queries=queries, limit=1 ) if not rows: return None row = rows[0] return self._row_to_encounter(row.data, row.id) def get_user_active_encounters(self, user_id: str) -> List[CombatEncounter]: """ Get all active encounters for a user. Args: user_id: User ID to query Returns: List of active CombatEncounter instances """ rows = self.db.list_rows( table_id=self.ENCOUNTERS_TABLE, queries=[ Query.equal('userId', user_id), Query.equal('status', CombatStatus.ACTIVE.value) ], limit=25 ) return [self._row_to_encounter(row.data, row.id) for row in rows] def update_encounter(self, encounter: CombatEncounter) -> None: """ Update an existing combat encounter. Call this after each action to persist the updated state. Args: encounter: CombatEncounter with updated state """ data = { 'status': encounter.status.value, 'roundNumber': encounter.round_number, 'currentTurnIndex': encounter.current_turn_index, 'turnOrder': json.dumps(encounter.turn_order), 'combatantsData': json.dumps([c.to_dict() for c in encounter.combatants]), 'combatLog': json.dumps(encounter.combat_log), } logger.info("Saving encounter to database", encounter_id=encounter.encounter_id, current_turn_index=encounter.current_turn_index, combat_log_entries=len(encounter.combat_log)) self.db.update_row( table_id=self.ENCOUNTERS_TABLE, row_id=encounter.encounter_id, data=data ) logger.info("Encounter saved successfully", encounter_id=encounter.encounter_id) def end_encounter( self, encounter_id: str, status: CombatStatus ) -> None: """ Mark an encounter as ended. Args: encounter_id: Encounter ID to end status: Final status (VICTORY, DEFEAT, FLED) """ ended_at = self._get_timestamp() data = { 'status': status.value, 'ended_at': ended_at, } self.db.update_row( table_id=self.ENCOUNTERS_TABLE, row_id=encounter_id, data=data ) logger.info("Combat encounter ended", encounter_id=encounter_id, status=status.value) def delete_encounter(self, encounter_id: str) -> bool: """ Delete an encounter and all its rounds. Args: encounter_id: Encounter ID to delete Returns: True if deleted successfully """ # Delete rounds first self._delete_rounds_for_encounter(encounter_id) # Delete encounter result = self.db.delete_row(self.ENCOUNTERS_TABLE, encounter_id) logger.info("Combat encounter deleted", encounter_id=encounter_id) return result # ========================================================================= # Round Operations # ========================================================================= def save_round( self, encounter_id: str, session_id: str, round_number: int, actions: List[Dict[str, Any]], states_start: List[Combatant], states_end: List[Combatant] ) -> str: """ Save a completed round's data for history/replay. Call this at the end of each round (after all combatants have acted). Args: encounter_id: Parent encounter ID session_id: Game session ID (denormalized for queries) round_number: Round number (1-indexed) actions: List of all actions taken this round states_start: Combatant states at round start states_end: Combatant states at round end Returns: round_id of created record """ round_id = f"rnd_{uuid4().hex[:12]}" created_at = self._get_timestamp() data = { 'encounterId': encounter_id, 'sessionId': session_id, 'roundNumber': round_number, 'actionsData': json.dumps(actions), 'combatantStatesStart': json.dumps([c.to_dict() for c in states_start]), 'combatantStatesEnd': json.dumps([c.to_dict() for c in states_end]), 'created_at': created_at, } self.db.create_row( table_id=self.ROUNDS_TABLE, data=data, row_id=round_id ) logger.debug("Combat round saved", round_id=round_id, encounter_id=encounter_id, round_number=round_number, action_count=len(actions)) return round_id def get_encounter_rounds( self, encounter_id: str, limit: int = 100 ) -> List[Dict[str, Any]]: """ Get all rounds for an encounter, ordered by round number. Args: encounter_id: Encounter ID to fetch rounds for limit: Maximum number of rounds to return Returns: List of round data dictionaries """ rows = self.db.list_rows( table_id=self.ROUNDS_TABLE, queries=[Query.equal('encounterId', encounter_id)], limit=limit ) rounds = [] for row in rows: rounds.append({ 'round_id': row.id, 'round_number': row.data.get('roundNumber'), 'actions': json.loads(row.data.get('actionsData', '[]')), 'states_start': json.loads(row.data.get('combatantStatesStart', '[]')), 'states_end': json.loads(row.data.get('combatantStatesEnd', '[]')), 'created_at': row.data.get('created_at'), }) # Sort by round number return sorted(rounds, key=lambda r: r['round_number']) def get_session_combat_history( self, session_id: str, limit: int = 50 ) -> List[Dict[str, Any]]: """ Get combat history for a session. Returns summary of all encounters for the session. Args: session_id: Game session ID limit: Maximum encounters to return Returns: List of encounter summaries """ rows = self.db.list_rows( table_id=self.ENCOUNTERS_TABLE, queries=[Query.equal('sessionId', session_id)], limit=limit ) history = [] for row in rows: history.append({ 'encounter_id': row.id, 'status': row.data.get('status'), 'round_count': row.data.get('roundNumber', 1), 'created_at': row.data.get('created_at'), 'ended_at': row.data.get('ended_at'), }) # Sort by created_at descending (newest first) return sorted(history, key=lambda h: h['created_at'] or '', reverse=True) # ========================================================================= # Cleanup Operations # ========================================================================= def delete_encounters_by_session(self, session_id: str) -> int: """ Delete all encounters for a session. Call this when a session is deleted. Args: session_id: Session ID to clean up Returns: Number of encounters deleted """ rows = self.db.list_rows( table_id=self.ENCOUNTERS_TABLE, queries=[Query.equal('sessionId', session_id)], limit=100 ) deleted = 0 for row in rows: # Delete rounds first self._delete_rounds_for_encounter(row.id) # Delete encounter self.db.delete_row(self.ENCOUNTERS_TABLE, row.id) deleted += 1 if deleted > 0: logger.info("Deleted encounters for session", session_id=session_id, deleted_count=deleted) return deleted def delete_old_encounters( self, older_than_days: int = DEFAULT_RETENTION_DAYS ) -> int: """ Delete ended encounters older than specified days. This is the main cleanup method for time-based retention. Should be scheduled to run periodically (daily recommended). Args: older_than_days: Delete encounters ended more than this many days ago Returns: Number of encounters deleted """ cutoff = datetime.now(timezone.utc) - timedelta(days=older_than_days) cutoff_str = cutoff.isoformat().replace("+00:00", "Z") # Find old ended encounters # Note: We only delete ended encounters, not active ones rows = self.db.list_rows( table_id=self.ENCOUNTERS_TABLE, queries=[ Query.notEqual('status', CombatStatus.ACTIVE.value), Query.lessThan('created_at', cutoff_str) ], limit=100 ) deleted = 0 for row in rows: self._delete_rounds_for_encounter(row.id) self.db.delete_row(self.ENCOUNTERS_TABLE, row.id) deleted += 1 if deleted > 0: logger.info("Deleted old combat encounters", deleted_count=deleted, older_than_days=older_than_days) return deleted # ========================================================================= # Helper Methods # ========================================================================= def _delete_rounds_for_encounter(self, encounter_id: str) -> int: """ Delete all rounds for an encounter. Args: encounter_id: Encounter ID Returns: Number of rounds deleted """ rows = self.db.list_rows( table_id=self.ROUNDS_TABLE, queries=[Query.equal('encounterId', encounter_id)], limit=100 ) for row in rows: self.db.delete_row(self.ROUNDS_TABLE, row.id) return len(rows) def _row_to_encounter( self, data: Dict[str, Any], encounter_id: str ) -> CombatEncounter: """ Convert database row data to CombatEncounter object. Args: data: Row data dictionary encounter_id: Encounter ID Returns: Deserialized CombatEncounter """ # Parse JSON fields combatants_data = json.loads(data.get('combatantsData', '[]')) combatants = [Combatant.from_dict(c) for c in combatants_data] turn_order = json.loads(data.get('turnOrder', '[]')) combat_log = json.loads(data.get('combatLog', '[]')) # Parse status enum status_str = data.get('status', 'active') status = CombatStatus(status_str) return CombatEncounter( encounter_id=encounter_id, combatants=combatants, turn_order=turn_order, current_turn_index=data.get('currentTurnIndex', 0), round_number=data.get('roundNumber', 1), combat_log=combat_log, status=status, ) def _get_timestamp(self) -> str: """Get current UTC timestamp in ISO format.""" return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") # ============================================================================= # Global Instance # ============================================================================= _repository_instance: Optional[CombatRepository] = None def get_combat_repository() -> CombatRepository: """ Get the global CombatRepository instance. Returns: Singleton CombatRepository instance """ global _repository_instance if _repository_instance is None: _repository_instance = CombatRepository() return _repository_instance