""" Pydantic models for in-memory game state management. These models represent the active game state cached in memory for fast gameplay. They are separate from the SQLAlchemy database models (db_models.py) to optimize for different use cases: - game_models.py: Fast in-memory operations, type-safe validation, WebSocket serialization - db_models.py: Database persistence, relationships, audit trail Author: Claude Date: 2025-10-22 """ import logging from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Optional from uuid import UUID from pydantic import BaseModel, ConfigDict, Field, field_validator from app.config.result_charts import PlayOutcome if TYPE_CHECKING: from app.models.player_models import PositionRating logger = logging.getLogger(f"{__name__}") # ============================================================================ # LINEUP STATE # ============================================================================ class LineupPlayerState(BaseModel): """ Represents a player in the game lineup. Contains both lineup-specific data (position, batting_order) and player data (name, image) loaded at game start for efficient access. Phase 3E-Main: Now includes position_rating for X-Check resolution. """ lineup_id: int card_id: int position: str batting_order: int | None = None is_active: bool = True is_starter: bool = True # Player data (loaded at game start from SBA/PD API) player_name: str | None = None player_image: str | None = None # Card image player_headshot: str | None = None # Headshot for UI circles # Phase 3E-Main: Position rating (loaded at game start for PD league) position_rating: Optional["PositionRating"] = None @field_validator("position") @classmethod def validate_position(cls, v: str) -> str: """Ensure position is valid""" valid_positions = ["P", "C", "1B", "2B", "3B", "SS", "LF", "CF", "RF", "DH", "BN"] if v not in valid_positions: raise ValueError(f"Position must be one of {valid_positions}") return v @field_validator("batting_order") @classmethod def validate_batting_order(cls, v: int | None) -> int | None: """Ensure batting order is 1-9 if provided""" if v is not None and (v < 1 or v > 9): raise ValueError("batting_order must be between 1 and 9") return v class TeamLineupState(BaseModel): """ Represents a team's active lineup in the game. Provides helper methods for common lineup queries. """ team_id: int players: list[LineupPlayerState] = Field(default_factory=list) def get_batting_order(self) -> list[LineupPlayerState]: """ Get players in batting order (1-9). Returns: List of players sorted by batting_order """ return sorted( [p for p in self.players if p.batting_order is not None], key=lambda x: x.batting_order or 0, # Type narrowing: filtered None above ) def get_pitcher(self) -> LineupPlayerState | None: """ Get the active pitcher for this team. Returns: Active pitcher or None if not found """ pitchers = [p for p in self.players if p.position == "P" and p.is_active] return pitchers[0] if pitchers else None def get_player_by_lineup_id(self, lineup_id: int) -> LineupPlayerState | None: """ Get player by lineup ID. Args: lineup_id: The lineup entry ID Returns: Player or None if not found """ for player in self.players: if player.lineup_id == lineup_id: return player return None def get_batter(self, batting_order_idx: int) -> LineupPlayerState | None: """ Get batter by batting order index (0-8). Args: batting_order_idx: Index in batting order (0 = leadoff, 8 = 9th batter) Returns: Player at that position in the order, or None """ order = self.get_batting_order() if 0 <= batting_order_idx < len(order): return order[batting_order_idx] return None def get_player_by_card_id(self, card_id: int) -> LineupPlayerState | None: """ Get player by card ID. Args: card_id: The card/player ID Returns: Player or None if not found """ for player in self.players: if player.card_id == card_id: return player return None # ============================================================================ # DECISION STATE # ============================================================================ class DefensiveDecision(BaseModel): """ Defensive team strategic decisions for a play. These decisions affect play outcomes (e.g., infield depth affects double play chances). """ infield_depth: str = "normal" # infield_in, normal, corners_in outfield_depth: str = "normal" # normal, shallow hold_runners: list[int] = Field(default_factory=list) # [1, 3] = hold 1st and 3rd @field_validator("infield_depth") @classmethod def validate_infield_depth(cls, v: str) -> str: """Validate infield depth""" valid = ["infield_in", "normal", "corners_in"] if v not in valid: raise ValueError(f"infield_depth must be one of {valid}") return v @field_validator("outfield_depth") @classmethod def validate_outfield_depth(cls, v: str) -> str: """Validate outfield depth""" valid = ["normal", "shallow"] if v not in valid: raise ValueError(f"outfield_depth must be one of {valid}") return v class OffensiveDecision(BaseModel): """ Offensive team strategic decisions for a play. These decisions affect baserunner actions. Session 2 Update (2025-01-14): Replaced approach field with action field. Valid actions: swing_away, steal, check_jump, hit_and_run, sac_bunt, squeeze_bunt When action="steal", steal_attempts must specify which bases to steal. """ # Specific action choice action: str = "swing_away" # Base stealing - only used when action="steal" steal_attempts: list[int] = Field( default_factory=list ) # [2] = steal second, [2, 3] = double steal @field_validator("action") @classmethod def validate_action(cls, v: str) -> str: """Validate action is one of the allowed values""" valid_actions = [ "swing_away", "steal", "check_jump", "hit_and_run", "sac_bunt", "squeeze_bunt", ] if v not in valid_actions: raise ValueError(f"action must be one of {valid_actions}") return v @field_validator("steal_attempts") @classmethod def validate_steal_attempts(cls, v: list[int]) -> list[int]: """Validate steal attempt bases""" for base in v: if base not in [2, 3, 4]: # 2nd, 3rd, home raise ValueError("steal_attempts must contain only bases 2, 3, or 4") return v class ManualOutcomeSubmission(BaseModel): """ Model for human players submitting play outcomes in manual mode. In manual mode (SBA + PD manual), players roll dice, read physical cards, and submit the outcome they see. The system then validates and processes. Usage: - Players submit via WebSocket after reading their physical card - Outcome is required (what the card shows) - Hit location is optional (only needed for certain outcomes) - System validates location is provided when required Example: ManualOutcomeSubmission( outcome=PlayOutcome.GROUNDBALL_C, hit_location='SS' ) """ outcome: PlayOutcome # PlayOutcome enum from result_charts hit_location: str | None = ( None # '1B', '2B', 'SS', '3B', 'LF', 'CF', 'RF', 'P', 'C' ) @field_validator("hit_location") @classmethod def validate_hit_location(cls, v: str | None) -> str | None: """Validate hit location is a valid position.""" if v is None: return v valid_locations = ["1B", "2B", "SS", "3B", "LF", "CF", "RF", "P", "C"] if v not in valid_locations: raise ValueError(f"hit_location must be one of {valid_locations}") return v # ============================================================================ # X-CHECK RESULT # ============================================================================ @dataclass class XCheckResult: """ Intermediate state for X-Check play resolution. Tracks all dice rolls, table lookups, and conversions from initial x-check through final outcome determination. Resolution Flow: 1. Roll 1d20 + 3d6 2. Look up base_result from defense table[d20][defender_range] 3. Apply SPD test if needed (base_result = 'SPD') 4. Apply G2#/G3# → SI2 conversion if conditions met 5. Look up error_result from error chart[error_rating][3d6] 6. Determine final_outcome (may be ERROR if out+error) Attributes: position: Position being checked (SS, LF, 3B, etc.) d20_roll: Defense range table row selector (1-20) d6_roll: Error chart lookup value (3-18, sum of 3d6) defender_range: Defender's range rating (1-5, adjusted for playing in) defender_error_rating: Defender's error rating (0-25) base_result: Initial result from defense table (G1, F2, SI2, SPD, etc.) converted_result: Result after SPD/G2#/G3# conversions (may equal base_result) error_result: Error type from error chart (NO, E1, E2, E3, RP) final_outcome: Final PlayOutcome after all conversions defender_id: Player ID of defender hit_type: Combined result string for Play.hit_type (e.g. 'single_2_plus_error_1') """ position: str d20_roll: int d6_roll: int defender_range: int defender_error_rating: int defender_id: int base_result: str converted_result: str error_result: str # 'NO', 'E1', 'E2', 'E3', 'RP' final_outcome: PlayOutcome hit_type: str # Optional: SPD test details if applicable spd_test_roll: int | None = None spd_test_target: int | None = None spd_test_passed: bool | None = None def to_dict(self) -> dict: """Convert to dict for WebSocket transmission.""" return { "position": self.position, "d20_roll": self.d20_roll, "d6_roll": self.d6_roll, "defender_range": self.defender_range, "defender_error_rating": self.defender_error_rating, "defender_id": self.defender_id, "base_result": self.base_result, "converted_result": self.converted_result, "error_result": self.error_result, "final_outcome": self.final_outcome.value, "hit_type": self.hit_type, "spd_test": { "roll": self.spd_test_roll, "target": self.spd_test_target, "passed": self.spd_test_passed, } if self.spd_test_roll else None, } # ============================================================================ # PENDING X-CHECK STATE # ============================================================================ class PendingXCheck(BaseModel): """ Intermediate state for interactive x-check resolution. Stores all x-check workflow data including dice rolls, chart information, and player selections as the defensive player progresses through the interactive workflow. Workflow: 1. System rolls dice, looks up chart row → stores position, dice, chart 2. Defensive player selects result + error → stores selected_result, error_result 3. System checks for DECIDE situations → stores decide_* fields if applicable 4. Play resolves → PendingXCheck cleared Attributes: position: Position being checked (SS, LF, 3B, etc.) ab_roll_id: Reference to the original AbRoll for audit trail d20_roll: 1-20 (chart row selector) d6_individual: [d6_1, d6_2, d6_3] for transparency d6_total: Sum of 3d6 (error chart reference) chart_row: 5 column values for this d20 row chart_type: Type of defense table used spd_d20: Pre-rolled d20 if any column is SPD (click-to-reveal) defender_lineup_id: Player making the defensive play selected_result: Result code chosen by defensive player error_result: Error type chosen by defensive player decide_runner_base: Base the runner is on for DECIDE decide_target_base: Base runner wants to reach for DECIDE decide_advance: Offensive player's DECIDE choice decide_throw: Defensive player's throw target choice decide_d20: Speed check d20 for DECIDE throw on runner """ # Initial state (set when x-check initiated) position: str ab_roll_id: str d20_roll: int = Field(ge=1, le=20) d6_individual: list[int] = Field(min_length=3, max_length=3) d6_total: int = Field(ge=3, le=18) chart_row: list[str] = Field(min_length=5, max_length=5) chart_type: str # "infield" | "outfield" | "catcher" spd_d20: int | None = Field(default=None, ge=1, le=20) defender_lineup_id: int # Result selection (set after defensive player selects) selected_result: str | None = None error_result: str | None = None # DECIDE workflow (set during DECIDE flow) decide_runner_base: int | None = Field(default=None, ge=1, le=3) decide_target_base: int | None = Field(default=None, ge=2, le=4) decide_advance: bool | None = None decide_throw: str | None = None # "runner" | "first" decide_d20: int | None = Field(default=None, ge=1, le=20) @field_validator("chart_type") @classmethod def validate_chart_type(cls, v: str) -> str: """Ensure chart_type is valid""" valid = ["infield", "outfield", "catcher"] if v not in valid: raise ValueError(f"chart_type must be one of {valid}") return v @field_validator("d6_individual") @classmethod def validate_d6_individual(cls, v: list[int]) -> list[int]: """Ensure each d6 is 1-6""" for die in v: if not 1 <= die <= 6: raise ValueError(f"Each d6 must be 1-6, got {die}") return v @field_validator("error_result") @classmethod def validate_error_result(cls, v: str | None) -> str | None: """Ensure error_result is valid""" if v is not None: valid = ["NO", "E1", "E2", "E3", "RP"] if v not in valid: raise ValueError(f"error_result must be one of {valid}") return v @field_validator("decide_throw") @classmethod def validate_decide_throw(cls, v: str | None) -> str | None: """Ensure decide_throw is valid""" if v is not None: valid = ["runner", "first"] if v not in valid: raise ValueError(f"decide_throw must be one of {valid}") return v model_config = ConfigDict(frozen=False) # Allow mutation during workflow # ============================================================================ # PENDING UNCAPPED HIT STATE # ============================================================================ class PendingUncappedHit(BaseModel): """ Intermediate state for interactive uncapped hit resolution. Stores all uncapped hit workflow data as offensive/defensive players make runner advancement decisions via WebSocket. Workflow: 1. System identifies eligible runners → stores lead/trail info 2. Offensive player decides if lead runner attempts advance 3. Defensive player decides if they throw to base 4. If trail runner exists, offensive decides trail advance 5. If both advance, defensive picks throw target 6. d20 speed check → offensive declares safe/out from card 7. Play finalizes with accumulated decisions Attributes: hit_type: "single" or "double" hit_location: Outfield position (LF, CF, RF) ab_roll_id: Reference to original AbRoll for audit trail lead_runner_base: Base of lead runner (1 or 2) lead_runner_lineup_id: Lineup ID of lead runner lead_target_base: Base lead runner is attempting (3 or 4=HOME) trail_runner_base: Base of trail runner (0=batter, 1=R1), None if no trail trail_runner_lineup_id: Lineup ID of trail runner, None if no trail trail_target_base: Base trail runner attempts, None if no trail auto_runners: Auto-scoring runners [(from_base, to_base, lineup_id)] batter_base: Minimum base batter reaches (1 for single, 2 for double) batter_lineup_id: Batter's lineup ID lead_advance: Offensive decision - does lead runner attempt advance? defensive_throw: Defensive decision - throw to base? trail_advance: Offensive decision - does trail runner attempt advance? throw_target: Defensive decision - throw at "lead" or "trail"? speed_check_d20: d20 roll for speed check speed_check_runner: Which runner is being checked ("lead" or "trail") speed_check_result: "safe" or "out" """ # Hit context hit_type: str # "single" or "double" hit_location: str # "LF", "CF", or "RF" ab_roll_id: str # Lead runner lead_runner_base: int # 1 or 2 lead_runner_lineup_id: int lead_target_base: int # 3 or 4 (HOME) # Trail runner (None if no trail) trail_runner_base: int | None = None # 0=batter, 1=R1 trail_runner_lineup_id: int | None = None trail_target_base: int | None = None # Auto-scoring runners (recorded before decision tree) auto_runners: list[tuple[int, int, int]] = Field(default_factory=list) # [(from_base, to_base, lineup_id), ...] e.g. R3 scores, R2 scores on double # Batter destination (minimum base) batter_base: int # 1 for single, 2 for double batter_lineup_id: int # Decisions (filled progressively) lead_advance: bool | None = None defensive_throw: bool | None = None trail_advance: bool | None = None throw_target: str | None = None # "lead" or "trail" # Speed check speed_check_d20: int | None = Field(default=None, ge=1, le=20) speed_check_runner: str | None = None # "lead" or "trail" speed_check_result: str | None = None # "safe" or "out" @field_validator("hit_type") @classmethod def validate_hit_type(cls, v: str) -> str: """Ensure hit_type is valid""" valid = ["single", "double"] if v not in valid: raise ValueError(f"hit_type must be one of {valid}") return v @field_validator("hit_location") @classmethod def validate_hit_location(cls, v: str) -> str: """Ensure hit_location is an outfield position""" valid = ["LF", "CF", "RF"] if v not in valid: raise ValueError(f"hit_location must be one of {valid}") return v @field_validator("throw_target") @classmethod def validate_throw_target(cls, v: str | None) -> str | None: """Ensure throw_target is valid""" if v is not None: valid = ["lead", "trail"] if v not in valid: raise ValueError(f"throw_target must be one of {valid}") return v @field_validator("speed_check_result") @classmethod def validate_speed_check_result(cls, v: str | None) -> str | None: """Ensure speed_check_result is valid""" if v is not None: valid = ["safe", "out"] if v not in valid: raise ValueError(f"speed_check_result must be one of {valid}") return v model_config = ConfigDict(frozen=False) # ============================================================================ # GAME STATE # ============================================================================ class GameState(BaseModel): """ Complete in-memory game state. This is the core state model representing an active game. It is optimized for fast in-memory operations during gameplay. Attributes: game_id: Unique game identifier league_id: League identifier ('sba' or 'pd') home_team_id: Home team ID (from league API) away_team_id: Away team ID (from league API) home_team_is_ai: Whether home team is AI-controlled away_team_is_ai: Whether away team is AI-controlled status: Game status (pending, active, paused, completed) inning: Current inning (1-9+) half: Current half ('top' or 'bottom') outs: Current outs (0-2) home_score: Home team score away_score: Away team score runners: List of runners currently on base away_team_batter_idx: Away team batting order position (0-8) home_team_batter_idx: Home team batting order position (0-8) current_batter: Snapshot - LineupPlayerState for current batter (required) current_pitcher: Snapshot - LineupPlayerState for current pitcher (optional) current_catcher: Snapshot - LineupPlayerState for current catcher (optional) current_on_base_code: Snapshot - sequential chart encoding (0=empty, 1=R1, 2=R2, 3=R3, 4=R1+R2, 5=R1+R3, 6=R2+R3, 7=loaded) pending_decision: Type of decision awaiting ('defensive', 'offensive', 'result_selection') decisions_this_play: Accumulated decisions for current play play_count: Total plays so far last_play_result: Description of last play outcome """ game_id: UUID league_id: str # Teams home_team_id: int away_team_id: int home_team_is_ai: bool = False away_team_is_ai: bool = False # Team display info (for UI - fetched from league API when game created) home_team_name: str | None = None # e.g., "Chicago Cyclones" home_team_abbrev: str | None = None # e.g., "CHC" home_team_color: str | None = None # e.g., "ff5349" (no # prefix) home_team_dice_color: str | None = None # Dice color, default "cc0000" (red) home_team_thumbnail: str | None = None # Team logo URL away_team_name: str | None = None away_team_abbrev: str | None = None away_team_color: str | None = None away_team_dice_color: str | None = None # Dice color, default "cc0000" (red) away_team_thumbnail: str | None = None # Creator (for demo/testing - creator can control home team) creator_discord_id: str | None = None # Resolution mode auto_mode: bool = ( False # True = auto-generate outcomes (PD only), False = manual submissions ) # Game rules (configurable per game) regulation_innings: int = Field(default=9, ge=1) # Standard 9, doubleheader 7, etc. outs_per_inning: int = Field(default=3, ge=1) # Standard 3 # Game state status: str = "pending" # pending, active, paused, completed inning: int = Field(default=1, ge=1) half: str = "top" # top or bottom outs: int = Field(default=0, ge=0, le=2) # Score home_score: int = Field(default=0, ge=0) away_score: int = Field(default=0, ge=0) # Runners (direct references matching DB structure) on_first: LineupPlayerState | None = None on_second: LineupPlayerState | None = None on_third: LineupPlayerState | None = None # Batting order tracking (per team) - indexes into batting order (0-8) away_team_batter_idx: int = Field(default=0, ge=0, le=8) home_team_batter_idx: int = Field(default=0, ge=0, le=8) # Current play snapshot (set by _prepare_next_play) # These capture the state BEFORE each play for accurate record-keeping # Changed to full objects for consistency with on_first/on_second/on_third current_batter: LineupPlayerState current_pitcher: LineupPlayerState | None = None current_catcher: LineupPlayerState | None = None current_on_base_code: int = Field( default=0, ge=0 ) # Sequential chart encoding: 0=empty, 1=R1, 2=R2, 3=R3, 4=R1+R2, 5=R1+R3, 6=R2+R3, 7=loaded # Decision tracking pending_decision: str | None = None # 'defensive', 'offensive', 'result_selection' decisions_this_play: dict[str, Any] = Field(default_factory=dict) # Phase 3: Enhanced decision workflow pending_defensive_decision: DefensiveDecision | None = None pending_offensive_decision: OffensiveDecision | None = None decision_phase: str = ( "idle" # idle, awaiting_defensive, awaiting_offensive, resolving, completed ) decision_deadline: str | None = None # ISO8601 timestamp for timeout # Manual mode support (Week 7 Task 7) pending_manual_roll: Any | None = ( None # AbRoll stored when dice rolled in manual mode ) # Interactive x-check workflow pending_x_check: PendingXCheck | None = None # Interactive uncapped hit workflow pending_uncapped_hit: PendingUncappedHit | None = None # Play tracking play_count: int = Field(default=0, ge=0) last_play_result: str | None = None @field_validator("league_id") @classmethod def validate_league_id(cls, v: str) -> str: """Ensure league_id is valid""" valid_leagues = ["sba", "pd"] if v not in valid_leagues: raise ValueError(f"league_id must be one of {valid_leagues}") return v @field_validator("status") @classmethod def validate_status(cls, v: str) -> str: """Ensure status is valid""" valid_statuses = ["pending", "active", "paused", "completed"] if v not in valid_statuses: raise ValueError(f"status must be one of {valid_statuses}") return v @field_validator("half") @classmethod def validate_half(cls, v: str) -> str: """Ensure half is valid""" if v not in ["top", "bottom"]: raise ValueError("half must be 'top' or 'bottom'") return v @field_validator("pending_decision") @classmethod def validate_pending_decision(cls, v: str | None) -> str | None: """Ensure pending_decision is valid""" if v is not None: valid = [ "defensive", "offensive", "result_selection", "substitution", "x_check_result", "decide_advance", "decide_throw", "decide_result", "uncapped_lead_advance", "uncapped_defensive_throw", "uncapped_trail_advance", "uncapped_throw_target", "uncapped_safe_out", ] if v not in valid: raise ValueError(f"pending_decision must be one of {valid}") return v @field_validator("decision_phase") @classmethod def validate_decision_phase(cls, v: str) -> str: """Ensure decision_phase is valid""" valid = [ "idle", "awaiting_defensive", "awaiting_offensive", "resolving", "completed", "awaiting_x_check_result", "awaiting_decide_advance", "awaiting_decide_throw", "awaiting_decide_result", "awaiting_uncapped_lead_advance", "awaiting_uncapped_defensive_throw", "awaiting_uncapped_trail_advance", "awaiting_uncapped_throw_target", "awaiting_uncapped_safe_out", ] if v not in valid: raise ValueError(f"decision_phase must be one of {valid}") return v # Helper methods def get_batting_team_id(self) -> int: """Get the ID of the team currently batting""" return self.away_team_id if self.half == "top" else self.home_team_id def get_fielding_team_id(self) -> int: """Get the ID of the team currently fielding""" return self.home_team_id if self.half == "top" else self.away_team_id def is_batting_team_ai(self) -> bool: """Check if the currently batting team is AI-controlled""" return self.away_team_is_ai if self.half == "top" else self.home_team_is_ai def is_fielding_team_ai(self) -> bool: """Check if the currently fielding team is AI-controlled""" return self.home_team_is_ai if self.half == "top" else self.away_team_is_ai def get_defender_for_position( self, position: str, state_manager: Any, # 'StateManager' - avoid circular import ) -> LineupPlayerState | None: """ Get the defender playing at specified position. Uses StateManager's lineup cache to find the defensive player. Args: position: Position code (P, C, 1B, 2B, 3B, SS, LF, CF, RF) state_manager: StateManager instance for lineup access Returns: LineupPlayerState if found, None otherwise Phase 3E-Main: Integrated with X-Check resolution """ # Get fielding team's lineup from cache fielding_team_id = self.get_fielding_team_id() lineup = state_manager.get_lineup(self.game_id, fielding_team_id) if not lineup: logger.warning(f"No lineup found for fielding team {fielding_team_id}") return None # Find active player at the specified position for player in lineup.players: if player.position == position and player.is_active: return player logger.warning( f"No active player found at position {position} for team {fielding_team_id}" ) return None def is_runner_on_first(self) -> bool: """Check if there's a runner on first base""" return self.on_first is not None def is_runner_on_second(self) -> bool: """Check if there's a runner on second base""" return self.on_second is not None def is_runner_on_third(self) -> bool: """Check if there's a runner on third base""" return self.on_third is not None def calculate_on_base_code(self) -> int: """ Calculate on-base code from current runner positions. Returns sequential chart encoding matching the official rulebook charts: 0 = empty bases 1 = runner on 1st only 2 = runner on 2nd only 3 = runner on 3rd only 4 = runners on 1st and 2nd 5 = runners on 1st and 3rd 6 = runners on 2nd and 3rd 7 = bases loaded (1st, 2nd, and 3rd) """ r1 = self.on_first is not None r2 = self.on_second is not None r3 = self.on_third is not None if r1 and r2 and r3: return 7 # Loaded if r2 and r3: return 6 # R2+R3 if r1 and r3: return 5 # R1+R3 if r1 and r2: return 4 # R1+R2 if r3: return 3 # R3 only if r2: return 2 # R2 only if r1: return 1 # R1 only return 0 # Empty def get_runner_at_base(self, base: int) -> LineupPlayerState | None: """Get runner at specified base (1, 2, or 3)""" if base == 1: return self.on_first if base == 2: return self.on_second if base == 3: return self.on_third return None def bases_occupied(self) -> list[int]: """Get list of occupied bases""" bases = [] if self.on_first: bases.append(1) if self.on_second: bases.append(2) if self.on_third: bases.append(3) return bases def get_all_runners(self) -> list[tuple[int, LineupPlayerState]]: """Returns list of (base, player) tuples for occupied bases""" runners = [] if self.on_first: runners.append((1, self.on_first)) if self.on_second: runners.append((2, self.on_second)) if self.on_third: runners.append((3, self.on_third)) return runners def clear_bases(self) -> None: """Clear all runners from bases""" self.on_first = None self.on_second = None self.on_third = None def add_runner(self, player: LineupPlayerState, base: int) -> None: """ Add a runner to a base Args: player: LineupPlayerState to place on base base: Base number (1, 2, or 3) """ if base not in [1, 2, 3]: raise ValueError("base must be 1, 2, or 3") if base == 1: self.on_first = player elif base == 2: self.on_second = player elif base == 3: self.on_third = player def advance_runner(self, from_base: int, to_base: int) -> None: """ Advance a runner from one base to another. Args: from_base: Starting base (1, 2, or 3) to_base: Ending base (2, 3, or 4 for home) """ runner = self.get_runner_at_base(from_base) if runner: # Remove from current base if from_base == 1: self.on_first = None elif from_base == 2: self.on_second = None elif from_base == 3: self.on_third = None if to_base == 4: # Runner scored if self.half == "top": self.away_score += 1 else: self.home_score += 1 else: # Runner advanced to another base if to_base == 1: self.on_first = runner elif to_base == 2: self.on_second = runner elif to_base == 3: self.on_third = runner def increment_outs(self) -> bool: """ Increment outs by 1. Returns: True if half-inning is over (3 outs), False otherwise """ self.outs += 1 if self.outs >= 3: return True return False def end_half_inning(self) -> None: """End the current half-inning and prepare for next""" self.outs = 0 self.clear_bases() if self.half == "top": # Switch to bottom of same inning self.half = "bottom" else: # End inning, go to top of next inning self.half = "top" self.inning += 1 def is_game_over(self) -> bool: """ Check if game is over. Game ends after regulation innings if home team is ahead or tied, or immediately if home team takes lead in bottom of final inning or later. Uses self.regulation_innings (default 9) for game length. """ reg = self.regulation_innings if self.inning < reg: return False if self.inning == reg and self.half == "bottom": # Bottom of final regulation inning - game ends if home team ahead if self.home_score > self.away_score: return True if self.inning > reg and self.half == "bottom": # Extra innings, bottom half - walk-off possible if self.home_score > self.away_score: return True if ( self.inning >= reg and self.half == "top" and self.outs >= self.outs_per_inning ): # Top of final inning or later just ended if self.home_score != self.away_score: return True return False model_config = ConfigDict( json_schema_extra={ "example": { "game_id": "550e8400-e29b-41d4-a716-446655440000", "league_id": "sba", "home_team_id": 1, "away_team_id": 2, "home_team_is_ai": False, "away_team_is_ai": True, "status": "active", "inning": 3, "half": "top", "outs": 1, "home_score": 2, "away_score": 1, "on_first": None, "on_second": { "lineup_id": 5, "card_id": 123, "position": "CF", "batting_order": 2, }, "on_third": None, "away_team_batter_idx": 3, "home_team_batter_idx": 5, "current_batter": { "lineup_id": 8, "card_id": 125, "position": "RF", "batting_order": 4, }, "current_pitcher": { "lineup_id": 10, "card_id": 130, "position": "P", "batting_order": 9, }, "current_catcher": { "lineup_id": 11, "card_id": 131, "position": "C", "batting_order": 2, }, "current_on_base_code": 2, "pending_decision": None, "decisions_this_play": {}, "play_count": 15, "last_play_result": "Single to left field", } } ) # ============================================================================ # MODEL REBUILD (for forward references) # ============================================================================ # Import PositionRating at runtime for model rebuild from app.models.player_models import PositionRating # noqa: E402 # Rebuild models that use forward references LineupPlayerState.model_rebuild() GameState.model_rebuild() # ============================================================================ # EXPORTS # ============================================================================ __all__ = [ "LineupPlayerState", "TeamLineupState", "DefensiveDecision", "OffensiveDecision", "PendingUncappedHit", "GameState", ]