strat-gameplay-webapp/.claude/implementation/player-data-catalog.md
Cal Corum a287784328 CLAUDE: Complete Week 4 - State Management & Persistence
Implemented hybrid state management system with in-memory game states and async
PostgreSQL persistence. This provides the foundation for fast gameplay (<500ms
response) with complete state recovery capabilities.

## Components Implemented

### Production Code (3 files, 1,150 lines)
- app/models/game_models.py (492 lines)
  - Pydantic GameState with 20+ helper methods
  - RunnerState, LineupPlayerState, TeamLineupState
  - DefensiveDecision and OffensiveDecision models
  - Full Pydantic v2 validation with field validators

- app/core/state_manager.py (296 lines)
  - In-memory state management with O(1) lookups
  - State recovery from database
  - Idle game eviction mechanism
  - Statistics tracking

- app/database/operations.py (362 lines)
  - Async PostgreSQL operations
  - Game, lineup, and play persistence
  - Complete state loading for recovery
  - GameSession WebSocket state tracking

### Tests (4 files, 1,963 lines, 115 tests)
- tests/unit/models/test_game_models.py (60 tests, ALL PASSING)
- tests/unit/core/test_state_manager.py (26 tests, ALL PASSING)
- tests/integration/database/test_operations.py (21 tests)
- tests/integration/test_state_persistence.py (8 tests)
- pytest.ini (async test configuration)

### Documentation (6 files)
- backend/CLAUDE.md (updated with Week 4 patterns)
- .claude/implementation/02-week4-state-management.md (marked complete)
- .claude/status-2025-10-22-0113.md (planning session summary)
- .claude/status-2025-10-22-1147.md (implementation session summary)
- .claude/implementation/player-data-catalog.md (player data reference)
- Week 5 & 6 plans created

## Key Features

- Hybrid state: in-memory (fast) + PostgreSQL (persistent)
- O(1) state access via dictionary lookups
- Async database writes (non-blocking)
- Complete state recovery from database
- Pydantic validation on all models
- Helper methods for common game operations
- Idle game eviction with configurable timeout
- 86 unit tests passing (100%)

## Performance

- State access: O(1) via UUID lookup
- Memory per game: ~1KB (just state)
- Target response time: <500ms 
- Database writes: <100ms (async) 

## Testing

- Unit tests: 86/86 passing (100%)
- Integration tests: 29 written
- Test configuration: pytest.ini created
- Fixed Pydantic v2 config deprecation
- Fixed pytest-asyncio configuration

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-22 12:01:03 -05:00

22 KiB
Raw Permalink Blame History

Player Data Catalog - In-Memory Cache Specification

Purpose: Comprehensive catalog of all player data fields to cache in memory for fast gameplay Source: Paper Dynasty Discord Bot (proven production system) Date: 2025-10-22


Overview

This document catalogs all player data that should be cached in memory during active gameplay. The goal is to avoid database queries during play resolution while maintaining complete access to all necessary ratings and attributes.

Memory Cost Estimate: ~500 bytes per player × 20 players = ~10KB per game


Core Player Identity

Source: Player model (players.py)

class PlayerIdentity:
    """Basic player identification and display"""
    # Database IDs
    player_id: int          # Database player ID
    card_id: int            # Specific card variant ID
    lineup_id: int          # Game lineup entry ID
    game_id: int            # Game ID since (in PD) could be active in multiple games

    # Display data
    name: str             # Player name
    image: str              # Primary card image URL
    image2: Optional[str]   # Alternate card image
    headshot: Optional[str] # Player headshot URL

    # Team/Set info
    cardset_id: Optional[int]         # Which cardset this card is from
    set_num: Optional[int]            # Card number in set
    rarity_id: Optional[int]          # Card rarity tier
    cost: Optional[int]               # Card value/cost

    # Metadata
    team_name: Optional[int]            # Maps to PdPlayer.mlbteam.name and SbaPlayer.team.lname
    franchise: Optional[int]          # Historical franchise
    strat_code: Optional[str]  # Strat-O-Matic code
    description: Optional[int]        # Card description/flavor text

    # Lineup data
    position: str           # Current position in game (P, C, 1B, etc.)
    batting_order: Optional[int]  # 1-9 or None for pitchers
    is_starter: bool        # Original lineup vs substitute
    is_active: bool         # Currently in game

