""" Card Building Contracts - Pluggable Placement Strategies A "contract" defines the rules for how plays are placed on a card: - Which rows to prefer for different play types - Priority ordering of plays - Secondary play pairing rules - Split vs whole number preferences - etc. This enables different card "personalities" while using the same fitting engine. """ from abc import ABC, abstractmethod from dataclasses import dataclass, field from enum import Enum from typing import List, Dict, Optional # ============================================================================= # ROW PREFERENCE 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 SYMMETRIC_OUT = ( "symmetric_out" # 7, 6, 8, 5, 9, 4, 10, 3, 11, 2, 12 (same as middle) ) SYMMETRIC_IN = "symmetric_in" # 2, 12, 3, 11, 4, 10, 5, 9, 6, 8, 7 (same as edges) BY_FREQUENCY_DESC = ( "freq_desc" # 7, 6, 8, 5, 9, 4, 10, 3, 11, 2, 12 (by chance count) ) BY_FREQUENCY_ASC = "freq_asc" # 2, 12, 3, 11, 4, 10, 5, 9, 6, 8, 7 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.SYMMETRIC_OUT: [7, 6, 8, 5, 9, 4, 10, 3, 11, 2, 12], RowPreference.SYMMETRIC_IN: [2, 12, 3, 11, 4, 10, 5, 9, 6, 8, 7], 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], } # ============================================================================= # PLAY CATEGORY DEFINITIONS # ============================================================================= 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 # ============================================================================= # THE CONTRACT INTERFACE # ============================================================================= @dataclass class PlacementRule: """A single rule for how to place a category of plays.""" category: PlayCategory row_preference: RowPreference priority: int = 50 # Lower = placed earlier (0-100) prefer_splits: bool = True # Use d20 splits when possible? max_per_column: Optional[int] = None # Limit instances per column secondary_pool: List[str] = field( default_factory=list ) # What can fill split remainder class CardContract(ABC): """ Abstract base class for card building contracts. A contract defines the "personality" of a card - how plays are distributed across the 2d6 probability space. """ @property @abstractmethod def name(self) -> str: """Human-readable contract name.""" pass @property @abstractmethod def description(self) -> str: """Describe what this contract does.""" pass @abstractmethod def get_row_preference(self, category: PlayCategory) -> List[int]: """ Return ordered list of preferred rows for this play category. First row in list is most preferred. """ pass @abstractmethod def get_play_priority(self) -> List[PlayCategory]: """ Return categories in priority order (placed first to last). """ pass def get_secondary_pool(self, category: PlayCategory) -> List[PlayCategory]: """ Return categories that can fill d20 split remainders for this category. Default: flyouts and groundouts. """ return [PlayCategory.FLYOUT, PlayCategory.GROUNDOUT] def should_use_splits(self, category: PlayCategory) -> bool: """Whether this category should use d20 splits. Default: True.""" return True def max_splits_per_column(self) -> int: """Maximum d20 splits allowed per column. Default: 4.""" return 4 def validate(self, card_data: dict) -> List[str]: """ Validate a built card against this contract's rules. Returns list of violations (empty if valid). """ return [] # ============================================================================= # CONCRETE CONTRACT IMPLEMENTATIONS # ============================================================================= class StandardContract(CardContract): """ The default contract - matches current database behavior. - On-base results favor middle rows (more likely to occur) - Outs fill remaining rows - Standard priority ordering """ @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, ]: # Good outcomes on likely rows return ROW_ORDERINGS[RowPreference.MIDDLE_FIRST] else: # Outs on whatever's left (edges tend to be available) return ROW_ORDERINGS[RowPreference.EDGES_FIRST] def get_play_priority(self) -> List[PlayCategory]: return [ PlayCategory.BATTER_POWER, # bp_hr, bp_single first PlayCategory.PATIENCE, # Walks, HBP PlayCategory.XCHECK, # X-checks PlayCategory.POWER, # HR, 3B, 2B PlayCategory.CONTACT, # Singles PlayCategory.STRIKEOUT, PlayCategory.GROUNDOUT, PlayCategory.FLYOUT, ] class ClutchContract(CardContract): """ "Clutch" card personality - on-base results on EDGE rows. This makes hits less likely overall, but when they happen, the player "came through in the clutch" (rare but impactful). Good for: Role players, clutch hitters, postseason heroes """ @property def name(self) -> str: return "Clutch" @property def description(self) -> str: return "On-base results on edge rows (2-4, 10-12) - less frequent but memorable" def get_row_preference(self, category: PlayCategory) -> List[int]: if category in [ PlayCategory.ON_BASE, PlayCategory.POWER, PlayCategory.CONTACT, PlayCategory.PATIENCE, ]: # Good outcomes on unlikely rows return ROW_ORDERINGS[RowPreference.EDGES_FIRST] else: # Outs on middle rows (more common) return ROW_ORDERINGS[RowPreference.MIDDLE_FIRST] def get_play_priority(self) -> List[PlayCategory]: # Same priority, different placement return StandardContract().get_play_priority() class ConsistentContract(CardContract): """ "Consistent" card personality - spread results evenly. Distributes on-base results across all rows proportionally, making the player more predictable/reliable. Good for: Contact hitters, batting average leaders """ @property def name(self) -> str: return "Consistent" @property def description(self) -> str: return "Spread results evenly across all rows - predictable performance" def get_row_preference(self, category: PlayCategory) -> List[int]: # Alternate between middle and edges to spread out return [7, 2, 8, 12, 6, 3, 9, 11, 5, 4, 10] def get_play_priority(self) -> List[PlayCategory]: return StandardContract().get_play_priority() class PowerHeavyContract(CardContract): """ Power-focused card - extra-base hits get prime real estate. HR and triples on row 7 (most likely), doubles on 6/8, singles pushed to edges. Good for: Power hitters, HR leaders """ @property def name(self) -> str: return "Power Heavy" @property def description(self) -> str: return "Power hits (HR, 3B, 2B) on prime rows, singles on edges" def get_row_preference(self, category: PlayCategory) -> List[int]: if category == PlayCategory.POWER: # Power on the most likely rows return [7, 6, 8, 5, 9] elif category == PlayCategory.CONTACT: # Singles pushed to edges return ROW_ORDERINGS[RowPreference.EDGES_FIRST] elif category == PlayCategory.PATIENCE: # Walks in the middle-ish return [5, 9, 4, 10, 6, 8] else: return ROW_ORDERINGS[RowPreference.EDGES_FIRST] def get_play_priority(self) -> List[PlayCategory]: # Power comes before patience return [ PlayCategory.BATTER_POWER, PlayCategory.POWER, # HR, 3B, 2B FIRST PlayCategory.PATIENCE, # Then walks PlayCategory.XCHECK, PlayCategory.CONTACT, PlayCategory.STRIKEOUT, PlayCategory.GROUNDOUT, PlayCategory.FLYOUT, ] class ContactFirstContract(CardContract): """ Contact-focused card - singles get prime placement. Singles on middle rows, power hits on edges. More base hits, but less extra-base damage. Good for: Contact hitters, leadoff types, high-average players """ @property def name(self) -> str: return "Contact First" @property def description(self) -> str: return "Singles on prime rows, power on edges - consistent base hits" 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] else: return ROW_ORDERINGS[RowPreference.EDGES_FIRST] def get_play_priority(self) -> List[PlayCategory]: return [ PlayCategory.BATTER_POWER, PlayCategory.CONTACT, # Singles FIRST PlayCategory.PATIENCE, PlayCategory.POWER, PlayCategory.XCHECK, PlayCategory.STRIKEOUT, PlayCategory.GROUNDOUT, PlayCategory.FLYOUT, ] class GroundballPitcherContract(CardContract): """ Groundball pitcher personality. Groundouts on middle rows, flyouts on edges. X-checks to corner infielders get priority. Good for: Sinkerballers, ground ball specialists """ @property def name(self) -> str: return "Groundball Pitcher" @property def description(self) -> str: return "Groundouts on likely rows, X-checks favor corner infielders" 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] # Mid-edges else: return ROW_ORDERINGS[RowPreference.EDGES_FIRST] def get_play_priority(self) -> List[PlayCategory]: return [ PlayCategory.BATTER_POWER, PlayCategory.PATIENCE, # Walks/HBP PlayCategory.XCHECK, # Corner IF X-checks PlayCategory.GROUNDOUT, # Groundouts prioritized PlayCategory.STRIKEOUT, PlayCategory.POWER, PlayCategory.CONTACT, PlayCategory.FLYOUT, ] class FlyballPitcherContract(CardContract): """ Flyball pitcher personality. Flyouts and strikeouts on middle rows. X-checks to outfielders get priority. Good for: Power pitchers, high-K guys """ @property def name(self) -> str: return "Flyball Pitcher" @property def description(self) -> str: return "Flyouts and strikeouts on likely rows, X-checks favor outfielders" 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, # K's prioritized PlayCategory.FLYOUT, # Flyouts prioritized PlayCategory.XCHECK, PlayCategory.POWER, PlayCategory.CONTACT, PlayCategory.GROUNDOUT, ] # ============================================================================= # CUSTOM CONTRACT BUILDER # ============================================================================= @dataclass class CustomContract(CardContract): """ Build a custom contract from individual rules. Example: contract = CustomContract( name="My Custom Card", description="Power on edges, walks in middle", rules={ PlayCategory.POWER: PlacementRule( category=PlayCategory.POWER, row_preference=RowPreference.EDGES_FIRST, priority=10 ), PlayCategory.PATIENCE: PlacementRule( category=PlayCategory.PATIENCE, row_preference=RowPreference.MIDDLE_FIRST, priority=5 ), } ) """ _name: str _description: str rules: Dict[PlayCategory, PlacementRule] = field(default_factory=dict) default_row_preference: RowPreference = RowPreference.MIDDLE_FIRST @property def name(self) -> str: return self._name @property def description(self) -> str: return self._description def get_row_preference(self, category: PlayCategory) -> List[int]: if category in self.rules: return ROW_ORDERINGS[self.rules[category].row_preference] return ROW_ORDERINGS[self.default_row_preference] def get_play_priority(self) -> List[PlayCategory]: # Sort by priority value (lower = first) sorted_rules = sorted(self.rules.items(), key=lambda x: x[1].priority) return [cat for cat, _ in sorted_rules] def get_secondary_pool(self, category: PlayCategory) -> List[PlayCategory]: if category in self.rules and self.rules[category].secondary_pool: # Convert strings to PlayCategory if needed return [ PlayCategory(s) if isinstance(s, str) else s for s in self.rules[category].secondary_pool ] return super().get_secondary_pool(category) def should_use_splits(self, category: PlayCategory) -> bool: if category in self.rules: return self.rules[category].prefer_splits return True # ============================================================================= # CONTRACT REGISTRY # ============================================================================= class ContractRegistry: """Registry of available contracts.""" _contracts: Dict[str, CardContract] = {} @classmethod def register(cls, contract: CardContract): """Register a contract by name.""" cls._contracts[contract.name.lower()] = contract @classmethod def get(cls, name: str) -> Optional[CardContract]: """Get a contract by name (case-insensitive).""" return cls._contracts.get(name.lower()) @classmethod def list_all(cls) -> List[str]: """List all registered contract names.""" return list(cls._contracts.keys()) @classmethod def get_default(cls) -> CardContract: """Get the default contract.""" return StandardContract() # Register built-in contracts ContractRegistry.register(StandardContract()) ContractRegistry.register(ClutchContract()) ContractRegistry.register(ConsistentContract()) ContractRegistry.register(PowerHeavyContract()) ContractRegistry.register(ContactFirstContract()) ContractRegistry.register(GroundballPitcherContract()) ContractRegistry.register(FlyballPitcherContract()) # ============================================================================= # USAGE EXAMPLE # ============================================================================= def example_contract_usage(): """ How contracts would integrate with the CardBuilder. """ # Get a contract standard = ContractRegistry.get("standard") clutch = ContractRegistry.get("clutch") print("Available contracts:") for name in ContractRegistry.list_all(): contract = ContractRegistry.get(name) print(f" - {contract.name}: {contract.description}") print("\nRow preferences for ON_BASE category:") print(f" Standard: {standard.get_row_preference(PlayCategory.ON_BASE)[:5]}...") print(f" Clutch: {clutch.get_row_preference(PlayCategory.ON_BASE)[:5]}...") # Build a custom contract custom = CustomContract( _name="My Custom", _description="Walks on edges, power in middle", rules={ PlayCategory.PATIENCE: PlacementRule( category=PlayCategory.PATIENCE, row_preference=RowPreference.EDGES_FIRST, priority=1, ), PlayCategory.POWER: PlacementRule( category=PlayCategory.POWER, row_preference=RowPreference.MIDDLE_FIRST, priority=2, ), }, ) print( f"\nCustom contract rows for PATIENCE: {custom.get_row_preference(PlayCategory.PATIENCE)[:5]}..." ) print( f"Custom contract rows for POWER: {custom.get_row_preference(PlayCategory.POWER)[:5]}..." ) # ============================================================================= # INTEGRATION WITH CARD BUILDER # ============================================================================= """ To integrate contracts with CardBuilder, modify the builder to accept a contract: class CardBuilder: def __init__(self, contract: CardContract = None): self.contract = contract or StandardContract() self.max_splits = self.contract.max_splits_per_column() def _get_available_rows(self, column, category: PlayCategory) -> List[int]: # Instead of returning rows in default order, # use the contract's preference for this category preferred_order = self.contract.get_row_preference(category) return [r for r in preferred_order if column.rows[r].is_empty] def build_batting_card(self, ...): # Use contract's priority order for category in self.contract.get_play_priority(): plays = self._get_plays_for_category(category, chances) for play, raw_chance in plays: # Use contract's row preference when placing available = self._get_available_rows(column, category) ... This makes the fitting algorithm contract-aware without changing its core logic. """ if __name__ == "__main__": example_contract_usage()