This commit includes cleanup from model refactoring and terminal client
modularization for better code organization and maintainability.
## Game Models Refactor
**Removed RunnerState class:**
- Eliminated separate RunnerState model (was redundant)
- Replaced runners: List[RunnerState] with direct base references:
- on_first: Optional[LineupPlayerState]
- on_second: Optional[LineupPlayerState]
- on_third: Optional[LineupPlayerState]
- Updated helper methods:
- get_runner_at_base() now returns LineupPlayerState directly
- get_all_runners() returns List[Tuple[int, LineupPlayerState]]
- is_runner_on_X() simplified to direct None checks
**Benefits:**
- Matches database structure (plays table has on_first_id, etc.)
- Simpler state management (direct references vs list management)
- Better type safety (LineupPlayerState vs generic runner)
- Easier to work with in game engine logic
**Updated files:**
- app/models/game_models.py - Removed RunnerState, updated GameState
- app/core/play_resolver.py - Use get_all_runners() instead of state.runners
- app/core/validators.py - Updated runner access patterns
- tests/unit/models/test_game_models.py - Updated test assertions
- tests/unit/core/test_play_resolver.py - Updated test data
- tests/unit/core/test_validators.py - Updated test data
## Terminal Client Refactor
**Modularization (DRY principle):**
Created separate modules for better code organization:
1. **terminal_client/commands.py** (10,243 bytes)
- Shared command functions for game operations
- Used by both CLI (main.py) and REPL (repl.py)
- Functions: submit_defensive_decision, submit_offensive_decision,
resolve_play, quick_play_sequence
- Single source of truth for command logic
2. **terminal_client/arg_parser.py** (7,280 bytes)
- Centralized argument parsing and validation
- Handles defensive/offensive decision arguments
- Validates formats (alignment, depths, hold runners, steal attempts)
3. **terminal_client/completions.py** (10,357 bytes)
- TAB completion support for REPL mode
- Command completions, option completions, dynamic completions
- Game ID completions, defensive/offensive option suggestions
4. **terminal_client/help_text.py** (10,839 bytes)
- Centralized help text and command documentation
- Detailed command descriptions
- Usage examples for all commands
**Updated main modules:**
- terminal_client/main.py - Simplified by using shared commands module
- terminal_client/repl.py - Cleaner with shared functions and completions
**Benefits:**
- DRY: Behavior consistent between CLI and REPL modes
- Maintainability: Changes in one place affect both interfaces
- Testability: Can test commands module independently
- Organization: Clear separation of concerns
## Documentation
**New files:**
- app/models/visual_model_relationships.md
- Visual documentation of model relationships
- Helps understand data flow between models
- terminal_client/update_docs/ (6 phase documentation files)
- Phased documentation for terminal client evolution
- Historical context for implementation decisions
## Tests
**New test files:**
- tests/unit/terminal_client/__init__.py
- tests/unit/terminal_client/test_arg_parser.py
- tests/unit/terminal_client/test_commands.py
- tests/unit/terminal_client/test_completions.py
- tests/unit/terminal_client/test_help_text.py
**Updated tests:**
- Integration tests updated for new runner model
- Unit tests updated for model changes
- All tests passing with new structure
## Summary
- ✅ Simplified game state model (removed RunnerState)
- ✅ Better alignment with database structure
- ✅ Modularized terminal client (DRY principle)
- ✅ Shared command logic between CLI and REPL
- ✅ Comprehensive test coverage
- ✅ Improved documentation
Total changes: 26 files modified/created
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
20 KiB
● Terminal Client Improvement Plan - Part 3: Tab Completion
Overview
Add intelligent tab completion to the REPL for commands, options, and values. This significantly improves the developer experience by reducing typing and providing discovery of available options.
Files to Create
- Create backend/terminal_client/completions.py
""" 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']
@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 name
if text.startswith('--') or (not text and line.endswith(' ')):
return self.completion_helper.complete_option(text, line, available_options)
# Check if we're completing an option value
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
)
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 name
if text.startswith('--') or (not text and line.endswith(' ')):
return self.completion_helper.complete_option(text, line, available_options)
# Check if we're completing an option value
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
)
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 name
if text.startswith('--') or (not text and line.endswith(' ')):
return self.completion_helper.complete_option(text, line, available_options)
# Check if we're completing an option value
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
)
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 []
# ==================== 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 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 -- --league --home-team --away-team
⚾ > new_game --league sba pd
⚾ > defensive -- --alignment --infield --outfield --hold
⚾ > defensive --alignment normal shifted_left shifted_right extreme_shift
⚾ > defensive --hold 1, 1,2 1,3
⚾ > offensive --approach normal contact power patient
⚾ > use_game [shows all active game UUIDs]
⚾ > quick_play 1 5 10 27 50 100 """
Files to Update
- Update backend/terminal_client/repl.py
Add import at the top
from terminal_client.completions import GameREPLCompletions
Update class definition to include mixin
class GameREPL(GameREPLCompletions, cmd.Cmd): """Interactive REPL for game engine testing."""
intro = """
╔══════════════════════════════════════════════════════════════════════════════╗ ║ Paper Dynasty Game Engine - Terminal Client ║ ║ Interactive Mode ║ ╚══════════════════════════════════════════════════════════════════════════════╝
Type 'help' or '?' to list commands. Type 'help ' for command details. Type 'quit' or 'exit' to leave. Use TAB for auto-completion of commands and options.
Quick start: new_game Create and start a new game with test lineups defensive Submit defensive decision offensive Submit offensive decision resolve Resolve the current play status Show current game state quick_play 10 Auto-play 10 plays
Note: Use underscores in command names (e.g., 'new_game' not 'new-game')
""" prompt = '⚾ > '
def __init__(self):
# Initialize both parent classes
cmd.Cmd.__init__(self)
GameREPLCompletions.__init__(self)
self.current_game_id: Optional[UUID] = None
self.db_ops = DatabaseOperations()
# Create persistent event loop for entire REPL session
self.loop = asyncio.new_event_loop()
asyncio.set_event_loop(self.loop)
# Try to load current game from config
saved_game = Config.get_current_game()
if saved_game:
self.current_game_id = saved_game
display.print_info(f"Loaded saved game: {saved_game}")
# ... rest of the methods stay the same ...
Testing Plan
- Create backend/tests/unit/terminal_client/test_completions.py
""" Unit tests for tab completion system. """ import pytest from unittest.mock import MagicMock, patch
from terminal_client.completions import CompletionHelper, GameREPLCompletions
class TestCompletionHelper: """Tests for CompletionHelper utility class."""
def test_filter_completions_exact_match(self):
"""Test filtering with exact match."""
options = ['apple', 'apricot', 'banana']
result = CompletionHelper.filter_completions('app', options)
assert result == ['apple', 'apricot']
def test_filter_completions_no_match(self):
"""Test filtering with no matches."""
options = ['apple', 'apricot', 'banana']
result = CompletionHelper.filter_completions('cherry', options)
assert result == []
def test_filter_completions_empty_text(self):
"""Test filtering with empty text returns all."""
options = ['apple', 'apricot', 'banana']
result = CompletionHelper.filter_completions('', options)
assert result == options
def test_complete_option_with_prefix(self):
"""Test completing option with -- prefix."""
available = ['league', 'home-team', 'away-team']
result = CompletionHelper.complete_option('--le', 'cmd --le', available)
assert result == ['--league']
def test_complete_option_show_all(self):
"""Test showing all options when text is empty."""
available = ['league', 'home-team']
result = CompletionHelper.complete_option('', 'cmd ', available)
assert set(result) == {'--league', '--home-team'}
def test_get_current_option_simple(self):
"""Test getting current option from simple line."""
line = 'defensive --alignment '
result = CompletionHelper.get_current_option(line, len(line))
assert result == 'alignment'
def test_get_current_option_multiple(self):
"""Test getting current option with multiple options."""
line = 'defensive --infield normal --alignment '
result = CompletionHelper.get_current_option(line, len(line))
assert result == 'alignment'
def test_get_current_option_none(self):
"""Test getting current option when none present."""
line = 'defensive '
result = CompletionHelper.get_current_option(line, len(line))
assert result is None
def test_get_current_option_hyphen_to_underscore(self):
"""Test option name converts hyphens to underscores."""
line = 'new_game --home-team '
result = CompletionHelper.get_current_option(line, len(line))
assert result == 'home_team'
class TestGameREPLCompletions: """Tests for GameREPLCompletions mixin."""
@pytest.fixture
def repl_completions(self):
"""Create GameREPLCompletions instance."""
return GameREPLCompletions()
def test_complete_new_game_options(self, repl_completions):
"""Test completing new_game options."""
result = repl_completions.complete_new_game(
'--', 'new_game --', 9, 11
)
assert '--league' in result
assert '--home-team' in result
assert '--away-team' in result
def test_complete_new_game_league_value(self, repl_completions):
"""Test completing league value."""
result = repl_completions.complete_new_game(
's', 'new_game --league s', 9, 20
)
assert 'sba' in result
assert 'pd' not in result
def test_complete_defensive_alignment(self, repl_completions):
"""Test completing defensive alignment values."""
result = repl_completions.complete_defensive(
'shift', 'defensive --alignment shift', 10, 30
)
assert 'shifted_left' in result
assert 'shifted_right' in result
assert 'normal' not in result
def test_complete_defensive_hold_bases(self, repl_completions):
"""Test completing hold bases."""
result = repl_completions.complete_defensive(
'1,', 'defensive --hold 1,', 10, 19
)
assert '1,2' in result
assert '1,3' in result
def test_complete_offensive_approach(self, repl_completions):
"""Test completing offensive approach values."""
result = repl_completions.complete_offensive(
'p', 'offensive --approach p', 10, 22
)
assert 'power' in result
assert 'patient' in result
assert 'normal' not in result
def test_complete_offensive_steal_bases(self, repl_completions):
"""Test completing steal bases."""
result = repl_completions.complete_offensive(
'', 'offensive --steal ', 10, 18
)
assert '2' in result
assert '3' in result
assert '1' not in result # Can't steal first
def test_complete_quick_play_counts(self, repl_completions):
"""Test completing quick_play with common counts."""
result = repl_completions.complete_quick_play(
'1', 'quick_play 1', 11, 12
)
assert '1' in result
assert '10' in result
assert '100' in result
@patch('terminal_client.completions.state_manager')
def test_complete_use_game_with_games(self, mock_sm, repl_completions):
"""Test completing use_game with active games."""
from uuid import uuid4
game_id1 = uuid4()
game_id2 = uuid4()
mock_sm.list_games.return_value = [game_id1, game_id2]
result = repl_completions.complete_use_game(
str(game_id1)[:8], f'use_game {str(game_id1)[:8]}', 9, 17
)
# Should return the matching game ID
assert any(str(game_id1) in r for r in result)
User Experience Improvements
Before (No Tab Completion): ⚾ > defensive --alignment shifted_left # Must type entire word ⚾ > offensive --approach power # Must remember exact spelling ⚾ > new_game --league pd --home-team 5 # Must know all options
After (With Tab Completion): ⚾ > def → defensive ⚾ > defensive --a → defensive --alignment ⚾ > defensive --alignment sh → defensive --alignment shifted_ ⚾ > defensive --alignment shifted_ shifted_left shifted_right
⚾ > off → offensive ⚾ > offensive --app → offensive --approach ⚾ > offensive --approach p power patient
⚾ > new_game -- --league --home-team --away-team
⚾ > use_game [shows all active game IDs - can copy/paste or select]
Benefits
- Faster typing: Complete commands/options with fewer keystrokes
- Discovery: See available options without checking help
- Error prevention: Can't tab-complete to invalid values
- Better UX: Feels like a professional CLI tool (like git, docker, etc.)
- Learn by doing: Users discover options through exploration