""" Shared Card Builder Module - Architecture Sketch This module would be the single source of truth for converting raw chance calculations into discrete card structures that honor the 2d6 × d20 mechanics. Could be packaged as: - A shared Python package both card-creation and database import - Or extracted to a standalone service Key insight: The fitting algorithm must know the full card context (which rows are available) to make optimal placement decisions. So we need to build the entire card in one pass, not quantize individual values independently. CONTRACT SYSTEM: Contracts define placement strategies - which rows to prefer for different play categories. This enables different card "personalities" while using the same core fitting engine. """ from __future__ import annotations from dataclasses import dataclass, field from decimal import Decimal from enum import Enum from typing import Optional, List, Dict, Tuple, TYPE_CHECKING import math # Import contracts (will be defined below or in separate module) # For now, we define a minimal contract interface inline # ============================================================================= # CONSTANTS: The discrete probability space # ============================================================================= # Valid fractional chances achievable via d20 splits on single 2d6 rows EXACT_CHANCES = [ Decimal('5.7'), Decimal('5.4'), Decimal('5.1'), Decimal('4.8'), Decimal('4.75'), Decimal('4.5'), Decimal('4.25'), Decimal('4.2'), Decimal('3.9'), Decimal('3.8'), Decimal('3.75'), Decimal('3.6'), Decimal('3.5'), Decimal('3.4'), Decimal('3.3'), Decimal('3.25'), Decimal('3.2'), Decimal('2.85'), Decimal('2.8'), Decimal('2.75'), Decimal('2.7'), Decimal('2.6'), Decimal('2.55'), Decimal('2.5'), Decimal('2.4'), Decimal('2.25'), Decimal('2.2'), Decimal('2.1'), Decimal('1.95'), Decimal('1.9'), Decimal('1.8'), Decimal('1.75'), Decimal('1.7'), Decimal('1.65'), Decimal('1.6'), Decimal('1.5'), Decimal('1.4'), Decimal('1.35'), Decimal('1.3'), Decimal('1.25'), Decimal('1.2'), Decimal('1.1'), Decimal('1.05'), Decimal('1.0'), Decimal('0.95'), Decimal('0.9'), Decimal('0.85'), Decimal('0.8'), Decimal('0.75'), Decimal('0.7'), Decimal('0.65'), Decimal('0.6'), Decimal('0.55'), Decimal('0.5'), Decimal('0.45'), Decimal('0.4'), Decimal('0.35'), Decimal('0.3'), Decimal('0.25'), Decimal('0.2'), Decimal('0.15'), Decimal('0.1'), Decimal('0.05'), ] # 2d6 row frequencies (how many times each result occurs out of 36 rolls) ROW_FREQUENCIES = { 2: 1, 3: 2, 4: 3, 5: 4, 6: 5, 7: 6, 8: 5, 9: 4, 10: 3, 11: 2, 12: 1 } # Maximum splits allowed per column (d20 subdivisions) MAX_SPLITS_PER_COLUMN = 4 # ============================================================================= # CONTRACT SYSTEM: Pluggable Placement Strategies # ============================================================================= class RowPreference(Enum): """Built-in row ordering strategies.""" MIDDLE_FIRST = "middle_first" # 7, 6, 8, 5, 9, 4, 10, 3, 11, 2, 12 EDGES_FIRST = "edges_first" # 2, 12, 3, 11, 4, 10, 5, 9, 6, 8, 7 HIGH_TO_LOW = "high_to_low" # 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2 LOW_TO_HIGH = "low_to_high" # 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12 BY_FREQUENCY_DESC = "freq_desc" # Same as middle_first BY_FREQUENCY_ASC = "freq_asc" # Same as edges_first ROW_ORDERINGS = { RowPreference.MIDDLE_FIRST: [7, 6, 8, 5, 9, 4, 10, 3, 11, 2, 12], RowPreference.EDGES_FIRST: [2, 12, 3, 11, 4, 10, 5, 9, 6, 8, 7], RowPreference.HIGH_TO_LOW: [12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2], RowPreference.LOW_TO_HIGH: [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], RowPreference.BY_FREQUENCY_DESC: [7, 6, 8, 5, 9, 4, 10, 3, 11, 2, 12], RowPreference.BY_FREQUENCY_ASC: [2, 12, 3, 11, 4, 10, 5, 9, 6, 8, 7], } class PlayCategory(Enum): """Categories of plays for contract rules.""" ON_BASE = "on_base" # Walks, HBP, singles, doubles, triples, HR POWER = "power" # HR, triples, doubles CONTACT = "contact" # Singles PATIENCE = "patience" # Walks, HBP STRIKEOUT = "strikeout" GROUNDOUT = "groundout" FLYOUT = "flyout" XCHECK = "xcheck" # Pitcher X-checks BATTER_POWER = "batter_power" # bp_homerun, bp_single class CardContract: """ Base class for card building contracts. A contract defines the "personality" of a card - how plays are distributed across the 2d6 probability space. Subclass this to create custom contracts. """ @property def name(self) -> str: return "Base Contract" @property def description(self) -> str: return "Override this in subclasses" def get_row_preference(self, category: PlayCategory) -> List[int]: """Return ordered list of preferred rows for this play category.""" return ROW_ORDERINGS[RowPreference.MIDDLE_FIRST] def get_play_priority(self) -> List[PlayCategory]: """Return categories in priority order (placed first to last).""" return [ PlayCategory.BATTER_POWER, PlayCategory.PATIENCE, PlayCategory.XCHECK, PlayCategory.POWER, PlayCategory.CONTACT, PlayCategory.STRIKEOUT, PlayCategory.GROUNDOUT, PlayCategory.FLYOUT, ] def get_secondary_pool(self, category: PlayCategory) -> List[str]: """Return play names that can fill d20 split remainders.""" return ['flyout_cf', 'groundout_a', 'strikeout'] def should_use_splits(self, category: PlayCategory) -> bool: """Whether this category should use d20 splits.""" return True def max_splits_per_column(self) -> int: """Maximum d20 splits allowed per column.""" return MAX_SPLITS_PER_COLUMN class StandardContract(CardContract): """Default contract - on-base results favor middle rows (most likely).""" @property def name(self) -> str: return "Standard" @property def description(self) -> str: return "Default card layout - on-base results on middle rows (6-8)" def get_row_preference(self, category: PlayCategory) -> List[int]: if category in [PlayCategory.ON_BASE, PlayCategory.POWER, PlayCategory.CONTACT, PlayCategory.PATIENCE]: return ROW_ORDERINGS[RowPreference.MIDDLE_FIRST] else: return ROW_ORDERINGS[RowPreference.EDGES_FIRST] class ClutchContract(CardContract): """Clutch card - on-base results on edge rows (less frequent but memorable).""" @property def name(self) -> str: return "Clutch" @property def description(self) -> str: return "On-base results on edge rows (2-4, 10-12) - clutch performer" def get_row_preference(self, category: PlayCategory) -> List[int]: if category in [PlayCategory.ON_BASE, PlayCategory.POWER, PlayCategory.CONTACT, PlayCategory.PATIENCE]: return ROW_ORDERINGS[RowPreference.EDGES_FIRST] else: return ROW_ORDERINGS[RowPreference.MIDDLE_FIRST] class PowerHeavyContract(CardContract): """Power-focused - HR/3B/2B get prime rows, singles on edges.""" @property def name(self) -> str: return "Power Heavy" @property def description(self) -> str: return "Power hits on prime rows (6-8), singles pushed to edges" def get_row_preference(self, category: PlayCategory) -> List[int]: if category == PlayCategory.POWER: return [7, 6, 8, 5, 9, 4, 10, 3, 11, 2, 12] elif category == PlayCategory.CONTACT: return ROW_ORDERINGS[RowPreference.EDGES_FIRST] elif category == PlayCategory.PATIENCE: return [5, 9, 4, 10, 6, 8, 7, 3, 11, 2, 12] else: return ROW_ORDERINGS[RowPreference.EDGES_FIRST] def get_play_priority(self) -> List[PlayCategory]: return [ PlayCategory.BATTER_POWER, PlayCategory.POWER, # Power BEFORE patience PlayCategory.PATIENCE, PlayCategory.XCHECK, PlayCategory.CONTACT, PlayCategory.STRIKEOUT, PlayCategory.GROUNDOUT, PlayCategory.FLYOUT, ] class ContactFirstContract(CardContract): """Contact-focused - singles get prime rows, power on edges.""" @property def name(self) -> str: return "Contact First" @property def description(self) -> str: return "Singles on prime rows, power on edges - high average hitter" def get_row_preference(self, category: PlayCategory) -> List[int]: if category == PlayCategory.CONTACT: return ROW_ORDERINGS[RowPreference.MIDDLE_FIRST] elif category == PlayCategory.POWER: return ROW_ORDERINGS[RowPreference.EDGES_FIRST] elif category == PlayCategory.PATIENCE: return [5, 9, 4, 10, 3, 11, 2, 12, 6, 8, 7] else: return ROW_ORDERINGS[RowPreference.EDGES_FIRST] def get_play_priority(self) -> List[PlayCategory]: return [ PlayCategory.BATTER_POWER, PlayCategory.CONTACT, # Contact BEFORE power PlayCategory.PATIENCE, PlayCategory.POWER, PlayCategory.XCHECK, PlayCategory.STRIKEOUT, PlayCategory.GROUNDOUT, PlayCategory.FLYOUT, ] class GroundballPitcherContract(CardContract): """Groundball pitcher - groundouts on prime rows.""" @property def name(self) -> str: return "Groundball Pitcher" @property def description(self) -> str: return "Groundouts on likely rows - sinkerball specialist" def get_row_preference(self, category: PlayCategory) -> List[int]: if category == PlayCategory.GROUNDOUT: return ROW_ORDERINGS[RowPreference.MIDDLE_FIRST] elif category == PlayCategory.FLYOUT: return ROW_ORDERINGS[RowPreference.EDGES_FIRST] elif category == PlayCategory.STRIKEOUT: return [5, 9, 4, 10, 3, 11, 2, 12, 6, 8, 7] else: return ROW_ORDERINGS[RowPreference.EDGES_FIRST] def get_play_priority(self) -> List[PlayCategory]: return [ PlayCategory.BATTER_POWER, PlayCategory.PATIENCE, PlayCategory.XCHECK, PlayCategory.GROUNDOUT, # Groundouts prioritized PlayCategory.STRIKEOUT, PlayCategory.POWER, PlayCategory.CONTACT, PlayCategory.FLYOUT, ] class FlyballPitcherContract(CardContract): """Flyball pitcher - strikeouts and flyouts on prime rows.""" @property def name(self) -> str: return "Flyball Pitcher" @property def description(self) -> str: return "Strikeouts and flyouts on likely rows - power pitcher" def get_row_preference(self, category: PlayCategory) -> List[int]: if category in [PlayCategory.FLYOUT, PlayCategory.STRIKEOUT]: return ROW_ORDERINGS[RowPreference.MIDDLE_FIRST] elif category == PlayCategory.GROUNDOUT: return ROW_ORDERINGS[RowPreference.EDGES_FIRST] else: return ROW_ORDERINGS[RowPreference.EDGES_FIRST] def get_play_priority(self) -> List[PlayCategory]: return [ PlayCategory.BATTER_POWER, PlayCategory.PATIENCE, PlayCategory.STRIKEOUT, # Strikeouts prioritized PlayCategory.FLYOUT, PlayCategory.XCHECK, PlayCategory.POWER, PlayCategory.CONTACT, PlayCategory.GROUNDOUT, ] # Contract registry for easy lookup CONTRACT_REGISTRY: Dict[str, CardContract] = { 'standard': StandardContract(), 'clutch': ClutchContract(), 'power_heavy': PowerHeavyContract(), 'contact_first': ContactFirstContract(), 'groundball_pitcher': GroundballPitcherContract(), 'flyball_pitcher': FlyballPitcherContract(), } def get_contract(name: str) -> CardContract: """Get a contract by name, or return Standard if not found.""" return CONTRACT_REGISTRY.get(name.lower(), StandardContract()) # ============================================================================= # DATA STRUCTURES: The card model # ============================================================================= class PlayType(Enum): """Categories of play results for priority ordering.""" # Common to both batting and pitching WALK = "walk" HBP = "hbp" STRIKEOUT = "strikeout" HOMERUN = "homerun" TRIPLE = "triple" DOUBLE = "double" SINGLE = "single" GROUNDOUT = "groundout" FLYOUT = "flyout" LINEOUT = "lineout" # Pitcher-specific BP_HOMERUN = "bp_homerun" # Batter Power HR - redirects to batter card BP_SINGLE = "bp_single" # Batter Power Single - redirects to batter card XCHECK = "xcheck" # X-check - redirects to batter card at position WILD_PITCH = "wild_pitch" BALK = "balk" @dataclass class PlayResult: """A single play outcome to place on the card.""" play_type: PlayType full_name: str # e.g., "SINGLE(rf)+" short_name: str # e.g., "SI(rf)+" for split rows is_offense: bool # Bold on card if True direction: Optional[str] = None # lf, cf, rf, etc. @dataclass class CardSlot: """A single result slot on a 2d6 row (may have d20 split).""" result_one: Optional[PlayResult] = None result_two: Optional[PlayResult] = None # Only if d20 split d20_split: Optional[int] = None # e.g., 13 means "1-13" and "14-20" @property def is_empty(self) -> bool: return self.result_one is None @property def is_split(self) -> bool: return self.result_two is not None def to_dict(self) -> dict: """Serialize for API/storage.""" return { 'result_one': self.result_one.full_name if self.result_one else None, 'result_two': self.result_two.short_name if self.result_two else None, 'd20_one': f'1-{self.d20_split}' if self.d20_split else None, 'd20_two': f'{self.d20_split + 1}-20' if self.d20_split and self.d20_split < 20 else None, 'bold_one': self.result_one.is_offense if self.result_one else False, 'bold_two': self.result_two.is_offense if self.result_two else False, } @dataclass class CardColumn: """A single column (vL or vR) with 11 2d6 result rows.""" rows: Dict[int, CardSlot] = field(default_factory=lambda: {i: CardSlot() for i in range(2, 13)}) num_splits: int = 0 def get_available_rows(self, min_chances: int = 1) -> List[int]: """Return 2d6 values with empty slots that have >= min_chances frequency.""" return [ row for row, slot in self.rows.items() if slot.is_empty and ROW_FREQUENCIES[row] >= min_chances ] def total_chances_used(self) -> int: """Sum of frequencies for all filled rows.""" return sum( ROW_FREQUENCIES[row] for row, slot in self.rows.items() if not slot.is_empty ) def is_full(self) -> bool: return all(not slot.is_empty for slot in self.rows.values()) def to_dict(self) -> dict: return {str(row): slot.to_dict() for row, slot in self.rows.items()} @dataclass class BuiltCard: """The fully-built card structure ready for storage/rendering.""" column_vl: CardColumn column_vr: CardColumn # Store the fitted chances for each play type (what actually got placed) fitted_chances_vl: Dict[str, Decimal] = field(default_factory=dict) fitted_chances_vr: Dict[str, Decimal] = field(default_factory=dict) # Store deltas from requested chances (for debugging/logging) deltas_vl: Dict[str, Decimal] = field(default_factory=dict) deltas_vr: Dict[str, Decimal] = field(default_factory=dict) def to_dict(self) -> dict: return { 'column_vl': self.column_vl.to_dict(), 'column_vr': self.column_vr.to_dict(), 'fitted_chances_vl': {k: float(v) for k, v in self.fitted_chances_vl.items()}, 'fitted_chances_vr': {k: float(v) for k, v in self.fitted_chances_vr.items()}, 'deltas_vl': {k: float(v) for k, v in self.deltas_vl.items()}, 'deltas_vr': {k: float(v) for k, v in self.deltas_vr.items()}, } # ============================================================================= # INPUT STRUCTURES: What Python card-creation provides # ============================================================================= @dataclass class RawBattingChances: """ Raw calculated chances (out of 108) for a batting card. This is what the Python card-creation code produces today. """ walk: Decimal = Decimal(0) hbp: Decimal = Decimal(0) strikeout: Decimal = Decimal(0) homerun: Decimal = Decimal(0) triple: Decimal = Decimal(0) double_pull: Decimal = Decimal(0) double_straight: Decimal = Decimal(0) double_oppo: Decimal = Decimal(0) single_pull: Decimal = Decimal(0) single_straight: Decimal = Decimal(0) single_oppo: Decimal = Decimal(0) groundout_pull: Decimal = Decimal(0) groundout_straight: Decimal = Decimal(0) groundout_oppo: Decimal = Decimal(0) flyout_pull: Decimal = Decimal(0) flyout_straight: Decimal = Decimal(0) flyout_oppo: Decimal = Decimal(0) lineout: Decimal = Decimal(0) # Alias for backwards compatibility RawChances = RawBattingChances @dataclass class RawPitchingChances: """ Raw calculated chances (out of 108) for a pitching card. Key differences from batting: - bp_homerun/bp_single: "Batter Power" outcomes redirect to batter's card - xcheck_*: X-check outcomes redirect to batter's card at that position - Hits allowed are NOT bolded (bad for pitcher) - Strikeouts ARE bolded (good for pitcher) """ # Batter Power outcomes (redirect to batter card for power hitters) bp_homerun: Decimal = Decimal(0) bp_single: Decimal = Decimal(0) # Standard outcomes hbp: Decimal = Decimal(0) walk: Decimal = Decimal(0) strikeout: Decimal = Decimal(0) # Hits allowed (NOT offense - bad for pitcher) homerun: Decimal = Decimal(0) triple: Decimal = Decimal(0) double_cf: Decimal = Decimal(0) # Center field double double_two: Decimal = Decimal(0) # Two-base double (standard) double_three: Decimal = Decimal(0) # Three-base double (gap shot) single_one: Decimal = Decimal(0) # One-base single single_two: Decimal = Decimal(0) # Two-base single (extra base potential) single_center: Decimal = Decimal(0) # Center field single # Outs flyout_lf: Decimal = Decimal(0) flyout_cf: Decimal = Decimal(0) flyout_rf: Decimal = Decimal(0) groundout_a: Decimal = Decimal(0) # Groundout type A (e.g., to 1B side) groundout_b: Decimal = Decimal(0) # Groundout type B (e.g., to 3B side) # X-checks: redirect to batter's card at specified position # These appear as "GB (p) X", "CATCH X", "FLY (cf) X", etc. xcheck_p: Decimal = Decimal(0) # Pitcher xcheck_c: Decimal = Decimal(0) # Catcher xcheck_1b: Decimal = Decimal(0) # First base xcheck_2b: Decimal = Decimal(0) # Second base xcheck_3b: Decimal = Decimal(0) # Third base xcheck_ss: Decimal = Decimal(0) # Shortstop xcheck_lf: Decimal = Decimal(0) # Left field xcheck_cf: Decimal = Decimal(0) # Center field xcheck_rf: Decimal = Decimal(0) # Right field # Special outcomes wild_pitch: Decimal = Decimal(0) balk: Decimal = Decimal(0) # ============================================================================= # THE FITTING ALGORITHM # ============================================================================= class CardBuilder: """ Converts raw continuous chances into a discrete card structure. This is the single source of truth for the fitting algorithm. Both Python card-creation and the database API would use this. The contract parameter controls placement strategy: - Which rows to prefer for different play categories - Priority ordering of plays - Secondary play selection rules """ def __init__( self, contract: CardContract = None, max_splits: int = None ): self.contract = contract or StandardContract() self.max_splits = max_splits or self.contract.max_splits_per_column() def build_batting_card( self, chances_vl: RawBattingChances, chances_vr: RawBattingChances, batter_hand: str, # 'L' or 'R' - affects hit directions contract: CardContract = None # Override instance contract ) -> BuiltCard: """ Build a complete batting card from raw chances. The order of play placement matters - high-value outcomes first, then fill remaining rows with outs. The contract controls which rows are preferred for each play category. """ active_contract = contract or self.contract card = BuiltCard( column_vl=CardColumn(), column_vr=CardColumn() ) # Build each column for column, chances, side in [ (card.column_vl, chances_vl, 'vL'), (card.column_vr, chances_vr, 'vR') ]: fitted = {} deltas = {} # Get play order from contract's priority, mapped to actual plays play_order = self._get_batting_play_order(chances, batter_hand, active_contract) for play_result, raw_chance, category in play_order: if raw_chance <= 0: continue # Use contract's row preference for this category actual_placed = self._place_play_with_contract( column, play_result, raw_chance, category, active_contract ) fitted[play_result.full_name] = actual_placed deltas[play_result.full_name] = actual_placed - raw_chance # Store results if side == 'vL': card.fitted_chances_vl = fitted card.deltas_vl = deltas else: card.fitted_chances_vr = fitted card.deltas_vr = deltas return card def build_pitching_card( self, chances_vl: RawPitchingChances, chances_vr: RawPitchingChances, pitcher_hand: str, # 'L' or 'R' - affects middle infield preference contract: CardContract = None # Override instance contract ) -> BuiltCard: """ Build a complete pitching card from raw chances. Key differences from batting: - X-checks and BP outcomes redirect to batter's card - Strikeouts are bolded (good for pitcher) - Hits allowed are NOT bolded (bad for pitcher) - Secondary play selection uses remaining chances from other categories - Contract controls row preferences and play priority """ active_contract = contract or self.contract card = BuiltCard( column_vl=CardColumn(), column_vr=CardColumn() ) for column, chances, side in [ (card.column_vl, chances_vl, 'vL'), (card.column_vr, chances_vr, 'vR') ]: fitted = {} deltas = {} # Create mutable copy of chances for secondary play tracking remaining = self._chances_to_dict(chances) # Get play order - X-checks and BP first, then standard outcomes play_order = self._get_pitching_play_order(chances, pitcher_hand, side) for play_result, raw_chance, secondary_pool in play_order: if raw_chance <= 0: continue # For pitching, we need to track secondary plays used actual_placed, secondary_used = self._place_play_with_secondary( column, play_result, raw_chance, remaining, secondary_pool ) fitted[play_result.full_name] = actual_placed deltas[play_result.full_name] = actual_placed - raw_chance # Update remaining chances for secondaries used for sec_name, sec_amount in secondary_used.items(): if sec_name in remaining: remaining[sec_name] -= sec_amount if sec_name not in fitted: fitted[sec_name] = sec_amount else: fitted[sec_name] += sec_amount if side == 'vL': card.fitted_chances_vl = fitted card.deltas_vl = deltas else: card.fitted_chances_vr = fitted card.deltas_vr = deltas return card def _chances_to_dict(self, chances: RawPitchingChances) -> Dict[str, Decimal]: """Convert chances dataclass to mutable dict for tracking.""" return { 'strikeout': chances.strikeout, 'flyout_cf': chances.flyout_cf, 'flyout_lf': chances.flyout_lf, 'flyout_rf': chances.flyout_rf, 'groundout_a': chances.groundout_a, 'groundout_b': chances.groundout_b, 'double_cf': chances.double_cf, 'double_two': chances.double_two, 'double_three': chances.double_three, 'triple': chances.triple, 'single_two': chances.single_two, 'single_center': chances.single_center, 'single_one': chances.single_one, } def _get_pitching_play_order( self, chances: RawPitchingChances, pitcher_hand: str, vs_hand: str ) -> List[Tuple[PlayResult, Decimal, List[str]]]: """ Return pitching plays in priority order with secondary play pools. Order based on database logic: 1. Batter Power HR (bp_homerun) 2. HBP 3. X-checks (p, c, 1b, 3b, rf, lf, 2b, cf, ss) 4. Walks (secondary: strikeouts) 5. Home runs (secondary: flyouts, doubles) 6. Triples (secondary: singles, flyouts, doubles) 7. Doubles (secondary: singles, flyouts) 8. Singles (secondary: flyouts, groundouts) 9. Strikeouts 10. Groundouts 11. Flyouts 12. Batter Power Single Each entry is (PlayResult, raw_chances, secondary_pool) Secondary pool defines what plays can fill the d20 split remainder. """ # Determine preferred middle infielder based on handedness matchup if pitcher_hand == 'L' and vs_hand == 'L': pref_mif = 'ss' elif pitcher_hand == 'L' or (pitcher_hand == 'R' and vs_hand == 'R'): pref_mif = '2b' else: pref_mif = 'ss' return [ # Batter Power HR - no secondary (full row) (PlayResult(PlayType.BP_HOMERUN, 'HOMERUN*', 'HR*', False), chances.bp_homerun, []), # HBP - no secondary typically (PlayResult(PlayType.HBP, 'HBP', 'HBP', False), chances.hbp, []), # X-checks - redirect to batter's card (PlayResult(PlayType.XCHECK, 'GB (p) X', 'GB (p) X', False), chances.xcheck_p, []), (PlayResult(PlayType.XCHECK, 'CATCH X', 'CATCH X', False), chances.xcheck_c, []), (PlayResult(PlayType.XCHECK, 'GB (1b) X', 'GB (1b) X', False), chances.xcheck_1b, []), (PlayResult(PlayType.XCHECK, 'GB (3b) X', 'GB (3b) X', False), chances.xcheck_3b, []), (PlayResult(PlayType.XCHECK, 'FLY (rf) X', 'FLY (rf) X', False), chances.xcheck_rf, []), (PlayResult(PlayType.XCHECK, 'FLY (lf) X', 'FLY (lf) X', False), chances.xcheck_lf, []), (PlayResult(PlayType.XCHECK, f'GB ({pref_mif}) X', f'GB ({pref_mif}) X', False), chances.xcheck_2b if pref_mif == '2b' else chances.xcheck_ss, []), (PlayResult(PlayType.XCHECK, 'FLY (cf) X', 'FLY (cf) X', False), chances.xcheck_cf, []), (PlayResult(PlayType.XCHECK, f'GB ({"ss" if pref_mif == "2b" else "2b"}) X', f'GB ({"ss" if pref_mif == "2b" else "2b"}) X', False), chances.xcheck_ss if pref_mif == '2b' else chances.xcheck_2b, []), # Walks - secondary is strikeout (good pairing: offense vs defense) (PlayResult(PlayType.WALK, 'WALK', 'BB', False), chances.walk, ['strikeout']), # Home runs - secondary is flyouts or doubles (PlayResult(PlayType.HOMERUN, 'HOMERUN', 'HR', False), chances.homerun, ['double_cf', 'flyout_cf', 'flyout_lf', 'flyout_rf', 'double_three', 'double_two', 'triple']), # Triples - secondary is singles or flyouts (PlayResult(PlayType.TRIPLE, 'TRIPLE', '3B', False), chances.triple, ['single_two', 'flyout_cf', 'flyout_lf', 'flyout_rf', 'double_cf', 'double_three', 'double_two']), # Doubles (PlayResult(PlayType.DOUBLE, 'DOUBLE (cf)', 'DO (cf)', False), chances.double_cf, ['flyout_cf', 'flyout_lf', 'flyout_rf', 'single_two', 'single_center']), (PlayResult(PlayType.DOUBLE, 'DOUBLE***', 'DO***', False), chances.double_three, ['flyout_cf', 'flyout_lf', 'flyout_rf', 'single_two']), (PlayResult(PlayType.DOUBLE, 'DOUBLE**', 'DO**', False), chances.double_two, ['flyout_cf', 'flyout_lf', 'flyout_rf', 'single_two']), # Singles (PlayResult(PlayType.SINGLE, 'SINGLE**', 'SI**', False), chances.single_two, ['flyout_cf', 'flyout_lf', 'flyout_rf', 'groundout_a', 'groundout_b']), (PlayResult(PlayType.SINGLE, 'SINGLE (cf)', 'SI (cf)', False), chances.single_center, ['flyout_cf', 'groundout_a', 'groundout_b']), (PlayResult(PlayType.SINGLE, 'SINGLE*', 'SI*', False), chances.single_one, ['groundout_a', 'groundout_b', 'flyout_lf', 'flyout_rf']), # Strikeouts - BOLDED (good for pitcher!) (PlayResult(PlayType.STRIKEOUT, 'strikeout', 'K', True), chances.strikeout, []), # Groundouts (PlayResult(PlayType.GROUNDOUT, 'GO (a)', 'GO (a)', False), chances.groundout_a, []), (PlayResult(PlayType.GROUNDOUT, 'GO (b)', 'GO (b)', False), chances.groundout_b, []), # Flyouts (PlayResult(PlayType.FLYOUT, 'FO (lf)', 'FO (lf)', False), chances.flyout_lf, []), (PlayResult(PlayType.FLYOUT, 'FO (cf)', 'FO (cf)', False), chances.flyout_cf, []), (PlayResult(PlayType.FLYOUT, 'FO (rf)', 'FO (rf)', False), chances.flyout_rf, []), # Batter Power Single - last (redirect to batter) (PlayResult(PlayType.BP_SINGLE, 'SINGLE*', 'SI*', False), chances.bp_single, []), ] def _place_play_with_secondary( self, column: CardColumn, play: PlayResult, target_chances: Decimal, remaining: Dict[str, Decimal], secondary_pool: List[str] ) -> Tuple[Decimal, Dict[str, Decimal]]: """ Place a play with potential secondary (d20 split). Returns (actual_placed, {secondary_name: amount_used}) """ if target_chances <= 0: return Decimal(0), {} placed = Decimal(0) remaining_target = target_chances secondary_used: Dict[str, Decimal] = {} while remaining_target > Decimal('0.05') and not column.is_full(): # Find best secondary from pool (one with most remaining chances) secondary = self._select_secondary(remaining, secondary_pool, remaining_target) chunk_placed, sec_chunk = self._place_chunk_with_secondary( column, play, remaining_target, secondary ) if chunk_placed == 0: break placed += chunk_placed remaining_target -= chunk_placed if sec_chunk and secondary: sec_name = secondary.full_name if sec_name not in secondary_used: secondary_used[sec_name] = Decimal(0) secondary_used[sec_name] += sec_chunk return placed, secondary_used def _select_secondary( self, remaining: Dict[str, Decimal], pool: List[str], min_needed: Decimal ) -> Optional[PlayResult]: """Select best secondary play from pool based on remaining chances.""" if not pool: return None # Find pool entry with most remaining chances that exceeds min_needed best_name = None best_amount = Decimal(0) for name in pool: amount = remaining.get(name, Decimal(0)) if amount > best_amount and amount > (1 - min_needed): best_amount = amount best_name = name if best_name is None: return None # Create appropriate PlayResult for the secondary return self._create_secondary_play(best_name) def _create_secondary_play(self, name: str) -> PlayResult: """Create a PlayResult for a secondary outcome.""" mapping = { 'strikeout': PlayResult(PlayType.STRIKEOUT, 'strikeout', 'so', True), 'flyout_cf': PlayResult(PlayType.FLYOUT, 'FO (cf)', 'FO (cf)', False), 'flyout_lf': PlayResult(PlayType.FLYOUT, 'FO (lf)', 'FO (lf)', False), 'flyout_rf': PlayResult(PlayType.FLYOUT, 'FO (rf)', 'FO (rf)', False), 'groundout_a': PlayResult(PlayType.GROUNDOUT, 'GO (a)', 'GO (a)', False), 'groundout_b': PlayResult(PlayType.GROUNDOUT, 'GO (b)', 'GO (b)', False), 'double_cf': PlayResult(PlayType.DOUBLE, 'DO (cf)', 'DO (cf)', False), 'double_two': PlayResult(PlayType.DOUBLE, 'DO**', 'DO**', False), 'double_three': PlayResult(PlayType.DOUBLE, 'DO***', 'DO***', False), 'triple': PlayResult(PlayType.TRIPLE, 'TR', 'TR', False), 'single_two': PlayResult(PlayType.SINGLE, 'SI**', 'SI**', False), 'single_center': PlayResult(PlayType.SINGLE, 'SI (cf)', 'SI (cf)', False), 'single_one': PlayResult(PlayType.SINGLE, 'SI*', 'SI*', False), } return mapping.get(name) def _place_chunk_with_secondary( self, column: CardColumn, play: PlayResult, target: Decimal, secondary: Optional[PlayResult] ) -> Tuple[Decimal, Optional[Decimal]]: """ Place a chunk with optional secondary. Returns (primary_placed, secondary_placed or None). """ quantized = self._quantize(target) if quantized <= 0: return Decimal(0), None available = column.get_available_rows() if not available: return Decimal(0), None # Whole number placement (no secondary needed) if quantized == quantized.to_integral_value(): int_target = int(quantized) placement = self._find_whole_placement(available, int_target) if placement: for row in placement: column.rows[row].result_one = play return Decimal(sum(ROW_FREQUENCIES[r] for r in placement)), None # Fractional placement with secondary if column.num_splits < self.max_splits and secondary: result = self._find_split_placement_with_secondary( column, quantized, play, secondary ) if result: return result # Fall back to floor floor_val = int(math.floor(float(quantized))) if floor_val > 0: placement = self._find_whole_placement(available, floor_val) if placement: for row in placement: column.rows[row].result_one = play return Decimal(sum(ROW_FREQUENCIES[r] for r in placement)), None return Decimal(0), None def _find_split_placement_with_secondary( self, column: CardColumn, target: Decimal, primary: PlayResult, secondary: PlayResult ) -> Optional[Tuple[Decimal, Decimal]]: """ Place fractional value with d20 split, filling remainder with secondary. Returns (primary_placed, secondary_placed) or None. """ for row in column.get_available_rows(): freq = ROW_FREQUENCIES[row] d20_needed = float(target) * 20 / freq if 1 <= d20_needed <= 19 and d20_needed == int(d20_needed): d20_int = int(d20_needed) primary_placed = Decimal(freq * d20_int) / Decimal(20) secondary_placed = Decimal(freq * (20 - d20_int)) / Decimal(20) # Create the split column.rows[row].result_one = primary column.rows[row].result_two = secondary column.rows[row].d20_split = d20_int column.num_splits += 1 return primary_placed, secondary_placed return None def _get_batting_play_order( self, chances: RawBattingChances, batter_hand: str, contract: CardContract ) -> List[Tuple[PlayResult, Decimal, PlayCategory]]: """ Return plays in priority order for placement, based on contract. Order matters because earlier plays get first pick of optimal rows. Returns tuples of (PlayResult, chance_amount, PlayCategory). """ # Determine pull/oppo directions based on batter hand pull_dir = 'rf' if batter_hand == 'L' else 'lf' oppo_dir = 'lf' if batter_hand == 'L' else 'rf' # Define all plays with their categories all_plays = { PlayCategory.PATIENCE: [ (PlayResult(PlayType.WALK, 'WALK', 'BB', True), chances.walk), (PlayResult(PlayType.HBP, 'HBP', 'HBP', True), chances.hbp), ], PlayCategory.POWER: [ (PlayResult(PlayType.HOMERUN, 'HOMERUN', 'HR', True), chances.homerun), (PlayResult(PlayType.TRIPLE, 'TRIPLE', '3B', True), chances.triple), (PlayResult(PlayType.DOUBLE, f'DOUBLE({pull_dir})', f'2B({pull_dir})', True, pull_dir), chances.double_pull), (PlayResult(PlayType.DOUBLE, 'DOUBLE(cf)', '2B(cf)', True, 'cf'), chances.double_straight), (PlayResult(PlayType.DOUBLE, f'DOUBLE({oppo_dir})', f'2B({oppo_dir})', True, oppo_dir), chances.double_oppo), ], PlayCategory.CONTACT: [ (PlayResult(PlayType.SINGLE, f'SINGLE({pull_dir})+', f'SI({pull_dir})+', True, pull_dir), chances.single_pull), (PlayResult(PlayType.SINGLE, 'SINGLE(cf)+', 'SI(cf)+', True, 'cf'), chances.single_straight), (PlayResult(PlayType.SINGLE, f'SINGLE({oppo_dir})+', f'SI({oppo_dir})+', True, oppo_dir), chances.single_oppo), ], PlayCategory.STRIKEOUT: [ (PlayResult(PlayType.STRIKEOUT, 'strikeout', 'K', False), chances.strikeout), ], PlayCategory.GROUNDOUT: [ (PlayResult(PlayType.GROUNDOUT, f'GO({pull_dir})', f'GO({pull_dir})', False, pull_dir), chances.groundout_pull), (PlayResult(PlayType.GROUNDOUT, 'GO(ss/2b)', 'GO(ss/2b)', False, 'middle'), chances.groundout_straight), (PlayResult(PlayType.GROUNDOUT, f'GO({oppo_dir})', f'GO({oppo_dir})', False, oppo_dir), chances.groundout_oppo), ], PlayCategory.FLYOUT: [ (PlayResult(PlayType.FLYOUT, f'FO({pull_dir})', f'FO({pull_dir})', False, pull_dir), chances.flyout_pull), (PlayResult(PlayType.FLYOUT, 'FO(cf)', 'FO(cf)', False, 'cf'), chances.flyout_straight), (PlayResult(PlayType.FLYOUT, f'FO({oppo_dir})', f'FO({oppo_dir})', False, oppo_dir), chances.flyout_oppo), (PlayResult(PlayType.LINEOUT, 'lineout', 'LO', False), chances.lineout), ], } # Build ordered list based on contract's priority result = [] for category in contract.get_play_priority(): if category in all_plays: for play, chance in all_plays[category]: result.append((play, chance, category)) return result def _place_play_with_contract( self, column: CardColumn, play: PlayResult, target_chances: Decimal, category: PlayCategory, contract: CardContract ) -> Decimal: """ Place a play using the contract's row preferences. """ if target_chances <= 0: return Decimal(0) placed = Decimal(0) remaining = target_chances # Get preferred row order from contract preferred_rows = contract.get_row_preference(category) while remaining > Decimal('0.05') and not column.is_full(): chunk_placed = self._place_chunk_with_preference( column, play, remaining, preferred_rows, contract ) if chunk_placed == 0: break placed += chunk_placed remaining -= chunk_placed return placed def _place_chunk_with_preference( self, column: CardColumn, play: PlayResult, target: Decimal, preferred_rows: List[int], contract: CardContract ) -> Decimal: """ Place a chunk using contract's preferred row order. """ quantized = self._quantize(target) if quantized <= 0: return Decimal(0) # Filter to available rows, maintaining preference order available = [r for r in preferred_rows if column.rows[r].is_empty] if not available: return Decimal(0) # Whole number: find row(s) that sum to quantized if quantized == quantized.to_integral_value(): int_target = int(quantized) placement = self._find_whole_placement_preferred(available, int_target) if placement: for row in placement: column.rows[row].result_one = play return Decimal(sum(ROW_FREQUENCIES[r] for r in placement)) # Fractional: need d20 split (if contract allows) if column.num_splits < self.max_splits and contract.should_use_splits( self._play_to_category(play) ): placement = self._find_split_placement(column, quantized, play) if placement: return placement # Fall back to floor floor_val = int(math.floor(float(quantized))) if floor_val > 0: placement = self._find_whole_placement_preferred(available, floor_val) if placement: for row in placement: column.rows[row].result_one = play return Decimal(sum(ROW_FREQUENCIES[r] for r in placement)) return Decimal(0) def _find_whole_placement_preferred( self, available: List[int], target: int ) -> Optional[List[int]]: """ Find combination of available rows that sum to target. Respects the order of available (which is already preference-sorted). """ # Single row solutions (prefer earlier in list) for row in available: if ROW_FREQUENCIES[row] == target: return [row] # Two row solutions for i, row1 in enumerate(available): for row2 in available[i+1:]: if ROW_FREQUENCIES[row1] + ROW_FREQUENCIES[row2] == target: return [row1, row2] # Three row solutions for i, row1 in enumerate(available): for j, row2 in enumerate(available[i+1:], i+1): for row3 in available[j+1:]: total = ROW_FREQUENCIES[row1] + ROW_FREQUENCIES[row2] + ROW_FREQUENCIES[row3] if total == target: return [row1, row2, row3] return None def _play_to_category(self, play: PlayResult) -> PlayCategory: """Map a PlayResult to its PlayCategory.""" mapping = { PlayType.WALK: PlayCategory.PATIENCE, PlayType.HBP: PlayCategory.PATIENCE, PlayType.HOMERUN: PlayCategory.POWER, PlayType.TRIPLE: PlayCategory.POWER, PlayType.DOUBLE: PlayCategory.POWER, PlayType.SINGLE: PlayCategory.CONTACT, PlayType.STRIKEOUT: PlayCategory.STRIKEOUT, PlayType.GROUNDOUT: PlayCategory.GROUNDOUT, PlayType.FLYOUT: PlayCategory.FLYOUT, PlayType.LINEOUT: PlayCategory.FLYOUT, PlayType.BP_HOMERUN: PlayCategory.BATTER_POWER, PlayType.BP_SINGLE: PlayCategory.BATTER_POWER, PlayType.XCHECK: PlayCategory.XCHECK, } return mapping.get(play.play_type, PlayCategory.FLYOUT) def _place_play( self, column: CardColumn, play: PlayResult, target_chances: Decimal ) -> Decimal: """ Place a play on the card, returning actual chances placed. This is the core fitting algorithm - finding the best combination of available rows to approximate the target chances. """ if target_chances <= 0: return Decimal(0) placed = Decimal(0) remaining = target_chances while remaining > Decimal('0.05') and not column.is_full(): # Try to place a chunk chunk_placed = self._place_chunk(column, play, remaining) if chunk_placed == 0: break placed += chunk_placed remaining -= chunk_placed return placed def _place_chunk( self, column: CardColumn, play: PlayResult, target: Decimal ) -> Decimal: """ Place a single chunk of chances, returning amount placed. Strategy: 1. If target is a whole number, find best row combination 2. If target is fractional and in EXACT_CHANCES, use d20 split 3. Otherwise, quantize to nearest valid value """ # Quantize to nearest valid discrete value quantized = self._quantize(target) if quantized <= 0: return Decimal(0) # Find best available placement available = column.get_available_rows() if not available: return Decimal(0) # Whole number: find row(s) that sum to quantized if quantized == quantized.to_integral_value(): int_target = int(quantized) placement = self._find_whole_placement(available, int_target) if placement: for row in placement: column.rows[row].result_one = play return Decimal(sum(ROW_FREQUENCIES[r] for r in placement)) # Fractional: need d20 split if column.num_splits < self.max_splits: placement = self._find_split_placement(column, quantized, play) if placement: return placement # Fall back to floor floor_val = int(math.floor(float(quantized))) if floor_val > 0: placement = self._find_whole_placement(available, floor_val) if placement: for row in placement: column.rows[row].result_one = play return Decimal(sum(ROW_FREQUENCIES[r] for r in placement)) return Decimal(0) def _quantize(self, value: Decimal) -> Decimal: """Snap to nearest valid discrete chance value.""" if value <= 0: return Decimal(0) if value > 6: return Decimal(6) # Check whole numbers first rounded = round(float(value)) if abs(value - rounded) < Decimal('0.025'): return Decimal(rounded) # Find nearest EXACT_CHANCE valid = EXACT_CHANCES + [Decimal(i) for i in range(1, 7)] return min(valid, key=lambda x: abs(x - value)) def _find_whole_placement( self, available: List[int], target: int ) -> Optional[List[int]]: """ Find combination of available rows that sum to target frequency. Prefers fewer rows (simpler card layout). """ # Single row solutions for row in available: if ROW_FREQUENCIES[row] == target: return [row] # Two row solutions for i, row1 in enumerate(available): for row2 in available[i+1:]: if ROW_FREQUENCIES[row1] + ROW_FREQUENCIES[row2] == target: return [row1, row2] # Three row solutions (common for 6 = 3+2+1) for i, row1 in enumerate(available): for j, row2 in enumerate(available[i+1:], i+1): for row3 in available[j+1:]: total = ROW_FREQUENCIES[row1] + ROW_FREQUENCIES[row2] + ROW_FREQUENCIES[row3] if total == target: return [row1, row2, row3] return None def _find_split_placement( self, column: CardColumn, target: Decimal, play: PlayResult ) -> Optional[Decimal]: """ Place a fractional value using d20 split on a single row. Returns actual chances placed, or None if not possible. A d20 split on a row with frequency F can produce: F * (d20_value / 20) chances For example, row 7 (freq=6) with d20 1-15: 6 * (15/20) = 4.5 chances """ for row in column.get_available_rows(): freq = ROW_FREQUENCIES[row] # What d20 split would give us target? # target = freq * (d20 / 20) # d20 = target * 20 / freq d20_needed = float(target) * 20 / freq if 1 <= d20_needed <= 19 and d20_needed == int(d20_needed): d20_int = int(d20_needed) actual = Decimal(freq * d20_int) / Decimal(20) # Create the split column.rows[row].result_one = play column.rows[row].d20_split = d20_int # Note: result_two would be a "secondary" play (typically an out) # For now, leaving it to be filled by the next pass column.num_splits += 1 return actual return None # ============================================================================= # USAGE EXAMPLES # ============================================================================= def example_batting_card(): """ How Python card-creation would use this module for a batting card. Demonstrates using different contracts for different card personalities. """ # Raw chances (same for all examples) raw_vl = RawBattingChances( walk=Decimal('8.5'), strikeout=Decimal('22.3'), homerun=Decimal('4.78'), triple=Decimal('1.2'), double_pull=Decimal('3.5'), single_pull=Decimal('12.1'), groundout_straight=Decimal('15.6'), flyout_straight=Decimal('18.2'), ) raw_vr = RawBattingChances( walk=Decimal('7.2'), strikeout=Decimal('24.1'), homerun=Decimal('3.95'), triple=Decimal('0.9'), double_pull=Decimal('2.8'), single_pull=Decimal('10.8'), groundout_straight=Decimal('17.3'), flyout_straight=Decimal('20.1'), ) # Build the SAME player with DIFFERENT contracts print("=" * 60) print("SAME STATS, DIFFERENT CONTRACTS") print("=" * 60) contracts_to_test = [ ('standard', StandardContract()), ('clutch', ClutchContract()), ('power_heavy', PowerHeavyContract()), ('contact_first', ContactFirstContract()), ] cards = {} for name, contract in contracts_to_test: builder = CardBuilder(contract=contract) card = builder.build_batting_card(raw_vl, raw_vr, batter_hand='R') cards[name] = card print(f"\n--- {contract.name} Contract ---") print(f"Description: {contract.description}") print("vL Column row assignments:") for row_num in [7, 6, 8, 2, 12]: # Show key rows slot = card.column_vl.rows[row_num] if slot.result_one: result = slot.result_one.short_name if slot.is_split: result += f" / {slot.result_two.short_name}" print(f" Row {row_num:2d} ({ROW_FREQUENCIES[row_num]} chances): {result}") return cards def example_contract_comparison(): """ Show how the same player looks different with different contracts. """ raw_vl = RawBattingChances( walk=Decimal('6'), homerun=Decimal('5'), single_pull=Decimal('6'), strikeout=Decimal('10'), flyout_straight=Decimal('9'), ) raw_vr = raw_vl # Same for simplicity print("\n" + "=" * 60) print("CONTRACT COMPARISON: Row 7 (6 chances - most likely)") print("=" * 60) for name, contract in CONTRACT_REGISTRY.items(): builder = CardBuilder(contract=contract) card = builder.build_batting_card(raw_vl, raw_vr, batter_hand='R') slot = card.column_vl.rows[7] result = slot.result_one.full_name if slot.result_one else "" print(f" {name:20s}: {result}") def example_pitching_card(): """ How Python card-creation would use this module for a pitching card. Key differences from batting: - X-checks redirect to batter's card at specific positions - Batter Power (bp_*) outcomes redirect to batter's card for power hitters - Secondary plays fill d20 split remainders intelligently - Strikeouts are bolded (good for pitcher), hits are not """ # 1. Calculate raw chances (existing logic) raw_vl = RawPitchingChances( # Batter power - redirects to batter card bp_homerun=Decimal('1.5'), bp_single=Decimal('2.0'), # Standard outcomes hbp=Decimal('1.2'), walk=Decimal('7.8'), strikeout=Decimal('24.5'), # Hits allowed homerun=Decimal('2.3'), triple=Decimal('0.8'), double_cf=Decimal('1.5'), double_two=Decimal('3.2'), double_three=Decimal('1.1'), single_one=Decimal('4.5'), single_two=Decimal('5.8'), single_center=Decimal('3.2'), # Outs flyout_lf=Decimal('8.5'), flyout_cf=Decimal('12.3'), flyout_rf=Decimal('7.8'), groundout_a=Decimal('10.2'), groundout_b=Decimal('8.5'), # X-checks - redirect to batter's card xcheck_p=Decimal('1.5'), xcheck_c=Decimal('0.8'), xcheck_1b=Decimal('2.3'), xcheck_2b=Decimal('3.5'), xcheck_3b=Decimal('2.1'), xcheck_ss=Decimal('3.2'), xcheck_lf=Decimal('1.8'), xcheck_cf=Decimal('2.5'), xcheck_rf=Decimal('1.5'), ) raw_vr = RawPitchingChances( bp_homerun=Decimal('2.0'), bp_single=Decimal('2.5'), hbp=Decimal('1.0'), walk=Decimal('6.5'), strikeout=Decimal('28.2'), homerun=Decimal('1.8'), triple=Decimal('0.5'), double_cf=Decimal('1.2'), double_two=Decimal('2.8'), double_three=Decimal('0.9'), single_one=Decimal('3.8'), single_two=Decimal('4.5'), single_center=Decimal('2.8'), flyout_lf=Decimal('9.2'), flyout_cf=Decimal('14.5'), flyout_rf=Decimal('8.5'), groundout_a=Decimal('11.5'), groundout_b=Decimal('9.2'), xcheck_p=Decimal('1.2'), xcheck_c=Decimal('0.6'), xcheck_1b=Decimal('2.0'), xcheck_2b=Decimal('3.0'), xcheck_3b=Decimal('1.8'), xcheck_ss=Decimal('2.8'), xcheck_lf=Decimal('1.5'), xcheck_cf=Decimal('2.2'), xcheck_rf=Decimal('1.2'), ) # 2. Build the card (fitting happens here) builder = CardBuilder() card = builder.build_pitching_card(raw_vl, raw_vr, pitcher_hand='R') # 3. Inspect what actually got placed print("\n=== PITCHING CARD ===") print("Fitted chances vL (with deltas):") for play, chance in card.fitted_chances_vl.items(): delta = card.deltas_vl.get(play, Decimal(0)) if delta != 0: print(f" {play}: {chance} (delta: {delta:+.2f})") # 4. Show a sample row with d20 split print("\nSample card structure (vL column):") for row_num in [6, 7, 8]: # Common split rows slot = card.column_vl.rows[row_num] if slot.result_one: if slot.is_split: print(f" Row {row_num}: {slot.result_one.short_name} (1-{slot.d20_split}) / " f"{slot.result_two.short_name} ({slot.d20_split+1}-20)") else: print(f" Row {row_num}: {slot.result_one.full_name}") return card def example_usage(): """Combined example showing contracts in action.""" # Show batting cards with different contracts batting_cards = example_batting_card() # Show contract comparison on row 7 example_contract_comparison() # Show pitching card pitching_card = example_pitching_card() # Return the standard batting card and pitching card for further inspection return batting_cards.get('standard'), pitching_card # ============================================================================= # MIGRATION PATH # ============================================================================= """ Migration strategy to adopt this architecture: PHASE 1: Extract & Validate - Extract current database fitting logic to this shared module - Run both old and new in parallel, compare outputs - Fix any discrepancies PHASE 2: Python Adoption - Modify Python card-creation to use CardBuilder - POST fitted card structures instead of raw chances - Keep raw chances in separate field for debugging/analysis PHASE 3: Database Simplification - Database receives pre-fitted card structures - Remove fitting logic from database (just store and render) - Remove step 7 (no more re-saving modified chances) PHASE 4: API Enhancement - Add preview endpoint that returns card structure without saving - Enable local card preview in Python scripts Benefits achieved: - Deterministic: Python knows exactly what card will look like - Testable: Can validate cards without database - Single source of truth: Fitting logic in one place - Cleaner separation: Database is just storage + rendering """ if __name__ == '__main__': import json batting_card, pitching_card = example_usage() print("\n" + "="*60) print("BATTING CARD JSON STRUCTURE (abbreviated)") print("="*60) batting_dict = batting_card.to_dict() print(f"Keys: {list(batting_dict.keys())}") print(f"Fitted vL plays: {len(batting_dict['fitted_chances_vl'])}") print(f"Deltas with non-zero: {sum(1 for d in batting_dict['deltas_vl'].values() if d != 0)}") print("\n" + "="*60) print("PITCHING CARD JSON STRUCTURE (abbreviated)") print("="*60) pitching_dict = pitching_card.to_dict() print(f"Keys: {list(pitching_dict.keys())}") print(f"Fitted vL plays: {len(pitching_dict['fitted_chances_vl'])}") print(f"Deltas with non-zero: {sum(1 for d in pitching_dict['deltas_vl'].values() if d != 0)}") # Show one full column structure print("\n" + "="*60) print("SAMPLE: Pitching card vL column structure") print("="*60) for row_num in range(2, 13): slot = pitching_card.column_vl.rows[row_num] if slot.result_one: if slot.is_split: print(f" {row_num:2d}: {slot.result_one.short_name:12s} (1-{slot.d20_split:2d}) | " f"{slot.result_two.short_name:12s} ({slot.d20_split+1:2d}-20)") else: print(f" {row_num:2d}: {slot.result_one.full_name}") else: print(f" {row_num:2d}: ")