Usage: Display in UI, WebSocket broadcasts, substitution tracking


Batting Card Data

Basic Batting Attributes

Source: BattingCard model (battingcards.py)

class BattingAttributes:
    """Baserunning and situational hitting"""
    # Stealing
    steal_low: int = 3      # Minimum dice roll to attempt steal (3-20)
    steal_high: int = 20    # Maximum dice roll for successful steal (3-20)
    steal_auto: bool        # Automatic steal success (special speedsters)
    steal_jump: float       # Jump rating (affects steal success), -1.0 to +1.0

    # Situational hitting
    bunting: str            # Bunting ability: A, B, C, D (A = best)
    hit_and_run: str        # Hit & run ability: A, B, C, D
    running: int = 10       # Base running rating: 1-20 (10 = average, 20 = fastest)

    # Card metadata
    offense_col: Optional[int]  # Offensive column number (for result charts)
    hand: str               # Batting handedness: R, L, S (switch)

Usage:

  • Stolen base decisions and resolution
  • Bunt attempt validation
  • Hit & run play processing
  • Base advancement calculations
  • Running evaluation for extra bases

Detailed Batting Ratings (vs LHP and RHP)

Source: BattingCardRatings model (battingcardratings.py)

CRITICAL: Each batter has TWO sets of ratings (vs LHP and vs RHP), stored separately

class BattingRatings:
    """
    Detailed batting result probabilities (108 total chances)

    IMPORTANT: These ratings exist TWICE per card:
    - vs_hand = 'vL' (vs Left-handed pitchers)
    - vs_hand = 'vR' (vs Right-handed pitchers)
    """
    vs_hand: str  # 'vL' or 'vR' - which rating set this is

    # Extra-base hits (out of 108)
    homerun: float              # Home run chances
    bp_homerun: float           # Ballpark home run (depends on park)
    triple: float               # Triple chances
    double_three: float         # Double (3-base advancement)
    double_two: float           # Double (2-base advancement)
    double_pull: float          # Pull double (to pull field)

    # Singles
    single_two: float           # Single (2-base advancement)
    single_one: float           # Single (1-base advancement)
    single_center: float        # Single to center field
    bp_single: float            # Ballpark single

    # Walks/HBP
    hbp: float                  # Hit by pitch chances
    walk: float                 # Base on balls chances

    # Strikeouts
    strikeout: float            # Strikeout chances

    # Outs (air)
    lineout: float              # Line drive out
    popout: float               # Pop fly out
    flyout_a: float             # Fly out (A range)
    flyout_bq: float            # Fly out (B/Q range)
    flyout_lf_b: float          # Fly out to LF (B range)
    flyout_rf_b: float          # Fly out to RF (B range)

    # Outs (ground)
    groundout_a: float          # Ground out (A range)
    groundout_b: float          # Ground out (B range)
    groundout_c: float          # Ground out (C range) - double play risk

    # Calculated stats (derived from above)
    avg: float                  # Batting average
    obp: float                  # On-base percentage
    slg: float                  # Slugging percentage
    pull_rate: float            # Pull tendency percentage
    center_rate: float          # Center field tendency
    slap_rate: float            # Opposite field tendency

Storage Strategy:

# Cache BOTH rating sets per batter
batting_ratings_vL: BattingRatings  # vs Left-handed pitchers
batting_ratings_vR: BattingRatings  # vs Right-handed pitchers

Usage:

  • Play resolution (dice roll → result lookup)
  • Result selection (show available outcomes to player)
  • Probability calculations for AI decisions
  • Matchup analysis (L/R splits)

Total: 27 float fields × 2 platoon splits = 54 values per batter


