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>
414 lines
13 KiB
Python
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()
|