Source code for haive.games.clue.ui

"""Clue rich UI visualization module.

This module provides a visually appealing terminal UI for Clue games,
with styled components, animations, and comprehensive game information.

It uses the Rich library to create a console-based UI with:
    - Game board visualization with players, suspects, weapons, and rooms
    - Guess history with detailed responses
    - Player cards and deduction notes
    - Game status and information
    - Thinking animations and guess visualization

Example:
    >>> from haive.games.clue.ui import ClueUI
    >>> from haive.games.clue.state import ClueState
    >>>
    >>> ui = ClueUI()
    >>> state = ClueState.initialize()
    >>> ui.display_state(state)  # Display the initial game state
    >>>
    >>> # Show thinking animation for player
    >>> ui.show_thinking("player1")
    >>>
    >>> # Display a guess
    >>> from haive.games.clue.models import ClueGuess, ValidSuspect, ValidWeapon, ValidRoom
    >>> guess = ClueGuess(
    >>>     suspect=ValidSuspect.COLONEL_MUSTARD,
    >>>     weapon=ValidWeapon.KNIFE,
    >>>     room=ValidRoom.KITCHEN
    >>> )
    >>> ui.show_guess(guess, "player1")

"""

import time
from typing import Any

from rich.align import Align
from rich.box import DOUBLE, ROUNDED
from rich.console import Console
from rich.layout import Layout
from rich.panel import Panel
from rich.progress import Progress, SpinnerColumn, TextColumn
from rich.table import Table
from rich.text import Text

from haive.games.clue.models import (
    ClueGuess,
    ClueResponse,
    ValidRoom,
    ValidSuspect,
    ValidWeapon,
)
from haive.games.clue.state import ClueState


