strat-gameplay-webapp/backend/app/models/db_models.py
Cal Corum a1f42a93b8 CLAUDE: Implement Phase 3A - X-Check data models and enums
Add foundational data structures for X-Check play resolution system:

Models Added:
- PositionRating: Defensive ratings (range 1-5, error 0-88) for X-Check resolution
- XCheckResult: Dataclass tracking complete X-Check resolution flow with dice rolls,
  conversions (SPD test, G2#/G3#→SI2), error results, and final outcomes
- BasePlayer.active_position_rating: Optional field for current defensive position

Enums Extended:
- PlayOutcome.X_CHECK: New outcome type requiring special resolution
- PlayOutcome.is_x_check(): Helper method for type checking

Documentation Enhanced:
- Play.check_pos: Documented as X-Check position identifier
- Play.hit_type: Documented with examples (single_2_plus_error_1, etc.)

Utilities Added:
- app/core/cache.py: Redis cache key helpers for player positions and game state

Implementation Planning:
- Complete 6-phase implementation plan (3A-3F) documented in .claude/implementation/
- Phase 3A complete with all acceptance criteria met
- Zero breaking changes, all existing tests passing

Next: Phase 3B will add defense tables, error charts, and advancement logic

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-01 15:32:09 -05:00

333 lines
13 KiB
Python

from sqlalchemy import Column, Integer, String, Boolean, DateTime, JSON, Text, ForeignKey, Float, CheckConstraint, UniqueConstraint, func
from sqlalchemy.orm import relationship
from sqlalchemy.dialects.postgresql import UUID, JSONB
import uuid
import pendulum
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.
"""
__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)
# 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)
# 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
else:
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
else:
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")