Pitching Card Data

Basic Pitching Attributes

Source: PitchingCard model (pitchingcards.py)

class PitchingAttributes:
    """Pitcher-specific ratings and metadata"""
    # Chaos rolls (special events)
    balk: int = 0               # Balk rating (0-20, higher = more balks)
    wild_pitch: int = 0         # Wild pitch rating (0-20)
    hold: int = 0               # Pickoff/hold runner rating (0-20)

    # Pitcher usage
    starter_rating: int = 1     # Innings as starter (1-9+)
    relief_rating: int = 0      # Effectiveness in relief (0-20)
    closer_rating: Optional[int]  # Closer rating if applicable

    # Pitcher batting
    batting: str = "#1WR-C"     # Pitcher batting result code
    offense_col: Optional[int]  # Offensive column (rarely used)

    # Handedness
    hand: str = 'R'             # R, L

Usage:

  • Chaos roll resolution (wild pitch, balk checks)
  • Pickoff attempts
  • Fatigue/substitution decisions
  • Pitcher batting when DH not used

Detailed Pitching Ratings (vs LHB and RHB)

Source: PitchingCardRatings model (pitchingcardratings.py)

CRITICAL: Each pitcher has TWO sets of ratings (vs LHB and vs RHB)

class PitchingRatings:
    """
    Detailed pitching result probabilities (108 total chances)

    IMPORTANT: These ratings exist TWICE per card:
    - vs_hand = 'vL' (vs Left-handed batters)
    - vs_hand = 'vR' (vs Right-handed batters)
    """
    vs_hand: str  # 'vL' or 'vR'

    # Extra-base hits allowed (out of 108)
    homerun: float              # Home runs allowed
    bp_homerun: float           # Ballpark home runs
    triple: float               # Triples allowed
    double_three: float         # Doubles (3-base)
    double_two: float           # Doubles (2-base)
    double_cf: float            # Double to CF

    # Singles allowed
    single_two: float           # Singles (2-base advancement)
    single_one: float           # Singles (1-base)
    single_center: float        # Singles to CF
    bp_single: float            # Ballpark singles

    # Walks/HBP
    hbp: float                  # Hit batters
    walk: float                 # Walks issued

    # Strikeouts (good for pitcher!)
    strikeout: float            # Strikeouts

    # Flyouts
    flyout_lf_b: float          # Flyout to LF (B range)
    flyout_cf_b: float          # Flyout to CF (B range)
    flyout_rf_b: float          # Flyout to RF (B range)

    # Groundouts
    groundout_a: float          # Groundout (A range)
    groundout_b: float          # Groundout (B range)

    # X-Checks (difficult defensive plays by position)
    xcheck_p: float             # X-check to pitcher
    xcheck_c: float             # X-check to catcher
    xcheck_1b: float            # X-check to first base
    xcheck_2b: float            # X-check to second base
    xcheck_3b: float            # X-check to third base
    xcheck_ss: float            # X-check to shortstop
    xcheck_lf: float            # X-check to left field
    xcheck_cf: float            # X-check to center field
    xcheck_rf: float            # X-check to right field

    # Calculated stats
    avg: float                  # Batting average against
    obp: float                  # OBP against
    slg: float                  # Slugging against

Storage Strategy:

# Cache BOTH rating sets per pitcher
pitching_ratings_vL: PitchingRatings  # vs Left-handed batters
pitching_ratings_vR: PitchingRatings  # vs Right-handed batters

Usage:

  • Play resolution when pitcher's card is rolled
  • X-check position determination
  • Matchup analysis
  • AI decision making

Total: 30 float fields × 2 platoon splits = 60 values per pitcher


Defensive Ratings (All Positions)

Source: CardPosition model (cardpositions.py)

CRITICAL: Players can have defensive ratings for MULTIPLE positions

