strat-gameplay-webapp/backend/terminal_client/commands.py
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

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()