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

@@ -100,7 +100,7 @@ def start_combat():
combat_service = get_combat_service()
encounter = combat_service.start_combat(
session_id=session_id,
user_id=user["user_id"],
user_id=user.id,
enemy_ids=enemy_ids,
)
@@ -139,9 +139,9 @@ def start_combat():
logger.warning("Attempt to start combat while already in combat",
session_id=session_id)
return error_response(
status_code=400,
status=400,
message=str(e),
error_code="ALREADY_IN_COMBAT"
code="ALREADY_IN_COMBAT"
)
except ValueError as e:
logger.warning("Invalid enemy ID",
@@ -154,9 +154,9 @@ def start_combat():
error=str(e),
exc_info=True)
return error_response(
status_code=500,
status=500,
message="Failed to start combat",
error_code="COMBAT_START_ERROR"
code="COMBAT_START_ERROR"
)
@@ -196,7 +196,7 @@ def get_combat_state(session_id: str):
try:
combat_service = get_combat_service()
encounter = combat_service.get_combat_state(session_id, user["user_id"])
encounter = combat_service.get_combat_state(session_id, user.id)
if not encounter:
return success_response({
@@ -245,9 +245,9 @@ def get_combat_state(session_id: str):
error=str(e),
exc_info=True)
return error_response(
status_code=500,
status=500,
message="Failed to get combat state",
error_code="COMBAT_STATE_ERROR"
code="COMBAT_STATE_ERROR"
)
@@ -306,11 +306,27 @@ def execute_action(session_id: str):
combatant_id = data.get("combatant_id")
action_type = data.get("action_type")
# If combatant_id not provided, auto-detect player combatant
if not combatant_id:
return validation_error_response(
message="combatant_id is required",
details={"field": "combatant_id", "issue": "Missing required field"}
)
try:
combat_service = get_combat_service()
encounter = combat_service.get_combat_state(session_id, user.id)
if encounter:
for combatant in encounter.combatants:
if combatant.is_player:
combatant_id = combatant.combatant_id
break
if not combatant_id:
return validation_error_response(
message="Could not determine player combatant",
details={"field": "combatant_id", "issue": "No player found in combat"}
)
except Exception as e:
logger.error("Failed to auto-detect combatant", error=str(e))
return validation_error_response(
message="combatant_id is required",
details={"field": "combatant_id", "issue": "Missing required field"}
)
if not action_type:
return validation_error_response(
@@ -335,16 +351,21 @@ def execute_action(session_id: str):
try:
combat_service = get_combat_service()
# Support both target_id (singular) and target_ids (array)
target_ids = data.get("target_ids", [])
if not target_ids and data.get("target_id"):
target_ids = [data.get("target_id")]
action = CombatAction(
action_type=action_type,
target_ids=data.get("target_ids", []),
target_ids=target_ids,
ability_id=data.get("ability_id"),
item_id=data.get("item_id"),
)
result = combat_service.execute_action(
session_id=session_id,
user_id=user["user_id"],
user_id=user.id,
combatant_id=combatant_id,
action=action,
)
@@ -367,9 +388,9 @@ def execute_action(session_id: str):
combatant_id=combatant_id,
error=str(e))
return error_response(
status_code=400,
status=400,
message=str(e),
error_code="INVALID_ACTION"
code="INVALID_ACTION"
)
except InsufficientResourceError as e:
logger.warning("Insufficient resources for action",
@@ -377,9 +398,9 @@ def execute_action(session_id: str):
combatant_id=combatant_id,
error=str(e))
return error_response(
status_code=400,
status=400,
message=str(e),
error_code="INSUFFICIENT_RESOURCES"
code="INSUFFICIENT_RESOURCES"
)
except Exception as e:
logger.error("Failed to execute combat action",
@@ -389,9 +410,9 @@ def execute_action(session_id: str):
error=str(e),
exc_info=True)
return error_response(
status_code=500,
status=500,
message="Failed to execute action",
error_code="ACTION_EXECUTION_ERROR"
code="ACTION_EXECUTION_ERROR"
)
@@ -428,7 +449,7 @@ def execute_enemy_turn(session_id: str):
combat_service = get_combat_service()
result = combat_service.execute_enemy_turn(
session_id=session_id,
user_id=user["user_id"],
user_id=user.id,
)
logger.info("Enemy turn executed",
@@ -441,9 +462,9 @@ def execute_enemy_turn(session_id: str):
return not_found_response(message="Session is not in combat")
except InvalidActionError as e:
return error_response(
status_code=400,
status=400,
message=str(e),
error_code="INVALID_ACTION"
code="INVALID_ACTION"
)
except Exception as e:
logger.error("Failed to execute enemy turn",
@@ -451,9 +472,9 @@ def execute_enemy_turn(session_id: str):
error=str(e),
exc_info=True)
return error_response(
status_code=500,
status=500,
message="Failed to execute enemy turn",
error_code="ENEMY_TURN_ERROR"
code="ENEMY_TURN_ERROR"
)
@@ -504,7 +525,7 @@ def attempt_flee(session_id: str):
result = combat_service.execute_action(
session_id=session_id,
user_id=user["user_id"],
user_id=user.id,
combatant_id=combatant_id,
action=action,
)
@@ -515,9 +536,9 @@ def attempt_flee(session_id: str):
return not_found_response(message="Session is not in combat")
except InvalidActionError as e:
return error_response(
status_code=400,
status=400,
message=str(e),
error_code="INVALID_ACTION"
code="INVALID_ACTION"
)
except Exception as e:
logger.error("Failed flee attempt",
@@ -525,9 +546,9 @@ def attempt_flee(session_id: str):
error=str(e),
exc_info=True)
return error_response(
status_code=500,
status=500,
message="Failed to attempt flee",
error_code="FLEE_ERROR"
code="FLEE_ERROR"
)
@@ -577,7 +598,7 @@ def end_combat(session_id: str):
combat_service = get_combat_service()
rewards = combat_service.end_combat(
session_id=session_id,
user_id=user["user_id"],
user_id=user.id,
outcome=outcome,
)
@@ -598,9 +619,9 @@ def end_combat(session_id: str):
error=str(e),
exc_info=True)
return error_response(
status_code=500,
status=500,
message="Failed to end combat",
error_code="COMBAT_END_ERROR"
code="COMBAT_END_ERROR"
)
@@ -680,9 +701,9 @@ def list_enemies():
error=str(e),
exc_info=True)
return error_response(
status_code=500,
status=500,
message="Failed to list enemies",
error_code="ENEMY_LIST_ERROR"
code="ENEMY_LIST_ERROR"
)
@@ -723,7 +744,89 @@ def get_enemy_details(enemy_id: str):
error=str(e),
exc_info=True)
return error_response(
status_code=500,
status=500,
message="Failed to get enemy details",
error_code="ENEMY_DETAILS_ERROR"
code="ENEMY_DETAILS_ERROR"
)
# =============================================================================
# Debug Endpoints
# =============================================================================
@combat_bp.route('/<session_id>/debug/reset-hp-mp', methods=['POST'])
@require_auth
def debug_reset_hp_mp(session_id: str):
"""
Reset player combatant's HP and MP to full (debug endpoint).
This is a debug-only endpoint for testing combat without using items.
Resets the player's current_hp to max_hp and current_mp to max_mp.
Path Parameters:
session_id: Game session ID
Returns:
{
"success": true,
"message": "HP and MP reset to full",
"current_hp": 100,
"max_hp": 100,
"current_mp": 50,
"max_mp": 50
}
Errors:
404: Session not in combat
"""
from app.services.session_service import get_session_service
user = get_current_user()
try:
session_service = get_session_service()
session = session_service.get_session(session_id, user.id)
if not session or not session.combat_encounter:
return not_found_response(message="Session is not in combat")
encounter = session.combat_encounter
# Find player combatant and reset HP/MP
player_combatant = None
for combatant in encounter.combatants:
if combatant.is_player:
combatant.current_hp = combatant.max_hp
combatant.current_mp = combatant.max_mp
player_combatant = combatant
break
if not player_combatant:
return not_found_response(message="No player combatant found in combat")
# Save the updated session state
session_service.update_session(session)
logger.info("Debug: HP/MP reset",
session_id=session_id,
combatant_id=player_combatant.combatant_id)
return success_response({
"success": True,
"message": "HP and MP reset to full",
"current_hp": player_combatant.current_hp,
"max_hp": player_combatant.max_hp,
"current_mp": player_combatant.current_mp,
"max_mp": player_combatant.max_mp,
})
except Exception as e:
logger.error("Failed to reset HP/MP",
session_id=session_id,
error=str(e),
exc_info=True)
return error_response(
status=500,
message="Failed to reset HP/MP",
code="DEBUG_RESET_ERROR"
)

