""" DungeonMaster AI - Combat Tracker Component Turn order display with HP tracking and condition management. """ from __future__ import annotations from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING import gradio as gr from ui import styles from ui.utils.portrait_mapper import get_combatant_portrait if TYPE_CHECKING: pass # ============================================================================= # Portrait Constants # ============================================================================= MONSTERS_PATH = Path(__file__).parent.parent / "assets" / "images" / "portraits" / "monsters" IMAGE_EXTENSIONS = (".webp", ".jpg", ".jpeg", ".png") def get_default_monster_portrait() -> str | None: """Get default monster portrait path.""" for ext in IMAGE_EXTENSIONS: default_path = MONSTERS_PATH / f"default{ext}" if default_path.exists(): return str(default_path) return None # ============================================================================= # Data Models # ============================================================================= @dataclass class Combatant: """A participant in combat.""" id: str name: str initiative: int hp_current: int hp_max: int armor_class: int conditions: list[str] is_player: bool is_current_turn: bool = False portrait_path: str | None = None @classmethod def from_dict(cls, data: dict[str, object], is_current: bool = False) -> "Combatant": """Create from dictionary.""" # Get portrait for this combatant portrait = get_combatant_portrait(data) if not portrait: portrait = get_default_monster_portrait() return cls( id=str(data.get("id", data.get("character_id", ""))), name=str(data.get("name", "Unknown")), initiative=int(data.get("initiative", 0)), hp_current=int(data.get("hp_current", data.get("current_hp", 0))), hp_max=int(data.get("hp_max", data.get("max_hp", 1))), armor_class=int(data.get("armor_class", data.get("ac", 10))), conditions=list(data.get("conditions", [])), is_player=bool(data.get("is_player", False)), is_current_turn=is_current, portrait_path=portrait, ) @dataclass class CombatState: """Current combat state.""" round_number: int turn_order: list[Combatant] current_turn_index: int is_active: bool @classmethod def from_dict(cls, data: dict[str, object] | None) -> "CombatState | None": """Create from dictionary.""" if not data: return None turn_order_data = data.get("turn_order", []) current_idx = int(data.get("current_turn_index", data.get("current_turn", 0))) combatants = [] for i, c_data in enumerate(turn_order_data): combatant = Combatant.from_dict(c_data, is_current=i == current_idx) combatants.append(combatant) return cls( round_number=int(data.get("round", data.get("round_number", 1))), turn_order=combatants, current_turn_index=current_idx, is_active=bool(data.get("is_active", True)), ) # ============================================================================= # Formatting Helpers # ============================================================================= def get_hp_percentage(current: int, maximum: int) -> float: """Calculate HP percentage.""" if maximum <= 0: return 100.0 return (current / maximum) * 100 def get_hp_color_class(percentage: float) -> str: """Get HP bar color class based on percentage.""" if percentage > 50: return "hp-bar-healthy" elif percentage > 25: return "hp-bar-wounded" else: return "hp-bar-critical" def format_combatant_row(combatant: Combatant) -> str: """Format a single combatant row with inline styles.""" hp_pct = get_hp_percentage(combatant.hp_current, combatant.hp_max) hp_color_class = get_hp_color_class(hp_pct) # Base row style row_style = ( "display: flex; " "align-items: center; " "gap: 8px; " "padding: 8px 12px; " "margin: 4px 0; " "border-radius: 6px; " "background: rgba(30, 26, 18, 0.6); " ) # Add type indicator (ally=blue, enemy=red border) if combatant.is_player: row_style += "border-left: 3px solid #4a9eff; " else: row_style += f"border-left: 3px solid {styles.BLOOD_RED_LIGHT}; " # Highlight current turn if combatant.is_current_turn: row_style += styles.current_turn_style() # Initiative style init_style = f"min-width: 24px; text-align: center; font-weight: bold; color: {styles.GOLD};" # Name style name_style = f"flex: 1; color: {styles.PARCHMENT}; font-weight: {'bold' if combatant.is_current_turn else 'normal'};" turn_indicator = "▶ " if combatant.is_current_turn else "" # Portrait HTML portrait_html = "" if combatant.portrait_path: border_color = "#4a9eff" if combatant.is_player else "#ff4a4a" portrait_style = ( f"width: 32px; height: 32px; border-radius: 50%; object-fit: cover; " f"border: 2px solid {border_color}; " f"box-shadow: 0 2px 4px rgba(0,0,0,0.3);" ) portrait_html = f'{combatant.name}' # HP bar with inline styles hp_bar_container = ( "position: relative; " "width: 70px; " "height: 16px; " "background: rgba(0, 0, 0, 0.4); " "border-radius: 8px; " "overflow: hidden; " ) # HP fill color based on status if hp_color_class == "hp-bar-healthy": fill_color = f"linear-gradient(90deg, {styles.EMERALD_DARK}, {styles.EMERALD})" elif hp_color_class == "hp-bar-wounded": fill_color = f"linear-gradient(90deg, #d68910, {styles.STATUS_YELLOW})" else: fill_color = f"linear-gradient(90deg, #922b21, {styles.STATUS_RED})" hp_fill_style = f"height: 100%; width: {hp_pct}%; background: {fill_color}; border-radius: 6px;" hp_text_style = ( "position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); " "font-size: 10px; font-weight: bold; color: white; text-shadow: 1px 1px 2px rgba(0,0,0,0.8);" ) hp_bar_html = f'''
{combatant.hp_current}/{combatant.hp_max}
''' # Conditions badges with inline styles conditions_html = "" if combatant.conditions: badges = [] for condition in combatant.conditions[:3]: badge_css = styles.condition_badge_style(condition) badges.append(f'{condition}') conditions_html = f'
{"".join(badges)}
' return f'''
{combatant.initiative} {portrait_html} {turn_indicator}{combatant.name} {hp_bar_html} {conditions_html}
''' def format_combat_display(combat_state: CombatState | None) -> str: """Format the full combat tracker display with inline styles.""" inactive_style = ( "text-align: center; " "padding: 1.5rem; " f"color: {styles.PARCHMENT_MUTED}; " "font-style: italic;" ) if not combat_state or not combat_state.is_active: return f'''
Not in combat
Combat tracker will appear here when initiative is rolled.
''' # Container style container_style = ( f"background: linear-gradient(180deg, rgba(42, 26, 26, 0.8) 0%, rgba(26, 16, 16, 0.8) 100%); " f"border: 2px solid {styles.BLOOD_RED}; " "border-radius: 10px; " "padding: 1rem; " f"box-shadow: 0 4px 12px rgba(139, 35, 50, 0.3);" ) # Header style header_style = ( f"color: {styles.BLOOD_RED_LIGHT}; " "font-size: 1.1rem; " "font-weight: bold; " f"border-bottom: 1px solid {styles.BLOOD_RED}; " "padding-bottom: 0.5rem; " "margin-bottom: 0.75rem;" ) html_parts = [ f'
', f'
Combat - Round {combat_state.round_number}
', ] for combatant in combat_state.turn_order: html_parts.append(format_combatant_row(combatant)) html_parts.append("
") return "".join(html_parts) def format_combat_summary(combat_state: CombatState | None) -> str: """Format a text summary of combat state.""" if not combat_state or not combat_state.is_active: return "*Not in combat*" current = None if 0 <= combat_state.current_turn_index < len(combat_state.turn_order): current = combat_state.turn_order[combat_state.current_turn_index] summary_lines = [ f"**Round {combat_state.round_number}**", ] if current: summary_lines.append(f"Current Turn: **{current.name}**") # Count allies and enemies allies = sum(1 for c in combat_state.turn_order if c.is_player and c.hp_current > 0) enemies = sum(1 for c in combat_state.turn_order if not c.is_player and c.hp_current > 0) summary_lines.append(f"Allies: {allies} | Enemies: {enemies}") return "\n".join(summary_lines) def format_turn_order_table(combat_state: CombatState | None) -> list[list[str]]: """Format combat state as table data for gr.Dataframe.""" if not combat_state or not combat_state.is_active: return [] rows = [] for combatant in combat_state.turn_order: current_marker = "▶" if combatant.is_current_turn else "" type_marker = "🛡️" if combatant.is_player else "👹" hp_display = f"{combatant.hp_current}/{combatant.hp_max}" conditions = ", ".join(combatant.conditions) if combatant.conditions else "-" rows.append([ current_marker, str(combatant.initiative), f"{type_marker} {combatant.name}", hp_display, conditions, ]) return rows # ============================================================================= # Component Creation # ============================================================================= def create_combat_panel() -> tuple[ gr.Group, # combat_group (for visibility toggle) gr.Markdown, # combat_header gr.HTML, # combat_display (changed from Markdown) gr.Markdown, # combat_summary gr.Button, # next_turn_btn gr.Button, # end_combat_btn ]: """ Create the combat tracker panel component. Returns: Tuple of all Gradio components for event wiring. """ with gr.Group(visible=True, elem_classes=["combat-tracker-container"]) as combat_group: # Header combat_header = gr.Markdown("## ⚔️ Combat Tracker") # Combat display (HTML format for styling) combat_display = gr.HTML( value=format_combat_display(None), ) # Combat summary (text format) combat_summary = gr.Markdown( value=format_combat_summary(None), ) # Action buttons with gr.Row(): next_turn_btn = gr.Button( "Next Turn", variant="primary", size="sm", interactive=False, # Disabled when not in combat ) end_combat_btn = gr.Button( "End Combat", variant="stop", size="sm", interactive=False, # Disabled when not in combat ) return ( combat_display, combat_summary, next_turn_btn, end_combat_btn, combat_group, # combat_container ) # ============================================================================= # Update Functions # ============================================================================= def update_combat_tracker( combat_state_dict: dict[str, object] | None, ) -> tuple[gr.update, str, str, gr.update, gr.update]: """ Update combat tracker from game state. Args: combat_state_dict: Combat state dictionary from GameState Returns: Tuple of component updates (visibility, display, summary, btn states) """ combat_state = CombatState.from_dict(combat_state_dict) is_active = combat_state is not None and combat_state.is_active return ( gr.update(visible=True), # Always show group, just change content format_combat_display(combat_state), format_combat_summary(combat_state), gr.update(interactive=is_active), # Next turn button gr.update(interactive=is_active), # End combat button ) def set_combat_visibility(in_combat: bool) -> gr.update: """ Set combat tracker visibility. Args: in_combat: Whether currently in combat Returns: gr.update with visibility setting """ # We always show the tracker but display different content return gr.update(visible=True) async def handle_next_turn( toolkit_client: object, game_state: object, ) -> tuple[str, str]: """ Handle next turn button click. Args: toolkit_client: TTRPGToolkitClient instance game_state: Current GameState Returns: Tuple of (combat_display, combat_summary) """ try: # Call MCP next_turn tool result = await toolkit_client.call_tool("next_turn", {}) # Parse result and update display combat_state = CombatState.from_dict(result) return ( format_combat_display(combat_state), format_combat_summary(combat_state), ) except Exception as e: return ( f"Error advancing turn: {e!s}", "*Combat state unknown*", ) async def handle_end_combat( toolkit_client: object, game_state: object, ) -> tuple[str, str, gr.update, gr.update]: """ Handle end combat button click. Args: toolkit_client: TTRPGToolkitClient instance game_state: Current GameState Returns: Tuple of (combat_display, combat_summary, next_btn, end_btn) """ try: # Call MCP end_combat tool await toolkit_client.call_tool("end_combat", {}) return ( format_combat_display(None), format_combat_summary(None), gr.update(interactive=False), gr.update(interactive=False), ) except Exception as e: return ( f"Error ending combat: {e!s}", "*Combat state unknown*", gr.update(interactive=True), gr.update(interactive=True), )