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>
971 lines
32 KiB
Python
971 lines
32 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 (Session 2: replaced approach with action, removed deprecated fields)
|
|
await game_commands.submit_offensive_decision(
|
|
game_id=gid,
|
|
action=args.get('action', 'swing_away'),
|
|
steal_attempts=args['steal']
|
|
)
|
|
|
|
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>
|
|
resolve_with x-check <position> [<result>[+<error>]]
|
|
|
|
Arguments:
|
|
outcome PlayOutcome value (e.g., single_1, homerun, strikeout)
|
|
position For x-check: P, C, 1B, 2B, 3B, SS, LF, CF, RF
|
|
result For x-check: G1, G2, G3, F1, F2, F3, SI1, SI2, DO2, DO3, TR3, FO, PO
|
|
error For x-check: NO, E1, E2, E3, RP (default: NO if not specified)
|
|
|
|
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
|
|
resolve_with x-check SS # Test X-Check to shortstop (random result)
|
|
resolve_with x-check LF DO2 # Force double to LF with no error
|
|
resolve_with x-check 2B G2+E1 # Force groundout to 2B with E1 error
|
|
resolve_with x-check SS SI2+E2 # Force single to SS with E2 error
|
|
"""
|
|
async def _resolve_with():
|
|
try:
|
|
gid = self._ensure_game()
|
|
await self._ensure_game_loaded(gid)
|
|
|
|
# Parse arguments
|
|
args = arg.strip().lower().split()
|
|
if not args:
|
|
display.print_error("Missing outcome argument")
|
|
display.print_info("Usage: resolve_with <outcome>")
|
|
display.print_info(" resolve_with x-check <position> [<result>[+<error>]]")
|
|
display.print_info("Use 'list_outcomes' to see available values")
|
|
return
|
|
|
|
# Check for x-check with position
|
|
outcome_str = args[0]
|
|
xcheck_position = None
|
|
xcheck_result = None
|
|
xcheck_error = None
|
|
|
|
if outcome_str in ['x-check', 'xcheck', 'x_check']:
|
|
if len(args) < 2:
|
|
display.print_error("Missing position for x-check")
|
|
display.print_info("Usage: resolve_with x-check <position> [<result>[+<error>]]")
|
|
display.print_info("Valid positions: P, C, 1B, 2B, 3B, SS, LF, CF, RF")
|
|
return
|
|
outcome_str = 'x_check'
|
|
xcheck_position = args[1].upper()
|
|
|
|
# Validate position
|
|
valid_positions = ['P', 'C', '1B', '2B', '3B', 'SS', 'LF', 'CF', 'RF']
|
|
if xcheck_position not in valid_positions:
|
|
display.print_error(f"Invalid position: {xcheck_position}")
|
|
display.print_info(f"Valid positions: {', '.join(valid_positions)}")
|
|
return
|
|
|
|
# Parse optional result+error (e.g., "DO2+E1" or "G2")
|
|
if len(args) >= 3:
|
|
result_spec = args[2].upper()
|
|
|
|
# Split on '+' to separate result and error
|
|
if '+' in result_spec:
|
|
parts = result_spec.split('+')
|
|
xcheck_result = parts[0]
|
|
xcheck_error = parts[1] if len(parts) > 1 else 'NO'
|
|
else:
|
|
xcheck_result = result_spec
|
|
xcheck_error = 'NO' # Default to no error
|
|
|
|
# Validate result code
|
|
valid_results = ['G1', 'G2', 'G3', 'F1', 'F2', 'F3', 'SI1', 'SI2', 'DO2', 'DO3', 'TR3', 'FO', 'PO']
|
|
if xcheck_result not in valid_results:
|
|
display.print_error(f"Invalid X-Check result: {xcheck_result}")
|
|
display.print_info(f"Valid results: {', '.join(valid_results)}")
|
|
return
|
|
|
|
# Validate error code
|
|
valid_errors = ['NO', 'E1', 'E2', 'E3', 'RP']
|
|
if xcheck_error not in valid_errors:
|
|
display.print_error(f"Invalid error code: {xcheck_error}")
|
|
display.print_info(f"Valid errors: {', '.join(valid_errors)}")
|
|
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 available values")
|
|
return
|
|
|
|
# Use shared command with forced outcome
|
|
await game_commands.resolve_play(
|
|
gid,
|
|
forced_outcome=outcome,
|
|
xcheck_position=xcheck_position,
|
|
xcheck_result=xcheck_result,
|
|
xcheck_error=xcheck_error
|
|
)
|
|
|
|
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_rollback(self, arg):
|
|
"""
|
|
Roll back the last N plays.
|
|
|
|
Usage: rollback <num_plays>
|
|
|
|
Arguments:
|
|
num_plays Number of plays to roll back (must be > 0)
|
|
|
|
Deletes the specified number of plays from the database and
|
|
reconstructs the game state by replaying the remaining plays.
|
|
Also removes any substitutions that occurred during the
|
|
rolled-back plays.
|
|
|
|
Use this for correcting mistakes or recovering from corrupted plays.
|
|
|
|
Examples:
|
|
rollback 1 # Undo the last play
|
|
rollback 3 # Undo the last 3 plays
|
|
rollback 5 # Undo the last 5 plays
|
|
"""
|
|
async def _rollback():
|
|
try:
|
|
gid = self._ensure_game()
|
|
await self._ensure_game_loaded(gid)
|
|
|
|
# Parse argument
|
|
if not arg:
|
|
display.print_error("Usage: rollback <num_plays>")
|
|
display.console.print("Example: [cyan]rollback 3[/cyan]")
|
|
return
|
|
|
|
try:
|
|
num_plays = int(arg.strip())
|
|
except ValueError:
|
|
display.print_error(f"Invalid number: {arg}")
|
|
return
|
|
|
|
# Use shared command
|
|
await game_commands.rollback_plays(gid, num_plays)
|
|
|
|
except ValueError:
|
|
pass
|
|
except Exception as e:
|
|
display.print_error(f"Failed: {e}")
|
|
logger.exception("Rollback error")
|
|
|
|
self._run_async(_rollback())
|
|
|
|
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_roll_dice(self, arg):
|
|
"""
|
|
Roll dice for manual outcome mode.
|
|
|
|
Server rolls dice and displays results. Players then read their
|
|
physical cards and submit the outcome using manual_outcome command.
|
|
|
|
Usage: roll_dice
|
|
|
|
Example:
|
|
roll_dice
|
|
# Read physical card based on dice
|
|
manual_outcome groundball_c SS
|
|
"""
|
|
game_id = self.current_game_id
|
|
if not game_id:
|
|
display.print_error("No game selected. Create one with 'new_game'")
|
|
return
|
|
|
|
self._run_async(game_commands.roll_manual_dice(game_id))
|
|
|
|
def do_manual_outcome(self, arg):
|
|
"""
|
|
Submit manual outcome from physical card (manual mode).
|
|
|
|
After rolling dice with 'roll_dice', read your physical card
|
|
and submit the outcome you see.
|
|
|
|
Usage: manual_outcome <outcome> [location]
|
|
|
|
Arguments:
|
|
outcome PlayOutcome enum value (e.g., groundball_c, single_1)
|
|
location Hit location (e.g., SS, 1B, LF) - required for groundballs/flyouts
|
|
|
|
Examples:
|
|
roll_dice # First, roll the dice
|
|
manual_outcome strikeout # Submit outcome from card
|
|
manual_outcome groundball_c SS
|
|
manual_outcome flyout_b LF
|
|
manual_outcome walk # Location not needed for walks
|
|
|
|
Note: Must call 'roll_dice' first before submitting outcome.
|
|
"""
|
|
game_id = self.current_game_id
|
|
if not game_id:
|
|
display.print_error("No game selected. Create one with 'new_game'")
|
|
return
|
|
|
|
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]")
|
|
display.console.print("[dim]Must call 'roll_dice' first[/dim]")
|
|
return
|
|
|
|
outcome = parts[0]
|
|
location = parts[1] if len(parts) > 1 else None
|
|
|
|
self._run_async(game_commands.submit_manual_outcome(game_id, 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')
|
|
|
|
# ==================== Interrupt Play Commands ====================
|
|
|
|
def do_force_wild_pitch(self, arg):
|
|
"""
|
|
Force a wild pitch interrupt play.
|
|
|
|
Usage: force_wild_pitch
|
|
|
|
Wild pitch advances all runners one base. This is an interrupt play
|
|
(pa=0) and does not count as a plate appearance.
|
|
|
|
Example:
|
|
force_wild_pitch # Runner on 2nd advances to 3rd
|
|
"""
|
|
async def _force_wild_pitch():
|
|
try:
|
|
gid = self._ensure_game()
|
|
await self._ensure_game_loaded(gid)
|
|
await game_commands.force_wild_pitch(gid)
|
|
except ValueError:
|
|
pass # Already printed error
|
|
|
|
self._run_async(_force_wild_pitch())
|
|
|
|
def do_force_passed_ball(self, arg):
|
|
"""
|
|
Force a passed ball interrupt play.
|
|
|
|
Usage: force_passed_ball
|
|
|
|
Passed ball advances all runners one base. This is an interrupt play
|
|
(pa=0) and does not count as a plate appearance.
|
|
|
|
Example:
|
|
force_passed_ball # Runner on 1st advances to 2nd
|
|
"""
|
|
async def _force_passed_ball():
|
|
try:
|
|
gid = self._ensure_game()
|
|
await self._ensure_game_loaded(gid)
|
|
await game_commands.force_passed_ball(gid)
|
|
except ValueError:
|
|
pass # Already printed error
|
|
|
|
self._run_async(_force_passed_ball())
|
|
|
|
# ==================== Jump Roll Testing Commands ====================
|
|
|
|
def do_roll_jump(self, arg):
|
|
"""
|
|
Roll jump dice for stolen base testing.
|
|
|
|
Usage: roll_jump [league]
|
|
|
|
Jump roll components:
|
|
- 1d20 check roll (1=pickoff, 2=balk, 3+=normal)
|
|
- 2d6 for normal jump (if check >= 3)
|
|
- 1d20 resolution (if check == 1 or 2)
|
|
|
|
Arguments:
|
|
league 'sba' or 'pd' (default: sba)
|
|
|
|
Examples:
|
|
roll_jump # Roll for SBA league
|
|
roll_jump pd # Roll for PD league
|
|
|
|
The jump roll determines steal attempt outcomes:
|
|
- Pickoff check (5%): Pitcher attempts to pick off runner
|
|
- Balk check (5%): Pitcher may commit balk
|
|
- Normal jump (90%): Use 2d6 total for steal success check
|
|
"""
|
|
parts = arg.split()
|
|
league = parts[0] if parts else 'sba'
|
|
|
|
if league not in ['sba', 'pd']:
|
|
display.print_error("League must be 'sba' or 'pd'")
|
|
return
|
|
|
|
game_commands.roll_jump(league, self.current_game_id)
|
|
|
|
def do_test_jump(self, arg):
|
|
"""
|
|
Test jump roll distribution.
|
|
|
|
Usage: test_jump [count] [league]
|
|
|
|
Rolls N jump rolls and displays distribution statistics including:
|
|
- Pickoff check frequency (expected: 5%)
|
|
- Balk check frequency (expected: 5%)
|
|
- Normal jump frequency (expected: 90%)
|
|
- Jump total distribution (2d6, expected avg: 7.0)
|
|
|
|
Arguments:
|
|
count Number of rolls (default: 10)
|
|
league 'sba' or 'pd' (default: sba)
|
|
|
|
Examples:
|
|
test_jump # 10 rolls for SBA
|
|
test_jump 100 # 100 rolls for SBA
|
|
test_jump 50 pd # 50 rolls for PD
|
|
"""
|
|
parts = arg.split()
|
|
count = int(parts[0]) if parts else 10
|
|
league = parts[1] if len(parts) > 1 else 'sba'
|
|
|
|
if league not in ['sba', 'pd']:
|
|
display.print_error("League must be 'sba' or 'pd'")
|
|
return
|
|
|
|
game_commands.test_jump(count, league)
|
|
|
|
# ==================== Fielding Roll Testing Commands ====================
|
|
|
|
def do_roll_fielding(self, arg):
|
|
"""
|
|
Roll fielding check dice for testing.
|
|
|
|
Usage: roll_fielding <position> [league]
|
|
|
|
Fielding roll components:
|
|
- 1d20: Range check
|
|
- 3d6: Error total (3-18)
|
|
- 1d100: Rare play check
|
|
|
|
Arguments:
|
|
position P, C, 1B, 2B, 3B, SS, LF, CF, RF (required)
|
|
league 'sba' or 'pd' (default: sba)
|
|
|
|
Examples:
|
|
roll_fielding SS # Roll for shortstop (SBA)
|
|
roll_fielding P pd # Roll for pitcher (PD)
|
|
roll_fielding CF # Roll for center field
|
|
|
|
Rare plays:
|
|
- SBA: d100 = 1 (1% chance)
|
|
- PD: error_total = 5 (~2.78% chance)
|
|
"""
|
|
parts = arg.split()
|
|
if not parts:
|
|
display.print_error("Usage: roll_fielding <position> [league]")
|
|
display.console.print("[dim]Valid positions: P, C, 1B, 2B, 3B, SS, LF, CF, RF[/dim]")
|
|
return
|
|
|
|
position = parts[0]
|
|
league = parts[1] if len(parts) > 1 else 'sba'
|
|
|
|
if league not in ['sba', 'pd']:
|
|
display.print_error("League must be 'sba' or 'pd'")
|
|
return
|
|
|
|
game_commands.roll_fielding(position, league, self.current_game_id)
|
|
|
|
def do_test_fielding(self, arg):
|
|
"""
|
|
Test fielding roll distribution for a position.
|
|
|
|
Usage: test_fielding <position> [count] [league]
|
|
|
|
Rolls N fielding rolls and displays distribution statistics including:
|
|
- Rare play frequency
|
|
- Average range roll (d20, expected: 10.5)
|
|
- Average error total (3d6, expected: 10.5)
|
|
- Error total distribution (3-18)
|
|
|
|
Arguments:
|
|
position P, C, 1B, 2B, 3B, SS, LF, CF, RF (required)
|
|
count Number of rolls (default: 10)
|
|
league 'sba' or 'pd' (default: sba)
|
|
|
|
Examples:
|
|
test_fielding SS # 10 rolls for shortstop (SBA)
|
|
test_fielding 2B 100 # 100 rolls for second base
|
|
test_fielding CF 50 pd # 50 rolls for center field (PD)
|
|
"""
|
|
parts = arg.split()
|
|
if not parts:
|
|
display.print_error("Usage: test_fielding <position> [count] [league]")
|
|
display.console.print("[dim]Valid positions: P, C, 1B, 2B, 3B, SS, LF, CF, RF[/dim]")
|
|
return
|
|
|
|
position = parts[0]
|
|
count = int(parts[1]) if len(parts) > 1 else 10
|
|
league = parts[2] if len(parts) > 2 else 'sba'
|
|
|
|
if league not in ['sba', 'pd']:
|
|
display.print_error("League must be 'sba' or 'pd'")
|
|
return
|
|
|
|
game_commands.test_fielding(position, count, league)
|
|
|
|
# ==================== 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()
|