strat-gameplay-webapp/backend/terminal_client/repl.py
Cal Corum 9cae63ac43 CLAUDE: Implement Week 7 Task 7 - WebSocket manual outcome handlers
Complete manual outcome workflow for SBA and PD manual mode gameplay:

**WebSocket Event Handlers** (app/websocket/handlers.py):
- roll_dice: Server rolls dice, stores in state, broadcasts to players
- submit_manual_outcome: Validates and processes player submissions
- Events: dice_rolled, outcome_accepted, outcome_rejected, play_resolved

**Game Engine Integration** (app/core/game_engine.py):
- resolve_manual_play(): Processes manual outcomes with server dice
- Uses ab_roll for audit trail, player outcome for resolution
- Same orchestration as resolve_play() (save, update, advance inning)

**Data Model** (app/models/game_models.py):
- pending_manual_roll: Stores server dice between roll and submission

**Terminal Client** (terminal_client/):
- roll_dice command: Roll dice and display results
- manual_outcome command: Submit outcomes from physical cards
- Both integrated into REPL for testing

**Tests** (tests/unit/websocket/test_manual_outcome_handlers.py):
- 12 comprehensive tests covering all validation paths
- All tests passing (roll_dice: 4, submit_manual_outcome: 8)

**Key Decisions**:
- Server rolls dice for fairness (not players!)
- One-time roll usage (cleared after submission)
- Early validation (check pending roll before accepting)
- Field-level error messages for clear feedback

**Impact**:
- Complete manual mode workflow ready
- Frontend WebSocket integration supported
- Terminal testing commands available
- Audit trail with server-rolled dice maintained

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-30 22:51:31 -05:00

719 lines
23 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_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
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
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')
# ==================== 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()