Backend refactor complete - removed all deprecated parameters and replaced with clean action-based system. Changes: - OffensiveDecision model: Added 'action' field (6 choices), removed deprecated 'hit_and_run' and 'bunt_attempt' boolean fields - Validators: Added action-specific validation (squeeze_bunt, check_jump, sac_bunt, hit_and_run situational constraints) - WebSocket handler: Updated submit_offensive_decision to use action field - Terminal client: Updated CLI, REPL, arg parser, and display for actions - Tests: Updated all 739 unit tests (100% passing) Action field values: - swing_away (default) - steal (requires steal_attempts parameter) - check_jump (requires runner on base) - hit_and_run (requires runner on base) - sac_bunt (cannot use with 2 outs) - squeeze_bunt (requires R3, not with bases loaded, not with 2 outs) Breaking changes: - Removed: hit_and_run boolean → use action="hit_and_run" - Removed: bunt_attempt boolean → use action="sac_bunt" or "squeeze_bunt" - Removed: approach field → use action field Files modified: - app/models/game_models.py - app/core/validators.py - app/websocket/handlers.py - terminal_client/main.py - terminal_client/arg_parser.py - terminal_client/commands.py - terminal_client/repl.py - terminal_client/display.py - tests/unit/models/test_game_models.py - tests/unit/core/test_validators.py - tests/unit/terminal_client/test_arg_parser.py - tests/unit/terminal_client/test_commands.py Test results: 739/739 passing (100%) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
411 lines
13 KiB
Python
411 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('--action', default='swing_away', help='Offensive action (swing_away, steal, check_jump, hit_and_run, sac_bunt, squeeze_bunt)')
|
|
@click.option('--steal', default=None, help='Comma-separated bases to steal (e.g., 2,3)')
|
|
def offensive(game_id, action, steal):
|
|
"""
|
|
Submit offensive decision.
|
|
|
|
Session 2 Update (2025-01-14): Changed --approach to --action, removed deprecated flags.
|
|
Valid actions: swing_away (default), steal, check_jump, hit_and_run, sac_bunt, squeeze_bunt
|
|
Valid steal: comma-separated bases (2, 3, 4) - used only when action="steal"
|
|
"""
|
|
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,
|
|
action=action,
|
|
steal_attempts=steal_list
|
|
)
|
|
|
|
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()
|