strat-gameplay-webapp/backend/terminal_client/update_docs/phase_3.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

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

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

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

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

  1. Faster typing: Complete commands/options with fewer keystrokes
  2. Discovery: See available options without checking help
  3. Error prevention: Can't tab-complete to invalid values
  4. Better UX: Feels like a professional CLI tool (like git, docker, etc.)
  5. Learn by doing: Users discover options through exploration