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>
502 lines
16 KiB
Markdown
502 lines
16 KiB
Markdown
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
|