combat testing and polishing in the dev console, many bug fixes
This commit is contained in:
578
api/app/services/combat_repository.py
Normal file
578
api/app/services/combat_repository.py
Normal file
@@ -0,0 +1,578 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user