From dd92cf59918ce304c4b664e8a151e0b6c3fca9a8 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Thu, 27 Nov 2025 20:37:53 -0600 Subject: [PATCH] combat testing and polishing in the dev console, many bug fixes --- api/app/api/combat.py | 177 +- api/app/api/sessions.py | 27 +- api/app/models/combat.py | 32 +- api/app/models/session.py | 17 +- api/app/services/character_service.py | 6 +- api/app/services/combat_repository.py | 578 ++++++ api/app/services/combat_service.py | 236 ++- api/app/services/database_init.py | 338 ++++ api/app/services/session_service.py | 6 +- api/app/tasks/combat_cleanup.py | 144 ++ api/docs/API_REFERENCE.md | 1564 ++++++----------- api/scripts/migrate_combat_data.py | 245 +++ docs/PHASE4_COMBAT_IMPLEMENTATION.md | 8 +- public_web/app/__init__.py | 4 +- public_web/app/views/combat_views.py | 561 ++++++ public_web/app/views/dev.py | 649 +++++++ public_web/app/views/game_views.py | 216 ++- public_web/static/css/combat.css | 1178 +++++++++++++ public_web/static/css/inventory.css | 722 ++++++++ public_web/static/img/items/armor.svg | 7 + public_web/static/img/items/consumable.svg | 14 + public_web/static/img/items/default.svg | 7 + public_web/static/img/items/quest_item.svg | 12 + public_web/static/img/items/weapon.svg | 10 + public_web/templates/dev/combat.html | 337 ++++ public_web/templates/dev/combat_session.html | 864 +++++++++ public_web/templates/dev/index.html | 8 + .../templates/dev/partials/ability_modal.html | 62 + .../dev/partials/combat_debug_log.html | 19 + .../templates/dev/partials/combat_defeat.html | 32 + .../dev/partials/combat_items_sheet.html | 88 + .../templates/dev/partials/combat_state.html | 84 + .../dev/partials/combat_victory.html | 68 + public_web/templates/game/combat.html | 258 +++ .../game/partials/ability_modal.html | 61 + .../game/partials/character_panel.html | 13 +- .../game/partials/combat_actions.html | 87 + .../game/partials/combat_defeat.html | 55 + .../game/partials/combat_items_sheet.html | 52 + .../templates/game/partials/combat_log.html | 25 + .../game/partials/combat_victory.html | 84 + .../game/partials/inventory_item_detail.html | 118 ++ .../game/partials/inventory_modal.html | 138 ++ .../templates/game/partials/item_modal.html | 51 + public_web/templates/game/play.html | 1 + 45 files changed, 8157 insertions(+), 1106 deletions(-) create mode 100644 api/app/services/combat_repository.py create mode 100644 api/app/tasks/combat_cleanup.py create mode 100644 api/scripts/migrate_combat_data.py create mode 100644 public_web/app/views/combat_views.py create mode 100644 public_web/static/css/combat.css create mode 100644 public_web/static/css/inventory.css create mode 100644 public_web/static/img/items/armor.svg create mode 100644 public_web/static/img/items/consumable.svg create mode 100644 public_web/static/img/items/default.svg create mode 100644 public_web/static/img/items/quest_item.svg create mode 100644 public_web/static/img/items/weapon.svg create mode 100644 public_web/templates/dev/combat.html create mode 100644 public_web/templates/dev/combat_session.html create mode 100644 public_web/templates/dev/partials/ability_modal.html create mode 100644 public_web/templates/dev/partials/combat_debug_log.html create mode 100644 public_web/templates/dev/partials/combat_defeat.html create mode 100644 public_web/templates/dev/partials/combat_items_sheet.html create mode 100644 public_web/templates/dev/partials/combat_state.html create mode 100644 public_web/templates/dev/partials/combat_victory.html create mode 100644 public_web/templates/game/combat.html create mode 100644 public_web/templates/game/partials/ability_modal.html create mode 100644 public_web/templates/game/partials/combat_actions.html create mode 100644 public_web/templates/game/partials/combat_defeat.html create mode 100644 public_web/templates/game/partials/combat_items_sheet.html create mode 100644 public_web/templates/game/partials/combat_log.html create mode 100644 public_web/templates/game/partials/combat_victory.html create mode 100644 public_web/templates/game/partials/inventory_item_detail.html create mode 100644 public_web/templates/game/partials/inventory_modal.html create mode 100644 public_web/templates/game/partials/item_modal.html diff --git a/api/app/api/combat.py b/api/app/api/combat.py index 7ae197c..e29c2d5 100644 --- a/api/app/api/combat.py +++ b/api/app/api/combat.py @@ -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('//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" ) diff --git a/api/app/api/sessions.py b/api/app/api/sessions.py index cdd662a..c3fd4d6 100644 --- a/api/app/api/sessions.py +++ b/api/app/api/sessions.py @@ -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 }) diff --git a/api/app/models/combat.py b/api/app/models/combat.py index 0565608..5f9e65a 100644 --- a/api/app/models/combat.py +++ b/api/app/models/combat.py @@ -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]]: """ diff --git a/api/app/models/session.py b/api/app/models/session.py index cab046c..bb64a1c 100644 --- a/api/app/models/session.py +++ b/api/app/models/session.py @@ -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", []), diff --git a/api/app/services/character_service.py b/api/app/services/character_service.py index e97e9b5..50f1d2b 100644 --- a/api/app/services/character_service.py +++ b/api/app/services/character_service.py @@ -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} ) diff --git a/api/app/services/combat_repository.py b/api/app/services/combat_repository.py new file mode 100644 index 0000000..a3cc9b5 --- /dev/null +++ b/api/app/services/combat_repository.py @@ -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 diff --git a/api/app/services/combat_service.py b/api/app/services/combat_service.py index 957c289..6c3342f 100644 --- a/api/app/services/combat_service.py +++ b/api/app/services/combat_service.py @@ -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 diff --git a/api/app/services/database_init.py b/api/app/services/database_init.py index f86e7ae..7a618e6 100644 --- a/api/app/services/database_init.py +++ b/api/app/services/database_init.py @@ -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, diff --git a/api/app/services/session_service.py b/api/app/services/session_service.py index 5ecd427..3383ff5 100644 --- a/api/app/services/session_service.py +++ b/api/app/services/session_service.py @@ -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 diff --git a/api/app/tasks/combat_cleanup.py b/api/app/tasks/combat_cleanup.py new file mode 100644 index 0000000..8d4ce49 --- /dev/null +++ b/api/app/tasks/combat_cleanup.py @@ -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) + } diff --git a/api/docs/API_REFERENCE.md b/api/docs/API_REFERENCE.md index 1d2f611..9a6140b 100644 --- a/api/docs/API_REFERENCE.md +++ b/api/docs/API_REFERENCE.md @@ -4,10 +4,10 @@ All API responses follow standardized format: ```json { - "app": "AI Dungeon Master", - "version": "1.0.0", + "app": "Code of Conquest", + "version": "0.1.0", "status": 200, - "timestamp": "2025-11-14T12:00:00Z", + "timestamp": "2025-11-27T12:00:00Z", "request_id": "optional-request-id", "result": {}, "error": null, @@ -203,21 +203,7 @@ Set-Cookie: coc_session=; HttpOnly; Secure; SameSite=Lax; Max-Age } ``` -### Reset Password (Display Form) - -| | | -|---|---| -| **Endpoint** | `GET /api/v1/auth/reset-password` | -| **Description** | Display password reset form | -| **Auth Required** | No | - -**Query Parameters:** -- `userId` - User ID from reset email -- `secret` - Reset secret from email - -**Success:** Renders password reset form - -### Reset Password (Submit) +### Reset Password | | | |---|---| @@ -439,25 +425,6 @@ Set-Cookie: coc_session=; HttpOnly; Secure; SameSite=Lax; Max-Age } ``` -**Error Response (400 Bad Request - Validation Error):** -```json -{ - "app": "Code of Conquest", - "version": "0.1.0", - "status": 400, - "timestamp": "2025-11-15T12:00:00Z", - "result": null, - "error": { - "code": "INVALID_INPUT", - "message": "Validation failed", - "details": { - "name": "Character name must be at least 2 characters", - "class_id": "Invalid class ID. Must be one of: vanguard, assassin, arcanist, luminary, wildstrider, oathkeeper, necromancer, lorekeeper" - } - } -} -``` - ### Delete Character | | | @@ -512,38 +479,6 @@ Set-Cookie: coc_session=; HttpOnly; Secure; SameSite=Lax; Max-Age } ``` -**Error Response (400 Bad Request - Prerequisites Not Met):** -```json -{ - "app": "Code of Conquest", - "version": "0.1.0", - "status": 400, - "timestamp": "2025-11-15T12:00:00Z", - "result": null, - "error": { - "code": "SKILL_UNLOCK_ERROR", - "message": "Prerequisite not met: iron_defense required for fortified_resolve", - "details": {} - } -} -``` - -**Error Response (400 Bad Request - No Skill Points):** -```json -{ - "app": "Code of Conquest", - "version": "0.1.0", - "status": 400, - "timestamp": "2025-11-15T12:00:00Z", - "result": null, - "error": { - "code": "SKILL_UNLOCK_ERROR", - "message": "No skill points available (Level 1, 1 skills unlocked)", - "details": {} - } -} -``` - ### Respec Skills | | | @@ -569,22 +504,6 @@ Set-Cookie: coc_session=; HttpOnly; Secure; SameSite=Lax; Max-Age } ``` -**Error Response (400 Bad Request - Insufficient Gold):** -```json -{ - "app": "Code of Conquest", - "version": "0.1.0", - "status": 400, - "timestamp": "2025-11-15T12:00:00Z", - "result": null, - "error": { - "code": "INSUFFICIENT_GOLD", - "message": "Insufficient gold for respec. Cost: 500, Available: 100", - "details": {} - } -} -``` - --- ## Classes & Origins (Reference Data) @@ -621,22 +540,6 @@ Set-Cookie: coc_session=; HttpOnly; Secure; SameSite=Lax; Max-Age "skill_trees": ["Shield Bearer", "Weapon Master"], "starting_equipment": ["rusty_sword"], "starting_abilities": ["basic_attack"] - }, - { - "class_id": "assassin", - "name": "Assassin", - "description": "A master of stealth and precision...", - "base_stats": { - "strength": 11, - "dexterity": 15, - "constitution": 10, - "intelligence": 9, - "wisdom": 10, - "charisma": 10 - }, - "skill_trees": ["Shadow Dancer", "Blade Specialist"], - "starting_equipment": ["rusty_dagger"], - "starting_abilities": ["basic_attack"] } ], "count": 8 @@ -689,12 +592,6 @@ Set-Cookie: coc_session=; HttpOnly; Secure; SameSite=Lax; Max-Age "unlocks_abilities": ["shield_bash"] } ] - }, - { - "tree_id": "weapon_master", - "name": "Weapon Master", - "description": "Offensive damage specialization", - "nodes": [] } ], "starting_equipment": ["rusty_sword"], @@ -703,22 +600,6 @@ Set-Cookie: coc_session=; HttpOnly; Secure; SameSite=Lax; Max-Age } ``` -**Error Response (404 Not Found):** -```json -{ - "app": "Code of Conquest", - "version": "0.1.0", - "status": 404, - "timestamp": "2025-11-15T12:00:00Z", - "result": null, - "error": { - "code": "NOT_FOUND", - "message": "Class not found: invalid_class", - "details": {} - } -} -``` - ### List Origins | | | @@ -748,11 +629,7 @@ Set-Cookie: coc_session=; HttpOnly; Secure; SameSite=Lax; Max-Age }, "narrative_hooks": [ "Why were you brought back to life?", - "What happened in the centuries you were dead?", - "Do you remember your past life?", - "Who or what resurrected you?", - "Are there others like you?", - "What is your purpose now?" + "What happened in the centuries you were dead?" ], "starting_bonus": { "trait": "Deathless Resolve", @@ -768,6 +645,187 @@ Set-Cookie: coc_session=; HttpOnly; Secure; SameSite=Lax; Max-Age --- +## Inventory + +### Get Inventory + +| | | +|---|---| +| **Endpoint** | `GET /api/v1/characters//inventory` | +| **Description** | Get character inventory and equipped items | +| **Auth Required** | Yes | + +**Response (200 OK):** +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-27T12:00:00Z", + "result": { + "inventory": [ + { + "item_id": "gen_abc123", + "name": "Flaming Dagger", + "item_type": "weapon", + "rarity": "rare", + "value": 250, + "description": "A dagger imbued with fire magic" + } + ], + "equipped": { + "weapon": { + "item_id": "rusty_sword", + "name": "Rusty Sword", + "item_type": "weapon", + "rarity": "common" + }, + "helmet": null, + "chest": null, + "legs": null, + "boots": null, + "gloves": null, + "ring1": null, + "ring2": null, + "amulet": null + }, + "inventory_count": 5, + "max_inventory": 100 + } +} +``` + +### Equip Item + +| | | +|---|---| +| **Endpoint** | `POST /api/v1/characters//inventory/equip` | +| **Description** | Equip an item from inventory to a specified slot | +| **Auth Required** | Yes | + +**Request Body:** +```json +{ + "item_id": "gen_abc123", + "slot": "weapon" +} +``` + +**Response (200 OK):** +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-27T12:00:00Z", + "result": { + "message": "Equipped Flaming Dagger to weapon slot", + "equipped": { + "weapon": {...}, + "helmet": null + }, + "unequipped_item": { + "item_id": "rusty_sword", + "name": "Rusty Sword" + } + } +} +``` + +### Unequip Item + +| | | +|---|---| +| **Endpoint** | `POST /api/v1/characters//inventory/unequip` | +| **Description** | Unequip an item from a specified slot (returns to inventory) | +| **Auth Required** | Yes | + +**Request Body:** +```json +{ + "slot": "weapon" +} +``` + +**Response (200 OK):** +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-27T12:00:00Z", + "result": { + "message": "Unequipped Flaming Dagger from weapon slot", + "unequipped_item": {...}, + "equipped": {...} + } +} +``` + +### Use Item + +| | | +|---|---| +| **Endpoint** | `POST /api/v1/characters//inventory/use` | +| **Description** | Use a consumable item from inventory | +| **Auth Required** | Yes | + +**Request Body:** +```json +{ + "item_id": "health_potion_small" +} +``` + +**Response (200 OK):** +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-27T12:00:00Z", + "result": { + "item_used": "Small Health Potion", + "effects_applied": [ + { + "effect_name": "Healing", + "effect_type": "hot", + "value": 25, + "message": "Restored 25 HP" + } + ], + "hp_restored": 25, + "mp_restored": 0, + "message": "Used Small Health Potion: Restored 25 HP" + } +} +``` + +### Drop Item + +| | | +|---|---| +| **Endpoint** | `DELETE /api/v1/characters//inventory/` | +| **Description** | Drop (remove) an item from inventory | +| **Auth Required** | Yes | + +**Response (200 OK):** +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-27T12:00:00Z", + "result": { + "message": "Dropped Rusty Sword", + "dropped_item": {...}, + "inventory_count": 4 + } +} +``` + +--- + ## Health ### Health Check @@ -818,25 +876,23 @@ Set-Cookie: coc_session=; HttpOnly; Secure; SameSite=Lax; Max-Age { "session_id": "sess_789", "character_id": "char_456", + "character_name": "Thorin", "turn_number": 5, "status": "active", "created_at": "2025-11-16T10:00:00Z", "last_activity": "2025-11-16T10:25:00Z", + "in_combat": false, "game_state": { "current_location": "crossville_village", - "location_type": "town" + "location_type": "town", + "in_combat": false, + "combat_round": null } } ] } ``` -**Error Responses:** -- `401` - Not authenticated -- `500` - Internal server error - ---- - ### Create Session | | | @@ -872,11 +928,6 @@ Set-Cookie: coc_session=; HttpOnly; Secure; SameSite=Lax; Max-Age } ``` -**Error Responses:** -- `400` - Validation error (missing character_id) -- `404` - Character not found -- `409` - Session limit exceeded (tier-based limit) - **Session Limits by Tier:** | Tier | Max Active Sessions | |------|---------------------| @@ -885,23 +936,6 @@ Set-Cookie: coc_session=; HttpOnly; Secure; SameSite=Lax; Max-Age | PREMIUM | 3 | | ELITE | 5 | -**Error Response (409 Conflict - Session Limit Exceeded):** -```json -{ - "app": "Code of Conquest", - "version": "0.1.0", - "status": 409, - "timestamp": "2025-11-16T10:30:00Z", - "result": null, - "error": { - "code": "SESSION_LIMIT_EXCEEDED", - "message": "Maximum active sessions reached for free tier (1/1). Please delete an existing session to start a new one." - } -} -``` - ---- - ### Get Session State | | | @@ -922,10 +956,12 @@ Set-Cookie: coc_session=; HttpOnly; Secure; SameSite=Lax; Max-Age "character_id": "char_456", "turn_number": 5, "status": "active", + "in_combat": false, "game_state": { "current_location": "The Rusty Anchor", "location_type": "tavern", - "active_quests": ["quest_goblin_cave"] + "active_quests": ["quest_goblin_cave"], + "in_combat": false }, "available_actions": [ { @@ -945,11 +981,6 @@ Set-Cookie: coc_session=; HttpOnly; Secure; SameSite=Lax; Max-Age } ``` -**Error Responses:** -- `404` - Session not found or not owned by user - ---- - ### Take Action | | | @@ -985,31 +1016,6 @@ Set-Cookie: coc_session=; HttpOnly; Secure; SameSite=Lax; Max-Age - Only button actions with predefined prompts are supported - Poll `/api/v1/jobs//status` to check processing status - Rate limits apply based on subscription tier -- Available actions depend on user tier and current location - -**Error Responses:** -- `400` - Validation error (invalid action_type, missing prompt_id) -- `403` - Action not available for tier/location -- `404` - Session not found -- `429` - Rate limit exceeded - -**Rate Limit Error Response (429):** -```json -{ - "app": "Code of Conquest", - "version": "0.1.0", - "status": 429, - "timestamp": "2025-11-16T10:30:00Z", - "result": null, - "error": { - "code": "RATE_LIMIT_EXCEEDED", - "message": "Daily turn limit reached (20 turns). Resets at 00:00 UTC", - "details": {} - } -} -``` - ---- ### Get Job Status @@ -1050,26 +1056,6 @@ Set-Cookie: coc_session=; HttpOnly; Secure; SameSite=Lax; Max-Age } ``` -**Response (200 OK - Failed):** -```json -{ - "app": "Code of Conquest", - "version": "0.1.0", - "status": 200, - "timestamp": "2025-11-16T10:30:10Z", - "result": { - "job_id": "ai_TaskType.NARRATIVE_abc123", - "status": "failed", - "error": "AI generation failed" - } -} -``` - -**Error Responses:** -- `404` - Job not found - ---- - ### Get Conversation History | | | @@ -1097,12 +1083,6 @@ Set-Cookie: coc_session=; HttpOnly; Secure; SameSite=Lax; Max-Age "action": "I explore the tavern", "dm_response": "You enter a smoky tavern filled with weary travelers...", "timestamp": "2025-11-16T10:30:00Z" - }, - { - "turn": 2, - "action": "Ask locals for information", - "dm_response": "A grizzled dwarf at the bar tells you about goblin raids...", - "timestamp": "2025-11-16T10:32:00Z" } ], "pagination": { @@ -1114,11 +1094,6 @@ Set-Cookie: coc_session=; HttpOnly; Secure; SameSite=Lax; Max-Age } ``` -**Error Responses:** -- `404` - Session not found - ---- - ### Delete Session | | | @@ -1127,12 +1102,6 @@ Set-Cookie: coc_session=; HttpOnly; Secure; SameSite=Lax; Max-Age | **Description** | Permanently delete a session and all associated chat messages | | **Auth Required** | Yes | -**Behavior:** -- Permanently removes the session from the database (hard delete) -- Also deletes all chat messages associated with this session -- Frees up the session slot for the user's tier limit -- Cannot be undone - **Response (200 OK):** ```json { @@ -1147,38 +1116,38 @@ Set-Cookie: coc_session=; HttpOnly; Secure; SameSite=Lax; Max-Age } ``` -**Error Responses:** -- `401` - Not authenticated -- `404` - Session not found or not owned by user -- `500` - Internal server error - ---- - -### Export Session +### Get Usage Information | | | |---|---| -| **Endpoint** | `GET /api/v1/sessions//export` | -| **Description** | Export session log as Markdown | +| **Endpoint** | `GET /api/v1/usage` | +| **Description** | Get user's daily usage information (turn limits) | | **Auth Required** | Yes | -**Response:** -```markdown -# Session Log: sess_789 -**Date:** 2025-11-14 -**Character:** Aragorn the Brave - -## Turn 1 -**Action:** I explore the tavern -**DM:** You enter a smoky tavern... +**Response (200 OK):** +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-27T12:00:00Z", + "result": { + "user_id": "user_123", + "user_tier": "free", + "current_usage": 15, + "daily_limit": 50, + "remaining": 35, + "reset_time": "2025-11-27T00:00:00+00:00", + "is_limited": false, + "is_unlimited": false + } +} ``` --- ## Travel -The Travel API enables location-based world exploration. Locations are defined in YAML files and players can travel to any unlocked (discovered) location. - ### Get Available Destinations | | | @@ -1206,24 +1175,12 @@ The Travel API enables location-based world exploration. Locations are defined i "location_type": "tavern", "region_id": "crossville", "description": "A cozy tavern where travelers share tales..." - }, - { - "location_id": "crossville_forest", - "name": "Whispering Woods", - "location_type": "wilderness", - "region_id": "crossville", - "description": "A dense forest on the outskirts of town..." } ] } } ``` -**Error Responses:** -- `400` - Missing session_id parameter -- `404` - Session or character not found -- `500` - Internal server error - ### Travel to Location | | | @@ -1279,12 +1236,6 @@ The Travel API enables location-based world exploration. Locations are defined i } ``` -**Error Responses:** -- `400` - Location not discovered -- `403` - Location not discovered -- `404` - Session or location not found -- `500` - Internal server error - ### Get Location Details | | | @@ -1301,42 +1252,18 @@ The Travel API enables location-based world exploration. Locations are defined i "status": 200, "timestamp": "2025-11-25T10:30:00Z", "result": { - "location": { - "location_id": "crossville_village", - "name": "Crossville Village", - "location_type": "town", - "region_id": "crossville", - "description": "A modest farming village built around a central square...", - "lore": "Founded two centuries ago by settlers from the eastern kingdoms...", - "ambient_description": "The village square bustles with activity...", - "available_quests": ["quest_mayors_request"], - "npc_ids": ["npc_mayor_aldric", "npc_blacksmith_hilda"], - "discoverable_locations": ["crossville_tavern", "crossville_forest"], - "is_starting_location": true, - "tags": ["town", "social", "merchant", "safe"] - }, - "npcs_present": [ - { - "npc_id": "npc_mayor_aldric", - "name": "Mayor Aldric", - "role": "village mayor", - "appearance": "A portly man in fine robes" - } - ] + "location": {...}, + "npcs_present": [...] } } ``` -**Error Responses:** -- `404` - Location not found -- `500` - Internal server error - ### Get Current Location | | | |---|---| | **Endpoint** | `GET /api/v1/travel/current` | -| **Description** | Get current location details with NPCs present | +| **Description** | Get details about the current location in a session | | **Auth Required** | Yes | **Query Parameters:** @@ -1350,24 +1277,8 @@ The Travel API enables location-based world exploration. Locations are defined i "status": 200, "timestamp": "2025-11-25T10:30:00Z", "result": { - "location": { - "location_id": "crossville_village", - "name": "Crossville Village", - "location_type": "town", - "description": "A modest farming village..." - }, - "npcs_present": [ - { - "npc_id": "npc_mayor_aldric", - "name": "Mayor Aldric", - "role": "village mayor" - }, - { - "npc_id": "npc_blacksmith_hilda", - "name": "Hilda Ironforge", - "role": "blacksmith" - } - ] + "location": {...}, + "npcs_present": [...] } } ``` @@ -1376,8 +1287,6 @@ The Travel API enables location-based world exploration. Locations are defined i ## NPCs -The NPC API enables interaction with persistent NPCs. NPCs have personalities, knowledge, and relationships that affect dialogue generation. - ### Get NPC Details | | | @@ -1449,11 +1358,6 @@ The NPC API enables interaction with persistent NPCs. NPCs have personalities, k } ``` -**Parameters:** -- `session_id` (required): Active game session ID -- `topic` (optional): Conversation topic for initial greeting (default: "greeting") -- `player_response` (optional): Player's custom message to the NPC. If provided, overrides `topic`. Enables bidirectional conversation. - **Response (202 Accepted):** ```json { @@ -1488,31 +1392,12 @@ The NPC API enables interaction with persistent NPCs. NPCs have personalities, k { "player_line": "Hello there!", "npc_response": "*nods gruffly* \"Welcome to the Rusty Anchor.\"" - }, - { - "player_line": "What's the news around town?", - "npc_response": "*leans in* \"Strange folk been coming through lately...\"" } ] } } ``` -**Conversation History:** -- Previous dialogue exchanges are automatically stored per character-NPC pair -- Up to 10 exchanges are kept per NPC (oldest are pruned) -- The AI receives the last 3 exchanges as context for continuity -- The job result includes prior `conversation_history` for UI display - -**Bidirectional Dialogue:** -- If `player_response` is provided in the request, it overrides `topic` and enables full bidirectional conversation -- The player's response is stored in the conversation history -- The NPC's reply takes into account the full conversation context - -**Error Responses:** -- `400` - NPC not at current location -- `404` - NPC or session not found - ### Get NPCs at Location | | | @@ -1538,14 +1423,6 @@ The NPC API enables interaction with persistent NPCs. NPCs have personalities, k "appearance": "Stout dwarf with a braided grey beard", "tags": ["merchant", "quest_giver"], "image_url": "/static/images/npcs/crossville/grom_ironbeard.png" - }, - { - "npc_id": "npc_mira_swiftfoot", - "name": "Mira Swiftfoot", - "role": "traveling rogue", - "appearance": "Lithe half-elf with sharp eyes", - "tags": ["information", "secret_keeper"], - "image_url": "/static/images/npcs/crossville/mira_swiftfoot.png" } ] } @@ -1618,7 +1495,7 @@ The NPC API enables interaction with persistent NPCs. NPCs have personalities, k ## Chat / Conversation History -The Chat API provides access to complete player-NPC conversation history. All dialogue exchanges are stored in the `chat_messages` collection for unlimited history, with the most recent 3 messages cached in character documents for quick AI context. +The Chat API provides access to complete player-NPC conversation history. All dialogue exchanges are stored in the `chat_messages` collection for unlimited history. ### Get All Conversations Summary @@ -1643,13 +1520,6 @@ The Chat API provides access to complete player-NPC conversation history. All di "last_message_timestamp": "2025-11-25T14:30:00Z", "message_count": 15, "recent_preview": "Aye, the rats in the cellar have been causing trouble..." - }, - { - "npc_id": "npc_mira_swiftfoot", - "npc_name": "Mira Swiftfoot", - "last_message_timestamp": "2025-11-25T12:15:00Z", - "message_count": 8, - "recent_preview": "*leans in and whispers* I've heard rumors about the mayor..." } ] } @@ -1692,19 +1562,6 @@ The Chat API provides access to complete player-NPC conversation history. All di "session_id": "sess_789", "metadata": {}, "is_deleted": false - }, - { - "message_id": "msg_abc122", - "character_id": "char_123", - "npc_id": "npc_grom_ironbeard", - "player_message": "Hello there!", - "npc_response": "*nods gruffly* Welcome to the Rusty Anchor.", - "timestamp": "2025-11-25T14:25:00Z", - "context": "dialogue", - "location_id": "crossville_tavern", - "session_id": "sess_789", - "metadata": {}, - "is_deleted": false } ], "pagination": { @@ -1716,14 +1573,6 @@ The Chat API provides access to complete player-NPC conversation history. All di } ``` -**Message Context Types:** -- `dialogue` - General conversation -- `quest_offered` - Quest offering dialogue -- `quest_completed` - Quest completion dialogue -- `shop` - Merchant transaction -- `location_revealed` - New location discovered -- `lore` - Lore/backstory reveals - ### Search Messages | | | @@ -1736,16 +1585,11 @@ The Chat API provides access to complete player-NPC conversation history. All di - `q` (required): Search text to find in player_message and npc_response - `npc_id` (optional): Filter by specific NPC - `context` (optional): Filter by message context type -- `date_from` (optional): Start date in ISO format (e.g., 2025-11-25T00:00:00Z) +- `date_from` (optional): Start date in ISO format - `date_to` (optional): End date in ISO format - `limit` (optional): Maximum messages to return (default: 50, max: 100) - `offset` (optional): Number of messages to skip (default: 0) -**Example Request:** -``` -GET /api/v1/characters/char_123/chats/search?q=quest&npc_id=npc_grom_ironbeard&context=quest_offered -``` - **Response (200 OK):** ```json { @@ -1762,23 +1606,7 @@ GET /api/v1/characters/char_123/chats/search?q=quest&npc_id=npc_grom_ironbeard&c "date_to": null }, "total_results": 2, - "messages": [ - { - "message_id": "msg_abc125", - "character_id": "char_123", - "npc_id": "npc_grom_ironbeard", - "player_message": "Do you have any work for me?", - "npc_response": "*sighs heavily* Aye, there's rats in me cellar. Big ones.", - "timestamp": "2025-11-25T13:00:00Z", - "context": "quest_offered", - "location_id": "crossville_tavern", - "session_id": "sess_789", - "metadata": { - "quest_id": "quest_cellar_rats" - }, - "is_deleted": false - } - ], + "messages": [...], "pagination": { "limit": 50, "offset": 0, @@ -1788,7 +1616,7 @@ GET /api/v1/characters/char_123/chats/search?q=quest&npc_id=npc_grom_ironbeard&c } ``` -### Delete Message (Soft Delete) +### Delete Message | | | |---|---| @@ -1810,152 +1638,325 @@ GET /api/v1/characters/char_123/chats/search?q=quest&npc_id=npc_grom_ironbeard&c } ``` -**Notes:** -- Messages are soft deleted (is_deleted=true), not removed from database -- Deleted messages are filtered from all queries -- Only the character owner can delete their own messages - -**Error Responses:** -- `403` - User does not own the character -- `404` - Message not found - --- ## Combat -### Attack +### Start Combat | | | |---|---| -| **Endpoint** | `POST /api/v1/combat//attack` | -| **Description** | Execute physical attack | +| **Endpoint** | `POST /api/v1/combat/start` | +| **Description** | Start a new combat encounter | | **Auth Required** | Yes | **Request Body:** ```json { - "attacker_id": "char123", - "target_id": "enemy1", - "weapon_id": "sword1" + "session_id": "sess_123", + "enemy_ids": ["goblin", "goblin", "goblin_shaman"] } ``` -**Response:** +**Response (200 OK):** ```json { + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-27T12:00:00Z", "result": { - "damage": 15, - "critical": true, - "narrative": "Aragorn's blade strikes true...", - "target_hp": 25 - } -} -``` - -### Cast Spell - -| | | -|---|---| -| **Endpoint** | `POST /api/v1/combat//cast` | -| **Description** | Cast spell or ability | -| **Auth Required** | Yes | - -**Request Body:** -```json -{ - "caster_id": "char123", - "spell_id": "fireball", - "target_id": "enemy1" -} -``` - -**Response:** -```json -{ - "result": { - "damage": 30, - "mana_cost": 15, - "narrative": "Flames engulf the target...", - "effects_applied": ["burning"] - } -} -``` - -### Use Item - -| | | -|---|---| -| **Endpoint** | `POST /api/v1/combat//item` | -| **Description** | Use item from inventory | -| **Auth Required** | Yes | - -**Request Body:** -```json -{ - "character_id": "char123", - "item_id": "health_potion", - "target_id": "char123" -} -``` - -**Response:** -```json -{ - "result": { - "healing": 50, - "narrative": "You drink the potion and feel refreshed", - "current_hp": 100 - } -} -``` - -### Defend - -| | | -|---|---| -| **Endpoint** | `POST /api/v1/combat//defend` | -| **Description** | Take defensive stance | -| **Auth Required** | Yes | - -**Request Body:** -```json -{ - "character_id": "char123" -} -``` - -**Response:** -```json -{ - "result": { - "defense_bonus": 10, - "duration": 1, - "narrative": "You brace for impact" - } -} -``` - -### Get Combat Status - -| | | -|---|---| -| **Endpoint** | `GET /api/v1/combat//status` | -| **Description** | Get current combat state | -| **Auth Required** | Yes | - -**Response:** -```json -{ - "result": { - "combatants": [], - "turn_order": [], - "current_turn": 0, + "encounter_id": "enc_abc123", + "combatants": [ + { + "combatant_id": "char_456", + "name": "Thorin", + "is_player": true, + "current_hp": 100, + "max_hp": 100, + "current_mp": 50, + "max_mp": 50, + "initiative": 15, + "abilities": ["basic_attack", "shield_bash"] + } + ], + "turn_order": ["char_456", "goblin_0", "goblin_shaman_0", "goblin_1"], + "current_turn": "char_456", "round_number": 1, "status": "active" } } ``` +### Get Combat State + +| | | +|---|---| +| **Endpoint** | `GET /api/v1/combat//state` | +| **Description** | Get current combat state for a session | +| **Auth Required** | Yes | + +**Response (200 OK):** +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-27T12:00:00Z", + "result": { + "in_combat": true, + "encounter": { + "encounter_id": "enc_abc123", + "combatants": [...], + "turn_order": [...], + "current_turn": "char_456", + "round_number": 2, + "status": "active", + "combat_log": [ + "Thorin attacks Goblin for 15 damage!", + "Goblin attacks Thorin for 5 damage!" + ] + } + } +} +``` + +### Execute Combat Action + +| | | +|---|---| +| **Endpoint** | `POST /api/v1/combat//action` | +| **Description** | Execute a combat action for a combatant | +| **Auth Required** | Yes | + +**Request Body:** +```json +{ + "combatant_id": "char_456", + "action_type": "attack", + "target_ids": ["goblin_0"], + "ability_id": "shield_bash" +} +``` + +**Response (200 OK):** +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-27T12:00:00Z", + "result": { + "success": true, + "message": "Shield Bash hits Goblin for 18 damage and stuns!", + "damage_results": [ + { + "target_id": "goblin_0", + "damage": 18, + "is_critical": false + } + ], + "effects_applied": [ + { + "target_id": "goblin_0", + "effect": "stunned", + "duration": 1 + } + ], + "combat_ended": false, + "combat_status": null, + "next_combatant_id": "goblin_1" + } +} +``` + +### Execute Enemy Turn + +| | | +|---|---| +| **Endpoint** | `POST /api/v1/combat//enemy-turn` | +| **Description** | Execute the current enemy's turn using AI logic | +| **Auth Required** | Yes | + +**Response (200 OK):** +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-27T12:00:00Z", + "result": { + "success": true, + "message": "Goblin attacks Thorin for 8 damage!", + "damage_results": [...], + "effects_applied": [], + "combat_ended": false, + "combat_status": null, + "next_combatant_id": "char_456" + } +} +``` + +### Attempt Flee + +| | | +|---|---| +| **Endpoint** | `POST /api/v1/combat//flee` | +| **Description** | Attempt to flee from combat | +| **Auth Required** | Yes | + +**Request Body:** +```json +{ + "combatant_id": "char_456" +} +``` + +**Response (200 OK - Success):** +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-27T12:00:00Z", + "result": { + "success": true, + "message": "Successfully fled from combat!", + "combat_ended": true, + "combat_status": "fled" + } +} +``` + +### End Combat + +| | | +|---|---| +| **Endpoint** | `POST /api/v1/combat//end` | +| **Description** | Force end the current combat (debug/admin endpoint) | +| **Auth Required** | Yes | + +**Request Body:** +```json +{ + "outcome": "victory" +} +``` + +**Response (200 OK):** +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-27T12:00:00Z", + "result": { + "outcome": "victory", + "rewards": { + "experience": 100, + "gold": 50, + "items": [...], + "level_ups": [] + } + } +} +``` + +### List Enemies + +| | | +|---|---| +| **Endpoint** | `GET /api/v1/combat/enemies` | +| **Description** | List all available enemy templates | +| **Auth Required** | No | + +**Query Parameters:** +- `difficulty` (optional): Filter by difficulty (easy, medium, hard, boss) +- `tag` (optional): Filter by tag (undead, beast, humanoid, etc.) + +**Response (200 OK):** +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-27T12:00:00Z", + "result": { + "enemies": [ + { + "enemy_id": "goblin", + "name": "Goblin Scout", + "description": "A small, cunning creature...", + "difficulty": "easy", + "tags": ["humanoid", "goblinoid"], + "experience_reward": 15, + "gold_reward_range": [5, 15] + } + ] + } +} +``` + +### Get Enemy Details + +| | | +|---|---| +| **Endpoint** | `GET /api/v1/combat/enemies/` | +| **Description** | Get detailed information about a specific enemy template | +| **Auth Required** | No | + +**Response (200 OK):** +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-27T12:00:00Z", + "result": { + "enemy_id": "goblin", + "name": "Goblin Scout", + "description": "A small, cunning creature with sharp claws", + "base_stats": { + "strength": 8, + "dexterity": 14, + "constitution": 10 + }, + "abilities": ["quick_strike", "dodge"], + "loot_table": [...], + "difficulty": "easy", + "experience_reward": 15, + "gold_reward_min": 5, + "gold_reward_max": 15 + } +} +``` + +### Debug: Reset HP/MP + +| | | +|---|---| +| **Endpoint** | `POST /api/v1/combat//debug/reset-hp-mp` | +| **Description** | Reset player combatant's HP and MP to full (debug endpoint) | +| **Auth Required** | Yes | + +**Response (200 OK):** +```json +{ + "app": "Code of Conquest", + "version": "0.1.0", + "status": 200, + "timestamp": "2025-11-27T12:00:00Z", + "result": { + "success": true, + "message": "HP and MP reset to full", + "current_hp": 100, + "max_hp": 100, + "current_mp": 50, + "max_mp": 50 + } +} +``` + --- ## Game Mechanics @@ -2053,12 +2054,6 @@ GET /api/v1/characters/char_123/chats/search?q=quest&npc_id=npc_grom_ironbeard&c "name": "Ancient Coin", "description": "A weathered coin from ages past", "value": 25 - }, - { - "template_key": "healing_herb", - "name": "Healing Herb", - "description": "A medicinal plant bundle", - "value": 10 } ], "gold_found": 15 @@ -2066,94 +2061,11 @@ GET /api/v1/characters/char_123/chats/search?q=quest&npc_id=npc_grom_ironbeard&c } ``` -**Response (200 OK - Check Failed):** -```json -{ - "app": "Code of Conquest", - "version": "0.1.0", - "status": 200, - "timestamp": "2025-11-23T10:30:00Z", - "result": { - "check_result": { - "roll": 7, - "modifier": 1, - "total": 8, - "dc": 15, - "success": false, - "margin": -7, - "skill_type": "stealth" - }, - "context": { - "skill_used": "stealth", - "stat_used": "dexterity", - "situation": "Sneaking past guards" - } - } -} -``` - -**Error Response (400 Bad Request - Invalid skill):** -```json -{ - "app": "Code of Conquest", - "version": "0.1.0", - "status": 400, - "timestamp": "2025-11-23T10:30:00Z", - "result": null, - "error": { - "code": "INVALID_INPUT", - "message": "Invalid skill type", - "details": { - "field": "skill", - "issue": "Must be one of: perception, insight, survival, medicine, stealth, acrobatics, sleight_of_hand, lockpicking, persuasion, deception, intimidation, performance, athletics, arcana, history, investigation, nature, religion, endurance" - } - } -} -``` - -**Error Response (404 Not Found - Character not found):** -```json -{ - "app": "Code of Conquest", - "version": "0.1.0", - "status": 404, - "timestamp": "2025-11-23T10:30:00Z", - "result": null, - "error": { - "code": "NOT_FOUND", - "message": "Character not found: char_999" - } -} -``` - -**Error Response (403 Forbidden - Not character owner):** -```json -{ - "app": "Code of Conquest", - "version": "0.1.0", - "status": 403, - "timestamp": "2025-11-23T10:30:00Z", - "result": null, - "error": { - "code": "FORBIDDEN", - "message": "You don't have permission to access this character" - } -} -``` - **Notes:** - `check_type` must be "search" or "skill" - For skill checks, `skill` is required -- For search checks, `location_type` is optional (defaults to "default") - `dc` or `difficulty` must be provided (dc takes precedence) - Valid difficulty values: trivial (5), easy (10), medium (15), hard (20), very_hard (25), nearly_impossible (30) -- `bonus` is optional (defaults to 0) -- `context` is optional and merged with the response for AI narration -- Roll uses d20 + stat modifier + optional bonus -- Margin is calculated as (total - dc) -- Items found depend on location type and success margin - ---- ### List Available Skills @@ -2172,94 +2084,16 @@ GET /api/v1/characters/char_123/chats/search?q=quest&npc_id=npc_grom_ironbeard&c "timestamp": "2025-11-23T10:30:00Z", "result": { "skills": [ - { - "name": "perception", - "stat": "wisdom" - }, - { - "name": "insight", - "stat": "wisdom" - }, - { - "name": "survival", - "stat": "wisdom" - }, - { - "name": "medicine", - "stat": "wisdom" - }, - { - "name": "stealth", - "stat": "dexterity" - }, - { - "name": "acrobatics", - "stat": "dexterity" - }, - { - "name": "sleight_of_hand", - "stat": "dexterity" - }, - { - "name": "lockpicking", - "stat": "dexterity" - }, - { - "name": "persuasion", - "stat": "charisma" - }, - { - "name": "deception", - "stat": "charisma" - }, - { - "name": "intimidation", - "stat": "charisma" - }, - { - "name": "performance", - "stat": "charisma" - }, - { - "name": "athletics", - "stat": "strength" - }, - { - "name": "arcana", - "stat": "intelligence" - }, - { - "name": "history", - "stat": "intelligence" - }, - { - "name": "investigation", - "stat": "intelligence" - }, - { - "name": "nature", - "stat": "intelligence" - }, - { - "name": "religion", - "stat": "intelligence" - }, - { - "name": "endurance", - "stat": "constitution" - } + {"name": "perception", "stat": "wisdom"}, + {"name": "insight", "stat": "wisdom"}, + {"name": "stealth", "stat": "dexterity"}, + {"name": "persuasion", "stat": "charisma"}, + {"name": "athletics", "stat": "strength"} ] } } ``` -**Notes:** -- No authentication required -- Skills are grouped by their associated stat -- Use the skill names in the `skill` parameter of the `/check` endpoint - ---- - ### List Difficulty Levels | | | @@ -2277,322 +2111,17 @@ GET /api/v1/characters/char_123/chats/search?q=quest&npc_id=npc_grom_ironbeard&c "timestamp": "2025-11-23T10:30:00Z", "result": { "difficulties": [ - { - "name": "trivial", - "dc": 5 - }, - { - "name": "easy", - "dc": 10 - }, - { - "name": "medium", - "dc": 15 - }, - { - "name": "hard", - "dc": 20 - }, - { - "name": "very_hard", - "dc": 25 - }, - { - "name": "nearly_impossible", - "dc": 30 - } + {"name": "trivial", "dc": 5}, + {"name": "easy", "dc": 10}, + {"name": "medium", "dc": 15}, + {"name": "hard", "dc": 20}, + {"name": "very_hard", "dc": 25}, + {"name": "nearly_impossible", "dc": 30} ] } } ``` -**Notes:** -- No authentication required -- Use difficulty names in the `difficulty` parameter of the `/check` endpoint instead of providing raw DC values -- DC values range from 5 (trivial) to 30 (nearly impossible) - ---- - -## Marketplace - -### Browse Listings - -| | | -|---|---| -| **Endpoint** | `GET /api/v1/marketplace` | -| **Description** | Browse marketplace listings | -| **Auth Required** | Yes (Premium+ only) | - -**Query Parameters:** -- `type` - "auction" or "fixed_price" -- `category` - "weapon", "armor", "consumable" -- `min_price` - Minimum price -- `max_price` - Maximum price -- `sort` - "price_asc", "price_desc", "ending_soon" -- `page` - Page number -- `limit` - Items per page - -**Response:** -```json -{ - "result": { - "listings": [ - { - "listing_id": "list123", - "item": {}, - "listing_type": "auction", - "current_bid": 500, - "buyout_price": 1000, - "auction_end": "2025-11-15T12:00:00Z" - } - ], - "total": 50, - "page": 1, - "pages": 5 - } -} -``` - -### Get Listing - -| | | -|---|---| -| **Endpoint** | `GET /api/v1/marketplace/` | -| **Description** | Get listing details | -| **Auth Required** | Yes (Premium+ only) | - -**Response:** -```json -{ - "result": { - "listing_id": "list123", - "seller_name": "Aragorn", - "item": {}, - "listing_type": "auction", - "current_bid": 500, - "bid_count": 5, - "bids": [], - "auction_end": "2025-11-15T12:00:00Z" - } -} -``` - -### Create Listing - -| | | -|---|---| -| **Endpoint** | `POST /api/v1/marketplace/list` | -| **Description** | Create new marketplace listing | -| **Auth Required** | Yes (Premium+ only) | - -**Request Body (Auction):** -```json -{ - "character_id": "char123", - "item_id": "sword1", - "listing_type": "auction", - "starting_bid": 100, - "buyout_price": 1000, - "duration_hours": 48 -} -``` - -**Request Body (Fixed Price):** -```json -{ - "character_id": "char123", - "item_id": "sword1", - "listing_type": "fixed_price", - "price": 500 -} -``` - -**Response:** -```json -{ - "result": { - "listing_id": "list123", - "message": "Listing created successfully" - } -} -``` - -### Place Bid - -| | | -|---|---| -| **Endpoint** | `POST /api/v1/marketplace//bid` | -| **Description** | Place bid on auction | -| **Auth Required** | Yes (Premium+ only) | - -**Request Body:** -```json -{ - "character_id": "char123", - "amount": 600 -} -``` - -**Response:** -```json -{ - "result": { - "current_bid": 600, - "is_winning": true, - "message": "Bid placed successfully" - } -} -``` - -### Buyout - -| | | -|---|---| -| **Endpoint** | `POST /api/v1/marketplace//buyout` | -| **Description** | Instant purchase at buyout price | -| **Auth Required** | Yes (Premium+ only) | - -**Request Body:** -```json -{ - "character_id": "char123" -} -``` - -**Response:** -```json -{ - "result": { - "transaction_id": "trans123", - "price": 1000, - "item": {}, - "message": "Purchase successful" - } -} -``` - -### Cancel Listing - -| | | -|---|---| -| **Endpoint** | `DELETE /api/v1/marketplace/` | -| **Description** | Cancel listing (owner only) | -| **Auth Required** | Yes (Premium+ only) | - -**Response:** -```json -{ - "result": { - "message": "Listing cancelled, item returned" - } -} -``` - -### My Listings - -| | | -|---|---| -| **Endpoint** | `GET /api/v1/marketplace/my-listings` | -| **Description** | Get current user's active listings | -| **Auth Required** | Yes (Premium+ only) | - -**Response:** -```json -{ - "result": { - "listings": [], - "total": 5 - } -} -``` - -### My Bids - -| | | -|---|---| -| **Endpoint** | `GET /api/v1/marketplace/my-bids` | -| **Description** | Get current user's active bids | -| **Auth Required** | Yes (Premium+ only) | - -**Response:** -```json -{ - "result": { - "bids": [ - { - "listing_id": "list123", - "item": {}, - "your_bid": 500, - "current_bid": 600, - "is_winning": false, - "auction_end": "2025-11-15T12:00:00Z" - } - ] - } -} -``` - ---- - -## Shop - -### Browse Shop - -| | | -|---|---| -| **Endpoint** | `GET /api/v1/shop/items` | -| **Description** | Browse NPC shop inventory | -| **Auth Required** | Yes | - -**Query Parameters:** -- `category` - "consumable", "weapon", "armor" - -**Response:** -```json -{ - "result": { - "items": [ - { - "item_id": "health_potion", - "name": "Health Potion", - "price": 50, - "stock": -1, - "description": "Restores 50 HP" - } - ] - } -} -``` - -### Purchase from Shop - -| | | -|---|---| -| **Endpoint** | `POST /api/v1/shop/purchase` | -| **Description** | Buy item from NPC shop | -| **Auth Required** | Yes | - -**Request Body:** -```json -{ - "character_id": "char123", - "item_id": "health_potion", - "quantity": 5 -} -``` - -**Response:** -```json -{ - "result": { - "transaction_id": "trans123", - "total_cost": 250, - "items_purchased": 5, - "remaining_gold": 750 - } -} -``` - --- ## Error Responses @@ -2601,8 +2130,8 @@ GET /api/v1/characters/char_123/chats/search?q=quest&npc_id=npc_grom_ironbeard&c ```json { - "app": "AI Dungeon Master", - "version": "1.0.0", + "app": "Code of Conquest", + "version": "0.1.0", "status": 400, "timestamp": "2025-11-14T12:00:00Z", "result": null, @@ -2630,12 +2159,18 @@ GET /api/v1/characters/char_123/chats/search?q=quest&npc_id=npc_grom_ironbeard&c | `SESSION_LIMIT_EXCEEDED` | 409 | User has reached session limit for their tier | | `CHARACTER_NOT_FOUND` | 404 | Character does not exist or not accessible | | `SKILL_UNLOCK_ERROR` | 400 | Skill unlock failed (prerequisites, points, or tier) | -| `INSUFFICIENT_FUNDS` | 400 | Not enough gold | +| `INSUFFICIENT_GOLD` | 400 | Not enough gold | +| `INSUFFICIENT_RESOURCES` | 400 | Not enough MP/items for action | | `INVALID_ACTION` | 400 | Action not allowed | | `SESSION_FULL` | 400 | Session at max capacity | | `NOT_YOUR_TURN` | 400 | Not active player's turn | | `AI_LIMIT_EXCEEDED` | 429 | Daily AI call limit reached | | `PREMIUM_REQUIRED` | 403 | Feature requires premium subscription | +| `ALREADY_IN_COMBAT` | 400 | Session is already in combat | +| `NOT_IN_COMBAT` | 404 | Session is not in combat | +| `INVENTORY_FULL` | 400 | Inventory is full | +| `CANNOT_EQUIP` | 400 | Item cannot be equipped | +| `CANNOT_USE_ITEM` | 400 | Item cannot be used | --- @@ -2676,24 +2211,3 @@ Endpoints that return lists support pagination: } } ``` - ---- - -## Realtime Events (WebSocket) - -**Subscribe to session updates:** - -```javascript -client.subscribe( - 'databases.main.collections.game_sessions.documents.{sessionId}', - callback -); -``` - -**Event Types:** -- Session state change -- Turn change -- Combat update -- Chat message -- Player joined/left -- Marketplace bid notification diff --git a/api/scripts/migrate_combat_data.py b/api/scripts/migrate_combat_data.py new file mode 100644 index 0000000..e0055a5 --- /dev/null +++ b/api/scripts/migrate_combat_data.py @@ -0,0 +1,245 @@ +#!/usr/bin/env python3 +""" +Combat Data Migration Script. + +This script migrates existing inline combat encounter data from game_sessions +to the dedicated combat_encounters table. + +The migration is idempotent - it's safe to run multiple times. Sessions that +have already been migrated (have active_combat_encounter_id) are skipped. + +Usage: + python scripts/migrate_combat_data.py + +Note: + - Run this after deploying the new combat database schema + - The application handles automatic migration on-demand, so this is optional + - This script is useful for proactively migrating all data at once +""" + +import sys +import os +import json +from pathlib import Path + +# Add project root to path +project_root = Path(__file__).parent.parent +sys.path.insert(0, str(project_root)) + +from dotenv import load_dotenv + +# Load environment variables before importing app modules +load_dotenv() + +from app.services.database_service import get_database_service +from app.services.combat_repository import get_combat_repository +from app.models.session import GameSession +from app.models.combat import CombatEncounter +from app.utils.logging import get_logger + +logger = get_logger(__file__) + + +def migrate_inline_combat_encounters() -> dict: + """ + Migrate all inline combat encounters to the dedicated table. + + Scans all game sessions for inline combat_encounter data and migrates + them to the combat_encounters table. Updates sessions to use the new + active_combat_encounter_id reference. + + Returns: + Dict with migration statistics: + - total_sessions: Number of sessions scanned + - migrated: Number of sessions with combat data migrated + - skipped: Number of sessions already migrated or without combat + - errors: Number of sessions that failed to migrate + """ + db = get_database_service() + repo = get_combat_repository() + + stats = { + 'total_sessions': 0, + 'migrated': 0, + 'skipped': 0, + 'errors': 0, + 'error_details': [] + } + + print("Scanning game_sessions for inline combat data...") + + # Query all sessions (paginated) + offset = 0 + limit = 100 + + while True: + try: + rows = db.list_rows( + table_id='game_sessions', + limit=limit, + offset=offset + ) + except Exception as e: + logger.error("Failed to query sessions", error=str(e)) + print(f"Error querying sessions: {e}") + break + + if not rows: + break + + for row in rows: + stats['total_sessions'] += 1 + session_id = row.id + + try: + # Parse session data + session_json = row.data.get('sessionData', '{}') + session_data = json.loads(session_json) + + # Check if already migrated (has reference, no inline data) + if (session_data.get('active_combat_encounter_id') and + not session_data.get('combat_encounter')): + stats['skipped'] += 1 + continue + + # Check if has inline combat data to migrate + combat_data = session_data.get('combat_encounter') + if not combat_data: + stats['skipped'] += 1 + continue + + # Parse combat encounter + encounter = CombatEncounter.from_dict(combat_data) + user_id = session_data.get('user_id', row.data.get('userId', '')) + + logger.info("Migrating inline combat encounter", + session_id=session_id, + encounter_id=encounter.encounter_id) + + # Check if encounter already exists in repository + existing = repo.get_encounter(encounter.encounter_id) + if existing: + # Already migrated, just update session reference + session_data['active_combat_encounter_id'] = encounter.encounter_id + session_data['combat_encounter'] = None + else: + # Save to repository + repo.create_encounter( + encounter=encounter, + session_id=session_id, + user_id=user_id + ) + session_data['active_combat_encounter_id'] = encounter.encounter_id + session_data['combat_encounter'] = None + + # Update session + db.update_row( + table_id='game_sessions', + row_id=session_id, + data={'sessionData': json.dumps(session_data)} + ) + + stats['migrated'] += 1 + print(f" Migrated: {session_id} -> {encounter.encounter_id}") + + except Exception as e: + stats['errors'] += 1 + error_msg = f"Session {session_id}: {str(e)}" + stats['error_details'].append(error_msg) + logger.error("Failed to migrate session", + session_id=session_id, + error=str(e)) + print(f" Error: {session_id} - {e}") + + offset += limit + + # Safety check to prevent infinite loop + if offset > 10000: + print("Warning: Stopped after 10000 sessions (safety limit)") + break + + return stats + + +def main(): + """Run the migration.""" + print("=" * 60) + print("Code of Conquest - Combat Data Migration") + print("=" * 60) + print() + + # Verify environment variables + required_vars = [ + 'APPWRITE_ENDPOINT', + 'APPWRITE_PROJECT_ID', + 'APPWRITE_API_KEY', + 'APPWRITE_DATABASE_ID' + ] + + missing_vars = [var for var in required_vars if not os.getenv(var)] + if missing_vars: + print("ERROR: Missing required environment variables:") + for var in missing_vars: + print(f" - {var}") + print() + print("Please ensure your .env file is configured correctly.") + sys.exit(1) + + print("Environment configuration:") + print(f" Endpoint: {os.getenv('APPWRITE_ENDPOINT')}") + print(f" Project: {os.getenv('APPWRITE_PROJECT_ID')}") + print(f" Database: {os.getenv('APPWRITE_DATABASE_ID')}") + print() + + # Confirm before proceeding + print("This script will migrate inline combat data to the dedicated") + print("combat_encounters table. This operation is safe and idempotent.") + print() + response = input("Proceed with migration? (y/N): ").strip().lower() + if response != 'y': + print("Migration cancelled.") + sys.exit(0) + + print() + print("Starting migration...") + print() + + try: + stats = migrate_inline_combat_encounters() + + print() + print("=" * 60) + print("Migration Results") + print("=" * 60) + print() + print(f"Total sessions scanned: {stats['total_sessions']}") + print(f"Successfully migrated: {stats['migrated']}") + print(f"Skipped (no combat): {stats['skipped']}") + print(f"Errors: {stats['errors']}") + print() + + if stats['error_details']: + print("Error details:") + for error in stats['error_details'][:10]: # Show first 10 + print(f" - {error}") + if len(stats['error_details']) > 10: + print(f" ... and {len(stats['error_details']) - 10} more") + print() + + if stats['errors'] > 0: + print("Some sessions failed to migrate. Check logs for details.") + sys.exit(1) + else: + print("Migration completed successfully!") + + except Exception as e: + logger.error("Migration failed", error=str(e)) + print() + print(f"MIGRATION FAILED: {str(e)}") + print() + print("Check logs for details.") + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/docs/PHASE4_COMBAT_IMPLEMENTATION.md b/docs/PHASE4_COMBAT_IMPLEMENTATION.md index e696220..81b77ac 100644 --- a/docs/PHASE4_COMBAT_IMPLEMENTATION.md +++ b/docs/PHASE4_COMBAT_IMPLEMENTATION.md @@ -253,7 +253,7 @@ Implemented hybrid loot system: ### Week 3: Combat UI -#### Task 3.1: Create Combat Template (1 day / 8 hours) +#### Task 3.1: Create Combat Template ✅ COMPLETE **Objective:** Build HTMX-powered combat interface @@ -452,11 +452,11 @@ if (logDiv) { --- -#### Task 3.2: Combat HTMX Integration (1 day / 8 hours) +#### Task 3.2: Combat HTMX Integration ✅ COMPLETE **Objective:** Wire combat UI to API via HTMX -**File:** `/public_web/app/views/combat.py` +**File:** `/public_web/app/views/game_views.py` **Implementation:** @@ -583,7 +583,7 @@ app.register_blueprint(combat_bp, url_prefix='/combat') --- -#### Task 3.3: Inventory UI (1 day / 8 hours) +#### Task 3.3: Inventory UI ✅ COMPLETE **Objective:** Add inventory accordion to character panel diff --git a/public_web/app/__init__.py b/public_web/app/__init__.py index 9b22a14..20b8456 100644 --- a/public_web/app/__init__.py +++ b/public_web/app/__init__.py @@ -56,11 +56,13 @@ def create_app(): # Register blueprints from .views.auth_views import auth_bp from .views.character_views import character_bp + from .views.combat_views import combat_bp from .views.game_views import game_bp from .views.pages import pages_bp app.register_blueprint(auth_bp) app.register_blueprint(character_bp) + app.register_blueprint(combat_bp) app.register_blueprint(game_bp) app.register_blueprint(pages_bp) @@ -109,6 +111,6 @@ def create_app(): logger.error("internal_server_error", error=str(error)) return render_template('errors/500.html'), 500 - logger.info("flask_app_created", blueprints=["auth", "character", "game", "pages"]) + logger.info("flask_app_created", blueprints=["auth", "character", "combat", "game", "pages"]) return app diff --git a/public_web/app/views/combat_views.py b/public_web/app/views/combat_views.py new file mode 100644 index 0000000..6e91aab --- /dev/null +++ b/public_web/app/views/combat_views.py @@ -0,0 +1,561 @@ +""" +Combat Views + +Routes for combat UI. +""" + +from flask import Blueprint, render_template, request, redirect, url_for, make_response +import structlog + +from ..utils.api_client import get_api_client, APIError, APINotFoundError +from ..utils.auth import require_auth_web as require_auth + +logger = structlog.get_logger(__name__) + +combat_bp = Blueprint('combat', __name__, url_prefix='/combat') + + +@combat_bp.route('/') +@require_auth +def combat_view(session_id: str): + """ + Render the combat page for an active encounter. + + Displays the 3-column combat interface with: + - Left: Combatants (player + enemies) with HP/MP bars + - Center: Combat log + action buttons + - Right: Turn order + active effects + """ + client = get_api_client() + + try: + # Get combat state from API + response = client.get(f'/api/v1/combat/{session_id}/state') + result = response.get('result', {}) + + # Check if combat is still active + if not result.get('in_combat'): + # Combat ended - redirect to game play + return redirect(url_for('game.play', session_id=session_id)) + + encounter = result.get('encounter') or {} + combat_log = result.get('combat_log', []) + + # Get current turn combatant ID directly from API response + current_turn_id = encounter.get('current_turn') + + # Find if it's the player's turn + is_player_turn = False + player_combatant = None + for combatant in encounter.get('combatants', []): + if combatant.get('is_player'): + player_combatant = combatant + if combatant.get('combatant_id') == current_turn_id: + is_player_turn = True + break + + # Format combat log entries for display + formatted_log = [] + for entry in combat_log: + log_entry = { + 'actor': entry.get('combatant_name', entry.get('actor', '')), + 'message': entry.get('message', ''), + 'damage': entry.get('damage'), + 'heal': entry.get('healing'), + 'is_crit': entry.get('is_critical', False), + 'type': 'player' if entry.get('is_player', False) else 'enemy' + } + # Detect system messages + if entry.get('action_type') in ('round_start', 'combat_start', 'combat_end'): + log_entry['type'] = 'system' + formatted_log.append(log_entry) + + return render_template( + 'game/combat.html', + session_id=session_id, + encounter=encounter, + combat_log=formatted_log, + current_turn_id=current_turn_id, + is_player_turn=is_player_turn, + player_combatant=player_combatant + ) + + except APINotFoundError: + logger.warning("combat_not_found", session_id=session_id) + return render_template('errors/404.html', message="No active combat encounter"), 404 + except APIError as e: + logger.error("failed_to_load_combat", session_id=session_id, error=str(e)) + return render_template('errors/500.html', message=str(e)), 500 + + +@combat_bp.route('//action', methods=['POST']) +@require_auth +def combat_action(session_id: str): + """ + Execute a combat action (attack, defend, ability, item). + + Returns updated combat log entries. + """ + client = get_api_client() + + action_type = request.form.get('action_type', 'attack') + ability_id = request.form.get('ability_id') + item_id = request.form.get('item_id') + target_id = request.form.get('target_id') + + try: + # Build action payload + payload = { + 'action_type': action_type + } + + if ability_id: + payload['ability_id'] = ability_id + if item_id: + payload['item_id'] = item_id + if target_id: + payload['target_id'] = target_id + + # POST action to API + response = client.post(f'/api/v1/combat/{session_id}/action', payload) + result = response.get('result', {}) + + # Check if combat ended + combat_ended = result.get('combat_ended', False) + combat_status = result.get('combat_status') + + if combat_ended: + # API returns lowercase status values: 'victory', 'defeat', 'fled' + status_lower = (combat_status or '').lower() + if status_lower == 'victory': + return render_template( + 'game/partials/combat_victory.html', + session_id=session_id, + rewards=result.get('rewards', {}) + ) + elif status_lower == 'defeat': + return render_template( + 'game/partials/combat_defeat.html', + session_id=session_id, + gold_lost=result.get('gold_lost', 0), + can_retry=result.get('can_retry', False) + ) + + # Format action result for log display + # API returns data directly in result, not nested under 'action_result' + log_entries = [] + + # Player action entry + player_entry = { + 'actor': 'You', + 'message': result.get('message', f'used {action_type}'), + 'type': 'player', + 'is_crit': False + } + + # Add damage info if present + damage_results = result.get('damage_results', []) + if damage_results: + for dmg in damage_results: + player_entry['damage'] = dmg.get('total_damage') or dmg.get('damage') + player_entry['is_crit'] = dmg.get('is_critical', False) + if player_entry['is_crit']: + player_entry['type'] = 'crit' + + # Add healing info if present + if result.get('healing'): + player_entry['heal'] = result.get('healing') + player_entry['type'] = 'heal' + + log_entries.append(player_entry) + + # Add any effect entries + for effect in result.get('effects_applied', []): + log_entries.append({ + 'actor': '', + 'message': effect.get('message', f'Effect applied: {effect.get("name")}'), + 'type': 'system' + }) + + # Return log entries HTML + resp = make_response(render_template( + 'game/partials/combat_log.html', + combat_log=log_entries + )) + + # Trigger enemy turn if it's no longer player's turn + next_combatant = result.get('next_combatant_id') + if next_combatant and not result.get('next_is_player', True): + resp.headers['HX-Trigger'] = 'enemyTurn' + + return resp + + except APIError as e: + logger.error("combat_action_failed", session_id=session_id, action_type=action_type, error=str(e)) + return f''' +
+ Action failed: {e} +
+ ''', 500 + + +@combat_bp.route('//abilities') +@require_auth +def combat_abilities(session_id: str): + """Get abilities modal for combat.""" + client = get_api_client() + + try: + # Get combat state to get player's abilities + response = client.get(f'/api/v1/combat/{session_id}/state') + result = response.get('result', {}) + encounter = result.get('encounter', {}) + + # Find player combatant + player_combatant = None + for combatant in encounter.get('combatants', []): + if combatant.get('is_player'): + player_combatant = combatant + break + + # Get abilities from player combatant or character + abilities = [] + if player_combatant: + ability_ids = player_combatant.get('abilities', []) + current_mp = player_combatant.get('current_mp', 0) + cooldowns = player_combatant.get('cooldowns', {}) + + # Fetch ability details (if API has ability endpoint) + for ability_id in ability_ids: + try: + ability_response = client.get(f'/api/v1/abilities/{ability_id}') + ability_data = ability_response.get('result', {}) + + # Check availability + mp_cost = ability_data.get('mp_cost', 0) + cooldown = cooldowns.get(ability_id, 0) + available = current_mp >= mp_cost and cooldown == 0 + + abilities.append({ + 'id': ability_id, + 'name': ability_data.get('name', ability_id), + 'description': ability_data.get('description', ''), + 'mp_cost': mp_cost, + 'cooldown': cooldown, + 'max_cooldown': ability_data.get('cooldown', 0), + 'damage_type': ability_data.get('damage_type'), + 'effect_type': ability_data.get('effect_type'), + 'available': available + }) + except (APINotFoundError, APIError): + # Ability not found, add basic entry + abilities.append({ + 'id': ability_id, + 'name': ability_id.replace('_', ' ').title(), + 'description': '', + 'mp_cost': 0, + 'cooldown': cooldowns.get(ability_id, 0), + 'max_cooldown': 0, + 'available': True + }) + + return render_template( + 'game/partials/ability_modal.html', + session_id=session_id, + abilities=abilities + ) + + except APIError as e: + logger.error("failed_to_load_abilities", session_id=session_id, error=str(e)) + return f''' + + ''' + + +@combat_bp.route('//items') +@require_auth +def combat_items(session_id: str): + """ + Get combat items bottom sheet (consumables only). + + Returns a bottom sheet UI with only consumable items that can be used in combat. + """ + client = get_api_client() + + try: + # Get session to find character + session_response = client.get(f'/api/v1/sessions/{session_id}') + session_data = session_response.get('result', {}) + character_id = session_data.get('character_id') + + # Get character inventory - filter to consumables only + consumables = [] + if character_id: + try: + inv_response = client.get(f'/api/v1/characters/{character_id}/inventory') + inv_data = inv_response.get('result', {}) + inventory = inv_data.get('inventory', []) + + # Filter to consumable items only + for item in inventory: + item_type = item.get('item_type', item.get('type', '')) + if item_type == 'consumable' or item.get('usable_in_combat', False): + consumables.append({ + 'item_id': item.get('item_id'), + 'name': item.get('name', 'Unknown Item'), + 'description': item.get('description', ''), + 'effects_on_use': item.get('effects_on_use', []), + 'rarity': item.get('rarity', 'common') + }) + except (APINotFoundError, APIError) as e: + logger.warning("failed_to_load_combat_inventory", character_id=character_id, error=str(e)) + + return render_template( + 'game/partials/combat_items_sheet.html', + session_id=session_id, + consumables=consumables, + has_consumables=len(consumables) > 0 + ) + + except APIError as e: + logger.error("failed_to_load_items", session_id=session_id, error=str(e)) + return f''' +
+
+
+

Use Item

+ +
+
+
Failed to load items: {e}
+
+
+
+ ''' + + +@combat_bp.route('//items//detail') +@require_auth +def combat_item_detail(session_id: str, item_id: str): + """Get item detail for combat bottom sheet.""" + client = get_api_client() + + try: + # Get session to find character + session_response = client.get(f'/api/v1/sessions/{session_id}') + session_data = session_response.get('result', {}) + character_id = session_data.get('character_id') + + item = None + if character_id: + # Get inventory and find the item + inv_response = client.get(f'/api/v1/characters/{character_id}/inventory') + inv_data = inv_response.get('result', {}) + inventory = inv_data.get('inventory', []) + + for inv_item in inventory: + if inv_item.get('item_id') == item_id: + item = inv_item + break + + if not item: + return '

Item not found

', 404 + + # Format effect description + effect_desc = item.get('description', 'Use this item') + effects = item.get('effects_on_use', []) + if effects: + effect_parts = [] + for effect in effects: + if effect.get('stat') == 'hp': + effect_parts.append(f"Restores {effect.get('value', 0)} HP") + elif effect.get('stat') == 'mp': + effect_parts.append(f"Restores {effect.get('value', 0)} MP") + elif effect.get('name'): + effect_parts.append(effect.get('name')) + if effect_parts: + effect_desc = ', '.join(effect_parts) + + return f''' +
+
{item.get('name', 'Item')}
+
{effect_desc}
+
+ + ''' + + except APIError as e: + logger.error("failed_to_load_combat_item_detail", session_id=session_id, item_id=item_id, error=str(e)) + return f'

Failed to load item: {e}

', 500 + + +@combat_bp.route('//flee', methods=['POST']) +@require_auth +def combat_flee(session_id: str): + """Attempt to flee from combat.""" + client = get_api_client() + + try: + response = client.post(f'/api/v1/combat/{session_id}/flee', {}) + result = response.get('result', {}) + + if result.get('success'): + # Flee successful - redirect to play page + return redirect(url_for('game.play_session', session_id=session_id)) + else: + # Flee failed - return log entry + return f''' +
+ {result.get('message', 'Failed to flee!')} +
+ ''' + + except APIError as e: + logger.error("flee_failed", session_id=session_id, error=str(e)) + return f''' +
+ Flee failed: {e} +
+ ''', 500 + + +@combat_bp.route('//enemy-turn', methods=['POST']) +@require_auth +def combat_enemy_turn(session_id: str): + """Execute enemy turn and return result.""" + client = get_api_client() + + try: + response = client.post(f'/api/v1/combat/{session_id}/enemy-turn', {}) + result = response.get('result', {}) + + # Check if combat ended + combat_ended = result.get('combat_ended', False) + combat_status = result.get('combat_status') + + if combat_ended: + # API returns lowercase status values: 'victory', 'defeat', 'fled' + status_lower = (combat_status or '').lower() + if status_lower == 'victory': + return render_template( + 'game/partials/combat_victory.html', + session_id=session_id, + rewards=result.get('rewards', {}) + ) + elif status_lower == 'defeat': + return render_template( + 'game/partials/combat_defeat.html', + session_id=session_id, + gold_lost=result.get('gold_lost', 0), + can_retry=result.get('can_retry', False) + ) + + # Format enemy action for log + action_result = result.get('action_result', {}) + log_entries = [{ + 'actor': action_result.get('actor_name', 'Enemy'), + 'message': action_result.get('message', 'attacks'), + 'type': 'enemy', + 'is_crit': action_result.get('is_critical', False) + }] + + # Add damage info + damage_results = action_result.get('damage_results', []) + if damage_results: + log_entries[0]['damage'] = damage_results[0].get('damage') + + # Check if it's still enemy turn (multiple enemies) + resp = make_response(render_template( + 'game/partials/combat_log.html', + combat_log=log_entries + )) + + # If next combatant is also an enemy, trigger another enemy turn + if result.get('next_combatant_id') and not result.get('next_is_player', True): + resp.headers['HX-Trigger'] = 'enemyTurn' + + return resp + + except APIError as e: + logger.error("enemy_turn_failed", session_id=session_id, error=str(e)) + return f''' +
+ Enemy turn error: {e} +
+ ''', 500 + + +@combat_bp.route('//log') +@require_auth +def combat_log(session_id: str): + """Get current combat log.""" + client = get_api_client() + + try: + response = client.get(f'/api/v1/combat/{session_id}/state') + result = response.get('result', {}) + combat_log_data = result.get('combat_log', []) + + # Format log entries + formatted_log = [] + for entry in combat_log_data: + log_entry = { + 'actor': entry.get('combatant_name', entry.get('actor', '')), + 'message': entry.get('message', ''), + 'damage': entry.get('damage'), + 'heal': entry.get('healing'), + 'is_crit': entry.get('is_critical', False), + 'type': 'player' if entry.get('is_player', False) else 'enemy' + } + if entry.get('action_type') in ('round_start', 'combat_start', 'combat_end'): + log_entry['type'] = 'system' + formatted_log.append(log_entry) + + return render_template( + 'game/partials/combat_log.html', + combat_log=formatted_log + ) + + except APIError as e: + logger.error("failed_to_load_combat_log", session_id=session_id, error=str(e)) + return '
Failed to load combat log
', 500 + + +@combat_bp.route('//results') +@require_auth +def combat_results(session_id: str): + """Display combat results (victory/defeat).""" + client = get_api_client() + + try: + response = client.get(f'/api/v1/combat/{session_id}/results') + results = response.get('result', {}) + + return render_template( + 'game/combat_results.html', + victory=results['victory'], + xp_gained=results['xp_gained'], + gold_gained=results['gold_gained'], + loot=results['loot'] + ) + + except APIError as e: + logger.error("failed_to_load_combat_results", session_id=session_id, error=str(e)) + return redirect(url_for('game.play_session', session_id=session_id)) diff --git a/public_web/app/views/dev.py b/public_web/app/views/dev.py index 789fe94..e4b979d 100644 --- a/public_web/app/views/dev.py +++ b/public_web/app/views/dev.py @@ -380,3 +380,652 @@ def do_travel(session_id: str): except APIError as e: logger.error("failed_to_travel", session_id=session_id, location_id=location_id, error=str(e)) return f'
Travel failed: {e}
', 500 + + +# ===== Combat Test Endpoints ===== + +@dev_bp.route('/combat') +@require_auth +def combat_hub(): + """Combat testing hub - select character and enemies to start combat.""" + client = get_api_client() + + try: + # Get user's characters + characters_response = client.get('/api/v1/characters') + result = characters_response.get('result', {}) + characters = result.get('characters', []) + + # Get available enemy templates + enemies = [] + try: + enemies_response = client.get('/api/v1/combat/enemies') + enemies = enemies_response.get('result', {}).get('enemies', []) + except (APINotFoundError, APIError): + # Enemies endpoint may not exist yet + pass + + # Get all sessions to map characters to their sessions + sessions_in_combat = [] + character_session_map = {} # character_id -> session_id + try: + sessions_response = client.get('/api/v1/sessions') + all_sessions = sessions_response.get('result', []) + for session in all_sessions: + # Map character to session (for dropdown) + char_id = session.get('character_id') + if char_id: + character_session_map[char_id] = session.get('session_id') + + # Track sessions in combat (for resume list) + if session.get('in_combat') or session.get('game_state', {}).get('in_combat'): + sessions_in_combat.append(session) + except (APINotFoundError, APIError): + pass + + # Add session_id to each character for the template + for char in characters: + char['session_id'] = character_session_map.get(char.get('character_id')) + + return render_template( + 'dev/combat.html', + characters=characters, + enemies=enemies, + sessions_in_combat=sessions_in_combat + ) + except APIError as e: + logger.error("failed_to_load_combat_hub", error=str(e)) + return render_template('dev/combat.html', characters=[], enemies=[], sessions_in_combat=[], error=str(e)) + + +@dev_bp.route('/combat/start', methods=['POST']) +@require_auth +def start_combat(): + """Start a new combat encounter - returns redirect to combat session.""" + client = get_api_client() + + session_id = request.form.get('session_id') + enemy_ids = request.form.getlist('enemy_ids') + + logger.info("start_combat called", + session_id=session_id, + enemy_ids=enemy_ids, + form_data=dict(request.form)) + + if not session_id: + return '
No session selected
', 400 + + if not enemy_ids: + return '
No enemies selected
', 400 + + try: + response = client.post('/api/v1/combat/start', { + 'session_id': session_id, + 'enemy_ids': enemy_ids + }) + result = response.get('result', {}) + + # Return redirect script to combat session page + return f''' + +
Combat started! Redirecting...
+ ''' + except APIError as e: + logger.error("failed_to_start_combat", session_id=session_id, error=str(e)) + return f'
Failed to start combat: {e}
', 500 + + +@dev_bp.route('/combat/session/') +@require_auth +def combat_session(session_id: str): + """Combat session debug interface - full 3-column layout.""" + client = get_api_client() + + try: + # Get combat state from API + response = client.get(f'/api/v1/combat/{session_id}/state') + result = response.get('result', {}) + + # Check if combat is still active + if not result.get('in_combat'): + # Combat ended - redirect to combat index + return render_template('dev/combat.html', + message="Combat has ended. Start a new combat to continue.") + + encounter = result.get('encounter') or {} + combat_log = result.get('combat_log', []) + + # Get current turn combatant ID directly from API response + current_turn_id = encounter.get('current_turn') + turn_order = encounter.get('turn_order', []) + + # Find player and determine if it's player's turn + is_player_turn = False + player_combatant = None + enemy_combatants = [] + for combatant in encounter.get('combatants', []): + if combatant.get('is_player'): + player_combatant = combatant + if combatant.get('combatant_id') == current_turn_id: + is_player_turn = True + else: + enemy_combatants.append(combatant) + + # Format combat log entries for display + formatted_log = [] + for entry in combat_log: + log_entry = { + 'actor': entry.get('combatant_name', entry.get('actor', '')), + 'message': entry.get('message', ''), + 'damage': entry.get('damage'), + 'heal': entry.get('healing'), + 'is_crit': entry.get('is_critical', False), + 'type': 'player' if entry.get('is_player', False) else 'enemy' + } + # Detect system messages + if entry.get('action_type') in ('round_start', 'combat_start', 'combat_end'): + log_entry['type'] = 'system' + formatted_log.append(log_entry) + + return render_template( + 'dev/combat_session.html', + session_id=session_id, + encounter=encounter, + combat_log=formatted_log, + current_turn_id=current_turn_id, + is_player_turn=is_player_turn, + player_combatant=player_combatant, + enemy_combatants=enemy_combatants, + turn_order=turn_order, + raw_state=result + ) + + except APINotFoundError: + logger.warning("combat_not_found", session_id=session_id) + return render_template('dev/combat.html', error=f"No active combat for session {session_id}"), 404 + except APIError as e: + logger.error("failed_to_load_combat_session", session_id=session_id, error=str(e)) + return render_template('dev/combat.html', error=str(e)), 500 + + +@dev_bp.route('/combat//state') +@require_auth +def combat_state(session_id: str): + """Get combat state partial - returns refreshable state panel.""" + client = get_api_client() + + try: + response = client.get(f'/api/v1/combat/{session_id}/state') + result = response.get('result', {}) + + # Check if combat is still active + if not result.get('in_combat'): + return '

Combat Ended

No active combat.

' + + encounter = result.get('encounter') or {} + + # Get current turn combatant ID directly from API response + current_turn_id = encounter.get('current_turn') + + # Separate player and enemies + player_combatant = None + enemy_combatants = [] + is_player_turn = False + for combatant in encounter.get('combatants', []): + if combatant.get('is_player'): + player_combatant = combatant + if combatant.get('combatant_id') == current_turn_id: + is_player_turn = True + else: + enemy_combatants.append(combatant) + + return render_template( + 'dev/partials/combat_state.html', + session_id=session_id, + encounter=encounter, + player_combatant=player_combatant, + enemy_combatants=enemy_combatants, + current_turn_id=current_turn_id, + is_player_turn=is_player_turn + ) + + except APIError as e: + logger.error("failed_to_get_combat_state", session_id=session_id, error=str(e)) + return f'
Failed to load state: {e}
', 500 + + +@dev_bp.route('/combat//action', methods=['POST']) +@require_auth +def combat_action(session_id: str): + """Execute a combat action - returns log entry HTML.""" + client = get_api_client() + + action_type = request.form.get('action_type', 'attack') + ability_id = request.form.get('ability_id') + item_id = request.form.get('item_id') + target_id = request.form.get('target_id') + + try: + payload = {'action_type': action_type} + if ability_id: + payload['ability_id'] = ability_id + if item_id: + payload['item_id'] = item_id + if target_id: + payload['target_id'] = target_id + + response = client.post(f'/api/v1/combat/{session_id}/action', payload) + result = response.get('result', {}) + + # Check if combat ended + combat_ended = result.get('combat_ended', False) + combat_status = result.get('combat_status') + + if combat_ended: + # API returns lowercase status values: 'victory', 'defeat', 'fled' + status_lower = (combat_status or '').lower() + if status_lower == 'victory': + return render_template( + 'dev/partials/combat_victory.html', + session_id=session_id, + rewards=result.get('rewards', {}) + ) + elif status_lower == 'defeat': + return render_template( + 'dev/partials/combat_defeat.html', + session_id=session_id, + gold_lost=result.get('gold_lost', 0) + ) + + # Format action result for log + # API returns data directly in result, not nested under 'action_result' + log_entries = [] + + player_entry = { + 'actor': 'You', + 'message': result.get('message', f'used {action_type}'), + 'type': 'player', + 'is_crit': False + } + + damage_results = result.get('damage_results', []) + if damage_results: + for dmg in damage_results: + player_entry['damage'] = dmg.get('total_damage') or dmg.get('damage') + player_entry['is_crit'] = dmg.get('is_critical', False) + if player_entry['is_crit']: + player_entry['type'] = 'crit' + + if result.get('healing'): + player_entry['heal'] = result.get('healing') + player_entry['type'] = 'heal' + + log_entries.append(player_entry) + + for effect in result.get('effects_applied', []): + log_entries.append({ + 'actor': '', + 'message': effect.get('message', f'Effect applied: {effect.get("name")}'), + 'type': 'system' + }) + + # Return log entries with optional enemy turn trigger + from flask import make_response + resp = make_response(render_template( + 'dev/partials/combat_debug_log.html', + combat_log=log_entries + )) + + # Trigger enemy turn if needed + if result.get('next_combatant_id') and not result.get('next_is_player', True): + resp.headers['HX-Trigger'] = 'enemyTurn' + + return resp + + except APIError as e: + logger.error("combat_action_failed", session_id=session_id, action_type=action_type, error=str(e)) + return f''' +
+ Action failed: {e} +
+ ''', 500 + + +@dev_bp.route('/combat//enemy-turn', methods=['POST']) +@require_auth +def combat_enemy_turn(session_id: str): + """Execute enemy turn - returns log entry HTML.""" + client = get_api_client() + + try: + response = client.post(f'/api/v1/combat/{session_id}/enemy-turn', {}) + result = response.get('result', {}) + + # Check if combat ended + combat_ended = result.get('combat_ended', False) + combat_status = result.get('combat_status') + + if combat_ended: + # API returns lowercase status values: 'victory', 'defeat', 'fled' + status_lower = (combat_status or '').lower() + if status_lower == 'victory': + return render_template( + 'dev/partials/combat_victory.html', + session_id=session_id, + rewards=result.get('rewards', {}) + ) + elif status_lower == 'defeat': + return render_template( + 'dev/partials/combat_defeat.html', + session_id=session_id, + gold_lost=result.get('gold_lost', 0) + ) + + # Format enemy action for log + # The API returns the action result directly with a complete message + damage_results = result.get('damage_results', []) + is_crit = damage_results[0].get('is_critical', False) if damage_results else False + + log_entries = [{ + 'actor': '', # Message already contains the actor name + 'message': result.get('message', 'Enemy attacks!'), + 'type': 'crit' if is_crit else 'enemy', + 'is_crit': is_crit, + 'damage': damage_results[0].get('total_damage') if damage_results else None + }] + + from flask import make_response + resp = make_response(render_template( + 'dev/partials/combat_debug_log.html', + combat_log=log_entries + )) + + # Trigger another enemy turn if needed + if result.get('next_combatant_id') and not result.get('next_is_player', True): + resp.headers['HX-Trigger'] = 'enemyTurn' + + return resp + + except APIError as e: + logger.error("enemy_turn_failed", session_id=session_id, error=str(e)) + return f''' +
+ Enemy turn error: {e} +
+ ''', 500 + + +@dev_bp.route('/combat//abilities') +@require_auth +def combat_abilities(session_id: str): + """Get abilities modal for combat.""" + client = get_api_client() + + try: + response = client.get(f'/api/v1/combat/{session_id}/state') + result = response.get('result', {}) + encounter = result.get('encounter', {}) + + player_combatant = None + for combatant in encounter.get('combatants', []): + if combatant.get('is_player'): + player_combatant = combatant + break + + abilities = [] + if player_combatant: + ability_ids = player_combatant.get('abilities', []) + current_mp = player_combatant.get('current_mp', 0) + cooldowns = player_combatant.get('cooldowns', {}) + + for ability_id in ability_ids: + try: + ability_response = client.get(f'/api/v1/abilities/{ability_id}') + ability_data = ability_response.get('result', {}) + + mp_cost = ability_data.get('mp_cost', 0) + cooldown = cooldowns.get(ability_id, 0) + available = current_mp >= mp_cost and cooldown == 0 + + abilities.append({ + 'id': ability_id, + 'name': ability_data.get('name', ability_id), + 'description': ability_data.get('description', ''), + 'mp_cost': mp_cost, + 'cooldown': cooldown, + 'max_cooldown': ability_data.get('cooldown', 0), + 'damage_type': ability_data.get('damage_type'), + 'effect_type': ability_data.get('effect_type'), + 'available': available + }) + except (APINotFoundError, APIError): + abilities.append({ + 'id': ability_id, + 'name': ability_id.replace('_', ' ').title(), + 'description': '', + 'mp_cost': 0, + 'cooldown': cooldowns.get(ability_id, 0), + 'max_cooldown': 0, + 'available': True + }) + + return render_template( + 'dev/partials/ability_modal.html', + session_id=session_id, + abilities=abilities + ) + + except APIError as e: + logger.error("failed_to_load_abilities", session_id=session_id, error=str(e)) + return f''' + + ''' + + +@dev_bp.route('/combat//items') +@require_auth +def combat_items(session_id: str): + """Get combat items bottom sheet (consumables only).""" + client = get_api_client() + + try: + session_response = client.get(f'/api/v1/sessions/{session_id}') + session_data = session_response.get('result', {}) + character_id = session_data.get('character_id') + + consumables = [] + if character_id: + try: + inv_response = client.get(f'/api/v1/characters/{character_id}/inventory') + inv_data = inv_response.get('result', {}) + inventory = inv_data.get('inventory', []) + + for item in inventory: + item_type = item.get('item_type', item.get('type', '')) + if item_type == 'consumable' or item.get('usable_in_combat', False): + consumables.append({ + 'item_id': item.get('item_id'), + 'name': item.get('name', 'Unknown Item'), + 'description': item.get('description', ''), + 'effects_on_use': item.get('effects_on_use', []), + 'rarity': item.get('rarity', 'common') + }) + except (APINotFoundError, APIError) as e: + logger.warning("failed_to_load_combat_inventory", character_id=character_id, error=str(e)) + + return render_template( + 'dev/partials/combat_items_sheet.html', + session_id=session_id, + consumables=consumables, + has_consumables=len(consumables) > 0 + ) + + except APIError as e: + logger.error("failed_to_load_items", session_id=session_id, error=str(e)) + return f''' +
+
+

Use Item

+ +
+
+
Failed to load items: {e}
+
+
+
+ ''' + + +@dev_bp.route('/combat//items//detail') +@require_auth +def combat_item_detail(session_id: str, item_id: str): + """Get item detail for combat bottom sheet.""" + client = get_api_client() + + try: + session_response = client.get(f'/api/v1/sessions/{session_id}') + session_data = session_response.get('result', {}) + character_id = session_data.get('character_id') + + item = None + if character_id: + inv_response = client.get(f'/api/v1/characters/{character_id}/inventory') + inv_data = inv_response.get('result', {}) + inventory = inv_data.get('inventory', []) + + for inv_item in inventory: + if inv_item.get('item_id') == item_id: + item = inv_item + break + + if not item: + return '

Item not found

', 404 + + effect_desc = item.get('description', 'Use this item') + effects = item.get('effects_on_use', []) + if effects: + effect_parts = [] + for effect in effects: + if effect.get('stat') == 'hp': + effect_parts.append(f"Restores {effect.get('value', 0)} HP") + elif effect.get('stat') == 'mp': + effect_parts.append(f"Restores {effect.get('value', 0)} MP") + elif effect.get('name'): + effect_parts.append(effect.get('name')) + if effect_parts: + effect_desc = ', '.join(effect_parts) + + return f''' +
+
{item.get('name', 'Item')}
+
{effect_desc}
+
+ + ''' + + except APIError as e: + logger.error("failed_to_load_combat_item_detail", session_id=session_id, item_id=item_id, error=str(e)) + return f'

Failed to load item: {e}

', 500 + + +@dev_bp.route('/combat//end', methods=['POST']) +@require_auth +def force_end_combat(session_id: str): + """Force end combat (debug action).""" + client = get_api_client() + + victory = request.form.get('victory', 'true').lower() == 'true' + + try: + response = client.post(f'/api/v1/combat/{session_id}/end', {'victory': victory}) + result = response.get('result', {}) + + if victory: + return render_template( + 'dev/partials/combat_victory.html', + session_id=session_id, + rewards=result.get('rewards', {}) + ) + else: + return render_template( + 'dev/partials/combat_defeat.html', + session_id=session_id, + gold_lost=result.get('gold_lost', 0) + ) + + except APIError as e: + logger.error("failed_to_end_combat", session_id=session_id, error=str(e)) + return f'
Failed to end combat: {e}
', 500 + + +@dev_bp.route('/combat//reset-hp-mp', methods=['POST']) +@require_auth +def reset_hp_mp(session_id: str): + """Reset player HP and MP to full (debug action).""" + client = get_api_client() + + try: + response = client.post(f'/api/v1/combat/{session_id}/debug/reset-hp-mp', {}) + result = response.get('result', {}) + + return f''' +
+ HP and MP restored to full! (HP: {result.get('current_hp', '?')}/{result.get('max_hp', '?')}, MP: {result.get('current_mp', '?')}/{result.get('max_mp', '?')}) +
+ ''' + + except APIError as e: + logger.error("failed_to_reset_hp_mp", session_id=session_id, error=str(e)) + return f''' +
+ Failed to reset HP/MP: {e} +
+ ''', 500 + + +@dev_bp.route('/combat//log') +@require_auth +def combat_log(session_id: str): + """Get full combat log.""" + client = get_api_client() + + try: + response = client.get(f'/api/v1/combat/{session_id}/state') + result = response.get('result', {}) + combat_log_data = result.get('combat_log', []) + + formatted_log = [] + for entry in combat_log_data: + log_entry = { + 'actor': entry.get('combatant_name', entry.get('actor', '')), + 'message': entry.get('message', ''), + 'damage': entry.get('damage'), + 'heal': entry.get('healing'), + 'is_crit': entry.get('is_critical', False), + 'type': 'player' if entry.get('is_player', False) else 'enemy' + } + if entry.get('action_type') in ('round_start', 'combat_start', 'combat_end'): + log_entry['type'] = 'system' + formatted_log.append(log_entry) + + return render_template( + 'dev/partials/combat_debug_log.html', + combat_log=formatted_log + ) + + except APIError as e: + logger.error("failed_to_load_combat_log", session_id=session_id, error=str(e)) + return '
Failed to load combat log
', 500 diff --git a/public_web/app/views/game_views.py b/public_web/app/views/game_views.py index 8801aea..d4caad4 100644 --- a/public_web/app/views/game_views.py +++ b/public_web/app/views/game_views.py @@ -7,7 +7,7 @@ Provides the main gameplay interface with 3-column layout: - Right: Accordions for history, quests, NPCs, map """ -from flask import Blueprint, render_template, request +from flask import Blueprint, render_template, request, redirect, url_for import structlog from ..utils.api_client import get_api_client, APIError, APINotFoundError @@ -866,6 +866,220 @@ def npc_chat_history(session_id: str, npc_id: str): return '
Failed to load history
', 500 +# ===== Inventory Routes ===== + +@game_bp.route('/session//inventory-modal') +@require_auth +def inventory_modal(session_id: str): + """ + Get inventory modal with all items. + + Supports filtering by item type via ?filter= parameter. + """ + client = get_api_client() + filter_type = request.args.get('filter', 'all') + + try: + # Get session to find character + session_response = client.get(f'/api/v1/sessions/{session_id}') + session_data = session_response.get('result', {}) + character_id = session_data.get('character_id') + + inventory = [] + equipped = {} + gold = 0 + inventory_count = 0 + inventory_max = 100 + + if character_id: + try: + inv_response = client.get(f'/api/v1/characters/{character_id}/inventory') + inv_data = inv_response.get('result', {}) + inventory = inv_data.get('inventory', []) + equipped = inv_data.get('equipped', {}) + inventory_count = inv_data.get('inventory_count', len(inventory)) + inventory_max = inv_data.get('max_inventory', 100) + + # Get gold from character + char_response = client.get(f'/api/v1/characters/{character_id}') + char_data = char_response.get('result', {}) + gold = char_data.get('gold', 0) + except (APINotFoundError, APIError) as e: + logger.warning("failed_to_load_inventory", character_id=character_id, error=str(e)) + + # Filter inventory by type if specified + if filter_type != 'all': + inventory = [item for item in inventory if item.get('item_type') == filter_type] + + return render_template( + 'game/partials/inventory_modal.html', + session_id=session_id, + inventory=inventory, + equipped=equipped, + gold=gold, + inventory_count=inventory_count, + inventory_max=inventory_max, + filter=filter_type + ) + + except APIError as e: + logger.error("failed_to_load_inventory_modal", session_id=session_id, error=str(e)) + return f''' + + ''' + + +@game_bp.route('/session//inventory/item/') +@require_auth +def inventory_item_detail(session_id: str, item_id: str): + """Get item detail partial for HTMX swap.""" + client = get_api_client() + + try: + # Get session to find character + session_response = client.get(f'/api/v1/sessions/{session_id}') + session_data = session_response.get('result', {}) + character_id = session_data.get('character_id') + + item = None + if character_id: + # Get inventory and find the item + inv_response = client.get(f'/api/v1/characters/{character_id}/inventory') + inv_data = inv_response.get('result', {}) + inventory = inv_data.get('inventory', []) + + for inv_item in inventory: + if inv_item.get('item_id') == item_id: + item = inv_item + break + + if not item: + return '
Item not found
', 404 + + # Determine suggested slot for equipment + suggested_slot = None + item_type = item.get('item_type', '') + if item_type == 'weapon': + suggested_slot = 'weapon' + elif item_type == 'armor': + # Could be any armor slot - default to chest + suggested_slot = 'chest' + + return render_template( + 'game/partials/inventory_item_detail.html', + session_id=session_id, + item=item, + suggested_slot=suggested_slot + ) + + except APIError as e: + logger.error("failed_to_load_item_detail", session_id=session_id, item_id=item_id, error=str(e)) + return f'
Failed to load item: {e}
', 500 + + +@game_bp.route('/session//inventory/use', methods=['POST']) +@require_auth +def inventory_use(session_id: str): + """Use a consumable item.""" + client = get_api_client() + item_id = request.form.get('item_id') + + if not item_id: + return '
No item selected
', 400 + + try: + # Get session to find character + session_response = client.get(f'/api/v1/sessions/{session_id}') + session_data = session_response.get('result', {}) + character_id = session_data.get('character_id') + + if not character_id: + return '
No character found
', 400 + + # Use the item via API + client.post(f'/api/v1/characters/{character_id}/inventory/use', { + 'item_id': item_id + }) + + # Return updated character panel + return redirect(url_for('game.character_panel', session_id=session_id)) + + except APIError as e: + logger.error("failed_to_use_item", session_id=session_id, item_id=item_id, error=str(e)) + return f'
Failed to use item: {e}
', 500 + + +@game_bp.route('/session//inventory/equip', methods=['POST']) +@require_auth +def inventory_equip(session_id: str): + """Equip an item to a slot.""" + client = get_api_client() + item_id = request.form.get('item_id') + slot = request.form.get('slot') + + if not item_id: + return '
No item selected
', 400 + + try: + # Get session to find character + session_response = client.get(f'/api/v1/sessions/{session_id}') + session_data = session_response.get('result', {}) + character_id = session_data.get('character_id') + + if not character_id: + return '
No character found
', 400 + + # Equip the item via API + payload = {'item_id': item_id} + if slot: + payload['slot'] = slot + + client.post(f'/api/v1/characters/{character_id}/inventory/equip', payload) + + # Return updated character panel + return redirect(url_for('game.character_panel', session_id=session_id)) + + except APIError as e: + logger.error("failed_to_equip_item", session_id=session_id, item_id=item_id, error=str(e)) + return f'
Failed to equip item: {e}
', 500 + + +@game_bp.route('/session//inventory/', methods=['DELETE']) +@require_auth +def inventory_drop(session_id: str, item_id: str): + """Drop (delete) an item from inventory.""" + client = get_api_client() + + try: + # Get session to find character + session_response = client.get(f'/api/v1/sessions/{session_id}') + session_data = session_response.get('result', {}) + character_id = session_data.get('character_id') + + if not character_id: + return '
No character found
', 400 + + # Delete the item via API + client.delete(f'/api/v1/characters/{character_id}/inventory/{item_id}') + + # Return updated inventory modal + return redirect(url_for('game.inventory_modal', session_id=session_id)) + + except APIError as e: + logger.error("failed_to_drop_item", session_id=session_id, item_id=item_id, error=str(e)) + return f'
Failed to drop item: {e}
', 500 + + @game_bp.route('/session//npc//talk', methods=['POST']) @require_auth def talk_to_npc(session_id: str, npc_id: str): diff --git a/public_web/static/css/combat.css b/public_web/static/css/combat.css new file mode 100644 index 0000000..e3c207c --- /dev/null +++ b/public_web/static/css/combat.css @@ -0,0 +1,1178 @@ +/** + * Code of Conquest - Combat Screen Stylesheet + * Turn-based combat interface with 3-column layout + */ + +/* ===== COMBAT SCREEN VARIABLES ===== */ +:root { + /* Combat-specific colors */ + --combat-player: #3b82f6; /* Blue for player actions */ + --combat-enemy: #ef4444; /* Red for enemy actions */ + --combat-crit: var(--accent-gold); /* Gold for critical hits */ + --combat-system: var(--text-muted); /* Gray for system messages */ + --combat-heal: var(--accent-green); /* Green for healing */ + + /* Combat panel sizing */ + --combat-sidebar-width: 280px; + --combat-header-height: 60px; + --combat-actions-height: 120px; +} + +/* ===== COMBAT PAGE LAYOUT ===== */ +.combat-page main { + padding: 0; + align-items: stretch; + justify-content: stretch; +} + +.combat-container { + display: grid; + grid-template-columns: var(--combat-sidebar-width) 1fr var(--combat-sidebar-width); + grid-template-rows: auto 1fr; + gap: 1rem; + height: calc(100vh - 140px); + padding: 1rem; + max-width: 2400px; + margin: 0 auto; +} + +/* ===== COMBAT HEADER ===== */ +.combat-header { + grid-column: 1 / -1; + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem 1.25rem; + background: var(--bg-tertiary); + border: 1px solid var(--border-ornate); + border-radius: 8px; +} + +.combat-title { + font-family: var(--font-heading); + font-size: var(--text-xl); + color: var(--accent-gold); + text-transform: uppercase; + letter-spacing: 2px; + display: flex; + align-items: center; + gap: 0.75rem; +} + +.combat-title-icon { + font-size: var(--text-2xl); +} + +.combat-round { + display: flex; + align-items: center; + gap: 1rem; +} + +.round-counter { + font-family: var(--font-heading); + font-size: var(--text-lg); + color: var(--text-primary); +} + +.round-counter strong { + color: var(--accent-gold); +} + +.turn-indicator { + padding: 0.375rem 0.75rem; + border-radius: 4px; + font-size: var(--text-sm); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.turn-indicator--player { + background: rgba(59, 130, 246, 0.2); + color: var(--combat-player); + border: 1px solid var(--combat-player); +} + +.turn-indicator--enemy { + background: rgba(239, 68, 68, 0.2); + color: var(--combat-enemy); + border: 1px solid var(--combat-enemy); +} + +/* ===== COMBATANT PANEL (Left Column) ===== */ +.combatant-panel { + background: var(--bg-secondary); + border: 1px solid var(--border-primary); + border-radius: 8px; + overflow-y: auto; + display: flex; + flex-direction: column; +} + +.combatant-section { + padding: 1rem; + border-bottom: 1px solid var(--border-primary); +} + +.combatant-section:last-child { + border-bottom: none; +} + +.combatant-section-title { + font-family: var(--font-heading); + font-size: var(--text-xs); + color: var(--accent-gold); + text-transform: uppercase; + letter-spacing: 1px; + margin-bottom: 0.75rem; +} + +/* Combatant Card */ +.combatant-card { + padding: 0.75rem; + background: var(--bg-input); + border-radius: 6px; + margin-bottom: 0.5rem; + border-left: 3px solid transparent; + transition: all 0.2s ease; +} + +.combatant-card:last-child { + margin-bottom: 0; +} + +.combatant-card--player { + border-left-color: var(--combat-player); +} + +.combatant-card--enemy { + border-left-color: var(--combat-enemy); +} + +.combatant-card--active { + background: var(--bg-tertiary); + box-shadow: 0 0 10px rgba(243, 156, 18, 0.2); +} + +.combatant-card--defeated { + opacity: 0.5; +} + +.combatant-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.5rem; +} + +.combatant-name { + font-size: var(--text-sm); + font-weight: 600; + color: var(--text-primary); +} + +.combatant-level { + font-size: var(--text-xs); + color: var(--text-muted); +} + +/* Resource Bars (HP/MP) */ +.combatant-resources { + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.resource-bar { + position: relative; +} + +.resource-bar-label { + display: flex; + justify-content: space-between; + font-size: var(--text-xs); + margin-bottom: 0.125rem; +} + +.resource-bar-name { + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.resource-bar-value { + color: var(--text-primary); + font-weight: 600; +} + +.resource-bar-track { + height: 6px; + background: var(--bg-tertiary); + border-radius: 3px; + overflow: hidden; +} + +.resource-bar-fill { + height: 100%; + border-radius: 3px; + transition: width 0.3s ease; +} + +.resource-bar--hp .resource-bar-fill { + background: linear-gradient(90deg, var(--hp-bar-fill), #f87171); +} + +.resource-bar--mp .resource-bar-fill { + background: linear-gradient(90deg, var(--mp-bar-fill), #60a5fa); +} + +/* Low HP warning animation */ +.resource-bar--hp.low .resource-bar-fill { + animation: pulse-hp 1s ease-in-out infinite; +} + +@keyframes pulse-hp { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.6; } +} + +/* ===== COMBAT MAIN (Center Column) ===== */ +.combat-main { + background: var(--bg-secondary); + border: 1px solid var(--border-primary); + border-radius: 8px; + display: flex; + flex-direction: column; + overflow: hidden; +} + +/* Combat Log */ +.combat-log { + flex: 1; + padding: 1rem; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.combat-log__entry { + padding: 0.625rem 0.875rem; + background: var(--bg-input); + border-radius: 6px; + font-size: var(--text-sm); + line-height: 1.5; + border-left: 3px solid transparent; + animation: slideInLog 0.2s ease; +} + +@keyframes slideInLog { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.combat-log__entry--player { + border-left-color: var(--combat-player); + background: rgba(59, 130, 246, 0.1); +} + +.combat-log__entry--enemy { + border-left-color: var(--combat-enemy); + background: rgba(239, 68, 68, 0.1); +} + +.combat-log__entry--crit { + border-left-color: var(--combat-crit); + background: rgba(243, 156, 18, 0.15); +} + +.combat-log__entry--system { + border-left-color: var(--combat-system); + background: var(--bg-tertiary); + font-style: italic; + color: var(--text-secondary); +} + +.combat-log__entry--heal { + border-left-color: var(--combat-heal); + background: rgba(39, 174, 96, 0.1); +} + +.log-actor { + font-weight: 600; + color: var(--text-primary); +} + +.log-message { + color: var(--text-secondary); +} + +.log-damage { + font-weight: 700; + color: var(--combat-enemy); + margin-left: 0.25rem; +} + +.log-damage--crit { + color: var(--combat-crit); + font-size: var(--text-base); +} + +.log-heal { + font-weight: 700; + color: var(--combat-heal); + margin-left: 0.25rem; +} + +/* Empty log state */ +.combat-log__empty { + text-align: center; + color: var(--text-muted); + font-style: italic; + padding: 2rem; +} + +/* Combat Actions */ +.combat-actions { + padding: 1rem; + background: var(--bg-tertiary); + border-top: 1px solid var(--border-primary); +} + +.combat-actions__grid { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 0.5rem; +} + +.combat-action-btn { + padding: 0.75rem 0.5rem; + font-family: var(--font-heading); + font-size: var(--text-sm); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + border: 2px solid; + border-radius: 6px; + cursor: pointer; + transition: all 0.2s ease; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.25rem; +} + +.combat-action-btn:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: var(--shadow-md); +} + +.combat-action-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.combat-action-btn__icon { + font-size: var(--text-xl); +} + +/* Action button variants */ +.combat-action-btn--attack { + background: linear-gradient(135deg, var(--accent-red) 0%, #dc2626 100%); + border-color: var(--accent-red); + color: white; +} + +.combat-action-btn--attack:hover:not(:disabled) { + box-shadow: 0 0 15px rgba(239, 68, 68, 0.4); +} + +.combat-action-btn--ability { + background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%); + border-color: #8b5cf6; + color: white; +} + +.combat-action-btn--ability:hover:not(:disabled) { + box-shadow: 0 0 15px rgba(139, 92, 246, 0.4); +} + +.combat-action-btn--item { + background: linear-gradient(135deg, var(--accent-green) 0%, #16a34a 100%); + border-color: var(--accent-green); + color: white; +} + +.combat-action-btn--item:hover:not(:disabled) { + box-shadow: 0 0 15px rgba(39, 174, 96, 0.4); +} + +.combat-action-btn--defend { + background: linear-gradient(135deg, var(--accent-blue) 0%, #2563eb 100%); + border-color: var(--accent-blue); + color: white; +} + +.combat-action-btn--defend:hover:not(:disabled) { + box-shadow: 0 0 15px rgba(52, 152, 219, 0.4); +} + +.combat-action-btn--flee { + background: transparent; + border-color: var(--border-primary); + color: var(--text-secondary); +} + +.combat-action-btn--flee:hover:not(:disabled) { + border-color: var(--text-muted); + color: var(--text-primary); +} + +/* Disabled state message */ +.combat-actions__disabled-message { + text-align: center; + padding: 0.5rem; + font-size: var(--text-sm); + color: var(--text-muted); + font-style: italic; +} + +/* ===== COMBAT SIDEBAR (Right Column) ===== */ +.combat-sidebar { + background: var(--bg-secondary); + border: 1px solid var(--border-primary); + border-radius: 8px; + overflow-y: auto; + display: flex; + flex-direction: column; +} + +/* Turn Order */ +.turn-order { + padding: 1rem; + border-bottom: 1px solid var(--border-primary); +} + +.turn-order__title { + font-family: var(--font-heading); + font-size: var(--text-xs); + color: var(--accent-gold); + text-transform: uppercase; + letter-spacing: 1px; + margin-bottom: 0.75rem; +} + +.turn-order__list { + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.turn-order__item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.625rem; + background: var(--bg-input); + border-radius: 4px; + font-size: var(--text-sm); + border-left: 3px solid transparent; + transition: all 0.2s ease; +} + +.turn-order__item--active { + background: var(--bg-tertiary); + border-left-color: var(--accent-gold); + box-shadow: 0 0 8px rgba(243, 156, 18, 0.2); +} + +.turn-order__item--player { + border-left-color: var(--combat-player); +} + +.turn-order__item--enemy { + border-left-color: var(--combat-enemy); +} + +.turn-order__item--defeated { + opacity: 0.4; + text-decoration: line-through; +} + +.turn-order__position { + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + font-size: var(--text-xs); + font-weight: 600; + background: var(--bg-tertiary); + border-radius: 50%; + color: var(--text-muted); +} + +.turn-order__item--active .turn-order__position { + background: var(--accent-gold); + color: var(--bg-primary); +} + +.turn-order__name { + flex: 1; + color: var(--text-primary); +} + +.turn-order__check { + color: var(--accent-green); + font-size: var(--text-sm); +} + +/* Active Effects */ +.effects-panel { + padding: 1rem; + flex: 1; +} + +.effects-panel__title { + font-family: var(--font-heading); + font-size: var(--text-xs); + color: var(--accent-gold); + text-transform: uppercase; + letter-spacing: 1px; + margin-bottom: 0.75rem; +} + +.effects-list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.effect-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.625rem; + background: var(--bg-input); + border-radius: 4px; + font-size: var(--text-sm); +} + +.effect-item--buff { + border-left: 3px solid var(--accent-green); +} + +.effect-item--debuff { + border-left: 3px solid var(--accent-red); +} + +.effect-item--shield { + border-left: 3px solid var(--accent-blue); +} + +.effect-icon { + font-size: var(--text-base); +} + +.effect-name { + flex: 1; + color: var(--text-primary); +} + +.effect-duration { + font-size: var(--text-xs); + color: var(--text-muted); + padding: 0.125rem 0.375rem; + background: var(--bg-tertiary); + border-radius: 3px; +} + +.effects-empty { + text-align: center; + color: var(--text-muted); + font-size: var(--text-sm); + font-style: italic; + padding: 1rem; +} + +/* ===== ABILITY MODAL ===== */ +.ability-list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.ability-btn { + display: flex; + align-items: center; + gap: 0.75rem; + width: 100%; + padding: 0.75rem 1rem; + background: var(--bg-input); + border: 1px solid var(--border-primary); + border-radius: 6px; + cursor: pointer; + transition: all 0.2s ease; + text-align: left; +} + +.ability-btn:hover:not(:disabled) { + border-color: #8b5cf6; + background: rgba(139, 92, 246, 0.1); +} + +.ability-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.ability-icon { + font-size: var(--text-xl); + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-tertiary); + border-radius: 6px; +} + +.ability-info { + flex: 1; + min-width: 0; +} + +.ability-name { + font-size: var(--text-sm); + font-weight: 600; + color: var(--text-primary); + margin-bottom: 0.125rem; +} + +.ability-description { + font-size: var(--text-xs); + color: var(--text-muted); + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.ability-meta { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.25rem; +} + +.ability-cost { + font-size: var(--text-xs); + font-weight: 600; + color: var(--mp-bar-fill); + padding: 0.125rem 0.375rem; + background: rgba(59, 130, 246, 0.2); + border-radius: 3px; +} + +.ability-cooldown { + font-size: var(--text-xs); + color: var(--text-muted); +} + +.ability-cooldown--active { + color: var(--accent-red); +} + +/* ===== ITEM MODAL ===== */ +.item-list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.item-btn { + display: flex; + align-items: center; + gap: 0.75rem; + width: 100%; + padding: 0.75rem 1rem; + background: var(--bg-input); + border: 1px solid var(--border-primary); + border-radius: 6px; + cursor: pointer; + transition: all 0.2s ease; + text-align: left; +} + +.item-btn:hover:not(:disabled) { + border-color: var(--accent-green); + background: rgba(39, 174, 96, 0.1); +} + +.item-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.item-icon { + font-size: var(--text-xl); + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-tertiary); + border-radius: 6px; +} + +.item-info { + flex: 1; + min-width: 0; +} + +.item-name { + font-size: var(--text-sm); + font-weight: 600; + color: var(--text-primary); + margin-bottom: 0.125rem; +} + +.item-effect { + font-size: var(--text-xs); + color: var(--text-muted); +} + +.item-quantity { + font-size: var(--text-sm); + font-weight: 600; + color: var(--text-primary); + padding: 0.25rem 0.5rem; + background: var(--bg-tertiary); + border-radius: 4px; +} + +.items-empty { + text-align: center; + color: var(--text-muted); + font-style: italic; + padding: 2rem; +} + +/* ===== VICTORY/DEFEAT SCREENS ===== */ +.combat-result { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 60vh; + padding: 2rem; + text-align: center; +} + +.combat-result__icon { + font-size: 5rem; + margin-bottom: 1rem; + animation: bounceIn 0.5s ease; +} + +@keyframes bounceIn { + 0% { transform: scale(0); } + 50% { transform: scale(1.2); } + 100% { transform: scale(1); } +} + +.combat-result__title { + font-family: var(--font-heading); + font-size: var(--text-3xl); + text-transform: uppercase; + letter-spacing: 3px; + margin-bottom: 0.5rem; +} + +.combat-result--victory .combat-result__title { + color: var(--accent-gold); +} + +.combat-result--defeat .combat-result__title { + color: var(--accent-red); +} + +.combat-result__subtitle { + font-size: var(--text-lg); + color: var(--text-secondary); + margin-bottom: 2rem; +} + +/* Rewards Section */ +.combat-rewards { + background: var(--bg-secondary); + border: 2px solid var(--border-ornate); + border-radius: 8px; + padding: 1.5rem 2rem; + margin-bottom: 2rem; + min-width: 300px; +} + +.rewards-title { + font-family: var(--font-heading); + font-size: var(--text-sm); + color: var(--accent-gold); + text-transform: uppercase; + letter-spacing: 1px; + margin-bottom: 1rem; + text-align: center; +} + +.rewards-list { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.reward-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.5rem; + background: var(--bg-input); + border-radius: 4px; +} + +.reward-icon { + font-size: var(--text-xl); +} + +.reward-label { + flex: 1; + font-size: var(--text-sm); + color: var(--text-secondary); +} + +.reward-value { + font-size: var(--text-sm); + font-weight: 600; +} + +.reward-value--xp { + color: var(--accent-gold); +} + +.reward-value--gold { + color: #fbbf24; +} + +.reward-value--level { + color: var(--accent-green); +} + +/* Loot Items */ +.loot-section { + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid var(--border-primary); +} + +.loot-title { + font-size: var(--text-xs); + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 0.75rem; +} + +.loot-list { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.loot-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.375rem 0.625rem; + background: var(--bg-tertiary); + border-radius: 4px; + font-size: var(--text-sm); +} + +.loot-item--common { + border-left: 3px solid #9ca3af; +} + +.loot-item--uncommon { + border-left: 3px solid var(--accent-green); +} + +.loot-item--rare { + border-left: 3px solid var(--accent-blue); +} + +.loot-item--epic { + border-left: 3px solid #8b5cf6; +} + +.loot-item--legendary { + border-left: 3px solid var(--accent-gold); +} + +/* Combat Result Actions */ +.combat-result__actions { + display: flex; + gap: 1rem; +} + +.combat-result__actions .btn { + min-width: 150px; +} + +/* ===== RESPONSIVE DESIGN ===== */ + +/* Tablet (768px - 1024px) */ +@media (max-width: 1024px) { + .combat-container { + grid-template-columns: 1fr var(--combat-sidebar-width); + height: auto; + min-height: calc(100vh - 140px); + } + + .combat-header { + grid-column: 1 / -1; + } + + .combatant-panel { + display: none; + } + + /* Show combatants inline in header on tablet */ + .combat-header { + flex-wrap: wrap; + gap: 0.75rem; + } + + .combat-actions__grid { + grid-template-columns: repeat(5, 1fr); + } +} + +/* Mobile (< 768px) */ +@media (max-width: 768px) { + .combat-container { + grid-template-columns: 1fr; + grid-template-rows: auto auto 1fr auto; + gap: 0; + padding: 0; + height: 100vh; + } + + .combat-header { + border-radius: 0; + padding: 0.75rem 1rem; + position: sticky; + top: 0; + z-index: 100; + } + + .combat-title { + font-size: var(--text-base); + } + + .combat-round { + flex-direction: column; + align-items: flex-end; + gap: 0.25rem; + } + + .round-counter { + font-size: var(--text-sm); + } + + .turn-indicator { + font-size: var(--text-xs); + padding: 0.25rem 0.5rem; + } + + /* Mobile combatants bar */ + .combatant-panel--mobile { + display: flex; + flex-direction: row; + overflow-x: auto; + padding: 0.5rem; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border-primary); + gap: 0.5rem; + } + + .combatant-card--mobile { + flex-shrink: 0; + width: 140px; + padding: 0.5rem; + } + + .combat-main { + border-radius: 0; + border-left: none; + border-right: none; + flex: 1; + min-height: 0; + } + + .combat-log { + padding: 0.75rem; + } + + .combat-actions { + position: sticky; + bottom: 0; + padding: 0.75rem; + border-radius: 0; + } + + .combat-actions__grid { + grid-template-columns: repeat(3, 1fr); + grid-template-rows: repeat(2, 1fr); + } + + .combat-action-btn { + padding: 0.625rem 0.375rem; + font-size: var(--text-xs); + } + + .combat-action-btn__icon { + font-size: var(--text-base); + } + + /* Hide sidebar on mobile */ + .combat-sidebar { + display: none; + } + + /* Victory/Defeat mobile adjustments */ + .combat-result { + padding: 1.5rem; + min-height: 50vh; + } + + .combat-result__icon { + font-size: 3rem; + } + + .combat-result__title { + font-size: var(--text-2xl); + } + + .combat-rewards { + padding: 1rem; + min-width: auto; + width: 100%; + } + + .combat-result__actions { + flex-direction: column; + width: 100%; + } + + .combat-result__actions .btn { + width: 100%; + } +} + +/* Very small screens */ +@media (max-width: 400px) { + .combat-actions__grid { + grid-template-columns: repeat(2, 1fr); + grid-template-rows: repeat(3, 1fr); + } +} + +/* ===== HTMX LOADING STATES ===== */ +.combat-action-btn.htmx-request { + opacity: 0.7; + pointer-events: none; +} + +.combat-action-btn.htmx-request::after { + content: ''; + position: absolute; + width: 16px; + height: 16px; + border: 2px solid transparent; + border-top-color: currentColor; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +/* Combat log loading indicator */ +.combat-log.htmx-request::after { + content: 'Processing...'; + display: block; + text-align: center; + padding: 0.5rem; + color: var(--text-muted); + font-style: italic; + font-size: var(--text-sm); +} + +/* ===== ACCESSIBILITY ===== */ +.combat-log { + /* Screen reader announcements */ +} + +.combat-log[aria-live="polite"] .combat-log__entry:last-child { + /* Most recent entry */ +} + +/* Focus styles */ +.combat-action-btn:focus { + outline: 2px solid var(--accent-gold); + outline-offset: 2px; +} + +.ability-btn:focus, +.item-btn:focus { + outline: 2px solid var(--accent-gold); + outline-offset: 2px; +} + +/* Reduced motion preference */ +@media (prefers-reduced-motion: reduce) { + .combat-log__entry, + .combat-result__icon, + .resource-bar-fill { + animation: none; + transition: none; + } +} + +/* ===== CUSTOM SCROLLBAR ===== */ +.combat-log::-webkit-scrollbar, +.combatant-panel::-webkit-scrollbar, +.combat-sidebar::-webkit-scrollbar { + width: 6px; +} + +.combat-log::-webkit-scrollbar-track, +.combatant-panel::-webkit-scrollbar-track, +.combat-sidebar::-webkit-scrollbar-track { + background: var(--bg-tertiary); +} + +.combat-log::-webkit-scrollbar-thumb, +.combatant-panel::-webkit-scrollbar-thumb, +.combat-sidebar::-webkit-scrollbar-thumb { + background: var(--border-primary); + border-radius: 3px; +} + +.combat-log::-webkit-scrollbar-thumb:hover, +.combatant-panel::-webkit-scrollbar-thumb:hover, +.combat-sidebar::-webkit-scrollbar-thumb:hover { + background: var(--text-muted); +} diff --git a/public_web/static/css/inventory.css b/public_web/static/css/inventory.css new file mode 100644 index 0000000..a4b2ceb --- /dev/null +++ b/public_web/static/css/inventory.css @@ -0,0 +1,722 @@ +/** + * Code of Conquest - Inventory UI Stylesheet + * Inventory modal, item grid, and combat items sheet + */ + +/* ===== INVENTORY VARIABLES ===== */ +:root { + /* Rarity colors */ + --rarity-common: #9ca3af; + --rarity-uncommon: #22c55e; + --rarity-rare: #3b82f6; + --rarity-epic: #a855f7; + --rarity-legendary: #f59e0b; + + /* Item card */ + --item-bg: var(--bg-input, #1e1e24); + --item-border: var(--border-primary, #3a3a45); + --item-hover-bg: rgba(255, 255, 255, 0.05); + + /* Touch targets - WCAG compliant */ + --touch-target-min: 48px; + --touch-target-primary: 56px; + --touch-spacing: 8px; +} + +/* ===== INVENTORY MODAL ===== */ +.inventory-modal { + max-width: 800px; + width: 95%; + max-height: 85vh; +} + +.inventory-modal .modal-body { + display: flex; + flex-direction: row; + gap: 1rem; + padding: 1rem; + overflow: hidden; +} + +/* ===== TAB FILTER BAR ===== */ +.inventory-tabs { + display: flex; + gap: 0.25rem; + padding: 0 1rem; + background: var(--bg-tertiary, #16161a); + border-bottom: 1px solid var(--play-border, #3a3a45); + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} + +.inventory-tabs .tab { + min-height: var(--touch-target-min); + padding: 0.75rem 1rem; + background: transparent; + border: none; + border-bottom: 2px solid transparent; + color: var(--text-secondary, #a0a0a8); + font-size: var(--text-sm, 0.875rem); + cursor: pointer; + white-space: nowrap; + transition: all 0.2s ease; +} + +.inventory-tabs .tab:hover { + color: var(--text-primary, #e5e5e5); + background: var(--item-hover-bg); +} + +.inventory-tabs .tab.active { + color: var(--accent-gold, #f3a61a); + border-bottom-color: var(--accent-gold, #f3a61a); +} + +/* ===== INVENTORY CONTENT LAYOUT ===== */ +.inventory-body { + flex: 1; + display: flex; + gap: 1rem; + overflow: hidden; +} + +.inventory-grid-container { + flex: 1; + overflow-y: auto; + padding-right: 0.5rem; +} + +/* ===== ITEM GRID ===== */ +.inventory-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: var(--touch-spacing); +} + +/* Responsive grid columns */ +@media (max-width: 900px) { + .inventory-grid { + grid-template-columns: repeat(3, 1fr); + } +} + +@media (max-width: 600px) { + .inventory-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +/* ===== INVENTORY ITEM CARD ===== */ +.inventory-item { + display: flex; + flex-direction: column; + align-items: center; + padding: 0.75rem 0.5rem; + min-height: 96px; + min-width: 80px; + background: var(--item-bg); + border: 2px solid var(--item-border); + border-radius: 8px; + cursor: pointer; + transition: all 0.2s ease; + position: relative; +} + +.inventory-item:hover, +.inventory-item:focus { + background: var(--item-hover-bg); + transform: translateY(-2px); +} + +.inventory-item:focus { + outline: 2px solid var(--accent-gold, #f3a61a); + outline-offset: 2px; +} + +.inventory-item.selected { + border-color: var(--accent-gold, #f3a61a); + box-shadow: 0 0 12px rgba(243, 166, 26, 0.3); +} + +/* Rarity border colors */ +.inventory-item.rarity-common { border-color: var(--rarity-common); } +.inventory-item.rarity-uncommon { border-color: var(--rarity-uncommon); } +.inventory-item.rarity-rare { border-color: var(--rarity-rare); } +.inventory-item.rarity-epic { border-color: var(--rarity-epic); } +.inventory-item.rarity-legendary { + border-color: var(--rarity-legendary); + box-shadow: 0 0 8px rgba(245, 158, 11, 0.3); +} + +/* Item icon */ +.inventory-item img { + width: 40px; + height: 40px; + object-fit: contain; + margin-bottom: 0.5rem; + opacity: 0.9; +} + +/* Item name */ +.inventory-item .item-name { + font-size: var(--text-xs, 0.75rem); + color: var(--text-primary, #e5e5e5); + text-align: center; + line-height: 1.2; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +/* Item quantity badge */ +.inventory-item .item-quantity { + position: absolute; + top: 4px; + right: 4px; + min-width: 20px; + height: 20px; + padding: 0 6px; + background: var(--bg-tertiary, #16161a); + border: 1px solid var(--item-border); + border-radius: 10px; + font-size: 0.7rem; + font-weight: 600; + color: var(--text-primary, #e5e5e5); + display: flex; + align-items: center; + justify-content: center; +} + +/* Empty state */ +.inventory-empty { + grid-column: 1 / -1; + text-align: center; + padding: 2rem; + color: var(--text-muted, #707078); + font-style: italic; +} + +/* ===== ITEM DETAIL PANEL ===== */ +.item-detail { + width: 280px; + min-width: 280px; + background: var(--bg-tertiary, #16161a); + border: 1px solid var(--play-border, #3a3a45); + border-radius: 8px; + padding: 1rem; + display: flex; + flex-direction: column; +} + +.item-detail-empty { + color: var(--text-muted, #707078); + text-align: center; + padding: 2rem 1rem; + font-style: italic; +} + +.item-detail-content { + display: flex; + flex-direction: column; + height: 100%; +} + +.item-detail-header { + display: flex; + align-items: flex-start; + gap: 0.75rem; + margin-bottom: 1rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--play-border, #3a3a45); +} + +.item-detail-icon { + width: 48px; + height: 48px; + object-fit: contain; +} + +.item-detail-title h3 { + font-family: var(--font-heading); + font-size: var(--text-lg, 1.125rem); + margin: 0 0 0.25rem 0; +} + +.item-detail-title .item-type { + font-size: var(--text-xs, 0.75rem); + color: var(--text-muted, #707078); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +/* Rarity text colors */ +.rarity-text-common { color: var(--rarity-common); } +.rarity-text-uncommon { color: var(--rarity-uncommon); } +.rarity-text-rare { color: var(--rarity-rare); } +.rarity-text-epic { color: var(--rarity-epic); } +.rarity-text-legendary { color: var(--rarity-legendary); } + +.item-description { + font-size: var(--text-sm, 0.875rem); + color: var(--text-secondary, #a0a0a8); + line-height: 1.5; + margin-bottom: 1rem; +} + +/* Item stats */ +.item-stats { + background: var(--item-bg); + border-radius: 6px; + padding: 0.75rem; + margin-bottom: 1rem; +} + +.item-stats div { + display: flex; + justify-content: space-between; + padding: 0.25rem 0; + font-size: var(--text-sm, 0.875rem); +} + +.item-stats div:not(:last-child) { + border-bottom: 1px solid var(--item-border); +} + +/* Item action buttons */ +.item-actions { + margin-top: auto; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.item-actions .action-btn { + min-height: var(--touch-target-primary); + padding: 0.75rem 1rem; + border: none; + border-radius: 6px; + font-size: var(--text-sm, 0.875rem); + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; +} + +.item-actions .action-btn--primary { + background: var(--accent-gold, #f3a61a); + color: var(--bg-primary, #0a0a0c); +} + +.item-actions .action-btn--primary:hover { + background: var(--accent-gold-hover, #e69500); +} + +.item-actions .action-btn--secondary { + background: var(--bg-input, #1e1e24); + border: 1px solid var(--play-border, #3a3a45); + color: var(--text-primary, #e5e5e5); +} + +.item-actions .action-btn--secondary:hover { + background: var(--item-hover-bg); + border-color: var(--text-muted, #707078); +} + +.item-actions .action-btn--danger { + background: transparent; + border: 1px solid #ef4444; + color: #ef4444; +} + +.item-actions .action-btn--danger:hover { + background: rgba(239, 68, 68, 0.1); +} + +/* ===== MODAL FOOTER ===== */ +.inventory-modal .modal-footer { + display: flex; + justify-content: space-between; + align-items: center; +} + +.gold-display { + font-size: var(--text-sm, 0.875rem); + color: var(--accent-gold, #f3a61a); + font-weight: 600; +} + +.gold-display::before { + content: "coins "; + font-size: 1.1em; +} + +/* ===== COMBAT ITEMS BOTTOM SHEET ===== */ +.combat-items-sheet { + position: fixed; + bottom: 0; + left: 0; + right: 0; + max-height: 70vh; + background: var(--bg-secondary, #12121a); + border: 2px solid var(--border-ornate, #f3a61a); + border-bottom: none; + border-radius: 16px 16px 0 0; + z-index: 1001; + display: flex; + flex-direction: column; + transform: translateY(100%); + transition: transform 0.3s ease-out; +} + +.combat-items-sheet.open { + transform: translateY(0); +} + +/* Sheet backdrop */ +.sheet-backdrop { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.6); + z-index: 1000; + animation: fadeIn 0.2s ease; +} + +/* Drag handle */ +.sheet-handle { + width: 40px; + height: 4px; + background: var(--text-muted, #707078); + border-radius: 2px; + margin: 8px auto; +} + +/* Sheet header */ +.sheet-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--play-border, #3a3a45); +} + +.sheet-header h3 { + font-family: var(--font-heading); + font-size: var(--text-lg, 1.125rem); + color: var(--accent-gold, #f3a61a); + margin: 0; +} + +.sheet-close { + width: var(--touch-target-min); + height: var(--touch-target-min); + background: none; + border: none; + color: var(--text-muted, #707078); + font-size: 1.5rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} + +.sheet-close:hover { + color: var(--text-primary, #e5e5e5); +} + +/* Sheet body */ +.sheet-body { + flex: 1; + padding: 1rem; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 1rem; +} + +/* Combat items grid - larger items for combat */ +.combat-items-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: var(--touch-spacing); +} + +.combat-item { + display: flex; + flex-direction: column; + align-items: center; + padding: 1rem; + min-height: 120px; + background: var(--item-bg); + border: 2px solid var(--rarity-common); + border-radius: 8px; + cursor: pointer; + transition: all 0.2s ease; +} + +.combat-item:hover, +.combat-item:focus { + background: var(--item-hover-bg); + border-color: var(--accent-gold, #f3a61a); +} + +.combat-item:focus { + outline: 2px solid var(--accent-gold, #f3a61a); + outline-offset: 2px; +} + +.combat-item.selected { + border-color: var(--accent-gold, #f3a61a); + box-shadow: 0 0 12px rgba(243, 166, 26, 0.3); +} + +.combat-item img { + width: 48px; + height: 48px; + margin-bottom: 0.5rem; +} + +.combat-item .item-name { + font-size: var(--text-sm, 0.875rem); + color: var(--text-primary, #e5e5e5); + font-weight: 500; + text-align: center; + margin-bottom: 0.25rem; +} + +.combat-item .item-effect { + font-size: var(--text-xs, 0.75rem); + color: var(--text-muted, #707078); + text-align: center; +} + +/* Combat item detail section */ +.combat-item-detail { + background: var(--bg-tertiary, #16161a); + border: 1px solid var(--play-border, #3a3a45); + border-radius: 8px; + padding: 1rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.combat-item-detail .detail-info { + flex: 1; +} + +.combat-item-detail .detail-name { + font-weight: 600; + color: var(--text-primary, #e5e5e5); + margin-bottom: 0.25rem; +} + +.combat-item-detail .detail-effect { + font-size: var(--text-sm, 0.875rem); + color: var(--text-secondary, #a0a0a8); +} + +.combat-item-detail .use-btn { + min-width: 100px; + min-height: var(--touch-target-primary); + padding: 0.75rem 1.5rem; + background: var(--hp-bar-fill, #ef4444); + border: none; + border-radius: 6px; + color: white; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; +} + +.combat-item-detail .use-btn:hover { + background: #dc2626; +} + +/* No consumables message */ +.no-consumables { + text-align: center; + padding: 2rem; + color: var(--text-muted, #707078); + font-style: italic; +} + +/* ===== MOBILE RESPONSIVENESS ===== */ + +/* Full-screen modal on mobile */ +@media (max-width: 768px) { + .inventory-modal { + width: 100vw; + height: 100vh; + max-width: 100vw; + max-height: 100vh; + border-radius: 0; + border: none; + } + + .inventory-modal .modal-body { + flex-direction: column; + padding: 0.75rem; + } + + /* Item detail slides in from right on mobile */ + .item-detail { + position: fixed; + top: 0; + right: 0; + bottom: 0; + width: 100%; + max-width: 320px; + min-width: unset; + z-index: 1002; + border-radius: 0; + border-left: 2px solid var(--border-ornate, #f3a61a); + transform: translateX(100%); + transition: transform 0.3s ease; + } + + .item-detail.visible { + transform: translateX(0); + } + + .item-detail-backdrop { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 1001; + } + + /* Back button for mobile detail view */ + .item-detail-back { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem; + margin: -1rem -1rem 1rem -1rem; + background: var(--bg-secondary, #12121a); + border: none; + border-bottom: 1px solid var(--play-border, #3a3a45); + color: var(--accent-gold, #f3a61a); + font-size: var(--text-sm, 0.875rem); + cursor: pointer; + width: calc(100% + 2rem); + } + + .item-detail-back:hover { + background: var(--item-hover-bg); + } + + /* Action buttons fixed at bottom on mobile */ + .item-actions { + position: sticky; + bottom: 0; + background: var(--bg-tertiary, #16161a); + padding: 1rem; + margin: auto -1rem -1rem -1rem; + border-top: 1px solid var(--play-border, #3a3a45); + } + + /* Larger touch targets on mobile */ + .inventory-item { + min-height: 88px; + padding: 0.5rem; + } + + /* Tabs scroll horizontally on mobile */ + .inventory-tabs { + padding: 0 0.5rem; + } + + .inventory-tabs .tab { + min-height: 44px; + padding: 0.5rem 0.75rem; + font-size: 0.8rem; + } + + /* Combat sheet takes more space on mobile */ + .combat-items-sheet { + max-height: 80vh; + } + + .combat-items-grid { + grid-template-columns: repeat(2, 1fr); + } + + .combat-item { + min-height: 100px; + padding: 0.75rem; + } +} + +/* Extra small screens */ +@media (max-width: 400px) { + .inventory-grid { + grid-template-columns: repeat(2, 1fr); + } + + .inventory-item { + min-height: 80px; + } + + .inventory-item img { + width: 32px; + height: 32px; + } +} + +/* ===== LOADING STATE ===== */ +.inventory-loading { + display: flex; + align-items: center; + justify-content: center; + padding: 2rem; + color: var(--text-muted, #707078); +} + +.inventory-loading::after { + content: ""; + width: 24px; + height: 24px; + margin-left: 0.75rem; + border: 2px solid var(--text-muted, #707078); + border-top-color: var(--accent-gold, #f3a61a); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* ===== ACCESSIBILITY ===== */ + +/* Focus visible for keyboard navigation */ +.inventory-item:focus-visible, +.combat-item:focus-visible, +.inventory-tabs .tab:focus-visible, +.action-btn:focus-visible { + outline: 2px solid var(--accent-gold, #f3a61a); + outline-offset: 2px; +} + +/* Reduced motion */ +@media (prefers-reduced-motion: reduce) { + .inventory-item, + .combat-item, + .combat-items-sheet, + .item-detail { + transition: none; + } + + .inventory-loading::after { + animation: none; + } +} diff --git a/public_web/static/img/items/armor.svg b/public_web/static/img/items/armor.svg new file mode 100644 index 0000000..ef170d9 --- /dev/null +++ b/public_web/static/img/items/armor.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public_web/static/img/items/consumable.svg b/public_web/static/img/items/consumable.svg new file mode 100644 index 0000000..845c045 --- /dev/null +++ b/public_web/static/img/items/consumable.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/public_web/static/img/items/default.svg b/public_web/static/img/items/default.svg new file mode 100644 index 0000000..1054075 --- /dev/null +++ b/public_web/static/img/items/default.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public_web/static/img/items/quest_item.svg b/public_web/static/img/items/quest_item.svg new file mode 100644 index 0000000..9290fb9 --- /dev/null +++ b/public_web/static/img/items/quest_item.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/public_web/static/img/items/weapon.svg b/public_web/static/img/items/weapon.svg new file mode 100644 index 0000000..bcf0a0f --- /dev/null +++ b/public_web/static/img/items/weapon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public_web/templates/dev/combat.html b/public_web/templates/dev/combat.html new file mode 100644 index 0000000..a9c3e32 --- /dev/null +++ b/public_web/templates/dev/combat.html @@ -0,0 +1,337 @@ +{% extends "base.html" %} + +{% block title %}Combat Tester - Dev Tools{% endblock %} + +{% block extra_head %} + +{% endblock %} + +{% block content %} +
+ DEV MODE - Combat System Tester +
+ +
+ ← Back to Dev Tools + + {% if error %} +
{{ error }}
+ {% endif %} + + +
+

Start New Combat

+ +
+ + +
+ + +

You need an active story session to start combat. Create one in the Story Tester first.

+
+ + +
+ + {% if enemies %} +
+ {% for enemy in enemies %} + + {% endfor %} +
+ {% else %} +
+ No enemy templates available. Check that the API has enemy data loaded. +
+ {% endif %} +
+ + +
+ +
+
+ + +
+

Active Combat Sessions

+ + {% if sessions_in_combat %} +
+ {% for session in sessions_in_combat %} +
+
+
{{ session.session_id[:12] }}...
+
{{ session.character_name or 'Unknown Character' }}
+
In Combat - Round {{ session.game_state.combat_round or 1 }}
+
+ + Resume Combat + +
+ {% endfor %} +
+ {% else %} +
+ No active combat sessions. Start a new combat above. +
+ {% endif %} +
+
+ + +{% endblock %} diff --git a/public_web/templates/dev/combat_session.html b/public_web/templates/dev/combat_session.html new file mode 100644 index 0000000..308a3b9 --- /dev/null +++ b/public_web/templates/dev/combat_session.html @@ -0,0 +1,864 @@ +{% extends "base.html" %} + +{% block title %}Combat Debug - Dev Tools{% endblock %} + +{% block extra_head %} + +{% endblock %} + +{% block content %} +
+ DEV MODE - Combat Session {{ session_id[:8] }}... +
+ +
+ +
+

+ Combat State + +

+
+ {% include 'dev/partials/combat_state.html' %} +
+ + +
+

Debug Actions

+ + + +
+ + +
+ + +
+

Combat Log

+ + +
+ {% for entry in combat_log %} +
+ {% if entry.actor %} + {{ entry.actor }} + {% endif %} + {{ entry.message }} + {% if entry.damage %} + -{{ entry.damage }} HP + {% endif %} + {% if entry.heal %} + +{{ entry.heal }} HP + {% endif %} + {% if entry.is_crit %} + CRITICAL! + {% endif %} +
+ {% else %} +
+ Combat begins! + {% if is_player_turn %} + Take your action. + {% else %} + Waiting for enemy turn... + {% endif %} +
+ {% endfor %} +
+ + +
+ + + + + +
+ + +
+
+ [+] Raw State JSON (click to toggle) +
+ +
+
+ + +
+

Turn Order

+ +
+ {% for combatant_id in turn_order %} + {% set ns = namespace(combatant=None) %} + {% for c in encounter.combatants %} + {% if c.combatant_id == combatant_id %} + {% set ns.combatant = c %} + {% endif %} + {% endfor %} +
+ {{ loop.index }} + + {% if ns.combatant %}{{ ns.combatant.name }}{% else %}{{ combatant_id[:8] }}{% endif %} + +
+ {% endfor %} +
+ +

Active Effects

+
+ {% if player_combatant and player_combatant.active_effects %} + {% for effect in player_combatant.active_effects %} +
+ {{ effect.name }} + {{ effect.remaining_duration }} turns +
+ {% endfor %} + {% else %} +

No active effects

+ {% endif %} +
+
+
+ + + + + +
+ + +{% endblock %} diff --git a/public_web/templates/dev/index.html b/public_web/templates/dev/index.html index 72c3846..a2677b7 100644 --- a/public_web/templates/dev/index.html +++ b/public_web/templates/dev/index.html @@ -83,6 +83,14 @@ + +

Quest System

diff --git a/public_web/templates/dev/partials/ability_modal.html b/public_web/templates/dev/partials/ability_modal.html new file mode 100644 index 0000000..3209b1e --- /dev/null +++ b/public_web/templates/dev/partials/ability_modal.html @@ -0,0 +1,62 @@ + + + diff --git a/public_web/templates/dev/partials/combat_debug_log.html b/public_web/templates/dev/partials/combat_debug_log.html new file mode 100644 index 0000000..2d91408 --- /dev/null +++ b/public_web/templates/dev/partials/combat_debug_log.html @@ -0,0 +1,19 @@ + + +{% for entry in combat_log %} +
+ {% if entry.actor %} + {{ entry.actor }} + {% endif %} + {{ entry.message }} + {% if entry.damage %} + -{{ entry.damage }} HP + {% endif %} + {% if entry.heal %} + +{{ entry.heal }} HP + {% endif %} + {% if entry.is_crit %} + CRITICAL! + {% endif %} +
+{% endfor %} diff --git a/public_web/templates/dev/partials/combat_defeat.html b/public_web/templates/dev/partials/combat_defeat.html new file mode 100644 index 0000000..1048455 --- /dev/null +++ b/public_web/templates/dev/partials/combat_defeat.html @@ -0,0 +1,32 @@ + + +
+
💀
+

Defeat

+

You have been defeated in battle...

+ + + {% if gold_lost and gold_lost > 0 %} +
+
+ -{{ gold_lost }} gold lost +
+
+ {% endif %} + +

+ Your progress has been saved. You can try again or return to town. +

+ + + +
diff --git a/public_web/templates/dev/partials/combat_items_sheet.html b/public_web/templates/dev/partials/combat_items_sheet.html new file mode 100644 index 0000000..d4c0a55 --- /dev/null +++ b/public_web/templates/dev/partials/combat_items_sheet.html @@ -0,0 +1,88 @@ + + +
+
+
+

Use Item

+ +
+ +
+ {% if has_consumables %} +
+ {% for item in consumables %} + + {% endfor %} +
+ + +
+
+ Select an item to see details +
+
+ {% else %} +
+ No consumable items in inventory. +
+ {% endif %} +
+
+ + diff --git a/public_web/templates/dev/partials/combat_state.html b/public_web/templates/dev/partials/combat_state.html new file mode 100644 index 0000000..f66809b --- /dev/null +++ b/public_web/templates/dev/partials/combat_state.html @@ -0,0 +1,84 @@ + + +
+

Encounter Info

+
+
Round
+
{{ encounter.round_number or 1 }}
+
+
+
Status
+
{{ encounter.status or 'active' }}
+
+
+
Current Turn
+
+ {% if is_player_turn %} + Your Turn + {% else %} + Enemy Turn + {% endif %} +
+
+
+ + +{% if player_combatant %} +
+

Player

+
+
{{ player_combatant.name }}
+ + + {% set hp_percent = (player_combatant.current_hp / player_combatant.max_hp * 100) if player_combatant.max_hp > 0 else 0 %} +
+
+
+
+ HP + {{ player_combatant.current_hp }}/{{ player_combatant.max_hp }} +
+ + + {% if player_combatant.max_mp and player_combatant.max_mp > 0 %} + {% set mp_percent = (player_combatant.current_mp / player_combatant.max_mp * 100) if player_combatant.max_mp > 0 else 0 %} +
+
+
+
+ MP + {{ player_combatant.current_mp }}/{{ player_combatant.max_mp }} +
+ {% endif %} +
+
+{% endif %} + + +{% if enemy_combatants %} +
+

Enemies ({{ enemy_combatants | length }})

+ {% for enemy in enemy_combatants %} +
+
+ {{ enemy.name }} + {% if enemy.current_hp <= 0 %} + (Defeated) + {% endif %} +
+ + + {% set enemy_hp_percent = (enemy.current_hp / enemy.max_hp * 100) if enemy.max_hp > 0 else 0 %} +
+
+
+
+ HP + {{ enemy.current_hp }}/{{ enemy.max_hp }} +
+
+ {% endfor %} +
+{% endif %} diff --git a/public_web/templates/dev/partials/combat_victory.html b/public_web/templates/dev/partials/combat_victory.html new file mode 100644 index 0000000..d574cb9 --- /dev/null +++ b/public_web/templates/dev/partials/combat_victory.html @@ -0,0 +1,68 @@ + + +
+
🏆
+

Victory!

+

You have defeated your enemies!

+ + + {% if rewards %} +
+

Rewards

+ + {% if rewards.experience %} +
+ Experience + +{{ rewards.experience }} XP +
+ {% endif %} + + {% if rewards.gold %} +
+ Gold + +{{ rewards.gold }} gold +
+ {% endif %} + + {% if rewards.level_ups %} +
+
Level Up!
+
You have reached a new level!
+
+ {% endif %} + + {% if rewards.items %} +
+
Loot Obtained:
+ {% for item in rewards.items %} +
+ {{ item.name }} + {% if item.rarity and item.rarity != 'common' %} + + ({{ item.rarity }}) + + {% endif %} +
+ {% endfor %} +
+ {% endif %} +
+ {% endif %} + + + +
diff --git a/public_web/templates/game/combat.html b/public_web/templates/game/combat.html new file mode 100644 index 0000000..8425cb0 --- /dev/null +++ b/public_web/templates/game/combat.html @@ -0,0 +1,258 @@ +{% extends "base.html" %} + +{% block title %}Combat - Code of Conquest{% endblock %} + +{% block extra_head %} + + +{% endblock %} + +{% block content %} +
+
+ {# ===== COMBAT HEADER ===== #} +
+

+ + Combat Encounter +

+
+ Round {{ encounter.round_number }} + {% if is_player_turn %} + Your Turn + {% else %} + Enemy Turn + {% endif %} +
+
+ + {# ===== LEFT COLUMN: COMBATANTS ===== #} + + + {# ===== CENTER COLUMN: COMBAT LOG + ACTIONS ===== #} +
+ {# Combat Log #} +
+ {% include "game/partials/combat_log.html" %} +
+ + {# Combat Actions #} +
+ {% include "game/partials/combat_actions.html" %} +
+
+ + {# ===== RIGHT COLUMN: TURN ORDER + EFFECTS ===== #} + +
+ + {# Modal Container for Ability selection #} + + + {# Combat Items Sheet Container #} +
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/public_web/templates/game/partials/ability_modal.html b/public_web/templates/game/partials/ability_modal.html new file mode 100644 index 0000000..b0eb176 --- /dev/null +++ b/public_web/templates/game/partials/ability_modal.html @@ -0,0 +1,61 @@ +{# Ability Selection Modal - Shows available abilities during combat #} + + diff --git a/public_web/templates/game/partials/character_panel.html b/public_web/templates/game/partials/character_panel.html index 3ce2c77..eac800d 100644 --- a/public_web/templates/game/partials/character_panel.html +++ b/public_web/templates/game/partials/character_panel.html @@ -82,8 +82,19 @@ Displays character stats, resource bars, and action buttons
- {# Quick Actions (Equipment, NPC, Travel) #} + {# Quick Actions (Inventory, Equipment, NPC, Travel) #}
+ {# Inventory - Opens modal #} + + {# Equipment & Gear - Opens modal #} + + {# Ability Button - Opens modal #} + + + {# Item Button - Opens bottom sheet #} + + + {# Defend Button - Direct action #} + + + {# Flee Button - Direct action #} + +
+{% else %} +
+ {# Disabled buttons when not player's turn #} + + + + + +
+

Waiting for enemy turn...

+{% endif %} diff --git a/public_web/templates/game/partials/combat_defeat.html b/public_web/templates/game/partials/combat_defeat.html new file mode 100644 index 0000000..0733802 --- /dev/null +++ b/public_web/templates/game/partials/combat_defeat.html @@ -0,0 +1,55 @@ +{% extends "base.html" %} + +{% block title %}Defeated - Code of Conquest{% endblock %} + +{% block extra_head %} + +{% endblock %} + +{% block content %} +
+
💀
+

Defeated

+

Your party has fallen in battle...

+ + {# Defeat Message #} +
+

Battle Lost

+
+
+ + Your progress has been saved + No items lost +
+ {% if gold_lost %} +
+ 💰 + Gold dropped + -{{ gold_lost }} gold +
+ {% endif %} +
+ +
+

+ "Even the mightiest heroes face setbacks. Rise again, adventurer!" +

+
+
+ + {# Action Buttons #} +
+ + Return to Game + + {% if can_retry %} + + {% endif %} +
+
+{% endblock %} diff --git a/public_web/templates/game/partials/combat_items_sheet.html b/public_web/templates/game/partials/combat_items_sheet.html new file mode 100644 index 0000000..6d4585a --- /dev/null +++ b/public_web/templates/game/partials/combat_items_sheet.html @@ -0,0 +1,52 @@ +{# +Combat Items Sheet +Bottom sheet for selecting consumable items during combat +#} + +
+ + diff --git a/public_web/templates/game/partials/combat_log.html b/public_web/templates/game/partials/combat_log.html new file mode 100644 index 0000000..f47dabb --- /dev/null +++ b/public_web/templates/game/partials/combat_log.html @@ -0,0 +1,25 @@ +{# Combat Log Partial - Displays combat action history #} +{# This partial is swapped via HTMX when combat actions occur #} + +{% if combat_log %} + {% for entry in combat_log %} +
+ {% if entry.actor %} + {{ entry.actor }} + {% endif %} + {{ entry.message }} + {% if entry.damage %} + + {% if entry.is_crit %}CRITICAL! {% endif %}{{ entry.damage }} damage + + {% endif %} + {% if entry.heal %} + +{{ entry.heal }} HP + {% endif %} +
+ {% endfor %} +{% else %} +
+ Combat begins! Choose your action below. +
+{% endif %} diff --git a/public_web/templates/game/partials/combat_victory.html b/public_web/templates/game/partials/combat_victory.html new file mode 100644 index 0000000..6c58683 --- /dev/null +++ b/public_web/templates/game/partials/combat_victory.html @@ -0,0 +1,84 @@ +{% extends "base.html" %} + +{% block title %}Victory! - Code of Conquest{% endblock %} + +{% block extra_head %} + +{% endblock %} + +{% block content %} +
+
🏆
+

Victory!

+

You have defeated your enemies!

+ + {# Rewards Section #} + {% if rewards %} +
+

Rewards Earned

+
+ {# Experience #} + {% if rewards.experience %} +
+ + Experience Points + +{{ rewards.experience }} XP +
+ {% endif %} + + {# Gold #} + {% if rewards.gold %} +
+ 💰 + Gold + +{{ rewards.gold }} gold +
+ {% endif %} + + {# Level Up #} + {% if rewards.level_ups %} + {% for character_id in rewards.level_ups %} +
+ 🌟 + Level Up! + New abilities unlocked! +
+ {% endfor %} + {% endif %} +
+ + {# Loot Items #} + {% if rewards.items %} +
+

Items Obtained

+
+ {% for item in rewards.items %} +
+ + {% if item.type == 'weapon' %}⚔ + {% elif item.type == 'armor' %}🧳 + {% elif item.type == 'consumable' %}🍷 + {% elif item.type == 'material' %}🔥 + {% else %}📦 + {% endif %} + + {{ item.name }} + {% if item.quantity > 1 %} + x{{ item.quantity }} + {% endif %} +
+ {% endfor %} +
+
+ {% endif %} +
+ {% endif %} + + {# Action Buttons #} + +
+{% endblock %} diff --git a/public_web/templates/game/partials/inventory_item_detail.html b/public_web/templates/game/partials/inventory_item_detail.html new file mode 100644 index 0000000..f8541f8 --- /dev/null +++ b/public_web/templates/game/partials/inventory_item_detail.html @@ -0,0 +1,118 @@ +{# +Inventory Item Detail +Partial template loaded via HTMX when an item is selected +#} +
+ {# Mobile back button #} + + + {# Item header #} +
+ +
+

{{ item.name }}

+ {{ item.item_type|default('Item')|replace('_', ' ')|title }} +
+
+ + {# Item description #} +

{{ item.description|default('No description available.') }}

+ + {# Stats (for equipment) #} + {% if item.item_type in ['weapon', 'armor'] %} +
+ {% if item.damage %} +
+ Damage + {{ item.damage }} +
+ {% endif %} + {% if item.defense %} +
+ Defense + {{ item.defense }} +
+ {% endif %} + {% if item.spell_power %} +
+ Spell Power + {{ item.spell_power }} +
+ {% endif %} + {% if item.crit_chance %} +
+ Crit Chance + {{ (item.crit_chance * 100)|round|int }}% +
+ {% endif %} + {% if item.stat_bonuses %} + {% for stat, value in item.stat_bonuses.items() %} +
+ {{ stat|replace('_', ' ')|title }} + +{{ value }} +
+ {% endfor %} + {% endif %} +
+ {% endif %} + + {# Effects (for consumables) #} + {% if item.item_type == 'consumable' and item.effects_on_use %} +
+
Effects
+ {% for effect in item.effects_on_use %} +
+ {{ effect.name|default(effect.effect_type|default('Effect')|title) }} + {{ effect.value|default('') }} +
+ {% endfor %} +
+ {% endif %} + + {# Item value #} + {% if item.value %} +
+ Value: {{ item.value }} gold +
+ {% endif %} + + {# Action Buttons #} +
+ {% if item.item_type == 'consumable' %} + + {% elif item.item_type in ['weapon', 'armor'] %} + + {% endif %} + + {% if item.item_type != 'quest_item' %} + + {% else %} +

+ Quest items cannot be dropped +

+ {% endif %} +
+
diff --git a/public_web/templates/game/partials/inventory_modal.html b/public_web/templates/game/partials/inventory_modal.html new file mode 100644 index 0000000..b2d009f --- /dev/null +++ b/public_web/templates/game/partials/inventory_modal.html @@ -0,0 +1,138 @@ +{# +Inventory Modal +Full inventory management modal for play screen +#} + + + diff --git a/public_web/templates/game/partials/item_modal.html b/public_web/templates/game/partials/item_modal.html new file mode 100644 index 0000000..bb62f1b --- /dev/null +++ b/public_web/templates/game/partials/item_modal.html @@ -0,0 +1,51 @@ +{# Item Selection Modal - Shows consumable items during combat #} + + diff --git a/public_web/templates/game/play.html b/public_web/templates/game/play.html index 1a59db3..3191394 100644 --- a/public_web/templates/game/play.html +++ b/public_web/templates/game/play.html @@ -4,6 +4,7 @@ {% block extra_head %} + {% endblock %} {% block content %}