View File

@@ -132,23 +132,44 @@ def list_sessions():
user = get_current_user()
user_id = user.id
session_service = get_session_service()
character_service = get_character_service()
# Get user's active sessions
sessions = session_service.get_user_sessions(user_id, active_only=True)
# Build character name lookup for efficiency
character_ids = [s.solo_character_id for s in sessions if s.solo_character_id]
character_names = {}
for char_id in character_ids:
try:
char = character_service.get_character(char_id, user_id)
if char:
character_names[char_id] = char.name
except Exception:
pass # Character may have been deleted
# Build response with basic session info
sessions_list = []
for session in sessions:
# Get combat round if in combat
combat_round = None
if session.is_in_combat() and session.combat_encounter:
combat_round = session.combat_encounter.round_number
sessions_list.append({
'session_id': session.session_id,
'character_id': session.solo_character_id,
'character_name': character_names.get(session.solo_character_id),
'turn_number': session.turn_number,
'status': session.status.value,
'created_at': session.created_at,
'last_activity': session.last_activity,
'in_combat': session.is_in_combat(),
'game_state': {
'current_location': session.game_state.current_location,
'location_type': session.game_state.location_type.value
'location_type': session.game_state.location_type.value,
'in_combat': session.is_in_combat(),
'combat_round': combat_round
}
})
@@ -485,10 +506,12 @@ def get_session_state(session_id: str):
"character_id": session.get_character_id(),
"turn_number": session.turn_number,
"status": session.status.value,
"in_combat": session.is_in_combat(),
"game_state": {
"current_location": session.game_state.current_location,
"location_type": session.game_state.location_type.value,
"active_quests": session.game_state.active_quests
"active_quests": session.game_state.active_quests,
"in_combat": session.is_in_combat()
},
"available_actions": available_actions
})

