strat-gameplay-webapp/backend/terminal_client/display.py
Cal Corum e165b449f5 CLAUDE: Refactor offensive decisions - replace approach with action field
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>
2025-11-14 15:07:54 -06:00

358 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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]")