strat-gameplay-webapp/backend/terminal_client/repl.py
Cal Corum d7caa75310 CLAUDE: Add manual outcome testing to terminal client and Phase 3 planning
Terminal Client Enhancements:
- Added list_outcomes command to display all PlayOutcome values
- Added resolve_with <outcome> command for testing specific scenarios
- TAB completion for all outcome names
- Full help documentation and examples
- Infrastructure ready for Week 7 integration

Files Modified:
- terminal_client/commands.py - list_outcomes() and forced outcome support
- terminal_client/repl.py - do_list_outcomes() and do_resolve_with() commands
- terminal_client/completions.py - VALID_OUTCOMES and complete_resolve_with()
- terminal_client/help_text.py - Help entries for new commands

Phase 3 Planning:
- Created comprehensive Week 7 implementation plan (25 pages)
- 6 major tasks covering strategic decisions and result charts
- Updated 00-index.md to mark Week 6 as 100% complete
- Documented manual outcome testing feature

Week 6: 100% Complete 
Phase 3 Week 7: Ready to begin

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 20:53:47 -05:00

575 lines
18 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_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()