combat testing and polishing in the dev console, many bug fixes
This commit is contained in:
@@ -1074,9 +1074,9 @@ class CharacterService:
|
||||
character_json = json.dumps(character_dict)
|
||||
|
||||
# Update in database
|
||||
self.db.update_document(
|
||||
collection_id=self.collection_id,
|
||||
document_id=character.character_id,
|
||||
self.db.update_row(
|
||||
table_id=self.collection_id,
|
||||
row_id=character.character_id,
|
||||
data={'characterData': character_json}
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
@@ -30,6 +30,10 @@ from app.services.combat_loot_service import (
|
||||
CombatLootService,
|
||||
LootContext
|
||||
)
|
||||
from app.services.combat_repository import (
|
||||
get_combat_repository,
|
||||
CombatRepository
|
||||
)
|
||||
from app.utils.logging import get_logger
|
||||
|
||||
logger = get_logger(__file__)
|
||||
@@ -99,6 +103,7 @@ class ActionResult:
|
||||
combat_ended: bool = False
|
||||
combat_status: Optional[CombatStatus] = None
|
||||
next_combatant_id: Optional[str] = None
|
||||
next_is_player: bool = True # True if next turn is player's
|
||||
turn_effects: List[Dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
@@ -120,6 +125,7 @@ class ActionResult:
|
||||
"combat_ended": self.combat_ended,
|
||||
"combat_status": self.combat_status.value if self.combat_status else None,
|
||||
"next_combatant_id": self.next_combatant_id,
|
||||
"next_is_player": self.next_is_player,
|
||||
"turn_effects": self.turn_effects,
|
||||
}
|
||||
|
||||
@@ -203,6 +209,7 @@ class CombatService:
|
||||
self.enemy_loader = get_enemy_loader()
|
||||
self.ability_loader = AbilityLoader()
|
||||
self.loot_service = get_combat_loot_service()
|
||||
self.combat_repository = get_combat_repository()
|
||||
|
||||
logger.info("CombatService initialized")
|
||||
|
||||
@@ -283,9 +290,18 @@ class CombatService:
|
||||
# Initialize combat (roll initiative, set turn order)
|
||||
encounter.initialize_combat()
|
||||
|
||||
# Store in session
|
||||
session.start_combat(encounter)
|
||||
self.session_service.update_session(session, user_id)
|
||||
# Save encounter to dedicated table
|
||||
self.combat_repository.create_encounter(
|
||||
encounter=encounter,
|
||||
session_id=session_id,
|
||||
user_id=user_id
|
||||
)
|
||||
|
||||
# Update session with reference to encounter (not inline data)
|
||||
session.active_combat_encounter_id = encounter.encounter_id
|
||||
session.combat_encounter = None # Clear legacy inline storage
|
||||
session.update_activity()
|
||||
self.session_service.update_session(session)
|
||||
|
||||
logger.info("Combat started",
|
||||
session_id=session_id,
|
||||
@@ -303,6 +319,9 @@ class CombatService:
|
||||
"""
|
||||
Get current combat state for a session.
|
||||
|
||||
Uses the new database-backed storage, with fallback to legacy
|
||||
inline session storage for backward compatibility.
|
||||
|
||||
Args:
|
||||
session_id: Game session ID
|
||||
user_id: User ID for authorization
|
||||
@@ -311,7 +330,66 @@ class CombatService:
|
||||
CombatEncounter if in combat, None otherwise
|
||||
"""
|
||||
session = self.session_service.get_session(session_id, user_id)
|
||||
return session.combat_encounter
|
||||
|
||||
# New system: Check for reference to combat_encounters table
|
||||
if session.active_combat_encounter_id:
|
||||
encounter = self.combat_repository.get_encounter(
|
||||
session.active_combat_encounter_id
|
||||
)
|
||||
if encounter:
|
||||
return encounter
|
||||
# Reference exists but encounter not found - clear stale reference
|
||||
logger.warning("Stale combat encounter reference, clearing",
|
||||
session_id=session_id,
|
||||
encounter_id=session.active_combat_encounter_id)
|
||||
session.active_combat_encounter_id = None
|
||||
self.session_service.update_session(session)
|
||||
return None
|
||||
|
||||
# Legacy fallback: Check inline combat data and migrate if present
|
||||
if session.combat_encounter:
|
||||
return self._migrate_inline_encounter(session, user_id)
|
||||
|
||||
return None
|
||||
|
||||
def _migrate_inline_encounter(
|
||||
self,
|
||||
session,
|
||||
user_id: str
|
||||
) -> CombatEncounter:
|
||||
"""
|
||||
Migrate legacy inline combat encounter to database table.
|
||||
|
||||
This provides backward compatibility by automatically migrating
|
||||
existing inline combat data to the new database-backed system
|
||||
on first access.
|
||||
|
||||
Args:
|
||||
session: GameSession with inline combat_encounter
|
||||
user_id: User ID
|
||||
|
||||
Returns:
|
||||
The migrated CombatEncounter
|
||||
"""
|
||||
encounter = session.combat_encounter
|
||||
|
||||
logger.info("Migrating inline combat encounter to database",
|
||||
session_id=session.session_id,
|
||||
encounter_id=encounter.encounter_id)
|
||||
|
||||
# Save to repository
|
||||
self.combat_repository.create_encounter(
|
||||
encounter=encounter,
|
||||
session_id=session.session_id,
|
||||
user_id=user_id
|
||||
)
|
||||
|
||||
# Update session to use reference
|
||||
session.active_combat_encounter_id = encounter.encounter_id
|
||||
session.combat_encounter = None # Clear inline data
|
||||
self.session_service.update_session(session)
|
||||
|
||||
return encounter
|
||||
|
||||
def end_combat(
|
||||
self,
|
||||
@@ -339,7 +417,11 @@ class CombatService:
|
||||
if not session.is_in_combat():
|
||||
raise NotInCombatError("Session is not in combat")
|
||||
|
||||
encounter = session.combat_encounter
|
||||
# Get encounter from repository (or legacy inline)
|
||||
encounter = self.get_combat_state(session_id, user_id)
|
||||
if not encounter:
|
||||
raise NotInCombatError("Combat encounter not found")
|
||||
|
||||
encounter.status = outcome
|
||||
|
||||
# Calculate rewards if victory
|
||||
@@ -347,12 +429,22 @@ class CombatService:
|
||||
if outcome == CombatStatus.VICTORY:
|
||||
rewards = self._calculate_rewards(encounter, session, user_id)
|
||||
|
||||
# End combat on session
|
||||
session.end_combat()
|
||||
self.session_service.update_session(session, user_id)
|
||||
# End encounter in repository
|
||||
if session.active_combat_encounter_id:
|
||||
self.combat_repository.end_encounter(
|
||||
encounter_id=session.active_combat_encounter_id,
|
||||
status=outcome
|
||||
)
|
||||
|
||||
# Clear session combat reference
|
||||
session.active_combat_encounter_id = None
|
||||
session.combat_encounter = None # Also clear legacy field
|
||||
session.update_activity()
|
||||
self.session_service.update_session(session)
|
||||
|
||||
logger.info("Combat ended",
|
||||
session_id=session_id,
|
||||
encounter_id=encounter.encounter_id,
|
||||
outcome=outcome.value,
|
||||
xp_earned=rewards.experience,
|
||||
gold_earned=rewards.gold)
|
||||
@@ -396,7 +488,10 @@ class CombatService:
|
||||
if not session.is_in_combat():
|
||||
raise NotInCombatError("Session is not in combat")
|
||||
|
||||
encounter = session.combat_encounter
|
||||
# Get encounter from repository
|
||||
encounter = self.get_combat_state(session_id, user_id)
|
||||
if not encounter:
|
||||
raise NotInCombatError("Combat encounter not found")
|
||||
|
||||
# Validate it's this combatant's turn
|
||||
current = encounter.get_current_combatant()
|
||||
@@ -455,15 +550,29 @@ class CombatService:
|
||||
rewards = self._calculate_rewards(encounter, session, user_id)
|
||||
result.message += f" Victory! Earned {rewards.experience} XP and {rewards.gold} gold."
|
||||
|
||||
session.end_combat()
|
||||
# End encounter in repository
|
||||
if session.active_combat_encounter_id:
|
||||
self.combat_repository.end_encounter(
|
||||
encounter_id=session.active_combat_encounter_id,
|
||||
status=status
|
||||
)
|
||||
|
||||
# Clear session combat reference
|
||||
session.active_combat_encounter_id = None
|
||||
session.combat_encounter = None
|
||||
else:
|
||||
# Advance turn
|
||||
# Advance turn and save to repository
|
||||
self._advance_turn_and_save(encounter, session, user_id)
|
||||
next_combatant = encounter.get_current_combatant()
|
||||
result.next_combatant_id = next_combatant.combatant_id if next_combatant else None
|
||||
if next_combatant:
|
||||
result.next_combatant_id = next_combatant.combatant_id
|
||||
result.next_is_player = next_combatant.is_player
|
||||
else:
|
||||
result.next_combatant_id = None
|
||||
result.next_is_player = True
|
||||
|
||||
# Save session state
|
||||
self.session_service.update_session(session, user_id)
|
||||
self.session_service.update_session(session)
|
||||
|
||||
return result
|
||||
|
||||
@@ -487,7 +596,11 @@ class CombatService:
|
||||
if not session.is_in_combat():
|
||||
raise NotInCombatError("Session is not in combat")
|
||||
|
||||
encounter = session.combat_encounter
|
||||
# Get encounter from repository
|
||||
encounter = self.get_combat_state(session_id, user_id)
|
||||
if not encounter:
|
||||
raise NotInCombatError("Combat encounter not found")
|
||||
|
||||
current = encounter.get_current_combatant()
|
||||
|
||||
if not current:
|
||||
@@ -496,9 +609,55 @@ class CombatService:
|
||||
if current.is_player:
|
||||
raise InvalidActionError("Current combatant is a player, not an enemy")
|
||||
|
||||
# Check if the enemy is dead (shouldn't happen with fixed advance_turn, but defensive)
|
||||
if current.is_dead():
|
||||
# Skip this dead enemy's turn and advance
|
||||
self._advance_turn_and_save(encounter, session, user_id)
|
||||
next_combatant = encounter.get_current_combatant()
|
||||
return ActionResult(
|
||||
success=True,
|
||||
message=f"{current.name} is defeated and cannot act.",
|
||||
next_combatant_id=next_combatant.combatant_id if next_combatant else None,
|
||||
next_is_player=next_combatant.is_player if next_combatant else True,
|
||||
)
|
||||
|
||||
# Process start-of-turn effects
|
||||
turn_effects = encounter.start_turn()
|
||||
|
||||
# Check if enemy died from DoT effects at turn start
|
||||
if current.is_dead():
|
||||
# Check if combat ended
|
||||
combat_status = encounter.check_end_condition()
|
||||
if combat_status in [CombatStatus.VICTORY, CombatStatus.DEFEAT]:
|
||||
encounter.status = combat_status
|
||||
result = ActionResult(
|
||||
success=True,
|
||||
message=f"{current.name} was defeated by damage over time!",
|
||||
combat_ended=True,
|
||||
combat_status=combat_status,
|
||||
turn_effects=turn_effects,
|
||||
)
|
||||
if session.active_combat_encounter_id:
|
||||
self.combat_repository.end_encounter(
|
||||
encounter_id=session.active_combat_encounter_id,
|
||||
status=combat_status
|
||||
)
|
||||
session.active_combat_encounter_id = None
|
||||
session.combat_encounter = None
|
||||
self.session_service.update_session(session)
|
||||
return result
|
||||
else:
|
||||
# Advance past the dead enemy
|
||||
self._advance_turn_and_save(encounter, session, user_id)
|
||||
next_combatant = encounter.get_current_combatant()
|
||||
return ActionResult(
|
||||
success=True,
|
||||
message=f"{current.name} was defeated by damage over time!",
|
||||
next_combatant_id=next_combatant.combatant_id if next_combatant else None,
|
||||
next_is_player=next_combatant.is_player if next_combatant else True,
|
||||
turn_effects=turn_effects,
|
||||
)
|
||||
|
||||
# Check if stunned
|
||||
if current.is_stunned():
|
||||
result = ActionResult(
|
||||
@@ -539,14 +698,39 @@ class CombatService:
|
||||
if status != CombatStatus.ACTIVE:
|
||||
result.combat_ended = True
|
||||
result.combat_status = status
|
||||
session.end_combat()
|
||||
|
||||
# End encounter in repository
|
||||
if session.active_combat_encounter_id:
|
||||
self.combat_repository.end_encounter(
|
||||
encounter_id=session.active_combat_encounter_id,
|
||||
status=status
|
||||
)
|
||||
|
||||
# Clear session combat reference
|
||||
session.active_combat_encounter_id = None
|
||||
session.combat_encounter = None
|
||||
else:
|
||||
logger.info("Combat still active, advancing turn",
|
||||
session_id=session_id,
|
||||
encounter_id=encounter.encounter_id)
|
||||
self._advance_turn_and_save(encounter, session, user_id)
|
||||
next_combatant = encounter.get_current_combatant()
|
||||
result.next_combatant_id = next_combatant.combatant_id if next_combatant else None
|
||||
logger.info("Next combatant determined",
|
||||
next_combatant_id=next_combatant.combatant_id if next_combatant else None,
|
||||
next_is_player=next_combatant.is_player if next_combatant else None)
|
||||
if next_combatant:
|
||||
result.next_combatant_id = next_combatant.combatant_id
|
||||
result.next_is_player = next_combatant.is_player
|
||||
else:
|
||||
result.next_combatant_id = None
|
||||
result.next_is_player = True
|
||||
|
||||
self.session_service.update_session(session, user_id)
|
||||
self.session_service.update_session(session)
|
||||
|
||||
logger.info("Enemy turn complete, returning result",
|
||||
session_id=session_id,
|
||||
next_combatant_id=result.next_combatant_id,
|
||||
next_is_player=result.next_is_player)
|
||||
return result
|
||||
|
||||
# =========================================================================
|
||||
@@ -1146,9 +1330,25 @@ class CombatService:
|
||||
session,
|
||||
user_id: str
|
||||
) -> None:
|
||||
"""Advance the turn and save session state."""
|
||||
"""Advance the turn and save encounter state to repository."""
|
||||
logger.info("_advance_turn_and_save called",
|
||||
encounter_id=encounter.encounter_id,
|
||||
before_turn_index=encounter.current_turn_index,
|
||||
combat_log_count=len(encounter.combat_log))
|
||||
|
||||
encounter.advance_turn()
|
||||
|
||||
logger.info("Turn advanced, now saving",
|
||||
encounter_id=encounter.encounter_id,
|
||||
after_turn_index=encounter.current_turn_index,
|
||||
combat_log_count=len(encounter.combat_log))
|
||||
|
||||
# Save encounter to repository
|
||||
self.combat_repository.update_encounter(encounter)
|
||||
|
||||
logger.info("Encounter saved",
|
||||
encounter_id=encounter.encounter_id)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Global Instance
|
||||
|
||||
@@ -106,6 +106,24 @@ class DatabaseInitService:
|
||||
logger.error("Failed to initialize chat_messages table", error=str(e))
|
||||
results['chat_messages'] = False
|
||||
|
||||
# Initialize combat_encounters table
|
||||
try:
|
||||
self.init_combat_encounters_table()
|
||||
results['combat_encounters'] = True
|
||||
logger.info("Combat encounters table initialized successfully")
|
||||
except Exception as e:
|
||||
logger.error("Failed to initialize combat_encounters table", error=str(e))
|
||||
results['combat_encounters'] = False
|
||||
|
||||
# Initialize combat_rounds table
|
||||
try:
|
||||
self.init_combat_rounds_table()
|
||||
results['combat_rounds'] = True
|
||||
logger.info("Combat rounds table initialized successfully")
|
||||
except Exception as e:
|
||||
logger.error("Failed to initialize combat_rounds table", error=str(e))
|
||||
results['combat_rounds'] = False
|
||||
|
||||
success_count = sum(1 for v in results.values() if v)
|
||||
total_count = len(results)
|
||||
|
||||
@@ -746,6 +764,326 @@ class DatabaseInitService:
|
||||
code=e.code)
|
||||
raise
|
||||
|
||||
def init_combat_encounters_table(self) -> bool:
|
||||
"""
|
||||
Initialize the combat_encounters table for storing combat encounter state.
|
||||
|
||||
Table schema:
|
||||
- sessionId (string, required): Game session ID (FK to game_sessions)
|
||||
- userId (string, required): Owner user ID for authorization
|
||||
- status (string, required): Combat status (active, victory, defeat, fled)
|
||||
- roundNumber (integer, required): Current round number
|
||||
- currentTurnIndex (integer, required): Index in turn_order for current turn
|
||||
- turnOrder (string, required): JSON array of combatant IDs in initiative order
|
||||
- combatantsData (string, required): JSON array of Combatant objects (full state)
|
||||
- combatLog (string, optional): JSON array of all combat log entries
|
||||
- created_at (string, required): ISO timestamp of combat start
|
||||
- ended_at (string, optional): ISO timestamp when combat ended
|
||||
|
||||
Indexes:
|
||||
- idx_sessionId: Session-based lookups
|
||||
- idx_userId_status: User's active combats query
|
||||
- idx_status_created_at: Time-based cleanup queries
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
|
||||
Raises:
|
||||
AppwriteException: If table creation fails
|
||||
"""
|
||||
table_id = 'combat_encounters'
|
||||
|
||||
logger.info("Initializing combat_encounters table", table_id=table_id)
|
||||
|
||||
try:
|
||||
# Check if table already exists
|
||||
try:
|
||||
self.tables_db.get_table(
|
||||
database_id=self.database_id,
|
||||
table_id=table_id
|
||||
)
|
||||
logger.info("Combat encounters table already exists", table_id=table_id)
|
||||
return True
|
||||
except AppwriteException as e:
|
||||
if e.code != 404:
|
||||
raise
|
||||
logger.info("Combat encounters table does not exist, creating...")
|
||||
|
||||
# Create table
|
||||
logger.info("Creating combat_encounters table")
|
||||
table = self.tables_db.create_table(
|
||||
database_id=self.database_id,
|
||||
table_id=table_id,
|
||||
name='Combat Encounters'
|
||||
)
|
||||
logger.info("Combat encounters table created", table_id=table['$id'])
|
||||
|
||||
# Create columns
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='sessionId',
|
||||
column_type='string',
|
||||
size=255,
|
||||
required=True
|
||||
)
|
||||
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='userId',
|
||||
column_type='string',
|
||||
size=255,
|
||||
required=True
|
||||
)
|
||||
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='status',
|
||||
column_type='string',
|
||||
size=20,
|
||||
required=True
|
||||
)
|
||||
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='roundNumber',
|
||||
column_type='integer',
|
||||
required=True
|
||||
)
|
||||
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='currentTurnIndex',
|
||||
column_type='integer',
|
||||
required=True
|
||||
)
|
||||
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='turnOrder',
|
||||
column_type='string',
|
||||
size=2000, # JSON array of combatant IDs
|
||||
required=True
|
||||
)
|
||||
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='combatantsData',
|
||||
column_type='string',
|
||||
size=65535, # Large text field for JSON combatant array
|
||||
required=True
|
||||
)
|
||||
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='combatLog',
|
||||
column_type='string',
|
||||
size=65535, # Large text field for combat log
|
||||
required=False
|
||||
)
|
||||
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='created_at',
|
||||
column_type='string',
|
||||
size=50, # ISO timestamp format
|
||||
required=True
|
||||
)
|
||||
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='ended_at',
|
||||
column_type='string',
|
||||
size=50, # ISO timestamp format
|
||||
required=False
|
||||
)
|
||||
|
||||
# Wait for columns to fully propagate
|
||||
logger.info("Waiting for columns to propagate before creating indexes...")
|
||||
time.sleep(2)
|
||||
|
||||
# Create indexes
|
||||
self._create_index(
|
||||
table_id=table_id,
|
||||
index_id='idx_sessionId',
|
||||
index_type='key',
|
||||
attributes=['sessionId']
|
||||
)
|
||||
|
||||
self._create_index(
|
||||
table_id=table_id,
|
||||
index_id='idx_userId_status',
|
||||
index_type='key',
|
||||
attributes=['userId', 'status']
|
||||
)
|
||||
|
||||
self._create_index(
|
||||
table_id=table_id,
|
||||
index_id='idx_status_created_at',
|
||||
index_type='key',
|
||||
attributes=['status', 'created_at']
|
||||
)
|
||||
|
||||
logger.info("Combat encounters table initialized successfully", table_id=table_id)
|
||||
return True
|
||||
|
||||
except AppwriteException as e:
|
||||
logger.error("Failed to initialize combat_encounters table",
|
||||
table_id=table_id,
|
||||
error=str(e),
|
||||
code=e.code)
|
||||
raise
|
||||
|
||||
def init_combat_rounds_table(self) -> bool:
|
||||
"""
|
||||
Initialize the combat_rounds table for storing per-round action history.
|
||||
|
||||
Table schema:
|
||||
- encounterId (string, required): FK to combat_encounters
|
||||
- sessionId (string, required): Denormalized for efficient queries
|
||||
- roundNumber (integer, required): Round number (1-indexed)
|
||||
- actionsData (string, required): JSON array of all actions in this round
|
||||
- combatantStatesStart (string, required): JSON snapshot of combatant states at round start
|
||||
- combatantStatesEnd (string, required): JSON snapshot of combatant states at round end
|
||||
- created_at (string, required): ISO timestamp when round completed
|
||||
|
||||
Indexes:
|
||||
- idx_encounterId: Encounter-based lookups
|
||||
- idx_encounterId_roundNumber: Ordered retrieval of rounds
|
||||
- idx_sessionId: Session-based queries
|
||||
- idx_created_at: Time-based cleanup
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
|
||||
Raises:
|
||||
AppwriteException: If table creation fails
|
||||
"""
|
||||
table_id = 'combat_rounds'
|
||||
|
||||
logger.info("Initializing combat_rounds table", table_id=table_id)
|
||||
|
||||
try:
|
||||
# Check if table already exists
|
||||
try:
|
||||
self.tables_db.get_table(
|
||||
database_id=self.database_id,
|
||||
table_id=table_id
|
||||
)
|
||||
logger.info("Combat rounds table already exists", table_id=table_id)
|
||||
return True
|
||||
except AppwriteException as e:
|
||||
if e.code != 404:
|
||||
raise
|
||||
logger.info("Combat rounds table does not exist, creating...")
|
||||
|
||||
# Create table
|
||||
logger.info("Creating combat_rounds table")
|
||||
table = self.tables_db.create_table(
|
||||
database_id=self.database_id,
|
||||
table_id=table_id,
|
||||
name='Combat Rounds'
|
||||
)
|
||||
logger.info("Combat rounds table created", table_id=table['$id'])
|
||||
|
||||
# Create columns
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='encounterId',
|
||||
column_type='string',
|
||||
size=36, # UUID format: enc_xxxxxxxxxxxx
|
||||
required=True
|
||||
)
|
||||
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='sessionId',
|
||||
column_type='string',
|
||||
size=255,
|
||||
required=True
|
||||
)
|
||||
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='roundNumber',
|
||||
column_type='integer',
|
||||
required=True
|
||||
)
|
||||
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='actionsData',
|
||||
column_type='string',
|
||||
size=65535, # JSON array of action objects
|
||||
required=True
|
||||
)
|
||||
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='combatantStatesStart',
|
||||
column_type='string',
|
||||
size=65535, # JSON snapshot of combatant states
|
||||
required=True
|
||||
)
|
||||
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='combatantStatesEnd',
|
||||
column_type='string',
|
||||
size=65535, # JSON snapshot of combatant states
|
||||
required=True
|
||||
)
|
||||
|
||||
self._create_column(
|
||||
table_id=table_id,
|
||||
column_id='created_at',
|
||||
column_type='string',
|
||||
size=50, # ISO timestamp format
|
||||
required=True
|
||||
)
|
||||
|
||||
# Wait for columns to fully propagate
|
||||
logger.info("Waiting for columns to propagate before creating indexes...")
|
||||
time.sleep(2)
|
||||
|
||||
# Create indexes
|
||||
self._create_index(
|
||||
table_id=table_id,
|
||||
index_id='idx_encounterId',
|
||||
index_type='key',
|
||||
attributes=['encounterId']
|
||||
)
|
||||
|
||||
self._create_index(
|
||||
table_id=table_id,
|
||||
index_id='idx_encounterId_roundNumber',
|
||||
index_type='key',
|
||||
attributes=['encounterId', 'roundNumber']
|
||||
)
|
||||
|
||||
self._create_index(
|
||||
table_id=table_id,
|
||||
index_id='idx_sessionId',
|
||||
index_type='key',
|
||||
attributes=['sessionId']
|
||||
)
|
||||
|
||||
self._create_index(
|
||||
table_id=table_id,
|
||||
index_id='idx_created_at',
|
||||
index_type='key',
|
||||
attributes=['created_at']
|
||||
)
|
||||
|
||||
logger.info("Combat rounds table initialized successfully", table_id=table_id)
|
||||
return True
|
||||
|
||||
except AppwriteException as e:
|
||||
logger.error("Failed to initialize combat_rounds table",
|
||||
table_id=table_id,
|
||||
error=str(e),
|
||||
code=e.code)
|
||||
raise
|
||||
|
||||
def _create_column(
|
||||
self,
|
||||
table_id: str,
|
||||
|
||||
@@ -272,9 +272,9 @@ class SessionService:
|
||||
session_json = json.dumps(session_dict)
|
||||
|
||||
# Update in database
|
||||
self.db.update_document(
|
||||
collection_id=self.collection_id,
|
||||
document_id=session.session_id,
|
||||
self.db.update_row(
|
||||
table_id=self.collection_id,
|
||||
row_id=session.session_id,
|
||||
data={
|
||||
'sessionData': session_json,
|
||||
'status': session.status.value
|
||||
|
||||
Reference in New Issue
Block a user