- Add player_positions JSONB column to roster_links (migration 006) - Add player_data JSONB column to cache name/image/headshot (migration 007) - Add is_pitcher/is_batter computed properties for two-way player support - Update lineup submission to populate RosterLink with all players + positions - Update get_bench handler to use cached data (no runtime API calls) - Add BenchPlayer type to frontend with proper filtering - Add new Lineup components: InlineSubstitutionPanel, LineupSlotRow, PositionSelector, UnifiedLineupTab - Add integration tests for get_bench_players Bench players now load instantly without API dependency, and properly filter batters vs pitchers (including CP closer position). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
415 lines
14 KiB
Python
415 lines
14 KiB
Python
import uuid
|
|
|
|
import pendulum
|
|
from sqlalchemy import (
|
|
JSON,
|
|
Boolean,
|
|
CheckConstraint,
|
|
Column,
|
|
DateTime,
|
|
Float,
|
|
ForeignKey,
|
|
Integer,
|
|
String,
|
|
Text,
|
|
UniqueConstraint,
|
|
func,
|
|
)
|
|
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
|
from sqlalchemy.orm import relationship
|
|
|
|
from app.database.session import Base
|
|
|
|
|
|
class GameCardsetLink(Base):
|
|
"""Link table for PD games - tracks which cardsets are allowed"""
|
|
|
|
__tablename__ = "game_cardset_links"
|
|
|
|
game_id = Column(
|
|
UUID(as_uuid=True), ForeignKey("games.id", ondelete="CASCADE"), primary_key=True
|
|
)
|
|
cardset_id = Column(Integer, primary_key=True)
|
|
priority = Column(Integer, default=1, index=True)
|
|
|
|
# Relationships
|
|
game = relationship("Game", back_populates="cardset_links")
|
|
|
|
|
|
class RosterLink(Base):
|
|
"""Tracks eligible cards (PD) or players (SBA) for a game
|
|
|
|
PD League: Uses card_id to track which cards are rostered
|
|
SBA League: Uses player_id to track which players are rostered
|
|
|
|
Exactly one of card_id or player_id must be populated per row.
|
|
|
|
The player_positions field stores the player's natural positions from the API
|
|
for use in substitution UI filtering (batters vs pitchers). This supports
|
|
two-way players like Shohei Ohtani who have both pitching and batting positions.
|
|
Example: ["SS", "2B", "3B"] or ["SP", "RP"] or ["SP", "DH"]
|
|
"""
|
|
|
|
__tablename__ = "roster_links"
|
|
|
|
# Surrogate primary key (allows nullable card_id/player_id)
|
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
|
|
game_id = Column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("games.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
index=True,
|
|
)
|
|
card_id = Column(Integer, nullable=True) # PD only
|
|
player_id = Column(Integer, nullable=True) # SBA only
|
|
team_id = Column(Integer, nullable=False, index=True)
|
|
|
|
# Player's natural positions from API (for substitution UI filtering)
|
|
# Supports two-way players with both pitching and batting positions
|
|
player_positions = Column(JSONB, default=list)
|
|
|
|
# Cached player data (name, image, headshot) to avoid runtime API calls
|
|
# Populated at lineup submission time
|
|
player_data = Column(JSONB, default=dict)
|
|
|
|
# Relationships
|
|
game = relationship("Game", back_populates="roster_links")
|
|
|
|
# Table-level constraints
|
|
__table_args__ = (
|
|
# Ensure exactly one ID is populated (XOR logic)
|
|
CheckConstraint(
|
|
"(card_id IS NOT NULL)::int + (player_id IS NOT NULL)::int = 1",
|
|
name="roster_link_one_id_required",
|
|
),
|
|
# Unique constraint for PD: one card per game
|
|
UniqueConstraint("game_id", "card_id", name="uq_game_card"),
|
|
# Unique constraint for SBA: one player per game
|
|
UniqueConstraint("game_id", "player_id", name="uq_game_player"),
|
|
)
|
|
|
|
|
|
class Game(Base):
|
|
"""Game model"""
|
|
|
|
__tablename__ = "games"
|
|
|
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
league_id = Column(String(50), nullable=False, index=True)
|
|
home_team_id = Column(Integer, nullable=False)
|
|
away_team_id = Column(Integer, nullable=False)
|
|
status = Column(String(20), nullable=False, default="pending", index=True)
|
|
game_mode = Column(String(20), nullable=False)
|
|
visibility = Column(String(20), nullable=False)
|
|
current_inning = Column(Integer)
|
|
current_half = Column(String(10))
|
|
home_score = Column(Integer, default=0)
|
|
away_score = Column(Integer, default=0)
|
|
|
|
# AI opponent configuration
|
|
home_team_is_ai = Column(Boolean, default=False)
|
|
away_team_is_ai = Column(Boolean, default=False)
|
|
ai_difficulty = Column(String(20), nullable=True)
|
|
|
|
# External schedule reference (league-agnostic - works for SBA, PD, etc.)
|
|
schedule_game_id = Column(Integer, nullable=True, index=True)
|
|
|
|
# Timestamps
|
|
created_at = Column(
|
|
DateTime, default=lambda: pendulum.now("UTC").naive(), index=True
|
|
)
|
|
started_at = Column(DateTime)
|
|
completed_at = Column(DateTime)
|
|
|
|
# Results
|
|
winner_team_id = Column(Integer)
|
|
game_metadata = Column(JSON, default=dict)
|
|
|
|
# Relationships
|
|
plays = relationship("Play", back_populates="game", cascade="all, delete-orphan")
|
|
lineups = relationship(
|
|
"Lineup", back_populates="game", cascade="all, delete-orphan"
|
|
)
|
|
cardset_links = relationship(
|
|
"GameCardsetLink", back_populates="game", cascade="all, delete-orphan"
|
|
)
|
|
roster_links = relationship(
|
|
"RosterLink", back_populates="game", cascade="all, delete-orphan"
|
|
)
|
|
session = relationship(
|
|
"GameSession",
|
|
back_populates="game",
|
|
uselist=False,
|
|
cascade="all, delete-orphan",
|
|
)
|
|
rolls = relationship("Roll", back_populates="game", cascade="all, delete-orphan")
|
|
|
|
|
|
class Play(Base):
|
|
"""Play model - tracks individual plays/at-bats"""
|
|
|
|
__tablename__ = "plays"
|
|
|
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
game_id = Column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("games.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
index=True,
|
|
)
|
|
play_number = Column(Integer, nullable=False)
|
|
|
|
# Game state at start of play
|
|
inning = Column(Integer, nullable=False, default=1)
|
|
half = Column(String(10), nullable=False)
|
|
outs_before = Column(Integer, nullable=False, default=0)
|
|
batting_order = Column(Integer, nullable=False, default=1)
|
|
away_score = Column(Integer, default=0)
|
|
home_score = Column(Integer, default=0)
|
|
|
|
# Players involved (ForeignKeys to Lineup)
|
|
batter_id = Column(Integer, ForeignKey("lineups.id"), nullable=False)
|
|
pitcher_id = Column(Integer, ForeignKey("lineups.id"), nullable=False)
|
|
catcher_id = Column(Integer, ForeignKey("lineups.id"), nullable=False)
|
|
defender_id = Column(Integer, ForeignKey("lineups.id"), nullable=True)
|
|
runner_id = Column(Integer, ForeignKey("lineups.id"), nullable=True)
|
|
|
|
# Base runners (ForeignKeys to Lineup for who's on base)
|
|
on_first_id = Column(Integer, ForeignKey("lineups.id"), nullable=True)
|
|
on_second_id = Column(Integer, ForeignKey("lineups.id"), nullable=True)
|
|
on_third_id = Column(Integer, ForeignKey("lineups.id"), nullable=True)
|
|
|
|
# Runner final positions (None = out, 1-4 = base)
|
|
on_first_final = Column(Integer, nullable=True)
|
|
on_second_final = Column(Integer, nullable=True)
|
|
on_third_final = Column(Integer, nullable=True)
|
|
batter_final = Column(Integer, nullable=True)
|
|
|
|
# Base state code for efficient queries (bit field: 1=1st, 2=2nd, 4=3rd, 7=loaded)
|
|
on_base_code = Column(Integer, default=0)
|
|
|
|
# Strategic decisions
|
|
defensive_choices = Column(JSON, default=dict)
|
|
offensive_choices = Column(JSON, default=dict)
|
|
|
|
# Play result
|
|
dice_roll = Column(String(50))
|
|
hit_type = Column(
|
|
String(50),
|
|
comment="Detailed hit/out type including errors. Examples: "
|
|
"'single_2_plus_error_1', 'f2_plus_error_2', 'g1_no_error'. "
|
|
"Used primarily for X-Check plays to preserve full resolution details.",
|
|
)
|
|
result_description = Column(Text)
|
|
outs_recorded = Column(Integer, nullable=False, default=0)
|
|
runs_scored = Column(Integer, default=0)
|
|
|
|
# Defensive details
|
|
check_pos = Column(
|
|
String(10),
|
|
nullable=True,
|
|
comment="Position checked for X-Check plays (SS, LF, 3B, etc.). "
|
|
"Non-null indicates this was an X-Check play. "
|
|
"Used only for X-Checks - all other plays leave this null.",
|
|
)
|
|
error = Column(Integer, default=0)
|
|
|
|
# Batting statistics
|
|
pa = Column(Integer, default=0)
|
|
ab = Column(Integer, default=0)
|
|
hit = Column(Integer, default=0)
|
|
double = Column(Integer, default=0)
|
|
triple = Column(Integer, default=0)
|
|
homerun = Column(Integer, default=0)
|
|
bb = Column(Integer, default=0)
|
|
so = Column(Integer, default=0)
|
|
hbp = Column(Integer, default=0)
|
|
rbi = Column(Integer, default=0)
|
|
sac = Column(Integer, default=0)
|
|
ibb = Column(Integer, default=0)
|
|
gidp = Column(Integer, default=0)
|
|
|
|
# Baserunning statistics
|
|
sb = Column(Integer, default=0)
|
|
cs = Column(Integer, default=0)
|
|
|
|
# Pitching events
|
|
wild_pitch = Column(Integer, default=0)
|
|
passed_ball = Column(Integer, default=0)
|
|
pick_off = Column(Integer, default=0)
|
|
balk = Column(Integer, default=0)
|
|
|
|
# Ballpark power events
|
|
bphr = Column(Integer, default=0)
|
|
bpfo = Column(Integer, default=0)
|
|
bp1b = Column(Integer, default=0)
|
|
bplo = Column(Integer, default=0)
|
|
|
|
# Advanced analytics
|
|
wpa = Column(Float, default=0.0)
|
|
re24 = Column(Float, default=0.0)
|
|
|
|
# Earned/unearned runs
|
|
run = Column(Integer, default=0)
|
|
e_run = Column(Integer, default=0)
|
|
|
|
# Game situation flags
|
|
is_tied = Column(Boolean, default=False)
|
|
is_go_ahead = Column(Boolean, default=False)
|
|
is_new_inning = Column(Boolean, default=False)
|
|
in_pow = Column(Boolean, default=False)
|
|
|
|
# Play workflow
|
|
complete = Column(Boolean, default=False, index=True)
|
|
locked = Column(Boolean, default=False)
|
|
|
|
# Timestamps
|
|
created_at = Column(
|
|
DateTime, default=lambda: pendulum.now("UTC").naive(), index=True
|
|
)
|
|
|
|
# Extensibility (use for custom runner data like jump status, etc.)
|
|
play_metadata = Column(JSON, default=dict)
|
|
|
|
# Relationships
|
|
game = relationship("Game", back_populates="plays")
|
|
batter = relationship("Lineup", foreign_keys=[batter_id], lazy="joined")
|
|
pitcher = relationship("Lineup", foreign_keys=[pitcher_id], lazy="joined")
|
|
catcher = relationship("Lineup", foreign_keys=[catcher_id], lazy="joined")
|
|
defender = relationship("Lineup", foreign_keys=[defender_id])
|
|
runner = relationship("Lineup", foreign_keys=[runner_id])
|
|
on_first = relationship("Lineup", foreign_keys=[on_first_id])
|
|
on_second = relationship("Lineup", foreign_keys=[on_second_id])
|
|
on_third = relationship("Lineup", foreign_keys=[on_third_id])
|
|
|
|
@property
|
|
def ai_is_batting(self) -> bool:
|
|
"""Determine if current batting team is AI-controlled"""
|
|
# Type cast for Pylance - at runtime self.half is a string, not a Column
|
|
half_value: str = self.half # type: ignore[assignment]
|
|
if half_value == "top":
|
|
return self.game.away_team_is_ai
|
|
return self.game.home_team_is_ai
|
|
|
|
@property
|
|
def ai_is_fielding(self) -> bool:
|
|
"""Determine if current fielding team is AI-controlled"""
|
|
# Type cast for Pylance - at runtime self.half is a string, not a Column
|
|
half_value: str = self.half # type: ignore[assignment]
|
|
if half_value == "top":
|
|
return self.game.home_team_is_ai
|
|
return self.game.away_team_is_ai
|
|
|
|
|
|
class Lineup(Base):
|
|
"""Lineup model - tracks player assignments in a game
|
|
|
|
PD League: Uses card_id to track which cards are in the lineup
|
|
SBA League: Uses player_id to track which players are in the lineup
|
|
|
|
Exactly one of card_id or player_id must be populated per row.
|
|
"""
|
|
|
|
__tablename__ = "lineups"
|
|
|
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
game_id = Column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("games.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
index=True,
|
|
)
|
|
team_id = Column(Integer, nullable=False, index=True)
|
|
|
|
# Polymorphic player reference
|
|
card_id = Column(Integer, nullable=True) # PD only
|
|
player_id = Column(Integer, nullable=True) # SBA only
|
|
|
|
position = Column(String(10), nullable=False)
|
|
batting_order = Column(Integer)
|
|
|
|
# Substitution tracking
|
|
is_starter = Column(Boolean, default=True)
|
|
is_active = Column(Boolean, default=True, index=True)
|
|
entered_inning = Column(Integer, default=1)
|
|
replacing_id = Column(Integer, nullable=True) # Lineup ID of player being replaced
|
|
after_play = Column(
|
|
Integer, nullable=True
|
|
) # Play number when substitution occurred
|
|
|
|
# Pitcher fatigue
|
|
is_fatigued = Column(Boolean, nullable=True)
|
|
|
|
# Extensibility
|
|
lineup_metadata = Column(JSON, default=dict)
|
|
|
|
# Relationships
|
|
game = relationship("Game", back_populates="lineups")
|
|
|
|
# Table-level constraints
|
|
__table_args__ = (
|
|
# Ensure exactly one ID is populated (XOR logic)
|
|
CheckConstraint(
|
|
"(card_id IS NOT NULL)::int + (player_id IS NOT NULL)::int = 1",
|
|
name="lineup_one_id_required",
|
|
),
|
|
)
|
|
|
|
|
|
class GameSession(Base):
|
|
"""Game session tracking - real-time WebSocket state"""
|
|
|
|
__tablename__ = "game_sessions"
|
|
|
|
game_id = Column(
|
|
UUID(as_uuid=True), ForeignKey("games.id", ondelete="CASCADE"), primary_key=True
|
|
)
|
|
connected_users = Column(JSON, default=dict)
|
|
last_action_at = Column(
|
|
DateTime, default=lambda: pendulum.now("UTC").naive(), index=True
|
|
)
|
|
state_snapshot = Column(JSON, default=dict)
|
|
|
|
# Relationships
|
|
game = relationship("Game", back_populates="session")
|
|
|
|
|
|
class Roll(Base):
|
|
"""
|
|
Stores dice roll history for auditing and analytics
|
|
|
|
Tracks all dice rolls with full context for game recovery and statistics.
|
|
Supports both SBA and PD leagues with polymorphic player_id.
|
|
"""
|
|
|
|
__tablename__ = "rolls"
|
|
|
|
roll_id = Column(String, primary_key=True)
|
|
game_id = Column(
|
|
UUID(as_uuid=True),
|
|
ForeignKey("games.id", ondelete="CASCADE"),
|
|
nullable=False,
|
|
index=True,
|
|
)
|
|
roll_type = Column(
|
|
String, nullable=False, index=True
|
|
) # 'ab', 'jump', 'fielding', 'd20'
|
|
league_id = Column(String, nullable=False, index=True)
|
|
|
|
# Auditing/Analytics fields
|
|
team_id = Column(Integer, index=True)
|
|
player_id = Column(
|
|
Integer, index=True
|
|
) # Polymorphic: Lineup.player_id (SBA) or Lineup.card_id (PD)
|
|
|
|
# Full roll data stored as JSONB for flexibility
|
|
roll_data = Column(JSONB, nullable=False) # Complete roll with all dice values
|
|
context = Column(JSONB) # Additional metadata (pitcher, inning, outs, etc.)
|
|
|
|
timestamp = Column(DateTime(timezone=True), nullable=False, index=True)
|
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
|
|
|
# Relationships
|
|
game = relationship("Game", back_populates="rolls")
|