CLAUDE: Add interrupt plays, jump roll, and fielding roll testing commands to terminal client

Implemented features:
- Interrupt play commands:
  * force_wild_pitch - Force wild pitch interrupt (advances runners 1 base)
  * force_passed_ball - Force passed ball interrupt (advances runners 1 base)

- Jump roll testing commands:
  * roll_jump [league] - Roll jump dice for steal testing (1d20 + 2d6/1d20)
  * test_jump [count] [league] - Test jump roll distribution with statistics

- Fielding roll testing commands:
  * roll_fielding <position> [league] - Roll fielding dice (1d20 + 3d6 + 1d100)
  * test_fielding <position> [count] [league] - Test fielding roll distribution

All commands include:
- Rich terminal formatting with colored output
- Comprehensive help text and examples
- TAB completion for all arguments
- Input validation
- Statistical analysis for test commands

Jump rolls show:
- Pickoff attempts (5% chance, check_roll=1)
- Balk checks (5% chance, check_roll=2)
- Normal jump (90%, 2d6 for steal success)

Fielding rolls show:
- Range check (1d20)
- Error total (3d6, range 3-18)
- Rare play detection (SBA: d100=1, PD: error_total=5)

Testing commands provide:
- Distribution tables with percentages
- Visual bar charts
- Expected vs observed statistics
- Average calculations

Files modified:
- terminal_client/commands.py: Added 6 new command methods
- terminal_client/repl.py: Added 6 new REPL commands with help
- terminal_client/completions.py: Added TAB completion support
This commit is contained in:
Claude 2025-11-04 13:54:51 +00:00
parent 313c2c8b5f
commit 263e1536a9
No known key found for this signature in database
3 changed files with 583 additions and 0 deletions

View File

@ -666,6 +666,315 @@ class GameCommands:
logger.exception("Manual outcome error") logger.exception("Manual outcome error")
return False 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 # Singleton instance
game_commands = GameCommands() game_commands = GameCommands()

View File

@ -41,6 +41,9 @@ class CompletionHelper:
'bp_homerun', 'bp_single', 'bp_flyout', 'bp_lineout' 'bp_homerun', 'bp_single', 'bp_flyout', 'bp_lineout'
] ]
# Valid positions for fielding rolls
VALID_POSITIONS = ['P', 'C', '1B', '2B', '3B', 'SS', 'LF', 'CF', 'RF']
@staticmethod @staticmethod
def filter_completions(text: str, options: List[str]) -> List[str]: def filter_completions(text: str, options: List[str]) -> List[str]:
""" """
@ -259,6 +262,87 @@ class GameREPLCompletions:
return [] return []
# ==================== New Command Completions ====================
def complete_roll_jump(self, text: str, line: str, begidx: int, endidx: int) -> List[str]:
"""
Complete roll_jump command.
Usage: roll_jump [league]
"""
return self.completion_helper.filter_completions(
text, self.completion_helper.VALID_LEAGUES
)
def complete_test_jump(self, text: str, line: str, begidx: int, endidx: int) -> List[str]:
"""
Complete test_jump command.
Usage: test_jump [count] [league]
"""
parts = line.split()
num_args = len(parts) - 1 # Exclude command name
if num_args == 0 or (num_args == 1 and not text):
# Suggest common counts
common_counts = ['10', '50', '100', '500', '1000']
return self.completion_helper.filter_completions(text, common_counts)
elif num_args == 1 or (num_args == 2 and not text):
# Suggest leagues
return self.completion_helper.filter_completions(
text, self.completion_helper.VALID_LEAGUES
)
return []
def complete_roll_fielding(self, text: str, line: str, begidx: int, endidx: int) -> List[str]:
"""
Complete roll_fielding command.
Usage: roll_fielding <position> [league]
"""
parts = line.split()
num_args = len(parts) - 1 # Exclude command name
if num_args == 0 or (num_args == 1 and not text):
# Suggest positions
return self.completion_helper.filter_completions(
text, self.completion_helper.VALID_POSITIONS
)
elif num_args == 1 or (num_args == 2 and not text):
# Suggest leagues
return self.completion_helper.filter_completions(
text, self.completion_helper.VALID_LEAGUES
)
return []
def complete_test_fielding(self, text: str, line: str, begidx: int, endidx: int) -> List[str]:
"""
Complete test_fielding command.
Usage: test_fielding <position> [count] [league]
"""
parts = line.split()
num_args = len(parts) - 1 # Exclude command name
if num_args == 0 or (num_args == 1 and not text):
# Suggest positions
return self.completion_helper.filter_completions(
text, self.completion_helper.VALID_POSITIONS
)
elif num_args == 1 or (num_args == 2 and not text):
# Suggest common counts
common_counts = ['10', '50', '100', '500', '1000']
return self.completion_helper.filter_completions(text, common_counts)
elif num_args == 2 or (num_args == 3 and not text):
# Suggest leagues
return self.completion_helper.filter_completions(
text, self.completion_helper.VALID_LEAGUES
)
return []
# ==================== Helper Methods ==================== # ==================== Helper Methods ====================
def completedefault(self, text: str, line: str, begidx: int, endidx: int) -> List[str]: def completedefault(self, text: str, line: str, begidx: int, endidx: int) -> List[str]:

View File

