579 lines
17 KiB
Python
579 lines
17 KiB
Python
"""
|
|
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
|