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>
317 lines
10 KiB
Python
317 lines
10 KiB
Python
"""
|
|
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 asyncio
|
|
import logging
|
|
from uuid import UUID, uuid4
|
|
from typing import Optional, List, Tuple
|
|
|
|
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()
|