View File

@@ -349,14 +349,32 @@ class CombatEncounter:
return None
def advance_turn(self) -> None:
"""Advance to the next combatant's turn."""
self.current_turn_index += 1
"""Advance to the next alive combatant's turn, skipping dead combatants."""
# Track starting position to detect full cycle
start_index = self.current_turn_index
rounds_advanced = 0
# If we've cycled through all combatants, start a new round
if self.current_turn_index >= len(self.turn_order):
self.current_turn_index = 0
self.round_number += 1
self.log_action("round_start", None, f"Round {self.round_number} begins")
while True:
self.current_turn_index += 1
# If we've cycled through all combatants, start a new round
if self.current_turn_index >= len(self.turn_order):
self.current_turn_index = 0
self.round_number += 1
rounds_advanced += 1
self.log_action("round_start", None, f"Round {self.round_number} begins")
# Get the current combatant
current = self.get_current_combatant()
# If combatant is alive, their turn starts
if current and current.is_alive():
break
# Safety check: if we've gone through all combatants twice without finding
# someone alive, break to avoid infinite loop (combat should end)
if rounds_advanced >= 2:
break
def start_turn(self) -> List[Dict[str, Any]]:
"""

View File

@@ -167,7 +167,8 @@ class GameSession:
user_id: Owner of the session
party_member_ids: Character IDs in this party (multiplayer only)
config: Session configuration settings
combat_encounter: Current combat (None if not in combat)
combat_encounter: Legacy inline combat data (None if not in combat)
active_combat_encounter_id: Reference to combat_encounters table (new system)
conversation_history: Turn-by-turn log of actions and DM responses
game_state: Current world/quest state
turn_order: Character turn order
@@ -184,7 +185,8 @@ class GameSession:
user_id: str = ""
party_member_ids: List[str] = field(default_factory=list)
config: SessionConfig = field(default_factory=SessionConfig)
combat_encounter: Optional[CombatEncounter] = None
combat_encounter: Optional[CombatEncounter] = None # Legacy: inline combat data
active_combat_encounter_id: Optional[str] = None # New: reference to combat_encounters table
conversation_history: List[ConversationEntry] = field(default_factory=list)
game_state: GameState = field(default_factory=GameState)
turn_order: List[str] = field(default_factory=list)
@@ -202,8 +204,13 @@ class GameSession:
self.last_activity = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
def is_in_combat(self) -> bool:
"""Check if session is currently in combat."""
return self.combat_encounter is not None
"""
Check if session is currently in combat.
Checks both the new database reference and legacy inline storage
for backward compatibility.
"""
return self.active_combat_encounter_id is not None or self.combat_encounter is not None
def start_combat(self, encounter: CombatEncounter) -> None:
"""
@@ -341,6 +348,7 @@ class GameSession:
"party_member_ids": self.party_member_ids,
"config": self.config.to_dict(),
"combat_encounter": self.combat_encounter.to_dict() if self.combat_encounter else None,
"active_combat_encounter_id": self.active_combat_encounter_id,
"conversation_history": [entry.to_dict() for entry in self.conversation_history],
"game_state": self.game_state.to_dict(),
"turn_order": self.turn_order,
@@ -382,6 +390,7 @@ class GameSession:
party_member_ids=data.get("party_member_ids", []),
config=config,
combat_encounter=combat_encounter,
active_combat_encounter_id=data.get("active_combat_encounter_id"),
conversation_history=conversation_history,
game_state=game_state,
turn_order=data.get("turn_order", []),

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

