strat-gameplay-webapp/backend/terminal_client/commands.py
Cal Corum 8ecce0f5ad CLAUDE: Implement forced outcome feature for terminal client testing
Add ability to force specific play outcomes instead of random dice rolls,
enabling targeted testing of specific game scenarios.

Changes:
- play_resolver.resolve_play(): Add forced_outcome parameter, bypass dice
  rolls when provided, create dummy AbRoll with placeholder values
- game_engine.resolve_play(): Accept and pass through forced_outcome param
- terminal_client/commands.py: Pass forced_outcome to game engine

Testing:
- Verified TRIPLE, HOMERUN, and STRIKEOUT outcomes work correctly
- Dummy AbRoll properly constructed with all required fields
- Game state updates correctly with forced outcomes

Example usage in REPL:
  resolve_with triple
  resolve_with homerun

Fixes terminal client testing workflow to allow controlled scenarios.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-30 15:39:35 -05:00

504 lines
18 KiB
Python

"""
Shared command implementations for terminal client.
This module contains the core logic for game commands that can be
used by both the REPL (repl.py) and CLI (main.py) interfaces.
Author: Claude
Date: 2025-10-27
"""
import asyncio
import logging
from uuid import UUID, uuid4
from typing import Optional, List, Tuple
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.config import PlayOutcome
from app.database.operations import DatabaseOperations
from terminal_client import display
from terminal_client.config import Config
logger = logging.getLogger(f'{__name__}.commands')
class GameCommands:
"""Shared command implementations for game operations."""
def __init__(self):
self.db_ops = DatabaseOperations()
async def create_new_game(
self,
league: str = 'sba',
home_team: int = 1,
away_team: int = 2,
set_current: bool = True
) -> Tuple[UUID, bool]:
"""
Create a new game with lineups and start it.
Args:
league: 'sba' or 'pd'
home_team: Home team ID
away_team: Away team ID
set_current: Whether to set as current game
Returns:
Tuple of (game_id, success)
"""
gid = uuid4()
try:
# Step 1: Create game in memory and database
display.print_info("Step 1: 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}")
if set_current:
Config.set_current_game(gid)
display.print_info(f"Current game set to: {gid}")
# Step 2: Setup lineups
display.print_info("Step 2: Creating test lineups...")
await self._create_test_lineups(gid, league, home_team, away_team)
# Step 3: Start the game
display.print_info("Step 3: Starting game...")
state = await game_engine.start_game(gid)
display.print_success(f"Game started - Inning {state.inning} {state.half}")
display.display_game_state(state)
return gid, True
except Exception as e:
display.print_error(f"Failed to create new game: {e}")
logger.exception("New game error")
return gid, False
async def _create_test_lineups(
self,
game_id: UUID,
league: str,
home_team: int,
away_team: int
) -> None:
"""Create test lineups for both teams."""
positions = ['P', 'C', '1B', '2B', '3B', 'SS', 'LF', 'CF', 'RF']
for team_id in [home_team, away_team]:
team_name = "Home" if team_id == home_team else "Away"
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=game_id,
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=game_id,
team_id=team_id,
card_id=card_id,
position=position,
batting_order=i,
is_starter=True
)
display.console.print(f"{team_name} team lineup created (9 players)")
async def submit_defensive_decision(
self,
game_id: UUID,
alignment: str = 'normal',
infield: str = 'normal',
outfield: str = 'normal',
hold_runners: Optional[List[int]] = None
) -> bool:
"""
Submit defensive decision.
Returns:
True if successful, False otherwise
"""
try:
decision = DefensiveDecision(
alignment=alignment,
infield_depth=infield,
outfield_depth=outfield,
hold_runners=hold_runners or []
)
state = await game_engine.submit_defensive_decision(game_id, decision)
display.print_success("Defensive decision submitted")
display.display_decision("defensive", decision)
display.display_game_state(state)
return True
except Exception as e:
display.print_error(f"Failed to submit defensive decision: {e}")
logger.exception("Defensive decision error")
return False
async def submit_offensive_decision(
self,
game_id: UUID,
approach: str = 'normal',
steal_attempts: Optional[List[int]] = None,
hit_and_run: bool = False,
bunt_attempt: bool = False
) -> bool:
"""
Submit offensive decision.
Returns:
True if successful, False otherwise
"""
try:
decision = OffensiveDecision(
approach=approach,
steal_attempts=steal_attempts or [],
hit_and_run=hit_and_run,
bunt_attempt=bunt_attempt
)
state = await game_engine.submit_offensive_decision(game_id, decision)
display.print_success("Offensive decision submitted")
display.display_decision("offensive", decision)
display.display_game_state(state)
return True
except Exception as e:
display.print_error(f"Failed to submit offensive decision: {e}")
logger.exception("Offensive decision error")
return False
async def resolve_play(self, game_id: UUID, forced_outcome: Optional[PlayOutcome] = None) -> bool:
"""
Resolve the current play.
Args:
game_id: Game to resolve
forced_outcome: If provided, use this outcome instead of rolling dice
Returns:
True if successful, False otherwise
"""
try:
if forced_outcome:
display.print_info(f"🎯 Forcing outcome: {forced_outcome.value}")
result = await game_engine.resolve_play(game_id, forced_outcome)
state = await game_engine.get_game_state(game_id)
if state:
display.display_play_result(result, state)
display.display_game_state(state)
return True
else:
display.print_error(f"Game {game_id} not found after resolution")
return False
except Exception as e:
display.print_error(f"Failed to resolve play: {e}")
logger.exception("Resolve play error")
return False
def list_outcomes(self) -> None:
"""
Display all available PlayOutcome values for manual selection.
"""
from rich.table import Table
from rich.console import Console
console = Console()
# Create categorized table
table = Table(title="Available Play Outcomes", show_header=True, header_style="bold cyan")
table.add_column("Category", style="yellow", width=20)
table.add_column("Outcome", style="green", width=25)
table.add_column("Description", style="white", width=50)
# Outs
table.add_row("Outs", "strikeout", "Batter strikes out")
table.add_row("", "groundball_a", "Groundball - double play if possible")
table.add_row("", "groundball_b", "Groundball - standard")
table.add_row("", "groundball_c", "Groundball - weak contact")
table.add_row("", "flyout_a", "Flyout variant A")
table.add_row("", "flyout_b", "Flyout variant B (medium depth)")
table.add_row("", "flyout_c", "Flyout variant C (deep)")
table.add_row("", "lineout", "Line drive out")
table.add_row("", "popout", "Pop fly out")
# Hits
table.add_row("Hits", "single_1", "Single - standard advancement")
table.add_row("", "single_2", "Single - enhanced advancement")
table.add_row("", "single_uncapped", "Single (uncapped) - decision tree")
table.add_row("", "double_2", "Double to 2nd base")
table.add_row("", "double_3", "Double to 3rd base")
table.add_row("", "double_uncapped", "Double (uncapped) - decision tree")
table.add_row("", "triple", "Triple")
table.add_row("", "homerun", "Home run")
# Walks/HBP
table.add_row("Walks/HBP", "walk", "Base on balls")
table.add_row("", "hbp", "Hit by pitch")
table.add_row("", "intentional_walk", "Intentional walk")
# Errors
table.add_row("Errors", "error", "Defensive error")
# Interrupts
table.add_row("Interrupts", "wild_pitch", "Wild pitch (pa=0)")
table.add_row("", "passed_ball", "Passed ball (pa=0)")
table.add_row("", "stolen_base", "Stolen base (pa=0)")
table.add_row("", "caught_stealing", "Caught stealing (pa=0)")
table.add_row("", "balk", "Balk (pa=0)")
table.add_row("", "pick_off", "Pick off (pa=0)")
# Ballpark Power
table.add_row("Ballpark", "bp_homerun", "Ballpark home run")
table.add_row("", "bp_single", "Ballpark single")
table.add_row("", "bp_flyout", "Ballpark flyout")
table.add_row("", "bp_lineout", "Ballpark lineout")
console.print(table)
console.print("\n[cyan]Usage:[/cyan] [green]resolve_with[/green] [yellow]<outcome>[/yellow]")
console.print("[dim]Example: resolve_with single_1[/dim]")
async def quick_play_rounds(
self,
game_id: UUID,
count: int = 1
) -> int:
"""
Execute multiple plays with default decisions.
Returns:
Number of plays successfully executed
"""
plays_completed = 0
for i in range(count):
try:
state = await game_engine.get_game_state(game_id)
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(game_id, DefensiveDecision())
await game_engine.submit_offensive_decision(game_id, OffensiveDecision())
# Resolve
result = await game_engine.resolve_play(game_id)
state = await game_engine.get_game_state(game_id)
if state:
display.print_success(f"{result.description}")
display.console.print(
f"[cyan]Score: Away {state.away_score} - {state.home_score} Home, "
f"Inning {state.inning} {state.half}, {state.outs} outs[/cyan]"
)
plays_completed += 1
await asyncio.sleep(0.3) # Brief pause for readability
except Exception as e:
display.print_error(f"Error on play {i + 1}: {e}")
logger.exception("Quick play error")
break
# Show final state
state = await game_engine.get_game_state(game_id)
if state:
display.print_info("Final state:")
display.display_game_state(state)
return plays_completed
async def show_game_status(self, game_id: UUID) -> bool:
"""
Display current game state.
Returns:
True if successful, False otherwise
"""
try:
state = await game_engine.get_game_state(game_id)
if state:
display.display_game_state(state)
return True
else:
display.print_error(f"Game {game_id} not found")
return False
except Exception as e:
display.print_error(f"Failed to get game status: {e}")
return False
async def show_box_score(self, game_id: UUID) -> bool:
"""
Display box score.
Returns:
True if successful, False otherwise
"""
try:
state = await game_engine.get_game_state(game_id)
if state:
display.display_box_score(state)
return True
else:
display.print_error(f"Game {game_id} not found")
return False
except Exception as e:
display.print_error(f"Failed to get box score: {e}")
return False
def validate_manual_outcome(self, outcome: str, location: Optional[str] = None) -> bool:
"""
Validate a manual outcome submission.
Args:
outcome: PlayOutcome enum value (e.g., 'groundball_c')
location: Optional hit location (e.g., 'SS')
Returns:
True if valid, False otherwise
"""
from app.models.game_models import ManualOutcomeSubmission
from pydantic import ValidationError
try:
# Try to create ManualOutcomeSubmission
submission = ManualOutcomeSubmission(
outcome=outcome,
hit_location=location
)
# Show success
display.print_success(f"✅ Valid manual outcome submission")
display.console.print(f" [cyan]Outcome:[/cyan] [green]{submission.outcome}[/green]")
if submission.hit_location:
display.console.print(f" [cyan]Location:[/cyan] [green]{submission.hit_location}[/green]")
else:
display.console.print(f" [cyan]Location:[/cyan] [dim]None (not required for this outcome)[/dim]")
# Check if location is required
outcome_enum = PlayOutcome(outcome)
if outcome_enum.requires_hit_location():
if not location:
display.print_warning("⚠️ Note: This outcome typically requires a hit location")
display.console.print(" [dim]Groundballs and flyouts need location for runner advancement[/dim]")
return True
except ValidationError as e:
display.print_error("❌ Invalid manual outcome submission")
for error in e.errors():
field = error['loc'][0] if error['loc'] else 'unknown'
message = error['msg']
display.console.print(f" [red]•[/red] [yellow]{field}:[/yellow] {message}")
return False
except Exception as e:
display.print_error(f"Validation error: {e}")
return False
def test_hit_location(self, outcome: str, handedness: str = 'R', count: int = 10) -> None:
"""
Test hit location calculation for a given outcome and handedness.
Args:
outcome: PlayOutcome enum value (e.g., 'groundball_c')
handedness: Batter handedness ('L' or 'R')
count: Number of samples to generate
Displays:
Distribution of hit locations
"""
from app.config.result_charts import calculate_hit_location, PlayOutcome
from collections import Counter
from rich.table import Table
try:
outcome_enum = PlayOutcome(outcome)
except ValueError:
display.print_error(f"Invalid outcome: {outcome}")
display.console.print(" [dim]Use 'list_outcomes' to see valid outcomes[/dim]")
return
# Generate samples
locations = []
for _ in range(count):
location = calculate_hit_location(outcome_enum, handedness)
if location:
locations.append(location)
if not locations:
display.print_info(f"Outcome '{outcome}' does not require hit location")
display.console.print(" [dim]Location only tracked for groundballs and flyouts[/dim]")
return
# Count distribution
counter = Counter(locations)
total = len(locations)
# Display results
display.print_success(f"Hit Location Distribution for {outcome} ({handedness}HB)")
table = Table(show_header=True, header_style="bold cyan")
table.add_column("Location", style="yellow", width=15)
table.add_column("Count", style="green", width=10, justify="right")
table.add_column("Percentage", style="cyan", width=15, justify="right")
table.add_column("Visual", style="white", width=30)
for location in sorted(counter.keys()):
count_val = counter[location]
pct = (count_val / total) * 100
bar = "" * int(pct / 3) # Scale bar to fit
table.add_row(
location,
str(count_val),
f"{pct:.1f}%",
bar
)
display.console.print(table)
# Show pull rates info
display.console.print(f"\n[dim]Pull rates: 45% pull, 35% center, 20% opposite[/dim]")
if handedness == 'R':
display.console.print(f"[dim]RHB pulls left (3B, SS, LF)[/dim]")
else:
display.console.print(f"[dim]LHB pulls right (1B, 2B, RF)[/dim]")
# Singleton instance
game_commands = GameCommands()