class DefensivePosition:
    """
    Defensive ratings for a specific position

    A player may have ratings for multiple positions.
    Example: Utility player might have ratings at 2B, SS, 3B, LF
    """
    position: str               # P, C, 1B, 2B, 3B, SS, LF, CF, RF, DH
    innings: int = 1            # Innings playable at position (1-9+)

    # Core defensive ratings
    range: int = 5              # Fielding range (1-9, 5 = average)
    error: int = 0              # Error frequency (0-20, lower = better)

    # Position-specific ratings
    arm: Optional[int] = None   # Throwing arm (1-9) - Required for C, LF, CF, RF

    # Catcher-only ratings
    pb: Optional[int] = None    # Passed ball rating (0-20) - Catchers only
    overthrow: Optional[int] = None  # Overthrow rating (0-20) - Catchers only

Storage Strategy:

# Store as dictionary keyed by position
defense_ratings: Dict[str, DefensivePosition]

# Example:
{
    'SS': DefensivePosition(position='SS', range=7, error=5, innings=9),
    '2B': DefensivePosition(position='2B', range=6, error=6, innings=5),
    'LF': DefensivePosition(position='LF', range=5, error=8, arm=6, innings=3)
}

Usage:

  • X-check resolution (range + error checks)
  • Defensive substitution validation
  • Chaos rolls (catcher PB, overthrow)
  • Outfield throw calculations (arm rating)
  • Position eligibility checks

Validation Rules (from Discord bot):

  • Catchers (C) MUST have: arm, pb, overthrow
  • Outfielders (LF, CF, RF) MUST have: arm
  • All positions have: range, error

Complete Cached Player Model

Combining all the above into one comprehensive structure:

from typing import Dict, Optional
from pydantic import BaseModel

class CachedPlayer(BaseModel):
    """
    Complete player data cached in memory during active gameplay

    Estimated size: ~500 bytes per player
    Total for 20-player game: ~10KB
    """

    # ========================================
    # IDENTITY & DISPLAY (always present)
    # ========================================
    lineup_id: int              # Unique ID in this game's lineup
    player_id: int              # Database player ID
    card_id: int                # Specific card variant
    game_id: int                # Support multiple conccurrent players

    name: str                   # Display name
    image: str                  # Card image URL
    headshot: Optional[str]     # Player photo URL

    position: str               # Current position (P, C, 1B, etc.)
    batting_order: Optional[int]  # 1-9 or None
    hand: str                   # R, L, S

    # Team/set metadata
    cardset_id: Optional[int]   # Only required in PD
    mlbclub: Optional[int]
    cost: Optional[int]

    # ========================================
    # BATTING DATA (if position player)
    # ========================================
    batting_attrs: Optional[BattingAttributes] = None
    batting_ratings_vL: Optional[BattingRatings] = None  # vs LHP
    batting_ratings_vR: Optional[BattingRatings] = None  # vs RHP

    # ========================================
    # PITCHING DATA (if pitcher)
    # ========================================
    pitching_attrs: Optional[PitchingAttributes] = None
    pitching_ratings_vL: Optional[PitchingRatings] = None  # vs LHB
    pitching_ratings_vR: Optional[PitchingRatings] = None  # vs RHB

    # ========================================
    # DEFENSIVE DATA (all positions playable)
    # ========================================
    defense_ratings: Dict[str, DefensivePosition]

    # ========================================
    # COMPUTED FLAGS (for quick lookups)
    # ========================================
    is_pitcher: bool            # position == 'P'
    can_catch: bool             # 'C' in defense_ratings

    # ========================================
    # HELPER METHODS
    # ========================================
    def get_batting_vs(self, pitcher_hand: str) -> Optional[BattingRatings]:
        """Get batting ratings based on pitcher handedness"""
        if pitcher_hand == 'L':
            return self.batting_ratings_vL
        else:  # R or S
            return self.batting_ratings_vR

    def get_pitching_vs(self, batter_hand: str) -> Optional[PitchingRatings]:
        """Get pitching ratings based on batter handedness"""
        if batter_hand == 'L':
            return self.pitching_ratings_vL
        else:  # R or S
            return self.pitching_ratings_vR

    def get_defense_at(self, pos: str) -> Optional[DefensivePosition]:
        """Get defensive ratings for specific position"""
        return self.defense_ratings.get(pos)

    def can_play_position(self, pos: str) -> bool:
        """Check if player can play position"""
        return pos in self.defense_ratings

