strat-gameplay-webapp/backend/terminal_client/main.py
Cal Corum e165b449f5 CLAUDE: Refactor offensive decisions - replace approach with action field
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>
2025-11-14 15:07:54 -06:00

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