strat-gameplay-webapp/backend/terminal_client/repl.py
Cal Corum b40465ca8a CLAUDE: Add terminal client commands for manual outcome testing
New Commands:
- manual_outcome <outcome> [location] - Validates ManualOutcomeSubmission
  - Tests outcome and location validation
  - Shows which outcomes require hit location
  - Displays clear validation errors

- test_location <outcome> [handedness] [count] - Tests hit location distribution
  - Generates sample hit locations for an outcome
  - Shows distribution table with percentages
  - Validates pull rates (45% pull, 35% center, 20% opposite)
  - Supports both LHB and RHB

Implementation:
- Added validate_manual_outcome() to GameCommands class
- Added test_hit_location() to GameCommands class
- Added do_manual_outcome() to REPL
- Added do_test_location() to REPL
- Uses ManualOutcomeSubmission model from Task 3
- Uses calculate_hit_location() helper from Task 3

Testing:
- Tested manual_outcome with valid outcomes (groundball_c SS, strikeout)
- Tested manual_outcome with invalid outcome (proper error display)
- Tested test_location with groundball_c for RHB (shows distribution)
- All validation and display working correctly

Note: Full play resolution integration deferred to Week 7 Task 6 (WebSocket handlers).
      These commands validate and test the new models but don't resolve plays yet.

Files Modified:
- terminal_client/commands.py (+117 lines)
- terminal_client/repl.py (+65 lines)
2025-10-30 15:25:30 -05:00

640 lines
20 KiB
Python