Data Loading Strategy

Initial Game Load

async def load_game_with_full_lineups(game_id: UUID):
    """
    Load game and populate complete player cache

    This happens ONCE at game start, then players stay cached.
    """

    # 1. Load basic game + lineup from database
    game = await db_ops.get_game(game_id)
    home_lineup = await db_ops.get_active_lineup(game_id, game.home_team_id)
    away_lineup = await db_ops.get_active_lineup(game_id, game.away_team_id)

    # 2. Fetch ALL player data from League API
    all_card_ids = [l.card_id for l in home_lineup + away_lineup]

    # Single API call for all players (batching)
    players_data = await league_api.fetch_complete_player_data(
        card_ids=all_card_ids,
        include_batting=True,
        include_pitching=True,
        include_defense=True,
        include_ratings=True  # Both platoon splits
    )

    # 3. Build CachedPlayer objects
    cached_players = {
        player_data['lineup_id']: CachedPlayer(**player_data)
        for player_data in players_data
    }

    # 4. Store in GameState
    state = GameState(
        game_id=game_id,
        home_lineup=cached_players_home,
        away_lineup=cached_players_away,
        # ... other state
    )

    return state

Substitution Updates

async def make_substitution(game_id: UUID, old_player_id: int, new_card_id: int):
    """
    Add new player to cache on substitution

    Only need to fetch ONE player's data.
    """

    # Fetch complete data for new player
    new_player_data = await league_api.fetch_complete_player_data(
        card_ids=[new_card_id],
        include_batting=True,
        include_pitching=True,
        include_defense=True,
        include_ratings=True
    )

    # Create cached player
    new_player = CachedPlayer(**new_player_data[0])

    # Update state
    state = state_manager.get_state(game_id)
    if is_home_team:
        state.home_lineup[new_player.lineup_id] = new_player
    else:
        state.away_lineup[new_player.lineup_id] = new_player

    # Remove old player
    del state.home_lineup[old_player_id]

Usage Examples

Example 1: Resolve Hit - Need Batter Ratings

async def resolve_hit(state: GameState):
    """Player decision selects result from available outcomes"""

    # Get current batter
    batter = state.get_current_batter()  # Returns CachedPlayer

    # Get current pitcher for platoon matchup
    pitcher = state.get_current_pitcher()

    # Get appropriate batting ratings based on pitcher hand
    batting_ratings = batter.get_batting_vs(pitcher.hand)

    # Roll dice
    roll = dice.roll_d20()

    # Determine available results from rating chart
    # (This is where the 108-chance probabilities come into play)
    available_results = result_chart.get_options(
        roll=roll,
        ratings=batting_ratings
    )

    # Return options to player for selection
    return {
        "roll": roll,
        "batter_image": batter.image,
        "available_results": available_results
    }

Example 2: X-Check - Need Random Fielder Range/Error

async def resolve_xcheck(state: GameState, position: str):
    """Difficult defensive play at specified position"""

    # Get fielder at position
    fielder_id = state.get_fielder_at_position(position)
    fielder = state.home_lineup[fielder_id]

    # Get defensive ratings for that position
    defense = fielder.get_defense_at(position)

    # Roll against range
    range_roll = dice.roll_d20()

    if range_roll <= defense.range:
        # In range - now check for error
        error_roll = dice.roll_d20()

        if error_roll <= defense.error:
            # ERROR!
            return PlayResult(outcome="error", fielder=fielder.name)
        else:
            # OUT!
            return PlayResult(outcome="out", fielder=fielder.name)
    else:
        # Out of range - HIT!
        return PlayResult(outcome="hit", fielder=fielder.name)

Example 3: Chaos Roll - Need Catcher PB and Pitcher WP

