strat-gameplay-webapp/backend/terminal_client/update_docs/phase_1.md
Cal Corum 1c32787195 CLAUDE: Refactor game models and modularize terminal client
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>
2025-10-28 14:16:38 -05:00

16 KiB

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

  1. 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

  1. 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

  1. 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