  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
