""" Terminal client main entry point - Click CLI application. Direct GameEngine testing without WebSockets. Usage: python -m terminal_client start-game --league sba python -m terminal_client defensive --alignment normal python -m terminal_client offensive --approach power python -m terminal_client resolve python -m terminal_client status Author: Claude Date: 2025-10-26 """ import logging import asyncio from uuid import UUID, uuid4 from typing import Optional import click from app.core.game_engine import game_engine from app.core.state_manager import state_manager from app.models.game_models import DefensiveDecision, OffensiveDecision from app.database.operations import DatabaseOperations from terminal_client import display from terminal_client.config import Config from terminal_client.commands import game_commands # Setup logging logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' ) logger = logging.getLogger(f'{__name__}.main') def set_current_game(game_id: UUID) -> None: """Set the current game ID in persistent config.""" Config.set_current_game(game_id) display.print_info(f"Current game set to: {game_id}") def get_current_game() -> UUID: """Get the current game ID from persistent config or raise error.""" game_id = Config.get_current_game() if game_id is None: display.print_error("No current game set. Use 'new-game' or 'use-game' first.") raise click.Abort() return game_id @click.group() def cli(): """Terminal UI for testing game engine directly.""" pass @cli.command('start-game') @click.option('--league', default='sba', help='League (sba or pd)') @click.option('--game-id', default=None, help='Game UUID (auto-generated if not provided)') @click.option('--home-team', default=1, help='Home team ID') @click.option('--away-team', default=2, help='Away team ID') def start_game(league, game_id, home_team, away_team): """Start a new game and transition to active.""" async def _start(): # Generate or parse game ID gid = UUID(game_id) if game_id else uuid4() # Create game in state manager state = await state_manager.create_game( game_id=gid, league_id=league, home_team_id=home_team, away_team_id=away_team ) display.print_success(f"Game created: {gid}") display.print_info(f"League: {league}, Home: {home_team}, Away: {away_team}") # Set as current game set_current_game(gid) # Start the game try: state = await game_engine.start_game(gid) display.print_success(f"Game started - Inning {state.inning} {state.half}") display.display_game_state(state) except Exception as e: display.print_error(f"Failed to start game: {e}") raise click.Abort() asyncio.run(_start()) @cli.command() @click.option('--game-id', default=None, help='Game UUID (uses current if not provided)') @click.option('--alignment', default='normal', help='Defensive alignment') @click.option('--infield', default='normal', help='Infield depth') @click.option('--outfield', default='normal', help='Outfield depth') @click.option('--hold', default=None, help='Comma-separated bases to hold (e.g., 1,3)') def defensive(game_id, alignment, infield, outfield, hold): """ Submit defensive decision. Valid alignment: normal, shifted_left, shifted_right, extreme_shift Valid infield: in, normal, back, double_play Valid outfield: in, normal, back """ async def _defensive(): gid = UUID(game_id) if game_id else get_current_game() # Parse hold runners hold_list = [] if hold: try: hold_list = [int(b.strip()) for b in hold.split(',')] except ValueError: display.print_error("Invalid hold format. Use comma-separated numbers (e.g., '1,3')") raise click.Abort() # Use shared command success = await game_commands.submit_defensive_decision( game_id=gid, alignment=alignment, infield=infield, outfield=outfield, hold_runners=hold_list ) if not success: raise click.Abort() asyncio.run(_defensive()) @cli.command() @click.option('--game-id', default=None, help='Game UUID (uses current if not provided)') @click.option('--approach', default='normal', help='Batting approach') @click.option('--steal', default=None, help='Comma-separated bases to steal (e.g., 2,3)') @click.option('--hit-run', is_flag=True, help='Hit-and-run play') @click.option('--bunt', is_flag=True, help='Bunt attempt') def offensive(game_id, approach, steal, hit_run, bunt): """ Submit offensive decision. Valid approach: normal, contact, power, patient Valid steal: comma-separated bases (2, 3, 4) """ async def _offensive(): gid = UUID(game_id) if game_id else get_current_game() # Parse steal attempts steal_list = [] if steal: try: steal_list = [int(b.strip()) for b in steal.split(',')] except ValueError: display.print_error("Invalid steal format. Use comma-separated numbers (e.g., '2,3')") raise click.Abort() # Use shared command success = await game_commands.submit_offensive_decision( game_id=gid, approach=approach, steal_attempts=steal_list, hit_and_run=hit_run, bunt_attempt=bunt ) if not success: raise click.Abort() asyncio.run(_offensive()) @cli.command() @click.option('--game-id', default=None, help='Game UUID (uses current if not provided)') def resolve(game_id): """Resolve the current play (both decisions must be submitted first).""" async def _resolve(): gid = UUID(game_id) if game_id else get_current_game() # Use shared command success = await game_commands.resolve_play(gid) if not success: raise click.Abort() asyncio.run(_resolve()) @cli.command() @click.option('--game-id', default=None, help='Game UUID (uses current if not provided)') def status(game_id): """Display current game state.""" async def _status(): gid = UUID(game_id) if game_id else get_current_game() # Use shared command success = await game_commands.show_game_status(gid) if not success: raise click.Abort() asyncio.run(_status()) @cli.command('box-score') @click.option('--game-id', default=None, help='Game UUID (uses current if not provided)') def box_score(game_id): """Display box score (simple version).""" async def _box_score(): gid = UUID(game_id) if game_id else get_current_game() # Use shared command success = await game_commands.show_box_score(gid) if not success: raise click.Abort() asyncio.run(_box_score()) @cli.command('list-games') def list_games(): """List all games in state manager.""" games = state_manager.list_games() if not games: display.print_warning("No active games in state manager") return display.print_info(f"Active games: {len(games)}") for game_id in games: display.console.print(f" • {game_id}") @cli.command('use-game') @click.argument('game_id') def use_game(game_id): """Set current game ID.""" try: gid = UUID(game_id) set_current_game(gid) except ValueError: display.print_error(f"Invalid UUID: {game_id}") raise click.Abort() @cli.command('quick-play') @click.option('--count', default=1, help='Number of plays to execute') @click.option('--game-id', default=None, help='Game UUID (uses current if not provided)') def quick_play(count, game_id): """ Quick play mode - submit default decisions and resolve multiple plays. Useful for rapidly advancing the game for testing. """ async def _quick_play(): gid = UUID(game_id) if game_id else get_current_game() # Use shared command await game_commands.quick_play_rounds(gid, count) asyncio.run(_quick_play()) @cli.command('new-game') @click.option('--league', default='sba', help='League (sba or pd)') @click.option('--home-team', default=1, help='Home team ID') @click.option('--away-team', default=2, help='Away team ID') def new_game(league, home_team, away_team): """ Create a new game with lineups and start it immediately (all-in-one). This is a convenience command that combines: 1. Creating game state 2. Setting up test lineups 3. Starting the game Perfect for rapid testing! """ async def _new_game(): # Use shared command await game_commands.create_new_game( league=league, home_team=home_team, away_team=away_team, set_current=True ) asyncio.run(_new_game()) @cli.command('setup-game') @click.option('--game-id', default=None, help='Game UUID (uses current if not provided)') @click.option('--league', default='sba', help='League (sba or pd)') def setup_game(game_id, league): """ Create test lineups for both teams to allow game to start. Generates 9 players per team with proper positions and batting order. Uses mock player/card IDs for testing. """ async def _setup(): gid = UUID(game_id) if game_id else get_current_game() db_ops = DatabaseOperations() # Standard defensive positions positions = ['P', 'C', '1B', '2B', '3B', 'SS', 'LF', 'CF', 'RF'] try: # Get game to determine team IDs state = await game_engine.get_game_state(gid) if not state: display.print_error(f"Game {gid} not found") raise click.Abort() display.print_info(f"Setting up lineups for game {gid}") display.print_info(f"League: {league}, Home Team: {state.home_team_id}, Away Team: {state.away_team_id}") # Create lineups for both teams for team_id in [state.home_team_id, state.away_team_id]: team_name = "Home" if team_id == state.home_team_id else "Away" display.print_info(f"Creating {team_name} team lineup (Team ID: {team_id})...") for i, position in enumerate(positions, start=1): batting_order = i if league == 'sba': # SBA uses player_id player_id = (team_id * 100) + i # Generate unique player IDs await db_ops.add_sba_lineup_player( game_id=gid, team_id=team_id, player_id=player_id, position=position, batting_order=batting_order, is_starter=True ) display.console.print(f" ✓ Added {position} (Player #{player_id}) - Batting {batting_order}") else: # PD uses card_id card_id = (team_id * 100) + i # Generate unique card IDs await db_ops.add_pd_lineup_card( game_id=gid, team_id=team_id, card_id=card_id, position=position, batting_order=batting_order, is_starter=True ) display.console.print(f" ✓ Added {position} (Card #{card_id}) - Batting {batting_order}") display.print_success(f"Lineups created successfully!") display.print_info("You can now start the game with: python -m terminal_client start-game --game-id ") display.print_info("Or if this is your current game, just run: python -m terminal_client start-game") except Exception as e: display.print_error(f"Failed to setup game: {e}") logger.exception("Setup error") raise click.Abort() asyncio.run(_setup()) @cli.command('config') @click.option('--clear', is_flag=True, help='Clear current game from config') def config_cmd(clear): """ Show or manage terminal client configuration. Displays config file location and current game. Use --clear to reset the current game. """ config_path = Config.get_config_path() display.print_info(f"Config file: {config_path}") if clear: Config.clear_current_game() display.print_success("Current game cleared") return current_game = Config.get_current_game() if current_game: display.console.print(f"\n[green]Current game:[/green] {current_game}") else: display.console.print("\n[yellow]No current game set[/yellow]") @cli.command('help-cmd') @click.argument('command', required=False) def show_cli_help(command): """ Show help for terminal client commands. Usage: python -m terminal_client help-cmd # Show all commands python -m terminal_client help-cmd new-game # Show help for specific command """ from terminal_client.help_text import show_help # Convert hyphenated command to underscore for lookup if command: command = command.replace('-', '_') show_help(command) if __name__ == "__main__": cli()