strat-gameplay-webapp/backend/terminal_client/commands.py
Cal Corum e165b449f5 CLAUDE: Refactor offensive decisions - replace approach with action field
Backend refactor complete - removed all deprecated parameters and replaced
with clean action-based system.

Changes:
- OffensiveDecision model: Added 'action' field (6 choices), removed
  deprecated 'hit_and_run' and 'bunt_attempt' boolean fields
- Validators: Added action-specific validation (squeeze_bunt, check_jump,
  sac_bunt, hit_and_run situational constraints)
- WebSocket handler: Updated submit_offensive_decision to use action field
- Terminal client: Updated CLI, REPL, arg parser, and display for actions
- Tests: Updated all 739 unit tests (100% passing)

Action field values:
- swing_away (default)
- steal (requires steal_attempts parameter)
- check_jump (requires runner on base)
- hit_and_run (requires runner on base)
- sac_bunt (cannot use with 2 outs)
- squeeze_bunt (requires R3, not with bases loaded, not with 2 outs)

Breaking changes:
- Removed: hit_and_run boolean → use action="hit_and_run"
- Removed: bunt_attempt boolean → use action="sac_bunt" or "squeeze_bunt"
- Removed: approach field → use action field

Files modified:
- app/models/game_models.py
- app/core/validators.py
- app/websocket/handlers.py
- terminal_client/main.py
- terminal_client/arg_parser.py
- terminal_client/commands.py
- terminal_client/repl.py
- terminal_client/display.py
- tests/unit/models/test_game_models.py
- tests/unit/core/test_validators.py
- tests/unit/terminal_client/test_arg_parser.py
- tests/unit/terminal_client/test_commands.py

Test results: 739/739 passing (100%)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 15:07:54 -06:00

1036 lines
39 KiB
Python

"""
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]<outcome>[/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 <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()