strat-gameplay-webapp/backend/terminal_client/repl.py
Cal Corum 1c32787195 CLAUDE: Refactor game models and modularize terminal client
This commit includes cleanup from model refactoring and terminal client
modularization for better code organization and maintainability.

## Game Models Refactor

**Removed RunnerState class:**
- Eliminated separate RunnerState model (was redundant)
- Replaced runners: List[RunnerState] with direct base references:
  - on_first: Optional[LineupPlayerState]
  - on_second: Optional[LineupPlayerState]
  - on_third: Optional[LineupPlayerState]
- Updated helper methods:
  - get_runner_at_base() now returns LineupPlayerState directly
  - get_all_runners() returns List[Tuple[int, LineupPlayerState]]
  - is_runner_on_X() simplified to direct None checks

**Benefits:**
- Matches database structure (plays table has on_first_id, etc.)
- Simpler state management (direct references vs list management)
- Better type safety (LineupPlayerState vs generic runner)
- Easier to work with in game engine logic

**Updated files:**
- app/models/game_models.py - Removed RunnerState, updated GameState
- app/core/play_resolver.py - Use get_all_runners() instead of state.runners
- app/core/validators.py - Updated runner access patterns
- tests/unit/models/test_game_models.py - Updated test assertions
- tests/unit/core/test_play_resolver.py - Updated test data
- tests/unit/core/test_validators.py - Updated test data

## Terminal Client Refactor

**Modularization (DRY principle):**
Created separate modules for better code organization:

1. **terminal_client/commands.py** (10,243 bytes)
   - Shared command functions for game operations
   - Used by both CLI (main.py) and REPL (repl.py)
   - Functions: submit_defensive_decision, submit_offensive_decision,
     resolve_play, quick_play_sequence
   - Single source of truth for command logic

2. **terminal_client/arg_parser.py** (7,280 bytes)
   - Centralized argument parsing and validation
   - Handles defensive/offensive decision arguments
   - Validates formats (alignment, depths, hold runners, steal attempts)

3. **terminal_client/completions.py** (10,357 bytes)
   - TAB completion support for REPL mode
   - Command completions, option completions, dynamic completions
   - Game ID completions, defensive/offensive option suggestions

4. **terminal_client/help_text.py** (10,839 bytes)
   - Centralized help text and command documentation
   - Detailed command descriptions
   - Usage examples for all commands

**Updated main modules:**
- terminal_client/main.py - Simplified by using shared commands module
- terminal_client/repl.py - Cleaner with shared functions and completions

**Benefits:**
- DRY: Behavior consistent between CLI and REPL modes
- Maintainability: Changes in one place affect both interfaces
- Testability: Can test commands module independently
- Organization: Clear separation of concerns

## Documentation

**New files:**
- app/models/visual_model_relationships.md
  - Visual documentation of model relationships
  - Helps understand data flow between models
- terminal_client/update_docs/ (6 phase documentation files)
  - Phased documentation for terminal client evolution
  - Historical context for implementation decisions

## Tests

**New test files:**
- tests/unit/terminal_client/__init__.py
- tests/unit/terminal_client/test_arg_parser.py
- tests/unit/terminal_client/test_commands.py
- tests/unit/terminal_client/test_completions.py
- tests/unit/terminal_client/test_help_text.py

**Updated tests:**
- Integration tests updated for new runner model
- Unit tests updated for model changes
- All tests passing with new structure

## Summary

-  Simplified game state model (removed RunnerState)
-  Better alignment with database structure
-  Modularized terminal client (DRY principle)
-  Shared command logic between CLI and REPL
-  Comprehensive test coverage
-  Improved documentation

Total changes: 26 files modified/created

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-28 14:16:38 -05:00

509 lines
16 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_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()