async def check_chaos_event(state: GameState):
    """Wild pitch or passed ball check when runners on base"""

    if not state.runners:
        return None  # No chaos without runners

    # Get pitcher and catcher
    pitcher = state.get_current_pitcher()
    catcher = state.get_current_catcher()

    # Roll for chaos
    chaos_roll = dice.roll_d20()

    # Check wild pitch first
    if chaos_roll <= pitcher.pitching_attrs.wild_pitch:
        return ChaosEvent(
            type="wild_pitch",
            pitcher=pitcher.name,
            advance_runners=True
        )

    # Check passed ball
    catcher_defense = catcher.get_defense_at('C')
    if chaos_roll <= catcher_defense.pb:
        return ChaosEvent(
            type="passed_ball",
            catcher=catcher.name,
            advance_runners=True
        )

    return None  # No chaos this time

Example 4: Stolen Base - Need Batter Steal Ratings

async def attempt_stolen_base(state: GameState, runner_id: int, target_base: int):
    """Runner attempts to steal"""

    runner = state.get_runner_by_id(runner_id)
    catcher = state.get_current_catcher()
    pitcher = state.get_current_pitcher()

    # Check if runner can attempt
    if runner.batting_attrs.steal_auto:
        # Auto-steal success!
        return StealResult(success=True, reason="auto_steal")

    # Roll dice
    steal_roll = dice.roll_d20()

    # Check against runner's steal range
    if steal_roll < runner.batting_attrs.steal_low:
        return StealResult(success=False, reason="too_low", caught=True)

    if steal_roll > runner.batting_attrs.steal_high:
        return StealResult(success=False, reason="too_high", caught=True)

    # In range - now apply modifiers
    # - runner.batting_attrs.steal_jump
    # - pitcher.pitching_attrs.hold
    # - catcher defensive arm rating

    # ... complex calculation ...

    return StealResult(success=True)

Memory Usage Breakdown

Per Player:
- Identity fields: ~100 bytes
- Batting attributes: ~50 bytes
- Batting ratings (2 platoons × 27 fields): ~200 bytes
- Pitching attributes: ~50 bytes
- Pitching ratings (2 platoons × 30 fields): ~240 bytes
- Defense ratings (avg 2 positions): ~100 bytes
-----------------------------------------------
Total per player: ~740 bytes (conservative estimate)

Per Game (20 players):
~740 bytes × 20 = ~14.8 KB

100 concurrent games:
~14.8 KB × 100 = ~1.48 MB

CONCLUSION: Memory usage is negligible!

Cache Invalidation Strategy

When to update cache:

  1. Game Start: Load full lineups
  2. Substitution: Add new player, mark old as inactive
  3. Half Inning: Update current pitcher/catcher IDs
  4. Game End: Clear from memory

When NOT to update cache:

  • After each play (only update game state counters)
  • For historical data (read from DB on demand)
  • For box scores (aggregate from DB)

Consistency:

  • In-memory cache is source of truth for active games
  • Database is async backup for crash recovery
  • On crash: Rebuild cache from DB + last saved state

SBA League Simplifications

For SBA league (simpler model), many fields will be None or defaults:

SBA Players will have:

  • Basic identity (name, image, position)
  • Basic batting attributes (stealing, bunting, running)
  • NO detailed batting ratings (SBA uses simplified charts)
  • NO detailed pitching ratings

Implementation:

# For SBA, ratings are simpler
class SbaPlayer(CachedPlayer):
    """SBA league players have minimal data"""
    batting_ratings_vL: None  # Not used in SBA
    batting_ratings_vR: None
    # Uses simplified result selection instead

Next Steps

  1. Review & Supplement: Review this catalog and add any missing fields
  2. Implement Models: Create Pydantic models matching this spec
  3. API Client: Build API client to fetch this data
  4. State Manager: Integrate into StateManager cache
  5. Test Loading: Verify data loads correctly and completely

Document Status: Draft - Ready for Review Last Updated: 2025-10-22 Next Review: Before Week 4 implementation begins