Terminal Client Improvement Plan - Part 1: Extract Shared Command Logic Overview Reduce code duplication between repl.py and main.py by extracting shared command implementations into a separate module. Files to Create 1. Create backend/terminal_client/commands.py """ 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 """ import logging from uuid import UUID, uuid4 from typing import Optional, List, Tuple, Dict, Any from app.core.game_engine import game_engine from app.core.state_manager import state_manager from app.models.game_models import DefensiveDecision, OffensiveDecision 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) -> bool: """ Resolve the current play. Returns: True if successful, False otherwise """ try: result = await game_engine.resolve_play(game_id) 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 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 # Singleton instance game_commands = GameCommands() Files to Update 2. Update backend/terminal_client/repl.py (simplified version) ● # At the top, add import from terminal_client.commands import game_commands # Replace the do_new_game method with: def do_new_game(self, arg): """ Create a new game with lineups and start it. Usage: new_game [--league sba|pd] [--home-team N] [--away-team N] """ async def _new_game(): # Parse arguments (this will be improved in Part 2) args = arg.split() league = 'sba' home_team = 1 away_team = 2 i = 0 while i < len(args): if args[i] == '--league' and i + 1 < len(args): league = args[i + 1] i += 2 elif args[i] == '--home-team' and i + 1 < len(args): home_team = int(args[i + 1]) i += 2 elif args[i] == '--away-team' and i + 1 < len(args): away_team = int(args[i + 1]) i += 2 else: i += 1 # Use shared command gid, success = await game_commands.create_new_game( league=league, home_team=home_team, away_team=away_team, set_current=True ) if success: self.current_game_id = gid self._run_async(_new_game()) # Similar pattern for other commands - replace implementation with game_commands calls 3. Update backend/terminal_client/main.py (simplified version) # At the top, add import from terminal_client.commands import game_commands # Replace the new_game command with: @cli.command('new-game') @click.option('--league', default='sba', help='League (sba or pd)') @click.option('--home-team', default=1, help='Home team ID') @click.option('--away-team', default=2, help='Away team ID') def new_game(league, home_team, away_team): """Create a new game with lineups and start it immediately.""" async def _new_game(): await game_commands.create_new_game( league=league, home_team=home_team, away_team=away_team, set_current=True ) asyncio.run(_new_game()) # Similar pattern for other commands Testing Plan 4. Create backend/tests/unit/terminal_client/test_commands.py """ Unit tests for terminal client shared commands. """ import pytest from uuid import uuid4 from unittest.mock import AsyncMock, MagicMock, patch from terminal_client.commands import GameCommands from app.models.game_models import GameState @pytest.fixture def game_commands(): """Create GameCommands instance with mocked dependencies.""" commands = GameCommands() commands.db_ops = AsyncMock() return commands @pytest.mark.asyncio async def test_create_new_game_success(game_commands): """Test successful game creation.""" game_id = uuid4() with patch('terminal_client.commands.state_manager') as mock_sm: with patch('terminal_client.commands.game_engine') as mock_ge: with patch('terminal_client.commands.uuid4', return_value=game_id): # Setup mocks mock_state = GameState( game_id=game_id, league_id='sba', home_team_id=1, away_team_id=2, inning=1, half='top' ) mock_sm.create_game = AsyncMock(return_value=mock_state) mock_ge.start_game = AsyncMock(return_value=mock_state) # Execute gid, success = await game_commands.create_new_game() # Verify assert success is True assert gid == game_id mock_sm.create_game.assert_called_once() mock_ge.start_game.assert_called_once() @pytest.mark.asyncio async def test_submit_defensive_decision_success(game_commands): """Test successful defensive decision submission.""" game_id = uuid4() with patch('terminal_client.commands.game_engine') as mock_ge: mock_state = MagicMock() mock_ge.submit_defensive_decision = AsyncMock(return_value=mock_state) success = await game_commands.submit_defensive_decision( game_id=game_id, alignment='shifted_left', hold_runners=[1, 2] ) assert success is True mock_ge.submit_defensive_decision.assert_called_once() # Add more tests... Migration Steps 1. Create the new commands.py file with the shared logic 2. Update imports in both repl.py and main.py 3. Replace duplicated implementations with calls to game_commands 4. Test thoroughly to ensure no functionality is broken 5. Remove old duplicated code once confirmed working Benefits - -500 lines of duplicated code removed - Single source of truth for command logic - Easier to maintain and add new features - Consistent behavior between REPL and CLI modes - Better testability with isolated command logic --- Continue to Part 2? The next part will cover improving argument parsing with shlex. Todos ☐ Extract shared command logic to reduce duplication ☐ Implement robust argument parsing with shlex ☐ Add tab completion for REPL commands ☐ Create detailed help system for REPL ☐ Add player name caching and display (future) ☐ Write tests for terminal client improvements