View File

@@ -0,0 +1,144 @@
"""
Combat Cleanup Tasks.
This module provides scheduled tasks for cleaning up ended combat
encounters that are older than the retention period.
The cleanup can be scheduled to run periodically (daily recommended)
via APScheduler, cron, or manual invocation.
Usage:
# Manual invocation
from app.tasks.combat_cleanup import cleanup_old_combat_encounters
result = cleanup_old_combat_encounters(older_than_days=7)
# Via APScheduler
scheduler.add_job(
cleanup_old_combat_encounters,
'interval',
days=1,
kwargs={'older_than_days': 7}
)
"""
from typing import Dict, Any
from app.services.combat_repository import get_combat_repository
from app.utils.logging import get_logger
logger = get_logger(__file__)
# Default retention period in days
DEFAULT_RETENTION_DAYS = 7
def cleanup_old_combat_encounters(
older_than_days: int = DEFAULT_RETENTION_DAYS
) -> Dict[str, Any]:
"""
Delete ended combat encounters older than specified days.
This is the main cleanup function for time-based retention.
Should be scheduled to run periodically (daily recommended).
Only deletes ENDED encounters (victory, defeat, fled) - active
encounters are never deleted.
Args:
older_than_days: Number of days after which to delete ended combats.
Default is 7 days.
Returns:
Dict containing:
- deleted_encounters: Number of encounters deleted
- deleted_rounds: Approximate rounds deleted (cascaded)
- older_than_days: The threshold used
- success: Whether the operation completed successfully
- error: Error message if failed
Example:
>>> result = cleanup_old_combat_encounters(older_than_days=7)
>>> print(f"Deleted {result['deleted_encounters']} encounters")
"""
logger.info("Starting combat encounter cleanup",
older_than_days=older_than_days)
try:
repo = get_combat_repository()
deleted_count = repo.delete_old_encounters(older_than_days)
result = {
"deleted_encounters": deleted_count,
"older_than_days": older_than_days,
"success": True,
"error": None
}
logger.info("Combat encounter cleanup completed successfully",
deleted_count=deleted_count,
older_than_days=older_than_days)
return result
except Exception as e:
logger.error("Combat encounter cleanup failed",
error=str(e),
older_than_days=older_than_days)
return {
"deleted_encounters": 0,
"older_than_days": older_than_days,
"success": False,
"error": str(e)
}
def cleanup_encounters_for_session(session_id: str) -> Dict[str, Any]:
"""
Delete all combat encounters for a specific session.
Call this when a session is being deleted to clean up
associated combat data.
Args:
session_id: The session ID to clean up
Returns:
Dict containing:
- deleted_encounters: Number of encounters deleted
- session_id: The session ID processed
- success: Whether the operation completed successfully
- error: Error message if failed
"""
logger.info("Cleaning up combat encounters for session",
session_id=session_id)
try:
repo = get_combat_repository()
deleted_count = repo.delete_encounters_by_session(session_id)
result = {
"deleted_encounters": deleted_count,
"session_id": session_id,
"success": True,
"error": None
}
logger.info("Session combat cleanup completed",
session_id=session_id,
deleted_count=deleted_count)
return result
except Exception as e:
logger.error("Session combat cleanup failed",
session_id=session_id,
error=str(e))
return {
"deleted_encounters": 0,
"session_id": session_id,
"success": False,
"error": str(e)
}