@ -676,6 +676,196 @@ Press Ctrl+D or type 'quit' to exit.
"""Show detailed help for clear command.""" """Show detailed help for clear command."""
show_help('clear') show_help('clear')
# ==================== Interrupt Play Commands ====================
def do_force_wild_pitch(self, arg):
"""
Force a wild pitch interrupt play.
Usage: force_wild_pitch
Wild pitch advances all runners one base. This is an interrupt play
(pa=0) and does not count as a plate appearance.
Example:
force_wild_pitch # Runner on 2nd advances to 3rd
"""
async def _force_wild_pitch():
try:
gid = self._ensure_game()
await self._ensure_game_loaded(gid)
await game_commands.force_wild_pitch(gid)
except ValueError:
pass # Already printed error
self._run_async(_force_wild_pitch())
def do_force_passed_ball(self, arg):
"""
Force a passed ball interrupt play.
Usage: force_passed_ball
Passed ball advances all runners one base. This is an interrupt play
(pa=0) and does not count as a plate appearance.
Example:
force_passed_ball # Runner on 1st advances to 2nd
"""
async def _force_passed_ball():
try:
gid = self._ensure_game()
await self._ensure_game_loaded(gid)
await game_commands.force_passed_ball(gid)
except ValueError:
pass # Already printed error
self._run_async(_force_passed_ball())
# ==================== Jump Roll Testing Commands ====================
def do_roll_jump(self, arg):
"""
Roll jump dice for stolen base testing.
Usage: roll_jump [league]
Jump roll components:
- 1d20 check roll (1=pickoff, 2=balk, 3+=normal)
- 2d6 for normal jump (if check >= 3)
- 1d20 resolution (if check == 1 or 2)
Arguments:
league 'sba' or 'pd' (default: sba)
Examples:
roll_jump # Roll for SBA league
roll_jump pd # Roll for PD league
The jump roll determines steal attempt outcomes:
- Pickoff check (5%): Pitcher attempts to pick off runner
- Balk check (5%): Pitcher may commit balk
- Normal jump (90%): Use 2d6 total for steal success check
"""
parts = arg.split()
league = parts[0] if parts else 'sba'
if league not in ['sba', 'pd']:
display.print_error("League must be 'sba' or 'pd'")
return
game_commands.roll_jump(league, self.current_game_id)
def do_test_jump(self, arg):
"""
Test jump roll distribution.
Usage: test_jump [count] [league]
Rolls N jump rolls and displays distribution statistics including:
- Pickoff check frequency (expected: 5%)
- Balk check frequency (expected: 5%)
- Normal jump frequency (expected: 90%)
- Jump total distribution (2d6, expected avg: 7.0)
Arguments:
count Number of rolls (default: 10)
league 'sba' or 'pd' (default: sba)
Examples:
test_jump # 10 rolls for SBA
test_jump 100 # 100 rolls for SBA
test_jump 50 pd # 50 rolls for PD
"""
parts = arg.split()
count = int(parts[0]) if parts else 10
league = parts[1] if len(parts) > 1 else 'sba'
if league not in ['sba', 'pd']:
display.print_error("League must be 'sba' or 'pd'")
return
game_commands.test_jump(count, league)
# ==================== Fielding Roll Testing Commands ====================
def do_roll_fielding(self, arg):
"""
Roll fielding check dice for testing.
Usage: roll_fielding <position> [league]
Fielding roll components:
- 1d20: Range check
- 3d6: Error total (3-18)
- 1d100: Rare play check
Arguments:
position P, C, 1B, 2B, 3B, SS, LF, CF, RF (required)
league 'sba' or 'pd' (default: sba)
Examples:
roll_fielding SS # Roll for shortstop (SBA)
roll_fielding P pd # Roll for pitcher (PD)
roll_fielding CF # Roll for center field
Rare plays:
- SBA: d100 = 1 (1% chance)
- PD: error_total = 5 (~2.78% chance)
"""
parts = arg.split()
if not parts:
display.print_error("Usage: roll_fielding <position> [league]")
display.console.print("[dim]Valid positions: P, C, 1B, 2B, 3B, SS, LF, CF, RF[/dim]")
return
position = parts[0]
league = parts[1] if len(parts) > 1 else 'sba'
if league not in ['sba', 'pd']:
display.print_error("League must be 'sba' or 'pd'")
return
game_commands.roll_fielding(position, league, self.current_game_id)
def do_test_fielding(self, arg):
"""
Test fielding roll distribution for a position.
Usage: test_fielding <position> [count] [league]
Rolls N fielding rolls and displays distribution statistics including:
- Rare play frequency
- Average range roll (d20, expected: 10.5)
- Average error total (3d6, expected: 10.5)
- Error total distribution (3-18)
Arguments:
position P, C, 1B, 2B, 3B, SS, LF, CF, RF (required)
count Number of rolls (default: 10)
league 'sba' or 'pd' (default: sba)
Examples:
test_fielding SS # 10 rolls for shortstop (SBA)
test_fielding 2B 100 # 100 rolls for second base
test_fielding CF 50 pd # 50 rolls for center field (PD)
"""
parts = arg.split()
if not parts:
display.print_error("Usage: test_fielding <position> [count] [league]")
display.console.print("[dim]Valid positions: P, C, 1B, 2B, 3B, SS, LF, CF, RF[/dim]")
return
position = parts[0]
count = int(parts[1]) if len(parts) > 1 else 10
league = parts[2] if len(parts) > 2 else 'sba'
if league not in ['sba', 'pd']:
display.print_error("League must be 'sba' or 'pd'")
return
game_commands.test_fielding(position, count, league)
# ==================== REPL Control Commands ==================== # ==================== REPL Control Commands ====================
def do_clear(self, arg): def do_clear(self, arg):