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>
22 KiB
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:
- Game Start: Load full lineups
- Substitution: Add new player, mark old as inactive
- Half Inning: Update current pitcher/catcher IDs
- 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
- Review & Supplement: Review this catalog and add any missing fields
- Implement Models: Create Pydantic models matching this spec
- API Client: Build API client to fetch this data
- State Manager: Integrate into StateManager cache
- 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