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

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

View File

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

View File

@@ -132,23 +132,44 @@ def list_sessions():
user = get_current_user() user = get_current_user()
user_id = user.id user_id = user.id
session_service = get_session_service() session_service = get_session_service()
character_service = get_character_service()
# Get user's active sessions # Get user's active sessions
sessions = session_service.get_user_sessions(user_id, active_only=True) 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 # Build response with basic session info
sessions_list = [] sessions_list = []
for session in sessions: 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({ sessions_list.append({
'session_id': session.session_id, 'session_id': session.session_id,
'character_id': session.solo_character_id, 'character_id': session.solo_character_id,
'character_name': character_names.get(session.solo_character_id),
'turn_number': session.turn_number, 'turn_number': session.turn_number,
'status': session.status.value, 'status': session.status.value,
'created_at': session.created_at, 'created_at': session.created_at,
'last_activity': session.last_activity, 'last_activity': session.last_activity,
'in_combat': session.is_in_combat(),
'game_state': { 'game_state': {
'current_location': session.game_state.current_location, '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(), "character_id": session.get_character_id(),
"turn_number": session.turn_number, "turn_number": session.turn_number,
"status": session.status.value, "status": session.status.value,
"in_combat": session.is_in_combat(),
"game_state": { "game_state": {
"current_location": session.game_state.current_location, "current_location": session.game_state.current_location,
"location_type": session.game_state.location_type.value, "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 "available_actions": available_actions
}) })

View File

@@ -349,14 +349,32 @@ class CombatEncounter:
return None return None
def advance_turn(self) -> None: def advance_turn(self) -> None:
"""Advance to the next combatant's turn.""" """Advance to the next alive combatant's turn, skipping dead combatants."""
self.current_turn_index += 1 # 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 while True:
if self.current_turn_index >= len(self.turn_order): self.current_turn_index += 1
self.current_turn_index = 0
self.round_number += 1 # If we've cycled through all combatants, start a new round
self.log_action("round_start", None, f"Round {self.round_number} begins") 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]]: def start_turn(self) -> List[Dict[str, Any]]:
""" """

View File

@@ -167,7 +167,8 @@ class GameSession:
user_id: Owner of the session user_id: Owner of the session
party_member_ids: Character IDs in this party (multiplayer only) party_member_ids: Character IDs in this party (multiplayer only)
config: Session configuration settings 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 conversation_history: Turn-by-turn log of actions and DM responses
game_state: Current world/quest state game_state: Current world/quest state
turn_order: Character turn order turn_order: Character turn order
@@ -184,7 +185,8 @@ class GameSession:
user_id: str = "" user_id: str = ""
party_member_ids: List[str] = field(default_factory=list) party_member_ids: List[str] = field(default_factory=list)
config: SessionConfig = field(default_factory=SessionConfig) 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) conversation_history: List[ConversationEntry] = field(default_factory=list)
game_state: GameState = field(default_factory=GameState) game_state: GameState = field(default_factory=GameState)
turn_order: List[str] = field(default_factory=list) 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") self.last_activity = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
def is_in_combat(self) -> bool: 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: def start_combat(self, encounter: CombatEncounter) -> None:
""" """
@@ -341,6 +348,7 @@ class GameSession:
"party_member_ids": self.party_member_ids, "party_member_ids": self.party_member_ids,
"config": self.config.to_dict(), "config": self.config.to_dict(),
"combat_encounter": self.combat_encounter.to_dict() if self.combat_encounter else None, "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], "conversation_history": [entry.to_dict() for entry in self.conversation_history],
"game_state": self.game_state.to_dict(), "game_state": self.game_state.to_dict(),
"turn_order": self.turn_order, "turn_order": self.turn_order,
@@ -382,6 +390,7 @@ class GameSession:
party_member_ids=data.get("party_member_ids", []), party_member_ids=data.get("party_member_ids", []),
config=config, config=config,
combat_encounter=combat_encounter, combat_encounter=combat_encounter,
active_combat_encounter_id=data.get("active_combat_encounter_id"),
conversation_history=conversation_history, conversation_history=conversation_history,
game_state=game_state, game_state=game_state,
turn_order=data.get("turn_order", []), turn_order=data.get("turn_order", []),

View File

@@ -1074,9 +1074,9 @@ class CharacterService:
character_json = json.dumps(character_dict) character_json = json.dumps(character_dict)
# Update in database # Update in database
self.db.update_document( self.db.update_row(
collection_id=self.collection_id, table_id=self.collection_id,
document_id=character.character_id, row_id=character.character_id,
data={'characterData': character_json} data={'characterData': character_json}
) )

View File

@@ -0,0 +1,578 @@
"""
Combat Repository - Database operations for combat encounters.
This service handles all CRUD operations for combat data stored in
dedicated database tables (combat_encounters, combat_rounds).
Separates combat persistence from the CombatService which handles
business logic and game mechanics.
"""
import json
from typing import Dict, Any, List, Optional
from datetime import datetime, timezone, timedelta
from uuid import uuid4
from appwrite.query import Query
from app.models.combat import CombatEncounter, Combatant
from app.models.enums import CombatStatus
from app.services.database_service import get_database_service, DatabaseService
from app.utils.logging import get_logger
logger = get_logger(__file__)
# =============================================================================
# Exceptions
# =============================================================================
class CombatEncounterNotFound(Exception):
"""Raised when combat encounter is not found in database."""
pass
class CombatRoundNotFound(Exception):
"""Raised when combat round is not found in database."""
pass
# =============================================================================
# Combat Repository
# =============================================================================
class CombatRepository:
"""
Repository for combat encounter database operations.
Handles:
- Creating and reading combat encounters
- Updating combat state during actions
- Saving per-round history for logging and replay
- Time-based cleanup of old combat data
Tables:
- combat_encounters: Main encounter state and metadata
- combat_rounds: Per-round action history
"""
# Table IDs
ENCOUNTERS_TABLE = "combat_encounters"
ROUNDS_TABLE = "combat_rounds"
# Default retention period for cleanup (days)
DEFAULT_RETENTION_DAYS = 7
def __init__(self, db: Optional[DatabaseService] = None):
"""
Initialize the combat repository.
Args:
db: Optional DatabaseService instance (for testing/injection)
"""
self.db = db or get_database_service()
logger.info("CombatRepository initialized")
# =========================================================================
# Encounter CRUD Operations
# =========================================================================
def create_encounter(
self,
encounter: CombatEncounter,
session_id: str,
user_id: str
) -> str:
"""
Create a new combat encounter record.
Args:
encounter: CombatEncounter instance to persist
session_id: Game session ID this encounter belongs to
user_id: Owner user ID for authorization
Returns:
encounter_id of created record
"""
created_at = self._get_timestamp()
data = {
'sessionId': session_id,
'userId': user_id,
'status': encounter.status.value,
'roundNumber': encounter.round_number,
'currentTurnIndex': encounter.current_turn_index,
'turnOrder': json.dumps(encounter.turn_order),
'combatantsData': json.dumps([c.to_dict() for c in encounter.combatants]),
'combatLog': json.dumps(encounter.combat_log),
'created_at': created_at,
}
self.db.create_row(
table_id=self.ENCOUNTERS_TABLE,
data=data,
row_id=encounter.encounter_id
)
logger.info("Combat encounter created",
encounter_id=encounter.encounter_id,
session_id=session_id,
combatant_count=len(encounter.combatants))
return encounter.encounter_id
def get_encounter(self, encounter_id: str) -> Optional[CombatEncounter]:
"""
Get a combat encounter by ID.
Args:
encounter_id: Encounter ID to fetch
Returns:
CombatEncounter or None if not found
"""
logger.info("Fetching encounter from database",
encounter_id=encounter_id)
row = self.db.get_row(self.ENCOUNTERS_TABLE, encounter_id)
if not row:
logger.warning("Encounter not found", encounter_id=encounter_id)
return None
logger.info("Raw database row data",
encounter_id=encounter_id,
currentTurnIndex=row.data.get('currentTurnIndex'),
roundNumber=row.data.get('roundNumber'))
encounter = self._row_to_encounter(row.data, encounter_id)
logger.info("Encounter object created",
encounter_id=encounter_id,
current_turn_index=encounter.current_turn_index,
turn_order=encounter.turn_order)
return encounter
def get_encounter_by_session(
self,
session_id: str,
active_only: bool = True
) -> Optional[CombatEncounter]:
"""
Get combat encounter for a session.
Args:
session_id: Game session ID
active_only: If True, only return active encounters
Returns:
CombatEncounter or None if not found
"""
queries = [Query.equal('sessionId', session_id)]
if active_only:
queries.append(Query.equal('status', CombatStatus.ACTIVE.value))
rows = self.db.list_rows(
table_id=self.ENCOUNTERS_TABLE,
queries=queries,
limit=1
)
if not rows:
return None
row = rows[0]
return self._row_to_encounter(row.data, row.id)
def get_user_active_encounters(self, user_id: str) -> List[CombatEncounter]:
"""
Get all active encounters for a user.
Args:
user_id: User ID to query
Returns:
List of active CombatEncounter instances
"""
rows = self.db.list_rows(
table_id=self.ENCOUNTERS_TABLE,
queries=[
Query.equal('userId', user_id),
Query.equal('status', CombatStatus.ACTIVE.value)
],
limit=25
)
return [self._row_to_encounter(row.data, row.id) for row in rows]
def update_encounter(self, encounter: CombatEncounter) -> None:
"""
Update an existing combat encounter.
Call this after each action to persist the updated state.
Args:
encounter: CombatEncounter with updated state
"""
data = {
'status': encounter.status.value,
'roundNumber': encounter.round_number,
'currentTurnIndex': encounter.current_turn_index,
'turnOrder': json.dumps(encounter.turn_order),
'combatantsData': json.dumps([c.to_dict() for c in encounter.combatants]),
'combatLog': json.dumps(encounter.combat_log),
}
logger.info("Saving encounter to database",
encounter_id=encounter.encounter_id,
current_turn_index=encounter.current_turn_index,
combat_log_entries=len(encounter.combat_log))
self.db.update_row(
table_id=self.ENCOUNTERS_TABLE,
row_id=encounter.encounter_id,
data=data
)
logger.info("Encounter saved successfully",
encounter_id=encounter.encounter_id)
def end_encounter(
self,
encounter_id: str,
status: CombatStatus
) -> None:
"""
Mark an encounter as ended.
Args:
encounter_id: Encounter ID to end
status: Final status (VICTORY, DEFEAT, FLED)
"""
ended_at = self._get_timestamp()
data = {
'status': status.value,
'ended_at': ended_at,
}
self.db.update_row(
table_id=self.ENCOUNTERS_TABLE,
row_id=encounter_id,
data=data
)
logger.info("Combat encounter ended",
encounter_id=encounter_id,
status=status.value)
def delete_encounter(self, encounter_id: str) -> bool:
"""
Delete an encounter and all its rounds.
Args:
encounter_id: Encounter ID to delete
Returns:
True if deleted successfully
"""
# Delete rounds first
self._delete_rounds_for_encounter(encounter_id)
# Delete encounter
result = self.db.delete_row(self.ENCOUNTERS_TABLE, encounter_id)
logger.info("Combat encounter deleted", encounter_id=encounter_id)
return result
# =========================================================================
# Round Operations
# =========================================================================
def save_round(
self,
encounter_id: str,
session_id: str,
round_number: int,
actions: List[Dict[str, Any]],
states_start: List[Combatant],
states_end: List[Combatant]
) -> str:
"""
Save a completed round's data for history/replay.
Call this at the end of each round (after all combatants have acted).
Args:
encounter_id: Parent encounter ID
session_id: Game session ID (denormalized for queries)
round_number: Round number (1-indexed)
actions: List of all actions taken this round
states_start: Combatant states at round start
states_end: Combatant states at round end
Returns:
round_id of created record
"""
round_id = f"rnd_{uuid4().hex[:12]}"
created_at = self._get_timestamp()
data = {
'encounterId': encounter_id,
'sessionId': session_id,
'roundNumber': round_number,
'actionsData': json.dumps(actions),
'combatantStatesStart': json.dumps([c.to_dict() for c in states_start]),
'combatantStatesEnd': json.dumps([c.to_dict() for c in states_end]),
'created_at': created_at,
}
self.db.create_row(
table_id=self.ROUNDS_TABLE,
data=data,
row_id=round_id
)
logger.debug("Combat round saved",
round_id=round_id,
encounter_id=encounter_id,
round_number=round_number,
action_count=len(actions))
return round_id
def get_encounter_rounds(
self,
encounter_id: str,
limit: int = 100
) -> List[Dict[str, Any]]:
"""
Get all rounds for an encounter, ordered by round number.
Args:
encounter_id: Encounter ID to fetch rounds for
limit: Maximum number of rounds to return
Returns:
List of round data dictionaries
"""
rows = self.db.list_rows(
table_id=self.ROUNDS_TABLE,
queries=[Query.equal('encounterId', encounter_id)],
limit=limit
)
rounds = []
for row in rows:
rounds.append({
'round_id': row.id,
'round_number': row.data.get('roundNumber'),
'actions': json.loads(row.data.get('actionsData', '[]')),
'states_start': json.loads(row.data.get('combatantStatesStart', '[]')),
'states_end': json.loads(row.data.get('combatantStatesEnd', '[]')),
'created_at': row.data.get('created_at'),
})
# Sort by round number
return sorted(rounds, key=lambda r: r['round_number'])
def get_session_combat_history(
self,
session_id: str,
limit: int = 50
) -> List[Dict[str, Any]]:
"""
Get combat history for a session.
Returns summary of all encounters for the session.
Args:
session_id: Game session ID
limit: Maximum encounters to return
Returns:
List of encounter summaries
"""
rows = self.db.list_rows(
table_id=self.ENCOUNTERS_TABLE,
queries=[Query.equal('sessionId', session_id)],
limit=limit
)
history = []
for row in rows:
history.append({
'encounter_id': row.id,
'status': row.data.get('status'),
'round_count': row.data.get('roundNumber', 1),
'created_at': row.data.get('created_at'),
'ended_at': row.data.get('ended_at'),
})
# Sort by created_at descending (newest first)
return sorted(history, key=lambda h: h['created_at'] or '', reverse=True)
# =========================================================================
# Cleanup Operations
# =========================================================================
def delete_encounters_by_session(self, session_id: str) -> int:
"""
Delete all encounters for a session.
Call this when a session is deleted.
Args:
session_id: Session ID to clean up
Returns:
Number of encounters deleted
"""
rows = self.db.list_rows(
table_id=self.ENCOUNTERS_TABLE,
queries=[Query.equal('sessionId', session_id)],
limit=100
)
deleted = 0
for row in rows:
# Delete rounds first
self._delete_rounds_for_encounter(row.id)
# Delete encounter
self.db.delete_row(self.ENCOUNTERS_TABLE, row.id)
deleted += 1
if deleted > 0:
logger.info("Deleted encounters for session",
session_id=session_id,
deleted_count=deleted)
return deleted
def delete_old_encounters(
self,
older_than_days: int = DEFAULT_RETENTION_DAYS
) -> int:
"""
Delete ended encounters older than specified days.
This is the main cleanup method for time-based retention.
Should be scheduled to run periodically (daily recommended).
Args:
older_than_days: Delete encounters ended more than this many days ago
Returns:
Number of encounters deleted
"""
cutoff = datetime.now(timezone.utc) - timedelta(days=older_than_days)
cutoff_str = cutoff.isoformat().replace("+00:00", "Z")
# Find old ended encounters
# Note: We only delete ended encounters, not active ones
rows = self.db.list_rows(
table_id=self.ENCOUNTERS_TABLE,
queries=[
Query.notEqual('status', CombatStatus.ACTIVE.value),
Query.lessThan('created_at', cutoff_str)
],
limit=100
)
deleted = 0
for row in rows:
self._delete_rounds_for_encounter(row.id)
self.db.delete_row(self.ENCOUNTERS_TABLE, row.id)
deleted += 1
if deleted > 0:
logger.info("Deleted old combat encounters",
deleted_count=deleted,
older_than_days=older_than_days)
return deleted
# =========================================================================
# Helper Methods
# =========================================================================
def _delete_rounds_for_encounter(self, encounter_id: str) -> int:
"""
Delete all rounds for an encounter.
Args:
encounter_id: Encounter ID
Returns:
Number of rounds deleted
"""
rows = self.db.list_rows(
table_id=self.ROUNDS_TABLE,
queries=[Query.equal('encounterId', encounter_id)],
limit=100
)
for row in rows:
self.db.delete_row(self.ROUNDS_TABLE, row.id)
return len(rows)
def _row_to_encounter(
self,
data: Dict[str, Any],
encounter_id: str
) -> CombatEncounter:
"""
Convert database row data to CombatEncounter object.
Args:
data: Row data dictionary
encounter_id: Encounter ID
Returns:
Deserialized CombatEncounter
"""
# Parse JSON fields
combatants_data = json.loads(data.get('combatantsData', '[]'))
combatants = [Combatant.from_dict(c) for c in combatants_data]
turn_order = json.loads(data.get('turnOrder', '[]'))
combat_log = json.loads(data.get('combatLog', '[]'))
# Parse status enum
status_str = data.get('status', 'active')
status = CombatStatus(status_str)
return CombatEncounter(
encounter_id=encounter_id,
combatants=combatants,
turn_order=turn_order,
current_turn_index=data.get('currentTurnIndex', 0),
round_number=data.get('roundNumber', 1),
combat_log=combat_log,
status=status,
)
def _get_timestamp(self) -> str:
"""Get current UTC timestamp in ISO format."""
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
# =============================================================================
# Global Instance
# =============================================================================
_repository_instance: Optional[CombatRepository] = None
def get_combat_repository() -> CombatRepository:
"""
Get the global CombatRepository instance.
Returns:
Singleton CombatRepository instance
"""
global _repository_instance
if _repository_instance is None:
_repository_instance = CombatRepository()
return _repository_instance

View File

@@ -30,6 +30,10 @@ from app.services.combat_loot_service import (
CombatLootService, CombatLootService,
LootContext LootContext
) )
from app.services.combat_repository import (
get_combat_repository,
CombatRepository
)
from app.utils.logging import get_logger from app.utils.logging import get_logger
logger = get_logger(__file__) logger = get_logger(__file__)
@@ -99,6 +103,7 @@ class ActionResult:
combat_ended: bool = False combat_ended: bool = False
combat_status: Optional[CombatStatus] = None combat_status: Optional[CombatStatus] = None
next_combatant_id: Optional[str] = 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) turn_effects: List[Dict[str, Any]] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]: def to_dict(self) -> Dict[str, Any]:
@@ -120,6 +125,7 @@ class ActionResult:
"combat_ended": self.combat_ended, "combat_ended": self.combat_ended,
"combat_status": self.combat_status.value if self.combat_status else None, "combat_status": self.combat_status.value if self.combat_status else None,
"next_combatant_id": self.next_combatant_id, "next_combatant_id": self.next_combatant_id,
"next_is_player": self.next_is_player,
"turn_effects": self.turn_effects, "turn_effects": self.turn_effects,
} }
@@ -203,6 +209,7 @@ class CombatService:
self.enemy_loader = get_enemy_loader() self.enemy_loader = get_enemy_loader()
self.ability_loader = AbilityLoader() self.ability_loader = AbilityLoader()
self.loot_service = get_combat_loot_service() self.loot_service = get_combat_loot_service()
self.combat_repository = get_combat_repository()
logger.info("CombatService initialized") logger.info("CombatService initialized")
@@ -283,9 +290,18 @@ class CombatService:
# Initialize combat (roll initiative, set turn order) # Initialize combat (roll initiative, set turn order)
encounter.initialize_combat() encounter.initialize_combat()
# Store in session # Save encounter to dedicated table
session.start_combat(encounter) self.combat_repository.create_encounter(
self.session_service.update_session(session, user_id) 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", logger.info("Combat started",
session_id=session_id, session_id=session_id,
@@ -303,6 +319,9 @@ class CombatService:
""" """
Get current combat state for a session. Get current combat state for a session.
Uses the new database-backed storage, with fallback to legacy
inline session storage for backward compatibility.
Args: Args:
session_id: Game session ID session_id: Game session ID
user_id: User ID for authorization user_id: User ID for authorization
@@ -311,7 +330,66 @@ class CombatService:
CombatEncounter if in combat, None otherwise CombatEncounter if in combat, None otherwise
""" """
session = self.session_service.get_session(session_id, user_id) 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( def end_combat(
self, self,
@@ -339,7 +417,11 @@ class CombatService:
if not session.is_in_combat(): if not session.is_in_combat():
raise NotInCombatError("Session is not 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 encounter.status = outcome
# Calculate rewards if victory # Calculate rewards if victory
@@ -347,12 +429,22 @@ class CombatService:
if outcome == CombatStatus.VICTORY: if outcome == CombatStatus.VICTORY:
rewards = self._calculate_rewards(encounter, session, user_id) rewards = self._calculate_rewards(encounter, session, user_id)
# End combat on session # End encounter in repository
session.end_combat() if session.active_combat_encounter_id:
self.session_service.update_session(session, user_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", logger.info("Combat ended",
session_id=session_id, session_id=session_id,
encounter_id=encounter.encounter_id,
outcome=outcome.value, outcome=outcome.value,
xp_earned=rewards.experience, xp_earned=rewards.experience,
gold_earned=rewards.gold) gold_earned=rewards.gold)
@@ -396,7 +488,10 @@ class CombatService:
if not session.is_in_combat(): if not session.is_in_combat():
raise NotInCombatError("Session is not 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 # Validate it's this combatant's turn
current = encounter.get_current_combatant() current = encounter.get_current_combatant()
@@ -455,15 +550,29 @@ class CombatService:
rewards = self._calculate_rewards(encounter, session, user_id) rewards = self._calculate_rewards(encounter, session, user_id)
result.message += f" Victory! Earned {rewards.experience} XP and {rewards.gold} gold." 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: else:
# Advance turn # Advance turn and save to repository
self._advance_turn_and_save(encounter, session, user_id) self._advance_turn_and_save(encounter, session, user_id)
next_combatant = encounter.get_current_combatant() 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 # Save session state
self.session_service.update_session(session, user_id) self.session_service.update_session(session)
return result return result
@@ -487,7 +596,11 @@ class CombatService:
if not session.is_in_combat(): if not session.is_in_combat():
raise NotInCombatError("Session is not 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() current = encounter.get_current_combatant()
if not current: if not current:
@@ -496,9 +609,55 @@ class CombatService:
if current.is_player: if current.is_player:
raise InvalidActionError("Current combatant is a player, not an enemy") 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 # Process start-of-turn effects
turn_effects = encounter.start_turn() 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 # Check if stunned
if current.is_stunned(): if current.is_stunned():
result = ActionResult( result = ActionResult(
@@ -539,14 +698,39 @@ class CombatService:
if status != CombatStatus.ACTIVE: if status != CombatStatus.ACTIVE:
result.combat_ended = True result.combat_ended = True
result.combat_status = status 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: 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) self._advance_turn_and_save(encounter, session, user_id)
next_combatant = encounter.get_current_combatant() 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 return result
# ========================================================================= # =========================================================================
@@ -1146,9 +1330,25 @@ class CombatService:
session, session,
user_id: str user_id: str
) -> None: ) -> 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() 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 # Global Instance

View File

@@ -106,6 +106,24 @@ class DatabaseInitService:
logger.error("Failed to initialize chat_messages table", error=str(e)) logger.error("Failed to initialize chat_messages table", error=str(e))
results['chat_messages'] = False 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) success_count = sum(1 for v in results.values() if v)
total_count = len(results) total_count = len(results)
@@ -746,6 +764,326 @@ class DatabaseInitService:
code=e.code) code=e.code)
raise 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( def _create_column(
self, self,
table_id: str, table_id: str,

View File

@@ -272,9 +272,9 @@ class SessionService:
session_json = json.dumps(session_dict) session_json = json.dumps(session_dict)
# Update in database # Update in database
self.db.update_document( self.db.update_row(
collection_id=self.collection_id, table_id=self.collection_id,
document_id=session.session_id, row_id=session.session_id,
data={ data={
'sessionData': session_json, 'sessionData': session_json,
'status': session.status.value 'status': session.status.value

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -253,7 +253,7 @@ Implemented hybrid loot system:
### Week 3: Combat UI ### 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 **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 **Objective:** Wire combat UI to API via HTMX
**File:** `/public_web/app/views/combat.py` **File:** `/public_web/app/views/game_views.py`
**Implementation:** **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 **Objective:** Add inventory accordion to character panel

View File

@@ -56,11 +56,13 @@ def create_app():
# Register blueprints # Register blueprints
from .views.auth_views import auth_bp from .views.auth_views import auth_bp
from .views.character_views import character_bp from .views.character_views import character_bp
from .views.combat_views import combat_bp
from .views.game_views import game_bp from .views.game_views import game_bp
from .views.pages import pages_bp from .views.pages import pages_bp
app.register_blueprint(auth_bp) app.register_blueprint(auth_bp)
app.register_blueprint(character_bp) app.register_blueprint(character_bp)
app.register_blueprint(combat_bp)
app.register_blueprint(game_bp) app.register_blueprint(game_bp)
app.register_blueprint(pages_bp) app.register_blueprint(pages_bp)
@@ -109,6 +111,6 @@ def create_app():
logger.error("internal_server_error", error=str(error)) logger.error("internal_server_error", error=str(error))
return render_template('errors/500.html'), 500 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 return app

View File

@@ -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('/<session_id>')
@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('/<session_id>/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'''
<div class="combat-log__entry combat-log__entry--system">
<span class="log-message">Action failed: {e}</span>
</div>
''', 500
@combat_bp.route('/<session_id>/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'''
<div class="modal-overlay" onclick="closeModal()">
<div class="modal-content modal-content--md">
<div class="modal-header">
<h3 class="modal-title">Select Ability</h3>
<button class="modal-close" onclick="closeModal()">&times;</button>
</div>
<div class="modal-body">
<div class="items-empty">Failed to load abilities: {e}</div>
</div>
</div>
</div>
'''
@combat_bp.route('/<session_id>/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'''
<div class="combat-items-sheet open">
<div class="sheet-handle"></div>
<div class="sheet-header">
<h3>Use Item</h3>
<button class="sheet-close" onclick="closeCombatSheet()">&times;</button>
</div>
<div class="sheet-body">
<div class="no-consumables">Failed to load items: {e}</div>
</div>
</div>
<div class="sheet-backdrop" onclick="closeCombatSheet()"></div>
'''
@combat_bp.route('/<session_id>/items/<item_id>/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 '<p>Item not found</p>', 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'''
<div class="detail-info">
<div class="detail-name">{item.get('name', 'Item')}</div>
<div class="detail-effect">{effect_desc}</div>
</div>
<button class="use-btn"
hx-post="{url_for('combat.combat_action', session_id=session_id)}"
hx-vals='{{"action_type": "item", "item_id": "{item_id}"}}'
hx-target="#combat-log"
hx-swap="beforeend"
onclick="closeCombatSheet()">
Use
</button>
'''
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'<p>Failed to load item: {e}</p>', 500
@combat_bp.route('/<session_id>/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'''
<div class="combat-log__entry combat-log__entry--system">
<span class="log-message">{result.get('message', 'Failed to flee!')}</span>
</div>
'''
except APIError as e:
logger.error("flee_failed", session_id=session_id, error=str(e))
return f'''
<div class="combat-log__entry combat-log__entry--system">
<span class="log-message">Flee failed: {e}</span>
</div>
''', 500
@combat_bp.route('/<session_id>/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'''
<div class="combat-log__entry combat-log__entry--system">
<span class="log-message">Enemy turn error: {e}</span>
</div>
''', 500
@combat_bp.route('/<session_id>/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 '<div class="combat-log__empty">Failed to load combat log</div>', 500
@combat_bp.route('/<session_id>/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))

View File

@@ -380,3 +380,652 @@ def do_travel(session_id: str):
except APIError as e: except APIError as e:
logger.error("failed_to_travel", session_id=session_id, location_id=location_id, error=str(e)) logger.error("failed_to_travel", session_id=session_id, location_id=location_id, error=str(e))
return f'<div class="error">Travel failed: {e}</div>', 500 return f'<div class="error">Travel failed: {e}</div>', 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 '<div class="error">No session selected</div>', 400
if not enemy_ids:
return '<div class="error">No enemies selected</div>', 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'''
<script>window.location.href = '/dev/combat/session/{session_id}';</script>
<div class="success">Combat started! Redirecting...</div>
'''
except APIError as e:
logger.error("failed_to_start_combat", session_id=session_id, error=str(e))
return f'<div class="error">Failed to start combat: {e}</div>', 500
@dev_bp.route('/combat/session/<session_id>')
@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/<session_id>/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 '<div class="state-section"><h4>Combat Ended</h4><p>No active combat.</p></div>'
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'<div class="error">Failed to load state: {e}</div>', 500
@dev_bp.route('/combat/<session_id>/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'''
<div class="log-entry log-entry--system">
<span class="log-message">Action failed: {e}</span>
</div>
''', 500
@dev_bp.route('/combat/<session_id>/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'''
<div class="log-entry log-entry--system">
<span class="log-message">Enemy turn error: {e}</span>
</div>
''', 500
@dev_bp.route('/combat/<session_id>/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'''
<div class="modal-overlay" onclick="closeModal()">
<div class="modal-content">
<h3>Select Ability</h3>
<div class="error">Failed to load abilities: {e}</div>
<button class="modal-close" onclick="closeModal()">Close</button>
</div>
</div>
'''
@dev_bp.route('/combat/<session_id>/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'''
<div class="combat-items-sheet open">
<div class="sheet-header">
<h3>Use Item</h3>
<button class="sheet-close" onclick="closeCombatSheet()">&times;</button>
</div>
<div class="sheet-body">
<div class="error">Failed to load items: {e}</div>
</div>
</div>
<div class="sheet-backdrop" onclick="closeCombatSheet()"></div>
'''
@dev_bp.route('/combat/<session_id>/items/<item_id>/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 '<p>Item not found</p>', 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'''
<div class="detail-info">
<div class="detail-name">{item.get('name', 'Item')}</div>
<div class="detail-effect">{effect_desc}</div>
</div>
<button class="use-btn"
hx-post="/dev/combat/{session_id}/action"
hx-vals='{{"action_type": "item", "item_id": "{item_id}"}}'
hx-target="#combat-log"
hx-swap="beforeend"
onclick="closeCombatSheet()">
Use
</button>
'''
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'<p>Failed to load item: {e}</p>', 500
@dev_bp.route('/combat/<session_id>/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'<div class="error">Failed to end combat: {e}</div>', 500
@dev_bp.route('/combat/<session_id>/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'''
<div class="log-entry log-entry--heal">
<span class="log-message">HP and MP restored to full! (HP: {result.get('current_hp', '?')}/{result.get('max_hp', '?')}, MP: {result.get('current_mp', '?')}/{result.get('max_mp', '?')})</span>
</div>
'''
except APIError as e:
logger.error("failed_to_reset_hp_mp", session_id=session_id, error=str(e))
return f'''
<div class="log-entry log-entry--system">
<span class="log-message">Failed to reset HP/MP: {e}</span>
</div>
''', 500
@dev_bp.route('/combat/<session_id>/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 '<div class="error">Failed to load combat log</div>', 500

View File

@@ -7,7 +7,7 @@ Provides the main gameplay interface with 3-column layout:
- Right: Accordions for history, quests, NPCs, map - 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 import structlog
from ..utils.api_client import get_api_client, APIError, APINotFoundError 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 '<div class="history-empty">Failed to load history</div>', 500 return '<div class="history-empty">Failed to load history</div>', 500
# ===== Inventory Routes =====
@game_bp.route('/session/<session_id>/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'''
<div class="modal-overlay" onclick="closeModal()">
<div class="modal-content inventory-modal">
<div class="modal-header">
<h2>Inventory</h2>
<button class="modal-close" onclick="closeModal()">&times;</button>
</div>
<div class="modal-body">
<div class="inventory-empty">Failed to load inventory: {e}</div>
</div>
</div>
</div>
'''
@game_bp.route('/session/<session_id>/inventory/item/<item_id>')
@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 '<div class="item-detail-empty">Item not found</div>', 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'<div class="item-detail-empty">Failed to load item: {e}</div>', 500
@game_bp.route('/session/<session_id>/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 '<div class="error">No item selected</div>', 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 '<div class="error">No character found</div>', 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'<div class="error">Failed to use item: {e}</div>', 500
@game_bp.route('/session/<session_id>/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 '<div class="error">No item selected</div>', 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 '<div class="error">No character found</div>', 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'<div class="error">Failed to equip item: {e}</div>', 500
@game_bp.route('/session/<session_id>/inventory/<item_id>', 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 '<div class="error">No character found</div>', 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'<div class="error">Failed to drop item: {e}</div>', 500
@game_bp.route('/session/<session_id>/npc/<npc_id>/talk', methods=['POST']) @game_bp.route('/session/<session_id>/npc/<npc_id>/talk', methods=['POST'])
@require_auth @require_auth
def talk_to_npc(session_id: str, npc_id: str): def talk_to_npc(session_id: str, npc_id: str):

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<!-- Shield shape -->
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
<!-- Shield decoration -->
<path d="M12 8v6"/>
<path d="M9 11h6"/>
</svg>

After

Width:  |  Height:  |  Size: 321 B

View File

@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<!-- Potion bottle body -->
<path d="M10 2v4"/>
<path d="M14 2v4"/>
<!-- Bottle neck -->
<path d="M8 6h8"/>
<!-- Bottle shape -->
<path d="M8 6l-2 4v10a2 2 0 002 2h8a2 2 0 002-2V10l-2-4"/>
<!-- Liquid level -->
<path d="M6 14h12"/>
<!-- Bubbles -->
<circle cx="10" cy="17" r="1"/>
<circle cx="14" cy="16" r="0.5"/>
</svg>

After

Width:  |  Height:  |  Size: 505 B

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<!-- Box/crate shape -->
<path d="M21 8a2 2 0 00-1-1.73l-7-4a2 2 0 00-2 0l-7 4A2 2 0 003 8v8a2 2 0 001 1.73l7 4a2 2 0 002 0l7-4A2 2 0 0021 16V8z"/>
<!-- Box edges -->
<path d="M3.27 6.96L12 12.01l8.73-5.05"/>
<path d="M12 22.08V12"/>
</svg>

After

Width:  |  Height:  |  Size: 410 B

View File

@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<!-- Scroll body -->
<path d="M4 4a2 2 0 012-2h12a2 2 0 012 2v16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z"/>
<!-- Scroll roll top -->
<path d="M4 4h16"/>
<ellipse cx="4" cy="4" rx="1" ry="2"/>
<ellipse cx="20" cy="4" rx="1" ry="2"/>
<!-- Text lines -->
<path d="M8 9h8"/>
<path d="M8 13h6"/>
<path d="M8 17h4"/>
</svg>

After

Width:  |  Height:  |  Size: 488 B

View File

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<!-- Sword blade -->
<path d="M14.5 17.5L3 6V3h3l11.5 11.5"/>
<!-- Sword guard -->
<path d="M13 19l6-6"/>
<!-- Sword handle -->
<path d="M16 16l4 4"/>
<!-- Blade tip detail -->
<path d="M19 21l2-2"/>
</svg>

After

Width:  |  Height:  |  Size: 382 B

View File

@@ -0,0 +1,337 @@
{% extends "base.html" %}
{% block title %}Combat Tester - Dev Tools{% endblock %}
{% block extra_head %}
<style>
.dev-banner {
background: #dc2626;
color: white;
padding: 0.5rem 1rem;
text-align: center;
font-weight: bold;
position: sticky;
top: 0;
z-index: 100;
}
.combat-hub {
max-width: 900px;
margin: 2rem auto;
padding: 0 1rem;
}
.dev-section {
background: rgba(30, 30, 40, 0.9);
border: 1px solid #4a4a5a;
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.dev-section h2 {
color: #f59e0b;
margin-top: 0;
margin-bottom: 1rem;
font-size: 1.25rem;
border-bottom: 1px solid #4a4a5a;
padding-bottom: 0.5rem;
}
.form-group {
margin-bottom: 1rem;
}
.form-label {
display: block;
color: #9ca3af;
font-size: 0.85rem;
margin-bottom: 0.5rem;
text-transform: uppercase;
}
.form-select {
width: 100%;
padding: 0.75rem;
background: #1a1a2a;
border: 1px solid #4a4a5a;
border-radius: 6px;
color: #e5e7eb;
font-size: 1rem;
}
.form-select:focus {
outline: none;
border-color: #f59e0b;
}
.enemy-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 0.75rem;
margin-bottom: 1rem;
}
.enemy-option {
display: flex;
align-items: center;
padding: 0.75rem;
background: #2a2a3a;
border: 1px solid #4a4a5a;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
}
.enemy-option:hover {
background: #3a3a4a;
border-color: #5a5a6a;
}
.enemy-option.selected {
background: #3b3b5b;
border-color: #f59e0b;
}
.enemy-option input[type="checkbox"] {
margin-right: 0.75rem;
width: 18px;
height: 18px;
accent-color: #f59e0b;
}
.enemy-info {
flex: 1;
}
.enemy-name {
color: #e5e7eb;
font-weight: 500;
}
.enemy-level {
color: #9ca3af;
font-size: 0.8rem;
}
.btn-start {
width: 100%;
padding: 1rem;
background: #10b981;
color: white;
border: none;
border-radius: 6px;
font-size: 1.1rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.btn-start:hover {
background: #059669;
}
.btn-start:disabled {
background: #4a4a5a;
cursor: not-allowed;
}
#create-result {
margin-top: 1rem;
}
.session-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.session-card {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
background: #2a2a3a;
border: 1px solid #4a4a5a;
border-radius: 6px;
}
.session-info {
flex: 1;
}
.session-id {
color: #f59e0b;
font-family: monospace;
font-size: 0.85rem;
}
.session-character {
color: #e5e7eb;
font-weight: 500;
}
.session-status {
color: #10b981;
font-size: 0.85rem;
}
.btn-resume {
padding: 0.5rem 1rem;
background: #3b82f6;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
text-decoration: none;
font-size: 0.9rem;
}
.btn-resume:hover {
background: #2563eb;
}
.empty-state {
text-align: center;
color: #6b7280;
padding: 2rem;
font-style: italic;
}
.error {
background: #7f1d1d;
color: #fecaca;
padding: 0.75rem;
border-radius: 6px;
margin-bottom: 1rem;
}
.success {
background: #064e3b;
color: #a7f3d0;
padding: 0.75rem;
border-radius: 6px;
}
.back-link {
display: inline-block;
margin-bottom: 1rem;
color: #60a5fa;
text-decoration: none;
font-size: 0.9rem;
}
.back-link:hover {
text-decoration: underline;
}
.helper-text {
color: #9ca3af;
font-size: 0.85rem;
margin-top: 0.5rem;
}
</style>
{% endblock %}
{% block content %}
<div class="dev-banner">
DEV MODE - Combat System Tester
</div>
<div class="combat-hub">
<a href="{{ url_for('dev.index') }}" class="back-link">&larr; Back to Dev Tools</a>
{% if error %}
<div class="error">{{ error }}</div>
{% endif %}
<!-- Start New Combat -->
<div class="dev-section">
<h2>Start New Combat</h2>
<form hx-post="{{ url_for('dev.start_combat') }}"
hx-target="#create-result"
hx-swap="innerHTML">
<!-- Session Selection -->
<div class="form-group">
<label class="form-label">Select Session (must have a character)</label>
<select name="session_id" class="form-select" required>
<option value="">-- Select a session --</option>
{% for char in characters %}
<option value="{{ char.session_id if char.session_id else '' }}"
{% if not char.session_id %}disabled{% endif %}>
{{ char.name }} ({{ char.class_name }} Lv.{{ char.level }})
{% if not char.session_id %} - No active session{% endif %}
</option>
{% endfor %}
</select>
<p class="helper-text">You need an active story session to start combat. Create one in the Story Tester first.</p>
</div>
<!-- Enemy Selection -->
<div class="form-group">
<label class="form-label">Select Enemies (check multiple for group encounter)</label>
{% if enemies %}
<div class="enemy-grid">
{% for enemy in enemies %}
<label class="enemy-option" onclick="this.classList.toggle('selected')">
<input type="checkbox" name="enemy_ids" value="{{ enemy.enemy_id }}">
<div class="enemy-info">
<div class="enemy-name">{{ enemy.name }}</div>
<div class="enemy-level">{{ enemy.difficulty | capitalize }} · {{ enemy.experience_reward }} XP</div>
</div>
</label>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
No enemy templates available. Check that the API has enemy data loaded.
</div>
{% endif %}
</div>
<button type="submit" class="btn-start" {% if not enemies %}disabled{% endif %}>
Start Combat
</button>
</form>
<div id="create-result"></div>
</div>
<!-- Active Combat Sessions -->
<div class="dev-section">
<h2>Active Combat Sessions</h2>
{% if sessions_in_combat %}
<div class="session-list">
{% for session in sessions_in_combat %}
<div class="session-card">
<div class="session-info">
<div class="session-id">{{ session.session_id[:12] }}...</div>
<div class="session-character">{{ session.character_name or 'Unknown Character' }}</div>
<div class="session-status">In Combat - Round {{ session.game_state.combat_round or 1 }}</div>
</div>
<a href="{{ url_for('dev.combat_session', session_id=session.session_id) }}" class="btn-resume">
Resume Combat
</a>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
No active combat sessions. Start a new combat above.
</div>
{% endif %}
</div>
</div>
<script>
// Toggle selected state on checkbox change
document.querySelectorAll('.enemy-option input[type="checkbox"]').forEach(checkbox => {
checkbox.addEventListener('change', function() {
this.closest('.enemy-option').classList.toggle('selected', this.checked);
});
});
</script>
{% endblock %}

View File

@@ -0,0 +1,864 @@
{% extends "base.html" %}
{% block title %}Combat Debug - Dev Tools{% endblock %}
{% block extra_head %}
<style>
.dev-banner {
background: #dc2626;
color: white;
padding: 0.5rem 1rem;
text-align: center;
font-weight: bold;
position: sticky;
top: 0;
z-index: 100;
}
.combat-container {
max-width: 1400px;
margin: 1rem auto;
padding: 0 1rem;
display: grid;
grid-template-columns: 280px 1fr 300px;
gap: 1rem;
}
@media (max-width: 1200px) {
.combat-container {
grid-template-columns: 250px 1fr;
}
.right-panel {
display: none;
}
}
@media (max-width: 768px) {
.combat-container {
grid-template-columns: 1fr;
}
.left-panel {
display: none;
}
}
.panel {
background: rgba(30, 30, 40, 0.9);
border: 1px solid #4a4a5a;
border-radius: 8px;
padding: 1rem;
}
.panel h3 {
color: #f59e0b;
margin-top: 0;
margin-bottom: 1rem;
font-size: 1rem;
border-bottom: 1px solid #4a4a5a;
padding-bottom: 0.5rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.btn-refresh {
background: #6366f1;
color: white;
padding: 0.25rem 0.5rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.75rem;
}
.btn-refresh:hover {
background: #4f46e5;
}
/* Left Panel - State */
.state-section {
margin-bottom: 1.5rem;
}
.state-section h4 {
color: #9ca3af;
font-size: 0.75rem;
text-transform: uppercase;
margin: 0 0 0.5rem 0;
}
.state-item {
margin-bottom: 0.5rem;
}
.state-label {
color: #6b7280;
font-size: 0.75rem;
}
.state-value {
color: #e5e7eb;
font-weight: 500;
}
.combatant-card {
background: #2a2a3a;
border-radius: 6px;
padding: 0.75rem;
margin-bottom: 0.5rem;
border-left: 3px solid #4a4a5a;
}
.combatant-card.player {
border-left-color: #3b82f6;
}
.combatant-card.enemy {
border-left-color: #ef4444;
}
.combatant-card.active {
box-shadow: 0 0 0 2px #f59e0b;
}
.combatant-card.defeated {
opacity: 0.5;
}
.combatant-name {
color: #e5e7eb;
font-weight: 600;
margin-bottom: 0.5rem;
}
.resource-bar {
height: 8px;
background: #1a1a2a;
border-radius: 4px;
overflow: hidden;
margin-bottom: 0.25rem;
}
.resource-bar-fill {
height: 100%;
border-radius: 4px;
transition: width 0.3s ease;
}
.resource-bar-fill.hp {
background: linear-gradient(90deg, #ef4444, #f87171);
}
.resource-bar-fill.mp {
background: linear-gradient(90deg, #3b82f6, #60a5fa);
}
.resource-bar-fill.low {
background: linear-gradient(90deg, #dc2626, #ef4444);
animation: pulse 1s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
.resource-text {
font-size: 0.7rem;
color: #9ca3af;
display: flex;
justify-content: space-between;
}
/* Debug Actions */
.debug-actions {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid #4a4a5a;
}
.debug-btn {
width: 100%;
padding: 0.5rem;
margin-bottom: 0.5rem;
border: 1px solid #4a4a5a;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.2s;
}
.debug-btn.victory {
background: #064e3b;
color: #a7f3d0;
}
.debug-btn.victory:hover {
background: #065f46;
}
.debug-btn.defeat {
background: #7f1d1d;
color: #fecaca;
}
.debug-btn.defeat:hover {
background: #991b1b;
}
.debug-btn.reset {
background: #1e40af;
color: #bfdbfe;
}
.debug-btn.reset:hover {
background: #1d4ed8;
}
/* Center Panel - Main */
.main-panel {
min-height: 600px;
display: flex;
flex-direction: column;
}
#combat-log {
flex: 1;
background: #1a1a2a;
border-radius: 6px;
padding: 1rem;
min-height: 300px;
max-height: 400px;
overflow-y: auto;
margin-bottom: 1rem;
}
.log-entry {
padding: 0.5rem 0.75rem;
margin-bottom: 0.5rem;
border-radius: 4px;
font-size: 0.9rem;
}
.log-entry--player {
background: rgba(59, 130, 246, 0.15);
border-left: 3px solid #3b82f6;
}
.log-entry--enemy {
background: rgba(239, 68, 68, 0.15);
border-left: 3px solid #ef4444;
}
.log-entry--crit {
background: rgba(245, 158, 11, 0.2);
border-left: 3px solid #f59e0b;
}
.log-entry--system {
background: rgba(107, 114, 128, 0.15);
border-left: 3px solid #6b7280;
font-style: italic;
color: #9ca3af;
}
.log-entry--heal {
background: rgba(16, 185, 129, 0.15);
border-left: 3px solid #10b981;
}
.log-actor {
font-weight: 600;
color: #e5e7eb;
}
.log-message {
color: #d1d5db;
}
.log-damage {
color: #ef4444;
font-weight: 600;
}
.log-heal {
color: #10b981;
font-weight: 600;
}
.log-crit {
color: #f59e0b;
font-size: 0.75rem;
margin-left: 0.5rem;
}
/* Action Buttons */
.actions-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 0.5rem;
margin-bottom: 1rem;
}
@media (max-width: 900px) {
.actions-grid {
grid-template-columns: repeat(3, 1fr);
}
}
.action-btn {
padding: 0.75rem 1rem;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
font-weight: 500;
transition: all 0.2s;
text-align: center;
}
.action-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.action-btn.attack {
background: #ef4444;
color: white;
}
.action-btn.attack:hover:not(:disabled) {
background: #dc2626;
}
.action-btn.ability {
background: #8b5cf6;
color: white;
}
.action-btn.ability:hover:not(:disabled) {
background: #7c3aed;
}
.action-btn.item {
background: #10b981;
color: white;
}
.action-btn.item:hover:not(:disabled) {
background: #059669;
}
.action-btn.defend {
background: #3b82f6;
color: white;
}
.action-btn.defend:hover:not(:disabled) {
background: #2563eb;
}
.action-btn.flee {
background: #6b7280;
color: white;
}
.action-btn.flee:hover:not(:disabled) {
background: #4b5563;
}
/* Right Panel */
.turn-order {
margin-bottom: 1rem;
}
.turn-item {
display: flex;
align-items: center;
padding: 0.5rem;
margin-bottom: 0.25rem;
background: #2a2a3a;
border-radius: 4px;
font-size: 0.85rem;
}
.turn-item.active {
background: #3b3b5b;
border: 1px solid #f59e0b;
}
.turn-number {
width: 24px;
height: 24px;
background: #4a4a5a;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-right: 0.5rem;
font-size: 0.75rem;
color: #9ca3af;
}
.turn-item.active .turn-number {
background: #f59e0b;
color: #1a1a2a;
}
.turn-name {
color: #e5e7eb;
}
.turn-name.player {
color: #60a5fa;
}
.turn-name.enemy {
color: #f87171;
}
/* Effects Panel */
.effects-panel {
margin-top: 1rem;
}
.effect-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem;
margin-bottom: 0.25rem;
background: #2a2a3a;
border-radius: 4px;
font-size: 0.8rem;
}
.effect-name {
color: #e5e7eb;
}
.effect-duration {
color: #f59e0b;
font-size: 0.75rem;
}
/* Debug Panel */
.debug-panel {
margin-top: 1rem;
padding: 1rem;
background: #1a1a2a;
border-radius: 6px;
font-family: monospace;
font-size: 0.75rem;
}
.debug-toggle {
color: #9ca3af;
cursor: pointer;
user-select: none;
}
.debug-content {
margin-top: 0.5rem;
max-height: 300px;
overflow: auto;
white-space: pre;
color: #a3e635;
}
/* Modal Styles */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: #1a1a2a;
border: 1px solid #4a4a5a;
border-radius: 8px;
padding: 1.5rem;
max-width: 500px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
}
.modal-content h3 {
color: #f59e0b;
margin-top: 0;
margin-bottom: 1rem;
}
.modal-close {
margin-top: 1rem;
padding: 0.5rem 1rem;
background: #6b7280;
border: none;
border-radius: 4px;
color: white;
cursor: pointer;
}
/* Sheet Styles */
.combat-items-sheet {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: #1a1a2a;
border-top: 1px solid #4a4a5a;
border-radius: 16px 16px 0 0;
padding: 1rem;
max-height: 50vh;
overflow-y: auto;
z-index: 1000;
transform: translateY(100%);
transition: transform 0.3s ease;
}
.combat-items-sheet.open {
transform: translateY(0);
}
.sheet-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 999;
}
.sheet-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.sheet-header h3 {
color: #f59e0b;
margin: 0;
}
.sheet-close {
background: none;
border: none;
color: #9ca3af;
font-size: 1.5rem;
cursor: pointer;
}
.error {
background: #7f1d1d;
color: #fecaca;
padding: 0.75rem;
border-radius: 6px;
}
.success {
background: #064e3b;
color: #a7f3d0;
padding: 0.75rem;
border-radius: 6px;
}
.back-link {
color: #60a5fa;
text-decoration: none;
font-size: 0.85rem;
}
.back-link:hover {
text-decoration: underline;
}
</style>
{% endblock %}
{% block content %}
<div class="dev-banner">
DEV MODE - Combat Session {{ session_id[:8] }}...
</div>
<div class="combat-container">
<!-- Left Panel: Combat State -->
<div class="panel left-panel">
<h3>
Combat State
<button class="btn-refresh"
hx-get="{{ url_for('dev.combat_state', session_id=session_id) }}"
hx-target="#state-content"
hx-swap="innerHTML">
Refresh
</button>
</h3>
<div id="state-content">
{% include 'dev/partials/combat_state.html' %}
</div>
<!-- Debug Actions -->
<div class="debug-actions">
<h4 style="color: #f59e0b; font-size: 0.85rem; margin: 0 0 0.5rem 0;">Debug Actions</h4>
<button class="debug-btn reset"
hx-post="{{ url_for('dev.reset_hp_mp', session_id=session_id) }}"
hx-target="#combat-log"
hx-swap="beforeend">
Reset HP/MP
</button>
<button class="debug-btn victory"
hx-post="{{ url_for('dev.force_end_combat', session_id=session_id) }}"
hx-vals='{"victory": "true"}'
hx-target="#combat-log"
hx-swap="innerHTML">
Force Victory
</button>
<button class="debug-btn defeat"
hx-post="{{ url_for('dev.force_end_combat', session_id=session_id) }}"
hx-vals='{"victory": "false"}'
hx-target="#combat-log"
hx-swap="innerHTML">
Force Defeat
</button>
</div>
<div style="margin-top: 1rem; padding-top: 1rem; border-top: 1px solid #4a4a5a;">
<a href="{{ url_for('dev.combat_hub') }}" class="back-link">&larr; Back to Combat Hub</a>
</div>
</div>
<!-- Center Panel: Combat Log & Actions -->
<div class="panel main-panel">
<h3>Combat Log</h3>
<!-- Combat Log -->
<div id="combat-log" role="log" aria-live="polite">
{% for entry in combat_log %}
<div class="log-entry log-entry--{{ entry.type }}">
{% if entry.actor %}
<span class="log-actor">{{ entry.actor }}</span>
{% endif %}
<span class="log-message">{{ entry.message }}</span>
{% if entry.damage %}
<span class="log-damage">-{{ entry.damage }} HP</span>
{% endif %}
{% if entry.heal %}
<span class="log-heal">+{{ entry.heal }} HP</span>
{% endif %}
{% if entry.is_crit %}
<span class="log-crit">CRITICAL!</span>
{% endif %}
</div>
{% else %}
<div class="log-entry log-entry--system">
Combat begins!
{% if is_player_turn %}
Take your action.
{% else %}
Waiting for enemy turn...
{% endif %}
</div>
{% endfor %}
</div>
<!-- Action Buttons -->
<div class="actions-grid" id="action-buttons">
<button class="action-btn attack"
hx-post="{{ url_for('dev.combat_action', session_id=session_id) }}"
hx-vals='{"action_type": "attack"}'
hx-target="#combat-log"
hx-swap="beforeend"
hx-disabled-elt="this"
{% if not is_player_turn %}disabled{% endif %}>
Attack
</button>
<button class="action-btn ability"
hx-get="{{ url_for('dev.combat_abilities', session_id=session_id) }}"
hx-target="#modal-container"
hx-swap="innerHTML"
{% if not is_player_turn %}disabled{% endif %}>
Ability
</button>
<button class="action-btn item"
hx-get="{{ url_for('dev.combat_items', session_id=session_id) }}"
hx-target="#sheet-container"
hx-swap="innerHTML"
{% if not is_player_turn %}disabled{% endif %}>
Item
</button>
<button class="action-btn defend"
hx-post="{{ url_for('dev.combat_action', session_id=session_id) }}"
hx-vals='{"action_type": "defend"}'
hx-target="#combat-log"
hx-swap="beforeend"
hx-disabled-elt="this"
{% if not is_player_turn %}disabled{% endif %}>
Defend
</button>
<button class="action-btn flee"
hx-post="{{ url_for('dev.combat_action', session_id=session_id) }}"
hx-vals='{"action_type": "flee"}'
hx-target="#combat-log"
hx-swap="beforeend"
hx-disabled-elt="this"
{% if not is_player_turn %}disabled{% endif %}>
Flee
</button>
</div>
<!-- Debug Panel -->
<div class="debug-panel">
<div class="debug-toggle" onclick="this.nextElementSibling.style.display = this.nextElementSibling.style.display === 'none' ? 'block' : 'none'">
[+] Raw State JSON (click to toggle)
</div>
<div class="debug-content" style="display: none;">{{ raw_state | tojson(indent=2) }}</div>
</div>
</div>
<!-- Right Panel: Turn Order & Effects -->
<div class="panel right-panel">
<h3>Turn Order</h3>
<div class="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 %}
<div class="turn-item {% if combatant_id == current_turn_id %}active{% endif %}">
<span class="turn-number">{{ loop.index }}</span>
<span class="turn-name {% if ns.combatant and ns.combatant.is_player %}player{% else %}enemy{% endif %}">
{% if ns.combatant %}{{ ns.combatant.name }}{% else %}{{ combatant_id[:8] }}{% endif %}
</span>
</div>
{% endfor %}
</div>
<h3 style="margin-top: 1rem;">Active Effects</h3>
<div class="effects-panel">
{% if player_combatant and player_combatant.active_effects %}
{% for effect in player_combatant.active_effects %}
<div class="effect-item">
<span class="effect-name">{{ effect.name }}</span>
<span class="effect-duration">{{ effect.remaining_duration }} turns</span>
</div>
{% endfor %}
{% else %}
<p style="color: #6b7280; font-size: 0.85rem; text-align: center;">No active effects</p>
{% endif %}
</div>
</div>
</div>
<!-- Modal Container -->
<div id="modal-container"></div>
<!-- Sheet Container -->
<div id="sheet-container"></div>
<script>
// Close modal function
function closeModal() {
document.getElementById('modal-container').innerHTML = '';
}
// Close combat sheet function
function closeCombatSheet() {
document.getElementById('sheet-container').innerHTML = '';
}
// Refresh combat state panel
function refreshCombatState() {
htmx.ajax('GET', '{{ url_for("dev.combat_state", session_id=session_id) }}', {
target: '#state-content',
swap: 'innerHTML'
});
}
// Auto-scroll combat log
const combatLog = document.getElementById('combat-log');
if (combatLog) {
combatLog.scrollTop = combatLog.scrollHeight;
}
// Observe combat log for new entries and auto-scroll
const observer = new MutationObserver(function() {
combatLog.scrollTop = combatLog.scrollHeight;
});
observer.observe(combatLog, { childList: true });
// Guard against duplicate enemy turn requests
let enemyTurnPending = false;
let enemyTurnTimeout = null;
function triggerEnemyTurn(delay = 1000) {
// Prevent duplicate requests
if (enemyTurnPending) {
return;
}
// Clear any pending timeout
if (enemyTurnTimeout) {
clearTimeout(enemyTurnTimeout);
}
enemyTurnPending = true;
enemyTurnTimeout = setTimeout(function() {
htmx.ajax('POST', '{{ url_for("dev.combat_enemy_turn", session_id=session_id) }}', {
target: '#combat-log',
swap: 'beforeend'
}).then(function() {
enemyTurnPending = false;
// Refresh state after enemy turn completes
setTimeout(refreshCombatState, 500);
}).catch(function() {
enemyTurnPending = false;
});
}, delay);
}
// Auto-trigger enemy turn on page load if it's not the player's turn
{% if not is_player_turn %}
document.addEventListener('DOMContentLoaded', function() {
// Small delay to let the page render first
triggerEnemyTurn(500);
});
{% endif %}
// Handle enemy turn trigger
document.body.addEventListener('htmx:afterRequest', function(event) {
// Check for enemyTurn trigger
const trigger = event.detail.xhr.getResponseHeader('HX-Trigger');
if (trigger && trigger.includes('enemyTurn')) {
triggerEnemyTurn(1000);
}
// Refresh state after any combat action (player action, debug action, but NOT enemy turn - handled above)
const requestUrl = event.detail.pathInfo?.requestPath || '';
const isActionBtn = event.detail.elt && event.detail.elt.classList && event.detail.elt.classList.contains('action-btn');
const isDebugBtn = event.detail.elt && event.detail.elt.classList && event.detail.elt.classList.contains('debug-btn');
if (isActionBtn || isDebugBtn) {
setTimeout(refreshCombatState, 500);
}
});
// Re-enable buttons when player turn returns
document.body.addEventListener('htmx:afterSwap', function(event) {
// If state was updated, check if it's player turn
if (event.detail.target.id === 'state-content') {
const stateContent = document.getElementById('state-content');
const isPlayerTurn = stateContent && stateContent.textContent.includes('Your Turn');
const buttons = document.querySelectorAll('.action-btn');
buttons.forEach(function(btn) {
btn.disabled = !isPlayerTurn;
});
}
});
</script>
{% endblock %}

View File

@@ -83,6 +83,14 @@
</a> </a>
</div> </div>
<div class="dev-section">
<h2>Combat System</h2>
<a href="{{ url_for('dev.combat_hub') }}" class="dev-link">
Combat System Tester
<small>Start encounters, test actions, abilities, items, and enemy AI</small>
</a>
</div>
<div class="dev-section"> <div class="dev-section">
<h2>Quest System</h2> <h2>Quest System</h2>
<span class="dev-link dev-link-disabled"> <span class="dev-link dev-link-disabled">

View File

@@ -0,0 +1,62 @@
<!-- Ability Selection Modal -->
<div class="modal-overlay" onclick="closeModal()">
<div class="modal-content" onclick="event.stopPropagation()">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h3 style="margin: 0; color: #f59e0b;">Select Ability</h3>
<button onclick="closeModal()" style="background: none; border: none; color: #9ca3af; font-size: 1.5rem; cursor: pointer;">&times;</button>
</div>
{% if abilities %}
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
{% for ability in abilities %}
<button style="
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem;
background: {{ '#2a2a3a' if ability.available else '#1a1a2a' }};
border: 1px solid {{ '#4a4a5a' if ability.available else '#3a3a4a' }};
border-radius: 6px;
cursor: {{ 'pointer' if ability.available else 'not-allowed' }};
opacity: {{ '1' if ability.available else '0.5' }};
text-align: left;
transition: all 0.2s;
"
{% if ability.available %}
hx-post="{{ url_for('dev.combat_action', session_id=session_id) }}"
hx-vals='{"action_type": "ability", "ability_id": "{{ ability.id }}"}'
hx-target="#combat-log"
hx-swap="beforeend"
onclick="closeModal()"
{% else %}
disabled
{% endif %}>
<div>
<div style="color: #e5e7eb; font-weight: 500;">{{ ability.name }}</div>
{% if ability.description %}
<div style="color: #9ca3af; font-size: 0.8rem; margin-top: 0.25rem;">{{ ability.description[:100] }}{% if ability.description|length > 100 %}...{% endif %}</div>
{% endif %}
</div>
<div style="text-align: right;">
{% if ability.mp_cost > 0 %}
<div style="color: #60a5fa; font-size: 0.85rem;">{{ ability.mp_cost }} MP</div>
{% endif %}
{% if ability.cooldown > 0 %}
<div style="color: #f59e0b; font-size: 0.75rem;">CD: {{ ability.cooldown }}</div>
{% endif %}
</div>
</button>
{% endfor %}
</div>
{% else %}
<div style="text-align: center; color: #6b7280; padding: 2rem;">
No abilities available.
</div>
{% endif %}
<button class="modal-close" onclick="closeModal()" style="width: 100%; margin-top: 1rem;">
Cancel
</button>
</div>
</div>

View File

@@ -0,0 +1,19 @@
<!-- Combat Debug Log Entry Partial - appended to combat log -->
{% for entry in combat_log %}
<div class="log-entry log-entry--{{ entry.type }}">
{% if entry.actor %}
<span class="log-actor">{{ entry.actor }}</span>
{% endif %}
<span class="log-message">{{ entry.message }}</span>
{% if entry.damage %}
<span class="log-damage">-{{ entry.damage }} HP</span>
{% endif %}
{% if entry.heal %}
<span class="log-heal">+{{ entry.heal }} HP</span>
{% endif %}
{% if entry.is_crit %}
<span class="log-crit">CRITICAL!</span>
{% endif %}
</div>
{% endfor %}

View File

@@ -0,0 +1,32 @@
<!-- Combat Defeat Screen -->
<div style="text-align: center; padding: 2rem;">
<div style="font-size: 3rem; margin-bottom: 1rem;">&#128128;</div>
<h2 style="color: #ef4444; margin-bottom: 1rem;">Defeat</h2>
<p style="color: #d1d5db; margin-bottom: 2rem;">You have been defeated in battle...</p>
<!-- Penalties -->
{% if gold_lost and gold_lost > 0 %}
<div style="background: rgba(127, 29, 29, 0.3); border-radius: 8px; padding: 1rem; margin-bottom: 1.5rem;">
<div style="color: #fecaca;">
<span style="color: #ef4444; font-weight: 600;">-{{ gold_lost }} gold</span> lost
</div>
</div>
{% endif %}
<p style="color: #9ca3af; font-size: 0.9rem; margin-bottom: 2rem;">
Your progress has been saved. You can try again or return to town.
</p>
<!-- Actions -->
<div style="display: flex; gap: 1rem; justify-content: center;">
<a href="{{ url_for('dev.combat_hub') }}"
style="padding: 0.75rem 1.5rem; background: #ef4444; color: white; border-radius: 6px; text-decoration: none;">
Try Again
</a>
<a href="{{ url_for('dev.story_hub') }}"
style="padding: 0.75rem 1.5rem; background: #6b7280; color: white; border-radius: 6px; text-decoration: none;">
Return to Town
</a>
</div>
</div>

View File

@@ -0,0 +1,88 @@
<!-- Combat Items Bottom Sheet -->
<div class="sheet-backdrop" onclick="closeCombatSheet()"></div>
<div class="combat-items-sheet open">
<div class="sheet-header">
<h3>Use Item</h3>
<button class="sheet-close" onclick="closeCombatSheet()">&times;</button>
</div>
<div class="sheet-body">
{% if has_consumables %}
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 0.75rem;">
{% for item in consumables %}
<button style="
padding: 1rem;
background: #2a2a3a;
border: 1px solid #4a4a5a;
border-radius: 6px;
cursor: pointer;
text-align: left;
transition: all 0.2s;
"
hx-get="{{ url_for('dev.combat_item_detail', session_id=session_id, item_id=item.item_id) }}"
hx-target="#item-detail"
hx-swap="innerHTML">
<div style="color: #e5e7eb; font-weight: 500; margin-bottom: 0.25rem;">{{ item.name }}</div>
<div style="color:
{% if item.rarity == 'uncommon' %}#10b981
{% elif item.rarity == 'rare' %}#3b82f6
{% elif item.rarity == 'epic' %}#a78bfa
{% elif item.rarity == 'legendary' %}#f59e0b
{% else %}#9ca3af{% endif %};
font-size: 0.75rem; text-transform: capitalize;">
{{ item.rarity }}
</div>
</button>
{% endfor %}
</div>
<!-- Item Detail Panel -->
<div id="item-detail" style="margin-top: 1rem; padding-top: 1rem; border-top: 1px solid #4a4a5a;">
<div style="text-align: center; color: #6b7280; font-size: 0.9rem;">
Select an item to see details
</div>
</div>
{% else %}
<div style="text-align: center; color: #6b7280; padding: 2rem;">
No consumable items in inventory.
</div>
{% endif %}
</div>
</div>
<style>
.detail-info {
background: #1a1a2a;
border-radius: 6px;
padding: 1rem;
margin-bottom: 1rem;
}
.detail-name {
color: #e5e7eb;
font-weight: 600;
margin-bottom: 0.5rem;
}
.detail-effect {
color: #10b981;
font-size: 0.9rem;
}
.use-btn {
width: 100%;
padding: 0.75rem;
background: #10b981;
color: white;
border: none;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.use-btn:hover {
background: #059669;
}
</style>

View File

@@ -0,0 +1,84 @@
<!-- Combat State Partial - refreshable via HTMX -->
<div class="state-section">
<h4>Encounter Info</h4>
<div class="state-item">
<div class="state-label">Round</div>
<div class="state-value">{{ encounter.round_number or 1 }}</div>
</div>
<div class="state-item">
<div class="state-label">Status</div>
<div class="state-value">{{ encounter.status or 'active' }}</div>
</div>
<div class="state-item">
<div class="state-label">Current Turn</div>
<div class="state-value">
{% if is_player_turn %}
<span style="color: #60a5fa;">Your Turn</span>
{% else %}
<span style="color: #f87171;">Enemy Turn</span>
{% endif %}
</div>
</div>
</div>
<!-- Player Card -->
{% if player_combatant %}
<div class="state-section">
<h4>Player</h4>
<div class="combatant-card player {% if player_combatant.combatant_id == current_turn_id %}active{% endif %} {% if player_combatant.current_hp <= 0 %}defeated{% endif %}">
<div class="combatant-name">{{ player_combatant.name }}</div>
<!-- HP Bar -->
{% set hp_percent = (player_combatant.current_hp / player_combatant.max_hp * 100) if player_combatant.max_hp > 0 else 0 %}
<div class="resource-bar">
<div class="resource-bar-fill hp {% if hp_percent < 25 %}low{% endif %}"
style="width: {{ hp_percent }}%"></div>
</div>
<div class="resource-text">
<span>HP</span>
<span>{{ player_combatant.current_hp }}/{{ player_combatant.max_hp }}</span>
</div>
<!-- MP Bar -->
{% 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 %}
<div class="resource-bar" style="margin-top: 0.5rem;">
<div class="resource-bar-fill mp" style="width: {{ mp_percent }}%"></div>
</div>
<div class="resource-text">
<span>MP</span>
<span>{{ player_combatant.current_mp }}/{{ player_combatant.max_mp }}</span>
</div>
{% endif %}
</div>
</div>
{% endif %}
<!-- Enemy Cards -->
{% if enemy_combatants %}
<div class="state-section">
<h4>Enemies ({{ enemy_combatants | length }})</h4>
{% for enemy in enemy_combatants %}
<div class="combatant-card enemy {% if enemy.combatant_id == current_turn_id %}active{% endif %} {% if enemy.current_hp <= 0 %}defeated{% endif %}">
<div class="combatant-name">
{{ enemy.name }}
{% if enemy.current_hp <= 0 %}
<span style="color: #6b7280; font-size: 0.75rem;">(Defeated)</span>
{% endif %}
</div>
<!-- HP Bar -->
{% set enemy_hp_percent = (enemy.current_hp / enemy.max_hp * 100) if enemy.max_hp > 0 else 0 %}
<div class="resource-bar">
<div class="resource-bar-fill hp {% if enemy_hp_percent < 25 %}low{% endif %}"
style="width: {{ enemy_hp_percent }}%"></div>
</div>
<div class="resource-text">
<span>HP</span>
<span>{{ enemy.current_hp }}/{{ enemy.max_hp }}</span>
</div>
</div>
{% endfor %}
</div>
{% endif %}

View File

@@ -0,0 +1,68 @@
<!-- Combat Victory Screen -->
<div style="text-align: center; padding: 2rem;">
<div style="font-size: 3rem; margin-bottom: 1rem;">&#127942;</div>
<h2 style="color: #10b981; margin-bottom: 1rem;">Victory!</h2>
<p style="color: #d1d5db; margin-bottom: 2rem;">You have defeated your enemies!</p>
<!-- Rewards Section -->
{% if rewards %}
<div style="background: #2a2a3a; border-radius: 8px; padding: 1.5rem; margin-bottom: 1.5rem; text-align: left;">
<h3 style="color: #f59e0b; margin-top: 0; margin-bottom: 1rem;">Rewards</h3>
{% if rewards.experience %}
<div style="display: flex; justify-content: space-between; margin-bottom: 0.5rem;">
<span style="color: #9ca3af;">Experience</span>
<span style="color: #a78bfa; font-weight: 600;">+{{ rewards.experience }} XP</span>
</div>
{% endif %}
{% if rewards.gold %}
<div style="display: flex; justify-content: space-between; margin-bottom: 0.5rem;">
<span style="color: #9ca3af;">Gold</span>
<span style="color: #fbbf24; font-weight: 600;">+{{ rewards.gold }} gold</span>
</div>
{% endif %}
{% if rewards.level_ups %}
<div style="background: rgba(168, 85, 247, 0.2); border-radius: 6px; padding: 1rem; margin-top: 1rem;">
<div style="color: #a78bfa; font-weight: 600;">Level Up!</div>
<div style="color: #d1d5db; font-size: 0.9rem;">You have reached a new level!</div>
</div>
{% endif %}
{% if rewards.items %}
<div style="margin-top: 1rem; padding-top: 1rem; border-top: 1px solid #4a4a5a;">
<div style="color: #9ca3af; font-size: 0.85rem; margin-bottom: 0.5rem;">Loot Obtained:</div>
{% for item in rewards.items %}
<div style="display: flex; align-items: center; padding: 0.5rem; background: #1a1a2a; border-radius: 4px; margin-bottom: 0.25rem;">
<span style="color: #e5e7eb;">{{ item.name }}</span>
{% if item.rarity and item.rarity != 'common' %}
<span style="margin-left: 0.5rem; font-size: 0.75rem; color:
{% if item.rarity == 'uncommon' %}#10b981
{% elif item.rarity == 'rare' %}#3b82f6
{% elif item.rarity == 'epic' %}#a78bfa
{% elif item.rarity == 'legendary' %}#f59e0b
{% else %}#9ca3af{% endif %};">
({{ item.rarity }})
</span>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
</div>
{% endif %}
<!-- Actions -->
<div style="display: flex; gap: 1rem; justify-content: center;">
<a href="{{ url_for('dev.combat_hub') }}"
style="padding: 0.75rem 1.5rem; background: #3b82f6; color: white; border-radius: 6px; text-decoration: none;">
Back to Combat Hub
</a>
<a href="{{ url_for('dev.story_hub') }}"
style="padding: 0.75rem 1.5rem; background: #6b7280; color: white; border-radius: 6px; text-decoration: none;">
Continue Adventure
</a>
</div>
</div>

View File

@@ -0,0 +1,258 @@
{% extends "base.html" %}
{% block title %}Combat - Code of Conquest{% endblock %}
{% block extra_head %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/combat.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/inventory.css') }}">
{% endblock %}
{% block content %}
<div class="combat-page">
<div class="combat-container">
{# ===== COMBAT HEADER ===== #}
<header class="combat-header">
<h1 class="combat-title">
<span class="combat-title-icon">&#9876;</span>
Combat Encounter
</h1>
<div class="combat-round">
<span class="round-counter">Round <strong>{{ encounter.round_number }}</strong></span>
{% if is_player_turn %}
<span class="turn-indicator turn-indicator--player">Your Turn</span>
{% else %}
<span class="turn-indicator turn-indicator--enemy">Enemy Turn</span>
{% endif %}
</div>
</header>
{# ===== LEFT COLUMN: COMBATANTS ===== #}
<aside class="combatant-panel">
{# Player Section #}
<div class="combatant-section">
<h2 class="combatant-section-title">Your Party</h2>
{% for combatant in encounter.combatants if combatant.is_player %}
<div class="combatant-card combatant-card--player {% if combatant.combatant_id == current_turn_id %}combatant-card--active{% endif %} {% if combatant.current_hp <= 0 %}combatant-card--defeated{% endif %}">
<div class="combatant-header">
<span class="combatant-name">{{ combatant.name }}</span>
<span class="combatant-level">Lv.{{ combatant.level|default(1) }}</span>
</div>
<div class="combatant-resources">
{% set hp_percent = ((combatant.current_hp / combatant.max_hp) * 100)|round|int %}
<div class="resource-bar resource-bar--hp {% if hp_percent < 25 %}low{% endif %}">
<div class="resource-bar-label">
<span class="resource-bar-name">HP</span>
<span class="resource-bar-value">{{ combatant.current_hp }} / {{ combatant.max_hp }}</span>
</div>
<div class="resource-bar-track">
<div class="resource-bar-fill" style="width: {{ hp_percent }}%"></div>
</div>
</div>
{% set mp_percent = ((combatant.current_mp / combatant.max_mp) * 100)|round|int if combatant.max_mp > 0 else 0 %}
<div class="resource-bar resource-bar--mp">
<div class="resource-bar-label">
<span class="resource-bar-name">MP</span>
<span class="resource-bar-value">{{ combatant.current_mp }} / {{ combatant.max_mp }}</span>
</div>
<div class="resource-bar-track">
<div class="resource-bar-fill" style="width: {{ mp_percent }}%"></div>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{# Enemies Section #}
<div class="combatant-section">
<h2 class="combatant-section-title">Enemies</h2>
{% for combatant in encounter.combatants if not combatant.is_player %}
<div class="combatant-card combatant-card--enemy {% if combatant.combatant_id == current_turn_id %}combatant-card--active{% endif %} {% if combatant.current_hp <= 0 %}combatant-card--defeated{% endif %}">
<div class="combatant-header">
<span class="combatant-name">{{ combatant.name }}</span>
<span class="combatant-level">{% if combatant.current_hp <= 0 %}Defeated{% endif %}</span>
</div>
<div class="combatant-resources">
{% set hp_percent = ((combatant.current_hp / combatant.max_hp) * 100)|round|int %}
<div class="resource-bar resource-bar--hp {% if hp_percent < 25 %}low{% endif %}">
<div class="resource-bar-label">
<span class="resource-bar-name">HP</span>
<span class="resource-bar-value">{{ combatant.current_hp }} / {{ combatant.max_hp }}</span>
</div>
<div class="resource-bar-track">
<div class="resource-bar-fill" style="width: {{ hp_percent }}%"></div>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
</aside>
{# ===== CENTER COLUMN: COMBAT LOG + ACTIONS ===== #}
<main class="combat-main">
{# Combat Log #}
<div id="combat-log" class="combat-log" role="log" aria-live="polite" aria-label="Combat log">
{% include "game/partials/combat_log.html" %}
</div>
{# Combat Actions #}
<div id="combat-actions" class="combat-actions">
{% include "game/partials/combat_actions.html" %}
</div>
</main>
{# ===== RIGHT COLUMN: TURN ORDER + EFFECTS ===== #}
<aside class="combat-sidebar">
{# Turn Order #}
<div class="turn-order">
<h2 class="turn-order__title">Turn Order</h2>
<div class="turn-order__list">
{% for combatant_id in encounter.turn_order %}
{% set combatant = encounter.combatants|selectattr('combatant_id', 'equalto', combatant_id)|first %}
{% if combatant %}
<div class="turn-order__item {% if combatant.is_player %}turn-order__item--player{% else %}turn-order__item--enemy{% endif %} {% if combatant_id == current_turn_id %}turn-order__item--active{% endif %} {% if combatant.current_hp <= 0 %}turn-order__item--defeated{% endif %}">
<span class="turn-order__position">{{ loop.index }}</span>
<span class="turn-order__name">{{ combatant.name }}</span>
{% if combatant_id == current_turn_id %}
<span class="turn-order__check" title="Current turn">&#10148;</span>
{% elif combatant.current_hp <= 0 %}
<span class="turn-order__check" title="Defeated">&#10007;</span>
{% endif %}
</div>
{% endif %}
{% endfor %}
</div>
</div>
{# Active Effects #}
<div class="effects-panel">
<h2 class="effects-panel__title">Active Effects</h2>
{% if player_combatant and player_combatant.active_effects %}
<div class="effects-list">
{% for effect in player_combatant.active_effects %}
<div class="effect-item effect-item--{{ effect.effect_type|default('buff') }}">
<span class="effect-icon">
{% if effect.effect_type == 'shield' %}&#128737;
{% elif effect.effect_type == 'buff' %}&#11014;
{% elif effect.effect_type == 'debuff' %}&#11015;
{% elif effect.effect_type == 'dot' %}&#128293;
{% elif effect.effect_type == 'hot' %}&#10084;
{% else %}&#9733;
{% endif %}
</span>
<span class="effect-name">{{ effect.name }}</span>
<span class="effect-duration">{{ effect.remaining_duration }} {% if effect.remaining_duration == 1 %}turn{% else %}turns{% endif %}</span>
</div>
{% endfor %}
</div>
{% else %}
<p class="effects-empty">No active effects</p>
{% endif %}
</div>
</aside>
</div>
{# Modal Container for Ability selection #}
<div id="modal-container"></div>
{# Combat Items Sheet Container #}
<div id="combat-sheet-container"></div>
</div>
{% endblock %}
{% block scripts %}
<script>
// Auto-scroll combat log to bottom on new entries
function scrollCombatLog() {
const log = document.getElementById('combat-log');
if (log) {
log.scrollTop = log.scrollHeight;
}
}
// Scroll on page load
document.addEventListener('DOMContentLoaded', scrollCombatLog);
// Scroll after HTMX swaps
document.body.addEventListener('htmx:afterSwap', function(event) {
if (event.detail.target.id === 'combat-log' ||
event.detail.target.closest('#combat-log')) {
scrollCombatLog();
}
});
// Close modal function
function closeModal() {
const container = document.getElementById('modal-container');
if (container) {
container.innerHTML = '';
}
}
// Close combat items sheet
function closeCombatSheet() {
const container = document.getElementById('combat-sheet-container');
if (container) {
container.innerHTML = '';
}
}
// Close modal/sheet on Escape key
document.addEventListener('keydown', function(event) {
if (event.key === 'Escape') {
closeModal();
closeCombatSheet();
}
});
// Guard against duplicate enemy turn requests
let enemyTurnPending = false;
let enemyTurnTimeout = null;
function triggerEnemyTurn() {
// Prevent duplicate requests
if (enemyTurnPending) {
return;
}
// Clear any pending timeout
if (enemyTurnTimeout) {
clearTimeout(enemyTurnTimeout);
}
enemyTurnPending = true;
enemyTurnTimeout = setTimeout(function() {
htmx.ajax('POST', '{{ url_for("combat.combat_enemy_turn", session_id=session_id) }}', {
target: '#combat-log',
swap: 'beforeend'
}).then(function() {
enemyTurnPending = false;
}).catch(function() {
enemyTurnPending = false;
});
}, 1000);
}
// Handle enemy turn polling
document.body.addEventListener('htmx:afterRequest', function(event) {
// Check if we need to trigger enemy turn
const response = event.detail.xhr;
if (response && response.getResponseHeader('HX-Trigger')) {
const triggers = response.getResponseHeader('HX-Trigger');
if (triggers && triggers.includes('enemyTurn')) {
triggerEnemyTurn();
}
}
});
// Handle combat end redirect
document.body.addEventListener('htmx:beforeSwap', function(event) {
// If the response indicates combat ended, handle accordingly
const response = event.detail.xhr;
if (response && response.getResponseHeader('X-Combat-Ended')) {
// Let the full page swap happen for victory/defeat screen
}
});
</script>
{% endblock %}

View File

@@ -0,0 +1,61 @@
{# Ability Selection Modal - Shows available abilities during combat #}
<div class="modal-overlay" onclick="if(event.target === this) closeModal()">
<div class="modal-content modal-content--md">
<div class="modal-header">
<h3 class="modal-title">Select Ability</h3>
<button class="modal-close" onclick="closeModal()" aria-label="Close modal">&times;</button>
</div>
<div class="modal-body">
{% if abilities %}
<div class="ability-list">
{% for ability in abilities %}
<button class="ability-btn"
hx-post="{{ url_for('combat.combat_action', session_id=session_id) }}"
hx-vals='{"action_type": "ability", "ability_id": "{{ ability.id }}"}'
hx-target="#combat-log"
hx-swap="beforeend"
hx-disabled-elt="this"
{% if not ability.available %}disabled{% endif %}
onclick="closeModal()">
<span class="ability-icon">
{% if ability.damage_type == 'fire' %}&#128293;
{% elif ability.damage_type == 'ice' %}&#10052;
{% elif ability.damage_type == 'lightning' %}&#9889;
{% elif ability.effect_type == 'heal' %}&#10084;
{% elif ability.effect_type == 'buff' %}&#11014;
{% elif ability.effect_type == 'debuff' %}&#11015;
{% else %}&#10024;
{% endif %}
</span>
<div class="ability-info">
<span class="ability-name">{{ ability.name }}</span>
<span class="ability-description">{{ ability.description|default('A powerful ability.') }}</span>
</div>
<div class="ability-meta">
{% if ability.mp_cost > 0 %}
<span class="ability-cost">{{ ability.mp_cost }} MP</span>
{% endif %}
{% if ability.cooldown > 0 %}
<span class="ability-cooldown ability-cooldown--active">{{ ability.cooldown }} turns CD</span>
{% elif ability.max_cooldown > 0 %}
<span class="ability-cooldown">{{ ability.max_cooldown }} turns CD</span>
{% endif %}
</div>
</button>
{% endfor %}
</div>
{% else %}
<div class="items-empty">
<p>No abilities available.</p>
<p style="font-size: var(--text-xs); margin-top: 0.5rem; color: var(--text-muted);">
Learn abilities by leveling up or finding skill tomes.
</p>
</div>
{% endif %}
</div>
<div class="modal-footer">
<button class="btn btn--secondary" onclick="closeModal()">Cancel</button>
</div>
</div>
</div>

View File

@@ -82,8 +82,19 @@ Displays character stats, resource bars, and action buttons
</div> </div>
</div> </div>
{# Quick Actions (Equipment, NPC, Travel) #} {# Quick Actions (Inventory, Equipment, NPC, Travel) #}
<div class="quick-actions"> <div class="quick-actions">
{# Inventory - Opens modal #}
<button class="action-btn action-btn--special"
hx-get="{{ url_for('game.inventory_modal', session_id=session_id) }}"
hx-target="#modal-container"
hx-swap="innerHTML"
aria-label="Open inventory">
<span class="action-icon">&#128188;</span>
Inventory
<span class="action-count">({{ character.inventory|length|default(0) }})</span>
</button>
{# Equipment & Gear - Opens modal #} {# Equipment & Gear - Opens modal #}
<button class="action-btn action-btn--special" <button class="action-btn action-btn--special"
hx-get="{{ url_for('game.equipment_modal', session_id=session_id) }}" hx-get="{{ url_for('game.equipment_modal', session_id=session_id) }}"

View File

@@ -0,0 +1,87 @@
{# Combat Actions Partial - Action buttons for combat #}
{# This partial shows the available combat actions #}
{% if is_player_turn %}
<div class="combat-actions__grid">
{# Attack Button - Direct action #}
<button class="combat-action-btn combat-action-btn--attack"
hx-post="{{ url_for('combat.combat_action', session_id=session_id) }}"
hx-vals='{"action_type": "attack"}'
hx-target="#combat-log"
hx-swap="beforeend"
hx-disabled-elt="this"
title="Basic attack with your weapon">
<span class="combat-action-btn__icon">&#9876;</span>
<span>Attack</span>
</button>
{# Ability Button - Opens modal #}
<button class="combat-action-btn combat-action-btn--ability"
hx-get="{{ url_for('combat.combat_abilities', session_id=session_id) }}"
hx-target="#modal-container"
hx-swap="innerHTML"
title="Use a special ability or spell">
<span class="combat-action-btn__icon">&#10024;</span>
<span>Ability</span>
</button>
{# Item Button - Opens bottom sheet #}
<button class="combat-action-btn combat-action-btn--item"
hx-get="{{ url_for('combat.combat_items', session_id=session_id) }}"
hx-target="#combat-sheet-container"
hx-swap="innerHTML"
title="Use an item from your inventory">
<span class="combat-action-btn__icon">&#127863;</span>
<span>Item</span>
</button>
{# Defend Button - Direct action #}
<button class="combat-action-btn combat-action-btn--defend"
hx-post="{{ url_for('combat.combat_action', session_id=session_id) }}"
hx-vals='{"action_type": "defend"}'
hx-target="#combat-log"
hx-swap="beforeend"
hx-disabled-elt="this"
title="Take a defensive stance, reducing damage taken">
<span class="combat-action-btn__icon">&#128737;</span>
<span>Defend</span>
</button>
{# Flee Button - Direct action #}
<button class="combat-action-btn combat-action-btn--flee"
hx-post="{{ url_for('combat.combat_flee', session_id=session_id) }}"
hx-target="body"
hx-swap="innerHTML"
hx-disabled-elt="this"
hx-confirm="Are you sure you want to flee from combat?"
title="Attempt to escape from battle">
<span class="combat-action-btn__icon">&#127939;</span>
<span>Flee</span>
</button>
</div>
{% else %}
<div class="combat-actions__grid">
{# Disabled buttons when not player's turn #}
<button class="combat-action-btn combat-action-btn--attack" disabled>
<span class="combat-action-btn__icon">&#9876;</span>
<span>Attack</span>
</button>
<button class="combat-action-btn combat-action-btn--ability" disabled>
<span class="combat-action-btn__icon">&#10024;</span>
<span>Ability</span>
</button>
<button class="combat-action-btn combat-action-btn--item" disabled>
<span class="combat-action-btn__icon">&#127863;</span>
<span>Item</span>
</button>
<button class="combat-action-btn combat-action-btn--defend" disabled>
<span class="combat-action-btn__icon">&#128737;</span>
<span>Defend</span>
</button>
<button class="combat-action-btn combat-action-btn--flee" disabled>
<span class="combat-action-btn__icon">&#127939;</span>
<span>Flee</span>
</button>
</div>
<p class="combat-actions__disabled-message">Waiting for enemy turn...</p>
{% endif %}

View File

@@ -0,0 +1,55 @@
{% extends "base.html" %}
{% block title %}Defeated - Code of Conquest{% endblock %}
{% block extra_head %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/combat.css') }}">
{% endblock %}
{% block content %}
<div class="combat-result combat-result--defeat">
<div class="combat-result__icon">&#128128;</div>
<h1 class="combat-result__title">Defeated</h1>
<p class="combat-result__subtitle">Your party has fallen in battle...</p>
{# Defeat Message #}
<div class="combat-rewards" style="border-color: var(--accent-red);">
<h2 class="rewards-title" style="color: var(--accent-red);">Battle Lost</h2>
<div class="rewards-list">
<div class="reward-item">
<span class="reward-icon">&#9888;</span>
<span class="reward-label">Your progress has been saved</span>
<span class="reward-value" style="color: var(--text-muted);">No items lost</span>
</div>
{% if gold_lost %}
<div class="reward-item">
<span class="reward-icon">&#128176;</span>
<span class="reward-label">Gold dropped</span>
<span class="reward-value" style="color: var(--accent-red);">-{{ gold_lost }} gold</span>
</div>
{% endif %}
</div>
<div style="margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--border-primary); text-align: center;">
<p style="font-size: var(--text-sm); color: var(--text-secondary); font-style: italic;">
"Even the mightiest heroes face setbacks. Rise again, adventurer!"
</p>
</div>
</div>
{# Action Buttons #}
<div class="combat-result__actions">
<a href="{{ url_for('game.play_session', session_id=session_id) }}" class="btn btn-primary">
Return to Game
</a>
{% if can_retry %}
<button class="btn btn-secondary"
hx-post="{{ url_for('combat.combat_view', session_id=session_id) }}"
hx-target="body"
hx-swap="innerHTML">
Retry Battle
</button>
{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,52 @@
{#
Combat Items Sheet
Bottom sheet for selecting consumable items during combat
#}
<div class="combat-items-sheet open" role="dialog" aria-modal="true" aria-labelledby="combat-items-title">
{# Drag handle for mobile #}
<div class="sheet-handle" aria-hidden="true"></div>
{# Sheet header #}
<div class="sheet-header">
<h3 id="combat-items-title">Use Item</h3>
<button class="sheet-close" onclick="closeCombatSheet()" aria-label="Close">&times;</button>
</div>
{# Sheet body #}
<div class="sheet-body">
{# Consumables Grid #}
<div class="combat-items-grid">
{% for item in consumables %}
<button class="combat-item"
hx-get="{{ url_for('combat.combat_item_detail', session_id=session_id, item_id=item.item_id) }}"
hx-target="#combat-item-detail"
hx-swap="innerHTML"
aria-label="{{ item.name }}">
<img src="{{ url_for('static', filename='img/items/consumable.svg') }}" alt="">
<span class="item-name">{{ item.name }}</span>
<span class="item-effect">{{ item.description|truncate(30) }}</span>
</button>
{% else %}
<p class="no-consumables">No usable items in inventory</p>
{% endfor %}
</div>
{# Selected Item Detail + Use Button #}
<div class="combat-item-detail" id="combat-item-detail">
<p style="color: var(--text-muted); text-align: center;">Select an item to use</p>
</div>
</div>
</div>
<div class="sheet-backdrop" onclick="closeCombatSheet()"></div>
<script>
// Handle item selection highlighting in combat sheet
document.querySelectorAll('.combat-item').forEach(item => {
item.addEventListener('htmx:afterRequest', function() {
// Remove selected from all items
document.querySelectorAll('.combat-item.selected').forEach(i => i.classList.remove('selected'));
// Add selected to clicked item
this.classList.add('selected');
});
});
</script>

View File

@@ -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 %}
<div class="combat-log__entry combat-log__entry--{{ entry.type|default('system') }}{% if entry.is_crit %} combat-log__entry--crit{% endif %}">
{% if entry.actor %}
<span class="log-actor">{{ entry.actor }}</span>
{% endif %}
<span class="log-message">{{ entry.message }}</span>
{% if entry.damage %}
<span class="log-damage{% if entry.is_crit %} log-damage--crit{% endif %}">
{% if entry.is_crit %}CRITICAL! {% endif %}{{ entry.damage }} damage
</span>
{% endif %}
{% if entry.heal %}
<span class="log-heal">+{{ entry.heal }} HP</span>
{% endif %}
</div>
{% endfor %}
{% else %}
<div class="combat-log__empty">
Combat begins! Choose your action below.
</div>
{% endif %}

View File

@@ -0,0 +1,84 @@
{% extends "base.html" %}
{% block title %}Victory! - Code of Conquest{% endblock %}
{% block extra_head %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/combat.css') }}">
{% endblock %}
{% block content %}
<div class="combat-result combat-result--victory">
<div class="combat-result__icon">&#127942;</div>
<h1 class="combat-result__title">Victory!</h1>
<p class="combat-result__subtitle">You have defeated your enemies!</p>
{# Rewards Section #}
{% if rewards %}
<div class="combat-rewards">
<h2 class="rewards-title">Rewards Earned</h2>
<div class="rewards-list">
{# Experience #}
{% if rewards.experience %}
<div class="reward-item">
<span class="reward-icon">&#11088;</span>
<span class="reward-label">Experience Points</span>
<span class="reward-value reward-value--xp">+{{ rewards.experience }} XP</span>
</div>
{% endif %}
{# Gold #}
{% if rewards.gold %}
<div class="reward-item">
<span class="reward-icon">&#128176;</span>
<span class="reward-label">Gold</span>
<span class="reward-value reward-value--gold">+{{ rewards.gold }} gold</span>
</div>
{% endif %}
{# Level Up #}
{% if rewards.level_ups %}
{% for character_id in rewards.level_ups %}
<div class="reward-item">
<span class="reward-icon">&#127775;</span>
<span class="reward-label">Level Up!</span>
<span class="reward-value reward-value--level">New abilities unlocked!</span>
</div>
{% endfor %}
{% endif %}
</div>
{# Loot Items #}
{% if rewards.items %}
<div class="loot-section">
<h3 class="loot-title">Items Obtained</h3>
<div class="loot-list">
{% for item in rewards.items %}
<div class="loot-item loot-item--{{ item.rarity|default('common') }}">
<span>
{% if item.type == 'weapon' %}&#9876;
{% elif item.type == 'armor' %}&#129523;
{% elif item.type == 'consumable' %}&#127863;
{% elif item.type == 'material' %}&#128293;
{% else %}&#128230;
{% endif %}
</span>
<span>{{ item.name }}</span>
{% if item.quantity > 1 %}
<span style="color: var(--text-muted);">x{{ item.quantity }}</span>
{% endif %}
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
{% endif %}
{# Action Buttons #}
<div class="combat-result__actions">
<a href="{{ url_for('game.play_session', session_id=session_id) }}" class="btn btn-primary">
Continue Adventure
</a>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,118 @@
{#
Inventory Item Detail
Partial template loaded via HTMX when an item is selected
#}
<div class="item-detail-content">
{# Mobile back button #}
<button class="item-detail-back" onclick="hideMobileDetail()" aria-label="Back to inventory">
&larr; Back to items
</button>
{# Item header #}
<div class="item-detail-header">
<img src="{{ url_for('static', filename='img/items/' ~ (item.item_type|default('default')) ~ '.svg') }}"
class="item-detail-icon" alt="">
<div class="item-detail-title">
<h3 class="rarity-text-{{ item.rarity|default('common') }}">{{ item.name }}</h3>
<span class="item-type">{{ item.item_type|default('Item')|replace('_', ' ')|title }}</span>
</div>
</div>
{# Item description #}
<p class="item-description">{{ item.description|default('No description available.') }}</p>
{# Stats (for equipment) #}
{% if item.item_type in ['weapon', 'armor'] %}
<div class="item-stats">
{% if item.damage %}
<div>
<span>Damage</span>
<span>{{ item.damage }}</span>
</div>
{% endif %}
{% if item.defense %}
<div>
<span>Defense</span>
<span>{{ item.defense }}</span>
</div>
{% endif %}
{% if item.spell_power %}
<div>
<span>Spell Power</span>
<span>{{ item.spell_power }}</span>
</div>
{% endif %}
{% if item.crit_chance %}
<div>
<span>Crit Chance</span>
<span>{{ (item.crit_chance * 100)|round|int }}%</span>
</div>
{% endif %}
{% if item.stat_bonuses %}
{% for stat, value in item.stat_bonuses.items() %}
<div>
<span>{{ stat|replace('_', ' ')|title }}</span>
<span>+{{ value }}</span>
</div>
{% endfor %}
{% endif %}
</div>
{% endif %}
{# Effects (for consumables) #}
{% if item.item_type == 'consumable' and item.effects_on_use %}
<div class="item-stats">
<div class="item-stats-title" style="font-weight: 600; margin-bottom: 0.5rem;">Effects</div>
{% for effect in item.effects_on_use %}
<div>
<span>{{ effect.name|default(effect.effect_type|default('Effect')|title) }}</span>
<span>{{ effect.value|default('') }}</span>
</div>
{% endfor %}
</div>
{% endif %}
{# Item value #}
{% if item.value %}
<div class="item-value" style="font-size: var(--text-sm); color: var(--accent-gold); margin-bottom: 1rem;">
Value: {{ item.value }} gold
</div>
{% endif %}
{# Action Buttons #}
<div class="item-actions">
{% if item.item_type == 'consumable' %}
<button class="action-btn action-btn--primary"
hx-post="{{ url_for('game.inventory_use', session_id=session_id) }}"
hx-vals='{"item_id": "{{ item.item_id }}"}'
hx-target="#character-panel"
hx-swap="innerHTML"
onclick="closeModal()">
Use
</button>
{% elif item.item_type in ['weapon', 'armor'] %}
<button class="action-btn action-btn--primary"
hx-post="{{ url_for('game.inventory_equip', session_id=session_id) }}"
hx-vals='{"item_id": "{{ item.item_id }}"{% if suggested_slot %}, "slot": "{{ suggested_slot }}"{% endif %}}'
hx-target="#character-panel"
hx-swap="innerHTML"
onclick="closeModal()">
Equip
</button>
{% endif %}
{% if item.item_type != 'quest_item' %}
<button class="action-btn action-btn--danger"
hx-delete="{{ url_for('game.inventory_drop', session_id=session_id, item_id=item.item_id) }}"
hx-target=".inventory-modal"
hx-swap="outerHTML"
hx-confirm="Drop {{ item.name }}? This cannot be undone.">
Drop
</button>
{% else %}
<p style="font-size: var(--text-xs); color: var(--text-muted); text-align: center; padding: 0.5rem;">
Quest items cannot be dropped
</p>
{% endif %}
</div>
</div>

View File

@@ -0,0 +1,138 @@
{#
Inventory Modal
Full inventory management modal for play screen
#}
<div class="modal-overlay" onclick="if(event.target===this) closeModal()"
role="dialog" aria-modal="true" aria-labelledby="inventory-title">
<div class="modal-content inventory-modal">
{# Header #}
<div class="modal-header">
<h2 class="modal-title" id="inventory-title">
Inventory
<span class="inventory-count">({{ inventory_count }}/{{ inventory_max }})</span>
</h2>
<button class="modal-close" onclick="closeModal()" aria-label="Close inventory">&times;</button>
</div>
{# Tab Filter Bar #}
<div class="inventory-tabs" role="tablist">
<button class="tab {% if filter == 'all' %}active{% endif %}"
role="tab"
aria-selected="{{ 'true' if filter == 'all' else 'false' }}"
hx-get="{{ url_for('game.inventory_modal', session_id=session_id, filter='all') }}"
hx-target=".inventory-modal"
hx-swap="outerHTML">
All
</button>
<button class="tab {% if filter == 'weapon' %}active{% endif %}"
role="tab"
aria-selected="{{ 'true' if filter == 'weapon' else 'false' }}"
hx-get="{{ url_for('game.inventory_modal', session_id=session_id, filter='weapon') }}"
hx-target=".inventory-modal"
hx-swap="outerHTML">
Weapons
</button>
<button class="tab {% if filter == 'armor' %}active{% endif %}"
role="tab"
aria-selected="{{ 'true' if filter == 'armor' else 'false' }}"
hx-get="{{ url_for('game.inventory_modal', session_id=session_id, filter='armor') }}"
hx-target=".inventory-modal"
hx-swap="outerHTML">
Armor
</button>
<button class="tab {% if filter == 'consumable' %}active{% endif %}"
role="tab"
aria-selected="{{ 'true' if filter == 'consumable' else 'false' }}"
hx-get="{{ url_for('game.inventory_modal', session_id=session_id, filter='consumable') }}"
hx-target=".inventory-modal"
hx-swap="outerHTML">
Consumables
</button>
<button class="tab {% if filter == 'quest_item' %}active{% endif %}"
role="tab"
aria-selected="{{ 'true' if filter == 'quest_item' else 'false' }}"
hx-get="{{ url_for('game.inventory_modal', session_id=session_id, filter='quest_item') }}"
hx-target=".inventory-modal"
hx-swap="outerHTML">
Quest
</button>
</div>
{# Body #}
<div class="modal-body inventory-body">
{# Item Grid #}
<div class="inventory-grid-container">
<div class="inventory-grid" id="inventory-items" role="listbox">
{% for item in inventory %}
<button class="inventory-item rarity-{{ item.rarity|default('common') }}"
role="option"
hx-get="{{ url_for('game.inventory_item_detail', session_id=session_id, item_id=item.item_id) }}"
hx-target="#item-detail"
hx-swap="innerHTML"
aria-label="{{ item.name }}, {{ item.rarity|default('common') }} {{ item.item_type }}">
<img src="{{ url_for('static', filename='img/items/' ~ (item.item_type|default('default')) ~ '.svg') }}"
alt="" aria-hidden="true">
<span class="item-name">{{ item.name }}</span>
{% if item.quantity and item.quantity > 1 %}
<span class="item-quantity">x{{ item.quantity }}</span>
{% endif %}
</button>
{% else %}
<p class="inventory-empty">
{% if filter == 'all' %}
No items in inventory
{% else %}
No {{ filter|replace('_', ' ') }}s found
{% endif %}
</p>
{% endfor %}
</div>
</div>
{# Item Detail Panel #}
<div class="item-detail" id="item-detail" aria-live="polite">
<p class="item-detail-empty">Select an item to view details</p>
</div>
</div>
{# Footer #}
<div class="modal-footer">
<span class="gold-display">{{ gold }}</span>
<button class="btn btn--secondary" onclick="closeModal()">Close</button>
</div>
</div>
</div>
<script>
// Handle item selection highlighting
document.querySelectorAll('.inventory-item').forEach(item => {
item.addEventListener('htmx:afterRequest', function() {
// Remove selected from all items
document.querySelectorAll('.inventory-item.selected').forEach(i => i.classList.remove('selected'));
// Add selected to clicked item
this.classList.add('selected');
});
});
// Mobile: Show detail panel as slide-in
function showMobileDetail() {
const detail = document.getElementById('item-detail');
if (window.innerWidth <= 768 && detail) {
detail.classList.add('visible');
}
}
function hideMobileDetail() {
const detail = document.getElementById('item-detail');
if (detail) {
detail.classList.remove('visible');
}
}
// Listen for item detail loads on mobile
document.body.addEventListener('htmx:afterSwap', function(event) {
if (event.detail.target.id === 'item-detail') {
showMobileDetail();
}
});
</script>

View File

@@ -0,0 +1,51 @@
{# Item Selection Modal - Shows consumable items during combat #}
<div class="modal-overlay" onclick="if(event.target === this) closeModal()">
<div class="modal-content modal-content--md">
<div class="modal-header">
<h3 class="modal-title">Use Item</h3>
<button class="modal-close" onclick="closeModal()" aria-label="Close modal">&times;</button>
</div>
<div class="modal-body">
{% if items %}
<div class="item-list">
{% for item in items %}
<button class="item-btn"
hx-post="{{ url_for('combat.combat_action', session_id=session_id) }}"
hx-vals='{"action_type": "item", "item_id": "{{ item.id }}"}'
hx-target="#combat-log"
hx-swap="beforeend"
hx-disabled-elt="this"
{% if item.quantity <= 0 %}disabled{% endif %}
onclick="closeModal()">
<span class="item-icon">
{% if 'health' in item.name|lower or 'heal' in item.effect|lower %}&#127863;
{% elif 'mana' in item.name|lower or 'mp' in item.effect|lower %}&#129389;
{% elif 'antidote' in item.name|lower or 'cure' in item.effect|lower %}&#129514;
{% elif 'bomb' in item.name|lower or 'damage' in item.effect|lower %}&#128163;
{% elif 'elixir' in item.name|lower %}&#129380;
{% else %}&#128230;
{% endif %}
</span>
<div class="item-info">
<span class="item-name">{{ item.name }}</span>
<span class="item-effect">{{ item.effect|default('Use in combat.') }}</span>
</div>
<span class="item-quantity">x{{ item.quantity }}</span>
</button>
{% endfor %}
</div>
{% else %}
<div class="items-empty">
<p>No usable items in inventory.</p>
<p style="font-size: var(--text-xs); margin-top: 0.5rem; color: var(--text-muted);">
Purchase potions from merchants or find them while exploring.
</p>
</div>
{% endif %}
</div>
<div class="modal-footer">
<button class="btn btn--secondary" onclick="closeModal()">Cancel</button>
</div>
</div>
</div>

View File

@@ -4,6 +4,7 @@
{% block extra_head %} {% block extra_head %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/play.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/play.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/inventory.css') }}">
{% endblock %} {% endblock %}
{% block content %} {% block content %}