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