strat-gameplay-webapp/backend/terminal_client/repl.py
Cal Corum 918beadf24 CLAUDE: Add interactive terminal client for game engine testing
Created comprehensive terminal testing tool with two modes:
1. Interactive REPL (recommended) - Persistent in-memory state
2. Standalone CLI commands - Config file persistence

Features:
- Interactive REPL using Python cmd module
- Persistent event loop prevents DB connection issues
- 11 commands for full game control (new_game, defensive, offensive, resolve, etc.)
- Beautiful Rich formatting with colors and panels
- Auto-generated test lineups for rapid testing
- Direct GameEngine access (no WebSocket overhead)
- Config file (~/.terminal_client_config.json) for state persistence

Files added:
- terminal_client/repl.py (525 lines) - Interactive REPL
- terminal_client/main.py (516 lines) - Click standalone commands
- terminal_client/display.py (218 lines) - Rich formatting
- terminal_client/config.py (89 lines) - Persistent config
- terminal_client/__main__.py - Dual mode entry point
- terminal_client/CLAUDE.md (725 lines) - Full documentation

Updated:
- backend/CLAUDE.md - Added terminal client to testing section
- requirements.txt - Added rich==13.9.4

Perfect for rapid iteration on game engine without building frontend!

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-26 12:51:01 -05:00

528 lines
17 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
logger = logging.getLogger(f'{__name__}.repl')
class GameREPL(cmd.Cmd):
"""Interactive REPL for game engine testing."""
intro = """
╔══════════════════════════════════════════════════════════════════════════════╗
║ Paper Dynasty Game Engine - Terminal Client ║
║ Interactive Mode ║
╚══════════════════════════════════════════════════════════════════════════════╝
Type 'help' or '?' to list commands.
Type 'help <command>' for command details.
Type 'quit' or 'exit' to leave.
Quick start:
new_game Create and start a new game with test lineups
defensive Submit defensive decision
offensive Submit offensive decision
resolve Resolve the current play
status Show current game state
quick_play 10 Auto-play 10 plays
Note: Use underscores in command names (e.g., 'new_game' not 'new-game')
"""
prompt = '⚾ > '
def __init__(self):
super().__init__()
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
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():
# Parse arguments
args = arg.split()
league = 'sba'
home_team = 1
away_team = 2
i = 0
while i < len(args):
if args[i] == '--league' and i + 1 < len(args):
league = args[i + 1]
i += 2
elif args[i] == '--home-team' and i + 1 < len(args):
home_team = int(args[i + 1])
i += 2
elif args[i] == '--away-team' and i + 1 < len(args):
away_team = int(args[i + 1])
i += 2
else:
i += 1
gid = uuid4()
try:
# Step 1: Create game
display.print_info("Creating game...")
state = await state_manager.create_game(
game_id=gid,
league_id=league,
home_team_id=home_team,
away_team_id=away_team
)
await self.db_ops.create_game(
game_id=gid,
league_id=league,
home_team_id=home_team,
away_team_id=away_team,
game_mode="friendly",
visibility="public"
)
display.print_success(f"Game created: {gid}")
# Step 2: Setup lineups
display.print_info("Creating test lineups...")
positions = ['P', 'C', '1B', '2B', '3B', 'SS', 'LF', 'CF', 'RF']
for team_id in [home_team, away_team]:
for i, position in enumerate(positions, start=1):
if league == 'sba':
player_id = (team_id * 100) + i
await self.db_ops.add_sba_lineup_player(
game_id=gid,
team_id=team_id,
player_id=player_id,
position=position,
batting_order=i,
is_starter=True
)
else:
card_id = (team_id * 100) + i
await self.db_ops.add_pd_lineup_card(
game_id=gid,
team_id=team_id,
card_id=card_id,
position=position,
batting_order=i,
is_starter=True
)
display.print_success("Lineups created")
# Step 3: Start the game
display.print_info("Starting game...")
state = await game_engine.start_game(gid)
self.current_game_id = gid
Config.set_current_game(gid)
display.print_success(f"Game started - Inning {state.inning} {state.half}")
display.display_game_state(state)
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()
# Parse arguments
args = arg.split()
alignment = 'normal'
infield = 'normal'
outfield = 'normal'
hold_list = []
i = 0
while i < len(args):
if args[i] == '--alignment' and i + 1 < len(args):
alignment = args[i + 1]
i += 2
elif args[i] == '--infield' and i + 1 < len(args):
infield = args[i + 1]
i += 2
elif args[i] == '--outfield' and i + 1 < len(args):
outfield = args[i + 1]
i += 2
elif args[i] == '--hold' and i + 1 < len(args):
hold_list = [int(b.strip()) for b in args[i + 1].split(',')]
i += 2
else:
i += 1
decision = DefensiveDecision(
alignment=alignment,
infield_depth=infield,
outfield_depth=outfield,
hold_runners=hold_list
)
state = await game_engine.submit_defensive_decision(gid, decision)
display.print_success("Defensive decision submitted")
display.display_decision("defensive", decision)
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
--bunt Attempt bunt
Examples:
offensive
offensive --approach power
offensive --steal 2 --hit-run
"""
async def _offensive():
try:
gid = self._ensure_game()
# Parse arguments
args = arg.split()
approach = 'normal'
steal_list = []
hit_run = False
bunt = False
i = 0
while i < len(args):
if args[i] == '--approach' and i + 1 < len(args):
approach = args[i + 1]
i += 2
elif args[i] == '--steal' and i + 1 < len(args):
steal_list = [int(b.strip()) for b in args[i + 1].split(',')]
i += 2
elif args[i] == '--hit-run':
hit_run = True
i += 1
elif args[i] == '--bunt':
bunt = True
i += 1
else:
i += 1
decision = OffensiveDecision(
approach=approach,
steal_attempts=steal_list,
hit_and_run=hit_run,
bunt_attempt=bunt
)
state = await game_engine.submit_offensive_decision(gid, decision)
display.print_success("Offensive decision submitted")
display.display_decision("offensive", decision)
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()
result = await game_engine.resolve_play(gid)
state = await game_engine.get_game_state(gid)
if state:
display.display_play_result(result, state)
display.display_game_state(state)
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()
state = await game_engine.get_game_state(gid)
if state:
display.display_game_state(state)
else:
display.print_error("Game state not found")
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()
count = int(arg) if arg.strip() else 1
for i in range(count):
state = await game_engine.get_game_state(gid)
if not state or state.status != "active":
display.print_warning(f"Game ended at play {i + 1}")
break
display.print_info(f"Play {i + 1}/{count}")
# Submit default decisions
await game_engine.submit_defensive_decision(gid, DefensiveDecision())
await game_engine.submit_offensive_decision(gid, OffensiveDecision())
# Resolve
result = await game_engine.resolve_play(gid)
state = await game_engine.get_game_state(gid)
if state:
display.print_success(f"{result.description}")
display.console.print(f"[cyan]Score: Away {state.away_score} - {state.home_score} Home, Inning {state.inning} {state.half}, {state.outs} outs[/cyan]")
await asyncio.sleep(0.3)
# Final state
state = await game_engine.get_game_state(gid)
if state:
display.print_info("Final state:")
display.display_game_state(state)
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()
state = await game_engine.get_game_state(gid)
if state:
display.display_box_score(state)
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
"""
if not arg.strip():
display.print_error("Usage: use-game <game_id>")
return
try:
gid = UUID(arg.strip())
self.current_game_id = gid
Config.set_current_game(gid)
display.print_success(f"Switched to game: {gid}")
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]")
# ==================== 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()