strat-gameplay-webapp/backend/terminal_client/display.py
Cal Corum 4d2c905a1c CLAUDE: Fix RunnerAdvancementData tuple unpacking regression
After changing runners_advanced from list[tuple] to list[RunnerAdvancementData],
three locations were still trying to unpack as tuples causing "cannot unpack
non-iterable RunnerAdvancementData object" error on triples/doubles with runners.

Fixed locations:
- app/core/game_engine.py:772 - _apply_play_result() advancement map
- app/core/game_engine.py:1081 - _save_play() finals lookup
- terminal_client/display.py:150 - Runner movement display

Also renamed refactor_overview.md to game-engine-play-tracking-design.md
for clarity and updated the example code to use dataclass access pattern.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-28 23:11:26 -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 adv in result.runners_advanced:
if adv.to_base == 4:
result_text.append(f" {adv.from_base}B → SCORES\n", style="green")
else:
result_text.append(f" {adv.from_base}B → {adv.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]")