"""
Interactive REPL for terminal client.
Provides an interactive shell that keeps game state in memory across commands.
Uses Python's cmd module for readline support and command completion.
Author: Claude
Date: 2025-10-26
"""
import asyncio
import logging
import cmd
from uuid import UUID, uuid4
from typing import Optional
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
from terminal_client.completions import GameREPLCompletions
from terminal_client.help_text import show_help, HelpFormatter
from terminal_client.arg_parser import (
parse_new_game_args,
parse_defensive_args,
parse_offensive_args,
parse_quick_play_args,
parse_use_game_args,
ArgumentParseError
)
logger = logging.getLogger(f'{__name__}.repl')
class GameREPL(GameREPLCompletions, cmd.Cmd):
"""Interactive REPL for game engine testing."""
intro = """
╔══════════════════════════════════════════════════════════════════════════════╗
║ Paper Dynasty Game Engine - Terminal Client ║
║ Interactive Mode ║
╚══════════════════════════════════════════════════════════════════════════════╝
Type 'help' to see all available commands.
Type 'help <command>' for detailed information about a specific command.
Use TAB for auto-completion of commands and options.
Quick start:
new_game Create and start a new game
status Show current game state
defensive Submit defensive decision
offensive Submit offensive decision
resolve Resolve the play
quick_play 10 Auto-play 10 plays
Press Ctrl+D or type 'quit' to exit.
"""
prompt = '⚾ > '
def __init__(self):
# Initialize both parent classes
cmd.Cmd.__init__(self)
GameREPLCompletions.__init__(self)
self.current_game_id: Optional[UUID] = None
self.db_ops = DatabaseOperations()
# Create persistent event loop for entire REPL session
self.loop = asyncio.new_event_loop()
asyncio.set_event_loop(self.loop)
# Try to load current game from config
saved_game = Config.get_current_game()
if saved_game:
self.current_game_id = saved_game
display.print_info(f"Loaded saved game: {saved_game}")
def _ensure_game(self) -> UUID:
"""Ensure current game is set."""
if self.current_game_id is None:
display.print_error("No current game. Use 'new_game' first.")
raise ValueError("No current game")
return self.current_game_id
async def _ensure_game_loaded(self, game_id: UUID) -> None:
"""
Ensure game is loaded in state_manager.
If game exists in database but not in memory, recover it using
state_manager.recover_game() which replays plays to rebuild state,
then prepare the next play to populate snapshot fields.
"""
# Check if already in memory
state = state_manager.get_state(game_id)
if state is not None:
return # Already loaded
# Try to recover from database
try:
display.print_info(f"Loading game {game_id} from database...")
recovered_state = await state_manager.recover_game(game_id)
if recovered_state and recovered_state.status == "active":
# Call _prepare_next_play to populate snapshot fields
# (batter_id, pitcher_id, catcher_id, on_base_code)
await game_engine._prepare_next_play(recovered_state)
logger.debug(f"Prepared snapshot for recovered game {game_id}")
display.print_success("Game loaded successfully")
except Exception as e:
display.print_error(f"Failed to load game: {e}")
logger.error(f"Game recovery failed for {game_id}: {e}", exc_info=True)
raise ValueError(f"Game {game_id} not found")
def _run_async(self, coro):
"""
Helper to run async functions using persistent event loop.
This keeps database connections alive across commands.
"""
return self.loop.run_until_complete(coro)
# ==================== Game Management Commands ====================
def do_new_game(self, arg):
"""
Create a new game with lineups and start it.
Usage: new_game [--league sba|pd] [--home-team N] [--away-team N]
Examples:
new_game
new_game --league pd
new_game --home-team 5 --away-team 3
"""
async def _new_game():
try:
# Parse arguments with robust parser
args = parse_new_game_args(arg)
# Use shared command
gid, success = await game_commands.create_new_game(
league=args['league'],
home_team=args['home_team'],
away_team=args['away_team'],
set_current=True
)
if success:
self.current_game_id = gid
except ArgumentParseError as e:
display.print_error(f"Invalid arguments: {e}")
except Exception as e:
display.print_error(f"Failed to create game: {e}")
logger.exception("New game error")
self._run_async(_new_game())
def do_defensive(self, arg):
"""
Submit defensive decision.
Usage: defensive [--alignment TYPE] [--infield DEPTH] [--outfield DEPTH] [--hold BASES]
Options:
--alignment normal, shifted_left, shifted_right, extreme_shift
--infield in, normal, back, double_play
--outfield in, normal, back
--hold Comma-separated bases (e.g., 1,3)
Examples:
defensive
defensive --alignment shifted_left
defensive --infield double_play --hold 1,3
"""
async def _defensive():
try:
gid = self._ensure_game()
await self._ensure_game_loaded(gid)
# Parse arguments with robust parser
args = parse_defensive_args(arg)
# Submit decision
await game_commands.submit_defensive_decision(
game_id=gid,
alignment=args['alignment'],
infield=args['infield'],
outfield=args['outfield'],
hold_runners=args['hold']
)
except ArgumentParseError as e:
display.print_error(f"Invalid arguments: {e}")
except ValueError:
pass # Already printed error
except Exception as e:
display.print_error(f"Failed: {e}")
logger.exception("Defensive error")
self._run_async(_defensive())
def do_offensive(self, arg):
"""
Submit offensive decision.
Usage: offensive [--approach TYPE] [--steal BASES] [--hit-run] [--bunt]
Options:
--approach normal, contact, power, patient
--steal Comma-separated bases (e.g., 2,3)
--hit-run Enable hit-and-run (flag)
--bunt Attempt bunt (flag)
Examples:
offensive
offensive --approach power
offensive --steal 2 --hit-run
"""
async def _offensive():
try:
gid = self._ensure_game()
await self._ensure_game_loaded(gid)
# Parse arguments with robust parser
args = parse_offensive_args(arg)
# Submit decision
await game_commands.submit_offensive_decision(
game_id=gid,
approach=args['approach'],
steal_attempts=args['steal'],
hit_and_run=args['hit_run'],
bunt_attempt=args['bunt']
)
except ArgumentParseError as e:
display.print_error(f"Invalid arguments: {e}")
except ValueError:
pass
except Exception as e:
display.print_error(f"Failed: {e}")
logger.exception("Offensive error")
self._run_async(_offensive())
def do_resolve(self, arg):
"""
Resolve the current play.
Usage: resolve
Both defensive and offensive decisions must be submitted first.
"""
async def _resolve():
try:
gid = self._ensure_game()
await self._ensure_game_loaded(gid)
# Use shared command
await game_commands.resolve_play(gid)
except ValueError:
pass
except Exception as e:
display.print_error(f"Failed: {e}")
logger.exception("Resolve error")
self._run_async(_resolve())
def do_list_outcomes(self, arg):
"""
List all available PlayOutcome values for manual outcome testing.
Usage: list_outcomes
Displays a categorized table of all play outcomes that can be used
with the 'resolve_with' command for testing specific scenarios.
"""
# This is synchronous, no need for async
game_commands.list_outcomes()
def do_resolve_with(self, arg):
"""
Resolve the current play with a specific outcome (for testing).
Usage: resolve_with <outcome>
Arguments:
outcome PlayOutcome value (e.g., single_1, homerun, strikeout)
This command allows you to force a specific outcome instead of
rolling dice, useful for testing runner advancement, specific
game states, and edge cases.
Use 'list_outcomes' to see all available outcome values.
Examples:
resolve_with single_1
resolve_with homerun
resolve_with groundball_a
resolve_with double_uncapped
"""
async def _resolve_with():
try:
gid = self._ensure_game()
await self._ensure_game_loaded(gid)
# Parse outcome argument
outcome_str = arg.strip().lower()
if not outcome_str:
display.print_error("Missing outcome argument")
display.print_info("Usage: resolve_with <outcome>")
display.print_info("Use 'list_outcomes' to see available values")
return
# Try to convert string to PlayOutcome enum
from app.config import PlayOutcome
try:
outcome = PlayOutcome(outcome_str)
except ValueError:
display.print_error(f"Invalid outcome: {outcome_str}")
display.print_info("Use 'list_outcomes' to see valid values")
return
# Use shared command with forced outcome
await game_commands.resolve_play(gid, forced_outcome=outcome)
except ValueError:
pass
except Exception as e:
display.print_error(f"Failed: {e}")
logger.exception("Resolve with outcome error")
self._run_async(_resolve_with())
def do_status(self, arg):
"""
Display current game state.
Usage: status
"""
async def _status():
try:
gid = self._ensure_game()
await self._ensure_game_loaded(gid)
# Use shared command
await game_commands.show_game_status(gid)
except ValueError:
pass
except Exception as e:
display.print_error(f"Failed: {e}")
self._run_async(_status())
def do_quick_play(self, arg):
"""
Auto-play multiple plays with default decisions.
Usage: quick_play [COUNT]
Examples:
quick_play # Play 1 play
quick_play 10 # Play 10 plays
quick_play 27 # Play ~3 innings
"""
async def _quick_play():
try:
gid = self._ensure_game()
await self._ensure_game_loaded(gid)
# Parse arguments with robust parser
args = parse_quick_play_args(arg)
# Execute quick play
plays_completed = await game_commands.quick_play_rounds(
game_id=gid,
count=args['count']
)
display.print_success(f"Completed {plays_completed} plays")
except ArgumentParseError as e:
display.print_error(f"Invalid arguments: {e}")
except ValueError:
pass
except Exception as e:
display.print_error(f"Failed: {e}")
logger.exception("Quick play error")
self._run_async(_quick_play())
def do_box_score(self, arg):
"""
Display box score.
Usage: box-score
"""
async def _box_score():
try:
gid = self._ensure_game()
await self._ensure_game_loaded(gid)
# Use shared command
await game_commands.show_box_score(gid)
except ValueError:
pass
except Exception as e:
display.print_error(f"Failed: {e}")
self._run_async(_box_score())
def do_manual_outcome(self, arg):
"""
Validate a manual outcome submission (for testing manual mode).
Usage: manual_outcome <outcome> [location]
Arguments:
outcome PlayOutcome enum value (e.g., groundball_c, single_1)
location Hit location (e.g., SS, 1B, LF) - optional
Examples:
manual_outcome strikeout
manual_outcome groundball_c SS
manual_outcome flyout_b LF
manual_outcome single_1
Note: This validates the outcome but doesn't resolve the play yet.
Full integration with play resolution coming in Week 7 Task 6.
"""
parts = arg.split()
if not parts:
display.print_error("Usage: manual_outcome <outcome> [location]")
display.console.print("[dim]Example: manual_outcome groundball_c SS[/dim]")
return
outcome = parts[0]
location = parts[1] if len(parts) > 1 else None
game_commands.validate_manual_outcome(outcome, location)
def do_test_location(self, arg):
"""
Test hit location distribution for an outcome.
Usage: test_location <outcome> [handedness] [count]
Arguments:
outcome PlayOutcome enum value (e.g., groundball_c)
handedness 'L' or 'R' (default: R)
count Number of samples (default: 100)
Examples:
test_location groundball_c
test_location groundball_c L
test_location flyout_b R 200
Shows distribution of hit locations based on pull rates.
"""
parts = arg.split()
if not parts:
display.print_error("Usage: test_location <outcome> [handedness] [count]")
display.console.print("[dim]Example: test_location groundball_c R 100[/dim]")
return
outcome = parts[0]
handedness = parts[1] if len(parts) > 1 else 'R'
count = int(parts[2]) if len(parts) > 2 else 100
# Validate handedness
if handedness not in ['L', 'R']:
display.print_error("Handedness must be 'L' or 'R'")
return
game_commands.test_hit_location(outcome, handedness, count)
def do_list_games(self, arg):
"""
List all games in state manager.
Usage: list-games
"""
games = state_manager.list_games()
if not games:
display.print_warning("No active games in memory")
return
display.print_info(f"Active games: {len(games)}")
for game_id in games:
marker = "* " if game_id == self.current_game_id else " "
display.console.print(f"{marker}{game_id}")
def do_use_game(self, arg):
"""
Switch to a different game.
Usage: use_game <game_id>
Example:
use_game a1b2c3d4-e5f6-7890-abcd-ef1234567890
"""
try:
# Parse arguments with robust parser
args = parse_use_game_args(arg)
gid = UUID(args['game_id'])
self.current_game_id = gid
Config.set_current_game(gid)
display.print_success(f"Switched to game: {gid}")
except ArgumentParseError as e:
display.print_error(f"Invalid arguments: {e}")
except ValueError:
display.print_error(f"Invalid UUID: {arg}")
def do_config(self, arg):
"""
Show configuration.
Usage: config
"""
config_path = Config.get_config_path()
display.print_info(f"Config file: {config_path}")
if self.current_game_id:
display.console.print(f"\n[green]Current game:[/green] {self.current_game_id}")
else:
display.console.print("\n[yellow]No current game set[/yellow]")
# ==================== Enhanced Help System ====================
def do_help(self, arg):
"""
Show help for commands.
Usage:
help List all commands
help <command> Show detailed help for a command
"""
if arg:
# Show detailed help for specific command
show_help(arg)
else:
# Show command list
HelpFormatter.show_command_list()
def help_new_game(self):
"""Show detailed help for new_game command."""
show_help('new_game')
def help_defensive(self):
"""Show detailed help for defensive command."""
show_help('defensive')
def help_offensive(self):
"""Show detailed help for offensive command."""
show_help('offensive')
def help_resolve(self):
"""Show detailed help for resolve command."""
show_help('resolve')
def help_quick_play(self):
"""Show detailed help for quick_play command."""
show_help('quick_play')
def help_status(self):
"""Show detailed help for status command."""
show_help('status')
def help_box_score(self):
"""Show detailed help for box_score command."""
show_help('box_score')
def help_list_games(self):
"""Show detailed help for list_games command."""
show_help('list_games')
def help_use_game(self):
"""Show detailed help for use_game command."""
show_help('use_game')
def help_config(self):
"""Show detailed help for config command."""
show_help('config')
def help_clear(self):
"""Show detailed help for clear command."""
show_help('clear')
# ==================== REPL Control Commands ====================
def do_clear(self, arg):
"""Clear the screen."""
display.console.clear()
def do_quit(self, arg):
"""Exit the REPL."""
display.print_info("Goodbye!")
# Clean up event loop
self.loop.close()
return True
def do_exit(self, arg):
"""Exit the REPL."""
return self.do_quit(arg)
def do_EOF(self, arg):
"""Handle Ctrl+D."""
print() # New line
return self.do_quit(arg)
def emptyline(self):
"""Do nothing on empty line."""
pass
def default(self, line):
"""Handle unknown commands."""
display.print_error(f"Unknown command: {line}")
display.print_info("Type 'help' for available commands")
def start_repl():
"""Start the interactive REPL."""
repl = GameREPL()
repl.cmdloop()
if __name__ == "__main__":
start_repl()