combat testing and polishing in the dev console, many bug fixes

This commit is contained in:
2025-11-27 20:37:53 -06:00
parent 94c4ca9e95
commit dd92cf5991
45 changed files with 8157 additions and 1106 deletions

View File

@@ -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}
)

View 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

View File

@@ -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

View File

@@ -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,

View File

@@ -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