Test Fixes (609/609 passing): - Fixed DiceSystem API to accept team_id/player_id parameters for audit trails - Fixed dice roll history timing issue in test - Fixed terminal client mock to match resolve_play signature (X-Check params) - Fixed result chart test mocks with missing pitching fields - Fixed flaky test by using groundball_a (exists in both batting/pitching) Documentation Updates: - Added Testing Policy section to backend/CLAUDE.md - Added Testing Policy section to tests/CLAUDE.md - Documented 100% unit test requirement before commits - Added git hook setup instructions Git Hook System: - Created .git-hooks/pre-commit script (enforces 100% test pass) - Created .git-hooks/install-hooks.sh (easy installation) - Created .git-hooks/README.md (hook documentation) - Hook automatically runs all unit tests before each commit - Blocks commits if any test fails All 609 unit tests now passing (100%) Integration tests have known asyncpg connection issues (documented) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1004 lines
38 KiB
Python
1004 lines
38 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,
|
|
approach: str = 'normal',
|
|
steal_attempts: Optional[List[int]] = None,
|
|
hit_and_run: bool = False,
|
|
bunt_attempt: bool = False
|
|
) -> bool:
|
|
"""
|
|
Submit offensive decision.
|
|
|
|
Returns:
|
|
True if successful, False otherwise
|
|
"""
|
|
try:
|
|
decision = OffensiveDecision(
|
|
approach=approach,
|
|
steal_attempts=steal_attempts or [],
|
|
hit_and_run=hit_and_run,
|
|
bunt_attempt=bunt_attempt
|
|
)
|
|
|
|
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:
|
|
if forced_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}")
|
|
else:
|
|
display.print_info(f"🎯 Forcing outcome: {forced_outcome.value}")
|
|
|
|
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.
|
|
|
|
Returns:
|
|
True if successful, False otherwise
|
|
"""
|
|
try:
|
|
state = await game_engine.get_game_state(game_id)
|
|
if state:
|
|
display.display_box_score(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 box score: {e}")
|
|
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()
|