""" Shared command implementations for terminal client. This module contains the core logic for game commands that can be used by both the REPL (repl.py) and CLI (main.py) interfaces. Author: Claude Date: 2025-10-27 Updated: 2025-10-30 - Added manual outcome commands """ import asyncio import logging from uuid import UUID, uuid4 from typing import Optional, List, Tuple from app.core.game_engine import game_engine from app.core.state_manager import state_manager from app.core.dice import dice_system from app.models.game_models import DefensiveDecision, OffensiveDecision from app.config import PlayOutcome from app.database.operations import DatabaseOperations from terminal_client import display from terminal_client.config import Config logger = logging.getLogger(f'{__name__}.commands') class GameCommands: """Shared command implementations for game operations.""" def __init__(self): self.db_ops = DatabaseOperations() async def create_new_game( self, league: str = 'sba', home_team: int = 1, away_team: int = 2, set_current: bool = True ) -> Tuple[UUID, bool]: """ Create a new game with lineups and start it. Args: league: 'sba' or 'pd' home_team: Home team ID away_team: Away team ID set_current: Whether to set as current game Returns: Tuple of (game_id, success) """ gid = uuid4() try: # Step 1: Create game in memory and database display.print_info("Step 1: Creating game...") state = await state_manager.create_game( game_id=gid, league_id=league, home_team_id=home_team, away_team_id=away_team ) await self.db_ops.create_game( game_id=gid, league_id=league, home_team_id=home_team, away_team_id=away_team, game_mode="friendly", visibility="public" ) display.print_success(f"Game created: {gid}") if set_current: Config.set_current_game(gid) display.print_info(f"Current game set to: {gid}") # Step 2: Setup lineups display.print_info("Step 2: Creating test lineups...") await self._create_test_lineups(gid, league, home_team, away_team) # Step 3: Start the game display.print_info("Step 3: Starting game...") state = await game_engine.start_game(gid) display.print_success(f"Game started - Inning {state.inning} {state.half}") display.display_game_state(state) return gid, True except Exception as e: display.print_error(f"Failed to create new game: {e}") logger.exception("New game error") return gid, False async def _create_test_lineups( self, game_id: UUID, league: str, home_team: int, away_team: int ) -> None: """Create test lineups for both teams.""" positions = ['P', 'C', '1B', '2B', '3B', 'SS', 'LF', 'CF', 'RF'] for team_id in [home_team, away_team]: team_name = "Home" if team_id == home_team else "Away" for i, position in enumerate(positions, start=1): if league == 'sba': player_id = (team_id * 100) + i await self.db_ops.add_sba_lineup_player( game_id=game_id, team_id=team_id, player_id=player_id, position=position, batting_order=i, is_starter=True ) else: card_id = (team_id * 100) + i await self.db_ops.add_pd_lineup_card( game_id=game_id, team_id=team_id, card_id=card_id, position=position, batting_order=i, is_starter=True ) display.console.print(f" ✓ {team_name} team lineup created (9 players)") async def submit_defensive_decision( self, game_id: UUID, alignment: str = 'normal', infield: str = 'normal', outfield: str = 'normal', hold_runners: Optional[List[int]] = None ) -> bool: """ Submit defensive decision. Returns: True if successful, False otherwise """ try: decision = DefensiveDecision( alignment=alignment, infield_depth=infield, outfield_depth=outfield, hold_runners=hold_runners or [] ) state = await game_engine.submit_defensive_decision(game_id, decision) display.print_success("Defensive decision submitted") display.display_decision("defensive", decision) display.display_game_state(state) return True except Exception as e: display.print_error(f"Failed to submit defensive decision: {e}") logger.exception("Defensive decision error") return False async def submit_offensive_decision( self, game_id: UUID, action: str = 'swing_away', steal_attempts: Optional[List[int]] = None ) -> bool: """ Submit offensive decision. Args: action: Offensive action (swing_away, steal, check_jump, hit_and_run, sac_bunt, squeeze_bunt) steal_attempts: List of bases to steal (required when action="steal") Returns: True if successful, False otherwise Session 2 Update (2025-01-14): Replaced approach with action field. Removed deprecated fields. """ try: decision = OffensiveDecision( action=action, steal_attempts=steal_attempts or [] ) state = await game_engine.submit_offensive_decision(game_id, decision) display.print_success("Offensive decision submitted") display.display_decision("offensive", decision) display.display_game_state(state) return True except Exception as e: display.print_error(f"Failed to submit offensive decision: {e}") logger.exception("Offensive decision error") return False async def resolve_play( self, game_id: UUID, forced_outcome: Optional[PlayOutcome] = None, xcheck_position: Optional[str] = None, xcheck_result: Optional[str] = None, xcheck_error: Optional[str] = None ) -> bool: """ Resolve the current play. Args: game_id: Game to resolve forced_outcome: If provided, use this outcome instead of rolling dice xcheck_position: For X_CHECK outcomes, the position to check (SS, LF, etc.) xcheck_result: For X_CHECK, force the converted result (G1, G2, SI2, DO2, etc.) xcheck_error: For X_CHECK, force the error result (NO, E1, E2, E3, RP) Returns: True if successful, False otherwise """ try: # Get game state to check mode state = state_manager.get_state(game_id) if not state: display.print_error(f"Game {game_id} not found") return False # If no forced outcome and in manual mode, use manual resolution with random outcome if forced_outcome is None: # For terminal testing, auto-generate a random outcome import random test_outcomes = [ PlayOutcome.STRIKEOUT, PlayOutcome.GROUNDBALL_A, PlayOutcome.GROUNDBALL_B, PlayOutcome.GROUNDBALL_C, PlayOutcome.FLYOUT_A, PlayOutcome.FLYOUT_B, PlayOutcome.FLYOUT_C, PlayOutcome.SINGLE_1, PlayOutcome.DOUBLE_2, PlayOutcome.HOMERUN, PlayOutcome.WALK, ] forced_outcome = random.choice(test_outcomes) display.print_info(f"🎲 Auto-generated outcome for testing: {forced_outcome.value}") # Display what we're doing (if forcing specific outcome) if xcheck_position: if xcheck_result: error_display = f"+{xcheck_error}" if xcheck_error and xcheck_error != 'NO' else "" display.print_info(f"🎯 Forcing X-Check to: {xcheck_position} → {xcheck_result}{error_display}") else: display.print_info(f"🎯 Forcing X-Check to: {xcheck_position}") result = await game_engine.resolve_play( game_id, forced_outcome, xcheck_position, xcheck_result, xcheck_error ) state = await game_engine.get_game_state(game_id) if state: display.display_play_result(result, state) display.display_game_state(state) return True else: display.print_error(f"Game {game_id} not found after resolution") return False except Exception as e: display.print_error(f"Failed to resolve play: {e}") logger.exception("Resolve play error") return False def list_outcomes(self) -> None: """ Display all available PlayOutcome values for manual selection. """ from rich.table import Table from rich.console import Console console = Console() # Create categorized table table = Table(title="Available Play Outcomes", show_header=True, header_style="bold cyan") table.add_column("Category", style="yellow", width=20) table.add_column("Outcome", style="green", width=25) table.add_column("Description", style="white", width=50) # Outs table.add_row("Outs", "strikeout", "Batter strikes out") table.add_row("", "groundball_a", "Groundball - double play if possible") table.add_row("", "groundball_b", "Groundball - standard") table.add_row("", "groundball_c", "Groundball - weak contact") table.add_row("", "flyout_a", "Flyout variant A") table.add_row("", "flyout_b", "Flyout variant B (medium depth)") table.add_row("", "flyout_c", "Flyout variant C (deep)") table.add_row("", "lineout", "Line drive out") table.add_row("", "popout", "Pop fly out") # Hits table.add_row("Hits", "single_1", "Single - standard advancement") table.add_row("", "single_2", "Single - enhanced advancement") table.add_row("", "single_uncapped", "Single (uncapped) - decision tree") table.add_row("", "double_2", "Double to 2nd base") table.add_row("", "double_3", "Double to 3rd base") table.add_row("", "double_uncapped", "Double (uncapped) - decision tree") table.add_row("", "triple", "Triple") table.add_row("", "homerun", "Home run") # Walks/HBP table.add_row("Walks/HBP", "walk", "Base on balls") table.add_row("", "hbp", "Hit by pitch") table.add_row("", "intentional_walk", "Intentional walk") # Errors table.add_row("Errors", "error", "Defensive error") # Interrupts table.add_row("Interrupts", "wild_pitch", "Wild pitch (pa=0)") table.add_row("", "passed_ball", "Passed ball (pa=0)") table.add_row("", "stolen_base", "Stolen base (pa=0)") table.add_row("", "caught_stealing", "Caught stealing (pa=0)") table.add_row("", "balk", "Balk (pa=0)") table.add_row("", "pick_off", "Pick off (pa=0)") # Ballpark Power table.add_row("Ballpark", "bp_homerun", "Ballpark home run") table.add_row("", "bp_single", "Ballpark single") table.add_row("", "bp_flyout", "Ballpark flyout") table.add_row("", "bp_lineout", "Ballpark lineout") console.print(table) console.print("\n[cyan]Usage:[/cyan] [green]resolve_with[/green] [yellow][/yellow]") console.print("[dim]Example: resolve_with single_1[/dim]") async def quick_play_rounds( self, game_id: UUID, count: int = 1 ) -> int: """ Execute multiple plays with default decisions. Returns: Number of plays successfully executed """ plays_completed = 0 for i in range(count): try: state = await game_engine.get_game_state(game_id) if not state or state.status != "active": display.print_warning(f"Game ended at play {i + 1}") break display.print_info(f"Play {i + 1}/{count}") # Submit default decisions await game_engine.submit_defensive_decision(game_id, DefensiveDecision()) await game_engine.submit_offensive_decision(game_id, OffensiveDecision()) # Resolve result = await game_engine.resolve_play(game_id) state = await game_engine.get_game_state(game_id) if state: display.print_success(f"{result.description}") display.console.print( f"[cyan]Score: Away {state.away_score} - {state.home_score} Home, " f"Inning {state.inning} {state.half}, {state.outs} outs[/cyan]" ) plays_completed += 1 await asyncio.sleep(0.3) # Brief pause for readability except Exception as e: display.print_error(f"Error on play {i + 1}: {e}") logger.exception("Quick play error") break # Show final state state = await game_engine.get_game_state(game_id) if state: display.print_info("Final state:") display.display_game_state(state) return plays_completed async def show_game_status(self, game_id: UUID) -> bool: """ Display current game state. Returns: True if successful, False otherwise """ try: state = await game_engine.get_game_state(game_id) if state: display.display_game_state(state) return True else: display.print_error(f"Game {game_id} not found") return False except Exception as e: display.print_error(f"Failed to get game status: {e}") return False async def show_box_score(self, game_id: UUID) -> bool: """ Display box score using materialized views. Returns: True if successful, False otherwise """ try: from app.services import box_score_service # Get box score from materialized views box_score = await box_score_service.get_box_score(game_id) if box_score: display.display_box_score(box_score) return True else: display.print_error(f"No box score found for game {game_id}") display.print_info("Note: Run migration first (alembic upgrade head) and refresh views") return False except Exception as e: display.print_error(f"Failed to get box score: {e}") logger.error(f"Box score error for game {game_id}: {e}", exc_info=True) return False def validate_manual_outcome(self, outcome: str, location: Optional[str] = None) -> bool: """ Validate a manual outcome submission. Args: outcome: PlayOutcome enum value (e.g., 'groundball_c') location: Optional hit location (e.g., 'SS') Returns: True if valid, False otherwise """ from app.models.game_models import ManualOutcomeSubmission from pydantic import ValidationError try: # Try to create ManualOutcomeSubmission submission = ManualOutcomeSubmission( outcome=outcome, hit_location=location ) # Show success display.print_success(f"✅ Valid manual outcome submission") display.console.print(f" [cyan]Outcome:[/cyan] [green]{submission.outcome}[/green]") if submission.hit_location: display.console.print(f" [cyan]Location:[/cyan] [green]{submission.hit_location}[/green]") else: display.console.print(f" [cyan]Location:[/cyan] [dim]None (not required for this outcome)[/dim]") # Check if location is required outcome_enum = PlayOutcome(outcome) if outcome_enum.requires_hit_location(): if not location: display.print_warning("⚠️ Note: This outcome typically requires a hit location") display.console.print(" [dim]Groundballs and flyouts need location for runner advancement[/dim]") return True except ValidationError as e: display.print_error("❌ Invalid manual outcome submission") for error in e.errors(): field = error['loc'][0] if error['loc'] else 'unknown' message = error['msg'] display.console.print(f" [red]•[/red] [yellow]{field}:[/yellow] {message}") return False except Exception as e: display.print_error(f"Validation error: {e}") return False def test_hit_location(self, outcome: str, handedness: str = 'R', count: int = 10) -> None: """ Test hit location calculation for a given outcome and handedness. Args: outcome: PlayOutcome enum value (e.g., 'groundball_c') handedness: Batter handedness ('L' or 'R') count: Number of samples to generate Displays: Distribution of hit locations """ from app.config.result_charts import calculate_hit_location, PlayOutcome from collections import Counter from rich.table import Table try: outcome_enum = PlayOutcome(outcome) except ValueError: display.print_error(f"Invalid outcome: {outcome}") display.console.print(" [dim]Use 'list_outcomes' to see valid outcomes[/dim]") return # Generate samples locations = [] for _ in range(count): location = calculate_hit_location(outcome_enum, handedness) if location: locations.append(location) if not locations: display.print_info(f"Outcome '{outcome}' does not require hit location") display.console.print(" [dim]Location only tracked for groundballs and flyouts[/dim]") return # Count distribution counter = Counter(locations) total = len(locations) # Display results display.print_success(f"Hit Location Distribution for {outcome} ({handedness}HB)") table = Table(show_header=True, header_style="bold cyan") table.add_column("Location", style="yellow", width=15) table.add_column("Count", style="green", width=10, justify="right") table.add_column("Percentage", style="cyan", width=15, justify="right") table.add_column("Visual", style="white", width=30) for location in sorted(counter.keys()): count_val = counter[location] pct = (count_val / total) * 100 bar = "█" * int(pct / 3) # Scale bar to fit table.add_row( location, str(count_val), f"{pct:.1f}%", bar ) display.console.print(table) # Show pull rates info display.console.print(f"\n[dim]Pull rates: 45% pull, 35% center, 20% opposite[/dim]") if handedness == 'R': display.console.print(f"[dim]RHB pulls left (3B, SS, LF)[/dim]") else: display.console.print(f"[dim]LHB pulls right (1B, 2B, RF)[/dim]") async def rollback_plays(self, game_id: UUID, num_plays: int) -> bool: """ Roll back the last N plays. Deletes plays and reconstructs game state from remaining plays. Args: game_id: Game to roll back num_plays: Number of plays to roll back Returns: True if successful, False otherwise """ try: display.print_info(f"Rolling back {num_plays} play(s)...") # Call game engine rollback state = await game_engine.rollback_plays(game_id, num_plays) display.print_success(f"✓ Rolled back {num_plays} play(s)") display.console.print(f" [cyan]Now at play:[/cyan] {state.play_count}") display.console.print(f" [cyan]Inning:[/cyan] {state.inning} {state.half}") display.console.print(f" [cyan]Score:[/cyan] Away {state.away_score} - {state.home_score} Home") # Show current game state display.display_game_state(state) return True except ValueError as e: display.print_error(f"Cannot roll back: {e}") return False except Exception as e: display.print_error(f"Failed to roll back plays: {e}") logger.exception("Rollback error") return False async def roll_manual_dice(self, game_id: UUID) -> bool: """ Roll dice for manual outcome mode. Server rolls dice and stores in state for fairness/auditing. Players then read their physical cards and submit outcomes. Args: game_id: Game to roll dice for Returns: True if successful, False otherwise """ try: # Get game state state = state_manager.get_state(game_id) if not state: display.print_error(f"Game {game_id} not found") return False # Roll dice ab_roll = dice_system.roll_ab( league_id=state.league_id, game_id=game_id ) # Store in state state.pending_manual_roll = ab_roll state_manager.update_state(game_id, state) # Display dice results display.print_success("✓ Dice rolled!") display.console.print(f"\n[bold cyan]Dice Results:[/bold cyan]") display.console.print(f" [cyan]Roll ID:[/cyan] {ab_roll.roll_id}") display.console.print(f" [cyan]Column (1d6):[/cyan] {ab_roll.d6_one}") display.console.print(f" [cyan]Row (2d6):[/cyan] {ab_roll.d6_two_total} ({ab_roll.d6_two_a}+{ab_roll.d6_two_b})") display.console.print(f" [cyan]Chaos (1d20):[/cyan] {ab_roll.chaos_d20}") display.console.print(f" [cyan]Resolution (1d20):[/cyan] {ab_roll.resolution_d20}") if ab_roll.check_wild_pitch: display.console.print(f"\n[yellow]⚠️ Wild pitch check! (chaos d20 = 1)[/yellow]") elif ab_roll.check_passed_ball: display.console.print(f"\n[yellow]⚠️ Passed ball check! (chaos d20 = 2)[/yellow]") display.console.print(f"\n[dim]Read your physical card and submit outcome with:[/dim]") display.console.print(f"[dim] manual_outcome [hit_location][/dim]") return True except Exception as e: display.print_error(f"Failed to roll dice: {e}") logger.exception("Roll dice error") return False async def submit_manual_outcome( self, game_id: UUID, outcome: str, hit_location: Optional[str] = None ) -> bool: """ Submit manually-selected outcome from physical card. Args: game_id: Game to submit outcome for outcome: PlayOutcome value (e.g., 'groundball_c', 'walk') hit_location: Optional hit location (e.g., 'SS', '1B') Returns: True if successful, False otherwise """ try: # Get game state state = state_manager.get_state(game_id) if not state: display.print_error(f"Game {game_id} not found") return False # Validate outcome try: play_outcome = PlayOutcome(outcome.lower()) except ValueError: valid_outcomes = [o.value for o in PlayOutcome] display.print_error(f"Invalid outcome: {outcome}") display.console.print(f"[dim]Valid outcomes: {', '.join(valid_outcomes[:10])}...[/dim]") return False # Check for pending roll if not state.pending_manual_roll: display.print_error("No pending dice roll - run 'roll_dice' first") return False ab_roll = state.pending_manual_roll # Validate hit location if required if play_outcome.requires_hit_location() and not hit_location: display.print_error(f"Outcome '{outcome}' requires hit_location") display.console.print(f"[dim]Valid locations: 1B, 2B, SS, 3B, LF, CF, RF, P, C[/dim]") return False display.print_info(f"Submitting manual outcome: {play_outcome.value}" + (f" to {hit_location}" if hit_location else "")) # Call game engine result = await game_engine.resolve_manual_play( game_id=game_id, ab_roll=ab_roll, outcome=play_outcome, hit_location=hit_location ) # Display result display.display_play_result(result) # Refresh and display game state state = state_manager.get_state(game_id) if state: display.display_game_state(state) return True except ValueError as e: display.print_error(f"Validation error: {e}") return False except Exception as e: display.print_error(f"Failed to submit manual outcome: {e}") logger.exception("Manual outcome error") return False async def force_wild_pitch(self, game_id: UUID) -> bool: """ Force a wild pitch interrupt play. Wild pitch advances all runners one base. Args: game_id: Game to force wild pitch in Returns: True if successful, False otherwise """ display.print_info("🎯 Forcing interrupt: WILD PITCH") return await self.resolve_play(game_id, PlayOutcome.WILD_PITCH) async def force_passed_ball(self, game_id: UUID) -> bool: """ Force a passed ball interrupt play. Passed ball advances all runners one base. Args: game_id: Game to force passed ball in Returns: True if successful, False otherwise """ display.print_info("🎯 Forcing interrupt: PASSED BALL") return await self.resolve_play(game_id, PlayOutcome.PASSED_BALL) def roll_jump(self, league: str = 'sba', game_id: Optional[UUID] = None) -> bool: """ Roll jump dice for stolen base testing. Jump roll: 1d20 check + conditional 2d6 or 1d20 - check_roll == 1: Pickoff attempt (uses resolution_roll) - check_roll == 2: Balk check (uses resolution_roll) - check_roll >= 3: Normal jump (uses 2d6) Args: league: League ID ('sba' or 'pd') game_id: Optional game ID for context Returns: True if successful, False otherwise """ try: from app.core.roll_types import RollType # Roll jump dice roll = dice_system.roll_jump(league_id=league, game_id=game_id) # Display results display.print_success("✓ Jump roll completed!") display.console.print(f"\n[bold cyan]Jump Roll Results:[/bold cyan]") display.console.print(f" [cyan]Roll ID:[/cyan] {roll.roll_id}") display.console.print(f" [cyan]League:[/cyan] {league.upper()}") display.console.print(f" [cyan]Check Roll (1d20):[/cyan] {roll.check_roll}") if roll.is_pickoff_check: display.console.print(f"\n[bold red]🎯 PICKOFF ATTEMPT![/bold red]") display.console.print(f" [cyan]Resolution (1d20):[/cyan] {roll.resolution_roll}") display.console.print(f"\n[dim]Pitcher attempts to pick off runner[/dim]") elif roll.is_balk_check: display.console.print(f"\n[bold yellow]⚠️ BALK CHECK![/bold yellow]") display.console.print(f" [cyan]Resolution (1d20):[/cyan] {roll.resolution_roll}") display.console.print(f"\n[dim]Pitcher may have committed balk[/dim]") else: display.console.print(f" [cyan]Jump Dice (2d6):[/cyan] {roll.jump_total} ({roll.jump_dice_a}+{roll.jump_dice_b})") display.console.print(f"\n[green]Normal steal attempt - use jump total for success check[/green]") return True except Exception as e: display.print_error(f"Failed to roll jump: {e}") logger.exception("Jump roll error") return False def test_jump(self, count: int = 10, league: str = 'sba') -> bool: """ Test jump roll distribution. Rolls N jump rolls and displays distribution statistics. Args: count: Number of rolls to test league: League ID ('sba' or 'pd') Returns: True if successful, False otherwise """ try: from collections import Counter from rich.table import Table display.print_info(f"Rolling {count} jump rolls for {league.upper()} league...") # Roll multiple times pickoff_count = 0 balk_count = 0 normal_count = 0 jump_totals = [] for _ in range(count): roll = dice_system.roll_jump(league_id=league) if roll.is_pickoff_check: pickoff_count += 1 elif roll.is_balk_check: balk_count += 1 else: normal_count += 1 jump_totals.append(roll.jump_total) # Display summary display.print_success(f"✓ Completed {count} jump rolls") # Event distribution table event_table = Table(title="Jump Roll Event Distribution", show_header=True, header_style="bold cyan") event_table.add_column("Event Type", style="yellow", width=20) event_table.add_column("Count", style="green", width=10, justify="right") event_table.add_column("Percentage", style="cyan", width=12, justify="right") event_table.add_column("Expected", style="dim", width=12, justify="right") pickoff_pct = (pickoff_count / count) * 100 balk_pct = (balk_count / count) * 100 normal_pct = (normal_count / count) * 100 event_table.add_row("Pickoff Check", str(pickoff_count), f"{pickoff_pct:.1f}%", "5.0%") event_table.add_row("Balk Check", str(balk_count), f"{balk_pct:.1f}%", "5.0%") event_table.add_row("Normal Jump", str(normal_count), f"{normal_pct:.1f}%", "90.0%") display.console.print(event_table) # Jump total distribution (for normal rolls) if jump_totals: display.console.print(f"\n[bold cyan]Jump Total Distribution (2d6):[/bold cyan]") counter = Counter(jump_totals) jump_table = Table(show_header=True, header_style="bold cyan") jump_table.add_column("Total", style="yellow", width=10, justify="right") jump_table.add_column("Count", style="green", width=10, justify="right") jump_table.add_column("Percentage", style="cyan", width=12, justify="right") jump_table.add_column("Visual", style="white", width=30) for total in range(2, 13): # 2-12 possible with 2d6 count_val = counter.get(total, 0) pct = (count_val / len(jump_totals)) * 100 if jump_totals else 0 bar = "█" * int(pct / 2) # Scale bar jump_table.add_row(str(total), str(count_val), f"{pct:.1f}%", bar) display.console.print(jump_table) # Statistics if jump_totals: avg = sum(jump_totals) / len(jump_totals) display.console.print(f"\n[dim]Average jump total: {avg:.2f} (expected: 7.0)[/dim]") return True except Exception as e: display.print_error(f"Failed to test jump rolls: {e}") logger.exception("Test jump error") return False def roll_fielding(self, position: str, league: str = 'sba', game_id: Optional[UUID] = None) -> bool: """ Roll fielding check dice for testing. Fielding roll: 1d20 + 3d6 + 1d100 - d20: Range check - 3d6: Error total (3-18) - d100: Rare play check Args: position: Defensive position (P, C, 1B, 2B, 3B, SS, LF, CF, RF) league: League ID ('sba' or 'pd') game_id: Optional game ID for context Returns: True if successful, False otherwise """ try: # Validate position valid_positions = ['P', 'C', '1B', '2B', '3B', 'SS', 'LF', 'CF', 'RF'] position = position.upper() if position not in valid_positions: display.print_error(f"Invalid position: {position}") display.console.print(f"[dim]Valid positions: {', '.join(valid_positions)}[/dim]") return False # Roll fielding dice roll = dice_system.roll_fielding(position=position, league_id=league, game_id=game_id) # Display results display.print_success(f"✓ Fielding roll completed for {position}!") display.console.print(f"\n[bold cyan]Fielding Roll Results:[/bold cyan]") display.console.print(f" [cyan]Roll ID:[/cyan] {roll.roll_id}") display.console.print(f" [cyan]Position:[/cyan] {roll.position}") display.console.print(f" [cyan]League:[/cyan] {league.upper()}") display.console.print(f"\n[bold]Dice Components:[/bold]") display.console.print(f" [cyan]Range (1d20):[/cyan] {roll.d20}") display.console.print(f" [cyan]Error Dice (3d6):[/cyan] {roll.error_total} ({roll.d6_one}+{roll.d6_two}+{roll.d6_three})") display.console.print(f" [cyan]Rare Play (1d100):[/cyan] {roll.d100}") if roll.is_rare_play: if league == 'sba': display.console.print(f"\n[bold yellow]⚠️ RARE PLAY! (d100 = 1)[/bold yellow]") else: display.console.print(f"\n[bold yellow]⚠️ RARE PLAY! (error_total = 5)[/bold yellow]") display.console.print(f"[dim]Unusual fielding event may occur[/dim]") return True except ValueError as e: display.print_error(f"Validation error: {e}") return False except Exception as e: display.print_error(f"Failed to roll fielding: {e}") logger.exception("Fielding roll error") return False def test_fielding(self, position: str, count: int = 10, league: str = 'sba') -> bool: """ Test fielding roll distribution for a position. Rolls N fielding rolls and displays distribution statistics. Args: position: Defensive position (P, C, 1B, 2B, 3B, SS, LF, CF, RF) count: Number of rolls to test league: League ID ('sba' or 'pd') Returns: True if successful, False otherwise """ try: from collections import Counter from rich.table import Table # Validate position valid_positions = ['P', 'C', '1B', '2B', '3B', 'SS', 'LF', 'CF', 'RF'] position = position.upper() if position not in valid_positions: display.print_error(f"Invalid position: {position}") display.console.print(f"[dim]Valid positions: {', '.join(valid_positions)}[/dim]") return False display.print_info(f"Rolling {count} fielding checks for {position} in {league.upper()} league...") # Roll multiple times d20_values = [] error_totals = [] d100_values = [] rare_play_count = 0 for _ in range(count): roll = dice_system.roll_fielding(position=position, league_id=league) d20_values.append(roll.d20) error_totals.append(roll.error_total) d100_values.append(roll.d100) if roll.is_rare_play: rare_play_count += 1 # Display summary display.print_success(f"✓ Completed {count} fielding rolls for {position}") # Summary statistics display.console.print(f"\n[bold cyan]Summary Statistics:[/bold cyan]") display.console.print(f" [cyan]Rare Plays:[/cyan] {rare_play_count} ({(rare_play_count/count)*100:.1f}%)") display.console.print(f" [cyan]Avg Range (d20):[/cyan] {sum(d20_values)/len(d20_values):.2f} (expected: 10.5)") display.console.print(f" [cyan]Avg Error Total (3d6):[/cyan] {sum(error_totals)/len(error_totals):.2f} (expected: 10.5)") # Error total distribution display.console.print(f"\n[bold cyan]Error Total Distribution (3d6):[/bold cyan]") counter = Counter(error_totals) error_table = Table(show_header=True, header_style="bold cyan") error_table.add_column("Total", style="yellow", width=10, justify="right") error_table.add_column("Count", style="green", width=10, justify="right") error_table.add_column("Percentage", style="cyan", width=12, justify="right") error_table.add_column("Visual", style="white", width=30) for total in range(3, 19): # 3-18 possible with 3d6 count_val = counter.get(total, 0) pct = (count_val / count) * 100 bar = "█" * int(pct / 2) # Scale bar error_table.add_row(str(total), str(count_val), f"{pct:.1f}%", bar) display.console.print(error_table) # Rare play info if league == 'sba': expected_rare = 1.0 # 1% (d100 = 1) else: expected_rare = 2.78 # ~2.78% (3d6 = 5) display.console.print(f"\n[dim]Expected rare play rate: {expected_rare:.2f}%[/dim]") display.console.print(f"[dim]Observed rare play rate: {(rare_play_count/count)*100:.2f}%[/dim]") return True except ValueError as e: display.print_error(f"Validation error: {e}") return False except Exception as e: display.print_error(f"Failed to test fielding rolls: {e}") logger.exception("Test fielding error") return False # Singleton instance game_commands = GameCommands()