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

414 lines
13 KiB
Python

"""
Terminal client main entry point - Click CLI application.
Direct GameEngine testing without WebSockets.
Usage:
python -m terminal_client start-game --league sba
python -m terminal_client defensive --alignment normal
python -m terminal_client offensive --approach power
python -m terminal_client resolve
python -m terminal_client status
Author: Claude
Date: 2025-10-26
"""
import logging
import asyncio
from uuid import UUID, uuid4
from typing import Optional
import click
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
from terminal_client.commands import game_commands
# Setup logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(f'{__name__}.main')
def set_current_game(game_id: UUID) -> None:
"""Set the current game ID in persistent config."""
Config.set_current_game(game_id)
display.print_info(f"Current game set to: {game_id}")
def get_current_game() -> UUID:
"""Get the current game ID from persistent config or raise error."""
game_id = Config.get_current_game()
if game_id is None:
display.print_error("No current game set. Use 'new-game' or 'use-game' first.")
raise click.Abort()
return game_id
@click.group()
def cli():
"""Terminal UI for testing game engine directly."""
pass
@cli.command('start-game')
@click.option('--league', default='sba', help='League (sba or pd)')
@click.option('--game-id', default=None, help='Game UUID (auto-generated if not provided)')
@click.option('--home-team', default=1, help='Home team ID')
@click.option('--away-team', default=2, help='Away team ID')
def start_game(league, game_id, home_team, away_team):
"""Start a new game and transition to active."""
async def _start():
# Generate or parse game ID
gid = UUID(game_id) if game_id else uuid4()
# Create game in state manager
state = await state_manager.create_game(
game_id=gid,
league_id=league,
home_team_id=home_team,
away_team_id=away_team
)
display.print_success(f"Game created: {gid}")
display.print_info(f"League: {league}, Home: {home_team}, Away: {away_team}")
# Set as current game
set_current_game(gid)
# Start the game
try:
state = await game_engine.start_game(gid)
display.print_success(f"Game started - Inning {state.inning} {state.half}")
display.display_game_state(state)
except Exception as e:
display.print_error(f"Failed to start game: {e}")
raise click.Abort()
asyncio.run(_start())
@cli.command()
@click.option('--game-id', default=None, help='Game UUID (uses current if not provided)')
@click.option('--alignment', default='normal', help='Defensive alignment')
@click.option('--infield', default='normal', help='Infield depth')
@click.option('--outfield', default='normal', help='Outfield depth')
@click.option('--hold', default=None, help='Comma-separated bases to hold (e.g., 1,3)')
def defensive(game_id, alignment, infield, outfield, hold):
"""
Submit defensive decision.
Valid alignment: normal, shifted_left, shifted_right, extreme_shift
Valid infield: in, normal, back, double_play
Valid outfield: in, normal, back
"""
async def _defensive():
gid = UUID(game_id) if game_id else get_current_game()
# Parse hold runners
hold_list = []
if hold:
try:
hold_list = [int(b.strip()) for b in hold.split(',')]
except ValueError:
display.print_error("Invalid hold format. Use comma-separated numbers (e.g., '1,3')")
raise click.Abort()
# Use shared command
success = await game_commands.submit_defensive_decision(
game_id=gid,
alignment=alignment,
infield=infield,
outfield=outfield,
hold_runners=hold_list
)
if not success:
raise click.Abort()
asyncio.run(_defensive())
@cli.command()
@click.option('--game-id', default=None, help='Game UUID (uses current if not provided)')
@click.option('--approach', default='normal', help='Batting approach')
@click.option('--steal', default=None, help='Comma-separated bases to steal (e.g., 2,3)')
@click.option('--hit-run', is_flag=True, help='Hit-and-run play')
@click.option('--bunt', is_flag=True, help='Bunt attempt')
def offensive(game_id, approach, steal, hit_run, bunt):
"""
Submit offensive decision.
Valid approach: normal, contact, power, patient
Valid steal: comma-separated bases (2, 3, 4)
"""
async def _offensive():
gid = UUID(game_id) if game_id else get_current_game()
# Parse steal attempts
steal_list = []
if steal:
try:
steal_list = [int(b.strip()) for b in steal.split(',')]
except ValueError:
display.print_error("Invalid steal format. Use comma-separated numbers (e.g., '2,3')")
raise click.Abort()
# Use shared command
success = await game_commands.submit_offensive_decision(
game_id=gid,
approach=approach,
steal_attempts=steal_list,
hit_and_run=hit_run,
bunt_attempt=bunt
)
if not success:
raise click.Abort()
asyncio.run(_offensive())
@cli.command()
@click.option('--game-id', default=None, help='Game UUID (uses current if not provided)')
def resolve(game_id):
"""Resolve the current play (both decisions must be submitted first)."""
async def _resolve():
gid = UUID(game_id) if game_id else get_current_game()
# Use shared command
success = await game_commands.resolve_play(gid)
if not success:
raise click.Abort()
asyncio.run(_resolve())
@cli.command()
@click.option('--game-id', default=None, help='Game UUID (uses current if not provided)')
def status(game_id):
"""Display current game state."""
async def _status():
gid = UUID(game_id) if game_id else get_current_game()
# Use shared command
success = await game_commands.show_game_status(gid)
if not success:
raise click.Abort()
asyncio.run(_status())
@cli.command('box-score')
@click.option('--game-id', default=None, help='Game UUID (uses current if not provided)')
def box_score(game_id):
"""Display box score (simple version)."""
async def _box_score():
gid = UUID(game_id) if game_id else get_current_game()
# Use shared command
success = await game_commands.show_box_score(gid)
if not success:
raise click.Abort()
asyncio.run(_box_score())
@cli.command('list-games')
def list_games():
"""List all games in state manager."""
games = state_manager.list_games()
if not games:
display.print_warning("No active games in state manager")
return
display.print_info(f"Active games: {len(games)}")
for game_id in games:
display.console.print(f"{game_id}")
@cli.command('use-game')
@click.argument('game_id')
def use_game(game_id):
"""Set current game ID."""
try:
gid = UUID(game_id)
set_current_game(gid)
except ValueError:
display.print_error(f"Invalid UUID: {game_id}")
raise click.Abort()
@cli.command('quick-play')
@click.option('--count', default=1, help='Number of plays to execute')
@click.option('--game-id', default=None, help='Game UUID (uses current if not provided)')
def quick_play(count, game_id):
"""
Quick play mode - submit default decisions and resolve multiple plays.
Useful for rapidly advancing the game for testing.
"""
async def _quick_play():
gid = UUID(game_id) if game_id else get_current_game()
# Use shared command
await game_commands.quick_play_rounds(gid, count)
asyncio.run(_quick_play())
@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 (all-in-one).
This is a convenience command that combines:
1. Creating game state
2. Setting up test lineups
3. Starting the game
Perfect for rapid testing!
"""
async def _new_game():
# Use shared command
await game_commands.create_new_game(
league=league,
home_team=home_team,
away_team=away_team,
set_current=True
)
asyncio.run(_new_game())
@cli.command('setup-game')
@click.option('--game-id', default=None, help='Game UUID (uses current if not provided)')
@click.option('--league', default='sba', help='League (sba or pd)')
def setup_game(game_id, league):
"""
Create test lineups for both teams to allow game to start.
Generates 9 players per team with proper positions and batting order.
Uses mock player/card IDs for testing.
"""
async def _setup():
gid = UUID(game_id) if game_id else get_current_game()
db_ops = DatabaseOperations()
# Standard defensive positions
positions = ['P', 'C', '1B', '2B', '3B', 'SS', 'LF', 'CF', 'RF']
try:
# Get game to determine team IDs
state = await game_engine.get_game_state(gid)
if not state:
display.print_error(f"Game {gid} not found")
raise click.Abort()
display.print_info(f"Setting up lineups for game {gid}")
display.print_info(f"League: {league}, Home Team: {state.home_team_id}, Away Team: {state.away_team_id}")
# Create lineups for both teams
for team_id in [state.home_team_id, state.away_team_id]:
team_name = "Home" if team_id == state.home_team_id else "Away"
display.print_info(f"Creating {team_name} team lineup (Team ID: {team_id})...")
for i, position in enumerate(positions, start=1):
batting_order = i
if league == 'sba':
# SBA uses player_id
player_id = (team_id * 100) + i # Generate unique player IDs
await db_ops.add_sba_lineup_player(
game_id=gid,
team_id=team_id,
player_id=player_id,
position=position,
batting_order=batting_order,
is_starter=True
)
display.console.print(f" ✓ Added {position} (Player #{player_id}) - Batting {batting_order}")
else:
# PD uses card_id
card_id = (team_id * 100) + i # Generate unique card IDs
await db_ops.add_pd_lineup_card(
game_id=gid,
team_id=team_id,
card_id=card_id,
position=position,
batting_order=batting_order,
is_starter=True
)
display.console.print(f" ✓ Added {position} (Card #{card_id}) - Batting {batting_order}")
display.print_success(f"Lineups created successfully!")
display.print_info("You can now start the game with: python -m terminal_client start-game --game-id <uuid>")
display.print_info("Or if this is your current game, just run: python -m terminal_client start-game")
except Exception as e:
display.print_error(f"Failed to setup game: {e}")
logger.exception("Setup error")
raise click.Abort()
asyncio.run(_setup())
@cli.command('config')
@click.option('--clear', is_flag=True, help='Clear current game from config')
def config_cmd(clear):
"""
Show or manage terminal client configuration.
Displays config file location and current game.
Use --clear to reset the current game.
"""
config_path = Config.get_config_path()
display.print_info(f"Config file: {config_path}")
if clear:
Config.clear_current_game()
display.print_success("Current game cleared")
return
current_game = Config.get_current_game()
if current_game:
display.console.print(f"\n[green]Current game:[/green] {current_game}")
else:
display.console.print("\n[yellow]No current game set[/yellow]")
@cli.command('help-cmd')
@click.argument('command', required=False)
def show_cli_help(command):
"""
Show help for terminal client commands.
Usage:
python -m terminal_client help-cmd # Show all commands
python -m terminal_client help-cmd new-game # Show help for specific command
"""
from terminal_client.help_text import show_help
# Convert hyphenated command to underscore for lookup
if command:
command = command.replace('-', '_')
show_help(command)
if __name__ == "__main__":
cli()