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>
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
- 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
- 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
- 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
- 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
- Create the new commands.py file with the shared logic
- Update imports in both repl.py and main.py
- Replace duplicated implementations with calls to game_commands
- Test thoroughly to ensure no functionality is broken
- 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