[docs] class ClueUI: """Rich UI for beautiful Clue game visualization. This class provides a visually appealing terminal UI for Clue games, with styled components, animations, and comprehensive game information. Features: - Game board visualization with suspects, weapons, and rooms - Guess history with detailed responses - Player cards and deduction notes - Game status and information - Thinking animations and guess visualization Attributes: console (Console): Rich console for output layout (Layout): Layout manager for UI components colors (dict): Color schemes for different UI elements Examples: >>> ui = ClueUI() >>> state = ClueState.initialize() >>> ui.display_state(state) # Display the initial game state """ def __init__(self): """Initialize the Clue UI with default settings.""" self.console = Console() self.layout = Layout() # Define colors and styles self.colors = { "player1": "bright_green", "player2": "bright_blue", "header": "bold magenta", "info": "bright_white", "success": "green", "warning": "bright_yellow", "error": "bright_red", "suspect": "bright_cyan", "weapon": "bright_red", "room": "bright_green", "border": "bright_magenta", "title": "bold yellow", } # Set up the layout self._setup_layout() def _setup_layout(self): """Set up the layout structure for the UI.""" # Main layout with header, board, and sidebar self.layout.split( Layout(name="header", size=3), Layout(name="main", ratio=1), ) # Split main area into board and sidebar self.layout["main"].split_row( Layout(name="board", ratio=3), Layout(name="sidebar", ratio=2), ) # Split sidebar into sections self.layout["sidebar"].split( Layout(name="game_info", size=7), Layout(name="player_cards", ratio=1), Layout(name="deductions", ratio=2), ) # Split board into sections self.layout["board"].split( Layout(name="suspects_weapons", size=12), Layout(name="guess_history", ratio=1), ) # Split suspects_weapons into two columns self.layout["suspects_weapons"].split_row( Layout(name="suspects", ratio=1), Layout(name="weapons_rooms", ratio=1), ) # Split weapons_rooms into two rows self.layout["weapons_rooms"].split( Layout(name="weapons", ratio=1), Layout(name="rooms", ratio=1), ) def _render_header(self, state: ClueState) -> Panel: """Render the game header with title and status. Args: state: Current game state Returns: Panel: Styled header panel """ title = Text("🕵️ CLUE DETECTIVE GAME 🕵️", style=self.colors["header"]) if state.game_status == "ongoing": status_text = ( f"Status: ONGOING | Turn: {state.current_turn_number}/{state.max_turns}" ) turn_text = f"Current Player: [bold {self.colors[state.current_player]}]{state.current_player.upper()}[/]" else: status_text = "Status: GAME OVER" turn_text = ( f"Winner: [bold {self.colors[state.winner]}]{state.winner.upper()}[/]" if state.winner else "No Winner" ) status = Text(status_text, style=self.colors["info"]) return Panel( Align.center( Text.assemble( title, "\n", status, "\n", Text(turn_text, justify="center") ) ), box=ROUNDED, border_style=self.colors["border"], padding=(0, 2), ) def _render_suspects(self, state: ClueState) -> Panel: """Render the suspects panel. Args: state: Current game state Returns: Panel: Suspects panel """ suspects_table = Table( show_header=True, box=ROUNDED, expand=True, border_style=self.colors["suspect"], ) suspects_table.add_column("Suspect", style="bold cyan") suspects_table.add_column("Status", style="white") # Get all suspects all_suspects = [suspect.value for suspect in ValidSuspect] # Get suspects from player cards (if in hand, they're not the murderer) player1_cards = set(state.player1_cards) player2_cards = set(state.player2_cards) for suspect in all_suspects: status = "" if suspect == state.solution.suspect and state.game_status != "ongoing": status = "[bold red]MURDERER[/]" elif suspect in player1_cards: status = f"[{self.colors['player1']}]Player 1's Card[/]" elif suspect in player2_cards: status = f"[{self.colors['player2']}]Player 2's Card[/]" suspects_table.add_row(suspect, status) return Panel( suspects_table, title="Suspects", title_align="center", border_style=self.colors["suspect"], padding=(0, 1), ) def _render_weapons(self, state: ClueState) -> Panel: """Render the weapons panel. Args: state: Current game state Returns: Panel: Weapons panel """ weapons_table = Table( show_header=True, box=ROUNDED, expand=True, border_style=self.colors["weapon"], ) weapons_table.add_column("Weapon", style="bold red") weapons_table.add_column("Status", style="white") # Get all weapons all_weapons = [weapon.value for weapon in ValidWeapon] # Get weapons from player cards (if in hand, they're not the murder # weapon) player1_cards = set(state.player1_cards) player2_cards = set(state.player2_cards) for weapon in all_weapons: status = "" if weapon == state.solution.weapon and state.game_status != "ongoing": status = "[bold red]MURDER WEAPON[/]" elif weapon in player1_cards: status = f"[{self.colors['player1']}]Player 1's Card[/]" elif weapon in player2_cards: status = f"[{self.colors['player2']}]Player 2's Card[/]" weapons_table.add_row(weapon, status) return Panel( weapons_table, title="Weapons", title_align="center", border_style=self.colors["weapon"], padding=(0, 1), ) def _render_rooms(self, state: ClueState) -> Panel: """Render the rooms panel. Args: state: Current game state Returns: Panel: Rooms panel """ rooms_table = Table( show_header=True, box=ROUNDED, expand=True, border_style=self.colors["room"], ) rooms_table.add_column("Room", style="bold green") rooms_table.add_column("Status", style="white") # Get all rooms all_rooms = [room.value for room in ValidRoom] # Get rooms from player cards (if in hand, they're not the murder room) player1_cards = set(state.player1_cards) player2_cards = set(state.player2_cards) for room in all_rooms: status = "" if room == state.solution.room and state.game_status != "ongoing": status = "[bold red]CRIME SCENE[/]" elif room in player1_cards: status = f"[{self.colors['player1']}]Player 1's Card[/]" elif room in player2_cards: status = f"[{self.colors['player2']}]Player 2's Card[/]" rooms_table.add_row(room, status) return Panel( rooms_table, title="Rooms", title_align="center", border_style=self.colors["room"], padding=(0, 1), ) def _render_guess_history(self, state: ClueState) -> Panel: """Render the guess history panel. Args: state: Current game state Returns: Panel: Guess history panel """ if not state.guesses: return Panel( "[italic]No guesses have been made yet[/]", title="Guess History", title_align="center", border_style=self.colors["border"], padding=(1, 1), ) history_table = Table( show_header=True, box=ROUNDED, expand=True, border_style=self.colors["border"], ) history_table.add_column("#", style="dim", width=3) history_table.add_column("Player", style="white") history_table.add_column("Suspect", style=self.colors["suspect"]) history_table.add_column("Weapon", style=self.colors["weapon"]) history_table.add_column("Room", style=self.colors["room"]) history_table.add_column("Response", style="white") for i, (guess, response) in enumerate( zip(state.guesses, state.responses, strict=False) ): player = "Player 1" if i % 2 == 0 else "Player 2" player_color = ( self.colors["player1"] if i % 2 == 0 else self.colors["player2"] ) response_text = "" if response.is_correct: response_text = "[bold green]CORRECT![/]" elif response.responding_player: response_text = f"Refuted by [bold]{response.responding_player}[/]" if response.refuting_card: card_type_color = { "Suspect": self.colors["suspect"], "Weapon": self.colors["weapon"], "Room": self.colors["room"], }.get(response.refuting_card.card_type.value, "white") response_text += ( f" with [{card_type_color}]{response.refuting_card.name}[/]" ) else: response_text = "[italic]No one could refute[/]" history_table.add_row( str(i + 1), f"[{player_color}]{player}[/]", guess.suspect.value, guess.weapon.value, guess.room.value, response_text, ) return Panel( history_table, title="Guess History", title_align="center", border_style=self.colors["border"], padding=(0, 1), ) def _render_game_info(self, state: ClueState) -> Panel: """Render game information panel. Args: state: Current game state Returns: Panel: Game information panel """ info_table = Table( show_header=False, box=None, expand=True, padding=(0, 1), ) info_table.add_column("Key", style="bright_blue") info_table.add_column("Value", style="white") # Add game information info_table.add_row("Game Status", state.game_status.upper()) info_table.add_row( "Current Turn", f"{state.current_turn_number}/{state.max_turns}" ) info_table.add_row( "Current Player", f"[{self.colors[state.current_player]}]{state.current_player.upper()}[/]", ) # Add solution information if game is over if state.game_status != "ongoing": # Get the clean string values from enum values or handle as is suspect_value = ( state.solution.suspect.value if hasattr(state.solution.suspect, "value") else str(state.solution.suspect) ) weapon_value = ( state.solution.weapon.value if hasattr(state.solution.weapon, "value") else str(state.solution.weapon) ) room_value = ( state.solution.room.value if hasattr(state.solution.room, "value") else str(state.solution.room) ) info_table.add_row( "Solution", f"[{self.colors['suspect']}]{suspect_value}[/], " f"[{self.colors['weapon']}]{weapon_value}[/], " f"[{self.colors['room']}]{room_value}[/]", ) if state.winner: winner_color = ( self.colors["player1"] if state.winner == "player1" else self.colors["player2"] ) info_table.add_row( "Winner", f"[bold {winner_color}]{state.winner.upper()}[/]" ) else: info_table.add_row("Winner", "[italic]No winner (game ended)[/]") return Panel( info_table, title="Game Info", title_align="center", border_style="bright_blue", padding=(0, 1), ) def _render_player_cards(self, state: ClueState) -> Panel: """Render player cards panel. Args: state: Current game state Returns: Panel: Player cards panel """ cards_table = Table( show_header=True, box=ROUNDED, expand=True, border_style=self.colors["border"], ) cards_table.add_column("Player", style="white") cards_table.add_column("Cards", style="white") # Format player 1's cards player1_cards_text = "" for card in state.player1_cards: card_type = ( "suspect" if card in [s.value for s in ValidSuspect] else "weapon" if card in [w.value for w in ValidWeapon] else "room" ) player1_cards_text += f"[{self.colors[card_type]}]{card}[/], " player1_cards_text = player1_cards_text.rstrip(", ") # Format player 2's cards player2_cards_text = "" for card in state.player2_cards: card_type = ( "suspect" if card in [s.value for s in ValidSuspect] else "weapon" if card in [w.value for w in ValidWeapon] else "room" ) player2_cards_text += f"[{self.colors[card_type]}]{card}[/], " player2_cards_text = player2_cards_text.rstrip(", ") cards_table.add_row( f"[{self.colors['player1']}]Player 1[/]", player1_cards_text or "[italic]No cards[/]", ) cards_table.add_row( f"[{self.colors['player2']}]Player 2[/]", player2_cards_text or "[italic]No cards[/]", ) return Panel( cards_table, title="Player Cards", title_align="center", border_style=self.colors["border"], padding=(0, 1), ) def _render_deductions(self, state: ClueState) -> Panel: """Render deductions and analysis panel. Args: state: Current game state Returns: Panel: Deductions panel """ if not state.player1_hypotheses and not state.player2_hypotheses: return Panel( "[italic]No deductions have been made yet[/]", title="Deductions", title_align="center", border_style=self.colors["border"], padding=(1, 1), ) deduction_table = Table( show_header=True, box=ROUNDED, expand=True, border_style=self.colors["border"], ) deduction_table.add_column("Player", style="white") deduction_table.add_column("Suspect", style=self.colors["suspect"]) deduction_table.add_column("Weapon", style=self.colors["weapon"]) deduction_table.add_column("Room", style=self.colors["room"]) deduction_table.add_column("Confidence", style="white") # Player 1 hypotheses for hypothesis in state.player1_hypotheses[ -3: ]: # Show only the last 3 hypotheses deduction_table.add_row( f"[{self.colors['player1']}]Player 1[/]", hypothesis.get("prime_suspect", "Unknown"), hypothesis.get("prime_weapon", "Unknown"), hypothesis.get("prime_room", "Unknown"), f"{hypothesis.get('confidence', 0.0) * 100:.0f}%", ) # Player 2 hypotheses for hypothesis in state.player2_hypotheses[ -3: ]: # Show only the last 3 hypotheses deduction_table.add_row( f"[{self.colors['player2']}]Player 2[/]", hypothesis.get("prime_suspect", "Unknown"), hypothesis.get("prime_weapon", "Unknown"), hypothesis.get("prime_room", "Unknown"), f"{hypothesis.get('confidence', 0.0) * 100:.0f}%", ) return Panel( deduction_table, title="Deductions", title_align="center", border_style=self.colors["border"], padding=(0, 1), )
[docs] def display_state(self, state: ClueState | dict[str, Any]) -> None: """Display the current game state with rich formatting. Renders the complete game state including suspects, weapons, rooms, guess history, player cards, and game information in a formatted layout. Args: state (Union[ClueState, Dict[str, Any]]): Current game state Returns: None Example: >>> ui = ClueUI() >>> state = ClueState.initialize() >>> ui.display_state(state) """ # Convert dict to ClueState if needed if isinstance(state, dict): state = ClueState(**state) # Update each component in the layout self.layout["header"].update(self._render_header(state)) self.layout["suspects"].update(self._render_suspects(state)) self.layout["weapons"].update(self._render_weapons(state)) self.layout["rooms"].update(self._render_rooms(state)) self.layout["guess_history"].update(self._render_guess_history(state)) self.layout["game_info"].update(self._render_game_info(state)) self.layout["player_cards"].update(self._render_player_cards(state)) self.layout["deductions"].update(self._render_deductions(state)) # Render the complete layout self.console.clear() self.console.print(self.layout)
[docs] def show_thinking(self, player: str, message: str = "Thinking...") -> None: """Display a thinking animation for the current player. Shows a spinner animation with player-colored text to indicate that the player is thinking about their guess. Args: player (str): Current player ("player1" or "player2") message (str, optional): Custom message to display. Defaults to "Thinking...". Returns: None Example: >>> ui = ClueUI() >>> ui.show_thinking("player1", "Analyzing clues...") """ player_color = self.colors[player] with Progress( SpinnerColumn(), TextColumn(f"[{player_color}]{player.upper()}[/] {message}"), console=self.console, transient=True, ) as progress: progress.add_task("thinking", total=None) time.sleep(1.0) # Show thinking animation for 1 second
[docs] def show_guess(self, guess: ClueGuess, player: str) -> None: """Display a guess being made. Shows a formatted message indicating which player made a guess, including the suspect, weapon, and room. Args: guess (ClueGuess): The guess being made player (str): Player making the guess ("player1" or "player2") Returns: None Example: >>> ui = ClueUI() >>> guess = ClueGuess( ... suspect=ValidSuspect.COLONEL_MUSTARD, ... weapon=ValidWeapon.KNIFE, ... room=ValidRoom.KITCHEN ... ) >>> ui.show_guess(guess, "player1") """ player_color = self.colors[player] guess_panel = Panel( f"[{player_color}]{player.upper()}[/] guesses:\n\n" f"[{self.colors['suspect']}]Suspect:[/] {guess.suspect.value}\n" f"[{self.colors['weapon']}]Weapon:[/] {guess.weapon.value}\n" f"[{self.colors['room']}]Room:[/] {guess.room.value}", title="New Guess", title_align="center", border_style=player_color, padding=(1, 2), ) self.console.print(guess_panel) time.sleep(1.0) # Brief pause after showing the guess
[docs] def show_response(self, response: ClueResponse, player: str) -> None: """Display a response to a guess. Shows a formatted message indicating the response to a guess, including which player responded and what card was shown. Args: response (ClueResponse): The response to the guess player (str): Player who made the guess ("player1" or "player2") Returns: None Example: >>> ui = ClueUI() >>> response = ClueResponse( ... is_correct=False, ... responding_player="player2", ... refuting_card=ClueCard(name="Knife", card_type=CardType.WEAPON) ... ) >>> ui.show_response(response, "player1") """ player_color = self.colors[player] if response.is_correct: response_panel = Panel( f"[bold green]CORRECT GUESS![/]\n\n" f"[{player_color}]{player.upper()}[/] has solved the mystery!", title="Response", title_align="center", border_style="green", padding=(1, 2), ) elif response.responding_player: responding_color = ( self.colors["player1"] if response.responding_player == "player1" else self.colors["player2"] ) response_text = f"[{responding_color}]{response.responding_player.upper()}[/] refutes the guess" if response.refuting_card: card_type_color = { "Suspect": self.colors["suspect"], "Weapon": self.colors["weapon"], "Room": self.colors["room"], }.get(response.refuting_card.card_type.value, "white") response_text += f" by showing:\n\n[{card_type_color}]{ response.refuting_card.name }[/]" response_panel = Panel( response_text, title="Response", title_align="center", border_style=responding_color, padding=(1, 2), ) else: response_panel = Panel( "No player could refute this guess!", title="Response", title_align="center", border_style="yellow", padding=(1, 2), ) self.console.print(response_panel) time.sleep(1.0) # Brief pause after showing the response
[docs] def show_game_over(self, state: ClueState) -> None: """Display game over message with result. Shows a game over panel with the winner highlighted in their color, and reveals the solution. Args: state (ClueState): Final game state Returns: None Example: >>> ui = ClueUI() >>> state = ClueState.initialize() >>> state.game_status = "player1_win" >>> state.winner = "player1" >>> ui.show_game_over(state) """ # Get the clean string values from enum values or handle as is suspect_value = ( state.solution.suspect.value if hasattr(state.solution.suspect, "value") else str(state.solution.suspect) ) weapon_value = ( state.solution.weapon.value if hasattr(state.solution.weapon, "value") else str(state.solution.weapon) ) room_value = ( state.solution.room.value if hasattr(state.solution.room, "value") else str(state.solution.room) ) if state.winner: winner_color = self.colors[state.winner] game_over_panel = Panel( f"[bold {winner_color}]{state.winner.upper()}[/] wins!\n\n" f"The solution was:\n" f"[{self.colors['suspect']}]Suspect:[/] {suspect_value}\n" f"[{self.colors['weapon']}]Weapon:[/] {weapon_value}\n" f"[{self.colors['room']}]Room:[/] {room_value}", title="🏆 GAME OVER 🏆", title_align="center", border_style="bright_green", padding=(1, 2), box=DOUBLE, ) else: game_over_panel = Panel( f"Game over! Maximum turns reached.\n\n" f"The solution was:\n" f"[{self.colors['suspect']}]Suspect:[/] {suspect_value}\n" f"[{self.colors['weapon']}]Weapon:[/] {weapon_value}\n" f"[{self.colors['room']}]Room:[/] {room_value}", title="🏆 GAME OVER 🏆", title_align="center", border_style="bright_yellow", padding=(1, 2), box=DOUBLE, ) self.console.print(game_over_panel) time.sleep(1.0) # Pause to show the game over message