Backend refactor complete - removed all deprecated parameters and replaced with clean action-based system. Changes: - OffensiveDecision model: Added 'action' field (6 choices), removed deprecated 'hit_and_run' and 'bunt_attempt' boolean fields - Validators: Added action-specific validation (squeeze_bunt, check_jump, sac_bunt, hit_and_run situational constraints) - WebSocket handler: Updated submit_offensive_decision to use action field - Terminal client: Updated CLI, REPL, arg parser, and display for actions - Tests: Updated all 739 unit tests (100% passing) Action field values: - swing_away (default) - steal (requires steal_attempts parameter) - check_jump (requires runner on base) - hit_and_run (requires runner on base) - sac_bunt (cannot use with 2 outs) - squeeze_bunt (requires R3, not with bases loaded, not with 2 outs) Breaking changes: - Removed: hit_and_run boolean → use action="hit_and_run" - Removed: bunt_attempt boolean → use action="sac_bunt" or "squeeze_bunt" - Removed: approach field → use action field Files modified: - app/models/game_models.py - app/core/validators.py - app/websocket/handlers.py - terminal_client/main.py - terminal_client/arg_parser.py - terminal_client/commands.py - terminal_client/repl.py - terminal_client/display.py - tests/unit/models/test_game_models.py - tests/unit/core/test_validators.py - tests/unit/terminal_client/test_arg_parser.py - tests/unit/terminal_client/test_commands.py Test results: 739/739 passing (100%) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
358 lines
13 KiB
Python
358 lines
13 KiB
Python
"""
|
||
Rich display formatting for game state.
|
||
|
||
Provides formatted console output using the Rich library.
|
||
|
||
Author: Claude
|
||
Date: 2025-10-26
|
||
"""
|
||
import logging
|
||
from typing import Optional
|
||
from rich.console import Console
|
||
from rich.table import Table
|
||
from rich.panel import Panel
|
||
from rich.text import Text
|
||
from rich import box
|
||
|
||
from app.models.game_models import GameState, DefensiveDecision, OffensiveDecision
|
||
from app.core.play_resolver import PlayResult
|
||
|
||
logger = logging.getLogger(f'{__name__}.display')
|
||
console = Console()
|
||
|
||
|
||
def display_game_state(state: GameState) -> None:
|
||
"""
|
||
Display current game state in formatted panel.
|
||
|
||
Args:
|
||
state: Current game state
|
||
"""
|
||
# Status color based on game status
|
||
status_color = {
|
||
"pending": "yellow",
|
||
"active": "green",
|
||
"paused": "yellow",
|
||
"completed": "blue"
|
||
}
|
||
|
||
# Build state display
|
||
state_text = Text()
|
||
state_text.append(f"Game ID: {state.game_id}\n", style="bold")
|
||
state_text.append(f"League: {state.league_id.upper()}\n")
|
||
state_text.append(f"Status: ", style="bold")
|
||
state_text.append(f"{state.status}\n", style=status_color.get(state.status, "white"))
|
||
state_text.append("\n")
|
||
|
||
# Score
|
||
state_text.append("Score: ", style="bold cyan")
|
||
state_text.append(f"Away {state.away_score} - {state.home_score} Home\n", style="cyan")
|
||
|
||
# Inning
|
||
state_text.append("Inning: ", style="bold magenta")
|
||
state_text.append(f"{state.inning} {state.half.capitalize()}\n", style="magenta")
|
||
|
||
# Outs
|
||
state_text.append("Outs: ", style="bold yellow")
|
||
state_text.append(f"{state.outs}\n", style="yellow")
|
||
|
||
# Runners
|
||
runners = state.get_all_runners()
|
||
if runners:
|
||
state_text.append("\nRunners: ", style="bold green")
|
||
runner_bases = [f"{base}B(#{player.lineup_id})" for base, player in runners]
|
||
state_text.append(f"{', '.join(runner_bases)}\n", style="green")
|
||
else:
|
||
state_text.append("\nBases: ", style="bold")
|
||
state_text.append("Empty\n", style="dim")
|
||
|
||
# Current players
|
||
if state.current_batter:
|
||
state_text.append(f"\nBatter: Lineup #{state.current_batter.lineup_id}\n")
|
||
if state.current_pitcher:
|
||
state_text.append(f"Pitcher: Lineup #{state.current_pitcher.lineup_id}\n")
|
||
if state.current_catcher:
|
||
state_text.append(f"Catcher: Lineup #{state.current_catcher.lineup_id}\n")
|
||
|
||
# Pending decision - provide clear guidance
|
||
if state.pending_decision:
|
||
state_text.append(f"\n", style="bold")
|
||
state_text.append("⚠️ WAITING FOR ACTION\n", style="bold yellow")
|
||
state_text.append("─" * 40 + "\n", style="dim")
|
||
|
||
if state.pending_decision == "defensive":
|
||
state_text.append("The defense needs to submit their decision.\n", style="yellow")
|
||
state_text.append("Run: ", style="bold cyan")
|
||
state_text.append("defensive", style="green")
|
||
state_text.append(" [OPTIONS]\n", style="dim")
|
||
elif state.pending_decision == "offensive":
|
||
state_text.append("The offense needs to submit their decision.\n", style="yellow")
|
||
state_text.append("Run: ", style="bold cyan")
|
||
state_text.append("offensive", style="green")
|
||
state_text.append(" [OPTIONS]\n", style="dim")
|
||
elif state.pending_decision == "result_selection":
|
||
state_text.append("Ready to resolve play - both teams have decided.\n", style="yellow")
|
||
state_text.append("Run: ", style="bold cyan")
|
||
state_text.append("resolve", style="green")
|
||
state_text.append("\n", style="dim")
|
||
else:
|
||
state_text.append(f"Waiting for: {state.pending_decision}\n", style="yellow")
|
||
|
||
# Last play result
|
||
if state.last_play_result:
|
||
state_text.append(f"\nLast Play: ", style="bold")
|
||
state_text.append(f"{state.last_play_result}\n", style="italic")
|
||
|
||
# Display panel
|
||
panel = Panel(
|
||
state_text,
|
||
title=f"[bold]Game State[/bold]",
|
||
border_style="blue",
|
||
box=box.ROUNDED
|
||
)
|
||
console.print(panel)
|
||
|
||
|
||
def display_play_result(result: PlayResult, state: GameState) -> None:
|
||
"""
|
||
Display play result with rich formatting.
|
||
|
||
Args:
|
||
result: Play result from resolver
|
||
state: Updated game state
|
||
"""
|
||
result_text = Text()
|
||
|
||
# Outcome
|
||
result_text.append("Outcome: ", style="bold")
|
||
result_text.append(f"{result.outcome.value}\n", style="cyan")
|
||
|
||
# Description
|
||
result_text.append("Result: ", style="bold")
|
||
result_text.append(f"{result.description}\n\n", style="white")
|
||
|
||
# Dice roll
|
||
result_text.append("Roll: ", style="bold yellow")
|
||
result_text.append(f"{result.ab_roll}\n", style="yellow")
|
||
|
||
# Stats
|
||
if result.outs_recorded > 0:
|
||
result_text.append(f"Outs: ", style="bold red")
|
||
result_text.append(f"+{result.outs_recorded}\n", style="red")
|
||
|
||
if result.runs_scored > 0:
|
||
result_text.append(f"Runs: ", style="bold green")
|
||
result_text.append(f"+{result.runs_scored}\n", style="green")
|
||
|
||
# Runner advancement
|
||
if result.runners_advanced:
|
||
result_text.append(f"\nRunner Movement:\n", style="bold")
|
||
for from_base, to_base in result.runners_advanced:
|
||
if to_base == 4:
|
||
result_text.append(f" {from_base}B → SCORES\n", style="green")
|
||
else:
|
||
result_text.append(f" {from_base}B → {to_base}B\n")
|
||
|
||
# Batter result
|
||
if result.batter_result:
|
||
if result.batter_result < 4:
|
||
result_text.append(f"\nBatter: Reaches {result.batter_result}B\n", style="cyan")
|
||
elif result.batter_result == 4:
|
||
result_text.append(f"\nBatter: HOME RUN!\n", style="bold green")
|
||
|
||
# Display panel
|
||
panel = Panel(
|
||
result_text,
|
||
title=f"[bold]⚾ Play Result[/bold]",
|
||
border_style="green",
|
||
box=box.HEAVY
|
||
)
|
||
console.print(panel)
|
||
|
||
# Show updated score
|
||
console.print(f"\n[bold cyan]Score: Away {state.away_score} - {state.home_score} Home[/bold cyan]")
|
||
|
||
|
||
def display_decision(decision_type: str, decision: Optional[DefensiveDecision | OffensiveDecision]) -> None:
|
||
"""
|
||
Display submitted decision.
|
||
|
||
Args:
|
||
decision_type: 'defensive' or 'offensive'
|
||
decision: Decision object
|
||
"""
|
||
if not decision:
|
||
console.print(f"[yellow]No {decision_type} decision to display[/yellow]")
|
||
return
|
||
|
||
decision_text = Text()
|
||
|
||
if isinstance(decision, DefensiveDecision):
|
||
decision_text.append(f"Infield Depth: {decision.infield_depth}\n")
|
||
decision_text.append(f"Outfield Depth: {decision.outfield_depth}\n")
|
||
if decision.hold_runners:
|
||
decision_text.append(f"Hold Runners: {decision.hold_runners}\n")
|
||
elif isinstance(decision, OffensiveDecision):
|
||
decision_text.append(f"Action: {decision.action}\n")
|
||
if decision.steal_attempts:
|
||
decision_text.append(f"Steal Attempts: {decision.steal_attempts}\n")
|
||
|
||
panel = Panel(
|
||
decision_text,
|
||
title=f"[bold]{decision_type.capitalize()} Decision[/bold]",
|
||
border_style="yellow",
|
||
box=box.ROUNDED
|
||
)
|
||
console.print(panel)
|
||
|
||
|
||
def display_box_score(box_score: dict) -> None:
|
||
"""
|
||
Display complete box score with game stats, batting stats, and pitching stats.
|
||
|
||
Args:
|
||
box_score: Box score data with keys:
|
||
- game_stats: Team totals and linescore
|
||
- batting_stats: List of player batting lines
|
||
- pitching_stats: List of player pitching lines
|
||
"""
|
||
game_stats = box_score.get('game_stats', {})
|
||
batting_stats = box_score.get('batting_stats', [])
|
||
pitching_stats = box_score.get('pitching_stats', [])
|
||
|
||
# Team Totals Section
|
||
totals_table = Table(title="[bold]Game Summary[/bold]", box=box.ROUNDED)
|
||
totals_table.add_column("Team", style="cyan")
|
||
totals_table.add_column("Runs", justify="right", style="bold green")
|
||
totals_table.add_column("Hits", justify="right", style="yellow")
|
||
totals_table.add_column("Errors", justify="right", style="red")
|
||
|
||
totals_table.add_row(
|
||
"Away",
|
||
str(game_stats.get('away_runs', 0)),
|
||
str(game_stats.get('away_hits', 0)),
|
||
str(game_stats.get('away_errors', 0))
|
||
)
|
||
totals_table.add_row(
|
||
"Home",
|
||
str(game_stats.get('home_runs', 0)),
|
||
str(game_stats.get('home_hits', 0)),
|
||
str(game_stats.get('home_errors', 0))
|
||
)
|
||
|
||
console.print(totals_table)
|
||
|
||
# Linescore Section
|
||
home_linescore = game_stats.get('home_linescore', [])
|
||
away_linescore = game_stats.get('away_linescore', [])
|
||
|
||
if home_linescore or away_linescore:
|
||
console.print()
|
||
linescore_table = Table(title="[bold]Linescore[/bold]", box=box.SIMPLE)
|
||
linescore_table.add_column("Team", style="cyan")
|
||
|
||
# Add inning columns (1-9 or however many innings)
|
||
num_innings = max(len(home_linescore), len(away_linescore))
|
||
for inning in range(1, num_innings + 1):
|
||
linescore_table.add_column(str(inning), justify="center")
|
||
|
||
# Away linescore
|
||
away_row = ["Away"] + [str(away_linescore[i]) if i < len(away_linescore) else "-" for i in range(num_innings)]
|
||
linescore_table.add_row(*away_row)
|
||
|
||
# Home linescore
|
||
home_row = ["Home"] + [str(home_linescore[i]) if i < len(home_linescore) else "-" for i in range(num_innings)]
|
||
linescore_table.add_row(*home_row)
|
||
|
||
console.print(linescore_table)
|
||
|
||
# Batting Stats Section
|
||
if batting_stats:
|
||
console.print()
|
||
batting_table = Table(title="[bold]Batting Stats[/bold]", box=box.ROUNDED)
|
||
batting_table.add_column("Lineup", style="cyan", width=6)
|
||
batting_table.add_column("Card", style="yellow", width=6)
|
||
batting_table.add_column("PA", justify="right", width=4)
|
||
batting_table.add_column("AB", justify="right", width=4)
|
||
batting_table.add_column("R", justify="right", width=4)
|
||
batting_table.add_column("H", justify="right", width=4)
|
||
batting_table.add_column("2B", justify="right", width=4)
|
||
batting_table.add_column("3B", justify="right", width=4)
|
||
batting_table.add_column("HR", justify="right", width=4, style="bold green")
|
||
batting_table.add_column("RBI", justify="right", width=4)
|
||
batting_table.add_column("BB", justify="right", width=4)
|
||
batting_table.add_column("SO", justify="right", width=4)
|
||
|
||
for batter in batting_stats:
|
||
batting_table.add_row(
|
||
str(batter.get('lineup_id', '')),
|
||
str(batter.get('player_card_id', '')),
|
||
str(batter.get('pa', 0)),
|
||
str(batter.get('ab', 0)),
|
||
str(batter.get('run', 0)),
|
||
str(batter.get('hit', 0)),
|
||
str(batter.get('double', 0)),
|
||
str(batter.get('triple', 0)),
|
||
str(batter.get('hr', 0)),
|
||
str(batter.get('rbi', 0)),
|
||
str(batter.get('bb', 0)),
|
||
str(batter.get('so', 0))
|
||
)
|
||
|
||
console.print(batting_table)
|
||
|
||
# Pitching Stats Section
|
||
if pitching_stats:
|
||
console.print()
|
||
pitching_table = Table(title="[bold]Pitching Stats[/bold]", box=box.ROUNDED)
|
||
pitching_table.add_column("Lineup", style="cyan", width=6)
|
||
pitching_table.add_column("Card", style="yellow", width=6)
|
||
pitching_table.add_column("IP", justify="right", width=6)
|
||
pitching_table.add_column("BF", justify="right", width=4)
|
||
pitching_table.add_column("H", justify="right", width=4)
|
||
pitching_table.add_column("R", justify="right", width=4)
|
||
pitching_table.add_column("ER", justify="right", width=4)
|
||
pitching_table.add_column("BB", justify="right", width=4)
|
||
pitching_table.add_column("SO", justify="right", width=4, style="bold green")
|
||
pitching_table.add_column("HR", justify="right", width=4)
|
||
|
||
for pitcher in pitching_stats:
|
||
# Format IP to show fractional innings (e.g., 5.1 for 5 1/3 innings)
|
||
ip = pitcher.get('ip', 0.0)
|
||
ip_str = f"{ip:.1f}" if ip else "0.0"
|
||
|
||
pitching_table.add_row(
|
||
str(pitcher.get('lineup_id', '')),
|
||
str(pitcher.get('player_card_id', '')),
|
||
ip_str,
|
||
str(pitcher.get('batters_faced', 0)),
|
||
str(pitcher.get('hit_allowed', 0)),
|
||
str(pitcher.get('run_allowed', 0)),
|
||
str(pitcher.get('erun', 0)),
|
||
str(pitcher.get('bb', 0)),
|
||
str(pitcher.get('so', 0)),
|
||
str(pitcher.get('hr_allowed', 0))
|
||
)
|
||
|
||
console.print(pitching_table)
|
||
|
||
|
||
def print_success(message: str) -> None:
|
||
"""Print success message."""
|
||
console.print(f"✓ [green]{message}[/green]")
|
||
|
||
|
||
def print_error(message: str) -> None:
|
||
"""Print error message."""
|
||
console.print(f"✗ [red]{message}[/red]")
|
||
|
||
|
||
def print_info(message: str) -> None:
|
||
"""Print info message."""
|
||
console.print(f"[blue]ℹ {message}[/blue]")
|
||
|
||
|
||
def print_warning(message: str) -> None:
|
||
"""Print warning message."""
|
||
console.print(f"[yellow]⚠ {message}[/yellow]")
|