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
421 lines
14 KiB
Python
421 lines
14 KiB
Python
"""
|
|
Tab completion support for terminal client REPL.
|
|
|
|
Provides intelligent completion for commands, options, and values
|
|
using Python's cmd module completion hooks.
|
|
|
|
Author: Claude
|
|
Date: 2025-10-27
|
|
"""
|
|
import logging
|
|
from typing import List, Optional
|
|
|
|
logger = logging.getLogger(f'{__name__}.completions')
|
|
|
|
|
|
class CompletionHelper:
|
|
"""Helper class for generating tab completions."""
|
|
|
|
# Valid values for common options
|
|
VALID_LEAGUES = ['sba', 'pd']
|
|
VALID_ALIGNMENTS = ['normal', 'shifted_left', 'shifted_right', 'extreme_shift']
|
|
VALID_INFIELD_DEPTHS = ['in', 'normal', 'back', 'double_play']
|
|
VALID_OUTFIELD_DEPTHS = ['in', 'normal', 'back']
|
|
VALID_APPROACHES = ['normal', 'contact', 'power', 'patient']
|
|
|
|
# Valid bases for stealing/holding
|
|
VALID_BASES = ['1', '2', '3']
|
|
|
|
# Valid PlayOutcome values for resolve_with command
|
|
VALID_OUTCOMES = [
|
|
'strikeout',
|
|
'groundball_a', 'groundball_b', 'groundball_c',
|
|
'flyout_a', 'flyout_b', 'flyout_c',
|
|
'lineout', 'popout',
|
|
'single_1', 'single_2', 'single_uncapped',
|
|
'double_2', 'double_3', 'double_uncapped',
|
|
'triple', 'homerun',
|
|
'walk', 'hbp', 'intentional_walk',
|
|
'error',
|
|
'wild_pitch', 'passed_ball', 'stolen_base', 'caught_stealing', 'balk', 'pick_off',
|
|
'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
|
|
def filter_completions(text: str, options: List[str]) -> List[str]:
|
|
"""
|
|
Filter options that start with the given text.
|
|
|
|
Args:
|
|
text: Partial text to match
|
|
options: List of possible completions
|
|
|
|
Returns:
|
|
List of matching options
|
|
"""
|
|
if not text:
|
|
return options
|
|
return [opt for opt in options if opt.startswith(text)]
|
|
|
|
@staticmethod
|
|
def complete_option(text: str, line: str, available_options: List[str]) -> List[str]:
|
|
"""
|
|
Complete option names (--option).
|
|
|
|
Args:
|
|
text: Current text being completed
|
|
line: Full command line
|
|
available_options: List of valid option names
|
|
|
|
Returns:
|
|
List of matching options with -- prefix
|
|
"""
|
|
if text.startswith('--'):
|
|
# Completing option name
|
|
prefix = text[2:]
|
|
matches = [opt for opt in available_options if opt.startswith(prefix)]
|
|
return [f'--{match}' for match in matches]
|
|
elif not text:
|
|
# Show all options
|
|
return [f'--{opt}' for opt in available_options]
|
|
return []
|
|
|
|
@staticmethod
|
|
def get_current_option(line: str, endidx: int) -> Optional[str]:
|
|
"""
|
|
Determine which option we're currently completing the value for.
|
|
|
|
Args:
|
|
line: Full command line
|
|
endidx: Current cursor position
|
|
|
|
Returns:
|
|
Option name (without --) or None
|
|
"""
|
|
# Split line up to cursor position
|
|
before_cursor = line[:endidx]
|
|
tokens = before_cursor.split()
|
|
|
|
# Look for the last --option before cursor
|
|
for i in range(len(tokens) - 1, -1, -1):
|
|
if tokens[i].startswith('--'):
|
|
return tokens[i][2:].replace('-', '_')
|
|
|
|
return None
|
|
|
|
|
|
class GameREPLCompletions:
|
|
"""Mixin class providing tab completion methods for GameREPL."""
|
|
|
|
def __init__(self):
|
|
"""Initialize completion helper."""
|
|
self.completion_helper = CompletionHelper()
|
|
|
|
# ==================== Command Completions ====================
|
|
|
|
def complete_new_game(self, text: str, line: str, begidx: int, endidx: int) -> List[str]:
|
|
"""
|
|
Complete new_game command.
|
|
|
|
Available options:
|
|
--league sba|pd
|
|
--home-team N
|
|
--away-team N
|
|
"""
|
|
available_options = ['league', 'home-team', 'away-team']
|
|
|
|
# Check if we're completing an option value first
|
|
current_option = self.completion_helper.get_current_option(line, endidx)
|
|
|
|
if current_option == 'league':
|
|
return self.completion_helper.filter_completions(
|
|
text, self.completion_helper.VALID_LEAGUES
|
|
)
|
|
|
|
# Check if we're completing an option name
|
|
if text.startswith('--'):
|
|
return self.completion_helper.complete_option(text, line, available_options)
|
|
elif not text:
|
|
return self.completion_helper.complete_option(text, line, available_options)
|
|
|
|
return []
|
|
|
|
def complete_defensive(self, text: str, line: str, begidx: int, endidx: int) -> List[str]:
|
|
"""
|
|
Complete defensive command.
|
|
|
|
Available options:
|
|
--alignment normal|shifted_left|shifted_right|extreme_shift
|
|
--infield in|normal|back|double_play
|
|
--outfield in|normal|back
|
|
--hold 1,2,3
|
|
"""
|
|
available_options = ['alignment', 'infield', 'outfield', 'hold']
|
|
|
|
# Check if we're completing an option value first
|
|
current_option = self.completion_helper.get_current_option(line, endidx)
|
|
|
|
if current_option == 'alignment':
|
|
return self.completion_helper.filter_completions(
|
|
text, self.completion_helper.VALID_ALIGNMENTS
|
|
)
|
|
elif current_option == 'infield':
|
|
return self.completion_helper.filter_completions(
|
|
text, self.completion_helper.VALID_INFIELD_DEPTHS
|
|
)
|
|
elif current_option == 'outfield':
|
|
return self.completion_helper.filter_completions(
|
|
text, self.completion_helper.VALID_OUTFIELD_DEPTHS
|
|
)
|
|
elif current_option == 'hold':
|
|
# For comma-separated values, complete the last item
|
|
if ',' in text:
|
|
prefix = text.rsplit(',', 1)[0] + ','
|
|
last_item = text.rsplit(',', 1)[1]
|
|
matches = self.completion_helper.filter_completions(
|
|
last_item, self.completion_helper.VALID_BASES
|
|
)
|
|
return [f'{prefix}{match}' for match in matches]
|
|
else:
|
|
return self.completion_helper.filter_completions(
|
|
text, self.completion_helper.VALID_BASES
|
|
)
|
|
|
|
# Check if we're completing an option name
|
|
if text.startswith('--'):
|
|
return self.completion_helper.complete_option(text, line, available_options)
|
|
elif not text:
|
|
return self.completion_helper.complete_option(text, line, available_options)
|
|
|
|
return []
|
|
|
|
def complete_offensive(self, text: str, line: str, begidx: int, endidx: int) -> List[str]:
|
|
"""
|
|
Complete offensive command.
|
|
|
|
Available options:
|
|
--approach normal|contact|power|patient
|
|
--steal 2,3
|
|
--hit-run (flag)
|
|
--bunt (flag)
|
|
"""
|
|
available_options = ['approach', 'steal', 'hit-run', 'bunt']
|
|
|
|
# Check if we're completing an option value first
|
|
current_option = self.completion_helper.get_current_option(line, endidx)
|
|
|
|
if current_option == 'approach':
|
|
return self.completion_helper.filter_completions(
|
|
text, self.completion_helper.VALID_APPROACHES
|
|
)
|
|
elif current_option == 'steal':
|
|
# Only bases 2 and 3 can be stolen
|
|
valid_steal_bases = ['2', '3']
|
|
if ',' in text:
|
|
prefix = text.rsplit(',', 1)[0] + ','
|
|
last_item = text.rsplit(',', 1)[1]
|
|
matches = self.completion_helper.filter_completions(
|
|
last_item, valid_steal_bases
|
|
)
|
|
return [f'{prefix}{match}' for match in matches]
|
|
else:
|
|
return self.completion_helper.filter_completions(
|
|
text, valid_steal_bases
|
|
)
|
|
|
|
# Check if we're completing an option name
|
|
if text.startswith('--'):
|
|
return self.completion_helper.complete_option(text, line, available_options)
|
|
elif not text:
|
|
return self.completion_helper.complete_option(text, line, available_options)
|
|
|
|
return []
|
|
|
|
def complete_use_game(self, text: str, line: str, begidx: int, endidx: int) -> List[str]:
|
|
"""
|
|
Complete use_game command with available game IDs.
|
|
"""
|
|
# Import here to avoid circular dependency
|
|
from app.core.state_manager import state_manager
|
|
|
|
# Get list of active games
|
|
game_ids = state_manager.list_games()
|
|
game_id_strs = [str(gid) for gid in game_ids]
|
|
|
|
return self.completion_helper.filter_completions(text, game_id_strs)
|
|
|
|
def complete_quick_play(self, text: str, line: str, begidx: int, endidx: int) -> List[str]:
|
|
"""
|
|
Complete quick_play command with common counts.
|
|
"""
|
|
# Suggest common play counts
|
|
common_counts = ['1', '5', '10', '27', '50', '100']
|
|
|
|
# Check if completing a positional number
|
|
if text and text.isdigit():
|
|
return self.completion_helper.filter_completions(text, common_counts)
|
|
elif not text and line.strip() == 'quick_play':
|
|
return common_counts
|
|
|
|
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 ====================
|
|
|
|
def completedefault(self, text: str, line: str, begidx: int, endidx: int) -> List[str]:
|
|
"""
|
|
Default completion handler for commands without specific completers.
|
|
|
|
Provides basic option completion if line contains options.
|
|
"""
|
|
# If text starts with --, try to show common options
|
|
if text.startswith('--'):
|
|
common_options = ['help', 'verbose', 'debug']
|
|
return self.completion_helper.complete_option(text, line, common_options)
|
|
|
|
return []
|
|
|
|
def complete_resolve_with(self, text: str, line: str, begidx: int, endidx: int) -> List[str]:
|
|
"""
|
|
Complete resolve_with command with PlayOutcome values.
|
|
|
|
Usage: resolve_with <outcome>
|
|
|
|
Provides completions for all valid PlayOutcome enum values.
|
|
"""
|
|
# Complete the outcome argument (first and only argument)
|
|
return self.completion_helper.filter_completions(
|
|
text, self.completion_helper.VALID_OUTCOMES
|
|
)
|
|
|
|
def completenames(self, text: str, *ignored) -> List[str]:
|
|
"""
|
|
Override completenames to provide better command completion.
|
|
|
|
This is called when completing the first word (command name).
|
|
"""
|
|
# Get all do_* methods
|
|
dotext = 'do_' + text
|
|
commands = [name[3:] for name in self.get_names() if name.startswith(dotext)]
|
|
|
|
# Add aliases
|
|
if 'exit'.startswith(text):
|
|
commands.append('exit')
|
|
if 'quit'.startswith(text):
|
|
commands.append('quit')
|
|
|
|
return commands
|
|
|
|
|
|
# Example completion mappings for reference
|
|
COMPLETION_EXAMPLES = """
|
|
# Example Tab Completion Usage:
|
|
|
|
⚾ > new_game --<TAB>
|
|
--league --home-team --away-team
|
|
|
|
⚾ > new_game --league <TAB>
|
|
sba pd
|
|
|
|
⚾ > defensive --<TAB>
|
|
--alignment --infield --outfield --hold
|
|
|
|
⚾ > defensive --alignment <TAB>
|
|
normal shifted_left shifted_right extreme_shift
|
|
|
|
⚾ > defensive --hold 1,<TAB>
|
|
1,2 1,3
|
|
|
|
⚾ > offensive --approach <TAB>
|
|
normal contact power patient
|
|
|
|
⚾ > use_game <TAB>
|
|
[shows all active game UUIDs]
|
|
|
|
⚾ > quick_play <TAB>
|
|
1 5 10 27 50 100
|
|
"""
|