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>
528 lines
17 KiB
Python
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()
|